Add sort toggle for discussions (activity / newest)

This commit is contained in:
dtonon 2026-05-14 23:10:26 +01:00
parent 1ef6c66efd
commit 04ef78b8a5
4 changed files with 230 additions and 34 deletions

View file

@ -0,0 +1,118 @@
<script lang="ts">
import { tick } from "svelte";
import type { SortMode } from "$lib/threads.svelte";
type Props = { sort: SortMode };
let { sort }: Props = $props();
const options: { value: SortMode; label: string; href: string }[] = [
{ value: "active", label: "Recent activity", href: "?sort=active" },
{ value: "new", label: "Newest first", href: "?sort=new" },
];
let open = $state(false);
let triggerEl = $state<HTMLButtonElement>();
let menuEl = $state<HTMLDivElement>();
const currentLabel = $derived(
options.find((o) => o.value === sort)?.label ?? options[0].label,
);
async function openMenu() {
open = true;
await tick();
const current =
menuEl?.querySelector<HTMLAnchorElement>('[aria-current="true"]') ??
menuEl?.querySelector<HTMLAnchorElement>("a");
current?.focus();
}
function closeMenu(refocus = false) {
open = false;
if (refocus) triggerEl?.focus();
}
$effect(() => {
if (!open) return;
function onPointer(e: PointerEvent) {
const t = e.target as Node;
if (!triggerEl?.contains(t) && !menuEl?.contains(t)) closeMenu();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
closeMenu(true);
}
}
document.addEventListener("pointerdown", onPointer, true);
document.addEventListener("keydown", onKeydown, true);
return () => {
document.removeEventListener("pointerdown", onPointer, true);
document.removeEventListener("keydown", onKeydown, true);
};
});
</script>
<div class="relative">
<button
bind:this={triggerEl}
type="button"
onclick={() => (open ? closeMenu() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Sort discussions, current: {currentLabel}"
class="flex items-center gap-2 rounded bg-neutral-800 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-700"
>
<svg
class="h-4 w-4"
viewBox="0 0 17 17"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.6285 16.2714V0.728609L12.6287 0.713924C12.6348 0.406347 12.8339 0.135053 13.1267 0.0374353C13.4242 -0.0617326 13.7518 0.0405917 13.94 0.291467L16.8542 4.17716L16.8653 4.19236C17.0936 4.51329 17.0254 4.95951 16.7085 5.19716C16.3916 5.43481 15.9442 5.37533 15.7 5.06636L15.6885 5.05145L14.0857 2.91429V16.2714C14.0857 16.6738 13.7595 17 13.3571 17C12.9547 17 12.6285 16.6738 12.6285 16.2714ZM9.47141 13.6L9.49022 13.6002C9.8839 13.6102 10.2 13.9325 10.2 14.3286C10.2 14.7247 9.8839 15.0469 9.49022 15.0569L9.47141 15.0572H4.61427C4.2119 15.0572 3.8857 14.731 3.8857 14.3286C3.8857 13.9262 4.21189 13.6 4.61427 13.6H9.47141ZM9.47141 8.74288L9.49022 8.7431C9.8839 8.75308 10.2 9.07536 10.2 9.47145C10.2 9.86753 9.8839 10.1898 9.49022 10.1998L9.47141 10.2H2.67143C2.26905 10.2 1.94286 9.87382 1.94286 9.47145C1.94286 9.06907 2.26905 8.74288 2.67143 8.74288H9.47141ZM9.47141 3.88574L9.49022 3.88599C9.8839 3.89597 10.2 4.21822 10.2 4.6143C10.2 5.01039 9.8839 5.33267 9.49022 5.34265L9.47141 5.34287H0.72857C0.326192 5.34287 0 5.01668 0 4.6143C5.28744e-06 4.21193 0.326195 3.88574 0.72857 3.88574H9.47141Z"
/>
</svg>
<span>{currentLabel}</span>
<svg
class="h-3 w-3 opacity-70"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 4.5L6 7.5L9 4.5" />
</svg>
</button>
<div
bind:this={menuEl}
role="menu"
aria-label="Sort discussions"
class="absolute right-0 z-10 mt-1 min-w-48 rounded border border-neutral-200 bg-white py-1 shadow-lg {open
? 'block'
: 'hidden'}"
>
{#each options as o}
<a
href={o.href}
role="menuitem"
aria-current={o.value === sort ? "true" : undefined}
onclick={() => closeMenu()}
class="flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 {o.value ===
sort
? 'font-semibold text-neutral-900'
: 'text-neutral-600'}"
>
<span class="w-4 text-center" aria-hidden="true"
>{o.value === sort ? "✓" : ""}</span
>
{o.label}
</a>
{/each}
</div>
</div>

22
src/lib/sort.svelte.ts Normal file
View file

@ -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<SortMode>(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);
},
};

View file

