From 04ef78b8a5ba35992ea8807d86b74e458f4553ed Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 14 May 2026 23:10:26 +0100 Subject: [PATCH] Add sort toggle for discussions (activity / newest) --- src/lib/components/SortToggle.svelte | 118 +++++++++++++++++++++++++++ src/lib/sort.svelte.ts | 22 +++++ src/lib/threads.svelte.ts | 85 +++++++++++++------ src/routes/+page.svelte | 39 +++++++-- 4 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 src/lib/components/SortToggle.svelte create mode 100644 src/lib/sort.svelte.ts diff --git a/src/lib/components/SortToggle.svelte b/src/lib/components/SortToggle.svelte new file mode 100644 index 0000000..de4bfcf --- /dev/null +++ b/src/lib/components/SortToggle.svelte @@ -0,0 +1,118 @@ + + +
+ + + +
diff --git a/src/lib/sort.svelte.ts b/src/lib/sort.svelte.ts new file mode 100644 index 0000000..1a6c3ba --- /dev/null +++ b/src/lib/sort.svelte.ts @@ -0,0 +1,22 @@ +import { browser } from "$app/environment"; +import type { SortMode } from "$lib/threads.svelte"; + +const KEY = "squalk:sort"; + +function read(): SortMode { + if (!browser) return "active"; + return localStorage.getItem(KEY) === "new" ? "new" : "active"; +} + +let pref = $state(read()); + +// Global, remembered across home and rooms; survives reloads. +export const sortPref = { + get value() { + return pref; + }, + set value(v: SortMode) { + pref = v; + if (browser) localStorage.setItem(KEY, v); + }, +}; diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts index 2d225e6..0ef885d 100644 --- a/src/lib/threads.svelte.ts +++ b/src/lib/threads.svelte.ts @@ -9,6 +9,8 @@ const PAGE_SIZE = 30; const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE) const WALK_MAX_ITERS = 12; +export type SortMode = "active" | "new"; + export type ThreadData = { id: string; title: string; @@ -27,8 +29,10 @@ let exhausted = $state(false); let loading = $state(false); let loadingMore = $state(false); -let cursor: number | null = null; // latestAt of the last loaded thread +let sortMode: SortMode = "active"; +let cursor: number | null = null; // sort-key value of the last loaded thread let snapshotAt = 0; // upper time bound, frozen at initial load +let reqId = 0; // supersedes in-flight loads when the sort/group changes export const threadStore = { get threads() { @@ -138,6 +142,38 @@ async function fetchActivitySlice( return { items, nextCursor, done }; } +// Chronological-by-creation slice: just OPs ordered by created_at. No reply +// data is needed to order them (stable cursor), keeping the path cheap; reply +// counts are still attached later via enrichment. +async function fetchNewSlice( + relay: Relay, + groupId: string, + until: number, + exclude: Set, + n: number, +): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> { + const ops = await querySync(relay, { + kinds: [11], + "#h": [groupId], + until, + limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor + }); + ops.sort((a, b) => b.created_at - a.created_at); + const fresh = ops.filter((e) => !exclude.has(e.id)); + const slice = fresh.slice(0, n); + + const items: SliceItem[] = slice.map((op) => ({ + id: op.id, + latestAt: op.created_at, + latestPubkey: op.pubkey, + op, + })); + const done = ops.length < n + 10; + const last = slice[slice.length - 1] ?? ops[ops.length - 1]; + const nextCursor = last ? last.created_at : null; + return { items, nextCursor, done }; +} + // Reply enrichment (exact counts + sampled repliers), bounded by the frozen // snapshot. Isolated so the future creation-by-date view can skip it entirely. async function enrichWithReplies( @@ -218,23 +254,26 @@ async function buildThreads( return out; } -async function fetchInto(append: boolean, groupId: string) { +async function runLoad(append: boolean, groupId: string) { + const id = ++reqId; + if (append) loadingMore = true; + else loading = true; + const relay = await Relay.connect(RELAY_URL); try { const until = append ? (cursor ?? snapshotAt) : snapshotAt; const exclude = new Set(threads.map((t) => t.id)); - const { items, nextCursor, done } = await fetchActivitySlice( - relay, - groupId, - until, - exclude, - PAGE_SIZE, - ); - const built = await buildThreads(relay, groupId, items); + const slice = + sortMode === "new" + ? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE) + : await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE); + const built = await buildThreads(relay, groupId, slice.items); + + if (id !== reqId) return; // Superseded by a newer load — discard results threads = append ? [...threads, ...built] : built; - cursor = nextCursor ?? cursor; - exhausted = done || built.length === 0; + cursor = slice.nextCursor ?? cursor; + exhausted = slice.done || built.length === 0; for (const t of built) { loadProfile(t.authorPubkey); @@ -243,29 +282,23 @@ async function fetchInto(append: boolean, groupId: string) { } } finally { relay.close(); + if (id === reqId) { + if (append) loadingMore = false; + else loading = false; + } } } -export async function loadThreads(groupId: string) { - if (loading) return; - loading = true; +export async function loadThreads(groupId: string, sort: SortMode = "active") { + sortMode = sort; threads = []; cursor = null; exhausted = false; snapshotAt = Math.floor(Date.now() / 1000); - try { - await fetchInto(false, groupId); - } finally { - loading = false; - } + await runLoad(false, groupId); } export async function loadMore(groupId: string) { if (loading || loadingMore || exhausted) return; - loadingMore = true; - try { - await fetchInto(true, groupId); - } finally { - loadingMore = false; - } + await runLoad(true, groupId); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a098af1..8bd36a1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,21 +1,41 @@