Implement quoting
This commit is contained in:
parent
33f650276d
commit
7b79b0914a
5 changed files with 497 additions and 114 deletions
|
|
@ -20,6 +20,7 @@
|
|||
placeholder?: string;
|
||||
minHeightClass?: string;
|
||||
contextPubkeys?: string[];
|
||||
threadEventAuthors?: Record<string, string>;
|
||||
};
|
||||
|
||||
let {
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
placeholder = "",
|
||||
minHeightClass = "",
|
||||
contextPubkeys = [],
|
||||
threadEventAuthors = {},
|
||||
}: Props = $props();
|
||||
|
||||
let textareaEl = $state<HTMLTextAreaElement | null>(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()}
|
||||
<PostContent content={value} />
|
||||
<PostContent content={value} {threadEventAuthors} />
|
||||
{:else}
|
||||
<p class="text-gray-400 italic">Nothing to preview</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,107 @@
|
|||
type Props = {
|
||||
content: string;
|
||||
profiles?: Record<string, NostrUser>;
|
||||
threadEventAuthors?: Record<string, string>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3">
|
||||
{#snippet renderInlines(inlines: Inline[])}
|
||||
{#each inlines as inline}
|
||||
{#if inline.type === "link"}
|
||||
<a
|
||||
href={inline.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline break-all">{inline.label}</a
|
||||
>
|
||||
{:else if inline.type === "mention"}
|
||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline">@{u?.shortName ?? inline.fallback}</a
|
||||
>
|
||||
{:else if inline.type === "thread-quote"}
|
||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
<a
|
||||
href="#post-{inline.eventId}"
|
||||
class="no-underline font-normal leading-4 bg-neutral-100 block -ml-3 pl-3 py-1 hover:bg-neutral-200"
|
||||
>{u?.shortName ?? inline.pubkey.slice(0, 8)} said
|
||||
<svg
|
||||
class="inline w-3 mb-0.5"
|
||||
viewBox="0 0 800 800"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;"
|
||||
><path
|
||||
d="M101.286,748.313l199.143,0c109.981,0 199.142,-89.161 199.142,-199.142l0,-497.856m0,-0l199.143,199.142m-199.143,-199.142l-199.142,199.142"
|
||||
style="fill:none;fill-rule:nonzero;stroke:#000;stroke-width:99.57px;"
|
||||
/></svg
|
||||
>
|
||||
</a>
|
||||
{:else if inline.type === "entity"}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline break-all">{inline.label}</a
|
||||
>
|
||||
{:else}{inline.value}{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderBlocks(blocks: Block[])}
|
||||
{#each blocks as block}
|
||||
{#if block.type === "image"}
|
||||
<img
|
||||
src={block.value}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
||||
/>
|
||||
{:else if block.type === "blockquote"}
|
||||
<blockquote
|
||||
class="my-3 pb-1 mb-0 border-l-3 border-gray-200 pl-3 text-gray-500"
|
||||
>
|
||||
{@render renderBlocks(block.blocks)}
|
||||
</blockquote>
|
||||
{:else}
|
||||
<p class="whitespace-pre-wrap">{@render renderInlines(block.inlines)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"
|
||||
>
|
||||
{#each paragraphs as blocks}
|
||||
{#each blocks as block}
|
||||
{#if block.type === "image"}
|
||||
<img
|
||||
src={block.value}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
||||
/>
|
||||
{:else}
|
||||
<p>
|
||||
{#each block.inlines as inline}
|
||||
{#if inline.type === "link"}
|
||||
<a
|
||||
href={inline.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline break-all">{inline.label}</a
|
||||
>
|
||||
{:else if inline.type === "mention"}
|
||||
{@const u =
|
||||
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline"
|
||||
>@{u?.shortName ?? inline.fallback}</a
|
||||
>
|
||||
{:else if inline.type === "entity"}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-brand hover:underline break-all">{inline.label}</a
|
||||
>
|
||||
{:else}{inline.value}{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
{@render renderBlocks(blocks)}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, QuotedEvent>();
|
||||
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<string | undefined> {
|
||||
try {
|
||||
const TIMEOUT = Symbol();
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
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…");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue