Render the forum shell, resources and contacts on the server
This commit is contained in:
parent
a7b5d8f223
commit
40e0be35d9
16 changed files with 259 additions and 30 deletions
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
|
|
@ -1,10 +1,16 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { ForumShell, Profiles } from "$lib/forum/snapshot";
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// Server-rendered snapshots; null in the browser and in client-only builds
|
||||
interface PageData {
|
||||
shell?: ForumShell | null;
|
||||
profiles?: Profiles | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { page } from "$app/state";
|
||||
import { MODE } from "$lib/config";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
|
|
@ -8,12 +9,13 @@ let byRoom = $state<Record<string, string[]>>({});
|
|||
let loaded = $state(false);
|
||||
let loadedKey = "";
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const roomAdminsStore = {
|
||||
get byRoom() {
|
||||
return byRoom;
|
||||
return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {});
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,11 +24,11 @@ export const roomAdminsStore = {
|
|||
export const adminPubkeys = {
|
||||
get list(): string[] {
|
||||
return MODE === "full"
|
||||
? [...new Set(Object.values(byRoom).flat())]
|
||||
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
|
||||
: (groupStore.data?.admins ?? []);
|
||||
},
|
||||
get loaded(): boolean {
|
||||
return MODE === "full" ? loaded : groupStore.loaded;
|
||||
return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -38,7 +40,7 @@ export function isGroupAdmin(
|
|||
): boolean {
|
||||
if (!pubkey || !groupId) return false;
|
||||
return MODE === "full"
|
||||
? (byRoom[groupId]?.includes(pubkey) ?? false)
|
||||
? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false)
|
||||
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import * as nip19 from "@nostr/tools/nip19";
|
|||
import type { NostrUser } from "$lib/gadgets";
|
||||
import type { Query } from "./query";
|
||||
|
||||
// Public relays with broad kind-0 coverage.
|
||||
export const PROFILE_RELAYS = [
|
||||
"wss://purplepag.es",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
// Same shape @nostr/gadgets produces, built here from raw kind-0 events so the
|
||||
// server can resolve authors without that (browser-only) library.
|
||||
export function bareUser(pubkey: string): NostrUser {
|
||||
|
|
|
|||
46
src/lib/forum/snapshot.ts
Normal file
46
src/lib/forum/snapshot.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { browser } from "$app/environment";
|
||||
import type { NostrUser } from "$lib/gadgets";
|
||||
import type { GroupMetadata, GroupSummary } from "./groups";
|
||||
import type { Resource } from "./resources";
|
||||
import type { Partial } from "./partials";
|
||||
|
||||
// What the server rendered a page with. Stores treat these as the baseline
|
||||
// (see the `page.data` fallbacks) until their own live fetch lands.
|
||||
export type ForumShell = {
|
||||
group: GroupMetadata | null; // simple mode
|
||||
groups: GroupSummary[]; // full mode
|
||||
adminsByRoom: Record<string, string[]>; // full mode
|
||||
admins: string[]; // the trust gate for resources and partials
|
||||
resources: Resource[];
|
||||
partials: Partial[];
|
||||
};
|
||||
|
||||
export type Profiles = Record<string, NostrUser>;
|
||||
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
// Read a snapshot endpoint. On the server the response is inlined into the
|
||||
// HTML; in the browser it is consumed only while hydrating that HTML. Client
|
||||
// navigations get null, leaving the stores to fetch live (and authenticated)
|
||||
// data as they always did — the same path a client-only build takes.
|
||||
export async function snapshot<T>(
|
||||
fetch: Fetch,
|
||||
url: string,
|
||||
): Promise<T | null> {
|
||||
if (browser && !hasInlinedResponse(url)) return null;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// SvelteKit inlines each SSR fetch as a script tag keyed by URL and removes
|
||||
// it once replayed, so its presence marks the hydration pass.
|
||||
function hasInlinedResponse(url: string): boolean {
|
||||
return !!document.querySelector(
|
||||
`script[data-sveltekit-fetched][data-url=${JSON.stringify(url)}]`,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { page } from "$app/state";
|
||||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
|
|
@ -15,12 +16,13 @@ export type GroupFlags = {
|
|||
let group = $state<GroupMetadata | null>(null);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const groupStore = {
|
||||
get data() {
|
||||
return group;
|
||||
return group ?? page.data.shell?.group ?? null;
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { page } from "$app/state";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
|
||||
|
||||
|
|
@ -6,12 +7,13 @@ export type { GroupSummary };
|
|||
let list = $state<GroupSummary[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const groupsStore = {
|
||||
get list() {
|
||||
return list;
|
||||
return loaded ? list : (page.data.shell?.groups ?? []);
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { page } from "$app/state";
|
||||
import { adminPubkeys } from "$lib/admins.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import {
|
||||
|
|
@ -12,12 +13,14 @@ export type { Partial, PartialSlot };
|
|||
let all = $state<Partial[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const partialsStore = {
|
||||
get(slot: PartialSlot): Partial | undefined {
|
||||
return pickPartial(all, slot, adminPubkeys.list);
|
||||
const source = loaded ? all : (page.data.shell?.partials ?? []);
|
||||
return pickPartial(source, slot, adminPubkeys.list);
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
|||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { GROUP_ID } from "$lib/config";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||
|
||||
export type ProfileEntry = {
|
||||
pubkey: string;
|
||||
|
|
@ -27,12 +28,6 @@ const SEARCH_RELAYS = [
|
|||
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
||||
// never has these — they live on each author's outbox relays — so we query
|
||||
// a small set of well-known aggregators.
|
||||
const PROFILE_RELAYS = [
|
||||
"wss://purplepag.es",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
const FETCH_BATCH_SIZE = 500;
|
||||
|
||||
const profiles = new SvelteMap<string, ProfileEntry>();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { page } from "$app/state";
|
||||
import { adminPubkeys } from "$lib/admins.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { fetchResources, type Resource } from "$lib/forum/resources";
|
||||
|
|
@ -8,14 +9,16 @@ let all = $state<Resource[]>([]);
|
|||
let loaded = $state(false);
|
||||
|
||||
// Only admin-authored resources are surfaced: the relay query is open, so the
|
||||
// admin set is the trust gate.
|
||||
// admin set is the trust gate. Until the live fetch lands, the server
|
||||
// snapshot (if any) stands in.
|
||||
export const resourcesStore = {
|
||||
get list() {
|
||||
const admins = new Set(adminPubkeys.list);
|
||||
return all.filter((r) => admins.has(r.pubkey));
|
||||
const source = loaded ? all : (page.data.shell?.resources ?? []);
|
||||
return source.filter((r) => admins.has(r.pubkey));
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
74
src/lib/ssr/relay.ts
Normal file
74
src/lib/ssr/relay.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { SimplePool } from "@nostr/tools";
|
||||
import type { Event } from "@nostr/tools/core";
|
||||
import type { Filter } from "@nostr/tools/filter";
|
||||
import { RELAY_URL } from "$lib/config";
|
||||
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||
import type { Query } from "$lib/forum/query";
|
||||
|
||||
// Anonymous relay access for server rendering. The server has no signer, so
|
||||
// it only ever sees what the relay serves the public; members get their
|
||||
// private rooms once the client takes over. Every query is bounded so a slow
|
||||
// relay degrades to an empty page, never a hung request, and results are
|
||||
// cached briefly to absorb crawler bursts.
|
||||
const QUERY_TIMEOUT = 2500;
|
||||
const PROFILE_TIMEOUT = 1500;
|
||||
const CACHE_TTL = 30_000;
|
||||
const CACHE_MAX = 2000;
|
||||
|
||||
const pool = new SimplePool();
|
||||
const cache = new Map<string, { at: number; result: Promise<Event[]> }>();
|
||||
|
||||
// Resolve at EOSE from every relay or at the deadline, whichever comes first,
|
||||
// with whatever arrived: a stalled relay must not cost the events the others
|
||||
// already delivered.
|
||||
function boundedQuery(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
maxWait: number,
|
||||
): Promise<Event[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: Event[] = [];
|
||||
let done = false;
|
||||
let sub: { close(): void } | null = null;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
sub?.close();
|
||||
resolve(events);
|
||||
};
|
||||
const timer = setTimeout(finish, maxWait);
|
||||
try {
|
||||
sub = pool.subscribeMany(relays, filter, {
|
||||
onevent: (e) => events.push(e),
|
||||
oneose: finish,
|
||||
});
|
||||
if (done) sub.close(); // EOSE fired synchronously
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cachedQuery(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
maxWait: number,
|
||||
): Promise<Event[]> {
|
||||
const key = relays.join(",") + "|" + JSON.stringify(filter);
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && now - hit.at < CACHE_TTL) return hit.result;
|
||||
if (cache.size >= CACHE_MAX) cache.clear();
|
||||
const result = boundedQuery(relays, filter, maxWait);
|
||||
cache.set(key, { at: now, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
export const forumQuery: Query = (filter) =>
|
||||
cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT);
|
||||
|
||||
// Profiles mostly live off the forum relay; ask it too for members who only
|
||||
// published there.
|
||||
export const profileQuery: Query = (filter) =>
|
||||
cachedQuery([RELAY_URL, ...PROFILE_RELAYS], filter, PROFILE_TIMEOUT);
|
||||
29
src/lib/ssr/shell.ts
Normal file
29
src/lib/ssr/shell.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroup, fetchGroups, fetchRoomAdmins } from "$lib/forum/groups";
|
||||
import { fetchResources } from "$lib/forum/resources";
|
||||
import { fetchPartials } from "$lib/forum/partials";
|
||||
import type { ForumShell } from "$lib/forum/snapshot";
|
||||
import { forumQuery } from "./relay";
|
||||
|
||||
// Everything the layout needs on every page: the group(s), the admin set,
|
||||
// sidebar resources and partials.
|
||||
export async function loadShell(): Promise<ForumShell> {
|
||||
const [group, groups, resources, partials] = await Promise.all([
|
||||
MODE === "simple" ? fetchGroup(forumQuery, GROUP_ID) : null,
|
||||
MODE === "full" ? fetchGroups(forumQuery) : [],
|
||||
fetchResources(forumQuery),
|
||||
fetchPartials(forumQuery),
|
||||
]);
|
||||
const adminsByRoom =
|
||||
MODE === "full"
|
||||
? await fetchRoomAdmins(
|
||||
forumQuery,
|
||||
groups.map((g) => g.id),
|
||||
)
|
||||
: {};
|
||||
const admins =
|
||||
MODE === "full"
|
||||
? [...new Set(Object.values(adminsByRoom).flat())]
|
||||
: (group?.admins ?? []);
|
||||
return { group, groups, adminsByRoom, admins, resources, partials };
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
import { SSR_ENABLED } from "$lib/config";
|
||||
import { snapshot, type ForumShell } from "$lib/forum/snapshot";
|
||||
|
||||
export const ssr = SSR_ENABLED;
|
||||
|
||||
export async function load({ fetch }) {
|
||||
return { shell: await snapshot<ForumShell>(fetch, "/api/shell") };
|
||||
}
|
||||
|
|
|
|||
9
src/routes/api/contacts/+server.ts
Normal file
9
src/routes/api/contacts/+server.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import { fetchProfiles } from "$lib/forum/profiles";
|
||||
import { loadShell } from "$lib/ssr/shell";
|
||||
import { profileQuery } from "$lib/ssr/relay";
|
||||
|
||||
export async function GET() {
|
||||
const shell = await loadShell();
|
||||
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
|
||||
}
|
||||
6
src/routes/api/shell/+server.ts
Normal file
6
src/routes/api/shell/+server.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import { loadShell } from "$lib/ssr/shell";
|
||||
|
||||
export async function GET() {
|
||||
return json(await loadShell());
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
} from "$lib/profiles.svelte";
|
||||
import { partialsStore } from "$lib/partials.svelte";
|
||||
import PostContent from "$lib/components/PostContent.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const partial = $derived(partialsStore.get("contacts"));
|
||||
|
||||
|
|
@ -48,11 +49,30 @@
|
|||
|
||||
const contacts = $derived<Contact[]>(
|
||||
adminPubkeys.list.map((pk) => {
|
||||
const entry = profileStore.profiles.get(pk);
|
||||
const entry = profileStore.profiles.get(pk) ?? snapshotEntry(pk);
|
||||
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
||||
}),
|
||||
);
|
||||
|
||||
// Server-rendered profile, used until the live one is fetched.
|
||||
function snapshotEntry(pk: string): ProfileEntry | undefined {
|
||||
const u = page.data.profiles?.[pk];
|
||||
if (!u) return undefined;
|
||||
const md = u.metadata ?? {};
|
||||
return {
|
||||
pubkey: pk,
|
||||
npub: u.npub,
|
||||
name: md.name,
|
||||
displayName: md.display_name,
|
||||
nip05: md.nip05,
|
||||
picture: md.picture ?? u.image,
|
||||
about: md.about,
|
||||
website: md.website,
|
||||
lud16: md.lud16,
|
||||
fetchedAt: u.lastUpdated || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function displayName(c: Contact): string {
|
||||
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
||||
}
|
||||
|
|
@ -81,14 +101,18 @@
|
|||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||
Loading…
|
||||
</p>
|
||||
{:else if contacts.length === 0}
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">No admins listed.</p>
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||
No admins listed.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-3">
|
||||
{#each contacts as c (c.pubkey)}
|
||||
<li
|
||||
class="rounded-lg border border-neutral-100 dark:border-neutral-800 p-4 shadow-sm"
|
||||
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
{#if c.entry?.picture}
|
||||
|
|
@ -99,7 +123,7 @@
|
|||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-lg font-semibold text-neutral-500 dark:text-neutral-400"
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
{displayName(c)[0].toUpperCase()}
|
||||
</span>
|
||||
|
|
@ -107,7 +131,9 @@
|
|||
|
||||
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<p
|
||||
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{displayName(c)}
|
||||
</p>
|
||||
{#if c.entry?.nip05}
|
||||
|
|
@ -129,7 +155,9 @@
|
|||
|
||||
<div class="mt-3 md:pl-18">
|
||||
{#if c.entry?.about}
|
||||
<p class="whitespace-pre-line text-neutral-600 dark:text-neutral-400">
|
||||
<p
|
||||
class="whitespace-pre-line text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{c.entry.about}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -147,7 +175,10 @@
|
|||
</a>
|
||||
{/if}
|
||||
{#if c.entry?.lud16}
|
||||
<span class="text-neutral-500 dark:text-neutral-400" title="Lightning address">
|
||||
<span
|
||||
class="text-neutral-500 dark:text-neutral-400"
|
||||
title="Lightning address"
|
||||
>
|
||||
⚡ {c.entry.lud16}
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -158,7 +189,9 @@
|
|||
{@const rooms = roomsOf(c.pubkey)}
|
||||
{#if rooms.length > 0}
|
||||
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||
<span class="text-neutral-400 dark:text-neutral-500">Manages:</span>
|
||||
<span class="text-neutral-400 dark:text-neutral-500"
|
||||
>Manages:</span
|
||||
>
|
||||
{#each rooms as r, i}<a
|
||||
href="/room/{r.id}"
|
||||
class="text-accent hover:underline">{r.name}</a
|
||||
|
|
|
|||
6
src/routes/contacts/+page.ts
Normal file
6
src/routes/contacts/+page.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { snapshot, type Profiles } from "$lib/forum/snapshot";
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const data = await snapshot<{ profiles: Profiles }>(fetch, "/api/contacts");
|
||||
return { profiles: data?.profiles ?? null };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue