Compare commits
61 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e63fda9f | ||
|
|
c370a87f12 | ||
|
|
5f8bf2fe7e | ||
|
|
58e81d1dba | ||
|
|
455898f3c9 | ||
|
|
dde46dc5d1 | ||
|
|
70cb4e8c22 | ||
|
|
2396407de6 | ||
|
|
6cc1674279 | ||
|
|
982c20d1fa | ||
|
|
8695d3e40e | ||
|
|
67d461333c | ||
|
|
4a9c3a6454 | ||
|
|
0c7ada6d5b | ||
|
|
1b31695d27 | ||
|
|
9872f33185 | ||
|
|
9ac14fc428 | ||
|
|
53b4865de1 | ||
|
|
272c7d5ce6 | ||
|
|
d4e644a964 | ||
|
|
0f487eb33a | ||
|
|
bfad456d82 | ||
|
|
8bacf1ccbf | ||
|
|
997184cd66 | ||
|
|
7b80afbd6c | ||
|
|
9950c4337a | ||
|
|
c793203d9f | ||
|
|
a9b0702c68 | ||
|
|
f40680cbf9 | ||
|
|
bcbc70ecb8 | ||
|
|
03395adf85 | ||
|
|
f734ff030c | ||
|
|
23ef212320 | ||
|
|
39d8d920b6 | ||
|
|
17f53946f1 | ||
|
|
7e98cb2575 | ||
|
|
2cda3acedb | ||
|
|
b4254ebe23 | ||
|
|
d4c0192dcf | ||
|
|
5329c611b1 | ||
|
|
7883e6df8a | ||
|
|
9ab145f816 | ||
|
|
018c72021f | ||
|
|
5c455bfd50 | ||
|
|
7f0d823d6c | ||
|
|
3b79271874 | ||
|
|
fc7c749856 | ||
|
|
2041f6b899 | ||
|
|
c23428f904 | ||
|
|
40e0be35d9 | ||
|
|
a7b5d8f223 | ||
|
|
54c0682dce | ||
|
|
9cc82dc1fe | ||
|
|
6ecd618bce | ||
|
|
aaa9a0c1e5 | ||
|
|
dadba06f8c | ||
|
|
81fde4c932 | ||
|
|
2c432b57e7 | ||
|
|
2ab69e4cd3 | ||
|
|
46176511f3 | ||
|
|
c9227ee8fb |
94 changed files with 9556 additions and 4780 deletions
12
.env.example
12
.env.example
|
|
@ -2,10 +2,20 @@ 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)
|
||||
PUBLIC_EXTERNAL_CLIENT= # external client for profiles/events, {e} is the bech32 entity; default https://njump.me/{e}
|
||||
# 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/ or .local/<mode>/static/); default is a filled circle in the accent color
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
21
LICENSE
Normal 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.
|
||||
31
README.md
31
README.md
|
|
@ -27,9 +27,14 @@ 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. |
|
||||
| `PUBLIC_EXTERNAL_CLIENT` | no | `https://njump.me/{e}` | URL template of the external Nostr client used to open profiles and events outside the forum. `{e}` is replaced with the bech32 entity (npub, nevent, naddr), e.g. `https://nostr.at/{e}`. |
|
||||
| `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. |
|
||||
| `PUBLIC_SECONDARY_COLOR` | no | `#ffaf25` | Override the secondary color. Same quoting rule and derived hover shade as above. |
|
||||
|
||||
|
|
@ -89,12 +94,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. Files an instance needs served at its root (a favicon, say) go in `.local/<mode>/static/` (gitignored): when that folder exists, the build ships it instead of the shared `static/`. 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.
|
||||
|
|
|
|||
25
deploy/production-example.service
Normal file
25
deploy/production-example.service
Normal 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
|
||||
43
justfile
43
justfile
|
|
@ -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
7752
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"name": "squalk",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/app.d.ts
vendored
22
src/app.d.ts
vendored
|
|
@ -1,10 +1,30 @@
|
|||
// 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, package.json and git
|
||||
const __SQUALK_SSR__: boolean;
|
||||
const __SQUALK_VERSION__: string;
|
||||
const __SQUALK_COMMIT__: string;
|
||||
|
||||
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
109
src/lib/access.svelte.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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,34 @@ 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;
|
||||
}
|
||||
|
||||
export function loginMethod(): string | null {
|
||||
return user ? localStorage.getItem(METHOD_KEY) : 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 +145,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 +178,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 +189,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 +249,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
216
src/lib/bunker.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
chatStore,
|
||||
getChatMessage,
|
||||
|
|
@ -15,11 +16,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 +78,7 @@
|
|||
});
|
||||
|
||||
function onJoinToChat() {
|
||||
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||
}
|
||||
|
||||
function requestDeleteMessage(msg: ChatMessageData) {
|
||||
|
|
@ -250,14 +252,34 @@
|
|||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
});
|
||||
|
||||
// Arriving with a #chat-… hash (a notification link): scroll to the message
|
||||
// once it is loaded, and stop anchoring to the bottom so it stays in view.
|
||||
let scrolledHashFor = "";
|
||||
$effect(() => {
|
||||
const hash = page.url.hash;
|
||||
messages.length;
|
||||
mobileActive;
|
||||
if (!hash.startsWith("#chat-") || scrolledHashFor === hash) return;
|
||||
tick().then(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (!el) return;
|
||||
scrolledHashFor = hash;
|
||||
userScrolledUp = true;
|
||||
el.scrollIntoView({ block: "center" });
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
@ -346,23 +368,32 @@
|
|||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div id="chat-{msg.id}">
|
||||
<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 +509,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 +517,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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { resourcesStore } from "$lib/resources.svelte";
|
||||
|
|
@ -7,6 +7,15 @@
|
|||
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
|
||||
import { openSearch } from "$lib/searchModal.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import UserAvatar from "$lib/components/UserAvatar.svelte";
|
||||
import { notificationsStore } from "$lib/notifications.svelte";
|
||||
|
||||
const unread = $derived(notificationsStore.unreadCount);
|
||||
const profileLabel = $derived(
|
||||
unread > 0
|
||||
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||
: "Your profile",
|
||||
);
|
||||
|
||||
type Props = {
|
||||
mode: "simple" | "full";
|
||||
|
|
@ -29,120 +38,153 @@
|
|||
const activeAbout = $derived(
|
||||
groupsStore.list.find((g) => g.id === activeRoom)?.about ?? "",
|
||||
);
|
||||
|
||||
// Bottom fade shown while the nav has more content below the fold.
|
||||
let navEl = $state<HTMLElement | null>(null);
|
||||
let moreBelow = $state(false);
|
||||
|
||||
function updateFade() {
|
||||
const el = navEl;
|
||||
if (!el) return;
|
||||
moreBelow = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = navEl;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(updateFade);
|
||||
ro.observe(el);
|
||||
for (const child of el.children) ro.observe(child);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden max-w-52 min-w-48 shrink-0 flex-col justify-between pr-1 pb-8 pl-8 md:flex md:pt-6"
|
||||
class="hidden max-w-52 min-w-48 shrink-0 flex-col justify-between pr-1 pb-8 pl-8 md:flex md:min-h-0 md:pt-6"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-3 mb-6 flex min-w-0 flex-col hover:opacity-90"
|
||||
aria-label="{name} — home"
|
||||
<!-- Own scroll container so a long about/rooms list never pushes the
|
||||
account row off screen (the page wrapper clips overflow). -->
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div
|
||||
bind:this={navEl}
|
||||
onscroll={updateFade}
|
||||
class="no-scrollbar h-full overflow-y-auto"
|
||||
>
|
||||
{#if groupStore.data?.picture}
|
||||
<img
|
||||
src={groupStore.data.picture}
|
||||
alt={name}
|
||||
class="max-w-[90%] shrink-0 object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="mt-2 text-[1.3rem] leading-6 font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>{name}</span
|
||||
<a
|
||||
href="/"
|
||||
class="mt-3 mb-6 flex min-w-0 flex-col hover:opacity-90"
|
||||
aria-label="{name} — home"
|
||||
>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 py-1 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{mode === "simple" ? "Discussions" : "Home"}
|
||||
</a>
|
||||
|
||||
{#if SEARCH_ENABLED}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openSearch}
|
||||
class="flex w-full items-center gap-2 py-1 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Search
|
||||
<kbd
|
||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded bg-neutral-200 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
aria-hidden="true">/</kbd
|
||||
{#if groupStore.data?.picture}
|
||||
<img
|
||||
src={groupStore.data.picture}
|
||||
alt={name}
|
||||
class="max-w-[90%] shrink-0 object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="mt-2 text-[1.3rem] leading-6 font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>{name}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if mode === "simple"}
|
||||
<div class="mt-6 flex">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{groupStore.data?.about ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if activeAbout}
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 py-1 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{mode === "simple" ? "Discussions" : "Home"}
|
||||
</a>
|
||||
|
||||
{#if SEARCH_ENABLED}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openSearch}
|
||||
class="flex w-full items-center gap-2 py-1 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Search
|
||||
<kbd
|
||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded bg-neutral-200 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
aria-hidden="true">/</kbd
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if mode === "simple"}
|
||||
<div class="mt-6 flex">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{groupStore.data?.about ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if activeAbout}
|
||||
<div class="mt-6">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
About
|
||||
</p>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{activeAbout}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<nav class="mt-6 flex-auto" aria-label="Rooms">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
About
|
||||
Rooms
|
||||
</p>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{activeAbout}
|
||||
</p>
|
||||
</div>
|
||||
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||
<p class="py-1 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Loading rooms…
|
||||
</p>
|
||||
{/if}
|
||||
{#each groupsStore.list as room}
|
||||
<a
|
||||
href="/room/{room.id}"
|
||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-800
|
||||
{activeRoom === room.id
|
||||
? ' text-accent'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
<nav class="mt-6 flex-auto" aria-label="Rooms">
|
||||
|
||||
<nav class="mt-6 flex-auto" aria-label="Resources">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
Rooms
|
||||
Resources
|
||||
</p>
|
||||
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||
<p class="py-1 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Loading rooms…
|
||||
</p>
|
||||
{/if}
|
||||
{#each groupsStore.list as room}
|
||||
{#each resourcesStore.list as r (r.slug)}
|
||||
<a
|
||||
href="/room/{room.id}"
|
||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-800
|
||||
{activeRoom === room.id
|
||||
? ' text-accent'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<nav class="mt-6 flex-auto" aria-label="Resources">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
Resources
|
||||
</p>
|
||||
{#each resourcesStore.list as r (r.slug)}
|
||||
<a
|
||||
href="/resource/{r.slug}"
|
||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||
href="/resource/{r.slug}"
|
||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||
{activeResource === r.slug
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}">{r.title}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
href="/contacts"
|
||||
aria-current={contactsActive ? "page" : undefined}
|
||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}">{r.title}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
href="/contacts"
|
||||
aria-current={contactsActive ? "page" : undefined}
|
||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||
{contactsActive
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}">Contacts</a
|
||||
>
|
||||
</nav>
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}">Contacts</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-transparent to-neutral-100 transition-opacity dark:to-neutral-800 {moreBelow
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -157,29 +199,14 @@
|
|||
{/if}
|
||||
{#if auth.user}
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => {
|
||||
if (confirm("Log out?")) logout();
|
||||
}}
|
||||
<a
|
||||
href="/profile/{auth.user.npub}"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
aria-label="Account options"
|
||||
aria-label={profileLabel}
|
||||
>
|
||||
{#if auth.user.metadata.picture}
|
||||
<img
|
||||
src={auth.user.metadata.picture}
|
||||
alt=""
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-xs font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
<UserAvatar />
|
||||
<span class="truncate font-medium">{auth.user.shortName}</span>
|
||||
</button>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
64
src/lib/components/Meta.svelte
Normal file
64
src/lib/components/Meta.svelte
Normal 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>
|
||||
|
|
@ -7,6 +7,15 @@
|
|||
import { SEARCH_ENABLED } from "$lib/config";
|
||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import UserAvatar from "$lib/components/UserAvatar.svelte";
|
||||
import { notificationsStore } from "$lib/notifications.svelte";
|
||||
|
||||
const unread = $derived(notificationsStore.unreadCount);
|
||||
const profileLabel = $derived(
|
||||
unread > 0
|
||||
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||
: "Your profile",
|
||||
);
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
|
|
@ -83,7 +92,7 @@
|
|||
transition:fade={{ duration: 150 }}
|
||||
></button>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white dark:bg-neutral-900 px-6 py-4 shadow-xl"
|
||||
class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white px-6 py-4 shadow-xl dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Menu"
|
||||
|
|
@ -97,7 +106,7 @@
|
|||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="-mr-1 rounded p-1 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
class="-mr-1 rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -142,19 +151,25 @@
|
|||
{#if mode === "full"}
|
||||
<nav class="mt-4" aria-label="Rooms">
|
||||
<p
|
||||
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase"
|
||||
class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
Rooms
|
||||
</p>
|
||||
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||
<p class="py-1.5 text-base text-neutral-400 dark:text-neutral-500">Loading rooms…</p>
|
||||
<p
|
||||
class="py-1.5 text-base text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Loading rooms…
|
||||
</p>
|
||||
{/if}
|
||||
{#each groupsStore.list as room}
|
||||
<a
|
||||
href="/room/{room.id}"
|
||||
onclick={onClose}
|
||||
class="block py-1.5 text-xl
|
||||
{activeRoom === room.id ? 'text-accent' : 'hover:text-accent text-neutral-700 dark:text-neutral-300'}"
|
||||
{activeRoom === room.id
|
||||
? 'text-accent'
|
||||
: 'hover:text-accent text-neutral-700 dark:text-neutral-300'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
|
|
@ -164,7 +179,7 @@
|
|||
|
||||
<nav class="mt-4" aria-label="Resources">
|
||||
<p
|
||||
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase"
|
||||
class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||
>
|
||||
Resources
|
||||
</p>
|
||||
|
|
@ -174,8 +189,9 @@
|
|||
onclick={onClose}
|
||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||
class="hover:text-accent block py-1.5 text-xl
|
||||
{activeResource === r.slug ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}"
|
||||
>{r.title}</a
|
||||
{activeResource === r.slug
|
||||
? 'text-accent'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}">{r.title}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
|
|
@ -183,11 +199,25 @@
|
|||
onclick={onClose}
|
||||
aria-current={contactsActive ? "page" : undefined}
|
||||
class="hover:text-accent block py-1.5 text-lg
|
||||
{contactsActive ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}">Contacts</a
|
||||
{contactsActive
|
||||
? 'text-accent'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}">Contacts</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 dark:border-neutral-800 pt-4">
|
||||
{#if auth.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLogout}
|
||||
class="hover:text-accent mt-4 py-1.5 text-left text-lg text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4 dark:border-neutral-800"
|
||||
>
|
||||
{#if draftState.iconized}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -199,34 +229,17 @@
|
|||
{/if}
|
||||
{#if auth.user}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLogout}
|
||||
<a
|
||||
href="/profile/{auth.user.npub}"
|
||||
onclick={onClose}
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-2 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
aria-label="Log out"
|
||||
aria-label={profileLabel}
|
||||
>
|
||||
{#if auth.user.metadata.picture}
|
||||
<img
|
||||
src={auth.user.metadata.picture}
|
||||
alt=""
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-sm font-semibold text-neutral-600 dark:text-neutral-400 dark:bg-neutral-600"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
<UserAvatar size="md" />
|
||||
<span class="truncate text-lg font-medium"
|
||||
>{auth.user.shortName}</span
|
||||
>
|
||||
<span
|
||||
class="ml-auto shrink-0 text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>Log out</span
|
||||
>
|
||||
</button>
|
||||
</a>
|
||||
<ThemeToggle size="md" />
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { notificationsStore } from "$lib/notifications.svelte";
|
||||
import { GROUP_ID, TITLE, MODE } from "$lib/config";
|
||||
|
||||
type Props = { onMenuToggle: () => void };
|
||||
let { onMenuToggle }: Props = $props();
|
||||
|
||||
// The unread notifications count rides on the menu button; the drawer's
|
||||
// profile link repeats it and leads to the list.
|
||||
const unread = $derived(notificationsStore.unreadCount);
|
||||
const menuLabel = $derived(
|
||||
unread > 0
|
||||
? `Open menu, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||
: "Open menu",
|
||||
);
|
||||
|
||||
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
|
||||
// own name; full mode has no single room, so it falls back to GROUP_ID.
|
||||
const name = $derived(
|
||||
|
|
@ -15,7 +25,7 @@
|
|||
|
||||
<!-- Mobile only: on desktop the logo lives at the top of the left sidebar. -->
|
||||
<header
|
||||
class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 dark:bg-neutral-800 px-4 md:hidden"
|
||||
class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 px-4 md:hidden dark:bg-neutral-800"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
|
|
@ -36,16 +46,24 @@
|
|||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100 md:text-[1.7rem]"
|
||||
class="truncate text-2xl font-medium text-neutral-900 md:text-[1.7rem] dark:text-neutral-100"
|
||||
>{name}</span
|
||||
>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMenuToggle}
|
||||
class="-mr-1 shrink-0 rounded p-1.5 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 md:hidden"
|
||||
aria-label="Open menu"
|
||||
class="relative -mr-1 shrink-0 rounded p-1.5 text-neutral-700 hover:bg-neutral-200 md:hidden dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
aria-label={menuLabel}
|
||||
>
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="bg-accent absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
{/if}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
|
|
|
|||
210
src/lib/components/Notifications.svelte
Normal file
210
src/lib/components/Notifications.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { MODE } from "$lib/config";
|
||||
import { getGroupName } from "$lib/group.svelte";
|
||||
import { profileStore } from "$lib/profiles.svelte";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import {
|
||||
notificationsStore,
|
||||
markNotificationsSeen,
|
||||
loadMoreNotifications,
|
||||
type Notification,
|
||||
} from "$lib/notifications.svelte";
|
||||
import { summarize } from "$lib/seo";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
|
||||
const PAGE = 5;
|
||||
|
||||
const items = $derived(notificationsStore.items);
|
||||
// Visible slice; "Show more" reveals another page from what is loaded and
|
||||
// fetches older ones once the loaded list runs out.
|
||||
let shown = $state(PAGE);
|
||||
const visible = $derived(items?.slice(0, shown) ?? null);
|
||||
const hasMore = $derived(
|
||||
!!items && (items.length > shown || !notificationsStore.done),
|
||||
);
|
||||
|
||||
async function showMore() {
|
||||
if (items && items.length <= shown) await loadMoreNotifications();
|
||||
shown += PAGE;
|
||||
}
|
||||
|
||||
// Unread threshold frozen when the list first shows, so new items keep their
|
||||
// mark while on screen even though they are counted as seen right away.
|
||||
let openedAt = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!items) return;
|
||||
if (openedAt === null) {
|
||||
const seenAt = untrack(() => notificationsStore.seenAt);
|
||||
openedAt = seenAt;
|
||||
// Every new item is visible at once; the page size is only a floor
|
||||
const unread = items.filter((n) => n.createdAt > seenAt).length;
|
||||
shown = Math.max(PAGE, unread);
|
||||
}
|
||||
markNotificationsSeen();
|
||||
});
|
||||
|
||||
function authorName(pubkey: string): string {
|
||||
const p = profileStore.profiles.get(pubkey);
|
||||
return (
|
||||
p?.displayName ||
|
||||
p?.name ||
|
||||
`${(p?.npub ?? nip19.npubEncode(pubkey)).slice(0, 12)}…`
|
||||
);
|
||||
}
|
||||
|
||||
function authorPicture(pubkey: string): string | undefined {
|
||||
return profileStore.profiles.get(pubkey)?.picture;
|
||||
}
|
||||
|
||||
function verb(n: Notification): string {
|
||||
switch (n.kind) {
|
||||
case "quote":
|
||||
case "reply-op":
|
||||
return "replied to you in";
|
||||
case "reply":
|
||||
return "wrote in";
|
||||
case "mention":
|
||||
return "mentioned you in";
|
||||
case "chat-reply":
|
||||
return "replied to you in chat";
|
||||
case "chat-mention":
|
||||
return MODE === "full" ? "mentioned you in" : "mentioned you in chat";
|
||||
}
|
||||
}
|
||||
|
||||
function href(n: Notification): string {
|
||||
if (n.threadId) return `/thread/${n.threadId}#post-${n.id}`;
|
||||
const room = MODE === "full" ? `/room/${n.groupId}` : "/";
|
||||
return `${room}#chat-${n.id}`;
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return "now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d`;
|
||||
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-labelledby="profile-notifications" class="mt-10">
|
||||
<h2
|
||||
id="profile-notifications"
|
||||
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||
>
|
||||
Notifications
|
||||
</h2>
|
||||
{#if !items}
|
||||
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">Loading…</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
No replies or mentions yet.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{#each visible ?? [] as n (n.id)}
|
||||
{@const unread = n.createdAt > (openedAt ?? 0)}
|
||||
{@const name = authorName(n.pubkey)}
|
||||
{@const picture = authorPicture(n.pubkey)}
|
||||
{@const text = summarize(n.content, 160)}
|
||||
<li class="flex gap-3 py-3">
|
||||
<a
|
||||
href={profilePath(n.pubkey)}
|
||||
class="mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if picture}
|
||||
<img
|
||||
src={picture}
|
||||
alt=""
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
{name[0].toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href={href(n)} class="group block">
|
||||
<p
|
||||
class="text-neutral-900 dark:text-neutral-100 {unread
|
||||
? 'font-semibold'
|
||||
: ''}"
|
||||
>
|
||||
{#if unread}
|
||||
<span
|
||||
class="bg-accent mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sr-only">New: </span>
|
||||
{/if}
|
||||
<span>{name}</span>
|
||||
<span
|
||||
class="font-normal text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{verb(n)}
|
||||
</span>
|
||||
{#if n.threadId}
|
||||
<span class="group-hover:text-accent">
|
||||
{n.title || "a discussion"}
|
||||
</span>
|
||||
{:else if n.kind === "chat-mention" && MODE === "full"}
|
||||
<span class="group-hover:text-accent"
|
||||
>{getGroupName(n.groupId)}</span
|
||||
>
|
||||
<span
|
||||
class="font-normal text-neutral-500 dark:text-neutral-400"
|
||||
>chat</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{#if text}
|
||||
<p
|
||||
class="mt-0.5 line-clamp-2 text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
<div
|
||||
class="mt-1 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
{#if MODE === "full" && n.groupId}
|
||||
<a href="/room/{n.groupId}" class="hover:text-accent truncate"
|
||||
>{getGroupName(n.groupId)}</a
|
||||
>
|
||||
<span aria-hidden="true">·</span>
|
||||
{/if}
|
||||
<time datetime={new Date(n.createdAt * 1000).toISOString()}
|
||||
>{formatTime(n.createdAt)}</time
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hasMore}
|
||||
{@const busy = notificationsStore.loadingMore}
|
||||
<div class="flex justify-center py-6">
|
||||
<button
|
||||
onclick={showMore}
|
||||
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}
|
||||
</section>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<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 { externalLink } from "$lib/config";
|
||||
import {
|
||||
resolveThreadRef,
|
||||
threadRefHref,
|
||||
|
|
@ -310,7 +312,7 @@
|
|||
const entity = dest.slice(6).toLowerCase();
|
||||
try {
|
||||
nip19.decode(entity);
|
||||
return { type: "link", href: `https://njump.me/${entity}`, label };
|
||||
return { type: "link", href: externalLink(entity), label };
|
||||
} catch {
|
||||
return { type: "text", value: label };
|
||||
}
|
||||
|
|
@ -333,7 +335,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 +612,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 +643,7 @@
|
|||
>
|
||||
{:else}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
href={externalLink(inline.entity)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent break-all hover:underline">{inline.label}</a
|
||||
|
|
@ -700,7 +698,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 +771,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>
|
||||
|
|
|
|||
106
src/lib/components/RelayGate.svelte
Normal file
106
src/lib/components/RelayGate.svelte
Normal 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>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
function select(r: SearchResult) {
|
||||
open = false;
|
||||
goto(`/thread/${r.threadId}`);
|
||||
goto(search.threadUrl(r.threadId));
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
src/lib/components/UserAvatar.svelte
Normal file
40
src/lib/components/UserAvatar.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
import { notificationsStore } from "$lib/notifications.svelte";
|
||||
|
||||
type Props = { size?: "sm" | "md" };
|
||||
let { size = "sm" }: Props = $props();
|
||||
|
||||
const box = $derived(size === "md" ? "h-8 w-8 text-sm" : "h-7 w-7 text-xs");
|
||||
const count = $derived(notificationsStore.unreadCount);
|
||||
const label = $derived(count > 99 ? "99+" : String(count));
|
||||
</script>
|
||||
|
||||
<!-- Logged-in user's picture with the unread notifications count. The count is
|
||||
visual only; the wrapping link carries it in its accessible name. -->
|
||||
{#if auth.user}
|
||||
<span class="relative inline-flex shrink-0">
|
||||
{#if auth.user.metadata.picture}
|
||||
<img
|
||||
src={auth.user.metadata.picture}
|
||||
alt=""
|
||||
class="{box} rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="{box} flex items-center justify-center rounded-full bg-neutral-300 font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if count > 0}
|
||||
<span
|
||||
class="bg-accent absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -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,31 @@ 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>`,
|
||||
);
|
||||
|
||||
// External Nostr client used to open events and profiles that live outside
|
||||
// the forum. {e} is replaced with the bech32 entity (npub, nevent, naddr...).
|
||||
const DEFAULT_EXTERNAL_CLIENT = "https://njump.me/{e}";
|
||||
export const EXTERNAL_CLIENT = env.PUBLIC_EXTERNAL_CLIENT?.includes("{e}")
|
||||
? env.PUBLIC_EXTERNAL_CLIENT
|
||||
: DEFAULT_EXTERNAL_CLIENT;
|
||||
export const EXTERNAL_CLIENT_NAME = (() => {
|
||||
try {
|
||||
return new URL(EXTERNAL_CLIENT.replace("{e}", "x")).hostname;
|
||||
} catch {
|
||||
return "external client";
|
||||
}
|
||||
})();
|
||||
export function externalLink(entity: string): string {
|
||||
return EXTERNAL_CLIENT.replace("{e}", entity);
|
||||
}
|
||||
|
|
|
|||
81
src/lib/forum/groups.ts
Normal file
81
src/lib/forum/groups.ts
Normal 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;
|
||||
}
|
||||
116
src/lib/forum/notifications.ts
Normal file
116
src/lib/forum/notifications.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { Event } from "@nostr/tools/core";
|
||||
import { extractMentionPubkeys } from "$lib/mentions";
|
||||
import { tag, type Query } from "./query";
|
||||
|
||||
export type NotificationKind =
|
||||
| "quote" // reply quoting one of the user's posts
|
||||
| "mention" // mentioned in a discussion reply
|
||||
| "reply-op" // reply to a discussion the user started
|
||||
| "reply" // reply in a discussion the user took part in, p-tag only
|
||||
| "chat-reply" // chat reply to the user's message
|
||||
| "chat-mention"; // mentioned in chat
|
||||
|
||||
// Everything but plain thread activity is addressed to the user. Not applied
|
||||
// yet: a future setting will let users leave plain activity out of the badge.
|
||||
export function isDirect(kind: NotificationKind): boolean {
|
||||
return kind !== "reply";
|
||||
}
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
pubkey: string; // author
|
||||
createdAt: number;
|
||||
content: string;
|
||||
groupId: string;
|
||||
threadId?: string; // discussion replies only
|
||||
title?: string; // thread title, filled by fetchThreadTitles
|
||||
};
|
||||
|
||||
export const NOTIFICATIONS_PAGE = 20;
|
||||
const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor
|
||||
|
||||
export type NotificationPage = {
|
||||
items: Notification[];
|
||||
done: boolean; // no older notifications left
|
||||
};
|
||||
|
||||
// Classify an event that p-tags the user. Own events and anything that is
|
||||
// neither a discussion reply nor a chat message yield null.
|
||||
export function parseNotification(e: Event, me: string): Notification | null {
|
||||
if (e.pubkey === me) return null;
|
||||
const mentioned = extractMentionPubkeys(e.content).includes(me);
|
||||
const base = {
|
||||
id: e.id,
|
||||
pubkey: e.pubkey,
|
||||
createdAt: e.created_at,
|
||||
content: e.content,
|
||||
groupId: tag(e, "h") ?? "",
|
||||
};
|
||||
if (e.kind === 1111) {
|
||||
const threadId = tag(e, "E");
|
||||
if (!threadId) return null;
|
||||
const quoted = e.tags.some((t) => t[0] === "q" && t[3] === me);
|
||||
const kind: NotificationKind = quoted
|
||||
? "quote"
|
||||
: mentioned
|
||||
? "mention"
|
||||
: tag(e, "P") === me
|
||||
? "reply-op"
|
||||
: "reply";
|
||||
return { ...base, kind, threadId };
|
||||
}
|
||||
if (e.kind === 9) {
|
||||
const q = e.tags.find((t) => t[0] === "q");
|
||||
return { ...base, kind: q?.[3] === me ? "chat-reply" : "chat-mention" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fill thread titles for discussion replies, in place.
|
||||
export async function fetchThreadTitles(
|
||||
q: Query,
|
||||
items: Notification[],
|
||||
): Promise<void> {
|
||||
const ids = [
|
||||
...new Set(items.flatMap((n) => (n.threadId ? [n.threadId] : []))),
|
||||
];
|
||||
if (ids.length === 0) return;
|
||||
const roots = await q({ kinds: [11], ids });
|
||||
const titles = new Map(
|
||||
roots.map((e) => [e.id, tag(e, "title") ?? "(untitled)"]),
|
||||
);
|
||||
for (const n of items) {
|
||||
if (n.threadId) n.title = titles.get(n.threadId) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// One page of replies and mentions addressed to the user, newest first,
|
||||
// older than the cursor. Without `groupId` every group the relay serves the
|
||||
// caller counts.
|
||||
export async function fetchNotifications(
|
||||
q: Query,
|
||||
me: string,
|
||||
req: { groupId?: string; until?: number; exclude?: Set<string> } = {},
|
||||
): Promise<NotificationPage> {
|
||||
const n = NOTIFICATIONS_PAGE;
|
||||
const exclude = req.exclude ?? new Set<string>();
|
||||
const events = await q({
|
||||
kinds: [1111, 9],
|
||||
"#p": [me],
|
||||
...(req.groupId ? { "#h": [req.groupId] } : {}),
|
||||
...(req.until ? { until: req.until } : {}),
|
||||
limit: n + CURSOR_HEADROOM,
|
||||
});
|
||||
const fresh = events
|
||||
.filter((e) => !exclude.has(e.id))
|
||||
.map((e) => parseNotification(e, me))
|
||||
.filter((x): x is Notification => x !== null)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const items = 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;
|
||||
await fetchThreadTitles(q, items);
|
||||
return { items, done };
|
||||
}
|
||||
73
src/lib/forum/overview.ts
Normal file
73
src/lib/forum/overview.ts
Normal 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
48
src/lib/forum/partials.ts
Normal 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
75
src/lib/forum/profiles.ts
Normal 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
36
src/lib/forum/query.ts
Normal 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]);
|
||||
}
|
||||
47
src/lib/forum/resources.ts
Normal file
47
src/lib/forum/resources.ts
Normal 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
74
src/lib/forum/snapshot.ts
Normal 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
56
src/lib/forum/thread.ts
Normal 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
238
src/lib/forum/threads.ts
Normal 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
106
src/lib/forum/userPosts.ts
Normal 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
17
src/lib/gadgets.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { RELAY_URL } from "./config";
|
||||
import { RELAY_URL, externalLink } from "./config";
|
||||
|
||||
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||
const TLDS = [
|
||||
|
|
@ -157,7 +157,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
|||
return {
|
||||
type: "entity",
|
||||
entity,
|
||||
href: `https://njump.me/${entity}`,
|
||||
href: externalLink(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: externalLink(entity),
|
||||
label: shortNostrEntity(entity),
|
||||
};
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
149
src/lib/notifications.svelte.ts
Normal file
149
src/lib/notifications.svelte.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { queryForum, subscribeForum } from "$lib/relay";
|
||||
import { ensureProfile } from "$lib/profiles.svelte";
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchThreadTitles,
|
||||
parseNotification,
|
||||
type Notification,
|
||||
type NotificationPage,
|
||||
} from "$lib/forum/notifications";
|
||||
|
||||
export type { Notification };
|
||||
|
||||
const SEEN_KEY = "notifications_seen";
|
||||
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
|
||||
|
||||
let pubkey = $state<string | null>(null);
|
||||
let items = $state<Notification[] | null>(null);
|
||||
let done = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let seenAt = $state(0);
|
||||
let liveSub: { close(): void } | null = null;
|
||||
let req = 0; // supersedes an in-flight load on login/logout
|
||||
|
||||
function readSeen(pk: string): number {
|
||||
try {
|
||||
return Number(localStorage.getItem(`${SEEN_KEY}_${pk}`)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationsStore = {
|
||||
get pubkey() {
|
||||
return pubkey;
|
||||
},
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get done() {
|
||||
return done;
|
||||
},
|
||||
get loadingMore() {
|
||||
return loadingMore;
|
||||
},
|
||||
get seenAt() {
|
||||
return seenAt;
|
||||
},
|
||||
get unreadCount() {
|
||||
return items?.filter((n) => n.createdAt > seenAt).length ?? 0;
|
||||
},
|
||||
};
|
||||
|
||||
function ingest(n: Notification) {
|
||||
if (!items || items.some((x) => x.id === n.id)) return;
|
||||
items = [n, ...items];
|
||||
ensureProfile(n.pubkey);
|
||||
}
|
||||
|
||||
// (Re)start for the logged-in user: one authenticated read of the latest
|
||||
// replies/mentions, then a live subscription that prepends new ones.
|
||||
export async function startNotifications(pk: string) {
|
||||
if (pk === pubkey) return;
|
||||
stopNotifications();
|
||||
pubkey = pk;
|
||||
seenAt = readSeen(pk);
|
||||
const current = ++req;
|
||||
const groupId = scope();
|
||||
|
||||
let result: NotificationPage = { items: [], done: true };
|
||||
try {
|
||||
result = await fetchNotifications(queryForum, pk, { groupId });
|
||||
} catch (e) {
|
||||
console.error("[notifications] load failed", e);
|
||||
}
|
||||
if (current !== req) return;
|
||||
items = result.items;
|
||||
done = result.done;
|
||||
for (const n of result.items) ensureProfile(n.pubkey);
|
||||
|
||||
liveSub = subscribeForum(
|
||||
{
|
||||
kinds: [1111, 9],
|
||||
"#p": [pk],
|
||||
...(groupId ? { "#h": [groupId] } : {}),
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
{
|
||||
onevent: async (ev) => {
|
||||
const n = parseNotification(ev, pk);
|
||||
if (!n) return;
|
||||
if (n.threadId) {
|
||||
try {
|
||||
await fetchThreadTitles(queryForum, [n]);
|
||||
} catch {
|
||||
// Shown without a title
|
||||
}
|
||||
}
|
||||
if (current === req) ingest(n);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function stopNotifications() {
|
||||
req++;
|
||||
liveSub?.close();
|
||||
liveSub = null;
|
||||
pubkey = null;
|
||||
items = null;
|
||||
done = true;
|
||||
loadingMore = false;
|
||||
seenAt = 0;
|
||||
}
|
||||
|
||||
// Append the next page of older notifications.
|
||||
export async function loadMoreNotifications() {
|
||||
const pk = pubkey;
|
||||
const current = req;
|
||||
if (!pk || !items || done || loadingMore) return;
|
||||
loadingMore = true;
|
||||
const oldest = items[items.length - 1];
|
||||
try {
|
||||
const next = await fetchNotifications(queryForum, pk, {
|
||||
groupId: scope(),
|
||||
until: oldest?.createdAt,
|
||||
exclude: new Set(items.map((n) => n.id)),
|
||||
});
|
||||
if (current !== req || !items) return;
|
||||
items = [...items, ...next.items];
|
||||
done = next.done;
|
||||
for (const n of next.items) ensureProfile(n.pubkey);
|
||||
} catch (e) {
|
||||
console.error("[notifications] failed to load more", e);
|
||||
} finally {
|
||||
if (current === req) loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything loaded so far counts as read.
|
||||
export function markNotificationsSeen() {
|
||||
if (!pubkey) return;
|
||||
seenAt = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
localStorage.setItem(`${SEEN_KEY}_${pubkey}`, String(seenAt));
|
||||
} catch {
|
||||
// Private mode: unread state lasts for the session only
|
||||
}
|
||||
}
|
||||
|
|
@ -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
61
src/lib/pageHighlight.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,29 @@ import { auth } from "$lib/auth.svelte";
|
|||
// persists so the handshake happens once rather than per request. Auth is
|
||||
// scoped to the forum relay — external relays (profiles, search) are never
|
||||
// authenticated against.
|
||||
const pool = new SimplePool();
|
||||
// Reconnection keeps the live subscriptions (chat, notifications) alive
|
||||
// across a network drop: the library re-sends them with `since` moved past
|
||||
// the last event seen, so what arrived meanwhile is replayed.
|
||||
const pool = new SimplePool({ enableReconnect: true });
|
||||
|
||||
// The library retries after a 10s backoff and gives up after one failed
|
||||
// attempt while still offline, so reconnect as soon as the network or the
|
||||
// tab is back, and keep a shorter backoff for drops in between.
|
||||
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
|
||||
async function forumRelay(): Promise<AbstractRelay> {
|
||||
const relay = await pool.ensureRelay(RELAY_URL);
|
||||
relay.resubscribeBackoff = FAST_BACKOFF;
|
||||
return relay;
|
||||
}
|
||||
function touchForumRelay() {
|
||||
forumRelay().catch(() => {});
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) touchForumRelay();
|
||||
});
|
||||
window.addEventListener("online", touchForumRelay);
|
||||
}
|
||||
|
||||
// Scoped to the forum relay — external relays (profiles, search) are never
|
||||
// authenticated against. Re-evaluated on every connection, so it picks up a
|
||||
|
|
@ -26,7 +48,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
|
||||
|
|
@ -40,7 +64,7 @@ export async function ensureForumReady(): Promise<void> {
|
|||
if (!signer) return; // anonymous: only public groups are visible anyway
|
||||
let relay: AbstractRelay;
|
||||
try {
|
||||
relay = await pool.ensureRelay(RELAY_URL);
|
||||
relay = await forumRelay();
|
||||
} catch {
|
||||
return; // connection failed; the caller's own query surfaces the error
|
||||
}
|
||||
|
|
@ -96,7 +120,7 @@ export function subscribeForum(
|
|||
// membership probe. Never close it — the pool owns its lifecycle.
|
||||
export async function ensureForumRelay(): Promise<AbstractRelay> {
|
||||
await ensureForumReady();
|
||||
return pool.ensureRelay(RELAY_URL);
|
||||
return forumRelay();
|
||||
}
|
||||
|
||||
// Drop the forum connection so the next use reconnects and re-runs the AUTH
|
||||
|
|
@ -104,3 +128,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
47
src/lib/seo.ts
Normal 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
26
src/lib/ssr/endpoint.ts
Normal 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
171
src/lib/ssr/relay.ts
Normal 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
29
src/lib/ssr/shell.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroup, fetchGroups, fetchRoomAdmins } from "$lib/forum/groups";
|
||||
import { fetchResources } from "$lib/forum/resources";
|
||||
import { fetchPartials } from "$lib/forum/partials";
|
||||
import type { ForumShell } from "$lib/forum/snapshot";
|
||||
import { forumQuery } from "./relay";
|
||||
|
||||
// Everything the layout needs on every page: the group(s), the admin set,
|
||||
// sidebar resources and partials.
|
||||
export async function loadShell(): Promise<ForumShell> {
|
||||
const [group, groups, resources, partials] = await Promise.all([
|
||||
MODE === "simple" ? fetchGroup(forumQuery, GROUP_ID) : null,
|
||||
MODE === "full" ? fetchGroups(forumQuery) : [],
|
||||
fetchResources(forumQuery),
|
||||
fetchPartials(forumQuery),
|
||||
]);
|
||||
const adminsByRoom =
|
||||
MODE === "full"
|
||||
? await fetchRoomAdmins(
|
||||
forumQuery,
|
||||
groups.map((g) => g.id),
|
||||
)
|
||||
: {};
|
||||
const admins =
|
||||
MODE === "full"
|
||||
? [...new Set(Object.values(adminsByRoom).flat())]
|
||||
: (group?.admins ?? []);
|
||||
return { group, groups, adminsByRoom, admins, resources, partials };
|
||||
}
|
||||
121
src/lib/textMatch.ts
Normal file
121
src/lib/textMatch.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
81
src/lib/userPosts.svelte.ts
Normal file
81
src/lib/userPosts.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
src/lib/version.ts
Normal file
23
src/lib/version.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { MODE, RELAY_URL, SSR_ENABLED } from "$lib/config";
|
||||
|
||||
export const VERSION = __SQUALK_VERSION__;
|
||||
export const COMMIT = __SQUALK_COMMIT__;
|
||||
|
||||
const REPO = "https://github.com/dtonon/squalk";
|
||||
// A release build links to its GitHub release, anything else to its commit
|
||||
export const VERSION_URL = VERSION.includes("+")
|
||||
? `${REPO}/commit/${COMMIT}`
|
||||
: `${REPO}/releases/tag/v${VERSION}`;
|
||||
|
||||
// Plain-text block to paste in a bug report
|
||||
export function diagnostics(loginMethod: string | null): string {
|
||||
return [
|
||||
`Squalk ${VERSION}`,
|
||||
`Commit: ${COMMIT || "unknown"}`,
|
||||
`Mode: ${MODE}`,
|
||||
`SSR: ${SSR_ENABLED ? "yes" : "no"}`,
|
||||
`Relay: ${RELAY_URL}`,
|
||||
`Login: ${loginMethod ?? "none"}`,
|
||||
`Browser: ${navigator.userAgent}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
|
@ -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,10 +11,13 @@
|
|||
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 { auth, restoreSession } from "$lib/auth.svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { auth, restoreSession, loginMethod } from "$lib/auth.svelte";
|
||||
import { showToast } from "$lib/toast.svelte";
|
||||
import { VERSION, VERSION_URL, diagnostics } from "$lib/version";
|
||||
import { loadGroup } from "$lib/group.svelte";
|
||||
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
||||
|
|
@ -24,17 +26,33 @@
|
|||
import { loadRoomAdmins } from "$lib/admins.svelte";
|
||||
import { seedProfiles } from "$lib/profiles.svelte";
|
||||
import { startChat } from "$lib/chat.svelte";
|
||||
import {
|
||||
startNotifications,
|
||||
stopNotifications,
|
||||
} from "$lib/notifications.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();
|
||||
|
||||
async function copyDiagnostics() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(diagnostics(loginMethod()));
|
||||
showToast("Version info copied, paste it in your bug report");
|
||||
} catch {
|
||||
showToast("Could not copy to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
// Optional env overrides for the theme colors. Set as inline custom props on
|
||||
// <html> so they outrank the @theme `:root` defaults; hover shades are derived
|
||||
// by darkening the base 15% so admins only set one value per color.
|
||||
|
|
@ -62,27 +80,52 @@
|
|||
// 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);
|
||||
}
|
||||
|
||||
// Replies/mentions follow the logged-in user. Keyed on the user rather than
|
||||
// on loadForum: a restored session resolves its profile after the probe.
|
||||
$effect(() => {
|
||||
const pk = auth.user?.pubkey ?? null;
|
||||
if (relayAccess.state !== "open") return;
|
||||
if (pk) startNotifications(pk);
|
||||
else stopNotifications();
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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 +134,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
|
||||
|
|
@ -157,128 +195,190 @@
|
|||
|
||||
// On mobile a route change should always land on the forum pane, so opening
|
||||
// a thread or room from the menu never leaves the user stranded on chat.
|
||||
// A chat message link (#chat-…, e.g. from a notification) opens the chat pane.
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
mobileView = "forum";
|
||||
mobileView = page.url.hash.startsWith("#chat-") ? "chat" : "forum";
|
||||
});
|
||||
</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"
|
||||
>
|
||||
Hosting relay is {RELAY_URL.replace(/^wss?:\/\//, "")}
|
||||
<br />
|
||||
This forum is built on
|
||||
<a
|
||||
href="https://njump.me"
|
||||
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
|
||||
>
|
||||
<a
|
||||
href={VERSION_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||
>v{VERSION}</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyDiagnostics}
|
||||
aria-label="Copy version info for bug reports"
|
||||
title="Copy version info for bug reports"
|
||||
class="focus-visible:ring-accent inline-flex cursor-pointer items-center rounded align-text-bottom hover:text-neutral-700 focus-visible:ring-2 focus-visible:outline-none dark:hover:text-neutral-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path
|
||||
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
20
src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import {
|
||||
snapshot,
|
||||
type OverviewSnapshot,
|
||||
type ThreadsSnapshot,
|
||||
} from "$lib/forum/snapshot";
|
||||
|
||||
// Simple mode: the single group's listing. Full mode: the room overview.
|
||||
export async function load({ fetch, url }) {
|
||||
if (MODE === "simple") {
|
||||
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||
const threads = await snapshot<ThreadsSnapshot>(
|
||||
fetch,
|
||||
`/api/threads/${GROUP_ID}${sort}`,
|
||||
);
|
||||
return { threads, profiles: threads?.profiles ?? null };
|
||||
}
|
||||
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||
return { overview, profiles: overview?.profiles ?? null };
|
||||
}
|
||||
11
src/routes/api/contacts/+server.ts
Normal file
11
src/routes/api/contacts/+server.ts
Normal 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) });
|
||||
});
|
||||
22
src/routes/api/overview/+server.ts
Normal file
22
src/routes/api/overview/+server.ts
Normal 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);
|
||||
});
|
||||
21
src/routes/api/profile/[id]/+server.ts
Normal file
21
src/routes/api/profile/[id]/+server.ts
Normal 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 });
|
||||
});
|
||||
8
src/routes/api/shell/+server.ts
Normal file
8
src/routes/api/shell/+server.ts
Normal 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()),
|
||||
);
|
||||
15
src/routes/api/thread/[id]/+server.ts
Normal file
15
src/routes/api/thread/[id]/+server.ts
Normal 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 });
|
||||
});
|
||||
45
src/routes/api/threads/[group]/+server.ts
Normal file
45
src/routes/api/threads/[group]/+server.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
src/routes/contacts/+page.ts
Normal file
17
src/routes/contacts/+page.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
297
src/routes/profile/[id]/+page.svelte
Normal file
297
src/routes/profile/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { MODE, EXTERNAL_CLIENT_NAME, externalLink } 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 Notifications from "$lib/components/Notifications.svelte";
|
||||
import { auth, logout } from "$lib/auth.svelte";
|
||||
import { summarize } from "$lib/seo";
|
||||
|
||||
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
|
||||
const isOwn = $derived(!!pubkey && auth.user?.pubkey === pubkey);
|
||||
|
||||
$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}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<h1 class="text-accent min-w-0 py-2 text-[1.65rem]">{name}</h1>
|
||||
{#if isOwn}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (confirm("Log out?")) logout();
|
||||
}}
|
||||
class="mt-3 shrink-0 rounded px-2 py-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- On mobile the picture floats so the bio wraps around it; from md up
|
||||
it is a flex column on the right. Placed first in the DOM for the float. -->
|
||||
<div class="mt-2 md:flex md:items-start md:gap-6">
|
||||
{#if entry?.picture}
|
||||
<img
|
||||
src={entry.picture}
|
||||
alt=""
|
||||
class="float-right mb-2 ml-4 h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32 md:order-last md:float-none md:mb-0 md:ml-0"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="float-right mb-2 ml-4 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 md:order-last md:float-none md:mb-0 md:ml-0 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/if}
|
||||
<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={externalLink(npub)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent text-sm hover:underline"
|
||||
>
|
||||
View on {EXTERNAL_CLIENT_NAME} ↗
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{#if isOwn}
|
||||
<Notifications />
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
24
src/routes/profile/[id]/+page.ts
Normal file
24
src/routes/profile/[id]/+page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
17
src/routes/resource/[slug]/+page.ts
Normal file
17
src/routes/resource/[slug]/+page.ts
Normal 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 };
|
||||
}
|
||||
13
src/routes/robots.txt/+server.ts
Normal file
13
src/routes/robots.txt/+server.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
10
src/routes/room/[slug]/+page.ts
Normal file
10
src/routes/room/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { snapshot, type ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||
|
||||
export async function load({ fetch, params, url }) {
|
||||
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||
const threads = await snapshot<ThreadsSnapshot>(
|
||||
fetch,
|
||||
`/api/threads/${params.slug}${sort}`,
|
||||
);
|
||||
return { threads, profiles: threads?.profiles ?? null };
|
||||
}
|
||||
57
src/routes/sitemap.xml/+server.ts
Normal file
57
src/routes/sitemap.xml/+server.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
10
src/routes/thread/[id]/+page.ts
Normal file
10
src/routes/thread/[id]/+page.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1,11 +1,35 @@
|
|||
import adapter from "@sveltejs/adapter-static";
|
||||
import { existsSync } from "node:fs";
|
||||
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";
|
||||
|
||||
// Instance assets: a build run with `--mode <mode>` serves the files in
|
||||
// .local/<mode>/static (gitignored) instead of the shared static/ folder.
|
||||
const argv = process.argv;
|
||||
const modeIdx = argv.findIndex((a) => a === "--mode" || a === "-m");
|
||||
const mode =
|
||||
modeIdx !== -1
|
||||
? argv[modeIdx + 1]
|
||||
: argv.find((a) => a.startsWith("--mode="))?.slice(7);
|
||||
const instanceAssets = mode ? `.local/${mode}/static` : undefined;
|
||||
const assets =
|
||||
instanceAssets && existsSync(instanceAssets) ? instanceAssets : "static";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
adapter: ssr ? adapterNode() : adapterStatic({ fallback: "index.html" }),
|
||||
files: { assets },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,47 @@
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||
function git(cmd: string): string {
|
||||
try {
|
||||
return execSync(`git ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Version comes from package.json; a build that is not exactly the tagged
|
||||
// release commit (or has local changes) carries the commit as build metadata,
|
||||
// e.g. 0.3.0+a1b2c3d or 0.3.0+a1b2c3d.dirty, so bug reports tell them apart.
|
||||
function version(): { version: string; commit: string } {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const commit = git("rev-parse --short HEAD");
|
||||
const tagged = git("tag --points-at HEAD")
|
||||
.split("\n")
|
||||
.includes(`v${pkg.version}`);
|
||||
const dirty = git("status --porcelain --untracked-files=no") !== "";
|
||||
let v = pkg.version;
|
||||
if (commit && (!tagged || dirty)) v += `+${commit}${dirty ? ".dirty" : ""}`;
|
||||
return { version: v, commit };
|
||||
}
|
||||
|
||||
// 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";
|
||||
const build = version();
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
define: {
|
||||
__SQUALK_SSR__: JSON.stringify(ssr),
|
||||
__SQUALK_VERSION__: JSON.stringify(build.version),
|
||||
__SQUALK_COMMIT__: JSON.stringify(build.commit),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue