Add proper p tags for replies and mentions

This commit is contained in:
dtonon 2026-04-23 12:59:21 +01:00
parent a8aebe8862
commit 33f650276d
4 changed files with 89 additions and 1 deletions

View file

@ -105,6 +105,17 @@
textareaEl?.focus(); 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<string>();
function onTextareaFocus() {
for (const pk of contextPubkeys) {
if (prefetched.has(pk)) continue;
prefetched.add(pk);
loadRelayList(pk).catch(() => {});
}
}
function togglePreview() { function togglePreview() {
previewing = !previewing; previewing = !previewing;
} }
@ -334,6 +345,7 @@
onkeydown={onTextareaKeydown} onkeydown={onTextareaKeydown}
onclick={onTextareaClickOrSelect} onclick={onTextareaClickOrSelect}
onkeyup={onTextareaClickOrSelect} onkeyup={onTextareaClickOrSelect}
onfocus={onTextareaFocus}
onblur={onTextareaBlur} onblur={onTextareaBlur}
aria-autocomplete="list" aria-autocomplete="list"
aria-controls={mentionOpen ? "mention-listbox" : undefined} aria-controls={mentionOpen ? "mention-listbox" : undefined}

View file

@ -2,6 +2,7 @@ import { SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import { GROUP_ID, RELAY_URL } from "$lib/config"; import { GROUP_ID, RELAY_URL } from "$lib/config";
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
let modalOpen = $state(false); let modalOpen = $state(false);
let iconized = $state(false); let iconized = $state(false);
@ -80,6 +81,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
publishError = null; publishError = null;
let threadId: string | undefined; let threadId: string | undefined;
const ownPubkey = auth.user?.pubkey;
const mentionPubkeys = extractMentionPubkeys(c).filter((pk) => pk !== ownPubkey);
const hints = await buildPTagHints(mentionPubkeys);
try { try {
const success = await withJoin(async () => { const success = await withJoin(async () => {
const tags: string[][] = [ const tags: string[][] = [
@ -87,6 +92,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
["title", t], ["title", t],
]; ];
for (const l of labels) tags.push(["t", l]); 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({ const event = await auth.signer!.signEvent({
kind: 11, kind: 11,

46
src/lib/mentions.ts Normal file
View file

@ -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<string>();
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<string | undefined> {
try {
const TIMEOUT = Symbol();
const result = await Promise.race([
loadRelayList(pubkey),
new Promise<typeof TIMEOUT>((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<string>): Promise<Map<string, string>> {
const hints = new Map<string, string>();
await Promise.all(
[...pubkeys].map(async (pk) => {
const url = await relayHintFor(pk);
if (url) hints.set(pk, url);
}),
);
return hints;
}

View file

@ -4,6 +4,7 @@ import { RELAY_URL, GROUP_ID } from "$lib/config";
import { threads as mockThreads } from "$lib/mock"; import { threads as mockThreads } from "$lib/mock";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
@ -112,11 +113,31 @@ export async function sendReply(content: string, ownPubkey: string) {
.slice(-3) .slice(-3)
.map((p) => p.id.slice(0, 8)); .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<string>();
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[][] = [ const tags: string[][] = [
["h", GROUP_ID], ["h", GROUP_ID],
["E", detail.id, opHint, detail.op.pubkey],
["K", "11"], ["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]); if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
console.log("[reply] signing event…"); console.log("[reply] signing event…");