Compare commits

...
Sign in to create a new pull request.

49 commits

Author SHA1 Message Date
sakrecoer
fc28e20811 personal modifications 2026-09-22 15:21:51 +02:00
dtonon
0c7ada6d5b Add LICENSE 2026-09-22 12:05:24 +02:00
dtonon
1b31695d27 Add credits + hosting relay in the footer 2026-09-21 17:14:02 +02:00
dtonon
9872f33185 Scroll to newest chat messages in mobile 2026-09-21 12:20:55 +02:00
dtonon
9ac14fc428 Fix chat input on mobile 2026-09-21 12:20:55 +02:00
dtonon
53b4865de1 Apply soft wrap to threads 2026-09-21 12:20:55 +02:00
dtonon
272c7d5ce6 Fix newlines rendering 2026-09-21 12:20:55 +02:00
dtonon
d4e644a964 Optimize bunker connections on mobile 2026-09-21 12:20:55 +02:00
dtonon
0f487eb33a Add a "Share link" action to posts 2026-08-28 21:35:47 +01:00
dtonon
bfad456d82 Fix og:title for the homepage 2026-08-28 17:28:13 +01:00
dtonon
8bacf1ccbf Make the favicon configurable via PUBLIC_FAVICON 2026-08-28 14:52:33 +01:00
dtonon
997184cd66 Fix thread scrubber anchoring 2026-08-27 22:50:03 +01:00
dtonon
7b80afbd6c Show "Seen on" in profiles 2026-08-27 21:04:04 +01:00
dtonon
9950c4337a Hide quoted text in replies preview 2026-08-27 18:55:30 +01:00
dtonon
c793203d9f Scroll to the reply when accessing it from profile 2026-08-26 18:48:55 +01:00
dtonon
a9b0702c68 Paginate last post in user profile 2026-08-26 15:08:13 +01:00
dtonon
f40680cbf9 Add the profile page for users 2026-08-26 14:48:41 +01:00
dtonon
bcbc70ecb8 Add sign-up via Nstart 2026-08-25 22:24:46 +01:00
dtonon
03395adf85 Implement login via bunker 2026-08-25 18:47:11 +01:00
dtonon
f734ff030c Personalize UI for rooms with few discussions 2026-08-25 16:28:55 +01:00
dtonon
23ef212320 Personalize UI for empty rooms 2026-08-24 19:09:37 +01:00
dtonon
39d8d920b6 Update and generalize the deploy workflow 2026-08-24 14:45:49 +01:00
dtonon
17f53946f1 Let the group join decide when a refused relay join matters 2026-08-24 13:48:38 +01:00
dtonon
7e98cb2575 Don't pass the click event as the login continuation in the chat sidebar 2026-08-21 18:43:19 +01:00
dtonon
2cda3acedb Close relay sockets on shutdown so the server exits on SIGTERM 2026-08-21 14:00:21 +01:00
dtonon
b4254ebe23 Override lightningcss to 1.33 so ::highlight() no longer warns at build 2026-08-21 11:45:28 +01:00
dtonon
d4c0192dcf Serve stale snapshots while refreshing, with cache windows from the env 2026-08-20 16:29:19 +01:00
dtonon
5329c611b1 Warm the server cache from client-side navigations, PUBLIC_SSR_WARM opts out 2026-08-20 14:42:48 +01:00
dtonon
7883e6df8a Cache server snapshots for 5 minutes and profiles for an hour 2026-08-20 11:20:04 +01:00
dtonon
9ab145f816 Skip relays that fail to connect when rendering on the server 2026-08-19 18:32:26 +01:00
dtonon
018c72021f Document the reverse proxy for the server-rendered deploy 2026-08-19 12:33:47 +01:00
dtonon
5c455bfd50 Run the remote deploy step in a login shell 2026-08-19 12:05:10 +01:00
dtonon
7f0d823d6c Bake PUBLIC_SSR into the build instead of reading it at runtime 2026-08-18 22:31:56 +01:00
dtonon
3b79271874 Add build and deploy recipes for the server-rendered target 2026-08-18 21:53:29 +01:00
dtonon
fc7c749856 Add cache headers, real 404s, social meta, JSON-LD, robots and sitemap 2026-08-18 17:49:01 +01:00
dtonon
2041f6b899 Render discussion listings and the room overview on the server 2026-08-17 22:05:12 +01:00
dtonon
c23428f904 Render thread pages on the server 2026-08-17 18:29:26 +01:00
dtonon
40e0be35d9 Render the forum shell, resources and contacts on the server 2026-08-17 13:25:44 +01:00
dtonon
a7b5d8f223 Extract relay-agnostic forum fetchers from the stores 2026-08-14 22:27:04 +01:00
dtonon
54c0682dce Add an opt-in SSR mode with a Node adapter behind PUBLIC_SSR 2026-08-14 19:25:55 +01:00
dtonon
9cc82dc1fe Show an access gate when the relay refuses reads 2026-08-14 18:10:35 +01:00
dtonon
6ecd618bce Join relay and group dynamically, dropping PUBLIC_JOINCODE 2026-08-13 22:54:26 +01:00
dtonon
aaa9a0c1e5 Match search terms at word starts/ends and rank results in tiers 2026-08-13 21:37:38 +01:00
dtonon
dadba06f8c Highlight only the phrase when the full string matches 2026-08-13 20:57:54 +01:00
dtonon
81fde4c932 Keep the best-matching snippet per thread 2026-08-13 14:26:34 +01:00
dtonon
2c432b57e7 Route stray keystrokes into the search modal input 2026-08-12 22:24:11 +01:00
dtonon
2ab69e4cd3 Highlight contiguous phrase matches as one block 2026-08-12 19:30:17 +01:00
dtonon
46176511f3 Window the search snippet on the densest term cluster 2026-08-12 18:58:21 +01:00
dtonon
c9227ee8fb Highlight the searched terms in the thread page 2026-08-12 11:33:00 +01:00
86 changed files with 8617 additions and 4620 deletions

View file

@ -2,10 +2,19 @@ PUBLIC_RELAY_URL=ws://localhost:3334 # required — NIP-29 relay hosting the g
PUBLIC_MODE=simple # simple | full (single forum vs. many rooms)
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
# Render on the server (Node deploy) instead of shipping a static SPA
PUBLIC_SSR=no
# With SSR on, warm the server cache from client-side navigations (yes/no)
PUBLIC_SSR_WARM=yes
# Snapshot cache windows in seconds: served as is / served stale while refreshing
PUBLIC_SSR_CACHE_FRESH=300
PUBLIC_SSR_CACHE_STALE=21600
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
PUBLIC_NOSTRCONNECT_RELAYS= # comma-separated relays for nostrconnect:// (bunker) login; defaults to bucket.coracle.social,relay.primal.net
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
PUBLIC_SECONDARY_COLOR= # override the secondary color (default #ffaf25), e.g. "#0000ff"; hover shade derived
PUBLIC_FAVICON= # favicon URL (absolute, or /favicon.png for a file in static/); default is a filled circle in the accent color

5
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules
.local
# Output
.output
@ -18,6 +19,10 @@ Thumbs.db
!.env.example
!.env.test
# Deploy: only the example unit is tracked
/deploy/*
!/deploy/production-example.service
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dtonon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -27,6 +27,10 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
| `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
| `PUBLIC_SSR` | no | `no` | `yes` to render pages on the server (crawlable HTML, real 404s). The build then targets Node (`node build`) instead of a static bundle; see [Deploying](#deploying). |
| `PUBLIC_SSR_WARM` | no | `yes` | With `PUBLIC_SSR=yes`, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to `no` to disable. |
| `PUBLIC_SSR_CACHE_FRESH` | no | `300` | Seconds a server-rendered snapshot is served as is. Also the edge cache's `s-maxage`. |
| `PUBLIC_SSR_CACHE_STALE` | no | `21600` | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's `stale-while-revalidate`. |
| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
@ -89,12 +93,26 @@ npm run dev -- --open
## Building
To create a production version of your app:
Two deployment targets share the same code base, selected by `PUBLIC_SSR`:
```sh
npm run build
```
- **Static (default, `PUBLIC_SSR=no`)** — `npm run build` (or `just build`) writes a single-page bundle to `build/`; serve it from any web server with `index.html` as the fallback for unknown paths. Everything is fetched by the browser.
- **Server-rendered (`PUBLIC_SSR=yes`)** — `just build-ssr` writes a Node app to `build/`. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live `robots.txt` and `sitemap.xml`, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.
You can preview the production build with `npm run preview`.
Preview a build locally with `npm run preview` (static) or `node --env-file=.env.production build` (server).
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
## Deploying
`just deploy <host>` rsyncs the static bundle to `~/squalk/` on the host and purges the Cloudflare cache.
`just deploy-ssr <mode>` builds with `--mode <mode>` (so vite bakes `.env.<mode>` in), ships the Node build, `package.json`/`package-lock.json` and `.env.<mode>` (as `.env` in the app dir, since the server reads the `PUBLIC_*` values at runtime), runs `npm ci --omit=dev` and restarts the instance's systemd unit. Everything instance-specific lives in `.env.<mode>.local` (gitignored, never shipped): `DEPLOY_HOST`, `DEPLOY_DIR` and `DEPLOY_SERVICE` (all required), plus the Cloudflare credentials (`CF_ZONE_ID`/`CF_API_TOKEN`) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. On the host you need:
- Node 22 or newer (the relay client uses the built-in `WebSocket`).
- The unit from [`deploy/production-example.service`](deploy/production-example.service), with `ORIGIN` set to the public URL — it feeds canonical links, `robots.txt` and the sitemap.
- A reverse proxy in front of the port in `PORT`, replacing whatever served the static files before. With Caddy:
```
forum.example.com {
reverse_proxy 127.0.0.1:3000
}
```
- If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with `Cache-Control: public, max-age=0, s-maxage=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE>` (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. `just deploy-ssr` purges the cache after each release.

View file

@ -0,0 +1,25 @@
# Example systemd unit for the server-rendered build (PUBLIC_SSR=yes).
# Copy to /etc/systemd/system/<instance>.service, adjust the paths, the port
# and ORIGIN, then: systemctl daemon-reload && systemctl enable --now <instance>
[Unit]
Description=Squalk forum
After=network.target
[Service]
Type=simple
# Ephemeral unprivileged user, created by systemd for the service's lifetime
# (implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp,
# NoNewPrivileges). The app dir stays root-owned; the service only reads it.
DynamicUser=yes
WorkingDirectory=/srv/squalk
# PUBLIC_* values read at runtime by the server (deploy-ssr copies .env.<mode> here)
EnvironmentFile=/srv/squalk/.env
# ORIGIN is the public URL: it drives canonical links, robots.txt and the sitemap
Environment=NODE_ENV=production PORT=3000 ORIGIN=https://forum.example.com
ExecStart=/usr/bin/node build
Restart=on-failure
TimeoutStopSec=10
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -1,8 +1,49 @@
set dotenv-load
# Cloudflare credentials: per deploy target in .env.<mode>.local (gitignored,
# never shipped to the server), falling back to the environment / .env
dev:
npm run dev
# Static single-page bundle (served by any web server)
build:
npm run build
PUBLIC_SSR=no npm run build
# Server-rendered bundle (needs Node 22+ on the host, see deploy/production-example.service)
build-ssr:
PUBLIC_SSR=yes npm run build
deploy target: build
rsync -av --delete --progress build/ {{target}}:~/squalk/
@just purge-web-cache
# Ships the Node build plus its runtime deps and env, then restarts the unit.
# `mode` picks the instance: vite bakes .env.<mode> into the build, and
# .env.<mode>.local provides the deployment details (DEPLOY_HOST, DEPLOY_DIR,
# DEPLOY_SERVICE) plus the Cloudflare credentials. The remote step runs in a
# login shell so the user's PATH (npm, nvm…) applies.
deploy-ssr mode:
#!/usr/bin/env bash
set -euo pipefail
[ -f .env.{{mode}}.local ] || { echo "Missing .env.{{mode}}.local"; exit 1; }
set -a; source .env.{{mode}}.local; set +a
: "${DEPLOY_HOST:?DEPLOY_HOST missing in .env.{{mode}}.local}"
: "${DEPLOY_DIR:?DEPLOY_DIR missing in .env.{{mode}}.local}"
: "${DEPLOY_SERVICE:?DEPLOY_SERVICE missing in .env.{{mode}}.local}"
PUBLIC_SSR=yes npm run build -- --mode {{mode}}
rsync -av --delete --progress --exclude node_modules build/ "$DEPLOY_HOST:$DEPLOY_DIR/build/"
rsync -av package.json package-lock.json "$DEPLOY_HOST:$DEPLOY_DIR/"
rsync -av .env.{{mode}} "$DEPLOY_HOST:$DEPLOY_DIR/.env"
ssh "$DEPLOY_HOST" "\$SHELL -l -c 'cd $DEPLOY_DIR && npm ci --omit=dev && sudo systemctl restart $DEPLOY_SERVICE'"
just purge-web-cache {{mode}}
purge-web-cache mode="production":
#!/usr/bin/env bash
if [ -f .env.{{mode}}.local ]; then set -a; source .env.{{mode}}.local; set +a; fi
echo -e "\nPurging Cloudflare cache... for zone ${CF_ZONE_ID:-<unset>}"
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID:-}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN:-}" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}' \
| jq -r 'if .success then "✅ Cache purged successfully" else "‼️ Error: " + (.errors[0].message // "Unknown error") end'

7752
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
{
"name": "squalk",
"license": "MIT",
"private": true,
"version": "0.0.1",
"type": "module",
@ -16,7 +17,7 @@
"devDependencies": {
"@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
@ -41,6 +42,11 @@
"dependencies": {
"@djot/djot": "^0.3.2",
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
"nstart-modal": "^2.1.0",
"uqr": "^0.1.3"
},
"overrides": {
"lightningcss": "^1.33.0"
}
}

20
src/app.d.ts vendored
View file

@ -1,10 +1,28 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type {
ForumShell,
OverviewSnapshot,
Profiles,
ThreadsSnapshot,
} from "$lib/forum/snapshot";
import type { ThreadDetail } from "$lib/forum/thread";
declare global {
// Injected by vite.config.ts from PUBLIC_SSR
const __SQUALK_SSR__: boolean;
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;
thread?: ThreadDetail | null;
threads?: ThreadsSnapshot | null;
overview?: OverviewSnapshot | null;
}
// interface PageState {}
// interface Platform {}
}

109
src/lib/access.svelte.ts Normal file
View file

@ -0,0 +1,109 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import { auth } from "$lib/auth.svelte";
import { ensureForumRelay, forumRelayInfo } from "$lib/relay";
import type { ForumRelayInfo } from "$lib/relay";
// Whether the forum relay will serve this visitor at all. A relay can refuse
// every read (auth-required for anonymous visitors, restricted for logged-in
// non-members, blocked for bans), in which case the app would otherwise just
// look empty; the layout shows a gate page instead until a probe comes back
// open.
export type RelayAccess =
| "checking"
| "open"
| "auth-required"
| "restricted"
| "blocked";
let state = $state<RelayAccess>("checking");
let message = $state<string | null>(null);
let info = $state<ForumRelayInfo | null>(null);
// Bumped when a probe completes, so listeners can react to every result even
// when the state itself didn't change (open → open after a login).
let probes = $state(0);
export const relayAccess = {
get state() {
return state;
},
get message() {
return message;
},
get info() {
return info;
},
get probes() {
return probes;
},
};
// Sends a throwaway REQ and reads the CLOSED reason: nostr-tools' query helpers
// discard it, and it is the only cross-relay signal for a read gate. No `#h`
// in the filter, so a private group's per-group auth-required (handled by the
// room itself) can't masquerade as a relay-wide one.
function probeRead(relay: AbstractRelay): Promise<string | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (reason: string | null) => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(reason);
};
const sub = relay.subscribe([{ kinds: [39000], limit: 1 }], {
onevent() {},
oneose() {
finish(null);
},
onclose(reason) {
finish(reason);
},
});
setTimeout(() => finish(null), 4000);
});
}
function classify(reason: string | null): RelayAccess {
const r = (reason ?? "").toLowerCase();
if (r.startsWith("auth-required:")) return "auth-required";
if (r.startsWith("restricted:")) return "restricted";
if (r.startsWith("blocked:")) return "blocked";
return "open";
}
let inFlight: Promise<RelayAccess> | null = null;
// Re-evaluates access for the current signer. Concurrent calls share one probe.
// An anonymous visitor on a relay whose NIP-11 declares auth_required is gated
// without waiting for the relay to refuse a request.
export function probeRelayAccess(): Promise<RelayAccess> {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const relayInfo = await forumRelayInfo();
info = relayInfo;
let next: RelayAccess;
let reason: string | null = null;
if (!auth.signer && relayInfo.authRequired) {
next = "auth-required";
} else {
try {
reason = await probeRead(await ensureForumRelay());
} catch {
reason = null; // connection failure: the pages surface their own errors
}
next = classify(reason);
}
state = next;
message =
next === "open" ? null : (reason?.replace(/^[a-z-]+:\s*/i, "") ?? null);
probes++;
return next;
} finally {
inFlight = null;
}
})();
return inFlight;
}

View file

@ -24,14 +24,20 @@ export const autogrow: Action<
const vPad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
const vBorder =
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
const min = lh * Math.max(node.rows, 1) + vPad + vBorder;
const max = lh * maxRows + vPad + vBorder;
node.style.height = "auto";
const next = Math.min(node.scrollHeight, max);
// scrollHeight is 0 while hidden (display:none); never go below `rows`.
const next = Math.min(Math.max(node.scrollHeight, min), max);
node.style.height = `${next}px`;
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
}
node.addEventListener("input", resize);
// Re-measure when the textarea becomes visible or changes width.
const ro =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
ro?.observe(node);
resize();
return {
@ -43,6 +49,7 @@ export const autogrow: Action<
},
destroy() {
node.removeEventListener("input", resize);
ro?.disconnect();
},
};
};

View file

@ -1,18 +1,21 @@
import { page } from "$app/state";
import { MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
import { queryForum } from "$lib/relay";
import { fetchRoomAdmins } from "$lib/forum/groups";
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
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;
},
};
@ -21,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;
},
};
@ -37,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);
}
@ -48,17 +51,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
loadedKey = key;
try {
const events = await queryForum({
kinds: [39001],
"#d": roomIds,
});
const map: Record<string, string[]> = {};
for (const e of events) {
const d = e.tags.find((t) => t[0] === "d")?.[1];
if (!d) continue;
map[d] = e.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
}
byRoom = map;
byRoom = await fetchRoomAdmins(queryForum, roomIds);
} finally {
loaded = true;
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -4,6 +4,14 @@ import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core";
import * as nip19 from "@nostr/tools/nip19";
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
import {
connectBunker,
restoreBunker,
startNostrConnect,
type BunkerSession,
type NostrConnect,
} from "$lib/bunker";
import type { BunkerSigner } from "@nostr/tools/nip46";
declare global {
interface Window {
@ -18,6 +26,8 @@ export type Signer = {
let user = $state<NostrUser | null>(null);
let signer = $state<Signer | null>(null);
// Kept outside $state (see makeExtensionSigner) so it can be closed on logout
let bunker: BunkerSigner | null = null;
let loginModalOpen = $state(false);
// Optional action to run once login succeeds, so an intent like "post" started
// while logged out resumes (login => join => composer) instead of being dropped.
@ -45,6 +55,8 @@ export const auth = {
const PUBKEY_KEY = "nostr_pubkey";
const METHOD_KEY = "nostr_login_method";
const NSEC_KEY = "nostr_nsec";
const BUNKER_KEY = "nostr_bunker";
const BUNKER_CLIENT_KEY = "nostr_bunker_client_key";
export function openLogin(after?: () => void) {
afterLogin = after ?? null;
@ -94,6 +106,30 @@ function makeExtensionSigner(): Signer {
};
}
function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer {
return {
async getPublicKey() {
return pubkey;
},
async signEvent(template) {
return remote.signEvent(template);
},
};
}
function closeBunker() {
bunker?.close().catch(() => {});
bunker = null;
}
function clearStoredSession() {
localStorage.removeItem(PUBKEY_KEY);
localStorage.removeItem(METHOD_KEY);
localStorage.removeItem(NSEC_KEY);
localStorage.removeItem(BUNKER_KEY);
localStorage.removeItem(BUNKER_CLIENT_KEY);
}
async function setUser(pubkey: string) {
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
user = await loadNostrUser(pubkey);
@ -105,10 +141,11 @@ export async function loginWithExtension() {
throw new Error("No Nostr extension found");
}
const pubkey = await window.nostr.getPublicKey();
closeBunker();
signer = makeExtensionSigner();
clearStoredSession();
localStorage.setItem(PUBKEY_KEY, pubkey);
localStorage.setItem(METHOD_KEY, "extension");
localStorage.removeItem(NSEC_KEY);
await setUser(pubkey);
sessionEpoch++;
runAfterLogin();
@ -137,7 +174,9 @@ export async function loginWithNsec(input: string) {
const sk = parseSecretKey(input);
const nsec = nip19.nsecEncode(sk);
const pubkey = getPublicKey(sk);
closeBunker();
signer = makeNsecSigner(sk);
clearStoredSession();
localStorage.setItem(PUBKEY_KEY, pubkey);
localStorage.setItem(METHOD_KEY, "nsec");
localStorage.setItem(NSEC_KEY, nsec);
@ -146,12 +185,47 @@ export async function loginWithNsec(input: string) {
runAfterLogin();
}
async function loginWithBunkerSession(session: BunkerSession) {
closeBunker();
bunker = session.signer;
signer = makeBunkerSigner(session.signer, session.pubkey);
clearStoredSession();
localStorage.setItem(PUBKEY_KEY, session.pubkey);
localStorage.setItem(METHOD_KEY, "bunker");
localStorage.setItem(BUNKER_KEY, session.bunkerUrl);
localStorage.setItem(BUNKER_CLIENT_KEY, session.clientSecretKey);
await setUser(session.pubkey);
sessionEpoch++;
runAfterLogin();
}
// Pasted bunker:// URL or a NIP-05 that advertises one
export async function loginWithBunker(input: string) {
await loginWithBunkerSession(await connectBunker(input));
}
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
// waiting when the user leaves the view.
// `onAck` reports the signer's first reply so the UI can show progress while
// the session is finalized.
export function loginWithNostrConnect(onAck?: () => void): {
uri: string;
done: Promise<void>;
cancel: () => void;
} {
const nc: NostrConnect = startNostrConnect(onAck);
return {
uri: nc.uri,
done: nc.session.then(loginWithBunkerSession),
cancel: nc.cancel,
};
}
export function logout() {
user = null;
signer = null;
localStorage.removeItem(PUBKEY_KEY);
localStorage.removeItem(METHOD_KEY);
localStorage.removeItem(NSEC_KEY);
closeBunker();
clearStoredSession();
resetJoinState();
sessionEpoch++;
}
@ -171,6 +245,16 @@ export async function restoreSession() {
} catch {
return;
}
} else if (method === "bunker") {
const url = localStorage.getItem(BUNKER_KEY);
const clientKey = localStorage.getItem(BUNKER_CLIENT_KEY);
if (!url || !clientKey) return;
try {
bunker = await restoreBunker(url, clientKey);
} catch {
return;
}
signer = makeBunkerSigner(bunker, pubkey);
} else {
signer = makeExtensionSigner();
}

216
src/lib/bunker.ts Normal file
View file

@ -0,0 +1,216 @@
import {
BunkerSigner,
createNostrConnectURI,
parseBunkerInput,
toBunkerURL,
type BunkerPointer,
} from "@nostr/tools/nip46";
import { SimplePool } from "@nostr/tools/pool";
import { NostrConnect as NostrConnectKind } from "@nostr/tools/kinds";
import { decrypt, getConversationKey } from "@nostr/tools/nip44";
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
import { NOSTRCONNECT_RELAYS } from "$lib/config";
// NIP-46 remote signing. Two ways in: the user pastes a bunker:// URL (or a
// NIP-05 that advertises one), or the app shows a nostrconnect:// URI and the
// signer app calls back. Either way the result is a live BunkerSigner plus the
// data needed to reconnect silently next time: the bunker URL (secret
// stripped) and the client key the conversation is bound to.
export type BunkerSession = {
signer: BunkerSigner;
pubkey: string;
bunkerUrl: string;
clientSecretKey: string;
};
// The signer app usually lives on the phone, and on mobile the browser tab is
// backgrounded while the user approves there, which can drop the socket. A
// reconnecting pool keeps the subscriptions alive across that.
const pool = new SimplePool({ enableReconnect: true });
// The approval happens in the signer app, so the tab is backgrounded and its
// sockets can drop; nostr-tools only retries after a 10s backoff. Reconnect
// the relays this flow has used as soon as the tab or the network is back,
// and keep a shorter backoff for drops in between.
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
const usedRelays = new Set<string>();
function touchRelays(urls: string[] = []) {
for (const url of urls) usedRelays.add(url);
for (const url of usedRelays) {
pool
.ensureRelay(url)
.then((relay) => {
relay.resubscribeBackoff = FAST_BACKOFF;
})
.catch(() => {});
}
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) touchRelays();
});
window.addEventListener("online", () => touchRelays());
}
const signerParams = {
pool,
onauth: (url: string) => {
window.open(url, "_blank");
},
};
function stripSecret(url: string): string {
const u = new URL(url);
u.searchParams.delete("secret");
return u.toString();
}
function describe(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export async function connectBunker(input: string): Promise<BunkerSession> {
const bp = await parseBunkerInput(input.trim());
if (!bp) throw new Error("Invalid bunker");
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
const sk = generateSecretKey();
touchRelays(bp.relays);
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
try {
await signer.connect();
const pubkey = await signer.getPublicKey();
return {
signer,
pubkey,
bunkerUrl: stripSecret(toBunkerURL(bp)),
clientSecretKey: bytesToHex(sk),
};
} catch (e) {
signer.close().catch(() => {});
throw new Error(`Bunker error: ${describe(e)}`);
}
}
export type NostrConnect = {
uri: string;
// Resolves once a signer answers the URI; rejects on abort or relay close
session: Promise<BunkerSession>;
cancel: () => void;
};
// Waits for the signer's reply to a nostrconnect:// URI. Unlike the library's
// helper, the filter has no `limit: 0`, so a reply published while the tab was
// offline is replayed once the relay reconnects.
function waitForConnect(
sk: Uint8Array,
secret: string,
signal: AbortSignal,
): Promise<BunkerPointer> {
const clientPubkey = getPublicKey(sk);
touchRelays(NOSTRCONNECT_RELAYS);
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
sub.close();
fn();
};
const sub = pool.subscribe(
NOSTRCONNECT_RELAYS,
{
kinds: [NostrConnectKind],
"#p": [clientPubkey],
since: Math.floor(Date.now() / 1000) - 60,
},
{
onevent: (event) => {
let reply: { result?: string; error?: string };
try {
const key = getConversationKey(sk, event.pubkey);
reply = JSON.parse(decrypt(event.content, key));
} catch (e) {
console.warn("nostrconnect: undecryptable reply", e);
return;
}
if (reply.result === secret) {
finish(() =>
resolve({
pubkey: event.pubkey,
relays: NOSTRCONNECT_RELAYS,
secret,
}),
);
} else if (reply.error) {
finish(() => reject(new Error(`Signer refused: ${reply.error}`)));
} else {
finish(() =>
reject(
new Error(
`Signer replied "${reply.result}" instead of the secret`,
),
),
);
}
},
onclose: (reasons) =>
finish(() =>
reject(new Error(`Relay closed: ${reasons.join("; ")}`)),
),
},
);
// Closes the subscription on every relay; the pool's own abort handling
// only reaches the last one
signal.addEventListener("abort", () =>
finish(() => reject(new Error("Cancelled"))),
);
});
}
// `onAck` fires the moment the signer answers the URI, before the follow-up
// round-trip that completes the session — the point where "waiting for the
// signer" becomes "finishing up".
export function startNostrConnect(onAck?: () => void): NostrConnect {
const sk = generateSecretKey();
const secret = Math.random().toString(36).substring(2, 10);
const uri = createNostrConnectURI({
clientPubkey: getPublicKey(sk),
relays: NOSTRCONNECT_RELAYS,
secret,
name: location.host,
url: location.origin,
});
const abort = new AbortController();
const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => {
onAck?.();
// The signer already accepted this client key; no connect round-trip
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
try {
const pubkey = await signer.getPublicKey();
return {
signer,
pubkey,
bunkerUrl: stripSecret(toBunkerURL(bp)),
clientSecretKey: bytesToHex(sk),
};
} catch (e) {
signer.close().catch(() => {});
throw new Error(`Bunker error: ${describe(e)}`);
}
});
return { uri, session, cancel: () => abort.abort() };
}
// Rebuilds the signer from stored data without a connect round-trip: the
// bunker already knows this client key, so the first sign request is enough.
export async function restoreBunker(
bunkerUrl: string,
clientSecretKey: string,
): Promise<BunkerSigner> {
const bp = await parseBunkerInput(bunkerUrl);
if (!bp) throw new Error("Invalid bunker");
touchRelays(bp.relays);
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
}

View file

