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;
|
placeholder?: string;
|
||||||
minHeightClass?: string;
|
minHeightClass?: string;
|
||||||
contextPubkeys?: string[];
|
contextPubkeys?: string[];
|
||||||
|
threadEventAuthors?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -30,6 +31,7 @@
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
minHeightClass = "",
|
minHeightClass = "",
|
||||||
contextPubkeys = [],
|
contextPubkeys = [],
|
||||||
|
threadEventAuthors = {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
||||||
|
|
@ -101,8 +103,14 @@
|
||||||
return Math.min(mentionIndex, mergedResults.length - 1);
|
return Math.min(mentionIndex, mergedResults.length - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function focus() {
|
export function focus(opts: { caretAtEnd?: boolean } = {}) {
|
||||||
textareaEl?.focus();
|
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
|
// Warm the kind:10002 cache for thread participants so relay hints are
|
||||||
|
|
@ -328,7 +336,7 @@
|
||||||
aria-label="Preview"
|
aria-label="Preview"
|
||||||
>
|
>
|
||||||
{#if value.trim()}
|
{#if value.trim()}
|
||||||
<PostContent content={value} />
|
<PostContent content={value} {threadEventAuthors} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-gray-400 italic">Nothing to preview</p>
|
<p class="text-gray-400 italic">Nothing to preview</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,107 @@
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string;
|
content: string;
|
||||||
profiles?: Record<string, NostrUser>;
|
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
|
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||||
const TLDS = [
|
const TLDS = [
|
||||||
"com","org","net","edu","gov","mil","int","info","biz","name","pro",
|
"com",
|
||||||
"io","co","app","dev","ai","sh","me","ly","tv","fm","lol","club",
|
"org",
|
||||||
"online","site","store","blog","tech","xyz","art","design","news",
|
"net",
|
||||||
"media","page","link","fun","gg","gl","st","to","run","life","world",
|
"edu",
|
||||||
"space","cloud","email","social","chat","wtf","cafe","zone","studio",
|
"gov",
|
||||||
"us","uk","de","fr","it","es","nl","ru","jp","cn","ca","au","br","in",
|
"mil",
|
||||||
"mx","se","no","fi","dk","ch","at","be","pl","pt","gr","cz","ie","nz",
|
"int",
|
||||||
"kr","sg","hk","tw","za","cc","ws","eu","tr","ua","il","ar","cl","pe",
|
"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("|");
|
].join("|");
|
||||||
|
|
||||||
const URL_RE = new RegExp(
|
const URL_RE = new RegExp(
|
||||||
|
|
@ -42,25 +129,151 @@
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "link"; href: string; label: string }
|
| { type: "link"; href: string; label: string }
|
||||||
| { type: "mention"; pubkey: string; entity: string; fallback: 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 Block =
|
||||||
| { type: "image"; value: string }
|
| { 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[] {
|
function tokenize(text: string): Block[] {
|
||||||
|
if (isBlockquoteParagraph(text)) return [tokenizeBlockquote(text)];
|
||||||
const blocks: Block[] = [];
|
const blocks: Block[] = [];
|
||||||
let inlines: Inline[] = [];
|
let inlines: Inline[] = [];
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (
|
// Trim leading/trailing newlines so block images don't carry an extra
|
||||||
inlines.some(
|
// visible line break under whitespace-pre-wrap.
|
||||||
(i) =>
|
while (inlines.length > 0) {
|
||||||
i.type === "link" ||
|
const first = inlines[0];
|
||||||
i.type === "mention" ||
|
if (first.type !== "text") break;
|
||||||
i.type === "entity" ||
|
const trimmed = first.value.replace(/^\n+/, "");
|
||||||
i.value.trim(),
|
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 });
|
blocks.push({ type: "para", inlines });
|
||||||
inlines = [];
|
inlines = [];
|
||||||
};
|
};
|
||||||
|
|
@ -81,41 +294,9 @@
|
||||||
inlines.push({ type: "text", value: text.slice(last, start) });
|
inlines.push({ type: "text", value: text.slice(last, start) });
|
||||||
if (isNostr) {
|
if (isNostr) {
|
||||||
const entity = url.slice(6).toLowerCase();
|
const entity = url.slice(6).toLowerCase();
|
||||||
let handled = false;
|
const decoded = decodeNostrInline(entity);
|
||||||
try {
|
if (decoded) inlines.push(decoded);
|
||||||
const decoded = nip19.decode(entity);
|
else inlines.push({ type: "text", value: m[0] });
|
||||||
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] });
|
|
||||||
} else {
|
} else {
|
||||||
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
||||||
if (IMG_EXT_RE.test(url)) {
|
if (IMG_EXT_RE.test(url)) {
|
||||||
|
|
@ -143,14 +324,19 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const blocks of paragraphs) {
|
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) {
|
for (const block of blocks) {
|
||||||
if (block.type !== "para") continue;
|
if (block.type === "para") collect(block.inlines);
|
||||||
for (const inline of block.inlines) {
|
else if (block.type === "blockquote") visit(block.blocks);
|
||||||
if (inline.type === "mention") seen.add(inline.pubkey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
for (const blocks of paragraphs) visit(blocks);
|
||||||
for (const pubkey of seen) {
|
for (const pubkey of seen) {
|
||||||
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
||||||
loadNostrUser(pubkey).then((u) => {
|
loadNostrUser(pubkey).then((u) => {
|
||||||
|
|
@ -160,19 +346,8 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3">
|
{#snippet renderInlines(inlines: Inline[])}
|
||||||
{#each paragraphs as blocks}
|
{#each inlines as inline}
|
||||||
{#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"}
|
{#if inline.type === "link"}
|
||||||
<a
|
<a
|
||||||
href={inline.href}
|
href={inline.href}
|
||||||
|
|
@ -181,15 +356,33 @@
|
||||||
class="text-brand hover:underline break-all">{inline.label}</a
|
class="text-brand hover:underline break-all">{inline.label}</a
|
||||||
>
|
>
|
||||||
{:else if inline.type === "mention"}
|
{:else if inline.type === "mention"}
|
||||||
{@const u =
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{inline.entity}"
|
href="https://njump.me/{inline.entity}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand hover:underline"
|
class="text-brand hover:underline">@{u?.shortName ?? inline.fallback}</a
|
||||||
>@{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"}
|
{:else if inline.type === "entity"}
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{inline.entity}"
|
href="https://njump.me/{inline.entity}"
|
||||||
|
|
@ -199,8 +392,33 @@
|
||||||
>
|
>
|
||||||
{:else}{inline.value}{/if}
|
{:else}{inline.value}{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
{/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}
|
{/if}
|
||||||
{/each}
|
{/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}
|
||||||
|
{@render renderBlocks(blocks)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,32 @@ export function extractMentionPubkeys(content: string): string[] {
|
||||||
return [...out];
|
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> {
|
export async function relayHintFor(pubkey: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const TIMEOUT = Symbol();
|
const TIMEOUT = Symbol();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ 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";
|
import {
|
||||||
|
extractMentionPubkeys,
|
||||||
|
extractQuotedEvents,
|
||||||
|
buildPTagHints,
|
||||||
|
} from "$lib/mentions";
|
||||||
|
|
||||||
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
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));
|
.map((p) => p.id.slice(0, 8));
|
||||||
|
|
||||||
// NIP-7D mandates flat-against-root, so parent === root for every reply.
|
// 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>();
|
const notifyPubkeys = new Set<string>();
|
||||||
notifyPubkeys.add(detail.op.pubkey);
|
notifyPubkeys.add(detail.op.pubkey);
|
||||||
for (const r of detail.replies) notifyPubkeys.add(r.pubkey);
|
for (const r of detail.replies) notifyPubkeys.add(r.pubkey);
|
||||||
for (const pk of extractMentionPubkeys(content)) notifyPubkeys.add(pk);
|
for (const pk of extractMentionPubkeys(content)) notifyPubkeys.add(pk);
|
||||||
|
for (const q of quoted) if (q.author) notifyPubkeys.add(q.author);
|
||||||
notifyPubkeys.delete(ownPubkey);
|
notifyPubkeys.delete(ownPubkey);
|
||||||
|
|
||||||
// Best-effort relay hints — cached calls return instantly, others race a timeout.
|
// 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);
|
const hint = hints.get(pk);
|
||||||
tags.push(hint ? ["p", pk, hint] : ["p", 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]);
|
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
|
||||||
|
|
||||||
console.log("[reply] signing event…");
|
console.log("[reply] signing event…");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import {
|
import {
|
||||||
threadDetailStore,
|
threadDetailStore,
|
||||||
loadThread,
|
loadThread,
|
||||||
|
|
@ -9,6 +10,7 @@
|
||||||
} from "$lib/thread.svelte";
|
} from "$lib/thread.svelte";
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { withJoin } from "$lib/join.svelte";
|
import { withJoin } from "$lib/join.svelte";
|
||||||
|
import { RELAY_URL } from "$lib/config";
|
||||||
import Reactions from "$lib/components/Reactions.svelte";
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
|
|
@ -44,6 +46,38 @@
|
||||||
let replyContent = $state("");
|
let replyContent = $state("");
|
||||||
let replying = $state(false);
|
let replying = $state(false);
|
||||||
let replyError = $state<string | null>(null);
|
let replyError = $state<string | null>(null);
|
||||||
|
let editorEl = $state<MessageEditor | null>(null);
|
||||||
|
|
||||||
|
let selectionTarget = $state<{
|
||||||
|
post: PostData;
|
||||||
|
text: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function formatQuoteBlock(text: string, ref: string): string {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const prefixed = lines.map((l) => (l.length > 0 ? `> ${l}` : ">"));
|
||||||
|
return [`> ${ref}`, ">", ...prefixed].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quotePost(post: PostData, selectedText?: string) {
|
||||||
|
const nevent = nip19.neventEncode({
|
||||||
|
id: post.id,
|
||||||
|
author: post.pubkey,
|
||||||
|
relays: [RELAY_URL],
|
||||||
|
});
|
||||||
|
const ref = `nostr:${nevent}`;
|
||||||
|
const source = (selectedText ?? post.content).trim();
|
||||||
|
if (!source) return;
|
||||||
|
const block = formatQuoteBlock(source, ref);
|
||||||
|
const sep = replyContent.length > 0 && !replyContent.endsWith("\n\n")
|
||||||
|
? replyContent.endsWith("\n") ? "\n" : "\n\n"
|
||||||
|
: "";
|
||||||
|
replyContent = replyContent + sep + block + "\n\n";
|
||||||
|
await tick();
|
||||||
|
editorEl?.focus({ caretAtEnd: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function submitReply() {
|
async function submitReply() {
|
||||||
if (!auth.user || !replyContent.trim()) return;
|
if (!auth.user || !replyContent.trim()) return;
|
||||||
|
|
@ -70,6 +104,9 @@
|
||||||
|
|
||||||
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||||
const postEls = $derived([opEl, ...replyEls]);
|
const postEls = $derived([opEl, ...replyEls]);
|
||||||
|
const threadEventAuthors = $derived(
|
||||||
|
Object.fromEntries(allPosts.map((p) => [p.id, p.pubkey])),
|
||||||
|
);
|
||||||
|
|
||||||
// Reload when navigating between threads
|
// Reload when navigating between threads
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -82,14 +119,68 @@
|
||||||
main.classList.add("no-scrollbar");
|
main.classList.add("no-scrollbar");
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
isScrolled = main.scrollTop > 0;
|
isScrolled = main.scrollTop > 0;
|
||||||
|
if (selectionTarget) selectionTarget = null;
|
||||||
};
|
};
|
||||||
main.addEventListener("scroll", onScroll, { passive: true });
|
main.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
const onSelectionChange = () => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||||
|
selectionTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const text = sel.toString().trim();
|
||||||
|
if (!text) {
|
||||||
|
selectionTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Selection must start and end inside the same post's content area.
|
||||||
|
const startEl =
|
||||||
|
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||||||
|
? (range.startContainer as Element)
|
||||||
|
: range.startContainer.parentElement;
|
||||||
|
const endEl =
|
||||||
|
range.endContainer.nodeType === Node.ELEMENT_NODE
|
||||||
|
? (range.endContainer as Element)
|
||||||
|
: range.endContainer.parentElement;
|
||||||
|
const startWrap = startEl?.closest("[data-quote-post-index]");
|
||||||
|
const endWrap = endEl?.closest("[data-quote-post-index]");
|
||||||
|
if (!startWrap || startWrap !== endWrap) {
|
||||||
|
selectionTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = Number(startWrap.getAttribute("data-quote-post-index"));
|
||||||
|
const post = allPosts[idx];
|
||||||
|
if (!post) {
|
||||||
|
selectionTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
selectionTarget = {
|
||||||
|
post,
|
||||||
|
text,
|
||||||
|
top: rect.top - 8,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
document.addEventListener("selectionchange", onSelectionChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
main.classList.remove("no-scrollbar");
|
main.classList.remove("no-scrollbar");
|
||||||
main.removeEventListener("scroll", onScroll);
|
main.removeEventListener("scroll", onScroll);
|
||||||
|
document.removeEventListener("selectionchange", onSelectionChange);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function quoteFromSelection() {
|
||||||
|
if (!selectionTarget) return;
|
||||||
|
const { post, text } = selectionTarget;
|
||||||
|
selectionTarget = null;
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
await quotePost(post, text);
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!opEl) return;
|
if (!opEl) return;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
|
|
@ -119,7 +210,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet post(p: PostData, bindEl: (el: HTMLElement | null) => void)}
|
{#snippet post(
|
||||||
|
p: PostData,
|
||||||
|
index: number,
|
||||||
|
bindEl: (el: HTMLElement | null) => void,
|
||||||
|
)}
|
||||||
{@const author = resolveAuthor(p.pubkey, profiles)}
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||||
<div use:bindEl class="flex gap-6 items-start">
|
<div use:bindEl class="flex gap-6 items-start">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
|
|
@ -132,13 +227,24 @@
|
||||||
>{formatDate(p.createdAt)}</span
|
>{formatDate(p.createdAt)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<PostContent content={p.content} {profiles} />
|
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
|
||||||
|
<PostContent
|
||||||
|
content={p.content}
|
||||||
|
{profiles}
|
||||||
|
{threadEventAuthors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between mt-3">
|
<div class="flex items-center justify-between mt-3">
|
||||||
<Reactions reactions={[]} zaps={0} />
|
<Reactions reactions={[]} zaps={0} />
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0"
|
class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<button class="hover:text-brand transition-colors">Quote</button>
|
<button
|
||||||
|
onclick={() => quotePost(p)}
|
||||||
|
disabled={!auth.user}
|
||||||
|
class="cursor-pointer hover:text-brand transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>Quote</button
|
||||||
|
>
|
||||||
<button class="hover:text-brand transition-colors">React</button>
|
<button class="hover:text-brand transition-colors">React</button>
|
||||||
<button class="hover:text-amber-500 transition-colors">⚡ Zap</button>
|
<button class="hover:text-amber-500 transition-colors">⚡ Zap</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,14 +277,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-6 pb-6" bind:this={opEl}>
|
<div class="pt-6 pb-6" bind:this={opEl}>
|
||||||
{@render post(detail.op, () => {})}
|
{@render post(detail.op, 0, () => {})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if detail.replies.length > 0}
|
{#if detail.replies.length > 0}
|
||||||
<div class="divide-y divide-gray-100 border-t border-gray-100">
|
<div class="divide-y divide-gray-100 border-t border-gray-100">
|
||||||
{#each detail.replies as reply, i}
|
{#each detail.replies as reply, i}
|
||||||
<div class="py-6" bind:this={replyEls[i]}>
|
<div class="py-6" bind:this={replyEls[i]}>
|
||||||
{@render post(reply, () => {})}
|
{@render post(reply, i + 1, () => {})}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,11 +300,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<MessageEditor
|
<MessageEditor
|
||||||
|
bind:this={editorEl}
|
||||||
bind:value={replyContent}
|
bind:value={replyContent}
|
||||||
disabled={replying}
|
disabled={replying}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Write a reply..."
|
placeholder="Write a reply..."
|
||||||
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
||||||
|
{threadEventAuthors}
|
||||||
/>
|
/>
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
@ -230,3 +338,15 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="py-12 text-center text-gray-400">Loading…</div>
|
<div class="py-12 text-center text-gray-400">Loading…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if selectionTarget && auth.user}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
onclick={quoteFromSelection}
|
||||||
|
style="top: {selectionTarget.top}px; left: {selectionTarget.left}px;"
|
||||||
|
class="fixed -translate-x-1/2 -translate-y-full z-50 rounded bg-gray-900 px-3 py-1 text-xs font-medium text-white shadow-md hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Quote
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue