diff --git a/src/app.d.ts b/src/app.d.ts index 53556b4..be7d5b8 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,11 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces -import type { ForumShell, Profiles } from "$lib/forum/snapshot"; +import type { + ForumShell, + OverviewSnapshot, + Profiles, + ThreadsSnapshot, +} from "$lib/forum/snapshot"; import type { ThreadDetail } from "$lib/forum/thread"; declare global { @@ -12,6 +17,8 @@ declare global { shell?: ForumShell | null; profiles?: Profiles | null; thread?: ThreadDetail | null; + threads?: ThreadsSnapshot | null; + overview?: OverviewSnapshot | null; } // interface PageState {} // interface Platform {} diff --git a/src/lib/forum/snapshot.ts b/src/lib/forum/snapshot.ts index 2ae6904..5a667a4 100644 --- a/src/lib/forum/snapshot.ts +++ b/src/lib/forum/snapshot.ts @@ -3,6 +3,8 @@ import type { NostrUser } from "$lib/gadgets"; import type { GroupMetadata, GroupSummary } from "./groups"; import type { Resource } from "./resources"; import type { Partial } from "./partials"; +import type { SortMode, ThreadPage } from "./threads"; +import type { Overview } from "./overview"; // What the server rendered a page with. Stores treat these as the baseline // (see the `page.data` fallbacks) until their own live fetch lands. @@ -17,6 +19,18 @@ export type ForumShell = { export type Profiles = Record; +export type ThreadsSnapshot = ThreadPage & { + groupId: string; + sort: SortMode; + snapshotAt: number; + profiles: Profiles; +}; + +export type OverviewSnapshot = Overview & { + roomIds: string[]; + profiles: Profiles; +}; + type Fetch = typeof fetch; // Read a snapshot endpoint. On the server the response is inlined into the diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts index 0dcfeac..e1e75fc 100644 --- a/src/lib/overview.svelte.ts +++ b/src/lib/overview.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { loadNostrUser, type NostrUser } from "$lib/gadgets"; import { ensureForumRelay } from "$lib/relay"; import { ingestNostrUser } from "$lib/profiles.svelte"; @@ -17,19 +18,26 @@ let recent = $state([]); let profiles = $state>({}); let loading = $state(false); let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches +let settled = $state(false); // first live load finished + +// Until the live fetch lands, the server snapshot (if any) stands in. +function fallback() { + return settled ? null : (page.data.overview ?? null); +} export const overviewStore = { get activity() { - return activity; + return fallback()?.activity ?? activity; }, get admins() { - return admins; + return fallback()?.admins ?? admins; }, get recent() { - return recent; + return fallback()?.recent ?? recent; }, get profiles() { - return profiles; + const base = page.data.overview?.profiles; + return base ? { ...base, ...profiles } : profiles; }, get loading() { return loading; @@ -58,6 +66,7 @@ export async function loadOverview(roomIds: string[]) { activity = o.activity; admins = o.admins; recent = o.recent; + settled = true; overviewPubkeys(o).forEach(loadProfile); } finally { loading = false; diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts index 1ce3c1e..af32a97 100644 --- a/src/lib/threads.svelte.ts +++ b/src/lib/threads.svelte.ts @@ -1,3 +1,4 @@ +import { page } from "$app/state"; import { loadNostrUser, type NostrUser } from "$lib/gadgets"; import { ensureForumRelay } from "$lib/relay"; import { ingestNostrUser } from "$lib/profiles.svelte"; @@ -21,15 +22,34 @@ 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 +// Which listing the client asked for, and whether its first page is in. +let requested = $state<{ groupId: string; sort: SortMode } | null>(null); +let settled = $state(false); + +// The server snapshot stands in until the live listing lands, but only for +// the listing that was requested: a different room or sort shows "Loading…". +function fallback() { + const s = page.data.threads; + if (!s) return null; + if ( + requested && + (requested.groupId !== s.groupId || requested.sort !== s.sort) + ) + return null; + return s; +} + export const threadStore = { get threads() { - return threads; + if (settled || threads.length > 0) return threads; + return fallback()?.threads ?? threads; }, get profiles() { - return profiles; + const base = fallback()?.profiles; + return base ? { ...base, ...profiles } : profiles; }, get exhausted() { - return exhausted; + return settled ? exhausted : (fallback()?.done ?? exhausted); }, get loading() { return loading; @@ -63,6 +83,7 @@ async function runLoad(append: boolean, groupId: string) { threads = append ? [...threads, ...page.threads] : page.threads; cursor = page.nextCursor ?? cursor; exhausted = page.done || page.threads.length === 0; + settled = true; for (const t of page.threads) { loadProfile(t.authorPubkey); loadProfile(t.latestPubkey); @@ -78,6 +99,8 @@ async function runLoad(append: boolean, groupId: string) { export async function loadThreads(groupId: string, sort: SortMode = "active") { sortMode = sort; + requested = { groupId, sort }; + settled = false; threads = []; cursor = null; exhausted = false; diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 0000000..41bda5f --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1,20 @@ +import { MODE, GROUP_ID } from "$lib/config"; +import { + snapshot, + type OverviewSnapshot, + type ThreadsSnapshot, +} from "$lib/forum/snapshot"; + +// Simple mode: the single group's listing. Full mode: the room overview. +export async function load({ fetch, url }) { + if (MODE === "simple") { + const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : ""; + const threads = await snapshot( + fetch, + `/api/threads/${GROUP_ID}${sort}`, + ); + return { threads, profiles: threads?.profiles ?? null }; + } + const overview = await snapshot(fetch, "/api/overview"); + return { overview, profiles: overview?.profiles ?? null }; +} diff --git a/src/routes/api/overview/+server.ts b/src/routes/api/overview/+server.ts new file mode 100644 index 0000000..a6171d6 --- /dev/null +++ b/src/routes/api/overview/+server.ts @@ -0,0 +1,20 @@ +import { json } from "@sveltejs/kit"; +import { MODE, GROUP_ID } from "$lib/config"; +import { fetchGroups } from "$lib/forum/groups"; +import { fetchOverview, overviewPubkeys } from "$lib/forum/overview"; +import { fetchProfiles } from "$lib/forum/profiles"; +import type { OverviewSnapshot } from "$lib/forum/snapshot"; +import { forumQuery, profileQuery } from "$lib/ssr/relay"; + +// Room activity and the latest discussions: every room in full mode, the +// single group in simple mode (mirrors LatestDiscussions' own room set). +export async function GET() { + const roomIds = + MODE === "full" + ? (await fetchGroups(forumQuery)).map((g) => g.id) + : [GROUP_ID]; + const overview = await fetchOverview(forumQuery, roomIds); + const profiles = await fetchProfiles(profileQuery, overviewPubkeys(overview)); + const body: OverviewSnapshot = { roomIds, ...overview, profiles }; + return json(body); +} diff --git a/src/routes/api/threads/[group]/+server.ts b/src/routes/api/threads/[group]/+server.ts new file mode 100644 index 0000000..4ed8fa8 --- /dev/null +++ b/src/routes/api/threads/[group]/+server.ts @@ -0,0 +1,43 @@ +import { error, json } from "@sveltejs/kit"; +import { MODE, GROUP_ID } from "$lib/config"; +import { fetchGroups } from "$lib/forum/groups"; +import { + fetchThreadPage, + threadPagePubkeys, + type SortMode, +} from "$lib/forum/threads"; +import { fetchProfiles } from "$lib/forum/profiles"; +import type { ThreadsSnapshot } from "$lib/forum/snapshot"; +import { forumQuery, profileQuery } from "$lib/ssr/relay"; + +// First page of a group's listing. Unknown rooms are a 404 so crawlers don't +// index empty shells; private rooms are simply empty to the anonymous server. +export async function GET({ params, url }) { + const groupId = params.group; + if (MODE === "simple") { + if (groupId !== GROUP_ID) error(404, "Not found"); + } else { + const groups = await fetchGroups(forumQuery); + if (!groups.some((g) => g.id === groupId)) error(404, "Not found"); + } + const sort: SortMode = + url.searchParams.get("sort") === "new" ? "new" : "active"; + const snapshotAt = Math.floor(Date.now() / 1000); + const page = await fetchThreadPage(forumQuery, groupId, { + sort, + until: snapshotAt, + snapshotAt, + }); + const profiles = await fetchProfiles( + profileQuery, + threadPagePubkeys(page.threads), + ); + const body: ThreadsSnapshot = { + groupId, + sort, + snapshotAt, + ...page, + profiles, + }; + return json(body); +} diff --git a/src/routes/contacts/+page.ts b/src/routes/contacts/+page.ts index 6521598..30c78ca 100644 --- a/src/routes/contacts/+page.ts +++ b/src/routes/contacts/+page.ts @@ -1,6 +1,17 @@ -import { snapshot, type Profiles } from "$lib/forum/snapshot"; +import { + snapshot, + type OverviewSnapshot, + type Profiles, +} from "$lib/forum/snapshot"; +// Admin profiles for the cards, plus the latest-discussions panel beside them. export async function load({ fetch }) { - const data = await snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"); - return { profiles: data?.profiles ?? null }; + const [contacts, overview] = await Promise.all([ + snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"), + snapshot(fetch, "/api/overview"), + ]); + return { + overview, + profiles: { ...overview?.profiles, ...contacts?.profiles }, + }; } diff --git a/src/routes/resource/[slug]/+page.ts b/src/routes/resource/[slug]/+page.ts new file mode 100644 index 0000000..7b54f01 --- /dev/null +++ b/src/routes/resource/[slug]/+page.ts @@ -0,0 +1,7 @@ +import { snapshot, type OverviewSnapshot } from "$lib/forum/snapshot"; + +// Articles show the latest-discussions panel next to the text. +export async function load({ fetch }) { + const overview = await snapshot(fetch, "/api/overview"); + return { overview, profiles: overview?.profiles ?? null }; +} diff --git a/src/routes/room/[slug]/+page.ts b/src/routes/room/[slug]/+page.ts new file mode 100644 index 0000000..419b0df --- /dev/null +++ b/src/routes/room/[slug]/+page.ts @@ -0,0 +1,10 @@ +import { snapshot, type ThreadsSnapshot } from "$lib/forum/snapshot"; + +export async function load({ fetch, params, url }) { + const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : ""; + const threads = await snapshot( + fetch, + `/api/threads/${params.slug}${sort}`, + ); + return { threads, profiles: threads?.profiles ?? null }; +}