feat(desktop): connect chat to relay (#6)

This commit is contained in:
Wes
2026-03-09 14:49:50 -07:00
committed by GitHub
parent 70ccc6ea59
commit 0705327312
20 changed files with 2250 additions and 86 deletions
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { getIdentity } from "@/shared/api/tauri";
export function useIdentityQuery() {
return useQuery({
queryKey: ["identity"],
queryFn: getIdentity,
staleTime: Number.POSITIVE_INFINITY,
});
}
+450
View File
@@ -0,0 +1,450 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import {
createAuthEvent,
getRelayWsUrl,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
type RelaySubscriptionFilter = {
kinds: number[];
"#e": string[];
limit: number;
};
type HistorySubscription = {
mode: "history";
events: RelayEvent[];
resolve: (events: RelayEvent[]) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
};
type LiveSubscription = {
mode: "live";
onEvent: (event: RelayEvent) => void;
};
type PendingEvent = {
event: RelayEvent;
resolve: (event: RelayEvent) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
};
type RelaySubscription = HistorySubscription | LiveSubscription;
function sortEvents(events: RelayEvent[]) {
return [...events].sort((left, right) => left.created_at - right.created_at);
}
function getTextPayload(message: unknown) {
if (typeof message === "string") {
return message;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Text" &&
"data" in message &&
typeof message.data === "string"
) {
return message.data;
}
if (
typeof message === "object" &&
message !== null &&
"Text" in message &&
typeof message.Text === "string"
) {
return message.Text;
}
return null;
}
class RelayClient {
private wsId: number | null = null;
private relayUrl: string | null = null;
private connectPromise: Promise<void> | null = null;
private authRequest: {
pendingEventId: string;
resolve: () => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
private activeLiveSubscriptionId: string | null = null;
async fetchChannelHistory(channelId: string, limit = 50) {
await this.ensureConnected();
return new Promise<RelayEvent[]>((resolve, reject) => {
const subId = `history-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
reject(new Error("Timed out while loading channel history."));
}, 8_000);
this.subscriptions.set(subId, {
mode: "history",
events: [],
resolve,
reject,
timeout,
});
void this.sendRaw([
"REQ",
subId,
this.buildChannelFilter(channelId, limit),
]).catch((error) => {
window.clearTimeout(timeout);
this.subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request channel history."),
);
});
});
}
async sendMessage(channelId: string, content: string) {
await this.ensureConnected();
const event = await signRelayEvent({
kind: 40001,
content: content.trim(),
tags: [["e", channelId]],
});
return new Promise<RelayEvent>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingEvents.delete(event.id);
reject(new Error("Timed out while sending the message."));
}, 8_000);
this.pendingEvents.set(event.id, {
event,
resolve,
reject,
timeout,
});
void this.sendRaw(["EVENT", event]).catch((error) => {
window.clearTimeout(timeout);
this.pendingEvents.delete(event.id);
reject(
error instanceof Error
? error
: new Error("Failed to send the message."),
);
});
});
}
async subscribeToChannel(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
await this.ensureConnected();
const subId = `live-${crypto.randomUUID()}`;
const previousSubscriptionId = this.activeLiveSubscriptionId;
this.activeLiveSubscriptionId = subId;
if (previousSubscriptionId) {
this.subscriptions.delete(previousSubscriptionId);
await this.closeSubscription(previousSubscriptionId);
}
this.subscriptions.set(subId, {
mode: "live",
onEvent,
});
await this.sendRaw(["REQ", subId, this.buildChannelFilter(channelId, 50)]);
return async () => {
const active = this.subscriptions.get(subId);
if (!active || active.mode !== "live") {
return;
}
this.subscriptions.delete(subId);
if (this.activeLiveSubscriptionId === subId) {
this.activeLiveSubscriptionId = null;
}
await this.closeSubscription(subId);
};
}
private async ensureConnected() {
if (this.connectPromise) {
return this.connectPromise;
}
if (this.wsId !== null) {
return;
}
this.connectPromise = this.connect();
try {
await this.connectPromise;
} finally {
this.connectPromise = null;
}
}
private async connect() {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const onMessageChannel = new Channel<unknown>((message) => {
void this.handleWsMessage(message);
});
this.wsId = await invoke<number>("plugin:websocket|connect", {
url: this.relayUrl,
onMessage: onMessageChannel,
config: {},
});
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.authRequest = null;
this.resetConnection(
new Error("Timed out while waiting for relay authentication."),
);
reject(new Error("Timed out while waiting for relay authentication."));
}, 8_000);
this.authRequest = {
pendingEventId: "",
resolve,
reject,
timeout,
};
});
}
private buildChannelFilter(
channelId: string,
limit: number,
): RelaySubscriptionFilter {
return {
kinds: [40001],
"#e": [channelId],
limit,
};
}
private async sendRaw(payload: unknown[]) {
if (this.wsId === null) {
throw new Error("Relay socket is not connected.");
}
await invoke("plugin:websocket|send", {
id: this.wsId,
message: {
type: "Text",
data: JSON.stringify(payload),
},
});
}
private async closeSubscription(subId: string) {
if (this.wsId === null) {
return;
}
await this.sendRaw(["CLOSE", subId]);
}
private async handleWsMessage(message: unknown) {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Close"
) {
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
const payload = getTextPayload(message);
if (!payload) {
return;
}
let data: unknown;
try {
data = JSON.parse(payload);
} catch {
return;
}
if (!Array.isArray(data) || data.length === 0) {
return;
}
const [type, ...rest] = data;
if (type === "AUTH" && typeof rest[0] === "string") {
await this.handleAuthChallenge(rest[0]);
return;
}
if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
this.handleEvent(rest[0], rest[1] as RelayEvent);
return;
}
if (
type === "OK" &&
typeof rest[0] === "string" &&
typeof rest[1] === "boolean"
) {
this.handleOk(
rest[0],
rest[1],
typeof rest[2] === "string" ? rest[2] : "",
);
return;
}
if (type === "EOSE" && typeof rest[0] === "string") {
this.handleEose(rest[0]);
}
}
private async handleAuthChallenge(challenge: string) {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const event = await createAuthEvent({
challenge,
relayUrl: this.relayUrl,
});
if (!this.authRequest) {
return;
}
this.authRequest.pendingEventId = event.id;
await this.sendRaw(["AUTH", event]);
}
private handleEvent(subId: string, event: RelayEvent) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "history") {
subscription.events.push(event);
return;
}
subscription.onEvent(event);
}
private handleEose(subId: string) {
const subscription = this.subscriptions.get(subId);
if (!subscription || subscription.mode !== "history") {
return;
}
window.clearTimeout(subscription.timeout);
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
}
private handleOk(eventId: string, success: boolean, message: string) {
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
window.clearTimeout(this.authRequest.timeout);
const authRequest = this.authRequest;
this.authRequest = null;
if (success) {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error);
}
return;
}
const pendingEvent = this.pendingEvents.get(eventId);
if (!pendingEvent) {
return;
}
window.clearTimeout(pendingEvent.timeout);
this.pendingEvents.delete(eventId);
if (success) {
pendingEvent.resolve(pendingEvent.event);
} else {
pendingEvent.reject(new Error(message || "Relay rejected the event."));
}
}
private resetConnection(error: Error) {
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {
return;
},
);
}
this.wsId = null;
if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
this.authRequest.reject(error);
this.authRequest = null;
}
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode === "history") {
window.clearTimeout(subscription.timeout);
subscription.reject(error);
}
this.subscriptions.delete(subId);
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
window.clearTimeout(pendingEvent.timeout);
pendingEvent.reject(error);
this.pendingEvents.delete(eventId);
}
this.activeLiveSubscriptionId = null;
}
}
export const relayClient = new RelayClient();
+65
View File
@@ -0,0 +1,65 @@
import { invoke } from "@tauri-apps/api/core";
import type {
Channel,
ChannelType,
Identity,
RelayEvent,
} from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
};
type RawChannel = {
id: string;
name: string;
channel_type: ChannelType;
description: string;
participants: string[];
participant_pubkeys: string[];
};
export async function getIdentity(): Promise<Identity> {
const identity = await invoke<RawIdentity>("get_identity");
return {
pubkey: identity.pubkey,
displayName: identity.display_name,
};
}
export function getRelayWsUrl(): Promise<string> {
return invoke<string>("get_relay_ws_url");
}
export async function getChannels(): Promise<Channel[]> {
const channels = await invoke<RawChannel[]>("get_channels");
return channels.map((channel) => ({
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
description: channel.description,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
}));
}
export async function signRelayEvent(input: {
kind: number;
content: string;
tags: string[][];
}): Promise<RelayEvent> {
const eventJson = await invoke<string>("sign_event", input);
return JSON.parse(eventJson) as RelayEvent;
}
export async function createAuthEvent(input: {
challenge: string;
relayUrl: string;
}): Promise<RelayEvent> {
const eventJson = await invoke<string>("create_auth_event", input);
return JSON.parse(eventJson) as RelayEvent;
}
+26
View File
@@ -0,0 +1,26 @@
export type ChannelType = "stream" | "forum" | "dm";
export type Channel = {
id: string;
name: string;
channelType: ChannelType;
description: string;
participants: string[];
participantPubkeys: string[];
};
export type Identity = {
pubkey: string;
displayName: string;
};
export type RelayEvent = {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
pending?: boolean;
};