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) {
|
||||
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") continue;
|
||||
for (const inline of block.inlines) {
|
||||
if (inline.type === "mention") seen.add(inline.pubkey);
|
||||
}
|
||||
}
|
||||
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,19 +346,8 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3">
|
||||
{#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}
|
||||
{#snippet renderInlines(inlines: Inline[])}
|
||||
{#each inlines as inline}
|
||||
{#if inline.type === "link"}
|
||||
<a
|
||||
href={inline.href}
|
||||
|
|
@ -181,15 +356,33 @@
|
|||
class="text-brand hover:underline break-all">{inline.label}</a
|
||||
>
|
||||
{:else if inline.type === "mention"}
|
||||
{@const u =
|
||||
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
{@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
|
||||
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}"
|
||||
|
|
@ -199,8 +392,33 @@
|
|||
>
|
||||
{:else}{inline.value}{/if}
|
||||
{/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}
|
||||
{/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}
|
||||
</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…");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import {
|
||||
threadDetailStore,
|
||||
loadThread,
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
} from "$lib/thread.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import { RELAY_URL } from "$lib/config";
|
||||
import Reactions from "$lib/components/Reactions.svelte";
|
||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||
|
|
@ -44,6 +46,38 @@
|
|||
let replyContent = $state("");
|
||||
let replying = $state(false);
|
||||
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() {
|
||||
if (!auth.user || !replyContent.trim()) return;
|
||||
|
|
@ -70,6 +104,9 @@
|
|||
|
||||
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||
const postEls = $derived([opEl, ...replyEls]);
|
||||
const threadEventAuthors = $derived(
|
||||
Object.fromEntries(allPosts.map((p) => [p.id, p.pubkey])),
|
||||
);
|
||||
|
||||
// Reload when navigating between threads
|
||||
$effect(() => {
|
||||
|
|
@ -82,14 +119,68 @@
|
|||
main.classList.add("no-scrollbar");
|
||||
const onScroll = () => {
|
||||
isScrolled = main.scrollTop > 0;
|
||||
if (selectionTarget) selectionTarget = null;
|
||||
};
|
||||
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 () => {
|
||||
main.classList.remove("no-scrollbar");
|
||||
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(() => {
|
||||
if (!opEl) return;
|
||||
const main = document.querySelector("main");
|
||||
|
|
@ -119,7 +210,11 @@
|
|||
{/if}
|
||||
{/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)}
|
||||
<div use:bindEl class="flex gap-6 items-start">
|
||||
<div class="flex-shrink-0">
|
||||
|
|
@ -132,13 +227,24 @@
|
|||
>{formatDate(p.createdAt)}</span
|
||||
>
|
||||
</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">
|
||||
<Reactions reactions={[]} zaps={0} />
|
||||
<div
|
||||
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-amber-500 transition-colors">⚡ Zap</button>
|
||||
</div>
|
||||
|
|
@ -171,14 +277,14 @@
|
|||
{/if}
|
||||
|
||||
<div class="pt-6 pb-6" bind:this={opEl}>
|
||||
{@render post(detail.op, () => {})}
|
||||
{@render post(detail.op, 0, () => {})}
|
||||
</div>
|
||||
|
||||
{#if detail.replies.length > 0}
|
||||
<div class="divide-y divide-gray-100 border-t border-gray-100">
|
||||
{#each detail.replies as reply, i}
|
||||
<div class="py-6" bind:this={replyEls[i]}>
|
||||
{@render post(reply, () => {})}
|
||||
{@render post(reply, i + 1, () => {})}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -194,11 +300,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
<MessageEditor
|
||||
bind:this={editorEl}
|
||||
bind:value={replyContent}
|
||||
disabled={replying}
|
||||
rows={4}
|
||||
placeholder="Write a reply..."
|
||||
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
||||
{threadEventAuthors}
|
||||
/>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
|
|
@ -230,3 +338,15 @@
|
|||
{:else}
|
||||
<div class="py-12 text-center text-gray-400">Loading…</div>
|
||||
{/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