From 33f650276d1bf57a73ffc4a879b63312bbdb159f Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 23 Apr 2026 12:59:21 +0100 Subject: [PATCH] Add proper p tags for replies and mentions --- src/lib/components/MessageEditor.svelte | 12 +++++++ src/lib/draft.svelte.ts | 9 +++++ src/lib/mentions.ts | 46 +++++++++++++++++++++++++ src/lib/thread.svelte.ts | 23 ++++++++++++- 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/lib/mentions.ts diff --git a/src/lib/components/MessageEditor.svelte b/src/lib/components/MessageEditor.svelte index c722e8b..43d5836 100644 --- a/src/lib/components/MessageEditor.svelte +++ b/src/lib/components/MessageEditor.svelte @@ -105,6 +105,17 @@ textareaEl?.focus(); } + // Warm the kind:10002 cache for thread participants so relay hints are + // ready by publish time. Best-effort, dedup across focus events. + const prefetched = new Set(); + function onTextareaFocus() { + for (const pk of contextPubkeys) { + if (prefetched.has(pk)) continue; + prefetched.add(pk); + loadRelayList(pk).catch(() => {}); + } + } + function togglePreview() { previewing = !previewing; } @@ -334,6 +345,7 @@ onkeydown={onTextareaKeydown} onclick={onTextareaClickOrSelect} onkeyup={onTextareaClickOrSelect} + onfocus={onTextareaFocus} onblur={onTextareaBlur} aria-autocomplete="list" aria-controls={mentionOpen ? "mention-listbox" : undefined} diff --git a/src/lib/draft.svelte.ts b/src/lib/draft.svelte.ts index 2edb4e2..0bbc17c 100644 --- a/src/lib/draft.svelte.ts +++ b/src/lib/draft.svelte.ts @@ -2,6 +2,7 @@ import { SimplePool } from "@nostr/tools"; import { auth } from "$lib/auth.svelte"; import { withJoin } from "$lib/join.svelte"; import { GROUP_ID, RELAY_URL } from "$lib/config"; +import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; let modalOpen = $state(false); let iconized = $state(false); @@ -80,6 +81,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string } publishError = null; let threadId: string | undefined; + const ownPubkey = auth.user?.pubkey; + const mentionPubkeys = extractMentionPubkeys(c).filter((pk) => pk !== ownPubkey); + const hints = await buildPTagHints(mentionPubkeys); + try { const success = await withJoin(async () => { const tags: string[][] = [ @@ -87,6 +92,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string } ["title", t], ]; for (const l of labels) tags.push(["t", l]); + for (const pk of mentionPubkeys) { + const hint = hints.get(pk); + tags.push(hint ? ["p", pk, hint] : ["p", pk]); + } const event = await auth.signer!.signEvent({ kind: 11, diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts new file mode 100644 index 0000000..1a63752 --- /dev/null +++ b/src/lib/mentions.ts @@ -0,0 +1,46 @@ +import * as nip19 from "@nostr/tools/nip19"; +import { loadRelayList } from "@nostr/gadgets/lists"; + +const RELAY_HINT_TIMEOUT_MS = 1500; + +export function extractMentionPubkeys(content: string): string[] { + const re = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/gi; + const out = new Set(); + for (const m of content.matchAll(re)) { + try { + const decoded = nip19.decode(m[1]); + if (decoded.type === "npub") out.add(decoded.data); + else if (decoded.type === "nprofile") out.add(decoded.data.pubkey); + } catch { + // Invalid bech32, skip + } + } + return [...out]; +} + +export async function relayHintFor(pubkey: string): Promise { + try { + const TIMEOUT = Symbol(); + const result = await Promise.race([ + loadRelayList(pubkey), + new Promise((resolve) => + setTimeout(() => resolve(TIMEOUT), RELAY_HINT_TIMEOUT_MS), + ), + ]); + if (result === TIMEOUT) return undefined; + return result.items.find((r) => r.write)?.url; + } catch { + return undefined; + } +} + +export async function buildPTagHints(pubkeys: Iterable): Promise> { + const hints = new Map(); + await Promise.all( + [...pubkeys].map(async (pk) => { + const url = await relayHintFor(pk); + if (url) hints.set(pk, url); + }), + ); + return hints; +} diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index 13468d5..f89039a 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -4,6 +4,7 @@ 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"; +import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); @@ -112,11 +113,31 @@ export async function sendReply(content: string, ownPubkey: string) { .slice(-3) .map((p) => p.id.slice(0, 8)); + // NIP-7D mandates flat-against-root, so parent === root for every reply. + // Build the notify set: thread participants + mentions in content, minus self. + const notifyPubkeys = new Set(); + notifyPubkeys.add(detail.op.pubkey); + for (const r of detail.replies) notifyPubkeys.add(r.pubkey); + for (const pk of extractMentionPubkeys(content)) notifyPubkeys.add(pk); + notifyPubkeys.delete(ownPubkey); + + // Best-effort relay hints — cached calls return instantly, others race a timeout. + const hints = await buildPTagHints([...notifyPubkeys, detail.op.pubkey]); + + const opHint = hints.get(detail.op.pubkey) ?? RELAY_URL; + const tags: string[][] = [ ["h", GROUP_ID], + ["E", detail.id, opHint, detail.op.pubkey], ["K", "11"], - ["E", detail.id, RELAY_URL, detail.op.pubkey], + ["P", detail.op.pubkey, opHint], + ["e", detail.id, opHint, detail.op.pubkey], + ["k", "11"], ]; + for (const pk of notifyPubkeys) { + const hint = hints.get(pk); + tags.push(hint ? ["p", pk, hint] : ["p", pk]); + } if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]); console.log("[reply] signing event…");