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

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