Render discussion listings and the room overview on the server
This commit is contained in:
parent
c23428f904
commit
2041f6b899
10 changed files with 175 additions and 11 deletions
9
src/app.d.ts
vendored
9
src/app.d.ts
vendored
|
|
@ -1,6 +1,11 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// 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";
|
import type { ThreadDetail } from "$lib/forum/thread";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -12,6 +17,8 @@ declare global {
|
||||||
shell?: ForumShell | null;
|
shell?: ForumShell | null;
|
||||||
profiles?: Profiles | null;
|
profiles?: Profiles | null;
|
||||||
thread?: ThreadDetail | null;
|
thread?: ThreadDetail | null;
|
||||||
|
threads?: ThreadsSnapshot | null;
|
||||||
|
overview?: OverviewSnapshot | null;
|
||||||
}
|
}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type { NostrUser } from "$lib/gadgets";
|
||||||
import type { GroupMetadata, GroupSummary } from "./groups";
|
import type { GroupMetadata, GroupSummary } from "./groups";
|
||||||
import type { Resource } from "./resources";
|
import type { Resource } from "./resources";
|
||||||
import type { Partial } from "./partials";
|
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
|
// What the server rendered a page with. Stores treat these as the baseline
|
||||||
// (see the `page.data` fallbacks) until their own live fetch lands.
|
// (see the `page.data` fallbacks) until their own live fetch lands.
|
||||||
|
|
@ -17,6 +19,18 @@ export type ForumShell = {
|
||||||
|
|
||||||
export type Profiles = Record<string, NostrUser>;
|
export type Profiles = Record<string, NostrUser>;
|
||||||
|
|
||||||
|
export type ThreadsSnapshot = ThreadPage & {
|
||||||
|
groupId: string;
|
||||||
|
sort: SortMode;
|
||||||
|
snapshotAt: number;
|
||||||
|
profiles: Profiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverviewSnapshot = Overview & {
|
||||||
|
roomIds: string[];
|
||||||
|
profiles: Profiles;
|
||||||
|
};
|
||||||
|
|
||||||
type Fetch = typeof fetch;
|
type Fetch = typeof fetch;
|
||||||
|
|
||||||
// Read a snapshot endpoint. On the server the response is inlined into the
|
// Read a snapshot endpoint. On the server the response is inlined into the
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
|
@ -17,19 +18,26 @@ let recent = $state<RecentThread[]>([]);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
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 = {
|
export const overviewStore = {
|
||||||
get activity() {
|
get activity() {
|
||||||
return activity;
|
return fallback()?.activity ?? activity;
|
||||||
},
|
},
|
||||||
get admins() {
|
get admins() {
|
||||||
return admins;
|
return fallback()?.admins ?? admins;
|
||||||
},
|
},
|
||||||
get recent() {
|
get recent() {
|
||||||
return recent;
|
return fallback()?.recent ?? recent;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = page.data.overview?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
|
|
@ -58,6 +66,7 @@ export async function loadOverview(roomIds: string[]) {
|
||||||
activity = o.activity;
|
activity = o.activity;
|
||||||
admins = o.admins;
|
admins = o.admins;
|
||||||
recent = o.recent;
|
recent = o.recent;
|
||||||
|
settled = true;
|
||||||
overviewPubkeys(o).forEach(loadProfile);
|
overviewPubkeys(o).forEach(loadProfile);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
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 snapshotAt = 0; // upper time bound, frozen at initial load
|
||||||
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
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 = {
|
export const threadStore = {
|
||||||
get threads() {
|
get threads() {
|
||||||
return threads;
|
if (settled || threads.length > 0) return threads;
|
||||||
|
return fallback()?.threads ?? threads;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = fallback()?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get exhausted() {
|
get exhausted() {
|
||||||
return exhausted;
|
return settled ? exhausted : (fallback()?.done ?? exhausted);
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
|
|
@ -63,6 +83,7 @@ async function runLoad(append: boolean, groupId: string) {
|
||||||
threads = append ? [...threads, ...page.threads] : page.threads;
|
threads = append ? [...threads, ...page.threads] : page.threads;
|
||||||
cursor = page.nextCursor ?? cursor;
|
cursor = page.nextCursor ?? cursor;
|
||||||
exhausted = page.done || page.threads.length === 0;
|
exhausted = page.done || page.threads.length === 0;
|
||||||
|
settled = true;
|
||||||
for (const t of page.threads) {
|
for (const t of page.threads) {
|
||||||
loadProfile(t.authorPubkey);
|
loadProfile(t.authorPubkey);
|
||||||
loadProfile(t.latestPubkey);
|
loadProfile(t.latestPubkey);
|
||||||
|
|
@ -78,6 +99,8 @@ async function runLoad(append: boolean, groupId: string) {
|
||||||
|
|
||||||
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
||||||
sortMode = sort;
|
sortMode = sort;
|
||||||
|
requested = { groupId, sort };
|
||||||
|
settled = false;
|
||||||
threads = [];
|
threads = [];
|
||||||
cursor = null;
|
cursor = null;
|
||||||
exhausted = false;
|
exhausted = false;
|
||||||
|
|
|
||||||
20
src/routes/+page.ts
Normal file
20
src/routes/+page.ts
Normal file
|
|
@ -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<ThreadsSnapshot>(
|
||||||
|
fetch,
|
||||||
|
`/api/threads/${GROUP_ID}${sort}`,
|
||||||
|
);
|
||||||
|
return { threads, profiles: threads?.profiles ?? null };
|
||||||
|
}
|
||||||
|
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||||
|
return { overview, profiles: overview?.profiles ?? null };
|
||||||
|
}
|
||||||
20
src/routes/api/overview/+server.ts
Normal file
20
src/routes/api/overview/+server.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
43
src/routes/api/threads/[group]/+server.ts
Normal file
43
src/routes/api/threads/[group]/+server.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 }) {
|
export async function load({ fetch }) {
|
||||||
const data = await snapshot<{ profiles: Profiles }>(fetch, "/api/contacts");
|
const [contacts, overview] = await Promise.all([
|
||||||
return { profiles: data?.profiles ?? null };
|
snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"),
|
||||||
|
snapshot<OverviewSnapshot>(fetch, "/api/overview"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
overview,
|
||||||
|
profiles: { ...overview?.profiles, ...contacts?.profiles },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/routes/resource/[slug]/+page.ts
Normal file
7
src/routes/resource/[slug]/+page.ts
Normal file
|
|
@ -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<OverviewSnapshot>(fetch, "/api/overview");
|
||||||
|
return { overview, profiles: overview?.profiles ?? null };
|
||||||
|
}
|
||||||
10
src/routes/room/[slug]/+page.ts
Normal file
10
src/routes/room/[slug]/+page.ts
Normal file
|
|
@ -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<ThreadsSnapshot>(
|
||||||
|
fetch,
|
||||||
|
`/api/threads/${params.slug}${sort}`,
|
||||||
|
);
|
||||||
|
return { threads, profiles: threads?.profiles ?? null };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue