Compare commits
49 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc28e20811 | ||
|
|
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 |
86 changed files with 8617 additions and 4620 deletions
11
.env.example
11
.env.example
|
|
@ -2,10 +2,19 @@ PUBLIC_RELAY_URL=ws://localhost:3334 # required — NIP-29 relay hosting the g
|
||||||
PUBLIC_MODE=simple # simple | full (single forum vs. many rooms)
|
PUBLIC_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_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_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_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_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_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
||||||
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
# 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_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_SECONDARY_COLOR= # override the secondary color (default #ffaf25), e.g. "#0000ff"; hover shade derived
|
||||||
|
PUBLIC_FAVICON= # favicon URL (absolute, or /favicon.png for a file in static/); default is a filled circle in the accent color
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
.local
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
|
|
@ -18,6 +19,10 @@ Thumbs.db
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Deploy: only the example unit is tracked
|
||||||
|
/deploy/*
|
||||||
|
!/deploy/production-example.service
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.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.
|
||||||
30
README.md
30
README.md
|
|
@ -27,6 +27,10 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI
|
||||||
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
|
| `PUBLIC_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_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_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_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_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_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
||||||
|
|
@ -89,12 +93,26 @@ npm run dev -- --open
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
To create a production version of your app:
|
Two deployment targets share the same code base, selected by `PUBLIC_SSR`:
|
||||||
|
|
||||||
```sh
|
- **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.
|
||||||
npm run build
|
- **Server-rendered (`PUBLIC_SSR=yes`)** — `just build-ssr` writes a Node app to `build/`. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live `robots.txt` and `sitemap.xml`, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
Preview a build locally with `npm run preview` (static) or `node --env-file=.env.production build` (server).
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
## Deploying
|
||||||
|
|
||||||
|
`just deploy <host>` rsyncs the static bundle to `~/squalk/` on the host and purges the Cloudflare cache.
|
||||||
|
|
||||||
|
`just deploy-ssr <mode>` builds with `--mode <mode>` (so vite bakes `.env.<mode>` in), ships the Node build, `package.json`/`package-lock.json` and `.env.<mode>` (as `.env` in the app dir, since the server reads the `PUBLIC_*` values at runtime), runs `npm ci --omit=dev` and restarts the instance's systemd unit. Everything instance-specific lives in `.env.<mode>.local` (gitignored, never shipped): `DEPLOY_HOST`, `DEPLOY_DIR` and `DEPLOY_SERVICE` (all required), plus the Cloudflare credentials (`CF_ZONE_ID`/`CF_API_TOKEN`) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. On the host you need:
|
||||||
|
|
||||||
|
- Node 22 or newer (the relay client uses the built-in `WebSocket`).
|
||||||
|
- The unit from [`deploy/production-example.service`](deploy/production-example.service), with `ORIGIN` set to the public URL — it feeds canonical links, `robots.txt` and the sitemap.
|
||||||
|
- A reverse proxy in front of the port in `PORT`, replacing whatever served the static files before. With Caddy:
|
||||||
|
|
||||||
|
```
|
||||||
|
forum.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with `Cache-Control: public, max-age=0, s-maxage=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE>` (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. `just deploy-ssr` purges the cache after each release.
|
||||||
|
|
|
||||||
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:
|
dev:
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# Static single-page bundle (served by any web server)
|
||||||
build:
|
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
|
deploy target: build
|
||||||
rsync -av --delete --progress build/ {{target}}:~/squalk/
|
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'
|
||||||
|
|
|
||||||
794
package-lock.json
generated
794
package-lock.json
generated
|
|
@ -10,12 +10,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djot/djot": "^0.3.2",
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "^2.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
|
@ -373,6 +375,23 @@
|
||||||
"nostr-wasm": "0.1.0"
|
"nostr-wasm": "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
|
|
@ -740,6 +759,484 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/plugin-commonjs": {
|
||||||
|
"version": "29.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"commondir": "^1.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"fdir": "^6.2.0",
|
||||||
|
"is-reference": "1.2.1",
|
||||||
|
"magic-string": "^0.30.3",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0 || 14 >= 14.17"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.68.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-json": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-node-resolve": {
|
||||||
|
"version": "16.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
|
||||||
|
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"@types/resolve": "1.20.2",
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"is-module": "^1.0.0",
|
||||||
|
"resolve": "^1.22.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.78.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-replace": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"magic-string": "^0.30.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/pluginutils": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@scure/base": {
|
"node_modules/@scure/base": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
|
|
@ -793,14 +1290,21 @@
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/adapter-auto": {
|
"node_modules/@sveltejs/adapter-node": {
|
||||||
"version": "7.0.1",
|
"version": "5.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz",
|
||||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
"integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^29.0.0",
|
||||||
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
|
"rollup": "^4.59.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sveltejs/kit": "^2.0.0"
|
"@sveltejs/kit": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/adapter-static": {
|
"node_modules/@sveltejs/adapter-static": {
|
||||||
|
|
@ -1199,9 +1703,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -1222,6 +1726,13 @@
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/resolve": {
|
||||||
|
"version": "1.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
|
@ -1568,6 +2079,13 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commondir": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
|
@ -1672,6 +2190,16 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
|
@ -1915,6 +2443,13 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
|
|
@ -2030,6 +2565,16 @@
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|
@ -2063,6 +2608,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/idb-keyval": {
|
"node_modules/idb-keyval": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
|
@ -2089,6 +2647,22 @@
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-core-module": {
|
||||||
|
"version": "2.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||||
|
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hasown": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
|
@ -2112,6 +2686,13 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-module": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
|
|
@ -2202,9 +2783,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2218,23 +2799,23 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"lightningcss-android-arm64": "1.32.0",
|
"lightningcss-android-arm64": "1.33.0",
|
||||||
"lightningcss-darwin-arm64": "1.32.0",
|
"lightningcss-darwin-arm64": "1.33.0",
|
||||||
"lightningcss-darwin-x64": "1.32.0",
|
"lightningcss-darwin-x64": "1.33.0",
|
||||||
"lightningcss-freebsd-x64": "1.32.0",
|
"lightningcss-freebsd-x64": "1.33.0",
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||||
"lightningcss-linux-x64-musl": "1.32.0",
|
"lightningcss-linux-x64-musl": "1.33.0",
|
||||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-android-arm64": {
|
"node_modules/lightningcss-android-arm64": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2253,9 +2834,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2274,9 +2855,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2295,9 +2876,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2316,9 +2897,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -2337,9 +2918,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2358,9 +2939,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2379,9 +2960,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2400,9 +2981,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2421,9 +3002,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2442,9 +3023,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2590,6 +3171,11 @@
|
||||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nstart-modal": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nstart-modal/-/nstart-modal-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-PolShYoWK07yJJbINtUn/IoOI5B0lmXRG9zOY9dirKKVjmMWFKjYLlafunNOl94EGcEndzAPWJAFDCt8flYRqg=="
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
|
@ -2671,6 +3257,13 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-parse": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -2968,6 +3561,28 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve": {
|
||||||
|
"version": "1.22.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
|
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"is-core-module": "^2.16.1",
|
||||||
|
"path-parse": "^1.0.7",
|
||||||
|
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"resolve": "bin/resolve"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||||
|
|
@ -3002,6 +3617,52 @@
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rollup": {
|
||||||
|
"version": "4.63.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
||||||
|
"integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "1.0.9"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rollup": "dist/bin/rollup"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
||||||
|
"@rollup/rollup-android-arm-eabi": "4.63.1",
|
||||||
|
"@rollup/rollup-android-arm64": "4.63.1",
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.63.1",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.63.1",
|
||||||
|
"@rollup/rollup-freebsd-arm64": "4.63.1",
|
||||||
|
"@rollup/rollup-freebsd-x64": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-loong64-musl": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.63.1",
|
||||||
|
"@rollup/rollup-openbsd-x64": "4.63.1",
|
||||||
|
"@rollup/rollup-openharmony-arm64": "4.63.1",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.63.1",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.63.1",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.63.1",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.63.1",
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
|
|
@ -3083,6 +3744,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.5",
|
"version": "5.55.5",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
||||||
|
|
@ -3355,6 +4029,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uqr": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "squalk",
|
"name": "squalk",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "^2.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
|
@ -41,6 +42,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djot/djot": "^0.3.2",
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||||
|
"nstart-modal": "^2.1.0",
|
||||||
|
"uqr": "^0.1.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"lightningcss": "^1.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/app.d.ts
vendored
20
src/app.d.ts
vendored
|
|
@ -1,10 +1,28 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
|
import type {
|
||||||
|
ForumShell,
|
||||||
|
OverviewSnapshot,
|
||||||
|
Profiles,
|
||||||
|
ThreadsSnapshot,
|
||||||
|
} from "$lib/forum/snapshot";
|
||||||
|
import type { ThreadDetail } from "$lib/forum/thread";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
// Injected by vite.config.ts from PUBLIC_SSR
|
||||||
|
const __SQUALK_SSR__: boolean;
|
||||||
|
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// 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 PageState {}
|
||||||
// interface Platform {}
|
// 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 vPad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||||
const vBorder =
|
const vBorder =
|
||||||
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||||
|
const min = lh * Math.max(node.rows, 1) + vPad + vBorder;
|
||||||
const max = lh * maxRows + vPad + vBorder;
|
const max = lh * maxRows + vPad + vBorder;
|
||||||
node.style.height = "auto";
|
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.height = `${next}px`;
|
||||||
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
|
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
node.addEventListener("input", resize);
|
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();
|
resize();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -43,6 +49,7 @@ export const autogrow: Action<
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("input", resize);
|
node.removeEventListener("input", resize);
|
||||||
|
ro?.disconnect();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { MODE } from "$lib/config";
|
import { MODE } from "$lib/config";
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { fetchRoomAdmins } from "$lib/forum/groups";
|
||||||
|
|
||||||
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
||||||
let byRoom = $state<Record<string, string[]>>({});
|
let byRoom = $state<Record<string, string[]>>({});
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let loadedKey = "";
|
let loadedKey = "";
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const roomAdminsStore = {
|
export const roomAdminsStore = {
|
||||||
get byRoom() {
|
get byRoom() {
|
||||||
return byRoom;
|
return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {});
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,11 +24,11 @@ export const roomAdminsStore = {
|
||||||
export const adminPubkeys = {
|
export const adminPubkeys = {
|
||||||
get list(): string[] {
|
get list(): string[] {
|
||||||
return MODE === "full"
|
return MODE === "full"
|
||||||
? [...new Set(Object.values(byRoom).flat())]
|
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
|
||||||
: (groupStore.data?.admins ?? []);
|
: (groupStore.data?.admins ?? []);
|
||||||
},
|
},
|
||||||
get loaded(): boolean {
|
get loaded(): boolean {
|
||||||
return MODE === "full" ? loaded : groupStore.loaded;
|
return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +40,7 @@ export function isGroupAdmin(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!pubkey || !groupId) return false;
|
if (!pubkey || !groupId) return false;
|
||||||
return MODE === "full"
|
return MODE === "full"
|
||||||
? (byRoom[groupId]?.includes(pubkey) ?? false)
|
? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false)
|
||||||
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,17 +51,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
|
||||||
loadedKey = key;
|
loadedKey = key;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
byRoom = await fetchRoomAdmins(queryForum, roomIds);
|
||||||
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;
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
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 * as nip19 from "@nostr/tools/nip19";
|
||||||
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
||||||
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -18,6 +26,8 @@ export type Signer = {
|
||||||
|
|
||||||
let user = $state<NostrUser | null>(null);
|
let user = $state<NostrUser | null>(null);
|
||||||
let signer = $state<Signer | 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);
|
let loginModalOpen = $state(false);
|
||||||
// Optional action to run once login succeeds, so an intent like "post" started
|
// Optional action to run once login succeeds, so an intent like "post" started
|
||||||
// while logged out resumes (login => join => composer) instead of being dropped.
|
// while logged out resumes (login => join => composer) instead of being dropped.
|
||||||
|
|
@ -45,6 +55,8 @@ export const auth = {
|
||||||
const PUBKEY_KEY = "nostr_pubkey";
|
const PUBKEY_KEY = "nostr_pubkey";
|
||||||
const METHOD_KEY = "nostr_login_method";
|
const METHOD_KEY = "nostr_login_method";
|
||||||
const NSEC_KEY = "nostr_nsec";
|
const NSEC_KEY = "nostr_nsec";
|
||||||
|
const BUNKER_KEY = "nostr_bunker";
|
||||||
|
const BUNKER_CLIENT_KEY = "nostr_bunker_client_key";
|
||||||
|
|
||||||
export function openLogin(after?: () => void) {
|
export function openLogin(after?: () => void) {
|
||||||
afterLogin = after ?? null;
|
afterLogin = after ?? null;
|
||||||
|
|
@ -94,6 +106,30 @@ function makeExtensionSigner(): Signer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer {
|
||||||
|
return {
|
||||||
|
async getPublicKey() {
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
async signEvent(template) {
|
||||||
|
return remote.signEvent(template);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBunker() {
|
||||||
|
bunker?.close().catch(() => {});
|
||||||
|
bunker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredSession() {
|
||||||
|
localStorage.removeItem(PUBKEY_KEY);
|
||||||
|
localStorage.removeItem(METHOD_KEY);
|
||||||
|
localStorage.removeItem(NSEC_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_CLIENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
async function setUser(pubkey: string) {
|
async function setUser(pubkey: string) {
|
||||||
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
||||||
user = await loadNostrUser(pubkey);
|
user = await loadNostrUser(pubkey);
|
||||||
|
|
@ -105,10 +141,11 @@ export async function loginWithExtension() {
|
||||||
throw new Error("No Nostr extension found");
|
throw new Error("No Nostr extension found");
|
||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
closeBunker();
|
||||||
signer = makeExtensionSigner();
|
signer = makeExtensionSigner();
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "extension");
|
localStorage.setItem(METHOD_KEY, "extension");
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
await setUser(pubkey);
|
await setUser(pubkey);
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
runAfterLogin();
|
runAfterLogin();
|
||||||
|
|
@ -137,7 +174,9 @@ export async function loginWithNsec(input: string) {
|
||||||
const sk = parseSecretKey(input);
|
const sk = parseSecretKey(input);
|
||||||
const nsec = nip19.nsecEncode(sk);
|
const nsec = nip19.nsecEncode(sk);
|
||||||
const pubkey = getPublicKey(sk);
|
const pubkey = getPublicKey(sk);
|
||||||
|
closeBunker();
|
||||||
signer = makeNsecSigner(sk);
|
signer = makeNsecSigner(sk);
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "nsec");
|
localStorage.setItem(METHOD_KEY, "nsec");
|
||||||
localStorage.setItem(NSEC_KEY, nsec);
|
localStorage.setItem(NSEC_KEY, nsec);
|
||||||
|
|
@ -146,12 +185,47 @@ export async function loginWithNsec(input: string) {
|
||||||
runAfterLogin();
|
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() {
|
export function logout() {
|
||||||
user = null;
|
user = null;
|
||||||
signer = null;
|
signer = null;
|
||||||
localStorage.removeItem(PUBKEY_KEY);
|
closeBunker();
|
||||||
localStorage.removeItem(METHOD_KEY);
|
clearStoredSession();
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
resetJoinState();
|
resetJoinState();
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +245,16 @@ export async function restoreSession() {
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
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 {
|
} else {
|
||||||
signer = makeExtensionSigner();
|
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 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 { RELAY_URL } from "$lib/config";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
|
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { tokenizeChat } from "$lib/linkify";
|
import { tokenizeChat } from "$lib/linkify";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -39,9 +40,7 @@
|
||||||
<span class="break-words whitespace-pre-wrap"
|
<span class="break-words whitespace-pre-wrap"
|
||||||
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
||||||
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
||||||
href="https://njump.me/{t.entity}"
|
href={profilePath(t.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||||
>{:else if t.type === "entity"}{@const ref = t.id
|
>{:else if t.type === "entity"}{@const ref = t.id
|
||||||
? resolvedThreads[t.id]
|
? resolvedThreads[t.id]
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@
|
||||||
} from "$lib/moderation.svelte";
|
} from "$lib/moderation.svelte";
|
||||||
import {
|
import {
|
||||||
withJoin,
|
withJoin,
|
||||||
|
joinState,
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { activeGroup } from "$lib/active.svelte";
|
import { activeGroup } from "$lib/active.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||||
|
|
@ -76,7 +77,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function onJoinToChat() {
|
function onJoinToChat() {
|
||||||
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDeleteMessage(msg: ChatMessageData) {
|
function requestDeleteMessage(msg: ChatMessageData) {
|
||||||
|
|
@ -250,8 +251,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor to bottom on new messages, unless user is reading older ones.
|
// 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(() => {
|
$effect(() => {
|
||||||
messages.length;
|
messages.length;
|
||||||
|
mobileActive;
|
||||||
if (!listEl || userScrolledUp) return;
|
if (!listEl || userScrolledUp) return;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
||||||
|
|
@ -348,21 +352,30 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<div class="mb-1 flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={profilePath(msg.pubkey)}
|
||||||
|
class="shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
{#if author.picture}
|
{#if author.picture}
|
||||||
<img
|
<img
|
||||||
src={author.picture}
|
src={author.picture}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
class="h-6 w-6 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<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"
|
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()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-medium text-neutral-500 dark:text-neutral-400"
|
</a>
|
||||||
>{author.name}</span
|
<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="ml-auto flex items-center gap-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
@ -478,7 +491,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if !auth.user}
|
{#if !auth.user}
|
||||||
<button
|
<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"
|
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
||||||
>
|
>
|
||||||
Login to chat
|
Login to chat
|
||||||
|
|
@ -486,9 +499,10 @@
|
||||||
{:else if joinToChat}
|
{:else if joinToChat}
|
||||||
<button
|
<button
|
||||||
onclick={onJoinToChat}
|
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>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
type Author,
|
type Author,
|
||||||
} from "$lib/components/ThreadItem.svelte";
|
} from "$lib/components/ThreadItem.svelte";
|
||||||
import SortToggle from "$lib/components/SortToggle.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 type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { openDraft } from "$lib/draft.svelte";
|
import { openDraft } from "$lib/draft.svelte";
|
||||||
|
|
@ -18,7 +22,8 @@
|
||||||
import {
|
import {
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
withJoin,
|
||||||
|
joinState,
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { sortPref } from "$lib/sort.svelte";
|
import { sortPref } from "$lib/sort.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
@ -44,6 +49,12 @@
|
||||||
if (urlSort) sortPref.value = urlSort;
|
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 flags = $derived(getGroupFlags(groupId));
|
||||||
const member = $derived(membershipOf(groupId));
|
const member = $derived(membershipOf(groupId));
|
||||||
|
|
||||||
|
|
@ -71,12 +82,7 @@
|
||||||
openLogin(onNewTopic);
|
openLogin(onNewTopic);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ensureMembershipChecked(groupId);
|
await withJoin(groupId, openDraft);
|
||||||
if (membershipOf(groupId) !== "member") {
|
|
||||||
openJoinModal(groupId, openDraft);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openDraft();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPrivateJoin() {
|
function onPrivateJoin() {
|
||||||
|
|
@ -84,7 +90,7 @@
|
||||||
openLogin();
|
openLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openJoinModal(groupId, () => loadThreads(groupId, sort));
|
withJoin(groupId, () => loadThreads(groupId, sort));
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(ts: number): string {
|
function relativeTime(ts: number): string {
|
||||||
|
|
@ -125,22 +131,36 @@
|
||||||
const rows = $derived(
|
const rows = $derived(
|
||||||
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta {title} description={about} />
|
||||||
<title>{title}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={onNewTopic}
|
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>
|
</button>
|
||||||
<SortToggle {sort} />
|
<SortToggle {sort} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,9 +185,27 @@
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={onPrivateJoin}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -194,6 +232,19 @@
|
||||||
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{:else if rows.length > 0}
|
||||||
<p
|
<p
|
||||||
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,38 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
|
import { joinState, closeJoinModal, submitJoinCode } from "$lib/join.svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
let code = $state("");
|
let code = $state("");
|
||||||
let codeInput = $state<HTMLInputElement | null>(null);
|
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(() => {
|
$effect(() => {
|
||||||
if (joinState.modalOpen && joinState.needsCode) {
|
if (!joinState.modalOpen) {
|
||||||
|
code = "";
|
||||||
|
codeRevealed = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (joinState.modalOpen && showCode) {
|
||||||
tick().then(() => codeInput?.focus());
|
tick().then(() => codeInput?.focus());
|
||||||
}
|
}
|
||||||
if (!joinState.modalOpen) code = "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
await submitJoin(code.trim() || undefined);
|
await submitJoinCode(code.trim() || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
|
|
@ -56,15 +75,28 @@
|
||||||
id="join-title"
|
id="join-title"
|
||||||
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
|
{#if step === "relay-code"}
|
||||||
|
Invite code required
|
||||||
|
{:else if step === "pending"}
|
||||||
|
Request sent
|
||||||
|
{:else if step === "error"}
|
||||||
|
Could not join
|
||||||
|
{:else}
|
||||||
Join this group
|
Join this group
|
||||||
|
{/if}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{#if joinState.needsCode}
|
{#if step === "relay-code"}
|
||||||
Join this group to participate. If you have an invite code, enter it
|
This relay only accepts members. Enter an invite code to join.
|
||||||
below — otherwise just send the request.
|
{: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}
|
{:else}
|
||||||
Join this group to participate. Your request is sent to the relay and,
|
The group refused the join request.
|
||||||
where needed, approved by an admin.
|
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -77,13 +109,17 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if joinState.needsCode}
|
{#if showCode}
|
||||||
<label for="join-code-input" class="sr-only">Invite code</label>
|
<label for="join-code-input" class="sr-only">
|
||||||
|
{step === "relay-code" ? "Relay invite code" : "Group invite code"}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="join-code-input"
|
id="join-code-input"
|
||||||
bind:this={codeInput}
|
bind:this={codeInput}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Invite code (optional)"
|
placeholder={step === "relay-code"
|
||||||
|
? "Relay invite code"
|
||||||
|
: "Group invite code"}
|
||||||
bind:value={code}
|
bind:value={code}
|
||||||
disabled={joinState.busy}
|
disabled={joinState.busy}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -92,6 +128,14 @@
|
||||||
onkeydown={(e) => e.key === "Enter" && onSubmit()}
|
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"
|
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}
|
{/if}
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
|
|
@ -103,18 +147,16 @@
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
{#if showCode}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onSubmit}
|
onclick={onSubmit}
|
||||||
disabled={joinState.busy}
|
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"
|
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
|
{joinState.busy ? "Joining…" : "Join"}
|
||||||
? "Joining…"
|
|
||||||
: joinState.needsCode
|
|
||||||
? "Request access"
|
|
||||||
: "Join"}
|
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,78 @@
|
||||||
import {
|
import {
|
||||||
auth,
|
auth,
|
||||||
closeLogin,
|
closeLogin,
|
||||||
|
loginWithBunker,
|
||||||
loginWithExtension,
|
loginWithExtension,
|
||||||
|
loginWithNostrConnect,
|
||||||
loginWithNsec,
|
loginWithNsec,
|
||||||
} from "$lib/auth.svelte";
|
} 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 nsec = $state("");
|
||||||
|
let bunkerUrl = $state("");
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
let nsecInput = $state<HTMLInputElement | null>(null);
|
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(
|
const hasExtension = $derived(
|
||||||
typeof window !== "undefined" && !!window.nostr,
|
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() {
|
function reset() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
nsec = "";
|
nsec = "";
|
||||||
|
bunkerUrl = "";
|
||||||
error = null;
|
error = null;
|
||||||
|
copied = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExtension() {
|
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() {
|
function onClose() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
closeLogin();
|
closeLogin();
|
||||||
reset();
|
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() {
|
async function showNsecView() {
|
||||||
|
stopConnect();
|
||||||
view = "nsec";
|
view = "nsec";
|
||||||
error = null;
|
error = null;
|
||||||
await tick();
|
await tick();
|
||||||
nsecInput?.focus();
|
nsecInput?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showBunkerView() {
|
||||||
|
view = "bunker";
|
||||||
|
error = null;
|
||||||
|
startConnect();
|
||||||
|
await tick();
|
||||||
|
bunkerInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function showExtensionView() {
|
function showExtensionView() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (!auth.loginModalOpen) return;
|
if (!auth.loginModalOpen || signingUp) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopConnect();
|
||||||
|
wizard?.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
<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">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -88,7 +259,7 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="login-title"
|
aria-labelledby="login-title"
|
||||||
|
|
@ -97,13 +268,16 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
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>
|
</button>
|
||||||
|
|
||||||
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
<h2
|
||||||
{view === "extension" ? "Log in" : "Log in with nsec"}
|
id="login-title"
|
||||||
|
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{titles[view]}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -128,6 +302,14 @@
|
||||||
No Nostr extension detected in this browser.
|
No Nostr extension detected in this browser.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={showNsecView}
|
onclick={showNsecView}
|
||||||
|
|
@ -135,6 +317,116 @@
|
||||||
>
|
>
|
||||||
Or log in using your nsec
|
Or log in using your nsec
|
||||||
</button>
|
</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}
|
{:else}
|
||||||
<label for="nsec-input" class="sr-only">nsec</label>
|
<label for="nsec-input" class="sr-only">nsec</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -149,7 +441,7 @@
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
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
|
<button
|
||||||
onclick={handleNsec}
|
onclick={handleNsec}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
import { loadRelayList } from "$lib/gadgets";
|
||||||
import {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
searchLocalProfiles,
|
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>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<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 * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -310,7 +311,7 @@
|
||||||
const entity = dest.slice(6).toLowerCase();
|
const entity = dest.slice(6).toLowerCase();
|
||||||
try {
|
try {
|
||||||
nip19.decode(entity);
|
nip19.decode(entity);
|
||||||
return { type: "link", href: `https://njump.me/${entity}`, label };
|
return { type: "link", href: `https://nostr.basspistol.org/${entity}`, label };
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: label };
|
return { type: "text", value: label };
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +334,6 @@
|
||||||
case "str":
|
case "str":
|
||||||
return tokenizeInline(node.text);
|
return tokenizeInline(node.text);
|
||||||
case "soft_break":
|
case "soft_break":
|
||||||
return [{ type: "text", value: " " }];
|
|
||||||
case "hard_break":
|
case "hard_break":
|
||||||
return [{ type: "br" }];
|
return [{ type: "br" }];
|
||||||
case "non_breaking_space":
|
case "non_breaking_space":
|
||||||
|
|
@ -611,11 +611,8 @@
|
||||||
>
|
>
|
||||||
{:else if inline.type === "mention"}
|
{:else if inline.type === "mention"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
<a
|
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
|
||||||
href="https://njump.me/{inline.entity}"
|
>@{u?.shortName ?? inline.fallback}</a
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
|
|
||||||
>
|
>
|
||||||
{:else if inline.type === "thread-quote"}
|
{:else if inline.type === "thread-quote"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
|
|
@ -645,7 +642,7 @@
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{inline.entity}"
|
href="https://nostr.basspistol.org/{inline.entity}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-accent break-all hover:underline">{inline.label}</a
|
class="text-accent break-all hover:underline">{inline.label}</a
|
||||||
|
|
@ -700,7 +697,7 @@
|
||||||
src={block.src}
|
src={block.src}
|
||||||
alt={block.alt}
|
alt={block.alt}
|
||||||
loading="lazy"
|
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"}
|
{:else if block.type === "blockquote"}
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
@ -773,7 +770,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div
|
<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)}
|
{@render renderBlocks(blocks)}
|
||||||
</div>
|
</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) {
|
function select(r: SearchResult) {
|
||||||
open = false;
|
open = false;
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,26 @@
|
||||||
|
|
||||||
function select(r: SearchResult) {
|
function select(r: SearchResult) {
|
||||||
closeSearch();
|
closeSearch();
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global "/" opens the search from any page, unless typing somewhere else
|
// Global "/" opens the search from any page, unless typing somewhere else
|
||||||
function onWindowKeydown(e: KeyboardEvent) {
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
if (searchModal.open) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
{#each search.results as r, i (r.threadId)}
|
{#each search.results as r, i (r.threadId)}
|
||||||
<a
|
<a
|
||||||
id="{idBase}-{i}"
|
id="{idBase}-{i}"
|
||||||
href="/thread/{r.threadId}"
|
href={search.threadUrl(r.threadId)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === search.activeIndex}
|
aria-selected={i === search.activeIndex}
|
||||||
class="block rounded px-4 py-2 {i === search.activeIndex
|
class="block rounded px-4 py-2 {i === search.activeIndex
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,38 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
|
||||||
if (MODE === "simple" && !GROUP_ID) {
|
if (MODE === "simple" && !GROUP_ID) {
|
||||||
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
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.
|
// Requires a relay with NIP-50 support.
|
||||||
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
||||||
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
||||||
|
|
@ -31,3 +62,14 @@ export const BLOSSOM_URL = (env.PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, "");
|
||||||
// CSS variables defined in layout.css. Hover shades are derived in the layout.
|
// CSS variables defined in layout.css. Hover shades are derived in the layout.
|
||||||
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
|
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
|
||||||
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
|
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
|
||||||
|
|
||||||
|
// Favicon: any URL (absolute, or root-relative for a file in static/). The
|
||||||
|
// default is a ring in the accent color (a circle with a transparent hole of
|
||||||
|
// half its radius), inlined as an SVG data URI.
|
||||||
|
const DEFAULT_ACCENT = "#e32a6d";
|
||||||
|
export const FAVICON =
|
||||||
|
env.PUBLIC_FAVICON ||
|
||||||
|
"data:image/svg+xml," +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill-rule="evenodd" fill="${ACCENT_COLOR || DEFAULT_ACCENT}" d="M16 0a16 16 0 1 0 0 32a16 16 0 1 0 0-32zM16 8a8 8 0 1 1 0 16a8 8 0 1 1 0-16z"/></svg>`,
|
||||||
|
);
|
||||||
|
|
|
||||||
81
src/lib/forum/groups.ts
Normal file
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;
|
||||||
|
}
|
||||||
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 { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { fetchGroup, type GroupMetadata } from "$lib/forum/groups";
|
||||||
|
|
||||||
export type GroupMetadata = {
|
export type { GroupMetadata };
|
||||||
name: string;
|
|
||||||
picture?: string;
|
|
||||||
about?: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
isClosed: boolean;
|
|
||||||
isRestricted: boolean;
|
|
||||||
isHidden: boolean;
|
|
||||||
admins: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 = {
|
export type GroupFlags = {
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
isClosed: boolean;
|
isClosed: boolean;
|
||||||
|
|
@ -25,44 +16,24 @@ export type GroupFlags = {
|
||||||
let group = $state<GroupMetadata | null>(null);
|
let group = $state<GroupMetadata | null>(null);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const groupStore = {
|
export const groupStore = {
|
||||||
get data() {
|
get data() {
|
||||||
return group;
|
return group ?? page.data.shell?.group ?? null;
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadGroup() {
|
export async function loadGroup() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
group = (await fetchGroup(queryForum, GROUP_ID)) ?? group;
|
||||||
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,
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
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 {
|
export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||||
if (MODE === "full") {
|
if (MODE === "full") {
|
||||||
const g = groupsStore.list.find((x) => x.id === groupId);
|
const g = groupsStore.list.find((x) => x.id === groupId);
|
||||||
|
|
@ -82,3 +53,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||||
isHidden: group.isHidden,
|
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 { queryForum } from "$lib/relay";
|
||||||
|
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
|
||||||
|
|
||||||
export type GroupSummary = {
|
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"];
|
|
||||||
|
|
||||||
let list = $state<GroupSummary[]>([]);
|
let list = $state<GroupSummary[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const groupsStore = {
|
export const groupsStore = {
|
||||||
get list() {
|
get list() {
|
||||||
return list;
|
return loaded ? list : (page.data.shell?.groups ?? []);
|
||||||
},
|
},
|
||||||
get loaded() {
|
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() {
|
export async function loadGroups() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({ kinds: [39000] });
|
list = await fetchGroups(queryForum);
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,42 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { ensureForumRelay, publishForum } from "$lib/relay";
|
import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
|
||||||
import { getGroupFlags } from "$lib/group.svelte";
|
import { getGroupName } from "$lib/group.svelte";
|
||||||
|
import { showToast } from "$lib/toast.svelte";
|
||||||
|
|
||||||
type Membership = "member" | "guest";
|
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
|
// Writing to a group can be gated at two levels: the relay itself (NIP-43) and
|
||||||
// actions can be gated before the user writes anything. Keyed by pubkey so a
|
// 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
|
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
|
||||||
// "guest" cached before auth.user was populated; the read stays a pure getter
|
// "guest" cached before auth.user was populated.
|
||||||
// (no state writes inside a derivation).
|
|
||||||
let membership = $state<Record<string, Membership>>({});
|
let membership = $state<Record<string, Membership>>({});
|
||||||
|
let relayMembership = $state<Record<string, RelayMembership>>({});
|
||||||
const checking = new Map<string, Promise<void>>();
|
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 modalOpen = $state(false);
|
||||||
|
let modalStep = $state<JoinStep>("group-code");
|
||||||
let modalGroup = $state<string | null>(null);
|
let modalGroup = $state<string | null>(null);
|
||||||
let modalError = $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);
|
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).
|
// reply box, reload a now-visible feed, or retry the original post).
|
||||||
let onJoined: (() => void | Promise<void>) | null = null;
|
let onJoined: (() => void | Promise<void>) | null = null;
|
||||||
|
|
||||||
|
|
@ -29,26 +47,27 @@ export const joinState = {
|
||||||
get modalOpen() {
|
get modalOpen() {
|
||||||
return modalOpen;
|
return modalOpen;
|
||||||
},
|
},
|
||||||
|
get modalStep() {
|
||||||
|
return modalStep;
|
||||||
|
},
|
||||||
get modalError() {
|
get modalError() {
|
||||||
return modalError;
|
return modalError;
|
||||||
},
|
},
|
||||||
|
get modalCodeHinted() {
|
||||||
|
return modalCodeHinted;
|
||||||
|
},
|
||||||
get busy() {
|
get busy() {
|
||||||
return 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() {
|
export function resetJoinState() {
|
||||||
membership = {};
|
membership = {};
|
||||||
|
relayMembership = {};
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
modalGroup = null;
|
modalGroup = null;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
|
modalCodeHinted = false;
|
||||||
busy = false;
|
busy = false;
|
||||||
onJoined = null;
|
onJoined = null;
|
||||||
}
|
}
|
||||||
|
|
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
|
||||||
return p;
|
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,
|
// 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(
|
function queryHasMatch(
|
||||||
relay: AbstractRelay,
|
relay: AbstractRelay,
|
||||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
filter: SubFilter,
|
||||||
timeoutMs = 3000,
|
timeoutMs = 3000,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -120,39 +169,34 @@ function queryHasMatch(
|
||||||
// Newest `created_at` among events matching the filter, or null if none. We
|
// 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
|
// 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).
|
// returned events (the #p filter keeps the set tiny).
|
||||||
function latestCreatedAt(
|
async function latestCreatedAt(
|
||||||
relay: AbstractRelay,
|
relay: AbstractRelay,
|
||||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
filter: SubFilter,
|
||||||
timeoutMs = 3000,
|
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
return new Promise((resolve) => {
|
const events = await queryEvents(relay, filter);
|
||||||
let settled = false;
|
return events.reduce<number | null>(
|
||||||
let latest: number | null = null;
|
(max, e) => (max === null || e.created_at > max ? e.created_at : max),
|
||||||
const finish = () => {
|
null,
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership signal for one group — i.e. "is this user allowed to write here".
|
// Add/remove events are the freshest signal: the newest of the two decides
|
||||||
// Admins (kind:39001) can post but need not appear in the members list, so they
|
// (a later removal unjoins). When the relay keeps neither, the list decides.
|
||||||
// count as members. Otherwise NIP-29 records membership as moderation events:
|
function decideMembership(
|
||||||
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
|
added: number | null,
|
||||||
// this user decides current membership (a later 9001 unjoins them). When the
|
removed: number | null,
|
||||||
// relay keeps neither, fall back to the kind:39002 members list.
|
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(
|
async function checkMembership(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
|
@ -163,7 +207,7 @@ async function checkMembership(
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const [isAdmin, added, removed] = await Promise.all([
|
const [isAdmin, added, removed, listed] = await Promise.all([
|
||||||
queryHasMatch(relay, {
|
queryHasMatch(relay, {
|
||||||
kinds: [39001],
|
kinds: [39001],
|
||||||
"#d": [groupId],
|
"#d": [groupId],
|
||||||
|
|
@ -172,25 +216,240 @@ async function checkMembership(
|
||||||
}),
|
}),
|
||||||
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
||||||
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
||||||
]);
|
queryHasMatch(relay, {
|
||||||
if (isAdmin) return true;
|
|
||||||
if (added !== null || removed !== null) {
|
|
||||||
return added !== null && added >= (removed ?? -Infinity);
|
|
||||||
}
|
|
||||||
return queryHasMatch(relay, {
|
|
||||||
kinds: [39002],
|
kinds: [39002],
|
||||||
"#d": [groupId],
|
"#d": [groupId],
|
||||||
"#p": [pubkey],
|
"#p": [pubkey],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return decideMembership(added, removed, listed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple mode pre-checks the single configured group at login so the first post
|
// Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
|
||||||
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
// 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) {
|
export async function initJoinForUser(pubkey: string) {
|
||||||
if (MODE !== "simple") return;
|
await Promise.all([
|
||||||
if (await checkMembership(pubkey, GROUP_ID)) {
|
ensureRelayMembershipChecked(pubkey),
|
||||||
membership[memberKey(GROUP_ID)] = "member";
|
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;
|
onJoined = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens the join modal for a group, with an optional continuation to run after
|
// Re-runs the join with the code entered for the current step. Errors keep the
|
||||||
// a successful join (open the composer, focus the reply box, reload the feed).
|
// modal open for a retry; a further gate switches the modal to that step.
|
||||||
export function openJoinModal(
|
export async function submitJoinCode(code?: string) {
|
||||||
groupId: string,
|
if (!modalOpen || busy) return;
|
||||||
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;
|
|
||||||
busy = true;
|
busy = true;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
const groupId = modalGroup;
|
const groupId = modalGroup;
|
||||||
const cb = onJoined;
|
const cb = onJoined;
|
||||||
|
const codes: Codes =
|
||||||
|
modalStep === "relay-code" ? { relay: code } : { group: code };
|
||||||
try {
|
try {
|
||||||
await publishJoinRequest(groupId, code);
|
const pubkey = auth.user?.pubkey;
|
||||||
membership[memberKey(groupId)] = "member";
|
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;
|
modalOpen = false;
|
||||||
modalGroup = null;
|
modalGroup = null;
|
||||||
onJoined = null;
|
onJoined = null;
|
||||||
if (cb) await cb();
|
if (cb) await cb();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
modalError = reason(e);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps an action that posts to `groupId`. The relay requires membership to
|
// Relay-only join, for a relay that refuses reads from non-members: no group
|
||||||
// write to any group, so a non-member is joined first. Compose entry points
|
// is involved, so the modal (if any) targets the relay alone.
|
||||||
// gate membership up front, making this mostly a safety net: it joins
|
export async function joinRelay(cb: () => void | Promise<void>): Promise<void> {
|
||||||
// transparently if possible, else opens the modal to retry.
|
const pubkey = auth.user?.pubkey;
|
||||||
export async function withJoin(
|
if (!pubkey || busy) return;
|
||||||
groupId: string,
|
|
||||||
action: () => Promise<void>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (membership[memberKey(groupId)] === "member") {
|
|
||||||
await action();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const pubkey = auth.user?.pubkey;
|
const outcome = await tryRelayJoin(pubkey, {});
|
||||||
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
if (!outcome.ok) {
|
||||||
membership[memberKey(groupId)] = "member";
|
openModal(null, outcome, cb);
|
||||||
await action();
|
return;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
await publishJoinRequest(groupId);
|
await cb();
|
||||||
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;
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
||||||
return {
|
return {
|
||||||
type: "entity",
|
type: "entity",
|
||||||
entity,
|
entity,
|
||||||
href: `https://njump.me/${entity}`,
|
href: `https://nostr.basspistol.org/${entity}`,
|
||||||
label: shortNostrEntity(entity),
|
label: shortNostrEntity(entity),
|
||||||
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
||||||
};
|
};
|
||||||
|
|
@ -165,7 +165,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
||||||
return {
|
return {
|
||||||
type: "entity",
|
type: "entity",
|
||||||
entity,
|
entity,
|
||||||
href: `https://njump.me/${entity}`,
|
href: `https://nostr.basspistol.org/${entity}`,
|
||||||
label: shortNostrEntity(entity),
|
label: shortNostrEntity(entity),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
import { loadRelayList } from "$lib/gadgets";
|
||||||
|
|
||||||
const RELAY_HINT_TIMEOUT_MS = 1500;
|
const RELAY_HINT_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import { page } from "$app/state";
|
||||||
import type { Event } from "@nostr/tools/core";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
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 = {
|
export type { RecentThread, RoomActivity };
|
||||||
latestAt: number;
|
|
||||||
latestPubkey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecentThread = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
groupId: string;
|
|
||||||
authorPubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let activity = $state<Record<string, RoomActivity>>({});
|
let activity = $state<Record<string, RoomActivity>>({});
|
||||||
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
|
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 profiles = $state<Record<string, NostrUser>>({});
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
||||||
|
let settled = $state(false); // first live load finished
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
|
function fallback() {
|
||||||
|
return settled ? null : (page.data.overview ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
export const overviewStore = {
|
export const overviewStore = {
|
||||||
get activity() {
|
get activity() {
|
||||||
return activity;
|
return fallback()?.activity ?? activity;
|
||||||
},
|
},
|
||||||
get admins() {
|
get admins() {
|
||||||
return admins;
|
return fallback()?.admins ?? admins;
|
||||||
},
|
},
|
||||||
get recent() {
|
get recent() {
|
||||||
return recent;
|
return fallback()?.recent ?? recent;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = page.data.overview?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
async function loadProfile(pubkey: string) {
|
||||||
if (profiles[pubkey]) return;
|
if (profiles[pubkey]) return;
|
||||||
const user = await loadNostrUser(pubkey);
|
const user = await loadNostrUser(pubkey);
|
||||||
|
|
@ -77,54 +60,15 @@ export async function loadOverview(roomIds: string[]) {
|
||||||
loadedKey = key;
|
loadedKey = key;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
const relay = await ensureForumRelay();
|
|
||||||
try {
|
try {
|
||||||
// One tiny query per room for its newest event (thread or reply).
|
const q = relayQuery(await ensureForumRelay());
|
||||||
const latest = await Promise.all(
|
const o = await fetchOverview(q, roomIds);
|
||||||
roomIds.map((id) =>
|
activity = o.activity;
|
||||||
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }),
|
admins = o.admins;
|
||||||
),
|
recent = o.recent;
|
||||||
);
|
settled = true;
|
||||||
const act: Record<string, RoomActivity> = {};
|
overviewPubkeys(o).forEach(loadProfile);
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
// shared forum connection is long-lived — don't close it here
|
|
||||||
loading = false;
|
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 { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
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 { Partial, PartialSlot };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
let all = $state<Partial[]>([]);
|
let all = $state<Partial[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const partialsStore = {
|
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 {
|
get(slot: PartialSlot): Partial | undefined {
|
||||||
const admins = new Set(adminPubkeys.list);
|
const source = loaded ? all : (page.data.shell?.partials ?? []);
|
||||||
let best: Partial | undefined;
|
return pickPartial(source, slot, adminPubkeys.list);
|
||||||
for (const p of all) {
|
|
||||||
if (p.slot !== slot || !admins.has(p.pubkey)) continue;
|
|
||||||
if (!best || p.createdAt > best.createdAt) best = p;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
},
|
},
|
||||||
get loaded() {
|
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() {
|
export async function loadPartials() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
all = await fetchPartials(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;
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { GROUP_ID } from "$lib/config";
|
import { GROUP_ID } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||||
|
|
||||||
export type ProfileEntry = {
|
export type ProfileEntry = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
|
@ -27,12 +28,6 @@ const SEARCH_RELAYS = [
|
||||||
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
// 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
|
// never has these — they live on each author's outbox relays — so we query
|
||||||
// a small set of well-known aggregators.
|
// 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 FETCH_BATCH_SIZE = 500;
|
||||||
|
|
||||||
const profiles = new SvelteMap<string, ProfileEntry>();
|
const profiles = new SvelteMap<string, ProfileEntry>();
|
||||||
|
|
@ -310,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
|
||||||
if (entry) upsertProfile(entry);
|
if (entry) upsertProfile(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestNostrUser(user: NostrUser) {
|
export function entryFromUser(user: NostrUser): ProfileEntry {
|
||||||
if (!user.pubkey) return;
|
|
||||||
const md = user.metadata ?? {};
|
const md = user.metadata ?? {};
|
||||||
const entry: ProfileEntry = {
|
return {
|
||||||
pubkey: user.pubkey,
|
pubkey: user.pubkey,
|
||||||
npub: user.npub,
|
npub: user.npub,
|
||||||
name: md.name,
|
name: md.name,
|
||||||
|
|
@ -325,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
|
||||||
lud16: md.lud16,
|
lud16: md.lud16,
|
||||||
fetchedAt: user.lastUpdated || 0,
|
fetchedAt: user.lastUpdated || 0,
|
||||||
};
|
};
|
||||||
upsertProfile(entry);
|
}
|
||||||
|
|
||||||
|
export function ingestNostrUser(user: NostrUser) {
|
||||||
|
if (!user.pubkey) return;
|
||||||
|
upsertProfile(entryFromUser(user));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ export const forumPool = pool;
|
||||||
|
|
||||||
function authParam() {
|
function authParam() {
|
||||||
const signer = auth.signer;
|
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
|
// Resolves once the forum connection is open and, when logged in, NIP-42
|
||||||
|
|
@ -104,3 +106,53 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
|
||||||
export function resetForumConnection(): void {
|
export function resetForumConnection(): void {
|
||||||
pool.close([RELAY_URL]);
|
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 { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { fetchResources, type Resource } from "$lib/forum/resources";
|
||||||
|
|
||||||
export type Resource = {
|
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
let all = $state<Resource[]>([]);
|
let all = $state<Resource[]>([]);
|
||||||
let loaded = $state(false);
|
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 = {
|
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() {
|
get list() {
|
||||||
const admins = new Set(adminPubkeys.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() {
|
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() {
|
export async function loadResources() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
all = await fetchResources(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);
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Event } from "@nostr/tools/core";
|
import type { Event } from "@nostr/tools/core";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
import type { Filter } from "@nostr/tools/filter";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { bestMatch } from "$lib/textMatch";
|
||||||
import { GROUP_ID, MODE } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
|
||||||
|
|
@ -12,22 +13,26 @@ export type SearchResult = {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Window the snippet around the first term match so the highlight is visible
|
// Window the snippet around the best match (full phrase > longest
|
||||||
// even when the match sits deep in a long post.
|
// contiguous run > densest single-term cluster, per textMatch rules). The
|
||||||
function snippetOf(content: string, query: string): string {
|
// 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 flat = content.replace(/\s+/g, " ").trim();
|
||||||
const MAX = 140;
|
const MAX = 140;
|
||||||
if (flat.length <= MAX) return flat;
|
const { anchor, score } = bestMatch(flat, query, MAX - 40);
|
||||||
const lower = flat.toLowerCase();
|
|
||||||
let idx = -1;
|
if (flat.length <= MAX) return { text: flat, score };
|
||||||
for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
|
||||||
const i = lower.indexOf(t);
|
const start = anchor - 40;
|
||||||
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
|
|
||||||
}
|
|
||||||
if (idx <= 40) return flat.slice(0, MAX) + "…";
|
|
||||||
const start = idx - 40;
|
|
||||||
const end = Math.min(flat.length, start + MAX);
|
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
|
// 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));
|
return h !== undefined && (groups.size === 0 || groups.has(h));
|
||||||
});
|
});
|
||||||
|
|
||||||
const byThread = new Map<string, SearchResult>();
|
type Candidate = SearchResult & { score: number };
|
||||||
const opless: SearchResult[] = [];
|
const byThread = new Map<string, Candidate>();
|
||||||
|
const needTitle: Candidate[] = [];
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.kind === 11) {
|
const isOp = e.kind === 11;
|
||||||
const existing = byThread.get(e.id);
|
const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
||||||
// An OP match wins over a reply match on the same thread
|
if (!threadId) continue;
|
||||||
if (existing && existing.matchKind === "thread") continue;
|
const title = isOp
|
||||||
byThread.set(e.id, {
|
? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
|
||||||
threadId: e.id,
|
: "";
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
const { text, score } = snippetOf(e.content, query);
|
||||||
snippet: snippetOf(e.content, query),
|
const existing = byThread.get(threadId);
|
||||||
matchKind: "thread",
|
if (!existing) {
|
||||||
createdAt: e.created_at,
|
const r: Candidate = {
|
||||||
});
|
threadId,
|
||||||
} else {
|
title,
|
||||||
const root = e.tags.find((t) => t[0] === "E")?.[1];
|
snippet: text,
|
||||||
if (!root || byThread.has(root)) continue;
|
matchKind: isOp ? "thread" : "reply",
|
||||||
const r: SearchResult = {
|
|
||||||
threadId: root,
|
|
||||||
title: "",
|
|
||||||
snippet: snippetOf(e.content, query),
|
|
||||||
matchKind: "reply",
|
|
||||||
createdAt: e.created_at,
|
createdAt: e.created_at,
|
||||||
|
score,
|
||||||
};
|
};
|
||||||
byThread.set(root, r);
|
byThread.set(threadId, r);
|
||||||
opless.push(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
|
// Backfill titles for reply-only matches; the group relay truncates
|
||||||
// multi-id queries, so fetch each OP on its own
|
// multi-id queries, so fetch each OP on its own
|
||||||
await Promise.all(
|
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 ops = await queryForum({ kinds: [11], ids: [r.threadId] });
|
||||||
const op: Event | undefined = ops[0];
|
const op: Event | undefined = ops[0];
|
||||||
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the relay's relevance order (arrival order); a thread keeps the
|
// Tiered ordering: full-phrase matches, then contiguous multi-word runs
|
||||||
// position of its best-ranked match
|
// (longest first), then single-word matches capped to keep noise down.
|
||||||
return [...byThread.values()];
|
// 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 { searchThreads, type SearchResult } from "$lib/search";
|
||||||
|
import { findPhraseSpans, findMatchSpans } from "$lib/textMatch";
|
||||||
|
|
||||||
// Debounced-search state machine shared by the inline (homepage) and modal
|
// Debounced-search state machine shared by the inline (homepage) and modal
|
||||||
// search shells, so behavior lives in one place and the shells only differ
|
// 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 timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
const terms = $derived(
|
|
||||||
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
function schedule() {
|
function schedule() {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
|
|
@ -41,19 +38,26 @@ export function createSearchState() {
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRe(s: string): string {
|
// Split into alternating plain/matched segments for <mark> rendering.
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
// 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.
|
||||||
// Split into alternating plain/matched segments for <mark> rendering
|
|
||||||
function highlight(text: string): { text: string; hit: boolean }[] {
|
function highlight(text: string): { text: string; hit: boolean }[] {
|
||||||
if (!text || terms.length === 0) return [{ text, hit: false }];
|
if (!text) return [{ text, hit: false }];
|
||||||
const alts = terms.map(escapeRe).join("|");
|
const phrase = findPhraseSpans(text, resultsQuery);
|
||||||
const exact = new RegExp(`^(${alts})$`, "i");
|
const spans =
|
||||||
return text
|
phrase.length > 0 ? phrase : findMatchSpans(text, resultsQuery);
|
||||||
.split(new RegExp(`(${alts})`, "gi"))
|
if (spans.length === 0) return [{ text, hit: false }];
|
||||||
.filter((s) => s !== "")
|
const out: { text: string; hit: boolean }[] = [];
|
||||||
.map((s) => ({ text: s, hit: exact.test(s) }));
|
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
|
// Arrow/Enter handling; returns true when the event was consumed
|
||||||
|
|
@ -98,6 +102,13 @@ export function createSearchState() {
|
||||||
get active() {
|
get active() {
|
||||||
return resultsQuery.trim().length >= 2;
|
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,
|
schedule,
|
||||||
highlight,
|
highlight,
|
||||||
navigate,
|
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 { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
import { queryForum, publishForum } from "$lib/relay";
|
import { queryForum, publishForum } from "$lib/relay";
|
||||||
import { threads as mockThreads } from "$lib/mock";
|
import { threads as mockThreads } from "$lib/mock";
|
||||||
|
|
@ -10,40 +11,34 @@ import {
|
||||||
buildPTagHints,
|
buildPTagHints,
|
||||||
} from "$lib/mentions";
|
} from "$lib/mentions";
|
||||||
import { convertForumUrls } from "$lib/linkify";
|
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);
|
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 detail = $state<ThreadDetail | null>(null);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
// "notfound" means the relay returned nothing — either no such thread or it sits
|
// "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.
|
// in a private group the current (non-member) user can't read.
|
||||||
let status = $state<"loading" | "ready" | "notfound">("loading");
|
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 = {
|
export const threadDetailStore = {
|
||||||
get detail() {
|
get detail() {
|
||||||
return detail;
|
return detail ?? page.data.thread ?? null;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = page.data.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get status() {
|
get status() {
|
||||||
return status;
|
return status === "loading" && page.data.thread ? "ready" : status;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,39 +99,13 @@ export async function loadThread(id: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [threadEvents, replyEvents] = await Promise.all([
|
const next = await fetchThread(queryForum, id, GROUP_ID);
|
||||||
queryForum({ kinds: [11], ids: [id] }),
|
if (!next) {
|
||||||
queryForum({ kinds: [1111], "#E": [id] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const event = threadEvents[0];
|
|
||||||
if (!event) {
|
|
||||||
status = "notfound";
|
status = "notfound";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
detail = next;
|
||||||
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
[next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||||
|
|
||||||
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);
|
|
||||||
status = "ready";
|
status = "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import { page } from "$app/state";
|
||||||
import type { Event } from "@nostr/tools/core";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
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;
|
export type { SortMode, ThreadData };
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
let threads = $state<ThreadData[]>([]);
|
let threads = $state<ThreadData[]>([]);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
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 snapshotAt = 0; // upper time bound, frozen at initial load
|
||||||
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
||||||
|
|
||||||
|
// Which listing the client asked for, and whether its first page is in.
|
||||||
|
let requested = $state<{ groupId: string; sort: SortMode } | null>(null);
|
||||||
|
let settled = $state(false);
|
||||||
|
|
||||||
|
// The server snapshot stands in until the live listing lands, but only for
|
||||||
|
// the listing that was requested: a different room or sort shows "Loading…".
|
||||||
|
function fallback() {
|
||||||
|
const s = page.data.threads;
|
||||||
|
if (!s) return null;
|
||||||
|
if (
|
||||||
|
requested &&
|
||||||
|
(requested.groupId !== s.groupId || requested.sort !== s.sort)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export const threadStore = {
|
export const threadStore = {
|
||||||
get threads() {
|
get threads() {
|
||||||
return threads;
|
if (settled || threads.length > 0) return threads;
|
||||||
|
return fallback()?.threads ?? threads;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = fallback()?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get exhausted() {
|
get exhausted() {
|
||||||
return exhausted;
|
return settled ? exhausted : (fallback()?.done ?? exhausted);
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
|
|
@ -59,238 +66,30 @@ async function loadProfile(pubkey: string) {
|
||||||
ingestNostrUser(user);
|
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) {
|
async function runLoad(append: boolean, groupId: string) {
|
||||||
const id = ++reqId;
|
const id = ++reqId;
|
||||||
if (append) loadingMore = true;
|
if (append) loadingMore = true;
|
||||||
else loading = true;
|
else loading = true;
|
||||||
|
// Paged loads drive their own subscriptions on the shared connection
|
||||||
const relay = await ensureForumRelay();
|
const q = relayQuery(await ensureForumRelay());
|
||||||
try {
|
try {
|
||||||
const until = append ? (cursor ?? snapshotAt) : snapshotAt;
|
const page = await fetchThreadPage(q, groupId, {
|
||||||
const exclude = new Set(threads.map((t) => t.id));
|
sort: sortMode,
|
||||||
const slice =
|
until: append ? (cursor ?? snapshotAt) : snapshotAt,
|
||||||
sortMode === "new"
|
snapshotAt,
|
||||||
? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE)
|
exclude: new Set(threads.map((t) => t.id)),
|
||||||
: await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE);
|
});
|
||||||
const built = await buildThreads(relay, groupId, slice.items);
|
|
||||||
|
|
||||||
if (id !== reqId) return; // Superseded by a newer load — discard results
|
if (id !== reqId) return; // Superseded by a newer load — discard results
|
||||||
|
threads = append ? [...threads, ...page.threads] : page.threads;
|
||||||
threads = append ? [...threads, ...built] : built;
|
cursor = page.nextCursor ?? cursor;
|
||||||
cursor = slice.nextCursor ?? cursor;
|
exhausted = page.done || page.threads.length === 0;
|
||||||
exhausted = slice.done || built.length === 0;
|
settled = true;
|
||||||
|
for (const t of page.threads) {
|
||||||
for (const t of built) {
|
|
||||||
loadProfile(t.authorPubkey);
|
loadProfile(t.authorPubkey);
|
||||||
loadProfile(t.latestPubkey);
|
loadProfile(t.latestPubkey);
|
||||||
for (const p of t.replierPubkeys) loadProfile(p);
|
for (const p of t.replierPubkeys) loadProfile(p);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// shared forum connection is long-lived — don't close it here
|
|
||||||
if (id === reqId) {
|
if (id === reqId) {
|
||||||
if (append) loadingMore = false;
|
if (append) loadingMore = false;
|
||||||
else loading = false;
|
else loading = false;
|
||||||
|
|
@ -300,6 +99,8 @@ async function runLoad(append: boolean, groupId: string) {
|
||||||
|
|
||||||
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
||||||
sortMode = sort;
|
sortMode = sort;
|
||||||
|
requested = { groupId, sort };
|
||||||
|
settled = false;
|
||||||
threads = [];
|
threads = [];
|
||||||
cursor = null;
|
cursor = null;
|
||||||
exhausted = false;
|
exhausted = false;
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.svg";
|
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
||||||
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
||||||
|
|
@ -12,9 +11,10 @@
|
||||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||||
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
||||||
import Toast from "$lib/components/Toast.svelte";
|
import Toast from "$lib/components/Toast.svelte";
|
||||||
|
import RelayGate from "$lib/components/RelayGate.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { afterNavigate } from "$app/navigation";
|
import { afterNavigate } from "$app/navigation";
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
import { loadGroup } from "$lib/group.svelte";
|
import { loadGroup } from "$lib/group.svelte";
|
||||||
import { ensureMembershipChecked } from "$lib/join.svelte";
|
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||||
|
|
@ -25,12 +25,15 @@
|
||||||
import { seedProfiles } from "$lib/profiles.svelte";
|
import { seedProfiles } from "$lib/profiles.svelte";
|
||||||
import { startChat } from "$lib/chat.svelte";
|
import { startChat } from "$lib/chat.svelte";
|
||||||
import { resetForumConnection } from "$lib/relay";
|
import { resetForumConnection } from "$lib/relay";
|
||||||
|
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||||
import {
|
import {
|
||||||
MODE,
|
MODE,
|
||||||
ACCENT_COLOR,
|
ACCENT_COLOR,
|
||||||
SECONDARY_COLOR,
|
SECONDARY_COLOR,
|
||||||
SEARCH_ENABLED,
|
SEARCH_ENABLED,
|
||||||
|
FAVICON,
|
||||||
|
RELAY_URL,
|
||||||
} from "$lib/config";
|
} from "$lib/config";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
@ -62,27 +65,43 @@
|
||||||
// The main column is its own scroll container (overflow-y-auto) and persists
|
// The main column is its own scroll container (overflow-y-auto) and persists
|
||||||
// across navigations, so SvelteKit's window-only scroll restoration never
|
// across navigations, so SvelteKit's window-only scroll restoration never
|
||||||
// resets it. Scroll it back to top on forward navigation; leave back/forward
|
// 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);
|
let mainEl = $state<HTMLElement | null>(null);
|
||||||
afterNavigate((nav) => {
|
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();
|
loadResources();
|
||||||
loadPartials();
|
loadPartials();
|
||||||
const tasks = [restoreSession(), loadGroup()];
|
const tasks = [loadGroup()];
|
||||||
if (mode === "full") tasks.push(loadGroups());
|
if (mode === "full") tasks.push(loadGroups());
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
// Resources are filtered by the admin set; in full mode that means every
|
// Resources are filtered by the admin set; in full mode that means every
|
||||||
// room's admins, needed on every page for the sidebar.
|
// room's admins, needed on every page for the sidebar.
|
||||||
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
|
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
|
||||||
seedProfiles(auth.user?.pubkey ?? null);
|
seedProfiles(auth.user?.pubkey ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each completed probe that finds the relay open reloads the room views: on
|
||||||
|
// first load, after a login (private/hidden rooms appear) or logout (they
|
||||||
|
// vanish), and after joining a members-only relay.
|
||||||
|
$effect(() => {
|
||||||
|
relayAccess.probes;
|
||||||
|
if (relayAccess.state === "open") untrack(loadForum);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload the room views when the user logs in or out: a logged-in member sees
|
// Re-probe when the user logs in or out. sessionEpoch only bumps on explicit
|
||||||
// their private/hidden rooms, so the listing must be re-fetched over the now
|
|
||||||
// (de)authenticated connection. sessionEpoch only bumps on explicit
|
|
||||||
// login/logout, never on the silent restore that onMount already covers.
|
// login/logout, never on the silent restore that onMount already covers.
|
||||||
let lastEpoch = -1;
|
let lastEpoch = -1;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -91,17 +110,12 @@
|
||||||
const first = lastEpoch === -1;
|
const first = lastEpoch === -1;
|
||||||
const loggedOut = auth.user === null;
|
const loggedOut = auth.user === null;
|
||||||
lastEpoch = epoch;
|
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
|
// On logout, drop the authenticated connection so the relay stops serving
|
||||||
// the previous user's private rooms; login reuses the open connection,
|
// the previous user's private rooms; login reuses the open connection,
|
||||||
// which ensureForumReady authenticates via its stored challenge.
|
// which ensureForumReady authenticates via its stored challenge.
|
||||||
if (loggedOut) resetForumConnection();
|
if (loggedOut) resetForumConnection();
|
||||||
loadGroup();
|
probeRelayAccess();
|
||||||
if (mode === "full") {
|
|
||||||
loadGroups().then(() =>
|
|
||||||
loadRoomAdmins(groupsStore.list.map((g) => g.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Full mode: the room route defines the active group. Thread pages set it
|
// Full mode: the room route defines the active group. Thread pages set it
|
||||||
|
|
@ -164,15 +178,20 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={FAVICON} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<!-- 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="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
|
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
|
||||||
'chat'
|
'chat'
|
||||||
? 'h-dvh overflow-hidden'
|
? 'h-dvh overflow-hidden'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<Navbar onMenuToggle={() => (menuOpen = true)} />
|
<Navbar onMenuToggle={() => (menuOpen = true)} />
|
||||||
<div
|
<div
|
||||||
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
|
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
|
||||||
|
|
@ -192,10 +211,33 @@
|
||||||
it first and it reappears once the top is reached. Desktop only. -->
|
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="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||||
<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"
|
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"
|
||||||
>
|
>
|
||||||
|
<div class="flex-1">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
<footer
|
||||||
|
class="mt-auto pt-16 pb-4 text-center text-[90%] text-neutral-500/70 dark:text-neutral-400/70"
|
||||||
|
>
|
||||||
|
This forum is built on
|
||||||
|
<a
|
||||||
|
href="https://nostr.basspistol.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
|
>Nostr</a
|
||||||
|
>
|
||||||
|
with
|
||||||
|
<a
|
||||||
|
href="https://github.com/dtonon/squalk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
|
>Squalk</a
|
||||||
|
>
|
||||||
|
- Hosting relay is {RELAY_URL.replace(/^wss?:\/\//, "")}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{#if showChat}
|
{#if showChat}
|
||||||
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
||||||
|
|
@ -278,7 +320,8 @@
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
open={menuOpen}
|
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 { partialsStore } from "$lib/partials.svelte";
|
||||||
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
||||||
import SearchInline from "$lib/components/SearchInline.svelte";
|
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";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
const partial = $derived(partialsStore.get("home"));
|
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.
|
// Full mode: load per-room activity + recent threads once the rooms are known.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (MODE !== "full") return;
|
if (MODE !== "full") return;
|
||||||
|
|
@ -35,9 +51,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta
|
||||||
<title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
|
title={siteName() || (MODE === "full" ? "Rooms" : "Discussions")}
|
||||||
</svelte:head>
|
{description}
|
||||||
|
{image}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet pic(a: { name: string; picture?: string })}
|
{#snippet pic(a: { name: string; picture?: string })}
|
||||||
{#if a.picture}
|
{#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 {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
ensureProfile,
|
ensureProfile,
|
||||||
|
entryFromUser,
|
||||||
type ProfileEntry,
|
type ProfileEntry,
|
||||||
} from "$lib/profiles.svelte";
|
} from "$lib/profiles.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import { partialsStore } from "$lib/partials.svelte";
|
import { partialsStore } from "$lib/partials.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.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"));
|
const partial = $derived(partialsStore.get("contacts"));
|
||||||
|
|
||||||
|
|
@ -48,11 +52,17 @@
|
||||||
|
|
||||||
const contacts = $derived<Contact[]>(
|
const contacts = $derived<Contact[]>(
|
||||||
adminPubkeys.list.map((pk) => {
|
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 };
|
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 {
|
function displayName(c: Contact): string {
|
||||||
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
||||||
}
|
}
|
||||||
|
|
@ -66,10 +76,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta title="Contacts" description="Admins and contacts for this community." />
|
||||||
<title>Contacts</title>
|
|
||||||
<meta name="description" content="Admins and contacts for this community." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
|
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
|
||||||
|
|
@ -81,34 +88,49 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#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}
|
{: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}
|
{:else}
|
||||||
<ul class="mt-2 space-y-3">
|
<ul class="mt-2 space-y-3">
|
||||||
{#each contacts as c (c.pubkey)}
|
{#each contacts as c (c.pubkey)}
|
||||||
<li
|
<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">
|
<div class="flex gap-4">
|
||||||
|
<a
|
||||||
|
href={profilePath(c.pubkey)}
|
||||||
|
class="shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
{#if c.entry?.picture}
|
{#if c.entry?.picture}
|
||||||
<img
|
<img
|
||||||
src={c.entry.picture}
|
src={c.entry.picture}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-14 w-14 shrink-0 rounded-full object-cover"
|
class="h-14 w-14 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-lg font-semibold text-neutral-500 dark:text-neutral-400"
|
class="flex h-14 w-14 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()}
|
{displayName(c)[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100">
|
<p
|
||||||
|
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
<a href={profilePath(c.pubkey)} class="hover:text-accent">
|
||||||
{displayName(c)}
|
{displayName(c)}
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{#if c.entry?.nip05}
|
{#if c.entry?.nip05}
|
||||||
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||||
|
|
@ -117,19 +139,19 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{c.npub}"
|
href={profilePath(c.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent shrink-0 text-sm hover:underline"
|
class="text-accent shrink-0 text-sm hover:underline"
|
||||||
>
|
>
|
||||||
View profile ↗
|
View profile
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 md:pl-18">
|
<div class="mt-3 md:pl-18">
|
||||||
{#if c.entry?.about}
|
{#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}
|
{c.entry.about}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -147,7 +169,10 @@
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.entry?.lud16}
|
{#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}
|
⚡ {c.entry.lud16}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -158,7 +183,9 @@
|
||||||
{@const rooms = roomsOf(c.pubkey)}
|
{@const rooms = roomsOf(c.pubkey)}
|
||||||
{#if rooms.length > 0}
|
{#if rooms.length > 0}
|
||||||
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
<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
|
{#each rooms as r, i}<a
|
||||||
href="/room/{r.id}"
|
href="/room/{r.id}"
|
||||||
class="text-accent hover:underline">{r.name}</a
|
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
|
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
||||||
typography plugin's em-based heading margins; the first block stays flush. */
|
typography plugin's em-based heading margins; the first block stays flush. */
|
||||||
.prose :is(h1, h2, h3, h4, h5, h6) {
|
.prose :is(h1, h2, h3, h4, h5, h6) {
|
||||||
|
|
@ -76,6 +85,12 @@ body {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
.prose :is(h2) {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
.prose :is(h3) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
.prose :is(p) {
|
.prose :is(p) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -104,3 +119,9 @@ body {
|
||||||
--color-secondary: #ffaf25;
|
--color-secondary: #ffaf25;
|
||||||
--color-secondary-hover: #e69e1a;
|
--color-secondary-hover: #e69e1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* setto*/
|
||||||
|
|
||||||
|
a[href*="room/h1"] {
|
||||||
|
display:none
|
||||||
|
}
|
||||||
|
|
|
||||||
277
src/routes/profile/[id]/+page.svelte
Normal file
277
src/routes/profile/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { MODE } from "$lib/config";
|
||||||
|
import { getGroupName } from "$lib/group.svelte";
|
||||||
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import {
|
||||||
|
profileStore,
|
||||||
|
ensureProfile,
|
||||||
|
entryFromUser,
|
||||||
|
type ProfileEntry,
|
||||||
|
} from "$lib/profiles.svelte";
|
||||||
|
import {
|
||||||
|
userPostsStore,
|
||||||
|
loadUserPosts,
|
||||||
|
loadMoreUserPosts,
|
||||||
|
} from "$lib/userPosts.svelte";
|
||||||
|
import type {
|
||||||
|
UserPost,
|
||||||
|
UserPostKind,
|
||||||
|
UserPostPage,
|
||||||
|
} from "$lib/forum/userPosts";
|
||||||
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
import { summarize } from "$lib/seo";
|
||||||
|
|
||||||
|
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!pubkey) return;
|
||||||
|
ensureProfile(pubkey);
|
||||||
|
loadUserPosts(pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-rendered profile, used until the live one is fetched.
|
||||||
|
const entry = $derived.by<ProfileEntry | undefined>(() => {
|
||||||
|
if (!pubkey) return undefined;
|
||||||
|
const live = profileStore.profiles.get(pubkey);
|
||||||
|
if (live) return live;
|
||||||
|
const u = page.data.profiles?.[pubkey];
|
||||||
|
return u ? entryFromUser(u) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = $derived(
|
||||||
|
pubkey && userPostsStore.pubkey === pubkey
|
||||||
|
? userPostsStore.posts
|
||||||
|
: (page.data.posts ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rooms the loaded posts come from, in the rooms list order (full mode).
|
||||||
|
const seenOn = $derived.by(() => {
|
||||||
|
if (MODE !== "full" || !posts) return [];
|
||||||
|
const ids = new Set(
|
||||||
|
[...posts.roots.items, ...posts.replies.items].map((p) => p.groupId),
|
||||||
|
);
|
||||||
|
return groupsStore.list.filter((g) => ids.has(g.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const npub = $derived(entry?.npub ?? page.params.id ?? "");
|
||||||
|
const name = $derived(
|
||||||
|
entry?.displayName || entry?.name || `${npub.slice(0, 12)}…`,
|
||||||
|
);
|
||||||
|
const initial = $derived(name[0]?.toUpperCase() ?? "?");
|
||||||
|
|
||||||
|
function websiteHref(url: string): string {
|
||||||
|
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websiteLabel(url: string): string {
|
||||||
|
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number) {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postHref(p: UserPost): string {
|
||||||
|
return p.id === p.threadId
|
||||||
|
? `/thread/${p.threadId}`
|
||||||
|
: `/thread/${p.threadId}#post-${p.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonLd = $derived(
|
||||||
|
pubkey
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
name,
|
||||||
|
url: page.url.origin + page.url.pathname,
|
||||||
|
...(entry?.picture ? { image: entry.picture } : {}),
|
||||||
|
...(entry?.about ? { description: summarize(entry.about, 160) } : {}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta
|
||||||
|
title={name}
|
||||||
|
description={entry?.about ? summarize(entry.about, 160) : ""}
|
||||||
|
image={entry?.picture}
|
||||||
|
{jsonLd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)}
|
||||||
|
{#if list.items.length === 0}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{#each list.items as p (p.id)}
|
||||||
|
{@const text = summarize(p.content, 200)}
|
||||||
|
<li class="py-3">
|
||||||
|
<a href={postHref(p)} class="group block">
|
||||||
|
{#if p.title}
|
||||||
|
<p
|
||||||
|
class="group-hover:text-accent line-clamp-2 text-lg leading-5 text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if text}
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm leading-5 text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
{#if MODE === "full"}
|
||||||
|
<a href="/room/{p.groupId}" class="hover:text-accent truncate"
|
||||||
|
>{getGroupName(p.groupId)}</a
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
{/if}
|
||||||
|
<time datetime={new Date(p.createdAt * 1000).toISOString()}
|
||||||
|
>{formatDate(p.createdAt)}</time
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !list.done}
|
||||||
|
{@const busy = userPostsStore.loadingMore[kind]}
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<button
|
||||||
|
onclick={() => loadMoreUserPosts(kind)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={busy}
|
||||||
|
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{busy ? "Loading…" : "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
{#if !pubkey}
|
||||||
|
<h1 class="text-accent py-2 text-[1.65rem]">Profile</h1>
|
||||||
|
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
Profile not found.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<h1 class="text-accent py-2 text-[1.65rem]">{name}</h1>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-start gap-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if entry?.nip05}
|
||||||
|
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||||
|
{entry.nip05}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if entry?.about}
|
||||||
|
<p
|
||||||
|
class="mt-2 whitespace-pre-line text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{entry.about}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
{#if entry?.website}
|
||||||
|
<a
|
||||||
|
href={websiteHref(entry.website)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-accent truncate hover:underline"
|
||||||
|
>
|
||||||
|
{websiteLabel(entry.website)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if entry?.lud16}
|
||||||
|
<span
|
||||||
|
class="text-neutral-500 dark:text-neutral-400"
|
||||||
|
title="Lightning address"
|
||||||
|
>
|
||||||
|
⚡ {entry.lud16}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="https://nostr.basspistol.org/{npub}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-accent text-sm hover:underline"
|
||||||
|
>
|
||||||
|
View on Bpistle ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if seenOn.length > 0}
|
||||||
|
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||||
|
<span class="text-neutral-400 dark:text-neutral-500">Seen on:</span>
|
||||||
|
{#each seenOn as r, i (r.id)}<a
|
||||||
|
href="/room/{r.id}"
|
||||||
|
class="text-accent hover:underline">{r.name}</a
|
||||||
|
>{i < seenOn.length - 1 ? ", " : ""}{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if entry?.picture}
|
||||||
|
<img
|
||||||
|
src={entry.picture}
|
||||||
|
alt=""
|
||||||
|
class="h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex h-24 w-24 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-3xl font-semibold text-neutral-500 sm:h-32 sm:w-32 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
|
||||||
|
<section aria-labelledby="profile-discussions">
|
||||||
|
<h2
|
||||||
|
id="profile-discussions"
|
||||||
|
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
Discussions
|
||||||
|
</h2>
|
||||||
|
{#if !posts}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{@render postList("roots", posts.roots, "No discussions yet.")}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="profile-replies">
|
||||||
|
<h2
|
||||||
|
id="profile-replies"
|
||||||
|
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
Replies
|
||||||
|
</h2>
|
||||||
|
{#if !posts}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{@render postList("replies", posts.replies, "No replies yet.")}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
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 { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { adminPubkeys } from "$lib/admins.svelte";
|
import { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.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 slug = $derived(page.params.slug ?? "");
|
||||||
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
||||||
|
|
@ -10,23 +12,10 @@
|
||||||
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
||||||
const notFound = $derived(ready && !resource);
|
const notFound = $derived(ready && !resource);
|
||||||
|
|
||||||
// First non-empty line of the body, trimmed of markdown markers, for SEO.
|
const description = $derived(excerpt(resource?.content ?? ""));
|
||||||
const description = $derived(
|
|
||||||
(resource?.content ?? "")
|
|
||||||
.replace(/^#+\s*/gm, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.find(Boolean)
|
|
||||||
?.slice(0, 160) ?? "",
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta title={resource?.title ?? "Resource"} {description} type="article" />
|
||||||
<title>{resource?.title ?? "Resource"}</title>
|
|
||||||
{#if description}
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<a
|
<a
|
||||||
|
|
@ -56,8 +45,12 @@
|
||||||
<PostContent content={resource.content} headingOffset={0} />
|
<PostContent content={resource.content} headingOffset={0} />
|
||||||
</div>
|
</div>
|
||||||
{:else if notFound}
|
{: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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { goto } from "$app/navigation";
|
import { afterNavigate, goto } from "$app/navigation";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import {
|
import {
|
||||||
threadDetailStore,
|
threadDetailStore,
|
||||||
|
|
@ -19,17 +19,21 @@
|
||||||
import { showToast } from "$lib/toast.svelte";
|
import { showToast } from "$lib/toast.svelte";
|
||||||
import {
|
import {
|
||||||
withJoin,
|
withJoin,
|
||||||
|
joinState,
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { setActiveGroup } from "$lib/active.svelte";
|
import { setActiveGroup } from "$lib/active.svelte";
|
||||||
|
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
import Tag from "$lib/components/Tag.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 };
|
type Author = { pubkey: string; name: string; picture?: string };
|
||||||
|
|
||||||
|
|
@ -119,6 +123,25 @@
|
||||||
const detail = $derived(threadDetailStore.detail);
|
const detail = $derived(threadDetailStore.detail);
|
||||||
const profiles = $derived(threadDetailStore.profiles);
|
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;
|
// Replying needs membership regardless of flags. Only gate a confirmed guest;
|
||||||
// while membership resolves the editor stays and submit awaits the check, so a
|
// while membership resolves the editor stays and submit awaits the check, so a
|
||||||
// member never flashes "Join to reply".
|
// member never flashes "Join to reply".
|
||||||
|
|
@ -135,7 +158,7 @@
|
||||||
|
|
||||||
function onJoinToReply() {
|
function onJoinToReply() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
openJoinModal(detail.groupId, async () => {
|
withJoin(detail.groupId, async () => {
|
||||||
await tick();
|
await tick();
|
||||||
editorEl?.focus();
|
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(() => {
|
$effect(() => {
|
||||||
if (!openMenuId) return;
|
if (!openMenuId) return;
|
||||||
const close = () => (openMenuId = null);
|
const close = () => (openMenuId = null);
|
||||||
|
|
@ -210,6 +247,89 @@
|
||||||
if (page.params.id) loadThread(page.params.id);
|
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
|
// A thread belongs to its own group; make that the active group so chat and
|
||||||
// replies target the right room.
|
// replies target the right room.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -290,18 +410,34 @@
|
||||||
await quotePost(post, text);
|
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(() => {
|
$effect(() => {
|
||||||
if (!opEl) return;
|
const el = opEl;
|
||||||
|
const header = threadEl?.querySelector("[data-thread-header]");
|
||||||
|
if (!el || !header) return;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
|
const measure = () => {
|
||||||
opTopOffset =
|
opTopOffset =
|
||||||
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
el.getBoundingClientRect().top -
|
||||||
|
main.getBoundingClientRect().top +
|
||||||
|
main.scrollTop;
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(header);
|
||||||
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta
|
||||||
<title>{detail?.title ?? "Thread"}</title>
|
title={detail?.title ?? "Thread"}
|
||||||
</svelte:head>
|
description={detail ? excerpt(detail.op.content) : ""}
|
||||||
|
type="article"
|
||||||
|
{jsonLd}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet avatar(author: Author)}
|
{#snippet avatar(author: Author)}
|
||||||
{#if author.picture}
|
{#if author.picture}
|
||||||
|
|
@ -326,26 +462,34 @@
|
||||||
)}
|
)}
|
||||||
{@const author = resolveAuthor(p.pubkey, profiles)}
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||||
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
<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)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
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 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)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<span
|
<a
|
||||||
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
|
href={profilePath(author.pubkey)}
|
||||||
>{author.name}</span
|
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||||
|
>{author.name}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||||
{#if canDeletePost(p)}
|
|
||||||
{@const tooOld =
|
|
||||||
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => toggleMenu(p.id, e)}
|
onclick={(e) => toggleMenu(p.id, e)}
|
||||||
|
|
@ -370,6 +514,18 @@
|
||||||
role="menu"
|
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"
|
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
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
disabled={tooOld}
|
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"
|
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
|
>Delete{tooOld ? " (too old)" : ""}</button
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
<span class="text-sm text-neutral-400 dark:text-neutral-500"
|
<span class="text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
>{formatDate(p.createdAt)}</span
|
>{formatDate(p.createdAt)}</span
|
||||||
>
|
>
|
||||||
|
|
@ -421,9 +577,10 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if detail}
|
{#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 class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
||||||
<div
|
<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"
|
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
|
<a
|
||||||
|
|
@ -491,9 +648,10 @@
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={onJoinToReply}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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,21 @@
|
||||||
import adapter from "@sveltejs/adapter-static";
|
import { loadEnv } from "vite";
|
||||||
|
import adapterNode from "@sveltejs/adapter-node";
|
||||||
|
import adapterStatic from "@sveltejs/adapter-static";
|
||||||
|
|
||||||
|
// PUBLIC_SSR picks the deployment target: a Node server that renders pages
|
||||||
|
// (yes) or a static single-page bundle (no, the default). vite.config.ts bakes
|
||||||
|
// the same flag into the app as SSR_ENABLED.
|
||||||
|
const env = loadEnv(
|
||||||
|
process.env.NODE_ENV ?? "production",
|
||||||
|
process.cwd(),
|
||||||
|
"PUBLIC_",
|
||||||
|
);
|
||||||
|
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: ssr ? adapterNode() : adapterStatic({ fallback: "index.html" }),
|
||||||
fallback: "index.html",
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
|
||||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
// PUBLIC_SSR is a build-time decision (it also picks the adapter in
|
||||||
|
// svelte.config.js), so it is compiled in as a constant rather than read from
|
||||||
|
// the environment when the server starts.
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "PUBLIC_");
|
||||||
|
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||||
|
return {
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
define: { __SQUALK_SSR__: JSON.stringify(ssr) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue