151 lines
4.3 KiB
TypeScript
151 lines
4.3 KiB
TypeScript
import { SimplePool } from "@nostr/tools";
|
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
|
import { threads as mockThreads } from "$lib/mock";
|
|
import { auth } from "$lib/auth.svelte";
|
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
|
|
|
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
|
|
|
export type PostData = {
|
|
id: string;
|
|
pubkey: string;
|
|
createdAt: number;
|
|
content: string;
|
|
};
|
|
|
|
type ThreadDetail = {
|
|
id: string;
|
|
title: string;
|
|
labels: string[];
|
|
op: PostData;
|
|
replies: PostData[];
|
|
};
|
|
|
|
let detail = $state<ThreadDetail | null>(null);
|
|
let profiles = $state<Record<string, NostrUser>>({});
|
|
|
|
export const threadDetailStore = {
|
|
get detail() { return detail; },
|
|
get profiles() { return profiles; },
|
|
};
|
|
|
|
async function loadProfile(pubkey: string) {
|
|
if (profiles[pubkey]) return;
|
|
const user = await loadNostrUser(pubkey);
|
|
profiles[pubkey] = user;
|
|
ingestNostrUser(user);
|
|
}
|
|
|
|
function loadMockThread(id: string) {
|
|
const t = mockThreads.find((t) => t.id === id);
|
|
if (!t) return;
|
|
const toUnix = (iso: string) => Math.floor(new Date(iso).getTime() / 1000);
|
|
detail = {
|
|
id: t.id,
|
|
title: t.title,
|
|
labels: t.tags.map((tag) => tag.label),
|
|
op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content },
|
|
replies: (t.op.replies ?? []).map((r) => ({
|
|
id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content,
|
|
})),
|
|
};
|
|
const allAuthors = [t.op.author, ...(t.op.replies ?? []).map((r) => r.author)];
|
|
for (const a of allAuthors) {
|
|
profiles[a.pubkey] = {
|
|
pubkey: a.pubkey, npub: a.pubkey, shortName: a.name, image: a.picture,
|
|
metadata: { name: a.name, picture: a.picture },
|
|
lastUpdated: 0,
|
|
} as NostrUser;
|
|
}
|
|
}
|
|
|
|
export async function loadThread(id: string) {
|
|
detail = null;
|
|
profiles = {};
|
|
|
|
if (!isNostrId(id)) { await Promise.resolve(); loadMockThread(id); return; }
|
|
|
|
const pool = new SimplePool();
|
|
try {
|
|
const [threadEvents, replyEvents] = await Promise.all([
|
|
pool.querySync([RELAY_URL], { kinds: [11], ids: [id] }),
|
|
pool.querySync([RELAY_URL], { kinds: [1111], "#E": [id] }),
|
|
]);
|
|
|
|
const event = threadEvents[0];
|
|
if (!event) return;
|
|
|
|
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
|
|
|
detail = {
|
|
id: event.id,
|
|
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
|
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
|
op: {
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
createdAt: event.created_at,
|
|
content: event.content,
|
|
},
|
|
replies: replies.map((r) => ({
|
|
id: r.id,
|
|
pubkey: r.pubkey,
|
|
createdAt: r.created_at,
|
|
content: r.content,
|
|
})),
|
|
};
|
|
|
|
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
|
|
} finally {
|
|
pool.close([RELAY_URL]);
|
|
}
|
|
}
|
|
|
|
export async function sendReply(content: string, ownPubkey: string) {
|
|
if (!detail) throw new Error("No thread loaded");
|
|
if (!auth.signer) throw new Error("Not logged in");
|
|
|
|
// Use last 3 events not authored by us as previous refs (NIP-29)
|
|
const previousRefs = [...detail.replies, detail.op]
|
|
.filter((p) => p.pubkey !== ownPubkey)
|
|
.slice(-3)
|
|
.map((p) => p.id.slice(0, 8));
|
|
|
|
const tags: string[][] = [
|
|
["h", GROUP_ID],
|
|
["K", "11"],
|
|
["E", detail.id, RELAY_URL, detail.op.pubkey],
|
|
];
|
|
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
|
|
|
|
console.log("[reply] signing event…");
|
|
const signed = await auth.signer.signEvent({
|
|
kind: 1111,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags,
|
|
content,
|
|
});
|
|
console.log("[reply] event signed:", signed.id);
|
|
|
|
console.log("[reply] publishing…");
|
|
const pool = new SimplePool();
|
|
try {
|
|
const timeout = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000)
|
|
);
|
|
await Promise.race([Promise.all(pool.publish([RELAY_URL], signed)), timeout]);
|
|
} finally {
|
|
pool.destroy();
|
|
}
|
|
console.log("[reply] published");
|
|
|
|
const newReply: PostData = {
|
|
id: signed.id,
|
|
pubkey: signed.pubkey,
|
|
createdAt: signed.created_at,
|
|
content: signed.content,
|
|
};
|
|
detail = { ...detail, replies: [...detail.replies, newReply] };
|
|
loadProfile(signed.pubkey);
|
|
}
|