Show full threads with replies
This commit is contained in:
parent
75d1d16cf1
commit
994e76313e
3 changed files with 187 additions and 139 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Post } from "$lib/mock";
|
type PostLike = { createdAt: number };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
posts: Post[];
|
posts: PostLike[];
|
||||||
postEls: (HTMLElement | null)[];
|
postEls: (HTMLElement | null)[];
|
||||||
topOffset: number;
|
topOffset: number;
|
||||||
};
|
};
|
||||||
|
|
@ -52,15 +52,15 @@
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
|
||||||
function formatShortDate(iso: string) {
|
function formatShortDate(ts: number) {
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(ts: number) {
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
|
||||||
97
src/lib/thread.svelte.ts
Normal file
97
src/lib/thread.svelte.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,35 +1,53 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { threads } from "$lib/mock";
|
import { page } from "$app/state";
|
||||||
import Tag from "$lib/components/Tag.svelte";
|
import { threadDetailStore, loadThread, type PostData } from "$lib/thread.svelte";
|
||||||
import Reactions from "$lib/components/Reactions.svelte";
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.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 opEl = $state<HTMLElement | null>(null);
|
||||||
let replyEls = $state<(HTMLElement | null)[]>([]);
|
let replyEls = $state<(HTMLElement | null)[]>([]);
|
||||||
|
|
||||||
// Gradient: visible only once the user has scrolled
|
|
||||||
let isScrolled = $state(false);
|
let isScrolled = $state(false);
|
||||||
|
|
||||||
// Scrubber: top offset = OP's natural distance from main's top border
|
|
||||||
let opTopOffset = $state(0);
|
let opTopOffset = $state(0);
|
||||||
|
|
||||||
const allPosts = $derived(
|
const detail = $derived(threadDetailStore.detail);
|
||||||
thread ? [thread.op, ...(thread.op.replies ?? [])] : [],
|
const profiles = $derived(threadDetailStore.profiles);
|
||||||
);
|
|
||||||
|
|
||||||
|
const allPosts = $derived(
|
||||||
|
detail ? [detail.op, ...detail.replies] : []
|
||||||
|
);
|
||||||
const postEls = $derived([opEl, ...replyEls]);
|
const postEls = $derived([opEl, ...replyEls]);
|
||||||
|
|
||||||
|
// Reload when navigating between threads
|
||||||
|
$effect(() => {
|
||||||
|
if (page.params.id) loadThread(page.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
main.classList.add("no-scrollbar");
|
main.classList.add("no-scrollbar");
|
||||||
const onScroll = () => {
|
const onScroll = () => { isScrolled = main.scrollTop > 0; };
|
||||||
isScrolled = main.scrollTop > 0;
|
|
||||||
};
|
|
||||||
main.addEventListener("scroll", onScroll, { passive: true });
|
main.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
main.classList.remove("no-scrollbar");
|
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(() => {
|
$effect(() => {
|
||||||
if (!opEl) return;
|
if (!opEl) return;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
|
|
@ -45,138 +62,75 @@
|
||||||
opTopOffset =
|
opTopOffset =
|
||||||
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{thread?.title ?? "Thread"}</title>
|
<title>{detail?.title ?? "Thread"}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if thread}
|
{#snippet avatar(author: Author)}
|
||||||
<div class="flex gap-6 items-start">
|
{#if author.picture}
|
||||||
<!-- Content column -->
|
<img src={author.picture} alt="" class="w-12 h-12 rounded-full object-cover" />
|
||||||
<div class="flex-1 min-w-0">
|
{:else}
|
||||||
<!-- Sticky title: -top-6 anchors to the border edge (past the py-6 padding gap) -->
|
<span class="w-12 h-12 flex items-center justify-center rounded-full bg-gray-200 text-lg font-semibold text-gray-500">
|
||||||
<!-- -mt-6 -mx-10 pull the bg flush to main's edges so nothing bleeds through above/sides -->
|
{author.name[0].toUpperCase()}
|
||||||
<div
|
</span>
|
||||||
class="sticky -top-6 bg-white z-10 pb-4 -mx-10 px-10 pt-6 -mt-6 relative"
|
{/if}
|
||||||
>
|
{/snippet}
|
||||||
<h1 class="text-[1.65rem] text-brand leading-7">{thread.title}</h1>
|
|
||||||
<!-- Gradient: only visible once scrolling starts -->
|
|
||||||
<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);"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags: not sticky, scroll away -->
|
{#snippet post(p: PostData, bindEl: (el: HTMLElement | null) => void)}
|
||||||
<div class="flex gap-2 pt-3 pb-6">
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||||
{#each thread.tags as tag}
|
<div use:bindEl class="flex gap-6 items-start">
|
||||||
<Tag label={tag.label} />
|
|
||||||
{/each}
|
|
||||||
</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">
|
<div class="flex-shrink-0">
|
||||||
<img
|
{@render avatar(author)}
|
||||||
src={thread.op.author.picture}
|
|
||||||
alt={thread.op.author.name}
|
|
||||||
class="w-12 h-12 rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-baseline justify-between mb-2">
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
<span class="font-medium text-gray-600"
|
<span class="font-medium text-gray-600">{author.name}</span>
|
||||||
>{thread.op.author.name}</span
|
<span class="text-sm text-gray-400 ml-4 flex-shrink-0">{formatDate(p.createdAt)}</span>
|
||||||
>
|
|
||||||
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
|
|
||||||
>{formatDate(thread.op.createdAt)}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="prose leading-5 max-w-none text-gray-700">
|
<div class="prose leading-5 max-w-none text-gray-700">
|
||||||
{#each thread.op.content.split("\n\n") as para}
|
{#each p.content.split("\n\n") as para}
|
||||||
<p>{para}</p>
|
<p>{para}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-3">
|
<div class="flex items-center justify-between mt-3">
|
||||||
<Reactions
|
<Reactions reactions={[]} zaps={0} />
|
||||||
reactions={thread.op.reactions}
|
<div class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0">
|
||||||
zaps={thread.op.zaps}
|
<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
|
<div
|
||||||
class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0"
|
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);"
|
||||||
<button class="hover:text-brand transition-colors">Quote</button
|
></div>
|
||||||
>
|
|
||||||
<button class="hover:text-brand transition-colors">React</button
|
|
||||||
>
|
|
||||||
<button class="hover:text-amber-500 transition-colors"
|
|
||||||
>⚡ Zap</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<div class="pt-6 pb-6 border-b border-gray-100" bind:this={opEl}>
|
||||||
{#if thread.op.replies && thread.op.replies.length > 0}
|
{@render post(detail.op, () => {})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if detail.replies.length > 0}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{#each thread.op.replies as reply, i}
|
{#each detail.replies as reply, i}
|
||||||
<div bind:this={replyEls[i]} class="py-6">
|
<div class="py-6" bind:this={replyEls[i]}>
|
||||||
<div class="flex gap-6 items-start">
|
{@render post(reply, () => {})}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Reply box -->
|
|
||||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
<div class="mt-8 border-t border-gray-200 pt-6">
|
||||||
<textarea
|
<textarea
|
||||||
rows="4"
|
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"
|
class="w-full rounded border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex justify-end">
|
||||||
<button
|
<button class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover">
|
||||||
class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover"
|
|
||||||
>
|
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timeline scrubber -->
|
|
||||||
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} />
|
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue