|
|
|
|
@@ -1,12 +1,11 @@
|
|
|
|
|
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
|
|
|
|
|
import type { Event as NostrEvent } from "nostr-tools";
|
|
|
|
|
import { nip19 } from "nostr-tools";
|
|
|
|
|
import { SimplePool } from "nostr-tools/pool";
|
|
|
|
|
import { fetchChatPubkeys } from "./api";
|
|
|
|
|
import { getActiveSigner, hasActiveSigner } from "./signer";
|
|
|
|
|
import { fetchChat, postChatMessage, postChatReaction } from "./api";
|
|
|
|
|
import { hasActiveSigner } from "./signer";
|
|
|
|
|
|
|
|
|
|
const RELAYS = ["wss://relay.primal.net"];
|
|
|
|
|
const PRIVATE_CHAT_KIND = 4;
|
|
|
|
|
const CHAT_ROOM = "gashboard-chat";
|
|
|
|
|
const POLL_MS = 2500;
|
|
|
|
|
const pool = new SimplePool();
|
|
|
|
|
|
|
|
|
|
export type ChatMessage = {
|
|
|
|
|
@@ -32,28 +31,7 @@ export type ChatProfile = {
|
|
|
|
|
picture: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PrivatePayload =
|
|
|
|
|
| {
|
|
|
|
|
room: typeof CHAT_ROOM;
|
|
|
|
|
v: 1;
|
|
|
|
|
type: "message";
|
|
|
|
|
id: string;
|
|
|
|
|
content: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
replyToId?: string;
|
|
|
|
|
replyToPubkey?: string;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
room: typeof CHAT_ROOM;
|
|
|
|
|
v: 1;
|
|
|
|
|
type: "reaction";
|
|
|
|
|
id: string;
|
|
|
|
|
messageId: string;
|
|
|
|
|
content: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let cachedRecipients: string[] | null = null;
|
|
|
|
|
let reactionCache: ChatReaction[] = [];
|
|
|
|
|
|
|
|
|
|
export function canSendChat(): boolean {
|
|
|
|
|
return hasActiveSigner();
|
|
|
|
|
@@ -70,82 +48,65 @@ export function pubkeyFromNpub(npub: string | null): string | null {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadChat(): Promise<ChatMessage[]> {
|
|
|
|
|
const { messages } = await loadPrivateChat();
|
|
|
|
|
return dedupe(messages).sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
|
const snapshot = await fetchChat();
|
|
|
|
|
reactionCache = snapshot.reactions;
|
|
|
|
|
return snapshot.messages.sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
|
|
|
|
|
const wanted = new Set(messageIds);
|
|
|
|
|
const { reactions } = await loadPrivateChat();
|
|
|
|
|
return dedupeReactions(reactions.filter((r) => wanted.has(r.messageId)));
|
|
|
|
|
if (!reactionCache.length) reactionCache = (await fetchChat()).reactions;
|
|
|
|
|
return reactionCache.filter((r) => wanted.has(r.messageId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function subscribeChat(
|
|
|
|
|
onMessage: (message: ChatMessage) => void,
|
|
|
|
|
onReaction?: (reaction: ChatReaction) => void,
|
|
|
|
|
): { close: () => void } {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
const seenMessages = new Set<string>();
|
|
|
|
|
const seenReactions = new Set<string>();
|
|
|
|
|
let closed = false;
|
|
|
|
|
let sub: { close: () => void } | null = null;
|
|
|
|
|
if (!signer) return { close: () => {} };
|
|
|
|
|
|
|
|
|
|
void signer.getPublicKey().then((ownPubkey) => {
|
|
|
|
|
async function poll(): Promise<void> {
|
|
|
|
|
if (closed) return;
|
|
|
|
|
const since = Math.floor(Date.now() / 1000) - 60;
|
|
|
|
|
sub = pool.subscribeMany(RELAYS, [{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], since }], {
|
|
|
|
|
onevent(event) {
|
|
|
|
|
void decryptEvent(event).then((payload) => {
|
|
|
|
|
if (!payload || closed) return;
|
|
|
|
|
if (payload.type === "reaction") onReaction?.(asReaction(payload, event.pubkey));
|
|
|
|
|
else onMessage(asMessage(payload, event.pubkey));
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const snapshot = await fetchChat();
|
|
|
|
|
for (const message of snapshot.messages) {
|
|
|
|
|
if (seenMessages.has(message.id)) continue;
|
|
|
|
|
seenMessages.add(message.id);
|
|
|
|
|
onMessage(message);
|
|
|
|
|
}
|
|
|
|
|
for (const reaction of snapshot.reactions) {
|
|
|
|
|
if (seenReactions.has(reaction.id)) continue;
|
|
|
|
|
seenReactions.add(reaction.id);
|
|
|
|
|
onReaction?.(reaction);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timer = window.setInterval(() => {
|
|
|
|
|
void poll().catch(() => {});
|
|
|
|
|
}, POLL_MS);
|
|
|
|
|
void poll().catch(() => {});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
close() {
|
|
|
|
|
closed = true;
|
|
|
|
|
sub?.close();
|
|
|
|
|
window.clearInterval(timer);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendChat(
|
|
|
|
|
export function sendChat(
|
|
|
|
|
content: string,
|
|
|
|
|
replyTo?: { id: string; pubkey: string },
|
|
|
|
|
): Promise<ChatMessage> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
if (!signer) throw new Error("Private chat needs the active signer. Reconnect your signer to send.");
|
|
|
|
|
const pubkey = await signer.getPublicKey();
|
|
|
|
|
const payload: PrivatePayload = {
|
|
|
|
|
room: CHAT_ROOM,
|
|
|
|
|
v: 1,
|
|
|
|
|
type: "message",
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
return postChatMessage({
|
|
|
|
|
content,
|
|
|
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
|
|
|
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
|
|
|
|
|
};
|
|
|
|
|
await publishPrivatePayload(payload);
|
|
|
|
|
return asMessage(payload, pubkey);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
if (!signer) throw new Error("Private reactions need the active signer. Reconnect your signer to react.");
|
|
|
|
|
const pubkey = await signer.getPublicKey();
|
|
|
|
|
const payload: PrivatePayload = {
|
|
|
|
|
room: CHAT_ROOM,
|
|
|
|
|
v: 1,
|
|
|
|
|
type: "reaction",
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
messageId: message.id,
|
|
|
|
|
content,
|
|
|
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
|
|
|
};
|
|
|
|
|
await publishPrivatePayload(payload);
|
|
|
|
|
return asReaction(payload, pubkey);
|
|
|
|
|
export function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
|
|
|
|
|
return postChatReaction({ messageId: message.id, content });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
|
|
|
|
|
@@ -176,100 +137,3 @@ export async function loadProfiles(pubkeys: string[]): Promise<Record<string, Ch
|
|
|
|
|
export function shortPubkey(pubkey: string): string {
|
|
|
|
|
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadPrivateChat(): Promise<{ messages: ChatMessage[]; reactions: ChatReaction[] }> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
if (!signer) return { messages: [], reactions: [] };
|
|
|
|
|
const ownPubkey = await signer.getPublicKey();
|
|
|
|
|
const events = await pool.querySync(
|
|
|
|
|
RELAYS,
|
|
|
|
|
{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], limit: 500 },
|
|
|
|
|
{ maxWait: 3500 },
|
|
|
|
|
);
|
|
|
|
|
const payloads = await Promise.all(events.map((event) => decryptEvent(event)));
|
|
|
|
|
const messages: ChatMessage[] = [];
|
|
|
|
|
const reactions: ChatReaction[] = [];
|
|
|
|
|
for (let i = 0; i < events.length; i += 1) {
|
|
|
|
|
const payload = payloads[i];
|
|
|
|
|
const event = events[i];
|
|
|
|
|
if (!payload || !event) continue;
|
|
|
|
|
if (payload.type === "reaction") reactions.push(asReaction(payload, event.pubkey));
|
|
|
|
|
else messages.push(asMessage(payload, event.pubkey));
|
|
|
|
|
}
|
|
|
|
|
return { messages, reactions };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function publishPrivatePayload(payload: PrivatePayload): Promise<void> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
if (!signer) throw new Error("Private chat needs the active signer.");
|
|
|
|
|
const recipients = await getRecipients();
|
|
|
|
|
const plaintext = JSON.stringify(payload);
|
|
|
|
|
const signedEvents = await Promise.all(
|
|
|
|
|
recipients.map(async (recipient) => {
|
|
|
|
|
const encrypted = await signer.encrypt(recipient, plaintext);
|
|
|
|
|
const template: EventTemplate = {
|
|
|
|
|
kind: PRIVATE_CHAT_KIND,
|
|
|
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
|
|
|
tags: [
|
|
|
|
|
["p", recipient],
|
|
|
|
|
["client", "gashboard"],
|
|
|
|
|
],
|
|
|
|
|
content: encrypted,
|
|
|
|
|
};
|
|
|
|
|
return signer.signEvent(template);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const results = await Promise.allSettled(signedEvents.flatMap((event) => pool.publish(RELAYS, event)));
|
|
|
|
|
if (!results.some((r) => r.status === "fulfilled")) {
|
|
|
|
|
throw new Error("Could not publish private chat event to relay");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getRecipients(): Promise<string[]> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
const own = await signer?.getPublicKey();
|
|
|
|
|
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
|
|
|
|
|
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptEvent(event: NostrEvent): Promise<PrivatePayload | null> {
|
|
|
|
|
const signer = getActiveSigner();
|
|
|
|
|
if (!signer) return null;
|
|
|
|
|
try {
|
|
|
|
|
const plaintext = await signer.decrypt(event.pubkey, event.content);
|
|
|
|
|
const payload = JSON.parse(plaintext) as PrivatePayload;
|
|
|
|
|
if (payload.room !== CHAT_ROOM || payload.v !== 1) return null;
|
|
|
|
|
return payload;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asMessage(payload: Extract<PrivatePayload, { type: "message" }>, pubkey: string): ChatMessage {
|
|
|
|
|
return {
|
|
|
|
|
id: payload.id,
|
|
|
|
|
pubkey,
|
|
|
|
|
content: payload.content,
|
|
|
|
|
createdAt: payload.createdAt,
|
|
|
|
|
replyToId: payload.replyToId ?? "",
|
|
|
|
|
replyToPubkey: payload.replyToPubkey ?? "",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asReaction(payload: Extract<PrivatePayload, { type: "reaction" }>, pubkey: string): ChatReaction {
|
|
|
|
|
return {
|
|
|
|
|
id: payload.id,
|
|
|
|
|
pubkey,
|
|
|
|
|
messageId: payload.messageId,
|
|
|
|
|
content: payload.content || "+",
|
|
|
|
|
createdAt: payload.createdAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dedupe(messages: ChatMessage[]): ChatMessage[] {
|
|
|
|
|
return [...new Map(messages.map((m) => [m.id, m])).values()];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dedupeReactions(reactions: ChatReaction[]): ChatReaction[] {
|
|
|
|
|
return [...new Map(reactions.map((r) => [r.id, r])).values()];
|
|
|
|
|
}
|
|
|
|
|
|