Show full threads with replies

This commit is contained in:
dtonon 2026-04-06 17:19:38 +01:00
parent 75d1d16cf1
commit 994e76313e
3 changed files with 187 additions and 139 deletions

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Post } from "$lib/mock";
type PostLike = { createdAt: number };
type Props = {
posts: Post[];
posts: PostLike[];
postEls: (HTMLElement | null)[];
topOffset: number;
};
@ -52,15 +52,15 @@
: "",
);
function formatShortDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
function formatShortDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",

97
src/lib/thread.svelte.ts Normal file
View file

@ -0,0 +1,97 @@
import { SimplePool } from "@nostr/tools";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { RELAY_URL } from "$lib/config";
import { threads as mockThreads } from "$lib/mock";
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
export type PostData = {
id: string;
pubkey: string;
createdAt: number;
content: string;
};
type ThreadDetail = {
id: string;
title: string;
op: PostData;
replies: PostData[];
};
let detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({});
export const threadDetailStore = {
get detail() { return detail; },
get profiles() { return profiles; },
};
async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return;
const user = await loadNostrUser(pubkey);
profiles[pubkey] = user;
}
function loadMockThread(id: string) {
const t = mockThreads.find((t) => t.id === id);
if (!t) return;
const toUnix = (iso: string) => Math.floor(new Date(iso).getTime() / 1000);
detail = {
id: t.id,
title: t.title,
op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content },
replies: (t.op.replies ?? []).map((r) => ({
id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content,
})),
};
const allAuthors = [t.op.author, ...(t.op.replies ?? []).map((r) => r.author)];
for (const a of allAuthors) {
profiles[a.pubkey] = {
pubkey: a.pubkey, npub: a.pubkey, shortName: a.name, image: a.picture,
metadata: { name: a.name, picture: a.picture },
lastUpdated: 0,
} as NostrUser;
}
}
export async function loadThread(id: string) {
detail = null;
profiles = {};
if (!isNostrId(id)) { await Promise.resolve(); loadMockThread(id); return; }
const pool = new SimplePool();
try {
const [threadEvents, replyEvents] = await Promise.all([
pool.querySync([RELAY_URL], { kinds: [11], ids: [id] }),
pool.querySync([RELAY_URL], { kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
if (!event) return;
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
detail = {
id: event.id,
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
op: {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
},
replies: replies.map((r) => ({
id: r.id,
pubkey: r.pubkey,
createdAt: r.created_at,
content: r.content,
})),
};
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
} finally {
pool.close([RELAY_URL]);
}
}

View file

@ -1,35 +1,53 @@
<script lang="ts">
import { onMount } from "svelte";
import { threads } from "$lib/mock";
import Tag from "$lib/components/Tag.svelte";
import { page } from "$app/state";
import { threadDetailStore, loadThread, type PostData } from "$lib/thread.svelte";
import Reactions from "$lib/components/Reactions.svelte";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import { page } from "$app/state";
import type { NostrUser } from "@nostr/gadgets/metadata";
const thread = $derived(threads.find((t) => t.id === page.params.id));
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)[]>([]);
// Gradient: visible only once the user has scrolled
let isScrolled = $state(false);
// Scrubber: top offset = OP's natural distance from main's top border
let opTopOffset = $state(0);
const allPosts = $derived(
thread ? [thread.op, ...(thread.op.replies ?? [])] : [],
);
const detail = $derived(threadDetailStore.detail);
const profiles = $derived(threadDetailStore.profiles);
const allPosts = $derived(
detail ? [detail.op, ...detail.replies] : []
);
const postEls = $derived([opEl, ...replyEls]);
// Reload when navigating between threads
$effect(() => {
if (page.params.id) loadThread(page.params.id);
});
onMount(() => {
const main = document.querySelector("main");
if (!main) return;
main.classList.add("no-scrollbar");
const onScroll = () => {
isScrolled = main.scrollTop > 0;
};
const onScroll = () => { isScrolled = main.scrollTop > 0; };
main.addEventListener("scroll", onScroll, { passive: true });
return () => {
main.classList.remove("no-scrollbar");
@ -37,7 +55,6 @@
};
});
// Measure OP's natural distance from main's border top (once after DOM is ready)
$effect(() => {
if (!opEl) return;
const main = document.querySelector("main");
@ -45,138 +62,75 @@
opTopOffset =
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
});
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<svelte:head>
<title>{thread?.title ?? "Thread"}</title>
<title>{detail?.title ?? "Thread"}</title>
</svelte:head>
{#if thread}
<div class="flex gap-6 items-start">
<!-- Content column -->
{#snippet avatar(author: Author)}
{#if author.picture}
<img src={author.picture} alt="" class="w-12 h-12 rounded-full object-cover" />
{:else}
<span class="w-12 h-12 flex items-center justify-center rounded-full bg-gray-200 text-lg font-semibold text-gray-500">
{author.name[0].toUpperCase()}
</span>
{/if}
{/snippet}
{#snippet post(p: PostData, 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">
{@render avatar(author)}
</div>
<div class="flex-1 min-w-0">
<!-- Sticky title: -top-6 anchors to the border edge (past the py-6 padding gap) -->
<!-- -mt-6 -mx-10 pull the bg flush to main's edges so nothing bleeds through above/sides -->
<div
class="sticky -top-6 bg-white z-10 pb-4 -mx-10 px-10 pt-6 -mt-6 relative"
>
<h1 class="text-[1.65rem] text-brand leading-7">{thread.title}</h1>
<!-- Gradient: only visible once scrolling starts -->
<div class="flex items-baseline justify-between mb-2">
<span class="font-medium text-gray-600">{author.name}</span>
<span class="text-sm text-gray-400 ml-4 flex-shrink-0">{formatDate(p.createdAt)}</span>
</div>
<div class="prose leading-5 max-w-none text-gray-700">
{#each p.content.split("\n\n") as para}
<p>{para}</p>
{/each}
</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 class="hover:text-brand transition-colors">React</button>
<button class="hover:text-amber-500 transition-colors">⚡ Zap</button>
</div>
</div>
</div>
</div>
{/snippet}
{#if detail}
<div class="flex gap-6 items-start">
<div class="flex-1 min-w-0">
<div class="sticky -top-6 bg-white z-10 pb-4 -mx-10 px-10 pt-6 -mt-6 relative">
<h1 class="text-[1.65rem] text-brand leading-7">{detail.title}</h1>
<div
class="absolute left-0 right-0 h-8 pointer-events-none transition-opacity duration-200"
style="top: 100%; opacity: {isScrolled
? 1
: 0}; background: linear-gradient(to bottom, white, transparent);"
style="top: 100%; opacity: {isScrolled ? 1 : 0}; background: linear-gradient(to bottom, white, transparent);"
></div>
</div>
<!-- Tags: not sticky, scroll away -->
<div class="flex gap-2 pt-3 pb-6">
{#each thread.tags as tag}
<Tag label={tag.label} />
{/each}
<div class="pt-6 pb-6 border-b border-gray-100" bind:this={opEl}>
{@render post(detail.op, () => {})}
</div>
<!-- OP post -->
<div bind:this={opEl} class="pb-6 border-b border-gray-100">
<div class="flex gap-6 items-start">
<div class="flex-shrink-0">
<img
src={thread.op.author.picture}
alt={thread.op.author.name}
class="w-12 h-12 rounded-full"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline justify-between mb-2">
<span class="font-medium text-gray-600"
>{thread.op.author.name}</span
>
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
>{formatDate(thread.op.createdAt)}</span
>
</div>
<div class="prose leading-5 max-w-none text-gray-700">
{#each thread.op.content.split("\n\n") as para}
<p>{para}</p>
{/each}
</div>
<div class="flex items-center justify-between mt-3">
<Reactions
reactions={thread.op.reactions}
zaps={thread.op.zaps}
/>
<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 class="hover:text-brand transition-colors">React</button
>
<button class="hover:text-amber-500 transition-colors"
>⚡ Zap</button
>
</div>
</div>
</div>
</div>
</div>
<!-- Replies -->
{#if thread.op.replies && thread.op.replies.length > 0}
{#if detail.replies.length > 0}
<div class="divide-y divide-gray-100">
{#each thread.op.replies as reply, i}
<div bind:this={replyEls[i]} class="py-6">
<div class="flex gap-6 items-start">
<div class="flex-shrink-0">
<img
src={reply.author.picture}
alt={reply.author.name}
class="w-12 h-12 rounded-full"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline justify-between mb-2">
<span class="font-medium text-gray-600"
>{reply.author.name}</span
>
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
>{formatDate(reply.createdAt)}</span
>
</div>
<p class="text-gray-700 leading-5">{reply.content}</p>
<div class="flex items-center justify-between mt-3">
<Reactions reactions={reply.reactions} zaps={reply.zaps} />
<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 class="hover:text-brand transition-colors"
>React</button
>
<button class="hover:text-amber-500 transition-colors"
>⚡ Zap</button
>
</div>
</div>
</div>
</div>
{#each detail.replies as reply, i}
<div class="py-6" bind:this={replyEls[i]}>
{@render post(reply, () => {})}
</div>
{/each}
</div>
{/if}
<!-- Reply box -->
<div class="mt-8 border-t border-gray-200 pt-6">
<textarea
rows="4"
@ -184,18 +138,15 @@
class="w-full rounded border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand"
></textarea>
<div class="mt-2 flex justify-end">
<button
class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover"
>
<button class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover">
Reply
</button>
</div>
</div>
</div>
<!-- Timeline scrubber -->
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} />
</div>
{:else}
<div class="px-6 py-6 text-gray-500">Thread not found.</div>
<div class="py-12 text-center text-gray-400">Loading…</div>
{/if}