Implement quoting

This commit is contained in:
dtonon 2026-04-27 14:58:50 +01:00
parent 33f650276d
commit 7b79b0914a
5 changed files with 497 additions and 114 deletions

View file

@ -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}

View file

@ -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 block of blocks) { for (const inline of inlines) {
if (block.type !== "para") continue; if (inline.type === "mention" || inline.type === "thread-quote")
for (const inline of block.inlines) { seen.add(inline.pubkey);
if (inline.type === "mention") 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) { 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,47 +346,79 @@
}); });
</script> </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 paragraphs as blocks}
{#each blocks as block} {@render renderBlocks(blocks)}
{#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}
{/each} {/each}
</div> </div>

View file

@ -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();

View file

@ -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…");

View file

@ -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}