Implement quoting
This commit is contained in:
parent
33f650276d
commit
7b79b0914a
5 changed files with 497 additions and 114 deletions
|
|
@ -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