by
-

- {#if thread.participants.length > 1}
+ {@render avatar(thread.author, "h-5 w-5 rounded-full object-cover")}
+ {#if thread.repliers.length > 0}
and
- {#each thread.participants
- .filter((p) => p.pubkey !== thread.op.author.pubkey)
- .slice(0, 4) as p}
-

+ {#each thread.repliers as r, i}
+
+ {@render avatar(r, "h-5 w-5 rounded-full object-cover ring-1 ring-white")}
+
{/each}
{/if}
-
-
- {thread.replyCount}
+ {thread.replyCount}
replies
- {score}
+ {thread.score}
score
-

-
{thread.lastActivity}
+ {@render avatar(thread.lastActiveAuthor, "h-5 w-5 rounded-full object-cover")}
+
{thread.lastActivity}
activity
diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts
new file mode 100644
index 0000000..9a81331
--- /dev/null
+++ b/src/lib/threads.svelte.ts
@@ -0,0 +1,105 @@
+import { SimplePool } from "@nostr/tools";
+import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
+import { RELAY_URL, GROUP_ID } from "$lib/config";
+
+export type ThreadData = {
+ id: string;
+ title: string;
+ authorPubkey: string;
+ createdAt: number;
+ replyCount: number;
+ latestAt: number;
+ latestPubkey: string;
+ replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
+};
+
+let threads = $state
([]);
+let profiles = $state>({});
+
+export const threadStore = {
+ get threads() { return threads; },
+ get profiles() { return profiles; },
+};
+
+async function loadProfile(pubkey: string) {
+ if (profiles[pubkey]) return;
+ const user = await loadNostrUser(pubkey);
+ profiles[pubkey] = user;
+}
+
+export async function loadThreads() {
+ const pool = new SimplePool();
+ try {
+ // Step 1: fetch threads
+ const events = await pool.querySync([RELAY_URL], {
+ kinds: [11],
+ "#h": [GROUP_ID],
+ limit: 50,
+ });
+
+ events.sort((a, b) => b.created_at - a.created_at);
+
+ threads = events.map((e) => ({
+ id: e.id,
+ title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
+ authorPubkey: e.pubkey,
+ createdAt: e.created_at,
+ replyCount: 0,
+ latestAt: e.created_at,
+ latestPubkey: e.pubkey,
+ replierPubkeys: [],
+ }));
+
+ events.forEach((e) => loadProfile(e.pubkey));
+
+ if (events.length === 0) return;
+
+ // Step 2: fetch replies for all threads in one request
+ const replies = await pool.querySync([RELAY_URL], {
+ kinds: [1111],
+ "#E": events.map((e) => e.id),
+ });
+
+ // Aggregate per-thread reply data
+ const replyMap = new Map<
+ string,
+ { count: number; latestAt: number; latestPubkey: string; pubkeys: Set }
+ >();
+
+ for (const reply of replies) {
+ const rootId = reply.tags.find((t) => t[0] === "E")?.[1];
+ if (!rootId) continue;
+ if (!replyMap.has(rootId)) {
+ replyMap.set(rootId, {
+ count: 0,
+ latestAt: 0,
+ latestPubkey: reply.pubkey,
+ pubkeys: new Set(),
+ });
+ }
+ const rd = replyMap.get(rootId)!;
+ rd.count++;
+ rd.pubkeys.add(reply.pubkey);
+ if (reply.created_at > rd.latestAt) {
+ rd.latestAt = reply.created_at;
+ rd.latestPubkey = reply.pubkey;
+ }
+ }
+
+ threads = threads.map((t) => {
+ const rd = replyMap.get(t.id);
+ if (!rd) return t;
+ return {
+ ...t,
+ replyCount: rd.count,
+ latestAt: rd.latestAt,
+ latestPubkey: rd.latestPubkey,
+ replierPubkeys: [...rd.pubkeys].slice(0, 4),
+ };
+ });
+
+ replies.forEach((r) => loadProfile(r.pubkey));
+ } finally {
+ pool.close([RELAY_URL]);
+ }
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 378804a..66f3411 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,6 +1,56 @@
@@ -18,7 +68,7 @@