@ -9,6 +9,8 @@ const PAGE_SIZE = 30;
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE) const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
const WALK_MAX_ITERS = 12; const WALK_MAX_ITERS = 12;
export type SortMode = "active" | "new";
export type ThreadData = { export type ThreadData = {
id: string; id: string;
title: string; title: string;
@ -27,8 +29,10 @@ let exhausted = $state(false);
let loading = $state(false); let loading = $state(false);
let loadingMore = $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 snapshotAt = 0; // upper time bound, frozen at initial load
let reqId = 0; // supersedes in-flight loads when the sort/group changes
export const threadStore = { export const threadStore = {
get threads() { get threads() {
@ -138,6 +142,38 @@ async function fetchActivitySlice(
return { items, nextCursor, done }; 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<string>,
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 // Reply enrichment (exact counts + sampled repliers), bounded by the frozen
// snapshot. Isolated so the future creation-by-date view can skip it entirely. // snapshot. Isolated so the future creation-by-date view can skip it entirely.
async function enrichWithReplies( async function enrichWithReplies(
@ -218,23 +254,26 @@ async function buildThreads(
return out; 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); const relay = await Relay.connect(RELAY_URL);
try { try {
const until = append ? (cursor ?? snapshotAt) : snapshotAt; const until = append ? (cursor ?? snapshotAt) : snapshotAt;
const exclude = new Set(threads.map((t) => t.id)); const exclude = new Set(threads.map((t) => t.id));
const { items, nextCursor, done } = await fetchActivitySlice( const slice =
relay, sortMode === "new"
groupId, ? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE)
until, : await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE);
exclude, const built = await buildThreads(relay, groupId, slice.items);
PAGE_SIZE,
); if (id !== reqId) return; // Superseded by a newer load — discard results
const built = await buildThreads(relay, groupId, items);
threads = append ? [...threads, ...built] : built; threads = append ? [...threads, ...built] : built;
cursor = nextCursor ?? cursor; cursor = slice.nextCursor ?? cursor;
exhausted = done || built.length === 0; exhausted = slice.done || built.length === 0;
for (const t of built) { for (const t of built) {
loadProfile(t.authorPubkey); loadProfile(t.authorPubkey);
@ -243,29 +282,23 @@ async function fetchInto(append: boolean, groupId: string) {
} }
} finally { } finally {
relay.close(); relay.close();
if (id === reqId) {
if (append) loadingMore = false;
else loading = false;
}
} }
} }
export async function loadThreads(groupId: string) { export async function loadThreads(groupId: string, sort: SortMode = "active") {
if (loading) return; sortMode = sort;
loading = true;
threads = []; threads = [];
cursor = null; cursor = null;
exhausted = false; exhausted = false;
snapshotAt = Math.floor(Date.now() / 1000); snapshotAt = Math.floor(Date.now() / 1000);
try { await runLoad(false, groupId);
await fetchInto(false, groupId);
} finally {
loading = false;
}
} }
export async function loadMore(groupId: string) { export async function loadMore(groupId: string) {
if (loading || loadingMore || exhausted) return; if (loading || loadingMore || exhausted) return;
loadingMore = true; await runLoad(true, groupId);
try {
await fetchInto(true, groupId);
} finally {
loadingMore = false;
}
} }

View file

@ -1,21 +1,41 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { import {
threadStore, threadStore,
loadThreads, loadThreads,
loadMore, loadMore,
type ThreadData, type ThreadData,
type SortMode,
} from "$lib/threads.svelte"; } from "$lib/threads.svelte";
import ThreadItem, { import ThreadItem, {
type ThreadRow, type ThreadRow,
type Author, type Author,
} from "$lib/components/ThreadItem.svelte"; } from "$lib/components/ThreadItem.svelte";
import SortToggle from "$lib/components/SortToggle.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte"; import { openDraft } from "$lib/draft.svelte";
import { GROUP_ID } from "$lib/config"; import { GROUP_ID } from "$lib/config";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
onMount(() => loadThreads(GROUP_ID)); function parseSort(v: string | null): SortMode | null {
return v === "new" ? "new" : v === "active" ? "active" : null;
}
// URL param is the explicit override; otherwise fall back to the saved
// preference so the sort survives Home/room navigation.
const urlSort = $derived(parseSort(page.url.searchParams.get("sort")));
const sort = $derived<SortMode>(urlSort ?? sortPref.value);
// Remember any explicit choice that arrives via the URL.
$effect(() => {
if (urlSort) sortPref.value = urlSort;
});
// Re-runs on mount and whenever the effective sort changes.
$effect(() => {
loadThreads(GROUP_ID, sort);
});
function onNewTopic() { function onNewTopic() {
if (!auth.user) { if (!auth.user) {
@ -72,12 +92,15 @@
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<h1 class="text-[1.65rem] text-brand">Discussions</h1> <h1 class="text-[1.65rem] text-brand">Discussions</h1>
<div class="flex items-center gap-2">
<button <button
onclick={onNewTopic} onclick={onNewTopic}
class="rounded bg-brand px-6 py-1.5 text-sm font-medium text-white hover:bg-brand-hover" class="rounded bg-brand px-6 py-1.5 text-sm font-medium text-white hover:bg-brand-hover"
> >
New Topic New Topic
</button> </button>
<SortToggle {sort} />
</div>
</div> </div>
<div> <div>