492 lines
16 KiB
Svelte
492 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from "svelte";
|
|
import { page } from "$app/state";
|
|
import { goto } from "$app/navigation";
|
|
import * as nip19 from "@nostr/tools/nip19";
|
|
import {
|
|
threadDetailStore,
|
|
loadThread,
|
|
sendReply,
|
|
removeReply,
|
|
type PostData,
|
|
} from "$lib/thread.svelte";
|
|
import { auth, openLogin } from "$lib/auth.svelte";
|
|
import { isGroupAdmin } from "$lib/admins.svelte";
|
|
import { requestDelete } from "$lib/moderation.svelte";
|
|
import { showToast } from "$lib/toast.svelte";
|
|
import { withJoin } from "$lib/join.svelte";
|
|
import { setActiveGroup } from "$lib/active.svelte";
|
|
import { RELAY_URL, MODE } from "$lib/config";
|
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
|
import PostContent from "$lib/components/PostContent.svelte";
|
|
import Tag from "$lib/components/Tag.svelte";
|
|
import { type NostrUser } from "@nostr/gadgets/metadata";
|
|
|
|
type Author = { pubkey: string; name: string; picture?: string };
|
|
|
|
function resolveAuthor(
|
|
pubkey: string,
|
|
profiles: Record<string, NostrUser>,
|
|
): Author {
|
|
const user = profiles[pubkey];
|
|
return {
|
|
pubkey,
|
|
name: user?.shortName ?? pubkey.slice(0, 8),
|
|
picture: user?.metadata.picture,
|
|
};
|
|
}
|
|
|
|
function formatDate(ts: number) {
|
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
let opEl = $state<HTMLElement | null>(null);
|
|
let replyEls = $state<(HTMLElement | null)[]>([]);
|
|
let isScrolled = $state(false);
|
|
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;
|
|
const content = replyContent.trim();
|
|
const pubkey = auth.user.pubkey;
|
|
if (!detail) return;
|
|
replying = true;
|
|
replyError = null;
|
|
try {
|
|
await withJoin(detail.groupId, async () => {
|
|
await sendReply(content, pubkey);
|
|
replyContent = "";
|
|
});
|
|
} catch (e) {
|
|
replyError = e instanceof Error ? e.message : "Failed to post reply";
|
|
} finally {
|
|
replying = false;
|
|
}
|
|
}
|
|
let opTopOffset = $state(0);
|
|
let scrubberVisible = $state(false);
|
|
|
|
const detail = $derived(threadDetailStore.detail);
|
|
const profiles = $derived(threadDetailStore.profiles);
|
|
|
|
const canModerate = $derived(
|
|
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
|
|
);
|
|
|
|
let openMenuId = $state<string | null>(null);
|
|
|
|
function toggleMenu(id: string, e: MouseEvent) {
|
|
e.stopPropagation();
|
|
openMenuId = openMenuId === id ? null : id;
|
|
}
|
|
|
|
// Deleting the OP removes the whole thread, so leave the page; a reply just
|
|
// disappears in place.
|
|
function requestDeletePost(p: PostData, isOp: boolean) {
|
|
if (!detail) return;
|
|
const groupId = detail.groupId;
|
|
openMenuId = null;
|
|
requestDelete(
|
|
{ eventId: p.id, groupId, label: isOp ? "discussion" : "reply" },
|
|
() => {
|
|
if (isOp) {
|
|
showToast("Discussion deleted");
|
|
goto(MODE === "full" ? `/room/${groupId}` : "/");
|
|
} else {
|
|
removeReply(p.id);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!openMenuId) return;
|
|
const close = () => (openMenuId = null);
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") openMenuId = null;
|
|
};
|
|
document.addEventListener("click", close);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("click", close);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
});
|
|
|
|
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(() => {
|
|
if (page.params.id) loadThread(page.params.id);
|
|
});
|
|
|
|
// A thread belongs to its own group; make that the active group so chat and
|
|
// replies target the right room.
|
|
$effect(() => {
|
|
if (detail?.groupId) setActiveGroup(detail.groupId);
|
|
});
|
|
|
|
onMount(() => {
|
|
const main = document.querySelector("main");
|
|
if (!main) return;
|
|
const onScroll = () => {
|
|
isScrolled = main.scrollTop > 0;
|
|
if (selectionTarget) selectionTarget = null;
|
|
};
|
|
main.addEventListener("scroll", onScroll, { passive: true });
|
|
|
|
// On mobile the document scrolls (not main), so clear the quote popup on
|
|
// window scroll too.
|
|
const onWinScroll = () => {
|
|
if (selectionTarget) selectionTarget = null;
|
|
};
|
|
window.addEventListener("scroll", onWinScroll, { 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.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("scroll", onWinScroll);
|
|
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");
|
|
if (!main) return;
|
|
opTopOffset =
|
|
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{detail?.title ?? "Thread"}</title>
|
|
</svelte:head>
|
|
|
|
{#snippet avatar(author: Author)}
|
|
{#if author.picture}
|
|
<img
|
|
src={author.picture}
|
|
alt=""
|
|
class="h-12 w-12 rounded-full object-cover"
|
|
/>
|
|
{:else}
|
|
<span
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-lg font-semibold text-neutral-500 dark:text-neutral-400"
|
|
>
|
|
{author.name[0].toUpperCase()}
|
|
</span>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet post(
|
|
p: PostData,
|
|
index: number,
|
|
bindEl: (el: HTMLElement | null) => void,
|
|
)}
|
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
|
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
|
<div class="hidden flex-shrink-0 md:block">
|
|
{@render avatar(author)}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div
|
|
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<div class="flex-shrink-0 md:hidden">
|
|
{@render avatar(author)}
|
|
</div>
|
|
<span class="truncate font-medium text-neutral-500 dark:text-neutral-400"
|
|
>{author.name}</span
|
|
>
|
|
</div>
|
|
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
|
{#if canModerate}
|
|
<div class="relative">
|
|
<button
|
|
onclick={(e) => toggleMenu(p.id, e)}
|
|
class="flex items-center justify-center rounded p-1 text-neutral-300 dark:text-neutral-600 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-500 dark:hover:text-neutral-400"
|
|
aria-label="Post actions"
|
|
aria-haspopup="menu"
|
|
aria-expanded={openMenuId === p.id}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{#if openMenuId === p.id}
|
|
<div
|
|
role="menu"
|
|
class="absolute top-7 right-0 z-20 w-36 rounded-lg border border-neutral-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 py-1 text-sm shadow-lg"
|
|
>
|
|
<button
|
|
role="menuitem"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
requestDeletePost(p, index === 0);
|
|
}}
|
|
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
|
|
>Delete</button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<span class="text-sm text-neutral-400 dark:text-neutral-500">{formatDate(p.createdAt)}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
|
|
<PostContent content={p.content} {profiles} {threadEventAuthors} />
|
|
</div>
|
|
{#if auth.user}
|
|
<div class="mt-6 flex items-center justify-start">
|
|
<button
|
|
onclick={() => quotePost(p)}
|
|
disabled={!auth.user}
|
|
class="hover:text-brand flex cursor-pointer items-center gap-1.5 text-neutral-300 dark:text-neutral-600 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<span>Quote post</span>
|
|
<svg
|
|
class="w-3"
|
|
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:currentColor;stroke-width:99.57px;"
|
|
/></svg
|
|
>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if detail}
|
|
<div class="flex items-start gap-6">
|
|
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
|
<div
|
|
class="relative z-10 bg-white dark:bg-neutral-900 pb-1 md:sticky md:-top-6 md:-mx-10 md:-mt-6 md:px-10 md:pt-6"
|
|
>
|
|
<a
|
|
href={MODE === "full" ? `/room/${detail.groupId}` : "/"}
|
|
class="hover:text-brand mb-1 inline-flex items-center gap-1 text-sm text-neutral-400 dark:text-neutral-500"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1.8"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M15.75 19.5 8.25 12l7.5-7.5"
|
|
/>
|
|
</svg>
|
|
Discussions
|
|
</a>
|
|
<h1 class="text-brand text-[1.65rem] leading-7">{detail.title}</h1>
|
|
{#if isScrolled}
|
|
<div
|
|
class="pointer-events-none absolute right-0 left-0 h-8 bg-gradient-to-b from-white to-transparent transition-opacity duration-200 dark:from-neutral-900"
|
|
style="top: 100%;"
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if detail.labels.length > 0}
|
|
<div class="mt-1 flex flex-wrap items-center gap-1">
|
|
{#each detail.labels as l}
|
|
<Tag label={l} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="pt-6 pb-6" bind:this={opEl}>
|
|
{@render post(detail.op, 0, () => {})}
|
|
</div>
|
|
|
|
{#if detail.replies.length > 0}
|
|
<div class="divide-y divide-neutral-100 dark:divide-neutral-800 border-t border-neutral-100 dark:border-neutral-800">
|
|
{#each detail.replies as reply, i}
|
|
<div class="py-6" bind:this={replyEls[i]}>
|
|
{@render post(reply, i + 1, () => {})}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-8 border-t border-neutral-200 dark:border-neutral-700 pt-6 md:pl-18">
|
|
{#if auth.user}
|
|
{#if replyError}
|
|
<div
|
|
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
|
>
|
|
{replyError}
|
|
</div>
|
|
{/if}
|
|
<MessageEditor
|
|
bind:this={editorEl}
|
|
bind:value={replyContent}
|
|
disabled={replying}
|
|
rows={8}
|
|
minHeightClass="min-h-[12rem]"
|
|
placeholder="Write a reply..."
|
|
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
|
{threadEventAuthors}
|
|
/>
|
|
<div class="mt-4 flex justify-end">
|
|
<button
|
|
onclick={submitReply}
|
|
disabled={replying || !replyContent.trim()}
|
|
class="bg-brand hover:bg-brand-hover rounded px-6 py-1.5 font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{replying ? "Posting…" : "post reply"}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<p class=" text-center text-neutral-500 dark:text-neutral-400">
|
|
To participate and reply, please
|
|
<button onclick={openLogin} class="text-brand hover:underline"
|
|
>login now</button
|
|
>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<ThreadScrubber
|
|
posts={allPosts}
|
|
{postEls}
|
|
topOffset={opTopOffset}
|
|
bind:visible={scrubberVisible}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500">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="bg-brand hover:bg-brand-hover fixed z-50 -translate-x-1/2 -translate-y-full rounded px-3 py-1 text-xs font-medium text-white shadow-md"
|
|
>
|
|
Quote
|
|
</button>
|
|
{/if}
|