diff --git a/src/lib/components/MessageEditor.svelte b/src/lib/components/MessageEditor.svelte index 43d5836..8cac93d 100644 --- a/src/lib/components/MessageEditor.svelte +++ b/src/lib/components/MessageEditor.svelte @@ -20,6 +20,7 @@ placeholder?: string; minHeightClass?: string; contextPubkeys?: string[]; + threadEventAuthors?: Record; }; let { @@ -30,6 +31,7 @@ placeholder = "", minHeightClass = "", contextPubkeys = [], + threadEventAuthors = {}, }: Props = $props(); let textareaEl = $state(null); @@ -101,8 +103,14 @@ return Math.min(mentionIndex, mergedResults.length - 1); }); - export function focus() { - textareaEl?.focus(); + export function focus(opts: { caretAtEnd?: boolean } = {}) { + const ta = textareaEl; + if (!ta) return; + ta.focus(); + if (opts.caretAtEnd) { + const pos = ta.value.length; + ta.setSelectionRange(pos, pos); + } } // Warm the kind:10002 cache for thread participants so relay hints are @@ -328,7 +336,7 @@ aria-label="Preview" > {#if value.trim()} - + {:else}

Nothing to preview

{/if} diff --git a/src/lib/components/PostContent.svelte b/src/lib/components/PostContent.svelte index ccf09f2..ad22f73 100644 --- a/src/lib/components/PostContent.svelte +++ b/src/lib/components/PostContent.svelte @@ -5,20 +5,107 @@ type Props = { content: string; profiles?: Record; + threadEventAuthors?: Record; }; - let { content, profiles = {} }: Props = $props(); + let { content, profiles = {}, threadEventAuthors = {} }: Props = $props(); // Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs const TLDS = [ - "com","org","net","edu","gov","mil","int","info","biz","name","pro", - "io","co","app","dev","ai","sh","me","ly","tv","fm","lol","club", - "online","site","store","blog","tech","xyz","art","design","news", - "media","page","link","fun","gg","gl","st","to","run","life","world", - "space","cloud","email","social","chat","wtf","cafe","zone","studio", - "us","uk","de","fr","it","es","nl","ru","jp","cn","ca","au","br","in", - "mx","se","no","fi","dk","ch","at","be","pl","pt","gr","cz","ie","nz", - "kr","sg","hk","tw","za","cc","ws","eu","tr","ua","il","ar","cl","pe", + "com", + "org", + "net", + "edu", + "gov", + "mil", + "int", + "info", + "biz", + "name", + "pro", + "io", + "co", + "app", + "dev", + "ai", + "sh", + "me", + "ly", + "tv", + "fm", + "lol", + "club", + "online", + "site", + "store", + "blog", + "tech", + "xyz", + "art", + "design", + "news", + "media", + "page", + "link", + "fun", + "gg", + "gl", + "st", + "to", + "run", + "life", + "world", + "space", + "cloud", + "email", + "social", + "chat", + "wtf", + "cafe", + "zone", + "studio", + "us", + "uk", + "de", + "fr", + "it", + "es", + "nl", + "ru", + "jp", + "cn", + "ca", + "au", + "br", + "in", + "mx", + "se", + "no", + "fi", + "dk", + "ch", + "at", + "be", + "pl", + "pt", + "gr", + "cz", + "ie", + "nz", + "kr", + "sg", + "hk", + "tw", + "za", + "cc", + "ws", + "eu", + "tr", + "ua", + "il", + "ar", + "cl", + "pe", ].join("|"); const URL_RE = new RegExp( @@ -42,25 +129,151 @@ | { type: "text"; value: string } | { type: "link"; href: string; label: string } | { type: "mention"; pubkey: string; entity: string; fallback: string } - | { type: "entity"; entity: string; label: string }; + | { type: "entity"; entity: string; label: string } + | { type: "thread-quote"; pubkey: string; eventId: string }; type Block = | { type: "image"; value: string } - | { type: "para"; inlines: Inline[] }; + | { type: "para"; inlines: Inline[] } + | { type: "blockquote"; blocks: Block[] }; + + const BLOCKQUOTE_LINE_RE = /^>\s?(.*)$/; + + function decodeNostrInline(entity: string): Inline | null { + try { + const decoded = nip19.decode(entity); + if (decoded.type === "npub") { + return { + type: "mention", + pubkey: decoded.data, + entity, + fallback: shortEntity(entity), + }; + } + if (decoded.type === "nprofile") { + return { + type: "mention", + pubkey: decoded.data.pubkey, + entity, + fallback: shortEntity(entity), + }; + } + if (decoded.type === "note") { + const id = decoded.data; + const author = threadEventAuthors[id]; + if (author) + return { type: "thread-quote", pubkey: author, eventId: id }; + return { type: "entity", entity, label: shortEntity(entity) }; + } + if (decoded.type === "nevent") { + const id = decoded.data.id; + const author = threadEventAuthors[id]; + if (author) + return { type: "thread-quote", pubkey: author, eventId: id }; + return { type: "entity", entity, label: shortEntity(entity) }; + } + if (decoded.type === "naddr") { + return { type: "entity", entity, label: shortEntity(entity) }; + } + } catch { + // Invalid bech32, fall through + } + return null; + } + + // Inline-only tokenizer: produces inline tokens for a single line of text. + // Used inside blockquote lines, where images are not supported. + function tokenizeInline(text: string): Inline[] { + const inlines: Inline[] = []; + let last = 0; + for (const m of text.matchAll(URL_RE)) { + const start = m.index ?? 0; + let url = m[0]; + const isNostr = /^nostr:/i.test(url); + let trailing = ""; + if (!isNostr) { + const trail = url.match(TRAILING_PUNCT_RE); + if (trail) { + trailing = trail[0]; + url = url.slice(0, -trailing.length); + } + } + if (start > last) + inlines.push({ type: "text", value: text.slice(last, start) }); + if (isNostr) { + const entity = url.slice(6).toLowerCase(); + const decoded = decodeNostrInline(entity); + if (decoded) inlines.push(decoded); + else inlines.push({ type: "text", value: m[0] }); + } else { + const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; + inlines.push({ type: "link", href, label: url }); + if (trailing) inlines.push({ type: "text", value: trailing }); + } + last = start + m[0].length; + } + if (last < text.length) + inlines.push({ type: "text", value: text.slice(last) }); + return inlines; + } + + function isBlockquoteParagraph(text: string): boolean { + const lines = text.split("\n"); + let hasQuoteLine = false; + for (const l of lines) { + if (l.length === 0) continue; + if (!BLOCKQUOTE_LINE_RE.test(l)) return false; + hasQuoteLine = true; + } + return hasQuoteLine; + } + + function tokenizeBlockquote(text: string): Block { + // Strip the > prefix per line, then route through the same paragraph + // pipeline so blockquotes get image support, pre-wrap newlines, and + // consistent paragraph handling. + const inner = text + .split("\n") + .map((l) => { + const m = l.match(BLOCKQUOTE_LINE_RE); + return m ? m[1] : l; + }) + .join("\n"); + const blocks: Block[] = []; + for (const para of inner.split(/\n{2,}/)) { + for (const b of tokenize(para)) blocks.push(b); + } + return { type: "blockquote", blocks }; + } function tokenize(text: string): Block[] { + if (isBlockquoteParagraph(text)) return [tokenizeBlockquote(text)]; const blocks: Block[] = []; let inlines: Inline[] = []; const flush = () => { - if ( - inlines.some( - (i) => - i.type === "link" || - i.type === "mention" || - i.type === "entity" || - i.value.trim(), - ) - ) + // Trim leading/trailing newlines so block images don't carry an extra + // visible line break under whitespace-pre-wrap. + while (inlines.length > 0) { + const first = inlines[0]; + if (first.type !== "text") break; + const trimmed = first.value.replace(/^\n+/, ""); + if (trimmed === "") inlines.shift(); + else { + inlines[0] = { type: "text", value: trimmed }; + break; + } + } + while (inlines.length > 0) { + const last = inlines[inlines.length - 1]; + if (last.type !== "text") break; + const trimmed = last.value.replace(/\n+$/, ""); + if (trimmed === "") inlines.pop(); + else { + inlines[inlines.length - 1] = { type: "text", value: trimmed }; + break; + } + } + if (inlines.some((i) => i.type !== "text" || i.value.trim())) blocks.push({ type: "para", inlines }); inlines = []; }; @@ -81,41 +294,9 @@ inlines.push({ type: "text", value: text.slice(last, start) }); if (isNostr) { const entity = url.slice(6).toLowerCase(); - let handled = false; - try { - const decoded = nip19.decode(entity); - if (decoded.type === "npub") { - inlines.push({ - type: "mention", - pubkey: decoded.data, - entity, - fallback: shortEntity(entity), - }); - handled = true; - } else if (decoded.type === "nprofile") { - inlines.push({ - type: "mention", - pubkey: decoded.data.pubkey, - entity, - fallback: shortEntity(entity), - }); - handled = true; - } else if ( - decoded.type === "note" || - decoded.type === "nevent" || - decoded.type === "naddr" - ) { - inlines.push({ - type: "entity", - entity, - label: shortEntity(entity), - }); - handled = true; - } - } catch { - // Fall through to text - } - if (!handled) inlines.push({ type: "text", value: m[0] }); + const decoded = decodeNostrInline(entity); + if (decoded) inlines.push(decoded); + else inlines.push({ type: "text", value: m[0] }); } else { const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; if (IMG_EXT_RE.test(url)) { @@ -143,14 +324,19 @@ $effect(() => { const seen = new Set(); - for (const blocks of paragraphs) { - for (const block of blocks) { - if (block.type !== "para") continue; - for (const inline of block.inlines) { - if (inline.type === "mention") seen.add(inline.pubkey); - } + const collect = (inlines: Inline[]) => { + for (const inline of inlines) { + if (inline.type === "mention" || inline.type === "thread-quote") + seen.add(inline.pubkey); } - } + }; + const visit = (blocks: Block[]) => { + for (const block of blocks) { + if (block.type === "para") collect(block.inlines); + else if (block.type === "blockquote") visit(block.blocks); + } + }; + for (const blocks of paragraphs) visit(blocks); for (const pubkey of seen) { if (resolvedUsers[pubkey] || profiles[pubkey]) continue; loadNostrUser(pubkey).then((u) => { @@ -160,47 +346,79 @@ }); -
+{#snippet renderInlines(inlines: Inline[])} + {#each inlines as inline} + {#if inline.type === "link"} + {inline.label} + {:else if inline.type === "mention"} + {@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]} + @{u?.shortName ?? inline.fallback} + {:else if inline.type === "thread-quote"} + {@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]} + {u?.shortName ?? inline.pubkey.slice(0, 8)} said + + + {:else if inline.type === "entity"} + {inline.label} + {:else}{inline.value}{/if} + {/each} +{/snippet} + +{#snippet renderBlocks(blocks: Block[])} + {#each blocks as block} + {#if block.type === "image"} + + {:else if block.type === "blockquote"} +
+ {@render renderBlocks(block.blocks)} +
+ {:else} +

{@render renderInlines(block.inlines)}

+ {/if} + {/each} +{/snippet} + +
{#each paragraphs as blocks} - {#each blocks as block} - {#if block.type === "image"} - - {:else} -

- {#each block.inlines as inline} - {#if inline.type === "link"} - {inline.label} - {:else if inline.type === "mention"} - {@const u = - profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]} - @{u?.shortName ?? inline.fallback} - {:else if inline.type === "entity"} - {inline.label} - {:else}{inline.value}{/if} - {/each} -

- {/if} - {/each} + {@render renderBlocks(blocks)} {/each}
diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index 1a63752..d20e7e8 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -18,6 +18,32 @@ export function extractMentionPubkeys(content: string): string[] { return [...out]; } +export type QuotedEvent = { + id: string; + relay?: string; + author?: string; +}; + +export function extractQuotedEvents(content: string): QuotedEvent[] { + const re = /nostr:(note1[a-z0-9]+|nevent1[a-z0-9]+)/gi; + const seen = new Map(); + for (const m of content.matchAll(re)) { + try { + const decoded = nip19.decode(m[1]); + if (decoded.type === "note") { + if (!seen.has(decoded.data)) seen.set(decoded.data, { id: decoded.data }); + } else if (decoded.type === "nevent") { + const { id, relays, author } = decoded.data; + if (!seen.has(id)) + seen.set(id, { id, relay: relays?.[0], author }); + } + } catch { + // Invalid bech32, skip + } + } + return [...seen.values()]; +} + export async function relayHintFor(pubkey: string): Promise { try { const TIMEOUT = Symbol(); diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index f89039a..9579a75 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -4,7 +4,11 @@ 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"; +import { + extractMentionPubkeys, + extractQuotedEvents, + buildPTagHints, +} from "$lib/mentions"; const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); @@ -114,11 +118,13 @@ export async function sendReply(content: string, ownPubkey: string) { .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. + // Build the notify set: thread participants + mentions + quoted authors, minus self. + const quoted = extractQuotedEvents(content); 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); + for (const q of quoted) if (q.author) notifyPubkeys.add(q.author); notifyPubkeys.delete(ownPubkey); // Best-effort relay hints — cached calls return instantly, others race a timeout. @@ -138,6 +144,11 @@ export async function sendReply(content: string, ownPubkey: string) { const hint = hints.get(pk); tags.push(hint ? ["p", pk, hint] : ["p", pk]); } + for (const q of quoted) { + const tag: string[] = ["q", q.id, q.relay ?? RELAY_URL]; + if (q.author) tag.push(q.author); + tags.push(tag); + } if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]); console.log("[reply] signing event…"); diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 59cd175..17cd5ae 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -1,6 +1,7 @@