@ -1,5 +1,5 @@
import type { Event } from "@nostr/tools";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { RELAY_URL } from "$lib/config";
import { auth } from "$lib/auth.svelte";
import { queryForum, publishForum, subscribeForum } from "$lib/relay";

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { tokenizeChat } from "$lib/linkify";
import { profilePath } from "$lib/forum/profiles";
import {
resolveThreadRef,
threadRefHref,
@ -39,9 +40,7 @@
<span class="break-words whitespace-pre-wrap"
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
href="https://njump.me/{t.entity}"
target="_blank"
rel="noopener noreferrer"
href={profilePath(t.pubkey)}
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
>{:else if t.type === "entity"}{@const ref = t.id
? resolvedThreads[t.id]

View file

@ -15,11 +15,12 @@
} from "$lib/moderation.svelte";
import {
withJoin,
joinState,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import { profilePath } from "$lib/forum/profiles";
import type { NostrUser } from "@nostr/gadgets/metadata";
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
import ChatContent from "$lib/components/ChatContent.svelte";
@ -76,7 +77,7 @@
});
function onJoinToChat() {
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
}
function requestDeleteMessage(msg: ChatMessageData) {
@ -250,8 +251,11 @@
}
// Anchor to bottom on new messages, unless user is reading older ones.
// Also runs when the mobile panel becomes visible: while hidden the list
// has no height, so earlier scrolls were no-ops.
$effect(() => {
messages.length;
mobileActive;
if (!listEl || userScrolledUp) return;
tick().then(() => {
if (listEl) listEl.scrollTop = listEl.scrollHeight;
@ -348,21 +352,30 @@
{/if}
<div>
<div class="mb-1 flex items-center gap-2">
{#if author.picture}
<img
src={author.picture}
alt=""
class="h-6 w-6 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{author.name[0].toUpperCase()}
</span>
{/if}
<span class="font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</span
<a
href={profilePath(msg.pubkey)}
class="shrink-0"
aria-hidden="true"
tabindex="-1"
>
{#if author.picture}
<img
src={author.picture}
alt=""
class="h-6 w-6 rounded-full object-cover"
/>
{:else}
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{author.name[0].toUpperCase()}
</span>
{/if}
</a>
<a
href={profilePath(msg.pubkey)}
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</a
>
<div class="ml-auto flex items-center gap-1">
<div class="relative">
@ -478,7 +491,7 @@
{/if}
{#if !auth.user}
<button
onclick={openLogin}
onclick={() => openLogin()}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
>
Login to chat
@ -486,9 +499,10 @@
{:else if joinToChat}
<button
onclick={onJoinToChat}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Join to chat
{joinState.busy ? "Joining…" : "Join to chat"}
</button>
{:else}
<div class="relative">

View file

@ -11,6 +11,10 @@
type Author,
} from "$lib/components/ThreadItem.svelte";
import SortToggle from "$lib/components/SortToggle.svelte";
import Meta from "$lib/components/Meta.svelte";
import { groupStore } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { MODE } from "$lib/config";
import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte";
@ -18,7 +22,8 @@
import {
membershipOf,
ensureMembershipChecked,
openJoinModal,
withJoin,
joinState,
} from "$lib/join.svelte";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
@ -44,6 +49,12 @@
if (urlSort) sortPref.value = urlSort;
});
const about = $derived(
MODE === "full"
? (groupsStore.list.find((g) => g.id === groupId)?.about ?? "")
: (groupStore.data?.about ?? ""),
);
const flags = $derived(getGroupFlags(groupId));
const member = $derived(membershipOf(groupId));
@ -71,12 +82,7 @@
openLogin(onNewTopic);
return;
}
await ensureMembershipChecked(groupId);
if (membershipOf(groupId) !== "member") {
openJoinModal(groupId, openDraft);
return;
}
openDraft();
await withJoin(groupId, openDraft);
}
function onPrivateJoin() {
@ -84,7 +90,7 @@
openLogin();
return;
}
openJoinModal(groupId, () => loadThreads(groupId, sort));
withJoin(groupId, () => loadThreads(groupId, sort));
}
function relativeTime(ts: number): string {
@ -125,22 +131,36 @@
const rows = $derived(
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
);
const empty = $derived(
rows.length === 0 && !threadStore.loading && threadStore.exhausted,
);
const fairlyNew = $derived(
rows.length > 0 &&
rows.length < 4 &&
!threadStore.loading &&
threadStore.exhausted,
);
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<Meta {title} description={about} />
<div class="mx-auto max-w-6xl">
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
{#if !showPrivateGate && !checkingAccess}
{#if !showPrivateGate && !checkingAccess && !empty}
<div class="flex items-center gap-2">
<button
onclick={onNewTopic}
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white md:px-6 md:text-sm"
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white disabled:opacity-50 md:px-6 md:text-sm"
>
{joinToPost ? "Join to post" : "New discussion"}
{joinState.busy
? "Joining…"
: joinToPost
? "Join to post"
: "New discussion"}
</button>
<SortToggle {sort} />
</div>
@ -165,9 +185,27 @@
</p>
<button
onclick={onPrivateJoin}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
>
{auth.user ? "Request to join" : "Log in to join"}
{joinState.busy
? "Joining…"
: auth.user
? "Request to join"
: "Log in to join"}
</button>
</div>
{:else if empty}
<div class="flex flex-col items-center gap-6 py-24 text-center">
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
This room is still empty
</p>
<button
onclick={onNewTopic}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
>
{joinState.busy ? "Joining…" : "Be the first one to open a discussion"}
</button>
</div>
{:else}
@ -194,6 +232,19 @@
{threadStore.loadingMore ? "Loading…" : "Show more"}
</button>
</div>
{:else if fairlyNew}
<div class="flex flex-col items-center gap-6 py-16 text-center">
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
This room is fairly new
</p>
<button
onclick={onNewTopic}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
>
{joinState.busy ? "Joining…" : "Start a new discussion"}
</button>
</div>
{:else if rows.length > 0}
<p
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"

View file

@ -1,19 +1,38 @@
<script lang="ts">
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
import { joinState, closeJoinModal, submitJoinCode } from "$lib/join.svelte";
import { tick } from "svelte";
let code = $state("");
let codeInput = $state<HTMLInputElement | null>(null);
// Pending approval hides the code field behind a discreet link, so a group
// that doesn't hand out codes doesn't send people hunting for one.
let codeRevealed = $state(false);
const step = $derived(joinState.modalStep);
const showCode = $derived(
step === "relay-code" ||
(step === "group-code" && joinState.modalCodeHinted) ||
codeRevealed,
);
const canOfferCode = $derived(
step === "pending" || (step === "group-code" && !joinState.modalCodeHinted),
);
$effect(() => {
if (joinState.modalOpen && joinState.needsCode) {
if (!joinState.modalOpen) {
code = "";
codeRevealed = false;
}
});
$effect(() => {
if (joinState.modalOpen && showCode) {
tick().then(() => codeInput?.focus());
}
if (!joinState.modalOpen) code = "";
});
async function onSubmit() {
await submitJoin(code.trim() || undefined);
await submitJoinCode(code.trim() || undefined);
}
function onClose() {
@ -56,15 +75,28 @@
id="join-title"
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
Join this group
{#if step === "relay-code"}
Invite code required
{:else if step === "pending"}
Request sent
{:else if step === "error"}
Could not join
{:else}
Join this group
{/if}
</h2>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if joinState.needsCode}
Join this group to participate. If you have an invite code, enter it
below — otherwise just send the request.
{#if step === "relay-code"}
This relay only accepts members. Enter an invite code to join.
{:else if step === "pending"}
Your request to join has been sent. An admin needs to approve it
before you can participate.
{:else if step === "error"}
The relay refused the request.
{:else if joinState.modalCodeHinted}
This group requires an invite code to join.
{:else}
Join this group to participate. Your request is sent to the relay and,
where needed, approved by an admin.
The group refused the join request.
{/if}
</p>
@ -77,13 +109,17 @@
</div>
{/if}
{#if joinState.needsCode}
<label for="join-code-input" class="sr-only">Invite code</label>
{#if showCode}
<label for="join-code-input" class="sr-only">
{step === "relay-code" ? "Relay invite code" : "Group invite code"}
</label>
<input
id="join-code-input"
bind:this={codeInput}
type="text"
placeholder="Invite code (optional)"
placeholder={step === "relay-code"
? "Relay invite code"
: "Group invite code"}
bind:value={code}
disabled={joinState.busy}
autocomplete="off"
@ -92,6 +128,14 @@
onkeydown={(e) => e.key === "Enter" && onSubmit()}
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
{:else if canOfferCode}
<button
type="button"
onclick={() => (codeRevealed = true)}
class="mb-3 text-sm text-neutral-500 underline hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
>
Have an invite code?
</button>
{/if}
<div class="flex justify-end gap-2">
@ -103,18 +147,16 @@
>
Close
</button>
<button
type="button"
onclick={onSubmit}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{joinState.busy
? "Joining…"
: joinState.needsCode
? "Request access"
: "Join"}
</button>
{#if showCode}
<button
type="button"
onclick={onSubmit}
disabled={joinState.busy || !code.trim()}
class="bg-accent hover:bg-accent-hover rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{joinState.busy ? "Joining…" : "Join"}
</button>
{/if}
</div>
</div>
</div>

View file

@ -2,25 +2,78 @@
import {
auth,
closeLogin,
loginWithBunker,
loginWithExtension,
loginWithNostrConnect,
loginWithNsec,
} from "$lib/auth.svelte";
import { tick } from "svelte";
import { TITLE, MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
import { themeState } from "$lib/theme.svelte";
import type { NstartModal } from "nstart-modal";
import { onDestroy, tick } from "svelte";
import { renderSVG } from "uqr";
let view = $state<"extension" | "nsec">("extension");
type View = "extension" | "bunker" | "nsec";
let view = $state<View>("extension");
let nsec = $state("");
let bunkerUrl = $state("");
let error = $state<string | null>(null);
let busy = $state(false);
let copied = $state(false);
let nsecInput = $state<HTMLInputElement | null>(null);
let bunkerInput = $state<HTMLInputElement | null>(null);
// Nstart sign-up wizard (an iframe overlay). Built lazily and reused; it is
// rebuilt only when the theme it was built for changes.
let wizard: NstartModal | null = null;
let wizardTheme: string | null = null;
// The login dialog hides while the wizard is up and comes back on cancel
let signingUp = $state(false);
const appName = $derived(
TITLE ||
(MODE === "simple" ? groupStore.data?.name : "") ||
(typeof location !== "undefined" ? location.host : ""),
);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
// `finalizing` flips when the signer first answers, so the QR gives way to
// a progress state while the session completes.
// $state.raw so identity checks against the raw object (connect === nc)
// hold; the object is replaced wholesale, never mutated.
let connect = $state.raw<ReturnType<typeof loginWithNostrConnect> | null>(
null,
);
let finalizing = $state(false);
const qrSvg = $derived(
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
);
const hasExtension = $derived(
typeof window !== "undefined" && !!window.nostr,
);
const titles: Record<View, string> = {
extension: "Log in",
bunker: "Log in with a bunker",
nsec: "Log in with nsec",
};
function stopConnect() {
connect?.cancel();
connect = null;
finalizing = false;
}
function reset() {
stopConnect();
view = "extension";
nsec = "";
bunkerUrl = "";
error = null;
copied = false;
}
async function handleExtension() {
@ -53,33 +106,151 @@
}
}
async function handleBunker() {
if (busy || !bunkerUrl.trim()) return;
error = null;
busy = true;
try {
await loginWithBunker(bunkerUrl);
closeLogin();
reset();
} catch (e) {
error =
e instanceof Error ? e.message : "Failed to connect to the bunker";
} finally {
busy = false;
}
}
function startConnect() {
stopConnect();
const nc = loginWithNostrConnect(() => {
if (connect === nc) finalizing = true;
});
connect = nc;
nc.done
.then(() => {
if (connect !== nc) return;
closeLogin();
reset();
})
.catch((e) => {
if (connect !== nc) return; // cancelled by leaving the view
connect = null;
finalizing = false;
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
});
}
async function copyUri() {
if (!connect) return;
try {
await navigator.clipboard.writeText(connect.uri);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch {
error = "Could not copy to the clipboard";
}
}
function onClose() {
if (busy) return;
closeLogin();
reset();
}
// The accent color as Nstart wants it (hex, no #), so the wizard matches the
// forum; env overrides are already applied to the CSS variable.
function accentHex(): string | undefined {
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--color-accent")
.trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.slice(1) : undefined;
}
async function handleSignup() {
if (busy) return;
error = null;
stopConnect();
const { NstartModal } = await import("nstart-modal");
if (!wizard || wizardTheme !== themeState.theme) {
wizard?.destroy();
wizardTheme = themeState.theme;
wizard = new NstartModal({
baseUrl: "https://nstart.me",
an: appName,
aa: accentHex(),
am: themeState.theme,
// Only bunker:// or nsec come back; ncryptsec has no login here
aac: true,
onComplete: ({ nostrLogin }) => void completeSignup(nostrLogin),
onCancel: () => {
signingUp = false;
},
});
}
signingUp = true;
wizard.open();
}
async function completeSignup(nostrLogin: string | null) {
signingUp = false;
if (!nostrLogin) {
error = "Sign-up finished without a login credential";
return;
}
busy = true;
try {
if (nostrLogin.startsWith("bunker://")) {
await loginWithBunker(nostrLogin);
} else {
await loginWithNsec(nostrLogin);
}
closeLogin();
reset();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to login";
} finally {
busy = false;
}
}
async function showNsecView() {
stopConnect();
view = "nsec";
error = null;
await tick();
nsecInput?.focus();
}
async function showBunkerView() {
view = "bunker";
error = null;
startConnect();
await tick();
bunkerInput?.focus();
}
function showExtensionView() {
stopConnect();
view = "extension";
error = null;
}
function onKeydown(e: KeyboardEvent) {
if (!auth.loginModalOpen) return;
if (!auth.loginModalOpen || signingUp) return;
if (e.key === "Escape") onClose();
}
onDestroy(() => {
stopConnect();
wizard?.destroy();
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if auth.loginModalOpen}
{#if auth.loginModalOpen && !signingUp}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
@ -88,7 +259,7 @@
onclick={onClose}
></button>
<div
class="relative w-full max-w-sm rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
@ -97,13 +268,16 @@
type="button"
onclick={onClose}
aria-label="Close"
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300"
>
×
</button>
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{view === "extension" ? "Log in" : "Log in with nsec"}
<h2
id="login-title"
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
{titles[view]}
</h2>
{#if error}
@ -128,6 +302,14 @@
No Nostr extension detected in this browser.
</p>
{/if}
<button
type="button"
onclick={showBunkerView}
disabled={busy}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
Log in with a bunker
</button>
<button
type="button"
onclick={showNsecView}
@ -135,6 +317,116 @@
>
Or log in using your nsec
</button>
<div
class="my-5 border-t border-neutral-200 dark:border-neutral-700"
></div>
<p
class="mb-3 text-center text-sm text-neutral-600 dark:text-neutral-400"
>
Don't have an account yet?
</p>
<button
type="button"
onclick={handleSignup}
disabled={busy}
class="border-accent text-accent hover:bg-accent/10 w-full rounded border px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
Sign up
</button>
{:else if view === "bunker"}
{#if finalizing}
<div
class="flex flex-col items-center gap-3 py-10"
role="status"
aria-live="polite"
>
<span
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600 dark:border-neutral-600 dark:border-t-neutral-300"
></span>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Connected to your signer — finishing login…
</p>
</div>
{:else}
{#if connect}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
aria-label={copied ? "Copied" : "Copy the connection string"}
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<span class="min-w-0 flex-1 truncate font-mono"
>{connect.uri}</span
>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the
connection.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
{/if}
<button
type="button"
onclick={showExtensionView}
disabled={busy}
class="text-accent mt-4 block w-full text-center text-sm hover:underline disabled:opacity-50"
>
Back
</button>
{:else}
<label for="nsec-input" class="sr-only">nsec</label>
<input
@ -149,7 +441,7 @@
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleNsec()}
class="focus:ring-accent w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleNsec}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from "svelte";
import * as nip19 from "@nostr/tools/nip19";
import { loadRelayList } from "@nostr/gadgets/lists";
import { loadRelayList } from "$lib/gadgets";
import {
profileStore,
searchLocalProfiles,

View file

@ -0,0 +1,64 @@
<script module lang="ts">
import { TITLE, GROUP_ID, MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
// Forum name from the env, or the group name in simple mode.
export function siteName(): string {
return (
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : "")
);
}
</script>
<script lang="ts">
import { page } from "$app/state";
type Props = {
title: string;
description?: string;
type?: "website" | "article";
image?: string;
jsonLd?: Record<string, unknown>;
};
let {
title,
description = "",
type = "website",
image,
jsonLd,
}: Props = $props();
// Escape "<" so the JSON can never close the script tag early.
const ld = $derived(
jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : "",
);
const site = $derived(siteName());
const canonical = $derived(page.url.origin + page.url.pathname);
</script>
<svelte:head>
<title>{title}</title>
<link rel="canonical" href={canonical} />
{#if description}
<meta name="description" content={description} />
<meta property="og:description" content={description} />
{/if}
<meta property="og:title" content={title} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
{#if site}
<meta property="og:site_name" content={site} />
{/if}
{#if image}
<meta property="og:image" content={image} />
{/if}
<meta
name="twitter:card"
content={image ? "summary_large_image" : "summary"}
/>
{#if ld}
{@html `<script type="application/ld+json">${ld}</script>`}
{/if}
</svelte:head>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import * as nip19 from "@nostr/tools/nip19";
import { profilePath } from "$lib/forum/profiles";
import {
resolveThreadRef,
threadRefHref,
@ -310,7 +311,7 @@
const entity = dest.slice(6).toLowerCase();
try {
nip19.decode(entity);
return { type: "link", href: `https://njump.me/${entity}`, label };
return { type: "link", href: `https://nostr.basspistol.org/${entity}`, label };
} catch {
return { type: "text", value: label };
}
@ -333,7 +334,6 @@
case "str":
return tokenizeInline(node.text);
case "soft_break":
return [{ type: "text", value: " " }];
case "hard_break":
return [{ type: "br" }];
case "non_breaking_space":
@ -611,11 +611,8 @@
>
{:else if inline.type === "mention"}
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
>@{u?.shortName ?? inline.fallback}</a
>
{:else if inline.type === "thread-quote"}
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
@ -645,7 +642,7 @@
>
{:else}
<a
href="https://njump.me/{inline.entity}"
href="https://nostr.basspistol.org/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-accent break-all hover:underline">{inline.label}</a
@ -700,7 +697,7 @@
src={block.src}
alt={block.alt}
loading="lazy"
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
class="mx-auto block max-h-[80vh] w-full rounded object-contain"
/>
{:else if block.type === "blockquote"}
<blockquote
@ -773,7 +770,7 @@
{/snippet}
<div
class="prose max-w-none leading-5 text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
class="prose max-w-none leading-5 wrap-anywhere text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
>
{@render renderBlocks(blocks)}
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
import { auth, openLogin } from "$lib/auth.svelte";
import { joinRelay, joinState } from "$lib/join.svelte";
import { TITLE } from "$lib/config";
// The relay's NIP-11 name is readable even when nothing else is, so the
// gate can still say which forum this is.
const title = $derived(TITLE || relayAccess.info?.name || "Forum");
const description = $derived(relayAccess.info?.description ?? "");
const state = $derived(relayAccess.state);
// A logged-in user still refused with auth-required means the handshake
// failed rather than that they must log in; offer a retry instead.
const authFailed = $derived(state === "auth-required" && !!auth.signer);
function onLogin() {
openLogin(() => {
probeRelayAccess();
});
}
function onJoin() {
joinRelay(() => {
probeRelayAccess();
});
}
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<div class="flex min-h-dvh flex-col">
<div class="flex justify-end p-4">
<ThemeToggle />
</div>
<main
class="flex flex-1 flex-col items-center justify-center px-6 pb-24 text-center"
>
{#if relayAccess.info?.icon}
<img
src={relayAccess.info.icon}
alt=""
class="mb-6 h-20 w-20 rounded-full object-cover"
/>
{/if}
<h1 class="text-accent text-3xl font-semibold">{title}</h1>
{#if description}
<p class="mt-2 max-w-md text-neutral-600 dark:text-neutral-400">
{description}
</p>
{/if}
<div class="mt-8 max-w-md" role="status" aria-live="polite">
{#if state === "checking"}
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Checking access…
</p>
{:else if authFailed}
<p class="text-neutral-700 dark:text-neutral-300">
The relay did not accept your login.
</p>
<button
type="button"
onclick={() => probeRelayAccess()}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
>
Retry
</button>
{:else if state === "auth-required"}
<p class="text-neutral-700 dark:text-neutral-300">
You need to log in to access this forum.
</p>
<button
type="button"
onclick={onLogin}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
>
Log in
</button>
{:else if state === "restricted"}
<p class="text-neutral-700 dark:text-neutral-300">
This forum is for members only.
</p>
<button
type="button"
onclick={onJoin}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white disabled:opacity-50"
>
{joinState.busy ? "Joining…" : "Join this forum"}
</button>
{:else if state === "blocked"}
<p class="text-neutral-700 dark:text-neutral-300">
You can't access this forum.
</p>
{#if relayAccess.message}
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{relayAccess.message}
</p>
{/if}
{/if}
</div>
</main>
</div>

View file

@ -15,7 +15,7 @@
function select(r: SearchResult) {
open = false;
goto(`/thread/${r.threadId}`);
goto(search.threadUrl(r.threadId));
}
function onKeydown(e: KeyboardEvent) {

View file

@ -21,13 +21,26 @@
function select(r: SearchResult) {
closeSearch();
goto(`/thread/${r.threadId}`);
goto(search.threadUrl(r.threadId));
}
// Global "/" opens the search from any page, unless typing somewhere else
function onWindowKeydown(e: KeyboardEvent) {
if (searchModal.open) {
if (e.key === "Escape") closeSearch();
if (e.key === "Escape") {
closeSearch();
return;
}
// Typing fast right after "/" can outrun the async focus; route the
// keystroke into the input so no leading characters get lost
if (
e.key.length === 1 &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
document.activeElement !== inputEl
)
inputEl?.focus();
return;
}
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;

View file

@ -41,7 +41,7 @@
{#each search.results as r, i (r.threadId)}
<a
id="{idBase}-{i}"
href="/thread/{r.threadId}"
href={search.threadUrl(r.threadId)}
role="option"
aria-selected={i === search.activeIndex}
class="block rounded px-4 py-2 {i === search.activeIndex

View file

@ -18,7 +18,38 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
if (MODE === "simple" && !GROUP_ID) {
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
}
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
// Relays used to hand a nostrconnect:// URI to a remote signer (NIP-46).
// Comma-separated override via PUBLIC_NOSTRCONNECT_RELAYS.
export const NOSTRCONNECT_RELAYS: string[] = (
env.PUBLIC_NOSTRCONNECT_RELAYS ||
"wss://bucket.coracle.social,wss://relay.primal.net"
)
.split(",")
.map((r) => r.trim())
.filter(Boolean);
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
export const SSR_ENABLED = __SQUALK_SSR__;
// With SSR on, a client-side navigation also asks the server for the same
// snapshot (without waiting for it) so the page is already cached for the
// next refresh, shared link or crawler. Costs one server→relay query per
// navigation; opt out with PUBLIC_SSR_WARM=no.
export const SSR_WARM = SSR_ENABLED && env.PUBLIC_SSR_WARM !== "no";
// Lifetime of server-rendered snapshots, in seconds. Within FRESH a cached
// page is served as is; up to STALE it is still served at once but refreshed
// in the background; beyond that it is fetched again before answering. Only
// crawlers and cold refreshes see the snapshot — the browser always refetches
// live data after hydration — so these trade first-paint staleness for speed.
function seconds(raw: string | undefined, fallback: number): number {
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
export const SSR_CACHE_FRESH = seconds(env.PUBLIC_SSR_CACHE_FRESH, 300);
export const SSR_CACHE_STALE = seconds(env.PUBLIC_SSR_CACHE_STALE, 6 * 3600);
// The same policy for a shared HTTP cache in front (Cloudflare honours it once
// HTML caching is enabled). Browsers always revalidate (max-age=0) so a login
// shows its content at once.
export const CACHE_CONTROL = `public, max-age=0, s-maxage=${SSR_CACHE_FRESH}, stale-while-revalidate=${SSR_CACHE_STALE}`;
// Requires a relay with NIP-50 support.
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
export const LABELS = (env.PUBLIC_LABELS ?? "")
@ -31,3 +62,14 @@ export const BLOSSOM_URL = (env.PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, "");
// CSS variables defined in layout.css. Hover shades are derived in the layout.
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
// Favicon: any URL (absolute, or root-relative for a file in static/). The
// default is a ring in the accent color (a circle with a transparent hole of
// half its radius), inlined as an SVG data URI.
const DEFAULT_ACCENT = "#e32a6d";
export const FAVICON =
env.PUBLIC_FAVICON ||
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill-rule="evenodd" fill="${ACCENT_COLOR || DEFAULT_ACCENT}" d="M16 0a16 16 0 1 0 0 32a16 16 0 1 0 0-32zM16 8a8 8 0 1 1 0 16a8 8 0 1 1 0-16z"/></svg>`,
);

81
src/lib/forum/groups.ts Normal file
View file

@ -0,0 +1,81 @@
import type { Event } from "@nostr/tools/core";
import { tag, tags, type Query } from "./query";
export type GroupSummary = {
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
name: string;
picture?: string;
about?: string;
createdAt: number;
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
};
export type GroupMetadata = {
name: string;
picture?: string;
about?: string;
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[];
};
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
function parseSummary(e: Event): GroupSummary {
const id = tag(e, "d") ?? "";
return {
id,
name: tag(e, "name") ?? id,
picture: tag(e, "picture"),
about: tag(e, "about"),
createdAt: e.created_at,
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
};
}
// Every group the relay serves this visitor (kind 39000), oldest first.
export async function fetchGroups(q: Query): Promise<GroupSummary[]> {
const events = await q({ kinds: [39000] });
return events
.map(parseSummary)
.filter((g) => g.id)
.sort((a, b) => a.createdAt - b.createdAt);
}
// One group's metadata plus its admins (kinds 39000 + 39001).
export async function fetchGroup(
q: Query,
id: string,
): Promise<GroupMetadata | null> {
const events = await q({ kinds: [39000, 39001], "#d": [id] });
const event = events.find((e) => e.kind === 39000);
if (!event) return null;
const adminsEvent = events.find((e) => e.kind === 39001);
return {
name: tag(event, "name") ?? id,
picture: tag(event, "picture"),
about: tag(event, "about"),
isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"),
isRestricted: event.tags.some((t) => t[0] === "restricted"),
isHidden: event.tags.some((t) => t[0] === "hidden"),
admins: adminsEvent ? tags(adminsEvent, "p") : [],
};
}
// room id -> admin pubkeys (kind 39001 `p` tags) for the given rooms.
export async function fetchRoomAdmins(
q: Query,
roomIds: string[],
): Promise<Record<string, string[]>> {
if (roomIds.length === 0) return {};
const events = await q({ kinds: [39001], "#d": roomIds });
const map: Record<string, string[]> = {};
for (const e of events) {
const d = tag(e, "d");
if (d) map[d] = tags(e, "p");
}
return map;
}

73
src/lib/forum/overview.ts Normal file
View file

@ -0,0 +1,73 @@
import { tag, type Query } from "./query";
export type RoomActivity = {
latestAt: number;
latestPubkey: string;
};
export type RecentThread = {
id: string;
title: string;
groupId: string;
authorPubkey: string;
createdAt: number;
};
export type Overview = {
activity: Record<string, RoomActivity>;
admins: Record<string, string>; // room id -> first admin pubkey
recent: RecentThread[];
};
const RECENT_LIMIT = 20;
// Landing data across rooms: each room's last activity and first admin (for
// the room cards) and the most recent threads overall (for the side panel).
// The group relay truncates multi-value "#h" filters, so rooms are queried
// one by one and merged.
export async function fetchOverview(
q: Query,
roomIds: string[],
): Promise<Overview> {
const latest = await Promise.all(
roomIds.map((id) => q({ kinds: [11, 1111], "#h": [id], limit: 1 })),
);
const activity: Record<string, RoomActivity> = {};
roomIds.forEach((id, i) => {
const e = latest[i][0];
if (e) activity[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
});
const adminEvents = await Promise.all(
roomIds.map((id) => q({ kinds: [39001], "#d": [id] })),
);
const admins: Record<string, string> = {};
roomIds.forEach((id, i) => {
const pk = adminEvents[i][0] ? tag(adminEvents[i][0], "p") : undefined;
if (pk) admins[id] = pk;
});
const perRoom = await Promise.all(
roomIds.map((id) => q({ kinds: [11], "#h": [id], limit: RECENT_LIMIT })),
);
const threads = perRoom.flat().sort((a, b) => b.created_at - a.created_at);
const recent = threads.slice(0, RECENT_LIMIT).map((e) => ({
id: e.id,
title: tag(e, "title") ?? "(untitled)",
groupId: tag(e, "h") ?? "",
authorPubkey: e.pubkey,
createdAt: e.created_at,
}));
return { activity, admins, recent };
}
export function overviewPubkeys(o: Overview): string[] {
return [
...new Set([
...Object.values(o.activity).map((a) => a.latestPubkey),
...Object.values(o.admins),
...o.recent.map((t) => t.authorPubkey),
]),
];
}

48
src/lib/forum/partials.ts Normal file
View file

@ -0,0 +1,48 @@
import { tag, type Query } from "./query";
export type PartialSlot = "home" | "contacts";
const SLOTS = new Set<string>(["home", "contacts"]);
export type Partial = {
id: string;
slot: PartialSlot;
title: string;
content: string; // markdown body
pubkey: string;
createdAt: number;
};
// Every `squalk-partial` event for a known slot. Not admin-filtered nor
// reduced to the newest per slot: both depend on the admin set.
export async function fetchPartials(q: Query): Promise<Partial[]> {
const events = await q({ kinds: [30023], "#t": ["squalk-partial"] });
const out: Partial[] = [];
for (const e of events) {
const slot = tag(e, "d");
if (!slot || !SLOTS.has(slot)) continue;
out.push({
id: e.id,
slot: slot as PartialSlot,
title: tag(e, "title") ?? slot,
content: e.content,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
return out;
}
// The newest admin-authored partial for a slot.
export function pickPartial(
all: Partial[],
slot: PartialSlot,
admins: string[],
): Partial | undefined {
const set = new Set(admins);
let best: Partial | undefined;
for (const p of all) {
if (p.slot !== slot || !set.has(p.pubkey)) continue;
if (!best || p.createdAt > best.createdAt) best = p;
}
return best;
}

75
src/lib/forum/profiles.ts Normal file
View file

@ -0,0 +1,75 @@
import type { Event } from "@nostr/tools/core";
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 {
const npub = nip19.npubEncode(pubkey);
return {
pubkey,
npub,
shortName: npub.substring(0, 8) + "…" + npub.substring(59),
metadata: {},
lastUpdated: 0,
};
}
export function userFromEvent(e: Event): NostrUser {
const u = bareUser(e.pubkey);
let md: NostrUser["metadata"] = {};
try {
md = JSON.parse(e.content) ?? {};
} catch {
// Malformed metadata: keep the bare user
}
u.metadata = md;
u.shortName =
md.name || md.display_name || md.nip05?.split("@")?.[0] || u.shortName;
u.lastUpdated = e.created_at;
if (md.picture) u.image = md.picture;
return u;
}
// Newest kind 0 per pubkey; pubkeys with no metadata are left out.
export async function fetchProfiles(
q: Query,
pubkeys: string[],
): Promise<Record<string, NostrUser>> {
const out: Record<string, NostrUser> = {};
if (pubkeys.length === 0) return out;
const events = await q({ kinds: [0], authors: pubkeys });
for (const e of events) {
const prev = out[e.pubkey];
if (prev && prev.lastUpdated >= e.created_at) continue;
out[e.pubkey] = userFromEvent(e);
}
return out;
}
// In-app profile page, always addressed by npub.
export function profilePath(pubkey: string): string {
return `/profile/${nip19.npubEncode(pubkey)}`;
}
// The pubkey behind a profile URL segment: npub, nprofile or bare hex.
export function decodeProfileId(id: string): string | null {
if (/^[0-9a-f]{64}$/.test(id)) return id;
try {
const decoded = nip19.decode(id);
if (decoded.type === "npub") return decoded.data;
if (decoded.type === "nprofile") return decoded.data.pubkey;
} catch {
// Not a bech32 entity
}
return null;
}

36
src/lib/forum/query.ts Normal file
View file

@ -0,0 +1,36 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
// A relay read. The fetchers in this folder only depend on this signature, so
// the same code runs behind the browser's authenticated connection and the
// server's anonymous one.
export type Query = (filter: Filter) => Promise<Event[]>;
// Drive a subscription on an already-open relay and resolve at EOSE.
export function relayQuery(relay: AbstractRelay): Query {
return (filter) =>
new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
export function tag(e: Event, name: string): string | undefined {
return e.tags.find((t) => t[0] === name)?.[1];
}
export function tags(e: Event, name: string): string[] {
return e.tags.filter((t) => t[0] === name && t[1]).map((t) => t[1]);
}

View file

@ -0,0 +1,47 @@
import { tag, type Query } from "./query";
export type Resource = {
id: string;
slug: string; // NIP-23 `d` tag — also the resource URL slug
title: string;
content: string; // markdown body
position?: number;
pubkey: string;
createdAt: number;
};
// Positioned resources first (ascending), then alphabetical by title.
function compare(a: Resource, b: Resource): number {
const ap = a.position;
const bp = b.position;
if (ap !== undefined && bp !== undefined)
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
if (ap !== undefined) return -1;
if (bp !== undefined) return 1;
return a.title.localeCompare(b.title);
}
// Every `squalk-resource` article, newest per slug. Not admin-filtered: the
// caller applies the admin set, which may resolve later than this fetch.
export async function fetchResources(q: Query): Promise<Resource[]> {
const events = await q({ kinds: [30023], "#t": ["squalk-resource"] });
const bySlug = new Map<string, Resource>();
for (const e of events) {
const slug = tag(e, "d");
if (!slug) continue;
const existing = bySlug.get(slug);
if (existing && existing.createdAt >= e.created_at) continue;
const posTag = tag(e, "position");
const pos = posTag !== undefined ? Number(posTag) : NaN;
bySlug.set(slug, {
id: e.id,
slug,
title: tag(e, "title") ?? slug,
content: e.content,
position: Number.isFinite(pos) ? pos : undefined,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
return [...bySlug.values()].sort(compare);
}

74
src/lib/forum/snapshot.ts Normal file
View file

@ -0,0 +1,74 @@
import { browser } from "$app/environment";
import { error } from "@sveltejs/kit";
import { SSR_WARM } from "$lib/config";
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.
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>;
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
// 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 — but
// may still poke the endpoint so the server has the page cached.
// A 404 from the endpoint becomes a real 404 page on the server, so crawlers
// never index an empty shell; the client keeps its own not-found handling.
export async function snapshot<T>(
fetch: Fetch,
url: string,
): Promise<T | null> {
if (browser && !hasInlinedResponse(url)) {
if (SSR_WARM) fetch(url).catch(() => {});
return null;
}
let res: Response;
try {
res = await fetch(url);
} catch {
return null;
}
if (res.status === 404 && !browser) error(404, "Not found");
if (!res.ok) return null;
try {
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)}]`,
);
}

56
src/lib/forum/thread.ts Normal file
View file

@ -0,0 +1,56 @@
import { tag, tags, type Query } from "./query";
export type PostData = {
id: string;
pubkey: string;
createdAt: number;
content: string;
};
export type ThreadDetail = {
id: string;
title: string;
labels: string[];
groupId: string; // the thread's NIP-29 group (its `h` tag)
op: PostData;
replies: PostData[];
};
// A thread (kind 11) with its flat replies (kind 1111), oldest first. Null
// when the relay returns nothing: no such thread, or one the visitor can't
// read. `fallbackGroup` covers OPs missing an `h` tag.
export async function fetchThread(
q: Query,
id: string,
fallbackGroup = "",
): Promise<ThreadDetail | null> {
const [threadEvents, replyEvents] = await Promise.all([
q({ kinds: [11], ids: [id] }),
q({ kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
if (!event) return null;
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
return {
id: event.id,
title: tag(event, "title") ?? "(untitled)",
labels: tags(event, "t"),
groupId: tag(event, "h") ?? fallbackGroup,
op: {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
},
replies: replies.map((r) => ({
id: r.id,
pubkey: r.pubkey,
createdAt: r.created_at,
content: r.content,
})),
};
}
export function threadAuthors(t: ThreadDetail): string[] {
return [...new Set([t.op.pubkey, ...t.replies.map((r) => r.pubkey)])];
}

238
src/lib/forum/threads.ts Normal file
View file

@ -0,0 +1,238 @@
import type { Event } from "@nostr/tools/core";
import { tag, tags, type Query } from "./query";
export 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;
labels: string[];
authorPubkey: string;
createdAt: number;
replyCount: number;
latestAt: number;
latestPubkey: string;
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
};
export type ThreadPage = {
threads: ThreadData[];
nextCursor: number | null; // sort-key value of the last thread
done: boolean;
};
export type PageRequest = {
sort: SortMode;
until: number; // upper bound on the sort key (inclusive)
snapshotAt: number; // upper time bound for reply counts, frozen per listing
exclude?: Set<string>; // thread ids already shown
n?: number;
};
type SliceItem = {
id: string;
latestAt: number;
latestPubkey: string;
op?: Event;
};
type Slice = { items: SliceItem[]; nextCursor: number | null; done: boolean };
function threadIdOf(e: Event): string | undefined {
return e.kind === 11 ? e.id : tag(e, "E");
}
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
// threads not already shown. The first event seen for a thread defines its
// activity timestamp.
async function fetchActivitySlice(
q: Query,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<Slice> {
const collected = new Map<string, SliceItem>();
let cur = until;
let done = false;
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
const events = await q({
kinds: [11, 1111],
"#h": [groupId],
until: cur,
limit: WALK_LIMIT,
});
if (events.length === 0) {
done = true;
break;
}
events.sort((a, b) => b.created_at - a.created_at);
let oldest = cur;
for (const e of events) {
oldest = Math.min(oldest, e.created_at);
const id = threadIdOf(e);
if (!id || exclude.has(id)) continue;
const seen = collected.get(id);
if (seen) {
// Grab the OP from the stream when a thread was first seen via a reply,
// so we avoid the by-id backfill the group relay truncates
if (!seen.op && e.kind === 11) seen.op = e;
continue;
}
if (collected.size >= n) continue; // Page full; keep scanning for OPs
collected.set(id, {
id,
latestAt: e.created_at,
latestPubkey: e.pubkey,
op: e.kind === 11 ? e : undefined,
});
}
if (events.length < WALK_LIMIT) {
done = true;
break;
}
if (oldest >= cur) break; // No progress (single timestamp floods the window)
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
}
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
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(
q: Query,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<Slice> {
const ops = await q({
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 };
}
async function enrichWithReplies(
q: Query,
groupId: string,
items: SliceItem[],
snapshotAt: number,
): Promise<Map<string, { count: number; repliers: string[] }>> {
const result = new Map<string, { count: number; repliers: string[] }>();
if (items.length === 0) return result;
const replies = await q({
kinds: [1111],
"#h": [groupId],
"#E": items.map((it) => it.id),
until: snapshotAt,
limit: 5000,
});
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
for (const r of replies) {
const root = tag(r, "E");
if (!root) continue;
let a = acc.get(root);
if (!a) {
a = { count: 0, pubkeys: new Set() };
acc.set(root, a);
}
a.count++;
a.pubkeys.add(r.pubkey);
}
for (const it of items) {
const a = acc.get(it.id);
const repliers = a
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
: [];
result.set(it.id, { count: a?.count ?? 0, repliers });
}
return result;
}
async function buildThreads(
q: Query,
groupId: string,
items: SliceItem[],
snapshotAt: number,
): Promise<ThreadData[]> {
const missing = items.filter((it) => !it.op);
if (missing.length > 0) {
const fetched = await Promise.all(
missing.map((it) => q({ kinds: [11], "#h": [groupId], ids: [it.id] })),
);
const byId = new Map<string, Event>();
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
for (const it of items) if (!it.op) it.op = byId.get(it.id);
}
const enriched = await enrichWithReplies(q, groupId, items, snapshotAt);
const out: ThreadData[] = [];
for (const it of items) {
const op = it.op;
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
const e = enriched.get(it.id);
out.push({
id: it.id,
title: tag(op, "title") ?? "(untitled)",
labels: tags(op, "t"),
authorPubkey: op.pubkey,
createdAt: op.created_at,
replyCount: e?.count ?? 0,
latestAt: it.latestAt,
latestPubkey: it.latestPubkey,
replierPubkeys: e?.repliers ?? [],
});
}
return out;
}
// One page of a group's thread listing, in the given sort order.
export async function fetchThreadPage(
q: Query,
groupId: string,
req: PageRequest,
): Promise<ThreadPage> {
const exclude = req.exclude ?? new Set<string>();
const n = req.n ?? PAGE_SIZE;
const slice =
req.sort === "new"
? await fetchNewSlice(q, groupId, req.until, exclude, n)
: await fetchActivitySlice(q, groupId, req.until, exclude, n);
const threads = await buildThreads(q, groupId, slice.items, req.snapshotAt);
return { threads, nextCursor: slice.nextCursor, done: slice.done };
}
export function threadPagePubkeys(threads: ThreadData[]): string[] {
const set = new Set<string>();
for (const t of threads) {
set.add(t.authorPubkey);
set.add(t.latestPubkey);
for (const p of t.replierPubkeys) set.add(p);
}
return [...set];
}

106
src/lib/forum/userPosts.ts Normal file
View file

@ -0,0 +1,106 @@
import type { Event } from "@nostr/tools/core";
import { tag, type Query } from "./query";
export type UserPost = {
id: string;
threadId: string; // the root thread; equals `id` for a root post
title: string; // thread title, empty when the root is unreachable
groupId: string;
createdAt: number;
content: string;
};
export type UserPostKind = "roots" | "replies";
export type UserPostPage = {
items: UserPost[];
done: boolean; // no older posts left
};
export type UserPosts = Record<UserPostKind, UserPostPage>;
// Posts per "Show more" step
export const USER_POSTS_PAGE = 20;
const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor
export type UserPostPageRequest = {
groupId?: string; // scope to one group (simple mode)
until?: number; // oldest loaded timestamp, inclusive
exclude?: Set<string>; // ids already shown
n?: number;
};
function newestFirst(events: Event[]): Event[] {
return [...events].sort((a, b) => b.created_at - a.created_at);
}
// One page of a user's root posts (kind 11) or replies (kind 1111), newest
// first, older than the cursor. Without `groupId` every group the relay
// serves the caller counts.
export async function fetchUserPostPage(
q: Query,
pubkey: string,
kind: UserPostKind,
req: UserPostPageRequest = {},
): Promise<UserPostPage> {
const n = req.n ?? USER_POSTS_PAGE;
const exclude = req.exclude ?? new Set<string>();
const events = await q({
kinds: [kind === "roots" ? 11 : 1111],
authors: [pubkey],
...(req.groupId ? { "#h": [req.groupId] } : {}),
...(req.until ? { until: req.until } : {}),
limit: n + CURSOR_HEADROOM,
});
const fresh = newestFirst(events).filter(
(e) => !exclude.has(e.id) && (kind === "roots" || tag(e, "E")),
);
const slice = fresh.slice(0, n);
// Finished only when everything fresh fits the page and the relay had
// nothing beyond the requested window
const done = fresh.length <= n && events.length < n + CURSOR_HEADROOM;
// Replies take their title from the root thread
const titles = new Map<string, string>();
if (kind === "replies" && slice.length > 0) {
const rootIds = [...new Set(slice.map((e) => tag(e, "E")!))];
const roots = await q({ kinds: [11], ids: rootIds });
for (const e of roots) titles.set(e.id, tag(e, "title") ?? "(untitled)");
}
const items = slice.map((e) => {
const threadId = kind === "roots" ? e.id : tag(e, "E")!;
return {
id: e.id,
threadId,
title:
kind === "roots"
? (tag(e, "title") ?? "(untitled)")
: (titles.get(threadId) ?? ""),
groupId: tag(e, "h") ?? req.groupId ?? "",
createdAt: e.created_at,
content: e.content,
};
});
return { items, done };
}
// The first page of both columns.
export async function fetchUserPosts(
q: Query,
pubkey: string,
groupId?: string,
): Promise<UserPosts> {
const [roots, replies] = await Promise.all([
fetchUserPostPage(q, pubkey, "roots", { groupId }),
fetchUserPostPage(q, pubkey, "replies", { groupId }),
]);
return { roots, replies };
}
// Cursor for the page after `page`: the oldest timestamp shown, inclusive
// (the ids already shown are excluded on the next read).
export function nextCursor(page: UserPostPage): number | undefined {
const last = page.items[page.items.length - 1];
return last?.createdAt;
}

17
src/lib/gadgets.ts Normal file
View file

@ -0,0 +1,17 @@
import type { NostrUser } from "@nostr/gadgets/metadata";
import type { ListFetcher, RelayItem } from "@nostr/gadgets/lists";
export type { NostrUser, RelayItem };
// @nostr/gadgets reads localStorage when imported, so it can't be loaded on
// the server. Every consumer goes through these lazy wrappers; the library is
// pulled in on first use, in the browser only.
export async function loadNostrUser(pubkey: string): Promise<NostrUser> {
const m = await import("@nostr/gadgets/metadata");
return m.loadNostrUser(pubkey);
}
export const loadRelayList: ListFetcher<RelayItem> = async (...args) => {
const m = await import("@nostr/gadgets/lists");
return m.loadRelayList(...args);
};

View file

@ -1,20 +1,11 @@
import { page } from "$app/state";
import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay";
import { groupsStore } from "$lib/groups.svelte";
import { fetchGroup, type GroupMetadata } from "$lib/forum/groups";
export type GroupMetadata = {
name: string;
picture?: string;
about?: string;
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[];
};
export type { GroupMetadata };
// NIP-29 access flags: private = members-only read, restricted = members-only
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
export type GroupFlags = {
isPrivate: boolean;
isClosed: boolean;
@ -25,44 +16,24 @@ 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;
},
};
export async function loadGroup() {
try {
const events = await queryForum({
kinds: [39000, 39001],
"#d": [GROUP_ID],
});
const event = events.find((e) => e.kind === 39000);
if (!event) return;
// Admins live in the NIP-29 kind 39001 event as `p` tags.
const adminsEvent = events.find((e) => e.kind === 39001);
const admins =
adminsEvent?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
group = {
name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID,
picture: event.tags.find((t) => t[0] === "picture")?.[1],
about: event.tags.find((t) => t[0] === "about")?.[1],
isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"),
isRestricted: event.tags.some((t) => t[0] === "restricted"),
isHidden: event.tags.some((t) => t[0] === "hidden"),
admins,
};
group = (await fetchGroup(queryForum, GROUP_ID)) ?? group;
} finally {
loaded = true;
}
}
// A group's access flags from whichever store holds them: full mode keeps every
// room in the groups list, simple mode has the single active group's metadata.
export function getGroupFlags(groupId: string): GroupFlags | null {
if (MODE === "full") {
const g = groupsStore.list.find((x) => x.id === groupId);
@ -82,3 +53,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
isHidden: group.isHidden,
};
}
// Display name for a group from whichever store holds it, falling back to the
// id when metadata hasn't loaded.
export function getGroupName(groupId: string): string {
if (MODE === "full") {
return groupsStore.list.find((x) => x.id === groupId)?.name ?? groupId;
}
return group?.name ?? groupId;
}

View file

@ -1,49 +1,25 @@
import { page } from "$app/state";
import { queryForum } from "$lib/relay";
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
export type GroupSummary = {
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
name: string;
picture?: string;
about?: string;
createdAt: number;
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
};
// NIP-29 metadata markers we surface as room tags, in display order.
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
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;
},
};
// Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata
// event per group, so an unfiltered query enumerates them all.
export async function loadGroups() {
try {
const events = await queryForum({ kinds: [39000] });
list = events
.map((e) => {
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? "";
return {
id,
name: e.tags.find((t) => t[0] === "name")?.[1] ?? id,
picture: e.tags.find((t) => t[0] === "picture")?.[1],
about: e.tags.find((t) => t[0] === "about")?.[1],
createdAt: e.created_at,
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
};
})
.filter((g) => g.id)
// Oldest first.
.sort((a, b) => a.createdAt - b.createdAt);
list = await fetchGroups(queryForum);
} finally {
loaded = true;
}

View file

@ -1,24 +1,42 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
import { auth } from "$lib/auth.svelte";
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
import { ensureForumRelay, publishForum } from "$lib/relay";
import { getGroupFlags } from "$lib/group.svelte";
import { GROUP_ID, MODE } from "$lib/config";
import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
import { getGroupName } from "$lib/group.svelte";
import { showToast } from "$lib/toast.svelte";
type Membership = "member" | "guest";
// "none": the relay doesn't support NIP-43, so no relay-level join exists.
type RelayMembership = Membership | "none";
// Per-group membership, resolved lazily and cached reactively so compose
// actions can be gated before the user writes anything. Keyed by pubkey so a
// Writing to a group can be gated at two levels: the relay itself (NIP-43) and
// the group (NIP-29). Neither gate is advertised up front, so membership is
// probed by reading the relay's membership events and, failing that, by trying
// to join and reacting to the rejection. Both caches are keyed by pubkey so a
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
// "guest" cached before auth.user was populated; the read stays a pure getter
// (no state writes inside a derivation).
// "guest" cached before auth.user was populated.
let membership = $state<Record<string, Membership>>({});
let relayMembership = $state<Record<string, RelayMembership>>({});
const checking = new Map<string, Promise<void>>();
// The modal only appears when a relay refused a step and needs user input.
// - relay-code: the relay requires an invite code (NIP-43 claim)
// - group-code: the group refused the join; a code may be needed
// - pending: the group accepted the request but membership isn't granted yet
// (admin approval); an invite code, if any, can still be tried
// - error: a terminal refusal (e.g. banned); nothing the user can enter helps
export type JoinStep = "relay-code" | "group-code" | "pending" | "error";
let modalOpen = $state(false);
let modalStep = $state<JoinStep>("group-code");
let modalGroup = $state<string | null>(null);
let modalError = $state<string | null>(null);
// A rejection message that mentions a code makes the code field the primary
// affordance rather than a discreet fallback.
let modalCodeHinted = $state(false);
let busy = $state(false);
// Continuation to run once the join succeeds (open the composer, focus the
// Continuation to run once access is granted (open the composer, focus the
// reply box, reload a now-visible feed, or retry the original post).
let onJoined: (() => void | Promise<void>) | null = null;
@ -29,26 +47,27 @@ export const joinState = {
get modalOpen() {
return modalOpen;
},
get modalStep() {
return modalStep;
},
get modalError() {
return modalError;
},
get modalCodeHinted() {
return modalCodeHinted;
},
get busy() {
return busy;
},
// Closed groups (and a globally configured invite gate) can't be self-joined,
// so the modal collects an invite code; open groups just confirm.
get needsCode() {
if (JOINCODE_REQUIRED) return true;
const f = modalGroup ? getGroupFlags(modalGroup) : null;
return !!f?.isClosed;
},
};
export function resetJoinState() {
membership = {};
relayMembership = {};
modalOpen = false;
modalGroup = null;
modalError = null;
modalCodeHinted = false;
busy = false;
onJoined = null;
}
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
return p;
}
type SubFilter = Parameters<AbstractRelay["subscribe"]>[0][number];
// Collects every event matching the filter until EOSE/close/timeout.
function queryEvents(
relay: AbstractRelay,
filter: SubFilter,
timeoutMs = 3000,
): Promise<Event[]> {
return new Promise((resolve) => {
let settled = false;
const events: Event[] = [];
const finish = () => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(events);
};
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose: finish,
onclose: finish,
});
setTimeout(finish, timeoutMs);
});
}
// Existence check for a single matching event (resolves true on first event,
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
// false on EOSE/close/timeout).
function queryHasMatch(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
filter: SubFilter,
timeoutMs = 3000,
): Promise<boolean> {
return new Promise((resolve) => {
@ -120,39 +169,34 @@ function queryHasMatch(
// Newest `created_at` among events matching the filter, or null if none. We
// don't trust the relay to honour limit ordering, so we keep the max across all
// returned events (the #p filter keeps the set tiny).
function latestCreatedAt(
async function latestCreatedAt(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
timeoutMs = 3000,
filter: SubFilter,
): Promise<number | null> {
return new Promise((resolve) => {
let settled = false;
let latest: number | null = null;
const finish = () => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(latest);
};
const sub = relay.subscribe([filter], {
onevent(e) {
if (latest === null || e.created_at > latest) latest = e.created_at;
},
oneose: finish,
onclose: finish,
});
setTimeout(finish, timeoutMs);
});
const events = await queryEvents(relay, filter);
return events.reduce<number | null>(
(max, e) => (max === null || e.created_at > max ? e.created_at : max),
null,
);
}
// Membership signal for one group — i.e. "is this user allowed to write here".
// Admins (kind:39001) can post but need not appear in the members list, so they
// count as members. Otherwise NIP-29 records membership as moderation events:
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
// this user decides current membership (a later 9001 unjoins them). When the
// relay keeps neither, fall back to the kind:39002 members list.
// Add/remove events are the freshest signal: the newest of the two decides
// (a later removal unjoins). When the relay keeps neither, the list decides.
function decideMembership(
added: number | null,
removed: number | null,
listed: boolean,
): boolean {
if (added !== null || removed !== null) {
return added !== null && added >= (removed ?? -Infinity);
}
return listed;
}
// Group membership — i.e. "is this user allowed to write here". Admins
// (kind:39001) can post but need not appear in the members list, so they count
// as members. Otherwise NIP-29 records membership as kind:9000 (add) and
// kind:9001 (remove) moderation events, with the kind:39002 list as fallback.
async function checkMembership(
pubkey: string,
groupId: string,
@ -163,7 +207,7 @@ async function checkMembership(
} catch {
return false;
}
const [isAdmin, added, removed] = await Promise.all([
const [isAdmin, added, removed, listed] = await Promise.all([
queryHasMatch(relay, {
kinds: [39001],
"#d": [groupId],
@ -172,25 +216,240 @@ async function checkMembership(
}),
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
}),
]);
if (isAdmin) return true;
if (added !== null || removed !== null) {
return added !== null && added >= (removed ?? -Infinity);
}
return queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
});
return decideMembership(added, removed, listed);
}
// Simple mode pre-checks the single configured group at login so the first post
// skips the 9021. Full mode checks lazily per room when the user first acts.
// Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
// kind:8001 (remove) signed by the relay, with the kind:13534 list as fallback.
// The `member` tag isn't filterable, so the list is fetched and scanned.
async function checkRelayMembership(
pubkey: string,
self: string,
): Promise<boolean> {
let relay: AbstractRelay;
try {
relay = await ensureForumRelay();
} catch {
return false;
}
const [added, removed, lists] = await Promise.all([
latestCreatedAt(relay, { kinds: [8000], authors: [self], "#p": [pubkey] }),
latestCreatedAt(relay, { kinds: [8001], authors: [self], "#p": [pubkey] }),
queryEvents(relay, { kinds: [13534], authors: [self], limit: 1 }),
]);
const listed = lists.some((e) =>
e.tags.some((t) => t[0] === "member" && t[1] === pubkey),
);
return decideMembership(added, removed, listed);
}
function ensureRelayMembershipChecked(pubkey: string): Promise<void> {
if (relayMembership[pubkey]) return Promise.resolve();
const k = `relay:${pubkey}`;
const existing = checking.get(k);
if (existing) return existing;
const p = (async () => {
try {
const self = await nip43Self();
if (!self) {
relayMembership[pubkey] = "none";
return;
}
const isMember = await checkRelayMembership(pubkey, self);
relayMembership[pubkey] = isMember ? "member" : "guest";
} finally {
checking.delete(k);
}
})();
checking.set(k, p);
return p;
}
// Probes both membership levels at login so a returning member's first post
// needs no join at all. Full mode checks groups lazily per room instead.
export async function initJoinForUser(pubkey: string) {
if (MODE !== "simple") return;
if (await checkMembership(pubkey, GROUP_ID)) {
membership[memberKey(GROUP_ID)] = "member";
await Promise.all([
ensureRelayMembershipChecked(pubkey),
MODE === "simple" ? ensureMembershipChecked(GROUP_ID) : undefined,
]);
}
// Publishing rejections carry the relay's NIP-01 OK message; the prefix is the
// only cross-relay contract for what went wrong.
function reason(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
const prefixed = (msg: string, prefix: string) =>
msg.toLowerCase().startsWith(prefix);
const stripPrefix = (msg: string) => msg.replace(/^[a-z-]+:\s*/i, "");
const mentionsCode = (msg: string) => /\b(code|invite|claim)\b/i.test(msg);
async function publishSigned(kind: number, tags: string[][]): Promise<void> {
if (!auth.signer) throw new Error("Not logged in");
const event = await auth.signer.signEvent({
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
type Codes = { relay?: string; group?: string };
type Refusal = { step: JoinStep; message: string | null; codeHinted: boolean };
type Outcome = { ok: true } | ({ ok: false } & Refusal);
const fail = (
step: JoinStep,
message: string | null,
codeHinted = false,
): Outcome => ({ ok: false, step, message, codeHinted });
// Bare relay joins the relay declined, per pubkey. A refusal is only a hint:
// some relays (pyramid) let non-members into open groups, so the group join
// decides whether relay membership was needed. Remembering it spares one
// doomed signature per room.
const relayRefusals: Record<string, Outcome> = {};
// Relay-level join (NIP-43). Returns the refusal, if any, without acting on it.
async function tryRelayJoin(pubkey: string, codes: Codes): Promise<Outcome> {
await ensureRelayMembershipChecked(pubkey);
if (relayMembership[pubkey] !== "guest") return { ok: true };
if (!codes.relay && relayRefusals[pubkey]) return relayRefusals[pubkey];
const tags: string[][] = [["-"]];
if (codes.relay) tags.push(["claim", codes.relay]);
let outcome: Outcome = { ok: true };
let policy = false;
try {
await publishSigned(28934, tags);
relayMembership[pubkey] = "member";
} catch (e) {
const msg = reason(e);
policy = prefixed(msg, "restricted:") || prefixed(msg, "blocked:");
if (prefixed(msg, "duplicate:")) {
relayMembership[pubkey] = "member";
} else if (prefixed(msg, "blocked:")) {
outcome = fail("error", stripPrefix(msg));
} else {
// A bare request refused is the expected probe, not a user error
outcome = fail("relay-code", codes.relay ? stripPrefix(msg) : null, true);
}
}
if (outcome.ok) delete relayRefusals[pubkey];
else if (!codes.relay && policy) relayRefusals[pubkey] = outcome;
return outcome;
}
// Runs the two-level join: relay first (a group join may need relay
// membership), then group. Everything free happens silently; the first
// refusal that actually blocks is reported as the step needing user input.
async function ensureAccess(groupId: string, codes: Codes): Promise<Outcome> {
const pubkey = auth.user?.pubkey;
if (!pubkey) return fail("error", "Not logged in");
await ensureMembershipChecked(groupId);
const k = memberKey(groupId);
if (membership[k] === "member") return { ok: true };
const relay = await tryRelayJoin(pubkey, codes);
// A code the user typed is judged at once; a bare refusal only counts if
// the group join fails too
if (!relay.ok && codes.relay) return relay;
const tags: string[][] = [["h", groupId]];
if (codes.group) tags.push(["code", codes.group]);
try {
await publishSigned(9021, tags);
} catch (e) {
const msg = reason(e);
if (!prefixed(msg, "duplicate:")) {
if (prefixed(msg, "restricted:") && !relay.ok) return relay;
if (prefixed(msg, "restricted:")) {
const hinted = mentionsCode(msg);
return fail(
"group-code",
codes.group || !hinted ? stripPrefix(msg) : null,
hinted,
);
}
return fail("error", stripPrefix(msg));
}
}
// An accepted request doesn't imply membership: closed groups may hold it
// for admin approval, so re-read the relay's records before trusting it.
if (await checkMembership(pubkey, groupId)) {
membership[k] = "member";
showToast(`You joined ${getGroupName(groupId)}`);
return { ok: true };
}
return fail("pending", codes.group ? "The code was not accepted" : null);
}
function openModal(
groupId: string | null,
refusal: Refusal,
cb: (() => void | Promise<void>) | null,
) {
modalGroup = groupId;
onJoined = cb;
modalStep = refusal.step;
modalCodeHinted = refusal.codeHinted;
modalError = refusal.message;
modalOpen = true;
}
// Wraps an action that posts to `groupId`, joining relay and group first when
// needed. A write refused for membership reasons (e.g. a stale cache after the
// relay changed policy) invalidates both caches and retries once from the top.
// Returns false when the modal took over; the action then runs on its success.
export async function withJoin(
groupId: string,
action: () => void | Promise<void>,
): Promise<boolean> {
if (busy) return false;
busy = true;
try {
return await runWithAccess(groupId, action, {}, true);
} finally {
busy = false;
}
}
async function runWithAccess(
groupId: string,
action: () => void | Promise<void>,
codes: Codes,
retry: boolean,
): Promise<boolean> {
const outcome = await ensureAccess(groupId, codes);
if (!outcome.ok) {
openModal(groupId, outcome, action);
return false;
}
try {
await action();
return true;
} catch (e) {
const pubkey = auth.user?.pubkey;
if (retry && pubkey && prefixed(reason(e), "restricted:")) {
delete membership[memberKey(groupId)];
delete relayRefusals[pubkey];
if (relayMembership[pubkey] === "member")
relayMembership[pubkey] = "guest";
return runWithAccess(groupId, action, codes, false);
}
throw e;
}
}
@ -202,86 +461,51 @@ export function closeJoinModal() {
onJoined = null;
}
// Opens the join modal for a group, with an optional continuation to run after
// a successful join (open the composer, focus the reply box, reload the feed).
export function openJoinModal(
groupId: string,
cb?: () => void | Promise<void>,
) {
modalGroup = groupId;
onJoined = cb ?? null;
modalError = null;
modalOpen = true;
}
async function publishJoinRequest(groupId: string, code?: string) {
if (!auth.signer) throw new Error("Not logged in");
const tags: string[][] = [["h", groupId]];
if (code) tags.push(["code", code]);
const event = await auth.signer.signEvent({
kind: 9021,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
// Sends the kind:9021 for the modal's target group, marks the user joined, and
// runs the pending continuation. Errors keep the modal open for a retry.
export async function submitJoin(code?: string) {
if (!modalGroup || busy) return;
// Re-runs the join with the code entered for the current step. Errors keep the
// modal open for a retry; a further gate switches the modal to that step.
export async function submitJoinCode(code?: string) {
if (!modalOpen || busy) return;
busy = true;
modalError = null;
const groupId = modalGroup;
const cb = onJoined;
const codes: Codes =
modalStep === "relay-code" ? { relay: code } : { group: code };
try {
await publishJoinRequest(groupId, code);
membership[memberKey(groupId)] = "member";
const pubkey = auth.user?.pubkey;
const outcome = groupId
? await ensureAccess(groupId, codes)
: pubkey
? await tryRelayJoin(pubkey, codes)
: fail("error", "Not logged in");
if (!outcome.ok) {
openModal(groupId, outcome, cb);
return;
}
modalOpen = false;
modalGroup = null;
onJoined = null;
if (cb) await cb();
} catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group";
modalError = reason(e);
} finally {
busy = false;
}
}
// Wraps an action that posts to `groupId`. The relay requires membership to
// write to any group, so a non-member is joined first. Compose entry points
// gate membership up front, making this mostly a safety net: it joins
// transparently if possible, else opens the modal to retry.
export async function withJoin(
groupId: string,
action: () => Promise<void>,
): Promise<boolean> {
if (membership[memberKey(groupId)] === "member") {
await action();
return true;
}
// Relay-only join, for a relay that refuses reads from non-members: no group
// is involved, so the modal (if any) targets the relay alone.
export async function joinRelay(cb: () => void | Promise<void>): Promise<void> {
const pubkey = auth.user?.pubkey;
if (!pubkey || busy) return;
busy = true;
try {
const pubkey = auth.user?.pubkey;
if (pubkey && (await checkMembership(pubkey, groupId))) {
membership[memberKey(groupId)] = "member";
await action();
return true;
const outcome = await tryRelayJoin(pubkey, {});
if (!outcome.ok) {
openModal(null, outcome, cb);
return;
}
await publishJoinRequest(groupId);
await action();
membership[memberKey(groupId)] = "member";
return true;
} catch (e) {
modalGroup = groupId;
onJoined = action;
modalError = e instanceof Error ? e.message : "Could not join the group";
modalOpen = true;
return false;
await cb();
} finally {
busy = false;
}

View file

@ -157,7 +157,7 @@ function decodeEntity(entity: string): ChatToken | null {
return {
type: "entity",
entity,
href: `https://njump.me/${entity}`,
href: `https://nostr.basspistol.org/${entity}`,
label: shortNostrEntity(entity),
id: decoded.type === "note" ? decoded.data : decoded.data.id,
};
@ -165,7 +165,7 @@ function decodeEntity(entity: string): ChatToken | null {
return {
type: "entity",
entity,
href: `https://njump.me/${entity}`,
href: `https://nostr.basspistol.org/${entity}`,
label: shortNostrEntity(entity),
};
} catch {

View file

@ -1,5 +1,5 @@
import * as nip19 from "@nostr/tools/nip19";
import { loadRelayList } from "@nostr/gadgets/lists";
import { loadRelayList } from "$lib/gadgets";
const RELAY_HINT_TIMEOUT_MS = 1500;

View file

@ -1,22 +1,16 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { page } from "$app/state";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { ensureForumRelay } from "$lib/relay";
import { ingestNostrUser } from "$lib/profiles.svelte";
import { relayQuery } from "$lib/forum/query";
import {
fetchOverview,
overviewPubkeys,
type RecentThread,
type RoomActivity,
} from "$lib/forum/overview";
export type RoomActivity = {
latestAt: number;
latestPubkey: string;
};
export type RecentThread = {
id: string;
title: string;
groupId: string;
authorPubkey: string;
createdAt: number;
};
export type { RecentThread, RoomActivity };
let activity = $state<Record<string, RoomActivity>>({});
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
@ -24,43 +18,32 @@ let recent = $state<RecentThread[]>([]);
let profiles = $state<Record<string, NostrUser>>({});
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;
},
};
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return;
const user = await loadNostrUser(pubkey);
@ -77,54 +60,15 @@ export async function loadOverview(roomIds: string[]) {
loadedKey = key;
loading = true;
const relay = await ensureForumRelay();
try {
// One tiny query per room for its newest event (thread or reply).
const latest = await Promise.all(
roomIds.map((id) =>
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }),
),
);
const act: Record<string, RoomActivity> = {};
roomIds.forEach((id, i) => {
const e = latest[i][0];
if (e) act[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
});
activity = act;
// Each room's admin (NIP-29 kind 39001, first `p` tag) for the card byline.
const adminEvents = await Promise.all(
roomIds.map((id) => querySync(relay, { kinds: [39001], "#d": [id] })),
);
const adm: Record<string, string> = {};
roomIds.forEach((id, i) => {
const pk = adminEvents[i][0]?.tags.find((t) => t[0] === "p")?.[1];
if (pk) adm[id] = pk;
});
admins = adm;
// Most recent discussions (thread OPs) across all rooms. The group relay
// truncates multi-value "#h" filters, so query each room and merge.
const perRoom = await Promise.all(
roomIds.map((id) =>
querySync(relay, { kinds: [11], "#h": [id], limit: 20 }),
),
);
const threads = perRoom.flat();
threads.sort((a, b) => b.created_at - a.created_at);
recent = threads.slice(0, 20).map((e) => ({
id: e.id,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
groupId: e.tags.find((t) => t[0] === "h")?.[1] ?? "",
authorPubkey: e.pubkey,
createdAt: e.created_at,
}));
for (const a of Object.values(act)) loadProfile(a.latestPubkey);
for (const pk of Object.values(adm)) loadProfile(pk);
for (const t of recent) loadProfile(t.authorPubkey);
const q = relayQuery(await ensureForumRelay());
const o = await fetchOverview(q, roomIds);
activity = o.activity;
admins = o.admins;
recent = o.recent;
settled = true;
overviewPubkeys(o).forEach(loadProfile);
} finally {
// shared forum connection is long-lived — don't close it here
loading = false;
}
}

61
src/lib/pageHighlight.ts Normal file
View file

@ -0,0 +1,61 @@
import {
findPhraseSpans,
findMatchSpans,
tokenize,
type Span,
} from "$lib/textMatch";
const HIGHLIGHT_NAME = "search-terms";
function supported(): boolean {
return typeof CSS !== "undefined" && "highlights" in CSS;
}
// Highlight the query inside the given roots using the CSS Custom Highlight
// API, which paints ranges without touching the DOM, so it never conflicts
// with Svelte re-renders. When the whole query appears contiguously anywhere
// on the page, only those phrase occurrences are painted; otherwise the
// textMatch fallback (contiguous runs as blocks, then single words) is used.
// Returns the first match in document order (for scrolling), or null. No-op
// on unsupported browsers.
export function applyHighlights(
roots: Iterable<Element>,
query: string,
): Range | null {
if (!supported()) return null;
CSS.highlights.delete(HIGHLIGHT_NAME);
if (tokenize(query).length === 0) return null;
const nodes: Node[] = [];
for (const root of roots) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) nodes.push(node);
}
let perNode: Span[][] = nodes.map((n) =>
findPhraseSpans(n.textContent ?? "", query),
);
if (perNode.every((spans) => spans.length === 0)) {
perNode = nodes.map((n) => findMatchSpans(n.textContent ?? "", query));
}
const ranges: Range[] = [];
nodes.forEach((node, i) => {
for (const span of perNode[i]) {
const range = new Range();
range.setStart(node, span.start);
range.setEnd(node, span.end);
ranges.push(range);
}
});
if (ranges.length === 0) return null;
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));
ranges.sort((a, b) => a.compareBoundaryPoints(Range.START_TO_START, b));
return ranges[0];
}
export function clearHighlights() {
if (supported()) CSS.highlights.delete(HIGHLIGHT_NAME);
}

View file

@ -1,63 +1,32 @@
import { page } from "$app/state";
import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay";
import {
fetchPartials,
pickPartial,
type Partial,
type PartialSlot,
} from "$lib/forum/partials";
// Named slots a partial can fill. The NIP-23 `d` tag carries the slot name.
export type PartialSlot = "home" | "contacts";
const SLOTS = new Set<string>(["home", "contacts"]);
export type Partial = {
id: string;
slot: PartialSlot;
title: string;
content: string; // markdown body
pubkey: string;
createdAt: number;
};
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 = {
// The newest admin-authored partial for a slot, or undefined. The relay query
// is open, so the trusted admin set is the gate; resolving the winner here
// (rather than at load time) keeps a non-admin from shadowing it with a newer
// event, and picks up the admin set once it finishes loading in full mode.
get(slot: PartialSlot): Partial | undefined {
const admins = new Set(adminPubkeys.list);
let best: Partial | undefined;
for (const p of all) {
if (p.slot !== slot || !admins.has(p.pubkey)) continue;
if (!best || p.createdAt > best.createdAt) best = p;
}
return best;
const source = loaded ? all : (page.data.shell?.partials ?? []);
return pickPartial(source, slot, adminPubkeys.list);
},
get loaded() {
return loaded;
return loaded || !!page.data.shell;
},
};
// Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"];
// the `d` tag names the slot the article fills.
export async function loadPartials() {
try {
const events = await queryForum({
kinds: [30023],
"#t": ["squalk-partial"],
});
const next: Partial[] = [];
for (const e of events) {
const slot = e.tags.find((t) => t[0] === "d")?.[1];
if (!slot || !SLOTS.has(slot)) continue;
next.push({
id: e.id,
slot: slot as PartialSlot,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slot,
content: e.content,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
all = next;
all = await fetchPartials(queryForum);
} finally {
loaded = true;
}

View file

@ -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>();
@ -310,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
if (entry) upsertProfile(entry);
}
export function ingestNostrUser(user: NostrUser) {
if (!user.pubkey) return;
export function entryFromUser(user: NostrUser): ProfileEntry {
const md = user.metadata ?? {};
const entry: ProfileEntry = {
return {
pubkey: user.pubkey,
npub: user.npub,
name: md.name,
@ -325,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
lud16: md.lud16,
fetchedAt: user.lastUpdated || 0,
};
upsertProfile(entry);
}
export function ingestNostrUser(user: NostrUser) {
if (!user.pubkey) return;
upsertProfile(entryFromUser(user));
}

View file

@ -26,7 +26,9 @@ export const forumPool = pool;
function authParam() {
const signer = auth.signer;
return signer ? { onauth: (evt: EventTemplate) => signer.signEvent(evt) } : {};
return signer
? { onauth: (evt: EventTemplate) => signer.signEvent(evt) }
: {};
}
// Resolves once the forum connection is open and, when logged in, NIP-42
@ -104,3 +106,53 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
export function resetForumConnection(): void {
pool.close([RELAY_URL]);
}
// NIP-11 document of the forum relay, fetched once per session. Used for NIP-43
// discovery (clients must only send relay join requests to relays advertising
// it; `self` signs the membership events we read back) and for the access gate,
// which can show the relay's name before anything else is readable.
export type ForumRelayInfo = {
self: string | null;
nips: string[];
name: string;
description: string;
icon: string;
authRequired: boolean;
};
let relayInfo: Promise<ForumRelayInfo> | null = null;
export function forumRelayInfo(): Promise<ForumRelayInfo> {
if (!relayInfo) {
relayInfo = import("@nostr/tools/nip11")
.then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL))
.then((info) => {
// `self` is a NIP-43 addition the library type doesn't know yet
const self = (info as { self?: unknown }).self;
return {
self: typeof self === "string" ? self : null,
// Relays mix numbers and strings in this list
nips: (info.supported_nips ?? []).map(String),
name: info.name ?? "",
description: info.description ?? "",
icon: info.icon ?? "",
authRequired: info.limitation?.auth_required === true,
};
})
.catch(() => ({
self: null,
nips: [],
name: "",
description: "",
icon: "",
authRequired: false,
}));
}
return relayInfo;
}
// The relay pubkey when the forum relay supports NIP-43 relay membership,
// otherwise null (no relay-level join is needed or allowed).
export async function nip43Self(): Promise<string | null> {
const info = await forumRelayInfo();
return info.nips.includes("43") ? info.self : null;
}

View file

@ -1,70 +1,30 @@
import { page } from "$app/state";
import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay";
import { fetchResources, type Resource } from "$lib/forum/resources";
export type Resource = {
id: string;
slug: string; // NIP-23 `d` tag — also the resource URL slug
title: string;
content: string; // markdown body
position?: number;
pubkey: string;
createdAt: number;
};
export type { Resource };
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. Until the live fetch lands, the server
// snapshot (if any) stands in.
export const resourcesStore = {
// Only resources authored by an admin are surfaced; the relay query is
// open, so the trusted admin set is the gate.
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;
},
};
// Positioned resources come first by ascending `position`; the rest follow
// alphabetically by title. Equal positions fall back to title order.
function compare(a: Resource, b: Resource): number {
const ap = a.position;
const bp = b.position;
if (ap !== undefined && bp !== undefined)
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
if (ap !== undefined) return -1;
if (bp !== undefined) return 1;
return a.title.localeCompare(b.title);
}
// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
export async function loadResources() {
try {
const events = await queryForum({
kinds: [30023],
"#t": ["squalk-resource"],
});
// Addressable events: keep the newest per `d` slug.
const bySlug = new Map<string, Resource>();
for (const e of events) {
const slug = e.tags.find((t) => t[0] === "d")?.[1];
if (!slug) continue;
const existing = bySlug.get(slug);
if (existing && existing.createdAt >= e.created_at) continue;
const posTag = e.tags.find((t) => t[0] === "position")?.[1];
const pos = posTag !== undefined ? Number(posTag) : NaN;
bySlug.set(slug, {
id: e.id,
slug,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug,
content: e.content,
position: Number.isFinite(pos) ? pos : undefined,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
all = [...bySlug.values()].sort(compare);
all = await fetchResources(queryForum);
} finally {
loaded = true;
}

View file

@ -1,6 +1,7 @@
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
import { queryForum } from "$lib/relay";
import { bestMatch } from "$lib/textMatch";
import { GROUP_ID, MODE } from "$lib/config";
import { groupsStore } from "$lib/groups.svelte";
@ -12,22 +13,26 @@ export type SearchResult = {
createdAt: number;
};
// Window the snippet around the first term match so the highlight is visible
// even when the match sits deep in a long post.
function snippetOf(content: string, query: string): string {
// Window the snippet around the best match (full phrase > longest
// contiguous run > densest single-term cluster, per textMatch rules). The
// score ranks how well this content matched, so dedupe can keep the best
// snippet per thread and the result list can order its tiers.
function snippetOf(
content: string,
query: string,
): { text: string; score: number } {
const flat = content.replace(/\s+/g, " ").trim();
const MAX = 140;
if (flat.length <= MAX) return flat;
const lower = flat.toLowerCase();
let idx = -1;
for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) {
const i = lower.indexOf(t);
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
}
if (idx <= 40) return flat.slice(0, MAX) + "…";
const start = idx - 40;
const { anchor, score } = bestMatch(flat, query, MAX - 40);
if (flat.length <= MAX) return { text: flat, score };
if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
const start = anchor - 40;
const end = Math.min(flat.length, start + MAX);
return "…" + flat.slice(start, end) + (end < flat.length ? "…" : "");
return {
text: "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""),
score,
};
}
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
@ -48,46 +53,58 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
return h !== undefined && (groups.size === 0 || groups.has(h));
});
const byThread = new Map<string, SearchResult>();
const opless: SearchResult[] = [];
type Candidate = SearchResult & { score: number };
const byThread = new Map<string, Candidate>();
const needTitle: Candidate[] = [];
for (const e of events) {
if (e.kind === 11) {
const existing = byThread.get(e.id);
// An OP match wins over a reply match on the same thread
if (existing && existing.matchKind === "thread") continue;
byThread.set(e.id, {
threadId: e.id,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
snippet: snippetOf(e.content, query),
matchKind: "thread",
createdAt: e.created_at,
});
} else {
const root = e.tags.find((t) => t[0] === "E")?.[1];
if (!root || byThread.has(root)) continue;
const r: SearchResult = {
threadId: root,
title: "",
snippet: snippetOf(e.content, query),
matchKind: "reply",
const isOp = e.kind === 11;
const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
if (!threadId) continue;
const title = isOp
? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
: "";
const { text, score } = snippetOf(e.content, query);
const existing = byThread.get(threadId);
if (!existing) {
const r: Candidate = {
threadId,
title,
snippet: text,
matchKind: isOp ? "thread" : "reply",
createdAt: e.created_at,
score,
};
byThread.set(root, r);
opless.push(r);
byThread.set(threadId, r);
if (!isOp) needTitle.push(r);
} else {
// Keep the snippet of whichever event matched the query best
if (score > existing.score) {
existing.snippet = text;
existing.matchKind = isOp ? "thread" : "reply";
existing.score = score;
}
if (isOp && !existing.title) existing.title = title;
}
}
// Backfill titles for reply-only matches; the group relay truncates
// multi-id queries, so fetch each OP on its own
await Promise.all(
opless.map(async (r) => {
needTitle.map(async (r) => {
if (r.title) return; // The OP appeared in the same result set
const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
const op: Event | undefined = ops[0];
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
}),
);
// Keep the relay's relevance order (arrival order); a thread keeps the
// position of its best-ranked match
return [...byThread.values()];
// Tiered ordering: full-phrase matches, then contiguous multi-word runs
// (longest first), then single-word matches capped to keep noise down.
// Within a tier the relay's relevance (arrival) order is preserved —
// Array.prototype.sort is stable.
const SINGLES_LIMIT = 5;
let singles = 0;
return [...byThread.values()]
.sort((a, b) => b.score - a.score)
.filter((r) => r.score >= 100 || ++singles <= SINGLES_LIMIT);
}

View file

@ -1,4 +1,5 @@
import { searchThreads, type SearchResult } from "$lib/search";
import { findPhraseSpans, findMatchSpans } from "$lib/textMatch";
// Debounced-search state machine shared by the inline (homepage) and modal
// search shells, so behavior lives in one place and the shells only differ
@ -12,10 +13,6 @@ export function createSearchState() {
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
const terms = $derived(
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
);
function schedule() {
const q = query.trim();
if (timer) clearTimeout(timer);
@ -41,19 +38,26 @@ export function createSearchState() {
}, 300);
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Split into alternating plain/matched segments for <mark> rendering
// Split into alternating plain/matched segments for <mark> rendering.
// When the text contains the whole query as a contiguous phrase, only the
// phrase is marked; otherwise contiguous runs (as single blocks) and
// single words are marked as a fallback, to show why the text matched.
function highlight(text: string): { text: string; hit: boolean }[] {
if (!text || terms.length === 0) return [{ text, hit: false }];
const alts = terms.map(escapeRe).join("|");
const exact = new RegExp(`^(${alts})$`, "i");
return text
.split(new RegExp(`(${alts})`, "gi"))
.filter((s) => s !== "")
.map((s) => ({ text: s, hit: exact.test(s) }));
if (!text) return [{ text, hit: false }];
const phrase = findPhraseSpans(text, resultsQuery);
const spans =
phrase.length > 0 ? phrase : findMatchSpans(text, resultsQuery);
if (spans.length === 0) return [{ text, hit: false }];
const out: { text: string; hit: boolean }[] = [];
let pos = 0;
for (const s of spans) {
if (s.start > pos)
out.push({ text: text.slice(pos, s.start), hit: false });
out.push({ text: text.slice(s.start, s.end), hit: true });
pos = s.end;
}
if (pos < text.length) out.push({ text: text.slice(pos), hit: false });
return out;
}
// Arrow/Enter handling; returns true when the event was consumed
@ -98,6 +102,13 @@ export function createSearchState() {
get active() {
return resultsQuery.trim().length >= 2;
},
get resultsQuery() {
return resultsQuery;
},
// Thread link carrying the query, so the page can highlight the terms
threadUrl(threadId: string) {
return `/thread/${threadId}?q=${encodeURIComponent(resultsQuery.trim())}`;
},
schedule,
highlight,
navigate,

47
src/lib/seo.ts Normal file
View file

@ -0,0 +1,47 @@
// Markdown inline syntax reduced to plain text.
function stripInline(text: string): string {
return text
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
.replace(/[*_`~]+/g, "")
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
.replace(/\s+/g, " ")
.trim();
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
}
function isProse(line: string): boolean {
return !!line && !line.startsWith("#") && !/^https?:\/\/\S+$/.test(line);
}
// Plain-text excerpt of a markdown body for description meta tags: the first
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
export function excerpt(markdown: string, max = 160): string {
const line = markdown
.split("\n")
.map((l) => l.trim())
.find(isProse);
return line ? truncate(stripInline(line), max) : "";
}
// The whole body flattened to one plain-text line, trimmed to `max` chars.
// Headings, bare media URLs, code blocks, quoted text and list markers are
// dropped, so a reply reads as its own words.
export function summarize(markdown: string, max = 200): string {
let inCode = false;
const lines: string[] = [];
for (const raw of markdown.split("\n")) {
const l = raw.trim();
if (l.startsWith("```")) {
inCode = !inCode;
continue;
}
if (inCode || l.startsWith(">")) continue;
const line = l.replace(/^(?:[-*+]\s+|\d+[.)]\s+)+/, "");
if (isProse(line)) lines.push(line);
}
return truncate(stripInline(lines.join(" ")), max);
}

26
src/lib/ssr/endpoint.ts Normal file
View file

@ -0,0 +1,26 @@
import { error, type RequestEvent } from "@sveltejs/kit";
import { CACHE_CONTROL } from "$lib/config";
import { RelayUnavailable } from "./relay";
// Wraps a snapshot endpoint: answers carry the shared cache policy, while a
// relay that could not be asked becomes an uncached 503, which snapshot()
// treats as "no snapshot" so the page hydrates empty and the client takes the
// same path as a client-only build (probe, gate or live fetch).
export function snapshotHandler<E extends RequestEvent>(
handler: (event: E) => Promise<Response>,
): (event: E) => Promise<Response> {
return async (event) => {
try {
const res = await handler(event);
event.setHeaders({ "cache-control": CACHE_CONTROL });
return res;
} catch (e) {
const unavailable = e instanceof RelayUnavailable;
event.setHeaders({
"cache-control": unavailable ? "no-store" : CACHE_CONTROL,
});
if (unavailable) error(503, "Relay unavailable");
throw e;
}
};
}

171
src/lib/ssr/relay.ts Normal file
View file

@ -0,0 +1,171 @@
import { SimplePool } from "@nostr/tools";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
import { RELAY_URL, SSR_CACHE_FRESH, SSR_CACHE_STALE } 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.
//
// A relay that is down or refuses anonymous reads (NIP-42 auth-required) is
// not the same as an empty result: the query rejects with RelayUnavailable so
// endpoints answer 503 rather than 404, and the client renders as it would
// without SSR (probing the relay and showing the gate when it must).
//
// Results are cached in memory with the fresh/stale windows from config:
// fresh entries are returned as they are, stale ones are returned at once and
// refreshed in the background, expired ones are fetched again. Profiles
// rarely change, so their fresh window is at least an hour.
const QUERY_TIMEOUT = 2500;
const PROFILE_TIMEOUT = 1500;
const CONNECT_TIMEOUT = 1500;
const FORUM_FRESH = SSR_CACHE_FRESH * 1000;
const PROFILE_FRESH = Math.max(FORUM_FRESH, 60 * 60_000);
const STALE = Math.max(SSR_CACHE_STALE * 1000, FORUM_FRESH);
const DEAD_RELAY_TTL = 5 * 60_000;
const CACHE_MAX = 2000;
export class RelayUnavailable extends Error {
constructor() {
super("No relay answered");
this.name = "RelayUnavailable";
}
}
const pool = new SimplePool();
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
process.on("sveltekit:shutdown", () => pool.destroy());
type Entry = { at: number; result: Promise<Event[]>; refreshing: boolean };
const cache = new Map<string, Entry>();
// Relays that failed to connect are skipped for a while, so a dead profile
// relay doesn't add its connect timeout to every cold page.
const deadUntil = new Map<string, number>();
// Ask every reachable relay and resolve as soon as each has answered (EOSE or
// closed), or at the deadline, whichever comes first — always with whatever
// arrived, so one stalled relay never costs the events the others delivered.
// Rejects when no relay reached EOSE: a CLOSED (auth-required), a failed
// connection or the deadline are not answers.
function boundedQuery(
relays: string[],
filter: Filter,
maxWait: number,
): Promise<Event[]> {
return new Promise((resolve, reject) => {
const events: Event[] = [];
const subs: { close(): void }[] = [];
let done = false;
let answered = false;
let pending = 0;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
for (const s of subs) {
try {
s.close();
} catch {
// Already closed
}
}
if (answered) resolve(events);
else reject(new RelayUnavailable());
};
const timer = setTimeout(finish, maxWait);
const now = Date.now();
const live = relays.filter((url) => (deadUntil.get(url) ?? 0) <= now);
pending = live.length;
if (pending === 0) return finish();
for (const url of live) {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
if (--pending <= 0) finish();
};
pool.ensureRelay(url, { connectionTimeout: CONNECT_TIMEOUT }).then(
(relay) => {
if (done) return settle();
const sub = relay.subscribe([filter], {
onevent: (e) => events.push(e),
oneose: () => {
answered = true;
sub.close();
settle();
},
onclose: settle,
// The library fakes an EOSE after its own timeout; the deadline
// above must win so a silent relay does not count as an answer
eoseTimeout: maxWait * 2,
});
subs.push(sub);
},
() => {
deadUntil.set(url, Date.now() + DEAD_RELAY_TTL);
settle();
},
);
}
});
}
function cachedQuery(
relays: string[],
filter: Filter,
maxWait: number,
fresh: number,
): Promise<Event[]> {
const key = relays.join(",") + "|" + JSON.stringify(filter);
const now = Date.now();
const hit = cache.get(key);
if (hit) {
const age = now - hit.at;
if (age < fresh) return hit.result;
if (age < STALE) {
if (!hit.refreshing) {
hit.refreshing = true;
boundedQuery(relays, filter, maxWait).then(
(events) => {
cache.set(key, {
at: Date.now(),
result: Promise.resolve(events),
refreshing: false,
});
},
// Keep serving the stale entry, try again next time
() => {
hit.refreshing = false;
},
);
}
return hit.result;
}
}
if (cache.size >= CACHE_MAX) cache.clear();
const result = boundedQuery(relays, filter, maxWait);
const entry: Entry = { at: now, result, refreshing: false };
cache.set(key, entry);
// A failure is never cached, so the next request asks the relay again
result.catch(() => {
if (cache.get(key) === entry) cache.delete(key);
});
return result;
}
export const forumQuery: Query = (filter) =>
cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT, FORUM_FRESH);
// 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,
PROFILE_FRESH,
);

29
src/lib/ssr/shell.ts Normal file
View 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 };
}

121
src/lib/textMatch.ts Normal file
View file

@ -0,0 +1,121 @@
// Shared matching rules for search highlighting and ranking. The query is
// tokenized on any non-alphanumeric character (so "-_|!./" all act as word
// separators, in the query and in the text), and a token matches where a
// word starts or ends with it — "id" marks "id" or "identify" but never the
// middle of "gravida", while still allowing prefix searches like the first
// characters of an npub.
export type Span = { start: number; end: number };
// Separator between adjacent tokens of a run: one or more non-word chars
const SEP = "[^\\p{L}\\p{N}]+";
export function tokenize(query: string): string[] {
return query
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((t) => t.length >= 2);
}
function isWordChar(ch: string | undefined): boolean {
return ch !== undefined && /[\p{L}\p{N}]/u.test(ch);
}
// Start-with or end-with: the match must begin at a word start or finish at
// a word end.
function validBoundaries(text: string, span: Span): boolean {
return !isWordChar(text[span.start - 1]) || !isWordChar(text[span.end]);
}
function overlaps(taken: Span[], s: Span): boolean {
return taken.some((t) => s.start < t.end && s.end > t.start);
}
// All spans where the given tokens appear consecutively (any separators
// between them). Tokens are alphanumeric-only, so no regex escaping needed.
function findRuns(text: string, tokens: string[]): Span[] {
const re = new RegExp(tokens.join(SEP), "giu");
const out: Span[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const span = { start: m.index, end: m.index + m[0].length };
if (validBoundaries(text, span)) out.push(span);
if (m[0].length === 0) re.lastIndex++;
}
return out;
}
// Occurrences of the whole query as one contiguous phrase
export function findPhraseSpans(text: string, query: string): Span[] {
const tokens = tokenize(query);
if (tokens.length < 2) return [];
return findRuns(text, tokens);
}
// Fallback matches: longest contiguous multi-token runs claim their spans
// first (each painted as one block), then single tokens outside them.
export function findMatchSpans(text: string, query: string): Span[] {
const tokens = tokenize(query);
if (tokens.length === 0) return [];
const spans: Span[] = [];
for (let len = tokens.length; len >= 2; len--) {
for (let s = 0; s + len <= tokens.length; s++) {
for (const span of findRuns(text, tokens.slice(s, s + len))) {
if (!overlaps(spans, span)) spans.push(span);
}
}
}
for (const t of new Set(tokens)) {
for (const span of findRuns(text, [t])) {
if (!overlaps(spans, span)) spans.push(span);
}
}
return spans.sort((a, b) => a.start - b.start);
}
// Rank how well a text matches the query, and where to anchor an excerpt:
// the full query (1000) beats any shorter contiguous run (100 + length),
// which beats scattered singles (count of distinct tokens inside a window of
// `windowSpan` chars, always < 100).
export function bestMatch(
text: string,
query: string,
windowSpan: number,
): { anchor: number; score: number } {
const tokens = tokenize(query);
if (tokens.length === 0) return { anchor: -1, score: 0 };
for (let len = tokens.length; len >= 2; len--) {
for (let s = 0; s + len <= tokens.length; s++) {
const runs = findRuns(text, tokens.slice(s, s + len));
if (runs.length > 0)
return {
anchor: runs[0].start,
score: len === tokens.length ? 1000 : 100 + len,
};
}
}
const singles: { start: number; token: string }[] = [];
for (const t of new Set(tokens)) {
for (const span of findRuns(text, [t]))
singles.push({ start: span.start, token: t });
}
if (singles.length === 0) return { anchor: -1, score: 0 };
singles.sort((a, b) => a.start - b.start);
let anchor = singles[0].start;
let score = 0;
for (const o of singles) {
const seen = new Set<string>();
for (const p of singles) {
if (p.start >= o.start && p.start <= o.start + windowSpan)
seen.add(p.token);
}
if (seen.size > score) {
score = seen.size;
anchor = o.start;
}
}
return { anchor, score };
}

View file

@ -1,4 +1,5 @@
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { page } from "$app/state";
import { RELAY_URL, GROUP_ID } from "$lib/config";
import { queryForum, publishForum } from "$lib/relay";
import { threads as mockThreads } from "$lib/mock";
@ -10,40 +11,34 @@ import {
buildPTagHints,
} from "$lib/mentions";
import { convertForumUrls } from "$lib/linkify";
import {
fetchThread,
type PostData,
type ThreadDetail,
} from "$lib/forum/thread";
export type { PostData };
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
export type PostData = {
id: string;
pubkey: string;
createdAt: number;
content: string;
};
type ThreadDetail = {
id: string;
title: string;
labels: string[];
groupId: string; // the thread's NIP-29 group (its `h` tag)
op: PostData;
replies: PostData[];
};
let detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({});
// "notfound" means the relay returned nothing — either no such thread or it sits
// in a private group the current (non-member) user can't read.
let status = $state<"loading" | "ready" | "notfound">("loading");
// Until the live fetch lands, the server snapshot (if any) stands in. Its
// profiles stay as a base layer: the live ones arrive one by one.
export const threadDetailStore = {
get detail() {
return detail;
return detail ?? page.data.thread ?? null;
},
get profiles() {
return profiles;
const base = page.data.profiles;
return base ? { ...base, ...profiles } : profiles;
},
get status() {
return status;
return status === "loading" && page.data.thread ? "ready" : status;
},
};
@ -104,39 +99,13 @@ export async function loadThread(id: string) {
return;
}
const [threadEvents, replyEvents] = await Promise.all([
queryForum({ kinds: [11], ids: [id] }),
queryForum({ kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
if (!event) {
const next = await fetchThread(queryForum, id, GROUP_ID);
if (!next) {
status = "notfound";
return;
}
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
detail = {
id: event.id,
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID,
op: {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
},
replies: replies.map((r) => ({
id: r.id,
pubkey: r.pubkey,
createdAt: r.created_at,
content: r.content,
})),
};
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
detail = next;
[next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile);
status = "ready";
}

View file

@ -1,27 +1,15 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { page } from "$app/state";
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { ensureForumRelay } from "$lib/relay";
import { ingestNostrUser } from "$lib/profiles.svelte";
import { relayQuery } from "$lib/forum/query";
import {
fetchThreadPage,
type SortMode,
type ThreadData,
} from "$lib/forum/threads";
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;
labels: string[];
authorPubkey: string;
createdAt: number;
replyCount: number;
latestAt: number;
latestPubkey: string;
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
};
export type { SortMode, ThreadData };
let threads = $state<ThreadData[]>([]);
let profiles = $state<Record<string, NostrUser>>({});
@ -34,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;
@ -59,238 +66,30 @@ async function loadProfile(pubkey: string) {
ingestNostrUser(user);
}
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
function threadIdOf(e: Event): string | undefined {
return e.kind === 11 ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
}
type SliceItem = {
id: string;
latestAt: number;
latestPubkey: string;
op?: Event;
};
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
// threads not already shown. The first event seen for a thread defines its
// activity timestamp. Returns the slice plus the cursor for the next call.
async function fetchActivitySlice(
relay: AbstractRelay,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
const collected = new Map<string, SliceItem>();
let cur = until;
let done = false;
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
const events = await querySync(relay, {
kinds: [11, 1111],
"#h": [groupId],
until: cur,
limit: WALK_LIMIT,
});
if (events.length === 0) {
done = true;
break;
}
events.sort((a, b) => b.created_at - a.created_at);
let oldest = cur;
for (const e of events) {
oldest = Math.min(oldest, e.created_at);
const id = threadIdOf(e);
if (!id || exclude.has(id)) continue;
const seen = collected.get(id);
if (seen) {
// Grab the OP from the stream when a thread was first seen via a reply,
// so we avoid the by-id backfill the group relay truncates
if (!seen.op && e.kind === 11) seen.op = e;
continue;
}
if (collected.size >= n) continue; // Page full; keep scanning for OPs
collected.set(id, {
id,
latestAt: e.created_at,
latestPubkey: e.pubkey,
op: e.kind === 11 ? e : undefined,
});
}
if (events.length < WALK_LIMIT) {
done = true;
break;
}
if (oldest >= cur) break; // No progress (single timestamp floods the window)
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
}
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
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: AbstractRelay,
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
// snapshot. Isolated so the future creation-by-date view can skip it entirely.
async function enrichWithReplies(
relay: AbstractRelay,
groupId: string,
items: SliceItem[],
): Promise<Map<string, { count: number; repliers: string[] }>> {
const result = new Map<string, { count: number; repliers: string[] }>();
if (items.length === 0) return result;
const replies = await querySync(relay, {
kinds: [1111],
"#h": [groupId],
"#E": items.map((it) => it.id),
until: snapshotAt,
limit: 5000,
});
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
for (const r of replies) {
const root = r.tags.find((t) => t[0] === "E")?.[1];
if (!root) continue;
let a = acc.get(root);
if (!a) {
a = { count: 0, pubkeys: new Set() };
acc.set(root, a);
}
a.count++;
a.pubkeys.add(r.pubkey);
}
for (const it of items) {
const a = acc.get(it.id);
const repliers = a
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
: [];
result.set(it.id, { count: a?.count ?? 0, repliers });
}
return result;
}
async function buildThreads(
relay: AbstractRelay,
groupId: string,
items: SliceItem[],
): Promise<ThreadData[]> {
// Backfill OPs that fell outside the activity window. The group relay
// truncates multi-id queries, so fetch each one on its own.
const missing = items.filter((it) => !it.op);
if (missing.length > 0) {
const fetched = await Promise.all(
missing.map((it) =>
querySync(relay, { kinds: [11], "#h": [groupId], ids: [it.id] }),
),
);
const byId = new Map<string, Event>();
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
for (const it of items) if (!it.op) it.op = byId.get(it.id);
}
const enriched = await enrichWithReplies(relay, groupId, items);
const out: ThreadData[] = [];
for (const it of items) {
const op = it.op;
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
const e = enriched.get(it.id);
out.push({
id: it.id,
title: op.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
labels: op.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
authorPubkey: op.pubkey,
createdAt: op.created_at,
replyCount: e?.count ?? 0,
latestAt: it.latestAt,
latestPubkey: it.latestPubkey,
replierPubkeys: e?.repliers ?? [],
});
}
return out;
}
async function runLoad(append: boolean, groupId: string) {
const id = ++reqId;
if (append) loadingMore = true;
else loading = true;
const relay = await ensureForumRelay();
// Paged loads drive their own subscriptions on the shared connection
const q = relayQuery(await ensureForumRelay());
try {
const until = append ? (cursor ?? snapshotAt) : snapshotAt;
const exclude = new Set(threads.map((t) => t.id));
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);
const page = await fetchThreadPage(q, groupId, {
sort: sortMode,
until: append ? (cursor ?? snapshotAt) : snapshotAt,
snapshotAt,
exclude: new Set(threads.map((t) => t.id)),
});
if (id !== reqId) return; // Superseded by a newer load — discard results
threads = append ? [...threads, ...built] : built;
cursor = slice.nextCursor ?? cursor;
exhausted = slice.done || built.length === 0;
for (const t of built) {
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);
for (const p of t.replierPubkeys) loadProfile(p);
}
} finally {
// shared forum connection is long-lived — don't close it here
if (id === reqId) {
if (append) loadingMore = false;
else loading = false;
@ -300,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;

View file

@ -0,0 +1,81 @@
import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay";
import {
fetchUserPosts,
fetchUserPostPage,
nextCursor,
type UserPostKind,
type UserPosts,
} from "$lib/forum/userPosts";
export type { UserPosts, UserPostKind };
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
let pubkey = $state<string | null>(null);
let posts = $state<UserPosts | null>(null);
let loadingMore = $state<Record<UserPostKind, boolean>>({
roots: false,
replies: false,
});
let initial: Promise<void> | null = null;
// Posts of the profile last loaded. Consumers check `pubkey` against the
// page's, so a stale result never shows under another user's name.
export const userPostsStore = {
get pubkey() {
return pubkey;
},
get posts() {
return posts;
},
get loadingMore() {
return loadingMore;
},
};
export function loadUserPosts(pk: string): Promise<void> {
pubkey = pk;
posts = null;
loadingMore = { roots: false, replies: false };
initial = (async () => {
let result: UserPosts = {
roots: { items: [], done: true },
replies: { items: [], done: true },
};
try {
result = await fetchUserPosts(queryForum, pk, scope());
} catch (e) {
console.error("[profile] failed to load posts", e);
}
if (pubkey === pk) posts = result;
})();
return initial;
}
// Append the next page of one column. Waits for the initial load when the
// page still shows the server snapshot.
export async function loadMoreUserPosts(kind: UserPostKind): Promise<void> {
const pk = pubkey;
if (!pk || loadingMore[kind]) return;
loadingMore[kind] = true;
try {
await initial;
const current = posts?.[kind];
if (!posts || !current || current.done || pubkey !== pk) return;
const next = await fetchUserPostPage(queryForum, pk, kind, {
groupId: scope(),
until: nextCursor(current),
exclude: new Set(current.items.map((p) => p.id)),
});
if (pubkey !== pk) return;
posts[kind] = {
items: [...current.items, ...next.items],
done: next.done,
};
} catch (e) {
console.error("[profile] failed to load more posts", e);
} finally {
loadingMore[kind] = false;
}
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import Navbar from "$lib/components/Navbar.svelte";
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
import MobileMenu from "$lib/components/MobileMenu.svelte";
@ -12,9 +11,10 @@
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
import DeleteModal from "$lib/components/DeleteModal.svelte";
import Toast from "$lib/components/Toast.svelte";
import RelayGate from "$lib/components/RelayGate.svelte";
import { page } from "$app/state";
import { afterNavigate } from "$app/navigation";
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { auth, restoreSession } from "$lib/auth.svelte";
import { loadGroup } from "$lib/group.svelte";
import { ensureMembershipChecked } from "$lib/join.svelte";
@ -25,12 +25,15 @@
import { seedProfiles } from "$lib/profiles.svelte";
import { startChat } from "$lib/chat.svelte";
import { resetForumConnection } from "$lib/relay";
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
import {
MODE,
ACCENT_COLOR,
SECONDARY_COLOR,
SEARCH_ENABLED,
FAVICON,
RELAY_URL,
} from "$lib/config";
let { children } = $props();
@ -62,27 +65,43 @@
// The main column is its own scroll container (overflow-y-auto) and persists
// across navigations, so SvelteKit's window-only scroll restoration never
// resets it. Scroll it back to top on forward navigation; leave back/forward
// (popstate) alone so returning to a listing keeps its place.
// (popstate) alone so returning to a listing keeps its place, and hash
// targets (#post-…) alone so the page can place the anchored post itself.
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((nav) => {
if (nav.type !== "popstate") mainEl?.scrollTo(0, 0);
if (nav.type !== "popstate" && !nav.to?.url.hash) mainEl?.scrollTo(0, 0);
});
onMount(async () => {
// Nothing is fetched until the relay confirms it will serve this visitor:
// the probe runs once the stored signer is in place (restoreSession sets it
// synchronously, before its profile fetch), and every probe that ends open
// (re)loads the forum below.
onMount(() => {
restoreSession();
probeRelayAccess();
});
async function loadForum() {
loadResources();
loadPartials();
const tasks = [restoreSession(), loadGroup()];
const tasks = [loadGroup()];
if (mode === "full") tasks.push(loadGroups());
await Promise.all(tasks);
// Resources are filtered by the admin set; in full mode that means every
// room's admins, needed on every page for the sidebar.
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
seedProfiles(auth.user?.pubkey ?? null);
}
// Each completed probe that finds the relay open reloads the room views: on
// first load, after a login (private/hidden rooms appear) or logout (they
// vanish), and after joining a members-only relay.
$effect(() => {
relayAccess.probes;
if (relayAccess.state === "open") untrack(loadForum);
});
// Reload the room views when the user logs in or out: a logged-in member sees
// their private/hidden rooms, so the listing must be re-fetched over the now
// (de)authenticated connection. sessionEpoch only bumps on explicit
// Re-probe when the user logs in or out. sessionEpoch only bumps on explicit
// login/logout, never on the silent restore that onMount already covers.
let lastEpoch = -1;
$effect(() => {
@ -91,17 +110,12 @@
const first = lastEpoch === -1;
const loggedOut = auth.user === null;
lastEpoch = epoch;
if (first) return; // initial mount: onMount already loaded everything
if (first) return;
// On logout, drop the authenticated connection so the relay stops serving
// the previous user's private rooms; login reuses the open connection,
// which ensureForumReady authenticates via its stored challenge.
if (loggedOut) resetForumConnection();
loadGroup();
if (mode === "full") {
loadGroups().then(() =>
loadRoomAdmins(groupsStore.list.map((g) => g.id)),
);
}
probeRelayAccess();
});
// Full mode: the room route defines the active group. Thread pages set it
@ -164,121 +178,150 @@
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={FAVICON} />
</svelte:head>
<div
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
'chat'
? 'h-dvh overflow-hidden'
: ''}"
>
<Navbar onMenuToggle={() => (menuOpen = true)} />
<!-- The server and the pre-probe client render the forum; the gate only
replaces it once a probe confirms the relay refuses this visitor. -->
{#if relayAccess.state !== "open" && relayAccess.state !== "checking"}
<RelayGate />
{:else}
<div
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
'chat'
? 'flex flex-1 overflow-hidden'
? 'h-dvh overflow-hidden'
: ''}"
>
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
<main
bind:this={mainEl}
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
? 'md:flex-[3]'
: 'md:flex-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
<Navbar onMenuToggle={() => (menuOpen = true)} />
<div
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
'chat'
? 'flex flex-1 overflow-hidden'
: ''}"
>
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
<main
bind:this={mainEl}
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
? 'md:flex-[3]'
: 'md:flex-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
>
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
it first and it reappears once the top is reached. Desktop only. -->
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 dark:bg-neutral-900"
>
{@render children()}
</div>
</main>
{#if showChat}
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
<ChatSidebar
expanded={chatExpanded}
onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"}
/>
{:else if showDiscussions}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
Hidden on mobile — it's supplementary to the main column. -->
<div
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
>
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
class="flex min-h-[calc(100dvh_-_4rem)] flex-col bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-[calc(100%_-_1.5rem)] md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 md:pb-6 dark:bg-neutral-900"
>
<LatestDiscussions />
<div class="flex-1">
{@render children()}
</div>
<footer
class="mt-auto pt-16 pb-4 text-center text-[90%] text-neutral-500/70 dark:text-neutral-400/70"
>
This forum is built on
<a
href="https://nostr.basspistol.org"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
>Nostr</a
>
with
<a
href="https://github.com/dtonon/squalk"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
>Squalk</a
>
- Hosting relay is {RELAY_URL.replace(/^wss?:\/\//, "")}
</footer>
</div>
</div>
</main>
{#if showChat}
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
<ChatSidebar
expanded={chatExpanded}
onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"}
/>
{:else if showDiscussions}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
Hidden on mobile — it's supplementary to the main column. -->
<div
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
>
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
>
<LatestDiscussions />
</div>
</div>
{/if}
</div>
{#if showChat}
<nav
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
aria-label="Switch view"
>
<button
type="button"
onclick={() => (mobileView = "forum")}
aria-pressed={mobileView === "forum"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'forum'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
Forum
</button>
<button
type="button"
onclick={() => (mobileView = "chat")}
aria-pressed={mobileView === "chat"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'chat'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
Chat
</button>
</nav>
{/if}
</div>
{#if showChat}
<nav
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
aria-label="Switch view"
>
<button
type="button"
onclick={() => (mobileView = "forum")}
aria-pressed={mobileView === "forum"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'forum'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
Forum
</button>
<button
type="button"
onclick={() => (mobileView = "chat")}
aria-pressed={mobileView === "chat"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'chat'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
Chat
</button>
</nav>
{/if}
</div>
{/if}
<MobileMenu
open={menuOpen}

View file

@ -1 +1,15 @@
export const ssr = false;
import { browser } from "$app/environment";
import { CACHE_CONTROL, SSR_ENABLED } from "$lib/config";
import { snapshot, type ForumShell } from "$lib/forum/snapshot";
export const ssr = SSR_ENABLED;
// Runs for every page, so the cache policy for rendered HTML is set here. A
// page rendered without its shell (relay down or refusing anonymous reads) is
// an empty placeholder the client fills in, not worth keeping in a shared cache.
export async function load({ fetch, setHeaders }) {
const shell = await snapshot<ForumShell>(fetch, "/api/shell");
if (!browser)
setHeaders({ "cache-control": shell ? CACHE_CONTROL : "no-store" });
return { shell };
}

View file

@ -7,10 +7,26 @@
import { partialsStore } from "$lib/partials.svelte";
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
import SearchInline from "$lib/components/SearchInline.svelte";
import Meta, { siteName } from "$lib/components/Meta.svelte";
import { groupStore } from "$lib/group.svelte";
import { excerpt } from "$lib/seo";
import type { NostrUser } from "@nostr/gadgets/metadata";
const partial = $derived(partialsStore.get("home"));
const description = $derived(
partial
? excerpt(partial.content)
: MODE === "simple"
? (groupStore.data?.about ?? "")
: "",
);
// A partial's leading image doubles as the social preview.
const image = $derived(
partial?.content.match(/^https?:\/\/\S+\.(?:png|jpe?g|webp|gif)$/im)?.[0] ??
groupStore.data?.picture,
);
// Full mode: load per-room activity + recent threads once the rooms are known.
$effect(() => {
if (MODE !== "full") return;
@ -35,9 +51,11 @@
}
</script>
<svelte:head>
<title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
</svelte:head>
<Meta
title={siteName() || (MODE === "full" ? "Rooms" : "Discussions")}
{description}
{image}
/>
{#snippet pic(a: { name: string; picture?: string })}
{#if a.picture}

20
src/routes/+page.ts Normal file
View 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 };
}

View file

@ -0,0 +1,11 @@
import { json } from "@sveltejs/kit";
import { fetchProfiles } from "$lib/forum/profiles";
import { snapshotHandler } from "$lib/ssr/endpoint";
import { loadShell } from "$lib/ssr/shell";
import { profileQuery } from "$lib/ssr/relay";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = snapshotHandler(async () => {
const shell = await loadShell();
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
});

View file

@ -0,0 +1,22 @@
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 { snapshotHandler } from "$lib/ssr/endpoint";
import { forumQuery, profileQuery } from "$lib/ssr/relay";
import type { RequestHandler } from "./$types";
// Room activity and the latest discussions: every room in full mode, the
// single group in simple mode (mirrors LatestDiscussions' own room set).
export const GET: RequestHandler = snapshotHandler(async () => {
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);
});

View file

@ -0,0 +1,21 @@
import { error, json } from "@sveltejs/kit";
import { GROUP_ID, MODE } from "$lib/config";
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
import { fetchUserPosts } from "$lib/forum/userPosts";
import { snapshotHandler } from "$lib/ssr/endpoint";
import { forumQuery, profileQuery } from "$lib/ssr/relay";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
const pubkey = decodeProfileId(params.id);
if (!pubkey) error(404, "Not found");
const [profiles, posts] = await Promise.all([
fetchProfiles(profileQuery, [pubkey]),
fetchUserPosts(
forumQuery,
pubkey,
MODE === "simple" ? GROUP_ID : undefined,
),
]);
return json({ pubkey, posts, profiles });
});

View file

@ -0,0 +1,8 @@
import { json } from "@sveltejs/kit";
import { snapshotHandler } from "$lib/ssr/endpoint";
import { loadShell } from "$lib/ssr/shell";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = snapshotHandler(async () =>
json(await loadShell()),
);

View file

@ -0,0 +1,15 @@
import { error, json } from "@sveltejs/kit";
import { GROUP_ID } from "$lib/config";
import { fetchThread, threadAuthors } from "$lib/forum/thread";
import { fetchProfiles } from "$lib/forum/profiles";
import { snapshotHandler } from "$lib/ssr/endpoint";
import { forumQuery, profileQuery } from "$lib/ssr/relay";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found");
const thread = await fetchThread(forumQuery, params.id, GROUP_ID);
if (!thread) error(404, "Not found");
const profiles = await fetchProfiles(profileQuery, threadAuthors(thread));
return json({ thread, profiles });
});

View file

@ -0,0 +1,45 @@
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 { snapshotHandler } from "$lib/ssr/endpoint";
import { forumQuery, profileQuery } from "$lib/ssr/relay";
import type { RequestHandler } from "./$types";
// 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 const GET: RequestHandler = snapshotHandler(async ({ 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);
});

View file

@ -11,10 +11,14 @@
import {
profileStore,
ensureProfile,
entryFromUser,
type ProfileEntry,
} from "$lib/profiles.svelte";
import { profilePath } from "$lib/forum/profiles";
import { partialsStore } from "$lib/partials.svelte";
import PostContent from "$lib/components/PostContent.svelte";
import { page } from "$app/state";
import Meta from "$lib/components/Meta.svelte";
const partial = $derived(partialsStore.get("contacts"));
@ -48,11 +52,17 @@
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];
return u ? entryFromUser(u) : undefined;
}
function displayName(c: Contact): string {
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
}
@ -66,10 +76,7 @@
}
</script>
<svelte:head>
<title>Contacts</title>
<meta name="description" content="Admins and contacts for this community." />
</svelte:head>
<Meta title="Contacts" description="Admins and contacts for this community." />
<div class="mx-auto max-w-6xl">
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
@ -81,34 +88,49 @@
{/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}
<img
src={c.entry.picture}
alt=""
class="h-14 w-14 shrink-0 rounded-full object-cover"
/>
{: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"
>
{displayName(c)[0].toUpperCase()}
</span>
{/if}
<a
href={profilePath(c.pubkey)}
class="shrink-0"
aria-hidden="true"
tabindex="-1"
>
{#if c.entry?.picture}
<img
src={c.entry.picture}
alt=""
class="h-14 w-14 rounded-full object-cover"
/>
{:else}
<span
class="flex h-14 w-14 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>
{/if}
</a>
<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">
{displayName(c)}
<p
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
<a href={profilePath(c.pubkey)} class="hover:text-accent">
{displayName(c)}
</a>
</p>
{#if c.entry?.nip05}
<p class="truncate text-neutral-400 dark:text-neutral-500">
@ -117,19 +139,19 @@
{/if}
</div>
<a
href="https://njump.me/{c.npub}"
target="_blank"
rel="noopener noreferrer"
href={profilePath(c.pubkey)}
class="text-accent shrink-0 text-sm hover:underline"
>
View profile ↗
View profile
</a>
</div>
</div>
<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 +169,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 +183,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

View file

@ -0,0 +1,17 @@
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 [contacts, overview] = await Promise.all([
snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"),
snapshot<OverviewSnapshot>(fetch, "/api/overview"),
]);
return {
overview,
profiles: { ...overview?.profiles, ...contacts?.profiles },
};
}

View file

@ -65,6 +65,15 @@ body {
}
}
/* Search terms painted on thread pages via the CSS Custom Highlight API,
matching the <mark> tint used in the search results. */
::highlight(search-terms) {
background-color: color-mix(in srgb, var(--color-secondary) 40%, transparent);
}
.dark ::highlight(search-terms) {
background-color: color-mix(in srgb, var(--color-secondary) 30%, transparent);
}
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
typography plugin's em-based heading margins; the first block stays flush. */
.prose :is(h1, h2, h3, h4, h5, h6) {
@ -76,6 +85,12 @@ body {
font-size: 1.65rem;
color: var(--color-accent);
}
.prose :is(h2) {
font-size: 1.35rem;
}
.prose :is(h3) {
font-size: 1.15rem;
}
.prose :is(p) {
margin-top: 0;
}
@ -104,3 +119,9 @@ body {
--color-secondary: #ffaf25;
--color-secondary-hover: #e69e1a;
}
/* setto*/
a[href*="room/h1"] {
display:none
}

View file

@ -0,0 +1,277 @@
<script lang="ts">
import { page } from "$app/state";
import { MODE } from "$lib/config";
import { getGroupName } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import {
profileStore,
ensureProfile,
entryFromUser,
type ProfileEntry,
} from "$lib/profiles.svelte";
import {
userPostsStore,
loadUserPosts,
loadMoreUserPosts,
} from "$lib/userPosts.svelte";
import type {
UserPost,
UserPostKind,
UserPostPage,
} from "$lib/forum/userPosts";
import Meta from "$lib/components/Meta.svelte";
import { summarize } from "$lib/seo";
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
$effect(() => {
if (!pubkey) return;
ensureProfile(pubkey);
loadUserPosts(pubkey);
});
// Server-rendered profile, used until the live one is fetched.
const entry = $derived.by<ProfileEntry | undefined>(() => {
if (!pubkey) return undefined;
const live = profileStore.profiles.get(pubkey);
if (live) return live;
const u = page.data.profiles?.[pubkey];
return u ? entryFromUser(u) : undefined;
});
const posts = $derived(
pubkey && userPostsStore.pubkey === pubkey
? userPostsStore.posts
: (page.data.posts ?? null),
);
// Rooms the loaded posts come from, in the rooms list order (full mode).
const seenOn = $derived.by(() => {
if (MODE !== "full" || !posts) return [];
const ids = new Set(
[...posts.roots.items, ...posts.replies.items].map((p) => p.groupId),
);
return groupsStore.list.filter((g) => ids.has(g.id));
});
const npub = $derived(entry?.npub ?? page.params.id ?? "");
const name = $derived(
entry?.displayName || entry?.name || `${npub.slice(0, 12)}…`,
);
const initial = $derived(name[0]?.toUpperCase() ?? "?");
function websiteHref(url: string): string {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function websiteLabel(url: string): string {
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
}
function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function postHref(p: UserPost): string {
return p.id === p.threadId
? `/thread/${p.threadId}`
: `/thread/${p.threadId}#post-${p.id}`;
}
const jsonLd = $derived(
pubkey
? {
"@context": "https://schema.org",
"@type": "Person",
name,
url: page.url.origin + page.url.pathname,
...(entry?.picture ? { image: entry.picture } : {}),
...(entry?.about ? { description: summarize(entry.about, 160) } : {}),
}
: undefined,
);
</script>
<Meta
title={name}
description={entry?.about ? summarize(entry.about, 160) : ""}
image={entry?.picture}
{jsonLd}
/>
{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)}
{#if list.items.length === 0}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each list.items as p (p.id)}
{@const text = summarize(p.content, 200)}
<li class="py-3">
<a href={postHref(p)} class="group block">
{#if p.title}
<p
class="group-hover:text-accent line-clamp-2 text-lg leading-5 text-neutral-900 dark:text-neutral-100"
>
{p.title}
</p>
{/if}
{#if text}
<p
class="mt-1 text-sm leading-5 text-neutral-600 dark:text-neutral-400"
>
{text}
</p>
{/if}
</a>
<div
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
>
{#if MODE === "full"}
<a href="/room/{p.groupId}" class="hover:text-accent truncate"
>{getGroupName(p.groupId)}</a
>
<span aria-hidden="true">·</span>
{/if}
<time datetime={new Date(p.createdAt * 1000).toISOString()}
>{formatDate(p.createdAt)}</time
>
</div>
</li>
{/each}
</ul>
{#if !list.done}
{@const busy = userPostsStore.loadingMore[kind]}
<div class="flex justify-center py-6">
<button
onclick={() => loadMoreUserPosts(kind)}
disabled={busy}
aria-busy={busy}
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{busy ? "Loading…" : "Show more"}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="mx-auto max-w-6xl">
{#if !pubkey}
<h1 class="text-accent py-2 text-[1.65rem]">Profile</h1>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Profile not found.
</p>
{:else}
<h1 class="text-accent py-2 text-[1.65rem]">{name}</h1>
<div class="mt-2 flex items-start gap-6">
<div class="min-w-0 flex-1">
{#if entry?.nip05}
<p class="truncate text-neutral-400 dark:text-neutral-500">
{entry.nip05}
</p>
{/if}
{#if entry?.about}
<p
class="mt-2 whitespace-pre-line text-neutral-600 dark:text-neutral-400"
>
{entry.about}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
{#if entry?.website}
<a
href={websiteHref(entry.website)}
target="_blank"
rel="noopener noreferrer"
class="text-accent truncate hover:underline"
>
{websiteLabel(entry.website)}
</a>
{/if}
{#if entry?.lud16}
<span
class="text-neutral-500 dark:text-neutral-400"
title="Lightning address"
>
⚡ {entry.lud16}
</span>
{/if}
<a
href="https://nostr.basspistol.org/{npub}"
target="_blank"
rel="noopener noreferrer"
class="text-accent text-sm hover:underline"
>
View on Bpistle ↗
</a>
</div>
{#if seenOn.length > 0}
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
<span class="text-neutral-400 dark:text-neutral-500">Seen on:</span>
{#each seenOn as r, i (r.id)}<a
href="/room/{r.id}"
class="text-accent hover:underline">{r.name}</a
>{i < seenOn.length - 1 ? ", " : ""}{/each}
</p>
{/if}
</div>
{#if entry?.picture}
<img
src={entry.picture}
alt=""
class="h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32"
/>
{:else}
<span
class="flex h-24 w-24 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-3xl font-semibold text-neutral-500 sm:h-32 sm:w-32 dark:bg-neutral-700 dark:text-neutral-400"
aria-hidden="true"
>
{initial}
</span>
{/if}
</div>
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
<section aria-labelledby="profile-discussions">
<h2
id="profile-discussions"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Discussions
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList("roots", posts.roots, "No discussions yet.")}
{/if}
</section>
<section aria-labelledby="profile-replies">
<h2
id="profile-replies"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Replies
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList("replies", posts.replies, "No replies yet.")}
{/if}
</section>
</div>
{/if}
</div>

View file

@ -0,0 +1,24 @@
import { redirect } from "@sveltejs/kit";
import * as nip19 from "@nostr/tools/nip19";
import { decodeProfileId } from "$lib/forum/profiles";
import { snapshot, type Profiles } from "$lib/forum/snapshot";
import type { UserPosts } from "$lib/forum/userPosts";
// nprofile and hex forms redirect to the canonical npub URL. An invalid id
// is a server 404 (via the endpoint) and an in-page message on the client.
export async function load({ fetch, params }) {
const pubkey = decodeProfileId(params.id);
if (pubkey) {
const npub = nip19.npubEncode(pubkey);
if (params.id !== npub) redirect(301, `/profile/${npub}`);
}
const data = await snapshot<{ posts: UserPosts; profiles: Profiles }>(
fetch,
`/api/profile/${params.id}`,
);
return {
pubkey,
posts: data?.posts ?? null,
profiles: data?.profiles ?? null,
};
}

View file

@ -3,6 +3,8 @@
import { resourcesStore } from "$lib/resources.svelte";
import { adminPubkeys } from "$lib/admins.svelte";
import PostContent from "$lib/components/PostContent.svelte";
import Meta from "$lib/components/Meta.svelte";
import { excerpt } from "$lib/seo";
const slug = $derived(page.params.slug ?? "");
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
@ -10,23 +12,10 @@
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
const notFound = $derived(ready && !resource);
// First non-empty line of the body, trimmed of markdown markers, for SEO.
const description = $derived(
(resource?.content ?? "")
.replace(/^#+\s*/gm, "")
.split("\n")
.map((l) => l.trim())
.find(Boolean)
?.slice(0, 160) ?? "",
);
const description = $derived(excerpt(resource?.content ?? ""));
</script>
<svelte:head>
<title>{resource?.title ?? "Resource"}</title>
{#if description}
<meta name="description" content={description} />
{/if}
</svelte:head>
<Meta title={resource?.title ?? "Resource"} {description} type="article" />
<div class="mx-auto max-w-6xl">
<a
@ -56,8 +45,12 @@
<PostContent content={resource.content} headingOffset={0} />
</div>
{:else if notFound}
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Resource not found.</p>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Resource not found.
</p>
{:else}
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{/if}
</div>

View file

@ -0,0 +1,17 @@
import { browser } from "$app/environment";
import { error } from "@sveltejs/kit";
import { snapshot, type OverviewSnapshot } from "$lib/forum/snapshot";
// Articles show the latest-discussions panel next to the text. An unknown
// slug is a real 404 on the server; the client keeps its in-page message.
export async function load({ fetch, params, parent }) {
if (!browser) {
const { shell } = await parent();
const known = shell?.resources.some(
(r) => r.slug === params.slug && shell.admins.includes(r.pubkey),
);
if (shell && !known) error(404, "Not found");
}
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
return { overview, profiles: overview?.profiles ?? null };
}

View file

@ -0,0 +1,13 @@
import { SSR_ENABLED } from "$lib/config";
// Static builds bake it in at build time (no sitemap to point at); the server
// build answers live so the sitemap URL carries the real origin.
export const prerender = !SSR_ENABLED;
export function GET({ url }) {
const lines = ["User-agent: *", "Disallow:"];
if (SSR_ENABLED) lines.push(`Sitemap: ${url.origin}/sitemap.xml`);
return new Response(lines.join("\n") + "\n", {
headers: { "content-type": "text/plain" },
});
}

View 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 };
}

View file

@ -0,0 +1,57 @@
import { MODE, GROUP_ID } from "$lib/config";
import { fetchGroups } from "$lib/forum/groups";
import { snapshotHandler } from "$lib/ssr/endpoint";
import { forumQuery } from "$lib/ssr/relay";
import { loadShell } from "$lib/ssr/shell";
import type { RequestHandler } from "./$types";
const THREADS_PER_ROOM = 1000;
type Entry = { path: string; lastmod?: number };
function xml(origin: string, entries: Entry[]): string {
const items = entries.map((e) => {
const loc = `<loc>${origin}${e.path}</loc>`;
const mod = e.lastmod
? `<lastmod>${new Date(e.lastmod * 1000).toISOString()}</lastmod>`
: "";
return `<url>${loc}${mod}</url>`;
});
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
items.join("") +
"</urlset>"
);
}
// Public pages only: rooms the anonymous server can see, their threads, and
// the admin-authored resources.
export const GET: RequestHandler = snapshotHandler(async ({ url }) => {
const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }];
const shell = await loadShell();
const admins = new Set(shell.admins);
for (const r of shell.resources) {
if (admins.has(r.pubkey))
entries.push({ path: `/resource/${r.slug}`, lastmod: r.createdAt });
}
const roomIds =
MODE === "full"
? (await fetchGroups(forumQuery))
.filter((g) => !g.flags.includes("private"))
.map((g) => g.id)
: [GROUP_ID];
for (const id of roomIds) {
if (MODE === "full") entries.push({ path: `/room/${id}` });
const ops = await forumQuery({
kinds: [11],
"#h": [id],
limit: THREADS_PER_ROOM,
});
for (const e of ops)
entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at });
}
return new Response(xml(url.origin, entries), {
headers: { "content-type": "application/xml" },
});
});

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { afterNavigate, goto } from "$app/navigation";
import * as nip19 from "@nostr/tools/nip19";
import {
threadDetailStore,
@ -19,17 +19,21 @@
import { showToast } from "$lib/toast.svelte";
import {
withJoin,
joinState,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { setActiveGroup } from "$lib/active.svelte";
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
import { RELAY_URL, MODE } from "$lib/config";
import { profilePath } from "$lib/forum/profiles";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import MessageEditor from "$lib/components/MessageEditor.svelte";
import PostContent from "$lib/components/PostContent.svelte";
import Tag from "$lib/components/Tag.svelte";
import { type NostrUser } from "@nostr/gadgets/metadata";
import Meta from "$lib/components/Meta.svelte";
import { excerpt } from "$lib/seo";
import type { NostrUser } from "$lib/gadgets";
type Author = { pubkey: string; name: string; picture?: string };
@ -119,6 +123,25 @@
const detail = $derived(threadDetailStore.detail);
const profiles = $derived(threadDetailStore.profiles);
// Structured data for crawlers: the OP as a forum posting.
const jsonLd = $derived(
detail
? {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: detail.title,
text: detail.op.content,
url: page.url.origin + page.url.pathname,
datePublished: new Date(detail.op.createdAt * 1000).toISOString(),
author: {
"@type": "Person",
name: resolveAuthor(detail.op.pubkey, profiles).name,
},
commentCount: detail.replies.length,
}
: undefined,
);
// Replying needs membership regardless of flags. Only gate a confirmed guest;
// while membership resolves the editor stays and submit awaits the check, so a
// member never flashes "Join to reply".
@ -135,7 +158,7 @@
function onJoinToReply() {
if (!detail) return;
openJoinModal(detail.groupId, async () => {
withJoin(detail.groupId, async () => {
await tick();
editorEl?.focus();
});
@ -185,6 +208,20 @@
);
}
// The OP shares the thread URL, a reply its anchored URL.
async function shareLink(p: PostData, isOp: boolean) {
if (!detail) return;
openMenuId = null;
const url =
page.url.origin + `/thread/${detail.id}` + (isOp ? "" : `#post-${p.id}`);
try {
await navigator.clipboard.writeText(url);
showToast("Link copied");
} catch {
showToast("Could not copy the link");
}
}
$effect(() => {
if (!openMenuId) return;
const close = () => (openMenuId = null);
@ -210,6 +247,89 @@
if (page.params.id) loadThread(page.params.id);
});
// Arriving from search (?q=…): paint the terms over the title and post
// bodies, and scroll to the first match once per navigation. Re-runs as
// replies stream in so late matches get painted too.
let threadEl = $state<HTMLElement | null>(null);
let scrolledFor = "";
$effect(() => {
const q = page.url.searchParams.get("q")?.trim() ?? "";
allPosts; // Repaint when posts load or change
if (!threadEl || !q) {
clearHighlights();
return;
}
const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]");
const first = applyHighlights(roots, q);
const key = `${page.params.id}|${q}`;
if (first && scrolledFor !== key) {
scrolledFor = key;
first.startContainer.parentElement?.scrollIntoView({ block: "center" });
}
return clearHighlights;
});
// Arriving with a #post-… hash (a reply link): scroll to the post once per
// navigation. It may only exist once the replies have loaded, so re-check as
// they stream in; on a direct load it is already there and SvelteKit's own
// deep-link scroll runs after ours, so afterNavigate applies ours again.
let scrolledHashFor = "";
function scrollToHash(force = false) {
const hash = page.url.hash;
if (!threadEl || !hash.startsWith("#post-")) return;
const key = `${page.params.id}|${hash}`;
if (!force && scrolledHashFor === key) return;
const el = document.getElementById(hash.slice(1));
if (!el) return;
scrolledHashFor = key;
scrollToPost(el);
}
$effect(() => {
allPosts;
scrollToHash();
});
afterNavigate(() => scrollToHash(true));
// On desktop the thread scrolls inside <main> under a sticky title with a
// fade below it, so place the post under both; elsewhere the scroll margin
// is enough.
const HEADER_FADE = 32; // the h-8 gradient
const HEADER_GAP = 12;
function scrollToPost(el: HTMLElement) {
const main = document.querySelector("main");
const header = threadEl?.querySelector("[data-thread-header]");
if (!main || !header || !window.matchMedia("(min-width: 768px)").matches) {
el.scrollIntoView({ block: "start" });
return;
}
const headerBottom =
main.getBoundingClientRect().top + header.getBoundingClientRect().height;
const offset = headerBottom + HEADER_FADE + HEADER_GAP;
main.scrollTop += el.getBoundingClientRect().top - offset;
}
// In-thread quote links ("… said") get the same placement instead of the
// browser's default anchor jump.
$effect(() => {
const root = threadEl;
if (!root) return;
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = (e.target as Element).closest("a[href^='#post-']");
if (!a) return;
const hash = a.getAttribute("href") ?? "";
const el = document.getElementById(hash.slice(1));
if (!el) return;
e.preventDefault();
scrolledHashFor = `${page.params.id}|${hash}`;
goto(hash, { noScroll: true });
scrollToPost(el);
};
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
});
// A thread belongs to its own group; make that the active group so chat and
// replies target the right room.
$effect(() => {
@ -290,18 +410,34 @@
await quotePost(post, text);
}
// The scrubber sticks where the OP starts. Measure independently of the
// current scroll (the page may mount already scrolled, e.g. arriving from a
// profile with a reply link) and again when the header changes height.
$effect(() => {
if (!opEl) return;
const el = opEl;
const header = threadEl?.querySelector("[data-thread-header]");
if (!el || !header) return;
const main = document.querySelector("main");
if (!main) return;
opTopOffset =
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
const measure = () => {
opTopOffset =
el.getBoundingClientRect().top -
main.getBoundingClientRect().top +
main.scrollTop;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(header);
return () => ro.disconnect();
});
</script>
<svelte:head>
<title>{detail?.title ?? "Thread"}</title>
</svelte:head>
<Meta
title={detail?.title ?? "Thread"}
description={detail ? excerpt(detail.op.content) : ""}
type="article"
{jsonLd}
/>
{#snippet avatar(author: Author)}
{#if author.picture}
@ -326,50 +462,70 @@
)}
{@const author = resolveAuthor(p.pubkey, profiles)}
<div use:bindEl class="md:flex md:items-start md:gap-6">
<div class="hidden flex-shrink-0 md:block">
<a
href={profilePath(author.pubkey)}
class="hidden flex-shrink-0 md:block"
aria-hidden="true"
tabindex="-1"
>
{@render avatar(author)}
</div>
</a>
<div class="min-w-0 flex-1">
<div
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
>
<div class="flex min-w-0 items-center gap-3">
<div class="flex-shrink-0 md:hidden">
<a
href={profilePath(author.pubkey)}
class="flex-shrink-0 md:hidden"
aria-hidden="true"
tabindex="-1"
>
{@render avatar(author)}
</div>
<span
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</span
</a>
<a
href={profilePath(author.pubkey)}
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</a
>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
{#if canDeletePost(p)}
{@const tooOld =
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
<div class="relative">
<button
onclick={(e) => toggleMenu(p.id, e)}
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400"
aria-label="Post actions"
aria-haspopup="menu"
aria-expanded={openMenuId === p.id}
<div class="relative">
<button
onclick={(e) => toggleMenu(p.id, e)}
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400"
aria-label="Post actions"
aria-haspopup="menu"
aria-expanded={openMenuId === p.id}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
{#if openMenuId === p.id}
<div
role="menu"
class="absolute top-7 right-0 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg dark:border-neutral-800 dark:bg-neutral-900"
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
{#if openMenuId === p.id}
<div
role="menu"
class="absolute top-7 right-0 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg dark:border-neutral-800 dark:bg-neutral-900"
>
<button
role="menuitem"
onclick={(e) => {
e.stopPropagation();
shareLink(p, index === 0);
}}
class="w-full px-3 py-1.5 text-left text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
>Share link</button
>
{#if canDeletePost(p)}
{@const tooOld =
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
<button
role="menuitem"
disabled={tooOld}
@ -380,10 +536,10 @@
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:disabled:text-neutral-600"
>Delete{tooOld ? " (too old)" : ""}</button
>
</div>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<span class="text-sm text-neutral-400 dark:text-neutral-500"
>{formatDate(p.createdAt)}</span
>
@ -421,9 +577,10 @@
{/snippet}
{#if detail}
<div class="flex items-start gap-6">
<div class="flex items-start gap-6" bind:this={threadEl}>
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
<div
data-thread-header
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
>
<a
@ -491,9 +648,10 @@
</p>
<button
onclick={onJoinToReply}
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white"
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
>
Join to reply
{joinState.busy ? "Joining…" : "Join to reply"}
</button>
</div>
{:else}

View file

@ -0,0 +1,10 @@
import { snapshot, type Profiles } from "$lib/forum/snapshot";
import type { ThreadDetail } from "$lib/forum/thread";
export async function load({ fetch, params }) {
const data = await snapshot<{ thread: ThreadDetail; profiles: Profiles }>(
fetch,
`/api/thread/${params.id}`,
);
return { thread: data?.thread ?? null, profiles: data?.profiles ?? null };
}

View file

@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:

View file

@ -1,11 +1,21 @@
import adapter from "@sveltejs/adapter-static";
import { loadEnv } from "vite";
import adapterNode from "@sveltejs/adapter-node";
import adapterStatic from "@sveltejs/adapter-static";
// PUBLIC_SSR picks the deployment target: a Node server that renders pages
// (yes) or a static single-page bundle (no, the default). vite.config.ts bakes
// the same flag into the app as SSR_ENABLED.
const env = loadEnv(
process.env.NODE_ENV ?? "production",
process.cwd(),
"PUBLIC_",
);
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
fallback: "index.html",
}),
adapter: ssr ? adapterNode() : adapterStatic({ fallback: "index.html" }),
},
};

View file

@ -1,5 +1,15 @@
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
// PUBLIC_SSR is a build-time decision (it also picks the adapter in
// svelte.config.js), so it is compiled in as a constant rather than read from
// the environment when the server starts.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "PUBLIC_");
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
return {
plugins: [tailwindcss(), sveltekit()],
define: { __SQUALK_SSR__: JSON.stringify(ssr) },
};
});