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 |
87 changed files with 8613 additions and 4731 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_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
|
||||
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
|
||||
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
|
||||
# Render on the server (Node deploy) instead of shipping a static SPA
|
||||
PUBLIC_SSR=no
|
||||
# With SSR on, warm the server cache from client-side navigations (yes/no)
|
||||
PUBLIC_SSR_WARM=yes
|
||||
# Snapshot cache windows in seconds: served as is / served stale while refreshing
|
||||
PUBLIC_SSR_CACHE_FRESH=300
|
||||
PUBLIC_SSR_CACHE_STALE=21600
|
||||
|
||||
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
||||
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
||||
PUBLIC_NOSTRCONNECT_RELAYS= # comma-separated relays for nostrconnect:// (bunker) login; defaults to bucket.coracle.social,relay.primal.net
|
||||
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
||||
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
||||
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
|
||||
PUBLIC_SECONDARY_COLOR= # override the secondary color (default #ffaf25), e.g. "#0000ff"; hover shade derived
|
||||
PUBLIC_FAVICON= # favicon URL (absolute, or /favicon.png for a file in static/); default is a filled circle in the accent color
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
.local
|
||||
|
||||
# Output
|
||||
.output
|
||||
|
|
@ -18,6 +19,10 @@ Thumbs.db
|
|||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Deploy: only the example unit is tracked
|
||||
/deploy/*
|
||||
!/deploy/production-example.service
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 dtonon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
32
README.md
32
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_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
|
||||
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
|
||||
| `PUBLIC_SSR` | no | `no` | `yes` to render pages on the server (crawlable HTML, real 404s). The build then targets Node (`node build`) instead of a static bundle; see [Deploying](#deploying). |
|
||||
| `PUBLIC_SSR_WARM` | no | `yes` | With `PUBLIC_SSR=yes`, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to `no` to disable. |
|
||||
| `PUBLIC_SSR_CACHE_FRESH` | no | `300` | Seconds a server-rendered snapshot is served as is. Also the edge cache's `s-maxage`. |
|
||||
| `PUBLIC_SSR_CACHE_STALE` | no | `21600` | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's `stale-while-revalidate`. |
|
||||
| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
|
||||
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
|
||||
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
||||
|
|
@ -89,12 +93,26 @@ npm run dev -- --open
|
|||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
Two deployment targets share the same code base, selected by `PUBLIC_SSR`:
|
||||
|
||||
- **Static (default, `PUBLIC_SSR=no`)** — `npm run build` (or `just build`) writes a single-page bundle to `build/`; serve it from any web server with `index.html` as the fallback for unknown paths. Everything is fetched by the browser.
|
||||
- **Server-rendered (`PUBLIC_SSR=yes`)** — `just build-ssr` writes a Node app to `build/`. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live `robots.txt` and `sitemap.xml`, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.
|
||||
|
||||
Preview a build locally with `npm run preview` (static) or `node --env-file=.env.production build` (server).
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
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
|
||||
95
homepages.sh
95
homepages.sh
|
|
@ -1,95 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
###
|
||||
# HOME
|
||||
###
|
||||
|
||||
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
|
||||
|
||||
### This is a forum Managed by nostr:npub15a5jljcjaqgty64hn5v3993zx09yjz2gepwt0q8strvlegdy2q8q6xkqfd — the Outernational Music Syndicate.
|
||||
|
||||
It is intended for all things music, including but not limited to technical questions, referals, self-promotion (it's a dirty job, but someone has to do it.). Read more in the [about page](/resource/about)" --tag t=squalk-partial --tag d=home --tag title=Homepage basspistol.org
|
||||
|
||||
###
|
||||
# ABOUT
|
||||
###
|
||||
|
||||
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
|
||||
|
||||
# About this forum
|
||||
|
||||
This is a community forum managed by nostr:npub15a5jljcjaqgty64hn5v3993zx09yjz2gepwt0q8strvlegdy2q8q6xkqfd and facilitated for people who enjoy, create, promote and grow Music. It is intended for all things music, including but not limited to technical questions, referals, self-promotion (it's a dirty job, but someone has to do it.) and it runs on [Nostr](https://nostr.org).
|
||||
|
||||
## What is Nostr?
|
||||
|
||||
Nostr — Notes and Other Stuff Transmitted by Relays — is an open protocol for decentralized social communication. There is no central server and no company in control: you own a keypair, your posts are signed events, and relays pass them around. That simplicity makes Nostr a fertile ground for experimentation, and this forum is a place to do that thinking out loud, together.
|
||||
|
||||
## What we talk about
|
||||
|
||||
Common themes include:
|
||||
|
||||
- Feedback — stuck in the creative process, uncertain where to take your tune, wondering if the high-hat cuts too deep? Just ask!
|
||||
- Promotion and growth — how to launch a project, reach people, write good posts, and tell the story of what you are creating without spamming.
|
||||
- Synths and software — proposing, refining, and discussing the must have in your studio.
|
||||
- Showcase — release announcements, demos, and works in progress that deserve eyes and feedback.
|
||||
- Help and onboarding — a friendly place to ask the obvious questions about how to leverage nostr for your music project, get unstuck, and help newcomers find their footing.
|
||||
|
||||
## How it works
|
||||
|
||||
Discussions are long-form threads you can follow and search, with a live chat sidebar for quick back-and-forth. Everything is built on open Nostr standards, so your identity and your posts are portable — they are not locked inside this site. Sign in with your Nostr key, join a room, and you are ready to take part.
|
||||
|
||||
## A community, not a feed
|
||||
|
||||
This forum favors depth over noise. We would rather have one thoughtful thread than ten throwaway hot takes. Be generous with your knowledge, credit the work of others, and keep the tone welcoming.
|
||||
|
||||
Before posting, take a moment to read the [Netiquette guidelines](/resource/guidelines) linked in the Resources menu — they describe the simple etiquette that keeps this place worth coming back to.
|
||||
|
||||
Welcome aboard, and happy musing." --tag t=squalk-resource --tag d=about --tag title=About basspistol.org
|
||||
|
||||
###
|
||||
# TERMS
|
||||
###
|
||||
|
||||
nak event --prompt-sec -k 30023 -c "https://basspistol.org/7c1a1054c24fdf0972fc2259c43cb516f42adf2d95a08e7ad50c8be0dfd5fdfd.png
|
||||
|
||||
# Forum Netiquette
|
||||
|
||||
Welcome! This forum works best when everyone treats it as a shared space. These guidelines describe the basic etiquette we expect from every member. They are not rigid rules so much as the habits that keep discussion useful, friendly, and worth coming back to.
|
||||
|
||||
## Be respectful
|
||||
|
||||
Treat others the way you would want to be treated in person. Disagree with ideas, not people. Personal attacks, insults, harassment, and discriminatory language have no place here. A heated topic is no excuse for contempt — if a thread makes you angry, step away before replying.
|
||||
|
||||
## Stay on topic
|
||||
|
||||
Post in the room that fits your subject, and keep replies relevant to the thread you are in. If a conversation drifts somewhere interesting, start a new thread for it rather than derailing the original one. Focused threads are easier for everyone to follow and to search later.
|
||||
|
||||
## Write so others can read you
|
||||
|
||||
Give your thread a descriptive title and take a moment to organize your thoughts. Break long posts into paragraphs, quote only the part you are replying to, and avoid writing in all capitals — it reads as shouting. Before asking a question, search to see whether it has already been answered.
|
||||
|
||||
## Add value
|
||||
|
||||
Aim to contribute something: an answer, a perspective, a useful link, or a thoughtful question. Avoid one-word replies, duplicate posts, and +1 comments that add noise. If you simply appreciate a post, a reaction is enough — there is no need to bump a thread without new content.
|
||||
|
||||
## Respect privacy and ownership
|
||||
|
||||
Do not share other people's private information, and do not repost private messages without permission. When you quote or build on someone else's work, credit them. Spam, advertising, and self-promotion that ignores the conversation are not welcome.
|
||||
|
||||
## Assume good faith
|
||||
|
||||
Tone is hard to read online. Assume the person you are replying to means well, ask for clarification before accusing, and be willing to admit when you were wrong. A short, honest my mistake defuses more arguments than any long rebuttal.
|
||||
|
||||
## Welcome newcomers
|
||||
|
||||
Everyone was new once. If a newcomer phrases a question awkwardly or misses an existing thread, point them in the right direction kindly instead of mocking them. The way regulars treat beginners sets the tone for the whole community, and patient answers are remembered long after the question is forgotten.
|
||||
|
||||
## A note on language
|
||||
|
||||
This is an international community, and many members are not writing in their first language. Be patient with grammar and spelling, and prefer plain wording over heavy slang, sarcasm, or inside jokes that are easy to misread. When tone matters, a short clarifying line or a reaction can prevent a misunderstanding before it starts.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
If you see a post that breaks these guidelines, report it to the moderators instead of starting a public fight. Moderators are volunteers keeping the community healthy: follow their guidance, and reach out privately if you disagree with a decision.
|
||||
|
||||
By taking part in this forum, you agree to follow this etiquette. Thanks for helping keep the community welcoming for everyone." --tag t=squalk-resource --tag d=guidelines --tag title=Guidelines basspistol.org
|
||||
43
justfile
43
justfile
|
|
@ -1,8 +1,49 @@
|
|||
set dotenv-load
|
||||
|
||||
# Cloudflare credentials: per deploy target in .env.<mode>.local (gitignored,
|
||||
# never shipped to the server), falling back to the environment / .env
|
||||
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
# Static single-page bundle (served by any web server)
|
||||
build:
|
||||
npm run build
|
||||
PUBLIC_SSR=no npm run build
|
||||
|
||||
# Server-rendered bundle (needs Node 22+ on the host, see deploy/production-example.service)
|
||||
build-ssr:
|
||||
PUBLIC_SSR=yes npm run build
|
||||
|
||||
deploy target: build
|
||||
rsync -av --delete --progress build/ {{target}}:~/squalk/
|
||||
@just purge-web-cache
|
||||
|
||||
# Ships the Node build plus its runtime deps and env, then restarts the unit.
|
||||
# `mode` picks the instance: vite bakes .env.<mode> into the build, and
|
||||
# .env.<mode>.local provides the deployment details (DEPLOY_HOST, DEPLOY_DIR,
|
||||
# DEPLOY_SERVICE) plus the Cloudflare credentials. The remote step runs in a
|
||||
# login shell so the user's PATH (npm, nvm…) applies.
|
||||
deploy-ssr mode:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[ -f .env.{{mode}}.local ] || { echo "Missing .env.{{mode}}.local"; exit 1; }
|
||||
set -a; source .env.{{mode}}.local; set +a
|
||||
: "${DEPLOY_HOST:?DEPLOY_HOST missing in .env.{{mode}}.local}"
|
||||
: "${DEPLOY_DIR:?DEPLOY_DIR missing in .env.{{mode}}.local}"
|
||||
: "${DEPLOY_SERVICE:?DEPLOY_SERVICE missing in .env.{{mode}}.local}"
|
||||
PUBLIC_SSR=yes npm run build -- --mode {{mode}}
|
||||
rsync -av --delete --progress --exclude node_modules build/ "$DEPLOY_HOST:$DEPLOY_DIR/build/"
|
||||
rsync -av package.json package-lock.json "$DEPLOY_HOST:$DEPLOY_DIR/"
|
||||
rsync -av .env.{{mode}} "$DEPLOY_HOST:$DEPLOY_DIR/.env"
|
||||
ssh "$DEPLOY_HOST" "\$SHELL -l -c 'cd $DEPLOY_DIR && npm ci --omit=dev && sudo systemctl restart $DEPLOY_SERVICE'"
|
||||
just purge-web-cache {{mode}}
|
||||
|
||||
purge-web-cache mode="production":
|
||||
#!/usr/bin/env bash
|
||||
if [ -f .env.{{mode}}.local ]; then set -a; source .env.{{mode}}.local; set +a; fi
|
||||
echo -e "\nPurging Cloudflare cache... for zone ${CF_ZONE_ID:-<unset>}"
|
||||
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID:-}/purge_cache" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN:-}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything": true}' \
|
||||
| jq -r 'if .success then "✅ Cache purged successfully" else "‼️ Error: " + (.errors[0].message // "Unknown error") end'
|
||||
|
|
|
|||
794
package-lock.json
generated
794
package-lock.json
generated
|
|
@ -10,12 +10,14 @@
|
|||
"dependencies": {
|
||||
"@djot/djot": "^0.3.2",
|
||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||
"nstart-modal": "^2.1.0",
|
||||
"uqr": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
|
|
@ -373,6 +375,23 @@
|
|||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
|
|
@ -740,6 +759,484 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
|
|
@ -793,14 +1290,21 @@
|
|||
"acorn": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-auto": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
||||
"node_modules/@sveltejs/adapter-node": {
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz",
|
||||
"integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"@sveltejs/kit": "^2.0.0"
|
||||
"@sveltejs/kit": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-static": {
|
||||
|
|
@ -1199,9 +1703,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -1222,6 +1726,13 @@
|
|||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
|
|
@ -1568,6 +2079,13 @@
|
|||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
|
|
@ -1672,6 +2190,16 @@
|
|||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
|
|
@ -1915,6 +2443,13 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"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_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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
|
|
@ -2063,6 +2608,19 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
|
|
@ -2089,6 +2647,22 @@
|
|||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -2112,6 +2686,13 @@
|
|||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
|
|
@ -2202,9 +2783,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2218,23 +2799,23 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2253,9 +2834,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2274,9 +2855,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2295,9 +2876,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2316,9 +2897,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -2337,9 +2918,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2358,9 +2939,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2379,9 +2960,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2400,9 +2981,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2421,9 +3002,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2442,9 +3023,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2590,6 +3171,11 @@
|
|||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
|
|
@ -2671,6 +3257,13 @@
|
|||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -2968,6 +3561,28 @@
|
|||
"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": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
|
|
@ -3083,6 +3744,19 @@
|
|||
"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": {
|
||||
"version": "5.55.5",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
||||
|
|
@ -3355,6 +4029,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"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",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
|
|
@ -41,6 +42,11 @@
|
|||
"dependencies": {
|
||||
"@djot/djot": "^0.3.2",
|
||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||
"nstart-modal": "^2.1.0",
|
||||
"uqr": "^0.1.3"
|
||||
},
|
||||
"overrides": {
|
||||
"lightningcss": "^1.33.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/app.d.ts
vendored
20
src/app.d.ts
vendored
|
|
@ -1,10 +1,28 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type {
|
||||
ForumShell,
|
||||
OverviewSnapshot,
|
||||
Profiles,
|
||||
ThreadsSnapshot,
|
||||
} from "$lib/forum/snapshot";
|
||||
import type { ThreadDetail } from "$lib/forum/thread";
|
||||
|
||||
declare global {
|
||||
// Injected by vite.config.ts from PUBLIC_SSR
|
||||
const __SQUALK_SSR__: boolean;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// Server-rendered snapshots; null in the browser and in client-only builds
|
||||
interface PageData {
|
||||
shell?: ForumShell | null;
|
||||
profiles?: Profiles | null;
|
||||
thread?: ThreadDetail | null;
|
||||
threads?: ThreadsSnapshot | null;
|
||||
overview?: OverviewSnapshot | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
|
|
|||
109
src/lib/access.svelte.ts
Normal file
109
src/lib/access.svelte.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
import { ensureForumRelay, forumRelayInfo } from "$lib/relay";
|
||||
import type { ForumRelayInfo } from "$lib/relay";
|
||||
|
||||
// Whether the forum relay will serve this visitor at all. A relay can refuse
|
||||
// every read (auth-required for anonymous visitors, restricted for logged-in
|
||||
// non-members, blocked for bans), in which case the app would otherwise just
|
||||
// look empty; the layout shows a gate page instead until a probe comes back
|
||||
// open.
|
||||
export type RelayAccess =
|
||||
| "checking"
|
||||
| "open"
|
||||
| "auth-required"
|
||||
| "restricted"
|
||||
| "blocked";
|
||||
|
||||
let state = $state<RelayAccess>("checking");
|
||||
let message = $state<string | null>(null);
|
||||
let info = $state<ForumRelayInfo | null>(null);
|
||||
// Bumped when a probe completes, so listeners can react to every result even
|
||||
// when the state itself didn't change (open → open after a login).
|
||||
let probes = $state(0);
|
||||
|
||||
export const relayAccess = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get message() {
|
||||
return message;
|
||||
},
|
||||
get info() {
|
||||
return info;
|
||||
},
|
||||
get probes() {
|
||||
return probes;
|
||||
},
|
||||
};
|
||||
|
||||
// Sends a throwaway REQ and reads the CLOSED reason: nostr-tools' query helpers
|
||||
// discard it, and it is the only cross-relay signal for a read gate. No `#h`
|
||||
// in the filter, so a private group's per-group auth-required (handled by the
|
||||
// room itself) can't masquerade as a relay-wide one.
|
||||
function probeRead(relay: AbstractRelay): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (reason: string | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sub.close();
|
||||
} catch {}
|
||||
resolve(reason);
|
||||
};
|
||||
const sub = relay.subscribe([{ kinds: [39000], limit: 1 }], {
|
||||
onevent() {},
|
||||
oneose() {
|
||||
finish(null);
|
||||
},
|
||||
onclose(reason) {
|
||||
finish(reason);
|
||||
},
|
||||
});
|
||||
setTimeout(() => finish(null), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
function classify(reason: string | null): RelayAccess {
|
||||
const r = (reason ?? "").toLowerCase();
|
||||
if (r.startsWith("auth-required:")) return "auth-required";
|
||||
if (r.startsWith("restricted:")) return "restricted";
|
||||
if (r.startsWith("blocked:")) return "blocked";
|
||||
return "open";
|
||||
}
|
||||
|
||||
let inFlight: Promise<RelayAccess> | null = null;
|
||||
|
||||
// Re-evaluates access for the current signer. Concurrent calls share one probe.
|
||||
// An anonymous visitor on a relay whose NIP-11 declares auth_required is gated
|
||||
// without waiting for the relay to refuse a request.
|
||||
export function probeRelayAccess(): Promise<RelayAccess> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const relayInfo = await forumRelayInfo();
|
||||
info = relayInfo;
|
||||
let next: RelayAccess;
|
||||
let reason: string | null = null;
|
||||
if (!auth.signer && relayInfo.authRequired) {
|
||||
next = "auth-required";
|
||||
} else {
|
||||
try {
|
||||
reason = await probeRead(await ensureForumRelay());
|
||||
} catch {
|
||||
reason = null; // connection failure: the pages surface their own errors
|
||||
}
|
||||
next = classify(reason);
|
||||
}
|
||||
state = next;
|
||||
message =
|
||||
next === "open" ? null : (reason?.replace(/^[a-z-]+:\s*/i, "") ?? null);
|
||||
probes++;
|
||||
return next;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
|
@ -24,14 +24,20 @@ export const autogrow: Action<
|
|||
const vPad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||
const vBorder =
|
||||
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||
const min = lh * Math.max(node.rows, 1) + vPad + vBorder;
|
||||
const max = lh * maxRows + vPad + vBorder;
|
||||
node.style.height = "auto";
|
||||
const next = Math.min(node.scrollHeight, max);
|
||||
// scrollHeight is 0 while hidden (display:none); never go below `rows`.
|
||||
const next = Math.min(Math.max(node.scrollHeight, min), max);
|
||||
node.style.height = `${next}px`;
|
||||
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
node.addEventListener("input", resize);
|
||||
// Re-measure when the textarea becomes visible or changes width.
|
||||
const ro =
|
||||
typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
|
||||
ro?.observe(node);
|
||||
resize();
|
||||
|
||||
return {
|
||||
|
|
@ -43,6 +49,7 @@ export const autogrow: Action<
|
|||
},
|
||||
destroy() {
|
||||
node.removeEventListener("input", resize);
|
||||
ro?.disconnect();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { page } from "$app/state";
|
||||
import { MODE } from "$lib/config";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { fetchRoomAdmins } from "$lib/forum/groups";
|
||||
|
||||
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
||||
let byRoom = $state<Record<string, string[]>>({});
|
||||
let loaded = $state(false);
|
||||
let loadedKey = "";
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const roomAdminsStore = {
|
||||
get byRoom() {
|
||||
return byRoom;
|
||||
return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {});
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -21,11 +24,11 @@ export const roomAdminsStore = {
|
|||
export const adminPubkeys = {
|
||||
get list(): string[] {
|
||||
return MODE === "full"
|
||||
? [...new Set(Object.values(byRoom).flat())]
|
||||
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
|
||||
: (groupStore.data?.admins ?? []);
|
||||
},
|
||||
get loaded(): boolean {
|
||||
return MODE === "full" ? loaded : groupStore.loaded;
|
||||
return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -37,7 +40,7 @@ export function isGroupAdmin(
|
|||
): boolean {
|
||||
if (!pubkey || !groupId) return false;
|
||||
return MODE === "full"
|
||||
? (byRoom[groupId]?.includes(pubkey) ?? false)
|
||||
? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false)
|
||||
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
||||
}
|
||||
|
||||
|
|
@ -48,17 +51,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
|
|||
loadedKey = key;
|
||||
|
||||
try {
|
||||
const events = await queryForum({
|
||||
kinds: [39001],
|
||||
"#d": roomIds,
|
||||
});
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const e of events) {
|
||||
const d = e.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (!d) continue;
|
||||
map[d] = e.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
|
||||
}
|
||||
byRoom = map;
|
||||
byRoom = await fetchRoomAdmins(queryForum, roomIds);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
<svg width="128" height="128" version="1.1" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<title>Basspistol Icon</title>
|
||||
<path d="m121.15 78.333q0 18.096-16.842 32.608-16.841 14.512-35.295 14.512-8.0624 0-15.587-3.0458-7.3458-3.0458-12.004-6.6291-4.6583-3.5833-9.8541-6.6291-5.0166-3.0458-8.7791-3.0458-3.5833 0-6.6291 2.15-2.8666 2.15-3.0458 5.7333h-3.0458q0.17912-10.212 9.3166-17.558 9.3166-7.3458 19.887-7.5249 7.1666-7.7041 7.1666-28.666v-27.591q0-4.4791-0.35837-7.5249-0.17912-3.225-1.2541-7.7041-0.89588-4.4791-3.5833-6.8083-2.6875-2.5083-6.8083-2.5083-4.3 0-6.8083 3.5833-2.3292 3.5833-2.3292 8.0624 0 5.0166 5.7333 15.229 5.7333 10.033 5.7333 16.842 0 15.946-25.8 24.008l-1.2541-1.9708q8.7791-3.9416 8.7791-10.392 0-4.1208-5.9124-14.154-5.7333-10.033-5.7333-18.454 0-12.362 11.467-20.246 11.646-8.0624 24.725-8.0624 18.454 0 25.8 14.692l21.679-14.692q9.8541 0.53749 17.379 5.7333 7.5249 5.0166 7.5249 14.333 0 8.7791-6.2708 14.692-6.0916 5.9124-15.587 8.9583 12.9 0.7166 20.246 9.8541 7.3458 8.9582 7.3458 22.216zm-27.412-44.791q0-6.4499-3.5833-11.287-3.5833-5.0166-9.6749-5.0166-5.0166 0-10.571 5.1958 1.2541 10.571 1.2541 25.083 0 3.7625 0 8.9582 9.3166-2.8666 15.946-8.5999 6.6291-5.9124 6.6291-14.333zm7.5249 61.274q0-10.212-8.5999-21.858-8.4208-11.646-18.096-11.646-2.3291 0-3.7625 0.71661-0.7166 6.0916-3.5833 10.75-2.6875 4.4791-5.3749 6.4499-2.5083 1.9708-8.4207 5.5541-5.7333 3.5833-8.5999 5.5541 6.2708 1.7917 21.141 11.646 15.05 9.6749 20.783 9.6749 6.6291 0 10.571-5.0166 3.9416-5.0166 3.9416-11.825z" fill="#7fff00" stroke-width="4.4791" style="white-space:pre"/>
|
||||
<metadata>
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title>Basspistol Icon</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -4,6 +4,14 @@ import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core";
|
|||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
||||
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
||||
import {
|
||||
connectBunker,
|
||||
restoreBunker,
|
||||
startNostrConnect,
|
||||
type BunkerSession,
|
||||
type NostrConnect,
|
||||
} from "$lib/bunker";
|
||||
import type { BunkerSigner } from "@nostr/tools/nip46";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -18,6 +26,8 @@ export type Signer = {
|
|||
|
||||
let user = $state<NostrUser | null>(null);
|
||||
let signer = $state<Signer | null>(null);
|
||||
// Kept outside $state (see makeExtensionSigner) so it can be closed on logout
|
||||
let bunker: BunkerSigner | null = null;
|
||||
let loginModalOpen = $state(false);
|
||||
// Optional action to run once login succeeds, so an intent like "post" started
|
||||
// while logged out resumes (login => join => composer) instead of being dropped.
|
||||
|
|
@ -45,6 +55,8 @@ export const auth = {
|
|||
const PUBKEY_KEY = "nostr_pubkey";
|
||||
const METHOD_KEY = "nostr_login_method";
|
||||
const NSEC_KEY = "nostr_nsec";
|
||||
const BUNKER_KEY = "nostr_bunker";
|
||||
const BUNKER_CLIENT_KEY = "nostr_bunker_client_key";
|
||||
|
||||
export function openLogin(after?: () => void) {
|
||||
afterLogin = after ?? null;
|
||||
|
|
@ -94,6 +106,30 @@ function makeExtensionSigner(): Signer {
|
|||
};
|
||||
}
|
||||
|
||||
function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer {
|
||||
return {
|
||||
async getPublicKey() {
|
||||
return pubkey;
|
||||
},
|
||||
async signEvent(template) {
|
||||
return remote.signEvent(template);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function closeBunker() {
|
||||
bunker?.close().catch(() => {});
|
||||
bunker = null;
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
localStorage.removeItem(PUBKEY_KEY);
|
||||
localStorage.removeItem(METHOD_KEY);
|
||||
localStorage.removeItem(NSEC_KEY);
|
||||
localStorage.removeItem(BUNKER_KEY);
|
||||
localStorage.removeItem(BUNKER_CLIENT_KEY);
|
||||
}
|
||||
|
||||
async function setUser(pubkey: string) {
|
||||
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
||||
user = await loadNostrUser(pubkey);
|
||||
|
|
@ -105,10 +141,11 @@ export async function loginWithExtension() {
|
|||
throw new Error("No Nostr extension found");
|
||||
}
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
closeBunker();
|
||||
signer = makeExtensionSigner();
|
||||
clearStoredSession();
|
||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||
localStorage.setItem(METHOD_KEY, "extension");
|
||||
localStorage.removeItem(NSEC_KEY);
|
||||
await setUser(pubkey);
|
||||
sessionEpoch++;
|
||||
runAfterLogin();
|
||||
|
|
@ -137,7 +174,9 @@ export async function loginWithNsec(input: string) {
|
|||
const sk = parseSecretKey(input);
|
||||
const nsec = nip19.nsecEncode(sk);
|
||||
const pubkey = getPublicKey(sk);
|
||||
closeBunker();
|
||||
signer = makeNsecSigner(sk);
|
||||
clearStoredSession();
|
||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||
localStorage.setItem(METHOD_KEY, "nsec");
|
||||
localStorage.setItem(NSEC_KEY, nsec);
|
||||
|
|
@ -146,12 +185,47 @@ export async function loginWithNsec(input: string) {
|
|||
runAfterLogin();
|
||||
}
|
||||
|
||||
async function loginWithBunkerSession(session: BunkerSession) {
|
||||
closeBunker();
|
||||
bunker = session.signer;
|
||||
signer = makeBunkerSigner(session.signer, session.pubkey);
|
||||
clearStoredSession();
|
||||
localStorage.setItem(PUBKEY_KEY, session.pubkey);
|
||||
localStorage.setItem(METHOD_KEY, "bunker");
|
||||
localStorage.setItem(BUNKER_KEY, session.bunkerUrl);
|
||||
localStorage.setItem(BUNKER_CLIENT_KEY, session.clientSecretKey);
|
||||
await setUser(session.pubkey);
|
||||
sessionEpoch++;
|
||||
runAfterLogin();
|
||||
}
|
||||
|
||||
// Pasted bunker:// URL or a NIP-05 that advertises one
|
||||
export async function loginWithBunker(input: string) {
|
||||
await loginWithBunkerSession(await connectBunker(input));
|
||||
}
|
||||
|
||||
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
|
||||
// waiting when the user leaves the view.
|
||||
// `onAck` reports the signer's first reply so the UI can show progress while
|
||||
// the session is finalized.
|
||||
export function loginWithNostrConnect(onAck?: () => void): {
|
||||
uri: string;
|
||||
done: Promise<void>;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const nc: NostrConnect = startNostrConnect(onAck);
|
||||
return {
|
||||
uri: nc.uri,
|
||||
done: nc.session.then(loginWithBunkerSession),
|
||||
cancel: nc.cancel,
|
||||
};
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
user = null;
|
||||
signer = null;
|
||||
localStorage.removeItem(PUBKEY_KEY);
|
||||
localStorage.removeItem(METHOD_KEY);
|
||||
localStorage.removeItem(NSEC_KEY);
|
||||
closeBunker();
|
||||
clearStoredSession();
|
||||
resetJoinState();
|
||||
sessionEpoch++;
|
||||
}
|
||||
|
|
@ -171,6 +245,16 @@ export async function restoreSession() {
|
|||
} catch {
|
||||
return;
|
||||
}
|
||||
} else if (method === "bunker") {
|
||||
const url = localStorage.getItem(BUNKER_KEY);
|
||||
const clientKey = localStorage.getItem(BUNKER_CLIENT_KEY);
|
||||
if (!url || !clientKey) return;
|
||||
try {
|
||||
bunker = await restoreBunker(url, clientKey);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
signer = makeBunkerSigner(bunker, pubkey);
|
||||
} else {
|
||||
signer = makeExtensionSigner();
|
||||
}
|
||||
|
|
|
|||
216
src/lib/bunker.ts
Normal file
216
src/lib/bunker.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import {
|
||||
BunkerSigner,
|
||||
createNostrConnectURI,
|
||||
parseBunkerInput,
|
||||
toBunkerURL,
|
||||
type BunkerPointer,
|
||||
} from "@nostr/tools/nip46";
|
||||
import { SimplePool } from "@nostr/tools/pool";
|
||||
import { NostrConnect as NostrConnectKind } from "@nostr/tools/kinds";
|
||||
import { decrypt, getConversationKey } from "@nostr/tools/nip44";
|
||||
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
|
||||
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
|
||||
import { NOSTRCONNECT_RELAYS } from "$lib/config";
|
||||
|
||||
// NIP-46 remote signing. Two ways in: the user pastes a bunker:// URL (or a
|
||||
// NIP-05 that advertises one), or the app shows a nostrconnect:// URI and the
|
||||
// signer app calls back. Either way the result is a live BunkerSigner plus the
|
||||
// data needed to reconnect silently next time: the bunker URL (secret
|
||||
// stripped) and the client key the conversation is bound to.
|
||||
|
||||
export type BunkerSession = {
|
||||
signer: BunkerSigner;
|
||||
pubkey: string;
|
||||
bunkerUrl: string;
|
||||
clientSecretKey: string;
|
||||
};
|
||||
|
||||
// The signer app usually lives on the phone, and on mobile the browser tab is
|
||||
// backgrounded while the user approves there, which can drop the socket. A
|
||||
// reconnecting pool keeps the subscriptions alive across that.
|
||||
const pool = new SimplePool({ enableReconnect: true });
|
||||
|
||||
// The approval happens in the signer app, so the tab is backgrounded and its
|
||||
// sockets can drop; nostr-tools only retries after a 10s backoff. Reconnect
|
||||
// the relays this flow has used as soon as the tab or the network is back,
|
||||
// and keep a shorter backoff for drops in between.
|
||||
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
|
||||
const usedRelays = new Set<string>();
|
||||
function touchRelays(urls: string[] = []) {
|
||||
for (const url of urls) usedRelays.add(url);
|
||||
for (const url of usedRelays) {
|
||||
pool
|
||||
.ensureRelay(url)
|
||||
.then((relay) => {
|
||||
relay.resubscribeBackoff = FAST_BACKOFF;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) touchRelays();
|
||||
});
|
||||
window.addEventListener("online", () => touchRelays());
|
||||
}
|
||||
|
||||
const signerParams = {
|
||||
pool,
|
||||
onauth: (url: string) => {
|
||||
window.open(url, "_blank");
|
||||
},
|
||||
};
|
||||
|
||||
function stripSecret(url: string): string {
|
||||
const u = new URL(url);
|
||||
u.searchParams.delete("secret");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export async function connectBunker(input: string): Promise<BunkerSession> {
|
||||
const bp = await parseBunkerInput(input.trim());
|
||||
if (!bp) throw new Error("Invalid bunker");
|
||||
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
|
||||
const sk = generateSecretKey();
|
||||
touchRelays(bp.relays);
|
||||
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||
try {
|
||||
await signer.connect();
|
||||
const pubkey = await signer.getPublicKey();
|
||||
return {
|
||||
signer,
|
||||
pubkey,
|
||||
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||
clientSecretKey: bytesToHex(sk),
|
||||
};
|
||||
} catch (e) {
|
||||
signer.close().catch(() => {});
|
||||
throw new Error(`Bunker error: ${describe(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type NostrConnect = {
|
||||
uri: string;
|
||||
// Resolves once a signer answers the URI; rejects on abort or relay close
|
||||
session: Promise<BunkerSession>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
// Waits for the signer's reply to a nostrconnect:// URI. Unlike the library's
|
||||
// helper, the filter has no `limit: 0`, so a reply published while the tab was
|
||||
// offline is replayed once the relay reconnects.
|
||||
function waitForConnect(
|
||||
sk: Uint8Array,
|
||||
secret: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<BunkerPointer> {
|
||||
const clientPubkey = getPublicKey(sk);
|
||||
touchRelays(NOSTRCONNECT_RELAYS);
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sub.close();
|
||||
fn();
|
||||
};
|
||||
const sub = pool.subscribe(
|
||||
NOSTRCONNECT_RELAYS,
|
||||
{
|
||||
kinds: [NostrConnectKind],
|
||||
"#p": [clientPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
{
|
||||
onevent: (event) => {
|
||||
let reply: { result?: string; error?: string };
|
||||
try {
|
||||
const key = getConversationKey(sk, event.pubkey);
|
||||
reply = JSON.parse(decrypt(event.content, key));
|
||||
} catch (e) {
|
||||
console.warn("nostrconnect: undecryptable reply", e);
|
||||
return;
|
||||
}
|
||||
if (reply.result === secret) {
|
||||
finish(() =>
|
||||
resolve({
|
||||
pubkey: event.pubkey,
|
||||
relays: NOSTRCONNECT_RELAYS,
|
||||
secret,
|
||||
}),
|
||||
);
|
||||
} else if (reply.error) {
|
||||
finish(() => reject(new Error(`Signer refused: ${reply.error}`)));
|
||||
} else {
|
||||
finish(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Signer replied "${reply.result}" instead of the secret`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onclose: (reasons) =>
|
||||
finish(() =>
|
||||
reject(new Error(`Relay closed: ${reasons.join("; ")}`)),
|
||||
),
|
||||
},
|
||||
);
|
||||
// Closes the subscription on every relay; the pool's own abort handling
|
||||
// only reaches the last one
|
||||
signal.addEventListener("abort", () =>
|
||||
finish(() => reject(new Error("Cancelled"))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// `onAck` fires the moment the signer answers the URI, before the follow-up
|
||||
// round-trip that completes the session — the point where "waiting for the
|
||||
// signer" becomes "finishing up".
|
||||
export function startNostrConnect(onAck?: () => void): NostrConnect {
|
||||
const sk = generateSecretKey();
|
||||
const secret = Math.random().toString(36).substring(2, 10);
|
||||
const uri = createNostrConnectURI({
|
||||
clientPubkey: getPublicKey(sk),
|
||||
relays: NOSTRCONNECT_RELAYS,
|
||||
secret,
|
||||
name: location.host,
|
||||
url: location.origin,
|
||||
});
|
||||
const abort = new AbortController();
|
||||
const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => {
|
||||
onAck?.();
|
||||
// The signer already accepted this client key; no connect round-trip
|
||||
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||
try {
|
||||
const pubkey = await signer.getPublicKey();
|
||||
return {
|
||||
signer,
|
||||
pubkey,
|
||||
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||
clientSecretKey: bytesToHex(sk),
|
||||
};
|
||||
} catch (e) {
|
||||
signer.close().catch(() => {});
|
||||
throw new Error(`Bunker error: ${describe(e)}`);
|
||||
}
|
||||
});
|
||||
return { uri, session, cancel: () => abort.abort() };
|
||||
}
|
||||
|
||||
// Rebuilds the signer from stored data without a connect round-trip: the
|
||||
// bunker already knows this client key, so the first sign request is enough.
|
||||
export async function restoreBunker(
|
||||
bunkerUrl: string,
|
||||
clientSecretKey: string,
|
||||
): Promise<BunkerSigner> {
|
||||
const bp = await parseBunkerInput(bunkerUrl);
|
||||
if (!bp) throw new Error("Invalid bunker");
|
||||
touchRelays(bp.relays);
|
||||
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Event } from "@nostr/tools";
|
||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import { RELAY_URL } from "$lib/config";
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import { tokenizeChat } from "$lib/linkify";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import {
|
||||
resolveThreadRef,
|
||||
threadRefHref,
|
||||
|
|
@ -39,9 +40,7 @@
|
|||
<span class="break-words whitespace-pre-wrap"
|
||||
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
||||
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
||||
href="https://njump.me/{t.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={profilePath(t.pubkey)}
|
||||
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||
>{:else if t.type === "entity"}{@const ref = t.id
|
||||
? resolvedThreads[t.id]
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@
|
|||
} from "$lib/moderation.svelte";
|
||||
import {
|
||||
withJoin,
|
||||
joinState,
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
} from "$lib/join.svelte";
|
||||
import { activeGroup } from "$lib/active.svelte";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
});
|
||||
|
||||
function onJoinToChat() {
|
||||
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||
}
|
||||
|
||||
function requestDeleteMessage(msg: ChatMessageData) {
|
||||
|
|
@ -250,8 +251,11 @@
|
|||
}
|
||||
|
||||
// Anchor to bottom on new messages, unless user is reading older ones.
|
||||
// Also runs when the mobile panel becomes visible: while hidden the list
|
||||
// has no height, so earlier scrolls were no-ops.
|
||||
$effect(() => {
|
||||
messages.length;
|
||||
mobileActive;
|
||||
if (!listEl || userScrolledUp) return;
|
||||
tick().then(() => {
|
||||
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
||||
|
|
@ -348,21 +352,30 @@
|
|||
{/if}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a
|
||||
href={profilePath(msg.pubkey)}
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if author.picture}
|
||||
<img
|
||||
src={author.picture}
|
||||
alt=""
|
||||
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
||||
class="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
{author.name[0].toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>{author.name}</span
|
||||
</a>
|
||||
<a
|
||||
href={profilePath(msg.pubkey)}
|
||||
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>{author.name}</a
|
||||
>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="relative">
|
||||
|
|
@ -478,7 +491,7 @@
|
|||
{/if}
|
||||
{#if !auth.user}
|
||||
<button
|
||||
onclick={openLogin}
|
||||
onclick={() => openLogin()}
|
||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Login to chat
|
||||
|
|
@ -486,9 +499,10 @@
|
|||
{:else if joinToChat}
|
||||
<button
|
||||
onclick={onJoinToChat}
|
||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
Join to chat
|
||||
{joinState.busy ? "Joining…" : "Join to chat"}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
type Author,
|
||||
} from "$lib/components/ThreadItem.svelte";
|
||||
import SortToggle from "$lib/components/SortToggle.svelte";
|
||||
import Meta from "$lib/components/Meta.svelte";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { MODE } from "$lib/config";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { openDraft } from "$lib/draft.svelte";
|
||||
|
|
@ -18,7 +22,8 @@
|
|||
import {
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
withJoin,
|
||||
joinState,
|
||||
} from "$lib/join.svelte";
|
||||
import { sortPref } from "$lib/sort.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
|
@ -44,6 +49,12 @@
|
|||
if (urlSort) sortPref.value = urlSort;
|
||||
});
|
||||
|
||||
const about = $derived(
|
||||
MODE === "full"
|
||||
? (groupsStore.list.find((g) => g.id === groupId)?.about ?? "")
|
||||
: (groupStore.data?.about ?? ""),
|
||||
);
|
||||
|
||||
const flags = $derived(getGroupFlags(groupId));
|
||||
const member = $derived(membershipOf(groupId));
|
||||
|
||||
|
|
@ -71,12 +82,7 @@
|
|||
openLogin(onNewTopic);
|
||||
return;
|
||||
}
|
||||
await ensureMembershipChecked(groupId);
|
||||
if (membershipOf(groupId) !== "member") {
|
||||
openJoinModal(groupId, openDraft);
|
||||
return;
|
||||
}
|
||||
openDraft();
|
||||
await withJoin(groupId, openDraft);
|
||||
}
|
||||
|
||||
function onPrivateJoin() {
|
||||
|
|
@ -84,7 +90,7 @@
|
|||
openLogin();
|
||||
return;
|
||||
}
|
||||
openJoinModal(groupId, () => loadThreads(groupId, sort));
|
||||
withJoin(groupId, () => loadThreads(groupId, sort));
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
|
|
@ -125,22 +131,36 @@
|
|||
const rows = $derived(
|
||||
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
|
||||
);
|
||||
|
||||
const empty = $derived(
|
||||
rows.length === 0 && !threadStore.loading && threadStore.exhausted,
|
||||
);
|
||||
|
||||
const fairlyNew = $derived(
|
||||
rows.length > 0 &&
|
||||
rows.length < 4 &&
|
||||
!threadStore.loading &&
|
||||
threadStore.exhausted,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
</svelte:head>
|
||||
<Meta {title} description={about} />
|
||||
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
||||
{#if !showPrivateGate && !checkingAccess}
|
||||
{#if !showPrivateGate && !checkingAccess && !empty}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={onNewTopic}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white md:px-6 md:text-sm"
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white disabled:opacity-50 md:px-6 md:text-sm"
|
||||
>
|
||||
{joinToPost ? "Join to post" : "New discussion"}
|
||||
{joinState.busy
|
||||
? "Joining…"
|
||||
: joinToPost
|
||||
? "Join to post"
|
||||
: "New discussion"}
|
||||
</button>
|
||||
<SortToggle {sort} />
|
||||
</div>
|
||||
|
|
@ -165,9 +185,27 @@
|
|||
</p>
|
||||
<button
|
||||
onclick={onPrivateJoin}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{auth.user ? "Request to join" : "Log in to join"}
|
||||
{joinState.busy
|
||||
? "Joining…"
|
||||
: auth.user
|
||||
? "Request to join"
|
||||
: "Log in to join"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if empty}
|
||||
<div class="flex flex-col items-center gap-6 py-24 text-center">
|
||||
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
|
||||
This room is still empty
|
||||
</p>
|
||||
<button
|
||||
onclick={onNewTopic}
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{joinState.busy ? "Joining…" : "Be the first one to open a discussion"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -194,6 +232,19 @@
|
|||
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if fairlyNew}
|
||||
<div class="flex flex-col items-center gap-6 py-16 text-center">
|
||||
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
|
||||
This room is fairly new
|
||||
</p>
|
||||
<button
|
||||
onclick={onNewTopic}
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{joinState.busy ? "Joining…" : "Start a new discussion"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if rows.length > 0}
|
||||
<p
|
||||
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
|
||||
import { joinState, closeJoinModal, submitJoinCode } from "$lib/join.svelte";
|
||||
import { tick } from "svelte";
|
||||
|
||||
let code = $state("");
|
||||
let codeInput = $state<HTMLInputElement | null>(null);
|
||||
// Pending approval hides the code field behind a discreet link, so a group
|
||||
// that doesn't hand out codes doesn't send people hunting for one.
|
||||
let codeRevealed = $state(false);
|
||||
|
||||
const step = $derived(joinState.modalStep);
|
||||
const showCode = $derived(
|
||||
step === "relay-code" ||
|
||||
(step === "group-code" && joinState.modalCodeHinted) ||
|
||||
codeRevealed,
|
||||
);
|
||||
const canOfferCode = $derived(
|
||||
step === "pending" || (step === "group-code" && !joinState.modalCodeHinted),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (joinState.modalOpen && joinState.needsCode) {
|
||||
if (!joinState.modalOpen) {
|
||||
code = "";
|
||||
codeRevealed = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (joinState.modalOpen && showCode) {
|
||||
tick().then(() => codeInput?.focus());
|
||||
}
|
||||
if (!joinState.modalOpen) code = "";
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
await submitJoin(code.trim() || undefined);
|
||||
await submitJoinCode(code.trim() || undefined);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
|
|
@ -56,15 +75,28 @@
|
|||
id="join-title"
|
||||
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{#if step === "relay-code"}
|
||||
Invite code required
|
||||
{:else if step === "pending"}
|
||||
Request sent
|
||||
{:else if step === "error"}
|
||||
Could not join
|
||||
{:else}
|
||||
Join this group
|
||||
{/if}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if joinState.needsCode}
|
||||
Join this group to participate. If you have an invite code, enter it
|
||||
below — otherwise just send the request.
|
||||
{#if step === "relay-code"}
|
||||
This relay only accepts members. Enter an invite code to join.
|
||||
{:else if step === "pending"}
|
||||
Your request to join has been sent. An admin needs to approve it
|
||||
before you can participate.
|
||||
{:else if step === "error"}
|
||||
The relay refused the request.
|
||||
{:else if joinState.modalCodeHinted}
|
||||
This group requires an invite code to join.
|
||||
{:else}
|
||||
Join this group to participate. Your request is sent to the relay and,
|
||||
where needed, approved by an admin.
|
||||
The group refused the join request.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
|
|
@ -77,13 +109,17 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if joinState.needsCode}
|
||||
<label for="join-code-input" class="sr-only">Invite code</label>
|
||||
{#if showCode}
|
||||
<label for="join-code-input" class="sr-only">
|
||||
{step === "relay-code" ? "Relay invite code" : "Group invite code"}
|
||||
</label>
|
||||
<input
|
||||
id="join-code-input"
|
||||
bind:this={codeInput}
|
||||
type="text"
|
||||
placeholder="Invite code (optional)"
|
||||
placeholder={step === "relay-code"
|
||||
? "Relay invite code"
|
||||
: "Group invite code"}
|
||||
bind:value={code}
|
||||
disabled={joinState.busy}
|
||||
autocomplete="off"
|
||||
|
|
@ -92,6 +128,14 @@
|
|||
onkeydown={(e) => e.key === "Enter" && onSubmit()}
|
||||
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||
/>
|
||||
{:else if canOfferCode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (codeRevealed = true)}
|
||||
class="mb-3 text-sm text-neutral-500 underline hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
>
|
||||
Have an invite code?
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
|
|
@ -103,18 +147,16 @@
|
|||
>
|
||||
Close
|
||||
</button>
|
||||
{#if showCode}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{joinState.busy
|
||||
? "Joining…"
|
||||
: joinState.needsCode
|
||||
? "Request access"
|
||||
: "Join"}
|
||||
{joinState.busy ? "Joining…" : "Join"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,25 +2,78 @@
|
|||
import {
|
||||
auth,
|
||||
closeLogin,
|
||||
loginWithBunker,
|
||||
loginWithExtension,
|
||||
loginWithNostrConnect,
|
||||
loginWithNsec,
|
||||
} from "$lib/auth.svelte";
|
||||
import { tick } from "svelte";
|
||||
import { TITLE, MODE } from "$lib/config";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { themeState } from "$lib/theme.svelte";
|
||||
import type { NstartModal } from "nstart-modal";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
let view = $state<"extension" | "nsec">("extension");
|
||||
type View = "extension" | "bunker" | "nsec";
|
||||
|
||||
let view = $state<View>("extension");
|
||||
let nsec = $state("");
|
||||
let bunkerUrl = $state("");
|
||||
let error = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
let copied = $state(false);
|
||||
let nsecInput = $state<HTMLInputElement | null>(null);
|
||||
let bunkerInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Nstart sign-up wizard (an iframe overlay). Built lazily and reused; it is
|
||||
// rebuilt only when the theme it was built for changes.
|
||||
let wizard: NstartModal | null = null;
|
||||
let wizardTheme: string | null = null;
|
||||
// The login dialog hides while the wizard is up and comes back on cancel
|
||||
let signingUp = $state(false);
|
||||
|
||||
const appName = $derived(
|
||||
TITLE ||
|
||||
(MODE === "simple" ? groupStore.data?.name : "") ||
|
||||
(typeof location !== "undefined" ? location.host : ""),
|
||||
);
|
||||
|
||||
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
|
||||
// `finalizing` flips when the signer first answers, so the QR gives way to
|
||||
// a progress state while the session completes.
|
||||
// $state.raw so identity checks against the raw object (connect === nc)
|
||||
// hold; the object is replaced wholesale, never mutated.
|
||||
let connect = $state.raw<ReturnType<typeof loginWithNostrConnect> | null>(
|
||||
null,
|
||||
);
|
||||
let finalizing = $state(false);
|
||||
const qrSvg = $derived(
|
||||
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
|
||||
);
|
||||
|
||||
const hasExtension = $derived(
|
||||
typeof window !== "undefined" && !!window.nostr,
|
||||
);
|
||||
|
||||
const titles: Record<View, string> = {
|
||||
extension: "Log in",
|
||||
bunker: "Log in with a bunker",
|
||||
nsec: "Log in with nsec",
|
||||
};
|
||||
|
||||
function stopConnect() {
|
||||
connect?.cancel();
|
||||
connect = null;
|
||||
finalizing = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stopConnect();
|
||||
view = "extension";
|
||||
nsec = "";
|
||||
bunkerUrl = "";
|
||||
error = null;
|
||||
copied = false;
|
||||
}
|
||||
|
||||
async function handleExtension() {
|
||||
|
|
@ -53,33 +106,151 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleBunker() {
|
||||
if (busy || !bunkerUrl.trim()) return;
|
||||
error = null;
|
||||
busy = true;
|
||||
try {
|
||||
await loginWithBunker(bunkerUrl);
|
||||
closeLogin();
|
||||
reset();
|
||||
} catch (e) {
|
||||
error =
|
||||
e instanceof Error ? e.message : "Failed to connect to the bunker";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startConnect() {
|
||||
stopConnect();
|
||||
const nc = loginWithNostrConnect(() => {
|
||||
if (connect === nc) finalizing = true;
|
||||
});
|
||||
connect = nc;
|
||||
nc.done
|
||||
.then(() => {
|
||||
if (connect !== nc) return;
|
||||
closeLogin();
|
||||
reset();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (connect !== nc) return; // cancelled by leaving the view
|
||||
connect = null;
|
||||
finalizing = false;
|
||||
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
|
||||
});
|
||||
}
|
||||
|
||||
async function copyUri() {
|
||||
if (!connect) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(connect.uri);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
error = "Could not copy to the clipboard";
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (busy) return;
|
||||
closeLogin();
|
||||
reset();
|
||||
}
|
||||
|
||||
// The accent color as Nstart wants it (hex, no #), so the wizard matches the
|
||||
// forum; env overrides are already applied to the CSS variable.
|
||||
function accentHex(): string | undefined {
|
||||
const v = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-accent")
|
||||
.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.slice(1) : undefined;
|
||||
}
|
||||
|
||||
async function handleSignup() {
|
||||
if (busy) return;
|
||||
error = null;
|
||||
stopConnect();
|
||||
const { NstartModal } = await import("nstart-modal");
|
||||
if (!wizard || wizardTheme !== themeState.theme) {
|
||||
wizard?.destroy();
|
||||
wizardTheme = themeState.theme;
|
||||
wizard = new NstartModal({
|
||||
baseUrl: "https://nstart.me",
|
||||
an: appName,
|
||||
aa: accentHex(),
|
||||
am: themeState.theme,
|
||||
// Only bunker:// or nsec come back; ncryptsec has no login here
|
||||
aac: true,
|
||||
onComplete: ({ nostrLogin }) => void completeSignup(nostrLogin),
|
||||
onCancel: () => {
|
||||
signingUp = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
signingUp = true;
|
||||
wizard.open();
|
||||
}
|
||||
|
||||
async function completeSignup(nostrLogin: string | null) {
|
||||
signingUp = false;
|
||||
if (!nostrLogin) {
|
||||
error = "Sign-up finished without a login credential";
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (nostrLogin.startsWith("bunker://")) {
|
||||
await loginWithBunker(nostrLogin);
|
||||
} else {
|
||||
await loginWithNsec(nostrLogin);
|
||||
}
|
||||
closeLogin();
|
||||
reset();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to login";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showNsecView() {
|
||||
stopConnect();
|
||||
view = "nsec";
|
||||
error = null;
|
||||
await tick();
|
||||
nsecInput?.focus();
|
||||
}
|
||||
|
||||
async function showBunkerView() {
|
||||
view = "bunker";
|
||||
error = null;
|
||||
startConnect();
|
||||
await tick();
|
||||
bunkerInput?.focus();
|
||||
}
|
||||
|
||||
function showExtensionView() {
|
||||
stopConnect();
|
||||
view = "extension";
|
||||
error = null;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!auth.loginModalOpen) return;
|
||||
if (!auth.loginModalOpen || signingUp) return;
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
stopConnect();
|
||||
wizard?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if auth.loginModalOpen}
|
||||
{#if auth.loginModalOpen && !signingUp}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -88,7 +259,7 @@
|
|||
onclick={onClose}
|
||||
></button>
|
||||
<div
|
||||
class="relative w-full max-w-sm rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
|
||||
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-title"
|
||||
|
|
@ -97,13 +268,16 @@
|
|||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{view === "extension" ? "Log in" : "Log in with nsec"}
|
||||
<h2
|
||||
id="login-title"
|
||||
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{titles[view]}
|
||||
</h2>
|
||||
|
||||
{#if error}
|
||||
|
|
@ -128,6 +302,14 @@
|
|||
No Nostr extension detected in this browser.
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={showBunkerView}
|
||||
disabled={busy}
|
||||
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Log in with a bunker
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={showNsecView}
|
||||
|
|
@ -135,6 +317,116 @@
|
|||
>
|
||||
Or log in using your nsec
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="my-5 border-t border-neutral-200 dark:border-neutral-700"
|
||||
></div>
|
||||
|
||||
<p
|
||||
class="mb-3 text-center text-sm text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
Don't have an account yet?
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignup}
|
||||
disabled={busy}
|
||||
class="border-accent text-accent hover:bg-accent/10 w-full rounded border px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
{:else if view === "bunker"}
|
||||
{#if finalizing}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-10"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600 dark:border-neutral-600 dark:border-t-neutral-300"
|
||||
></span>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Connected to your signer — finishing login…
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if connect}
|
||||
<a
|
||||
href={connect.uri}
|
||||
aria-label="Open in your signer app"
|
||||
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
|
||||
>
|
||||
{@html qrSvg}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyUri}
|
||||
aria-label={copied ? "Copied" : "Copy the connection string"}
|
||||
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-mono"
|
||||
>{connect.uri}</span
|
||||
>
|
||||
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
<p
|
||||
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
|
||||
aria-live="polite"
|
||||
>
|
||||
Scan or paste this in your signer app, then approve the
|
||||
connection.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 flex items-center gap-3" aria-hidden="true">
|
||||
<div
|
||||
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||
></div>
|
||||
<span class="text-xs text-neutral-400">or</span>
|
||||
<div
|
||||
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
for="bunker-input"
|
||||
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Paste a bunker URL
|
||||
</label>
|
||||
<input
|
||||
id="bunker-input"
|
||||
bind:this={bunkerInput}
|
||||
type="text"
|
||||
placeholder="bunker://…"
|
||||
bind:value={bunkerUrl}
|
||||
disabled={busy}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && handleBunker()}
|
||||
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||
/>
|
||||
<button
|
||||
onclick={handleBunker}
|
||||
disabled={busy || !bunkerUrl.trim()}
|
||||
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Connecting…" : "Log in"}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
A NIP-05 address that points to a bunker works too.
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={showExtensionView}
|
||||
disabled={busy}
|
||||
class="text-accent mt-4 block w-full text-center text-sm hover:underline disabled:opacity-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<label for="nsec-input" class="sr-only">nsec</label>
|
||||
<input
|
||||
|
|
@ -149,7 +441,7 @@
|
|||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
||||
class="focus:ring-accent w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
|
||||
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||
/>
|
||||
<button
|
||||
onclick={handleNsec}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
||||
import { loadRelayList } from "$lib/gadgets";
|
||||
import {
|
||||
profileStore,
|
||||
searchLocalProfiles,
|
||||
|
|
|
|||
64
src/lib/components/Meta.svelte
Normal file
64
src/lib/components/Meta.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script module lang="ts">
|
||||
import { TITLE, GROUP_ID, MODE } from "$lib/config";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
|
||||
// Forum name from the env, or the group name in simple mode.
|
||||
export function siteName(): string {
|
||||
return (
|
||||
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : "")
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: "website" | "article";
|
||||
image?: string;
|
||||
jsonLd?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
description = "",
|
||||
type = "website",
|
||||
image,
|
||||
jsonLd,
|
||||
}: Props = $props();
|
||||
|
||||
// Escape "<" so the JSON can never close the script tag early.
|
||||
const ld = $derived(
|
||||
jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : "",
|
||||
);
|
||||
|
||||
const site = $derived(siteName());
|
||||
const canonical = $derived(page.url.origin + page.url.pathname);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<link rel="canonical" href={canonical} />
|
||||
{#if description}
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
{/if}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
{#if site}
|
||||
<meta property="og:site_name" content={site} />
|
||||
{/if}
|
||||
{#if image}
|
||||
<meta property="og:image" content={image} />
|
||||
{/if}
|
||||
<meta
|
||||
name="twitter:card"
|
||||
content={image ? "summary_large_image" : "summary"}
|
||||
/>
|
||||
{#if ld}
|
||||
{@html `<script type="application/ld+json">${ld}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import {
|
||||
resolveThreadRef,
|
||||
threadRefHref,
|
||||
|
|
@ -310,7 +311,7 @@
|
|||
const entity = dest.slice(6).toLowerCase();
|
||||
try {
|
||||
nip19.decode(entity);
|
||||
return { type: "link", href: `https://njump.me/${entity}`, label };
|
||||
return { type: "link", href: `https://nostr.basspistol.org/${entity}`, label };
|
||||
} catch {
|
||||
return { type: "text", value: label };
|
||||
}
|
||||
|
|
@ -333,7 +334,6 @@
|
|||
case "str":
|
||||
return tokenizeInline(node.text);
|
||||
case "soft_break":
|
||||
return [{ type: "text", value: " " }];
|
||||
case "hard_break":
|
||||
return [{ type: "br" }];
|
||||
case "non_breaking_space":
|
||||
|
|
@ -611,11 +611,8 @@
|
|||
>
|
||||
{:else if inline.type === "mention"}
|
||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
|
||||
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
|
||||
>@{u?.shortName ?? inline.fallback}</a
|
||||
>
|
||||
{:else if inline.type === "thread-quote"}
|
||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||
|
|
@ -645,7 +642,7 @@
|
|||
>
|
||||
{:else}
|
||||
<a
|
||||
href="https://njump.me/{inline.entity}"
|
||||
href="https://nostr.basspistol.org/{inline.entity}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent break-all hover:underline">{inline.label}</a
|
||||
|
|
@ -700,7 +697,7 @@
|
|||
src={block.src}
|
||||
alt={block.alt}
|
||||
loading="lazy"
|
||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
||||
class="mx-auto block max-h-[80vh] w-full rounded object-contain"
|
||||
/>
|
||||
{:else if block.type === "blockquote"}
|
||||
<blockquote
|
||||
|
|
@ -773,7 +770,7 @@
|
|||
{/snippet}
|
||||
|
||||
<div
|
||||
class="prose max-w-none leading-5 text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
|
||||
class="prose max-w-none leading-5 wrap-anywhere text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
|
||||
>
|
||||
{@render renderBlocks(blocks)}
|
||||
</div>
|
||||
|
|
|
|||
106
src/lib/components/RelayGate.svelte
Normal file
106
src/lib/components/RelayGate.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { joinRelay, joinState } from "$lib/join.svelte";
|
||||
import { TITLE } from "$lib/config";
|
||||
|
||||
// The relay's NIP-11 name is readable even when nothing else is, so the
|
||||
// gate can still say which forum this is.
|
||||
const title = $derived(TITLE || relayAccess.info?.name || "Forum");
|
||||
const description = $derived(relayAccess.info?.description ?? "");
|
||||
const state = $derived(relayAccess.state);
|
||||
// A logged-in user still refused with auth-required means the handshake
|
||||
// failed rather than that they must log in; offer a retry instead.
|
||||
const authFailed = $derived(state === "auth-required" && !!auth.signer);
|
||||
|
||||
function onLogin() {
|
||||
openLogin(() => {
|
||||
probeRelayAccess();
|
||||
});
|
||||
}
|
||||
|
||||
function onJoin() {
|
||||
joinRelay(() => {
|
||||
probeRelayAccess();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<div class="flex justify-end p-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<main
|
||||
class="flex flex-1 flex-col items-center justify-center px-6 pb-24 text-center"
|
||||
>
|
||||
{#if relayAccess.info?.icon}
|
||||
<img
|
||||
src={relayAccess.info.icon}
|
||||
alt=""
|
||||
class="mb-6 h-20 w-20 rounded-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<h1 class="text-accent text-3xl font-semibold">{title}</h1>
|
||||
{#if description}
|
||||
<p class="mt-2 max-w-md text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 max-w-md" role="status" aria-live="polite">
|
||||
{#if state === "checking"}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Checking access…
|
||||
</p>
|
||||
{:else if authFailed}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
The relay did not accept your login.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => probeRelayAccess()}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
{:else if state === "auth-required"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
You need to log in to access this forum.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLogin}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{:else if state === "restricted"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
This forum is for members only.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onJoin}
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{joinState.busy ? "Joining…" : "Join this forum"}
|
||||
</button>
|
||||
{:else if state === "blocked"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
You can't access this forum.
|
||||
</p>
|
||||
{#if relayAccess.message}
|
||||
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{relayAccess.message}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
function select(r: SearchResult) {
|
||||
open = false;
|
||||
goto(`/thread/${r.threadId}`);
|
||||
goto(search.threadUrl(r.threadId));
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -21,13 +21,26 @@
|
|||
|
||||
function select(r: SearchResult) {
|
||||
closeSearch();
|
||||
goto(`/thread/${r.threadId}`);
|
||||
goto(search.threadUrl(r.threadId));
|
||||
}
|
||||
|
||||
// Global "/" opens the search from any page, unless typing somewhere else
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (searchModal.open) {
|
||||
if (e.key === "Escape") closeSearch();
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
return;
|
||||
}
|
||||
// Typing fast right after "/" can outrun the async focus; route the
|
||||
// keystroke into the input so no leading characters get lost
|
||||
if (
|
||||
e.key.length === 1 &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
document.activeElement !== inputEl
|
||||
)
|
||||
inputEl?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
{#each search.results as r, i (r.threadId)}
|
||||
<a
|
||||
id="{idBase}-{i}"
|
||||
href="/thread/{r.threadId}"
|
||||
href={search.threadUrl(r.threadId)}
|
||||
role="option"
|
||||
aria-selected={i === search.activeIndex}
|
||||
class="block rounded px-4 py-2 {i === search.activeIndex
|
||||
|
|
|
|||
|
|
@ -18,7 +18,38 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
|
|||
if (MODE === "simple" && !GROUP_ID) {
|
||||
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
||||
}
|
||||
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
|
||||
// Relays used to hand a nostrconnect:// URI to a remote signer (NIP-46).
|
||||
// Comma-separated override via PUBLIC_NOSTRCONNECT_RELAYS.
|
||||
export const NOSTRCONNECT_RELAYS: string[] = (
|
||||
env.PUBLIC_NOSTRCONNECT_RELAYS ||
|
||||
"wss://bucket.coracle.social,wss://relay.primal.net"
|
||||
)
|
||||
.split(",")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
|
||||
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
|
||||
export const SSR_ENABLED = __SQUALK_SSR__;
|
||||
// With SSR on, a client-side navigation also asks the server for the same
|
||||
// snapshot (without waiting for it) so the page is already cached for the
|
||||
// next refresh, shared link or crawler. Costs one server→relay query per
|
||||
// navigation; opt out with PUBLIC_SSR_WARM=no.
|
||||
export const SSR_WARM = SSR_ENABLED && env.PUBLIC_SSR_WARM !== "no";
|
||||
// Lifetime of server-rendered snapshots, in seconds. Within FRESH a cached
|
||||
// page is served as is; up to STALE it is still served at once but refreshed
|
||||
// in the background; beyond that it is fetched again before answering. Only
|
||||
// crawlers and cold refreshes see the snapshot — the browser always refetches
|
||||
// live data after hydration — so these trade first-paint staleness for speed.
|
||||
function seconds(raw: string | undefined, fallback: number): number {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
export const SSR_CACHE_FRESH = seconds(env.PUBLIC_SSR_CACHE_FRESH, 300);
|
||||
export const SSR_CACHE_STALE = seconds(env.PUBLIC_SSR_CACHE_STALE, 6 * 3600);
|
||||
// The same policy for a shared HTTP cache in front (Cloudflare honours it once
|
||||
// HTML caching is enabled). Browsers always revalidate (max-age=0) so a login
|
||||
// shows its content at once.
|
||||
export const CACHE_CONTROL = `public, max-age=0, s-maxage=${SSR_CACHE_FRESH}, stale-while-revalidate=${SSR_CACHE_STALE}`;
|
||||
// Requires a relay with NIP-50 support.
|
||||
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
||||
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
||||
|
|
@ -31,3 +62,14 @@ export const BLOSSOM_URL = (env.PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, "");
|
|||
// CSS variables defined in layout.css. Hover shades are derived in the layout.
|
||||
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
|
||||
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
|
||||
|
||||
// Favicon: any URL (absolute, or root-relative for a file in static/). The
|
||||
// default is a ring in the accent color (a circle with a transparent hole of
|
||||
// half its radius), inlined as an SVG data URI.
|
||||
const DEFAULT_ACCENT = "#e32a6d";
|
||||
export const FAVICON =
|
||||
env.PUBLIC_FAVICON ||
|
||||
"data:image/svg+xml," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill-rule="evenodd" fill="${ACCENT_COLOR || DEFAULT_ACCENT}" d="M16 0a16 16 0 1 0 0 32a16 16 0 1 0 0-32zM16 8a8 8 0 1 1 0 16a8 8 0 1 1 0-16z"/></svg>`,
|
||||
);
|
||||
|
|
|
|||
81
src/lib/forum/groups.ts
Normal file
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 { queryForum } from "$lib/relay";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { fetchGroup, type GroupMetadata } from "$lib/forum/groups";
|
||||
|
||||
export type GroupMetadata = {
|
||||
name: string;
|
||||
picture?: string;
|
||||
about?: string;
|
||||
isPrivate: boolean;
|
||||
isClosed: boolean;
|
||||
isRestricted: boolean;
|
||||
isHidden: boolean;
|
||||
admins: string[];
|
||||
};
|
||||
export type { GroupMetadata };
|
||||
|
||||
// NIP-29 access flags: private = members-only read, restricted = members-only
|
||||
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
|
||||
export type GroupFlags = {
|
||||
isPrivate: boolean;
|
||||
isClosed: boolean;
|
||||
|
|
@ -25,44 +16,24 @@ export type GroupFlags = {
|
|||
let group = $state<GroupMetadata | null>(null);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const groupStore = {
|
||||
get data() {
|
||||
return group;
|
||||
return group ?? page.data.shell?.group ?? null;
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadGroup() {
|
||||
try {
|
||||
const events = await queryForum({
|
||||
kinds: [39000, 39001],
|
||||
"#d": [GROUP_ID],
|
||||
});
|
||||
const event = events.find((e) => e.kind === 39000);
|
||||
if (!event) return;
|
||||
// Admins live in the NIP-29 kind 39001 event as `p` tags.
|
||||
const adminsEvent = events.find((e) => e.kind === 39001);
|
||||
const admins =
|
||||
adminsEvent?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
|
||||
group = {
|
||||
name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID,
|
||||
picture: event.tags.find((t) => t[0] === "picture")?.[1],
|
||||
about: event.tags.find((t) => t[0] === "about")?.[1],
|
||||
isPrivate: event.tags.some((t) => t[0] === "private"),
|
||||
isClosed: event.tags.some((t) => t[0] === "closed"),
|
||||
isRestricted: event.tags.some((t) => t[0] === "restricted"),
|
||||
isHidden: event.tags.some((t) => t[0] === "hidden"),
|
||||
admins,
|
||||
};
|
||||
group = (await fetchGroup(queryForum, GROUP_ID)) ?? group;
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A group's access flags from whichever store holds them: full mode keeps every
|
||||
// room in the groups list, simple mode has the single active group's metadata.
|
||||
export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||
if (MODE === "full") {
|
||||
const g = groupsStore.list.find((x) => x.id === groupId);
|
||||
|
|
@ -82,3 +53,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
|
|||
isHidden: group.isHidden,
|
||||
};
|
||||
}
|
||||
|
||||
// Display name for a group from whichever store holds it, falling back to the
|
||||
// id when metadata hasn't loaded.
|
||||
export function getGroupName(groupId: string): string {
|
||||
if (MODE === "full") {
|
||||
return groupsStore.list.find((x) => x.id === groupId)?.name ?? groupId;
|
||||
}
|
||||
return group?.name ?? groupId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,25 @@
|
|||
import { page } from "$app/state";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
|
||||
|
||||
export type GroupSummary = {
|
||||
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
|
||||
name: string;
|
||||
picture?: string;
|
||||
about?: string;
|
||||
createdAt: number;
|
||||
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
|
||||
};
|
||||
|
||||
// NIP-29 metadata markers we surface as room tags, in display order.
|
||||
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
|
||||
export type { GroupSummary };
|
||||
|
||||
let list = $state<GroupSummary[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const groupsStore = {
|
||||
get list() {
|
||||
return list;
|
||||
return loaded ? list : (page.data.shell?.groups ?? []);
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata
|
||||
// event per group, so an unfiltered query enumerates them all.
|
||||
export async function loadGroups() {
|
||||
try {
|
||||
const events = await queryForum({ kinds: [39000] });
|
||||
list = events
|
||||
.map((e) => {
|
||||
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? "";
|
||||
return {
|
||||
id,
|
||||
name: e.tags.find((t) => t[0] === "name")?.[1] ?? id,
|
||||
picture: e.tags.find((t) => t[0] === "picture")?.[1],
|
||||
about: e.tags.find((t) => t[0] === "about")?.[1],
|
||||
createdAt: e.created_at,
|
||||
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
|
||||
};
|
||||
})
|
||||
.filter((g) => g.id)
|
||||
// Oldest first.
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
list = await fetchGroups(queryForum);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,42 @@
|
|||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||
import type { Event } from "@nostr/tools/core";
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
|
||||
import { ensureForumRelay, publishForum } from "$lib/relay";
|
||||
import { getGroupFlags } from "$lib/group.svelte";
|
||||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
|
||||
import { getGroupName } from "$lib/group.svelte";
|
||||
import { showToast } from "$lib/toast.svelte";
|
||||
|
||||
type Membership = "member" | "guest";
|
||||
// "none": the relay doesn't support NIP-43, so no relay-level join exists.
|
||||
type RelayMembership = Membership | "none";
|
||||
|
||||
// Per-group membership, resolved lazily and cached reactively so compose
|
||||
// actions can be gated before the user writes anything. Keyed by pubkey so a
|
||||
// Writing to a group can be gated at two levels: the relay itself (NIP-43) and
|
||||
// the group (NIP-29). Neither gate is advertised up front, so membership is
|
||||
// probed by reading the relay's membership events and, failing that, by trying
|
||||
// to join and reacting to the rejection. Both caches are keyed by pubkey so a
|
||||
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
|
||||
// "guest" cached before auth.user was populated; the read stays a pure getter
|
||||
// (no state writes inside a derivation).
|
||||
// "guest" cached before auth.user was populated.
|
||||
let membership = $state<Record<string, Membership>>({});
|
||||
let relayMembership = $state<Record<string, RelayMembership>>({});
|
||||
const checking = new Map<string, Promise<void>>();
|
||||
|
||||
// The modal only appears when a relay refused a step and needs user input.
|
||||
// - relay-code: the relay requires an invite code (NIP-43 claim)
|
||||
// - group-code: the group refused the join; a code may be needed
|
||||
// - pending: the group accepted the request but membership isn't granted yet
|
||||
// (admin approval); an invite code, if any, can still be tried
|
||||
// - error: a terminal refusal (e.g. banned); nothing the user can enter helps
|
||||
export type JoinStep = "relay-code" | "group-code" | "pending" | "error";
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let modalStep = $state<JoinStep>("group-code");
|
||||
let modalGroup = $state<string | null>(null);
|
||||
let modalError = $state<string | null>(null);
|
||||
// A rejection message that mentions a code makes the code field the primary
|
||||
// affordance rather than a discreet fallback.
|
||||
let modalCodeHinted = $state(false);
|
||||
let busy = $state(false);
|
||||
// Continuation to run once the join succeeds (open the composer, focus the
|
||||
// Continuation to run once access is granted (open the composer, focus the
|
||||
// reply box, reload a now-visible feed, or retry the original post).
|
||||
let onJoined: (() => void | Promise<void>) | null = null;
|
||||
|
||||
|
|
@ -29,26 +47,27 @@ export const joinState = {
|
|||
get modalOpen() {
|
||||
return modalOpen;
|
||||
},
|
||||
get modalStep() {
|
||||
return modalStep;
|
||||
},
|
||||
get modalError() {
|
||||
return modalError;
|
||||
},
|
||||
get modalCodeHinted() {
|
||||
return modalCodeHinted;
|
||||
},
|
||||
get busy() {
|
||||
return busy;
|
||||
},
|
||||
// Closed groups (and a globally configured invite gate) can't be self-joined,
|
||||
// so the modal collects an invite code; open groups just confirm.
|
||||
get needsCode() {
|
||||
if (JOINCODE_REQUIRED) return true;
|
||||
const f = modalGroup ? getGroupFlags(modalGroup) : null;
|
||||
return !!f?.isClosed;
|
||||
},
|
||||
};
|
||||
|
||||
export function resetJoinState() {
|
||||
membership = {};
|
||||
relayMembership = {};
|
||||
modalOpen = false;
|
||||
modalGroup = null;
|
||||
modalError = null;
|
||||
modalCodeHinted = false;
|
||||
busy = false;
|
||||
onJoined = null;
|
||||
}
|
||||
|
|
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
|
|||
return p;
|
||||
}
|
||||
|
||||
type SubFilter = Parameters<AbstractRelay["subscribe"]>[0][number];
|
||||
|
||||
// Collects every event matching the filter until EOSE/close/timeout.
|
||||
function queryEvents(
|
||||
relay: AbstractRelay,
|
||||
filter: SubFilter,
|
||||
timeoutMs = 3000,
|
||||
): Promise<Event[]> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const events: Event[] = [];
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sub.close();
|
||||
} catch {}
|
||||
resolve(events);
|
||||
};
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(e) {
|
||||
events.push(e);
|
||||
},
|
||||
oneose: finish,
|
||||
onclose: finish,
|
||||
});
|
||||
setTimeout(finish, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Existence check for a single matching event (resolves true on first event,
|
||||
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
|
||||
// false on EOSE/close/timeout).
|
||||
function queryHasMatch(
|
||||
relay: AbstractRelay,
|
||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
||||
filter: SubFilter,
|
||||
timeoutMs = 3000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -120,39 +169,34 @@ function queryHasMatch(
|
|||
// Newest `created_at` among events matching the filter, or null if none. We
|
||||
// don't trust the relay to honour limit ordering, so we keep the max across all
|
||||
// returned events (the #p filter keeps the set tiny).
|
||||
function latestCreatedAt(
|
||||
async function latestCreatedAt(
|
||||
relay: AbstractRelay,
|
||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
||||
timeoutMs = 3000,
|
||||
filter: SubFilter,
|
||||
): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let latest: number | null = null;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sub.close();
|
||||
} catch {}
|
||||
resolve(latest);
|
||||
};
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(e) {
|
||||
if (latest === null || e.created_at > latest) latest = e.created_at;
|
||||
},
|
||||
oneose: finish,
|
||||
onclose: finish,
|
||||
});
|
||||
setTimeout(finish, timeoutMs);
|
||||
});
|
||||
const events = await queryEvents(relay, filter);
|
||||
return events.reduce<number | null>(
|
||||
(max, e) => (max === null || e.created_at > max ? e.created_at : max),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// Membership signal for one group — i.e. "is this user allowed to write here".
|
||||
// Admins (kind:39001) can post but need not appear in the members list, so they
|
||||
// count as members. Otherwise NIP-29 records membership as moderation events:
|
||||
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
|
||||
// this user decides current membership (a later 9001 unjoins them). When the
|
||||
// relay keeps neither, fall back to the kind:39002 members list.
|
||||
// Add/remove events are the freshest signal: the newest of the two decides
|
||||
// (a later removal unjoins). When the relay keeps neither, the list decides.
|
||||
function decideMembership(
|
||||
added: number | null,
|
||||
removed: number | null,
|
||||
listed: boolean,
|
||||
): boolean {
|
||||
if (added !== null || removed !== null) {
|
||||
return added !== null && added >= (removed ?? -Infinity);
|
||||
}
|
||||
return listed;
|
||||
}
|
||||
|
||||
// Group membership — i.e. "is this user allowed to write here". Admins
|
||||
// (kind:39001) can post but need not appear in the members list, so they count
|
||||
// as members. Otherwise NIP-29 records membership as kind:9000 (add) and
|
||||
// kind:9001 (remove) moderation events, with the kind:39002 list as fallback.
|
||||
async function checkMembership(
|
||||
pubkey: string,
|
||||
groupId: string,
|
||||
|
|
@ -163,7 +207,7 @@ async function checkMembership(
|
|||
} catch {
|
||||
return false;
|
||||
}
|
||||
const [isAdmin, added, removed] = await Promise.all([
|
||||
const [isAdmin, added, removed, listed] = await Promise.all([
|
||||
queryHasMatch(relay, {
|
||||
kinds: [39001],
|
||||
"#d": [groupId],
|
||||
|
|
@ -172,25 +216,240 @@ async function checkMembership(
|
|||
}),
|
||||
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
||||
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
||||
]);
|
||||
if (isAdmin) return true;
|
||||
if (added !== null || removed !== null) {
|
||||
return added !== null && added >= (removed ?? -Infinity);
|
||||
}
|
||||
return queryHasMatch(relay, {
|
||||
queryHasMatch(relay, {
|
||||
kinds: [39002],
|
||||
"#d": [groupId],
|
||||
"#p": [pubkey],
|
||||
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
|
||||
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
||||
// Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
|
||||
// kind:8001 (remove) signed by the relay, with the kind:13534 list as fallback.
|
||||
// The `member` tag isn't filterable, so the list is fetched and scanned.
|
||||
async function checkRelayMembership(
|
||||
pubkey: string,
|
||||
self: string,
|
||||
): Promise<boolean> {
|
||||
let relay: AbstractRelay;
|
||||
try {
|
||||
relay = await ensureForumRelay();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const [added, removed, lists] = await Promise.all([
|
||||
latestCreatedAt(relay, { kinds: [8000], authors: [self], "#p": [pubkey] }),
|
||||
latestCreatedAt(relay, { kinds: [8001], authors: [self], "#p": [pubkey] }),
|
||||
queryEvents(relay, { kinds: [13534], authors: [self], limit: 1 }),
|
||||
]);
|
||||
const listed = lists.some((e) =>
|
||||
e.tags.some((t) => t[0] === "member" && t[1] === pubkey),
|
||||
);
|
||||
return decideMembership(added, removed, listed);
|
||||
}
|
||||
|
||||
function ensureRelayMembershipChecked(pubkey: string): Promise<void> {
|
||||
if (relayMembership[pubkey]) return Promise.resolve();
|
||||
const k = `relay:${pubkey}`;
|
||||
const existing = checking.get(k);
|
||||
if (existing) return existing;
|
||||
const p = (async () => {
|
||||
try {
|
||||
const self = await nip43Self();
|
||||
if (!self) {
|
||||
relayMembership[pubkey] = "none";
|
||||
return;
|
||||
}
|
||||
const isMember = await checkRelayMembership(pubkey, self);
|
||||
relayMembership[pubkey] = isMember ? "member" : "guest";
|
||||
} finally {
|
||||
checking.delete(k);
|
||||
}
|
||||
})();
|
||||
checking.set(k, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Probes both membership levels at login so a returning member's first post
|
||||
// needs no join at all. Full mode checks groups lazily per room instead.
|
||||
export async function initJoinForUser(pubkey: string) {
|
||||
if (MODE !== "simple") return;
|
||||
if (await checkMembership(pubkey, GROUP_ID)) {
|
||||
membership[memberKey(GROUP_ID)] = "member";
|
||||
await Promise.all([
|
||||
ensureRelayMembershipChecked(pubkey),
|
||||
MODE === "simple" ? ensureMembershipChecked(GROUP_ID) : undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
// Publishing rejections carry the relay's NIP-01 OK message; the prefix is the
|
||||
// only cross-relay contract for what went wrong.
|
||||
function reason(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
const prefixed = (msg: string, prefix: string) =>
|
||||
msg.toLowerCase().startsWith(prefix);
|
||||
const stripPrefix = (msg: string) => msg.replace(/^[a-z-]+:\s*/i, "");
|
||||
const mentionsCode = (msg: string) => /\b(code|invite|claim)\b/i.test(msg);
|
||||
|
||||
async function publishSigned(kind: number, tags: string[][]): Promise<void> {
|
||||
if (!auth.signer) throw new Error("Not logged in");
|
||||
const event = await auth.signer.signEvent({
|
||||
kind,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
});
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
||||
);
|
||||
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
||||
}
|
||||
|
||||
type Codes = { relay?: string; group?: string };
|
||||
type Refusal = { step: JoinStep; message: string | null; codeHinted: boolean };
|
||||
type Outcome = { ok: true } | ({ ok: false } & Refusal);
|
||||
|
||||
const fail = (
|
||||
step: JoinStep,
|
||||
message: string | null,
|
||||
codeHinted = false,
|
||||
): Outcome => ({ ok: false, step, message, codeHinted });
|
||||
|
||||
// Bare relay joins the relay declined, per pubkey. A refusal is only a hint:
|
||||
// some relays (pyramid) let non-members into open groups, so the group join
|
||||
// decides whether relay membership was needed. Remembering it spares one
|
||||
// doomed signature per room.
|
||||
const relayRefusals: Record<string, Outcome> = {};
|
||||
|
||||
// Relay-level join (NIP-43). Returns the refusal, if any, without acting on it.
|
||||
async function tryRelayJoin(pubkey: string, codes: Codes): Promise<Outcome> {
|
||||
await ensureRelayMembershipChecked(pubkey);
|
||||
if (relayMembership[pubkey] !== "guest") return { ok: true };
|
||||
if (!codes.relay && relayRefusals[pubkey]) return relayRefusals[pubkey];
|
||||
const tags: string[][] = [["-"]];
|
||||
if (codes.relay) tags.push(["claim", codes.relay]);
|
||||
let outcome: Outcome = { ok: true };
|
||||
let policy = false;
|
||||
try {
|
||||
await publishSigned(28934, tags);
|
||||
relayMembership[pubkey] = "member";
|
||||
} catch (e) {
|
||||
const msg = reason(e);
|
||||
policy = prefixed(msg, "restricted:") || prefixed(msg, "blocked:");
|
||||
if (prefixed(msg, "duplicate:")) {
|
||||
relayMembership[pubkey] = "member";
|
||||
} else if (prefixed(msg, "blocked:")) {
|
||||
outcome = fail("error", stripPrefix(msg));
|
||||
} else {
|
||||
// A bare request refused is the expected probe, not a user error
|
||||
outcome = fail("relay-code", codes.relay ? stripPrefix(msg) : null, true);
|
||||
}
|
||||
}
|
||||
if (outcome.ok) delete relayRefusals[pubkey];
|
||||
else if (!codes.relay && policy) relayRefusals[pubkey] = outcome;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// Runs the two-level join: relay first (a group join may need relay
|
||||
// membership), then group. Everything free happens silently; the first
|
||||
// refusal that actually blocks is reported as the step needing user input.
|
||||
async function ensureAccess(groupId: string, codes: Codes): Promise<Outcome> {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (!pubkey) return fail("error", "Not logged in");
|
||||
|
||||
await ensureMembershipChecked(groupId);
|
||||
const k = memberKey(groupId);
|
||||
if (membership[k] === "member") return { ok: true };
|
||||
|
||||
const relay = await tryRelayJoin(pubkey, codes);
|
||||
// A code the user typed is judged at once; a bare refusal only counts if
|
||||
// the group join fails too
|
||||
if (!relay.ok && codes.relay) return relay;
|
||||
|
||||
const tags: string[][] = [["h", groupId]];
|
||||
if (codes.group) tags.push(["code", codes.group]);
|
||||
try {
|
||||
await publishSigned(9021, tags);
|
||||
} catch (e) {
|
||||
const msg = reason(e);
|
||||
if (!prefixed(msg, "duplicate:")) {
|
||||
if (prefixed(msg, "restricted:") && !relay.ok) return relay;
|
||||
if (prefixed(msg, "restricted:")) {
|
||||
const hinted = mentionsCode(msg);
|
||||
return fail(
|
||||
"group-code",
|
||||
codes.group || !hinted ? stripPrefix(msg) : null,
|
||||
hinted,
|
||||
);
|
||||
}
|
||||
return fail("error", stripPrefix(msg));
|
||||
}
|
||||
}
|
||||
// An accepted request doesn't imply membership: closed groups may hold it
|
||||
// for admin approval, so re-read the relay's records before trusting it.
|
||||
if (await checkMembership(pubkey, groupId)) {
|
||||
membership[k] = "member";
|
||||
showToast(`You joined ${getGroupName(groupId)}`);
|
||||
return { ok: true };
|
||||
}
|
||||
return fail("pending", codes.group ? "The code was not accepted" : null);
|
||||
}
|
||||
|
||||
function openModal(
|
||||
groupId: string | null,
|
||||
refusal: Refusal,
|
||||
cb: (() => void | Promise<void>) | null,
|
||||
) {
|
||||
modalGroup = groupId;
|
||||
onJoined = cb;
|
||||
modalStep = refusal.step;
|
||||
modalCodeHinted = refusal.codeHinted;
|
||||
modalError = refusal.message;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
// Wraps an action that posts to `groupId`, joining relay and group first when
|
||||
// needed. A write refused for membership reasons (e.g. a stale cache after the
|
||||
// relay changed policy) invalidates both caches and retries once from the top.
|
||||
// Returns false when the modal took over; the action then runs on its success.
|
||||
export async function withJoin(
|
||||
groupId: string,
|
||||
action: () => void | Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (busy) return false;
|
||||
busy = true;
|
||||
try {
|
||||
return await runWithAccess(groupId, action, {}, true);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithAccess(
|
||||
groupId: string,
|
||||
action: () => void | Promise<void>,
|
||||
codes: Codes,
|
||||
retry: boolean,
|
||||
): Promise<boolean> {
|
||||
const outcome = await ensureAccess(groupId, codes);
|
||||
if (!outcome.ok) {
|
||||
openModal(groupId, outcome, action);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
return true;
|
||||
} catch (e) {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (retry && pubkey && prefixed(reason(e), "restricted:")) {
|
||||
delete membership[memberKey(groupId)];
|
||||
delete relayRefusals[pubkey];
|
||||
if (relayMembership[pubkey] === "member")
|
||||
relayMembership[pubkey] = "guest";
|
||||
return runWithAccess(groupId, action, codes, false);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,86 +461,51 @@ export function closeJoinModal() {
|
|||
onJoined = null;
|
||||
}
|
||||
|
||||
// Opens the join modal for a group, with an optional continuation to run after
|
||||
// a successful join (open the composer, focus the reply box, reload the feed).
|
||||
export function openJoinModal(
|
||||
groupId: string,
|
||||
cb?: () => void | Promise<void>,
|
||||
) {
|
||||
modalGroup = groupId;
|
||||
onJoined = cb ?? null;
|
||||
modalError = null;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function publishJoinRequest(groupId: string, code?: string) {
|
||||
if (!auth.signer) throw new Error("Not logged in");
|
||||
const tags: string[][] = [["h", groupId]];
|
||||
if (code) tags.push(["code", code]);
|
||||
const event = await auth.signer.signEvent({
|
||||
kind: 9021,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
});
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
||||
);
|
||||
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
||||
}
|
||||
|
||||
// Sends the kind:9021 for the modal's target group, marks the user joined, and
|
||||
// runs the pending continuation. Errors keep the modal open for a retry.
|
||||
export async function submitJoin(code?: string) {
|
||||
if (!modalGroup || busy) return;
|
||||
// Re-runs the join with the code entered for the current step. Errors keep the
|
||||
// modal open for a retry; a further gate switches the modal to that step.
|
||||
export async function submitJoinCode(code?: string) {
|
||||
if (!modalOpen || busy) return;
|
||||
busy = true;
|
||||
modalError = null;
|
||||
const groupId = modalGroup;
|
||||
const cb = onJoined;
|
||||
const codes: Codes =
|
||||
modalStep === "relay-code" ? { relay: code } : { group: code };
|
||||
try {
|
||||
await publishJoinRequest(groupId, code);
|
||||
membership[memberKey(groupId)] = "member";
|
||||
const pubkey = auth.user?.pubkey;
|
||||
const outcome = groupId
|
||||
? await ensureAccess(groupId, codes)
|
||||
: pubkey
|
||||
? await tryRelayJoin(pubkey, codes)
|
||||
: fail("error", "Not logged in");
|
||||
if (!outcome.ok) {
|
||||
openModal(groupId, outcome, cb);
|
||||
return;
|
||||
}
|
||||
modalOpen = false;
|
||||
modalGroup = null;
|
||||
onJoined = null;
|
||||
if (cb) await cb();
|
||||
} catch (e) {
|
||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||
modalError = reason(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps an action that posts to `groupId`. The relay requires membership to
|
||||
// write to any group, so a non-member is joined first. Compose entry points
|
||||
// gate membership up front, making this mostly a safety net: it joins
|
||||
// transparently if possible, else opens the modal to retry.
|
||||
export async function withJoin(
|
||||
groupId: string,
|
||||
action: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (membership[memberKey(groupId)] === "member") {
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
// Relay-only join, for a relay that refuses reads from non-members: no group
|
||||
// is involved, so the modal (if any) targets the relay alone.
|
||||
export async function joinRelay(cb: () => void | Promise<void>): Promise<void> {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (!pubkey || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
||||
membership[memberKey(groupId)] = "member";
|
||||
await action();
|
||||
return true;
|
||||
const outcome = await tryRelayJoin(pubkey, {});
|
||||
if (!outcome.ok) {
|
||||
openModal(null, outcome, cb);
|
||||
return;
|
||||
}
|
||||
await publishJoinRequest(groupId);
|
||||
await action();
|
||||
membership[memberKey(groupId)] = "member";
|
||||
return true;
|
||||
} catch (e) {
|
||||
modalGroup = groupId;
|
||||
onJoined = action;
|
||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||
modalOpen = true;
|
||||
return false;
|
||||
await cb();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
|||
return {
|
||||
type: "entity",
|
||||
entity,
|
||||
href: `https://njump.me/${entity}`,
|
||||
href: `https://nostr.basspistol.org/${entity}`,
|
||||
label: shortNostrEntity(entity),
|
||||
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
||||
};
|
||||
|
|
@ -165,7 +165,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
|||
return {
|
||||
type: "entity",
|
||||
entity,
|
||||
href: `https://njump.me/${entity}`,
|
||||
href: `https://nostr.basspistol.org/${entity}`,
|
||||
label: shortNostrEntity(entity),
|
||||
};
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
||||
import { loadRelayList } from "$lib/gadgets";
|
||||
|
||||
const RELAY_HINT_TIMEOUT_MS = 1500;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||
import type { Event } from "@nostr/tools/core";
|
||||
import type { Filter } from "@nostr/tools/filter";
|
||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { page } from "$app/state";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import { ensureForumRelay } from "$lib/relay";
|
||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||
import { relayQuery } from "$lib/forum/query";
|
||||
import {
|
||||
fetchOverview,
|
||||
overviewPubkeys,
|
||||
type RecentThread,
|
||||
type RoomActivity,
|
||||
} from "$lib/forum/overview";
|
||||
|
||||
export type RoomActivity = {
|
||||
latestAt: number;
|
||||
latestPubkey: string;
|
||||
};
|
||||
|
||||
export type RecentThread = {
|
||||
id: string;
|
||||
title: string;
|
||||
groupId: string;
|
||||
authorPubkey: string;
|
||||
createdAt: number;
|
||||
};
|
||||
export type { RecentThread, RoomActivity };
|
||||
|
||||
let activity = $state<Record<string, RoomActivity>>({});
|
||||
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
|
||||
|
|
@ -24,43 +18,32 @@ let recent = $state<RecentThread[]>([]);
|
|||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
let loading = $state(false);
|
||||
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
||||
let settled = $state(false); // first live load finished
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
function fallback() {
|
||||
return settled ? null : (page.data.overview ?? null);
|
||||
}
|
||||
|
||||
export const overviewStore = {
|
||||
get activity() {
|
||||
return activity;
|
||||
return fallback()?.activity ?? activity;
|
||||
},
|
||||
get admins() {
|
||||
return admins;
|
||||
return fallback()?.admins ?? admins;
|
||||
},
|
||||
get recent() {
|
||||
return recent;
|
||||
return fallback()?.recent ?? recent;
|
||||
},
|
||||
get profiles() {
|
||||
return profiles;
|
||||
const base = page.data.overview?.profiles;
|
||||
return base ? { ...base, ...profiles } : profiles;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
|
||||
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: Event[] = [];
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(e) {
|
||||
events.push(e);
|
||||
},
|
||||
oneose() {
|
||||
sub.close();
|
||||
resolve(events);
|
||||
},
|
||||
onclose() {
|
||||
resolve(events);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProfile(pubkey: string) {
|
||||
if (profiles[pubkey]) return;
|
||||
const user = await loadNostrUser(pubkey);
|
||||
|
|
@ -77,54 +60,15 @@ export async function loadOverview(roomIds: string[]) {
|
|||
loadedKey = key;
|
||||
|
||||
loading = true;
|
||||
const relay = await ensureForumRelay();
|
||||
try {
|
||||
// One tiny query per room for its newest event (thread or reply).
|
||||
const latest = await Promise.all(
|
||||
roomIds.map((id) =>
|
||||
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }),
|
||||
),
|
||||
);
|
||||
const act: Record<string, RoomActivity> = {};
|
||||
roomIds.forEach((id, i) => {
|
||||
const e = latest[i][0];
|
||||
if (e) act[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
|
||||
});
|
||||
activity = act;
|
||||
|
||||
// Each room's admin (NIP-29 kind 39001, first `p` tag) for the card byline.
|
||||
const adminEvents = await Promise.all(
|
||||
roomIds.map((id) => querySync(relay, { kinds: [39001], "#d": [id] })),
|
||||
);
|
||||
const adm: Record<string, string> = {};
|
||||
roomIds.forEach((id, i) => {
|
||||
const pk = adminEvents[i][0]?.tags.find((t) => t[0] === "p")?.[1];
|
||||
if (pk) adm[id] = pk;
|
||||
});
|
||||
admins = adm;
|
||||
|
||||
// Most recent discussions (thread OPs) across all rooms. The group relay
|
||||
// truncates multi-value "#h" filters, so query each room and merge.
|
||||
const perRoom = await Promise.all(
|
||||
roomIds.map((id) =>
|
||||
querySync(relay, { kinds: [11], "#h": [id], limit: 20 }),
|
||||
),
|
||||
);
|
||||
const threads = perRoom.flat();
|
||||
threads.sort((a, b) => b.created_at - a.created_at);
|
||||
recent = threads.slice(0, 20).map((e) => ({
|
||||
id: e.id,
|
||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||
groupId: e.tags.find((t) => t[0] === "h")?.[1] ?? "",
|
||||
authorPubkey: e.pubkey,
|
||||
createdAt: e.created_at,
|
||||
}));
|
||||
|
||||
for (const a of Object.values(act)) loadProfile(a.latestPubkey);
|
||||
for (const pk of Object.values(adm)) loadProfile(pk);
|
||||
for (const t of recent) loadProfile(t.authorPubkey);
|
||||
const q = relayQuery(await ensureForumRelay());
|
||||
const o = await fetchOverview(q, roomIds);
|
||||
activity = o.activity;
|
||||
admins = o.admins;
|
||||
recent = o.recent;
|
||||
settled = true;
|
||||
overviewPubkeys(o).forEach(loadProfile);
|
||||
} finally {
|
||||
// shared forum connection is long-lived — don't close it here
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
src/lib/pageHighlight.ts
Normal file
61
src/lib/pageHighlight.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
findPhraseSpans,
|
||||
findMatchSpans,
|
||||
tokenize,
|
||||
type Span,
|
||||
} from "$lib/textMatch";
|
||||
|
||||
const HIGHLIGHT_NAME = "search-terms";
|
||||
|
||||
function supported(): boolean {
|
||||
return typeof CSS !== "undefined" && "highlights" in CSS;
|
||||
}
|
||||
|
||||
// Highlight the query inside the given roots using the CSS Custom Highlight
|
||||
// API, which paints ranges without touching the DOM, so it never conflicts
|
||||
// with Svelte re-renders. When the whole query appears contiguously anywhere
|
||||
// on the page, only those phrase occurrences are painted; otherwise the
|
||||
// textMatch fallback (contiguous runs as blocks, then single words) is used.
|
||||
// Returns the first match in document order (for scrolling), or null. No-op
|
||||
// on unsupported browsers.
|
||||
export function applyHighlights(
|
||||
roots: Iterable<Element>,
|
||||
query: string,
|
||||
): Range | null {
|
||||
if (!supported()) return null;
|
||||
CSS.highlights.delete(HIGHLIGHT_NAME);
|
||||
if (tokenize(query).length === 0) return null;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
for (const root of roots) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) nodes.push(node);
|
||||
}
|
||||
|
||||
let perNode: Span[][] = nodes.map((n) =>
|
||||
findPhraseSpans(n.textContent ?? "", query),
|
||||
);
|
||||
if (perNode.every((spans) => spans.length === 0)) {
|
||||
perNode = nodes.map((n) => findMatchSpans(n.textContent ?? "", query));
|
||||
}
|
||||
|
||||
const ranges: Range[] = [];
|
||||
nodes.forEach((node, i) => {
|
||||
for (const span of perNode[i]) {
|
||||
const range = new Range();
|
||||
range.setStart(node, span.start);
|
||||
range.setEnd(node, span.end);
|
||||
ranges.push(range);
|
||||
}
|
||||
});
|
||||
if (ranges.length === 0) return null;
|
||||
|
||||
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));
|
||||
ranges.sort((a, b) => a.compareBoundaryPoints(Range.START_TO_START, b));
|
||||
return ranges[0];
|
||||
}
|
||||
|
||||
export function clearHighlights() {
|
||||
if (supported()) CSS.highlights.delete(HIGHLIGHT_NAME);
|
||||
}
|
||||
|
|
@ -1,63 +1,32 @@
|
|||
import { page } from "$app/state";
|
||||
import { adminPubkeys } from "$lib/admins.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import {
|
||||
fetchPartials,
|
||||
pickPartial,
|
||||
type Partial,
|
||||
type PartialSlot,
|
||||
} from "$lib/forum/partials";
|
||||
|
||||
// Named slots a partial can fill. The NIP-23 `d` tag carries the slot name.
|
||||
export type PartialSlot = "home" | "contacts";
|
||||
const SLOTS = new Set<string>(["home", "contacts"]);
|
||||
|
||||
export type Partial = {
|
||||
id: string;
|
||||
slot: PartialSlot;
|
||||
title: string;
|
||||
content: string; // markdown body
|
||||
pubkey: string;
|
||||
createdAt: number;
|
||||
};
|
||||
export type { Partial, PartialSlot };
|
||||
|
||||
let all = $state<Partial[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||
export const partialsStore = {
|
||||
// The newest admin-authored partial for a slot, or undefined. The relay query
|
||||
// is open, so the trusted admin set is the gate; resolving the winner here
|
||||
// (rather than at load time) keeps a non-admin from shadowing it with a newer
|
||||
// event, and picks up the admin set once it finishes loading in full mode.
|
||||
get(slot: PartialSlot): Partial | undefined {
|
||||
const admins = new Set(adminPubkeys.list);
|
||||
let best: Partial | undefined;
|
||||
for (const p of all) {
|
||||
if (p.slot !== slot || !admins.has(p.pubkey)) continue;
|
||||
if (!best || p.createdAt > best.createdAt) best = p;
|
||||
}
|
||||
return best;
|
||||
const source = loaded ? all : (page.data.shell?.partials ?? []);
|
||||
return pickPartial(source, slot, adminPubkeys.list);
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
// Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"];
|
||||
// the `d` tag names the slot the article fills.
|
||||
export async function loadPartials() {
|
||||
try {
|
||||
const events = await queryForum({
|
||||
kinds: [30023],
|
||||
"#t": ["squalk-partial"],
|
||||
});
|
||||
const next: Partial[] = [];
|
||||
for (const e of events) {
|
||||
const slot = e.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (!slot || !SLOTS.has(slot)) continue;
|
||||
next.push({
|
||||
id: e.id,
|
||||
slot: slot as PartialSlot,
|
||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slot,
|
||||
content: e.content,
|
||||
pubkey: e.pubkey,
|
||||
createdAt: e.created_at,
|
||||
});
|
||||
}
|
||||
all = next;
|
||||
all = await fetchPartials(queryForum);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
|||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { GROUP_ID } from "$lib/config";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||
|
||||
export type ProfileEntry = {
|
||||
pubkey: string;
|
||||
|
|
@ -27,12 +28,6 @@ const SEARCH_RELAYS = [
|
|||
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
||||
// never has these — they live on each author's outbox relays — so we query
|
||||
// a small set of well-known aggregators.
|
||||
const PROFILE_RELAYS = [
|
||||
"wss://purplepag.es",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
const FETCH_BATCH_SIZE = 500;
|
||||
|
||||
const profiles = new SvelteMap<string, ProfileEntry>();
|
||||
|
|
@ -310,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
|
|||
if (entry) upsertProfile(entry);
|
||||
}
|
||||
|
||||
export function ingestNostrUser(user: NostrUser) {
|
||||
if (!user.pubkey) return;
|
||||
export function entryFromUser(user: NostrUser): ProfileEntry {
|
||||
const md = user.metadata ?? {};
|
||||
const entry: ProfileEntry = {
|
||||
return {
|
||||
pubkey: user.pubkey,
|
||||
npub: user.npub,
|
||||
name: md.name,
|
||||
|
|
@ -325,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
|
|||
lud16: md.lud16,
|
||||
fetchedAt: user.lastUpdated || 0,
|
||||
};
|
||||
upsertProfile(entry);
|
||||
}
|
||||
|
||||
export function ingestNostrUser(user: NostrUser) {
|
||||
if (!user.pubkey) return;
|
||||
upsertProfile(entryFromUser(user));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ export const forumPool = pool;
|
|||
|
||||
function authParam() {
|
||||
const signer = auth.signer;
|
||||
return signer ? { onauth: (evt: EventTemplate) => signer.signEvent(evt) } : {};
|
||||
return signer
|
||||
? { onauth: (evt: EventTemplate) => signer.signEvent(evt) }
|
||||
: {};
|
||||
}
|
||||
|
||||
// Resolves once the forum connection is open and, when logged in, NIP-42
|
||||
|
|
@ -104,3 +106,53 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
|
|||
export function resetForumConnection(): void {
|
||||
pool.close([RELAY_URL]);
|
||||
}
|
||||
|
||||
// NIP-11 document of the forum relay, fetched once per session. Used for NIP-43
|
||||
// discovery (clients must only send relay join requests to relays advertising
|
||||
// it; `self` signs the membership events we read back) and for the access gate,
|
||||
// which can show the relay's name before anything else is readable.
|
||||
export type ForumRelayInfo = {
|
||||
self: string | null;
|
||||
nips: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
authRequired: boolean;
|
||||
};
|
||||
let relayInfo: Promise<ForumRelayInfo> | null = null;
|
||||
|
||||
export function forumRelayInfo(): Promise<ForumRelayInfo> {
|
||||
if (!relayInfo) {
|
||||
relayInfo = import("@nostr/tools/nip11")
|
||||
.then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL))
|
||||
.then((info) => {
|
||||
// `self` is a NIP-43 addition the library type doesn't know yet
|
||||
const self = (info as { self?: unknown }).self;
|
||||
return {
|
||||
self: typeof self === "string" ? self : null,
|
||||
// Relays mix numbers and strings in this list
|
||||
nips: (info.supported_nips ?? []).map(String),
|
||||
name: info.name ?? "",
|
||||
description: info.description ?? "",
|
||||
icon: info.icon ?? "",
|
||||
authRequired: info.limitation?.auth_required === true,
|
||||
};
|
||||
})
|
||||
.catch(() => ({
|
||||
self: null,
|
||||
nips: [],
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
authRequired: false,
|
||||
}));
|
||||
}
|
||||
return relayInfo;
|
||||
}
|
||||
|
||||
// The relay pubkey when the forum relay supports NIP-43 relay membership,
|
||||
// otherwise null (no relay-level join is needed or allowed).
|
||||
export async function nip43Self(): Promise<string | null> {
|
||||
const info = await forumRelayInfo();
|
||||
return info.nips.includes("43") ? info.self : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,30 @@
|
|||
import { page } from "$app/state";
|
||||
import { adminPubkeys } from "$lib/admins.svelte";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { fetchResources, type Resource } from "$lib/forum/resources";
|
||||
|
||||
export type Resource = {
|
||||
id: string;
|
||||
slug: string; // NIP-23 `d` tag — also the resource URL slug
|
||||
title: string;
|
||||
content: string; // markdown body
|
||||
position?: number;
|
||||
pubkey: string;
|
||||
createdAt: number;
|
||||
};
|
||||
export type { Resource };
|
||||
|
||||
let all = $state<Resource[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Only admin-authored resources are surfaced: the relay query is open, so the
|
||||
// admin set is the trust gate. Until the live fetch lands, the server
|
||||
// snapshot (if any) stands in.
|
||||
export const resourcesStore = {
|
||||
// Only resources authored by an admin are surfaced; the relay query is
|
||||
// open, so the trusted admin set is the gate.
|
||||
get list() {
|
||||
const admins = new Set(adminPubkeys.list);
|
||||
return all.filter((r) => admins.has(r.pubkey));
|
||||
const source = loaded ? all : (page.data.shell?.resources ?? []);
|
||||
return source.filter((r) => admins.has(r.pubkey));
|
||||
},
|
||||
get loaded() {
|
||||
return loaded;
|
||||
return loaded || !!page.data.shell;
|
||||
},
|
||||
};
|
||||
|
||||
// Positioned resources come first by ascending `position`; the rest follow
|
||||
// alphabetically by title. Equal positions fall back to title order.
|
||||
function compare(a: Resource, b: Resource): number {
|
||||
const ap = a.position;
|
||||
const bp = b.position;
|
||||
if (ap !== undefined && bp !== undefined)
|
||||
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
|
||||
if (ap !== undefined) return -1;
|
||||
if (bp !== undefined) return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
|
||||
export async function loadResources() {
|
||||
try {
|
||||
const events = await queryForum({
|
||||
kinds: [30023],
|
||||
"#t": ["squalk-resource"],
|
||||
});
|
||||
// Addressable events: keep the newest per `d` slug.
|
||||
const bySlug = new Map<string, Resource>();
|
||||
for (const e of events) {
|
||||
const slug = e.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (!slug) continue;
|
||||
const existing = bySlug.get(slug);
|
||||
if (existing && existing.createdAt >= e.created_at) continue;
|
||||
const posTag = e.tags.find((t) => t[0] === "position")?.[1];
|
||||
const pos = posTag !== undefined ? Number(posTag) : NaN;
|
||||
bySlug.set(slug, {
|
||||
id: e.id,
|
||||
slug,
|
||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug,
|
||||
content: e.content,
|
||||
position: Number.isFinite(pos) ? pos : undefined,
|
||||
pubkey: e.pubkey,
|
||||
createdAt: e.created_at,
|
||||
});
|
||||
}
|
||||
all = [...bySlug.values()].sort(compare);
|
||||
all = await fetchResources(queryForum);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Event } from "@nostr/tools/core";
|
||||
import type { Filter } from "@nostr/tools/filter";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { bestMatch } from "$lib/textMatch";
|
||||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
|
||||
|
|
@ -12,22 +13,26 @@ export type SearchResult = {
|
|||
createdAt: number;
|
||||
};
|
||||
|
||||
// Window the snippet around the first term match so the highlight is visible
|
||||
// even when the match sits deep in a long post.
|
||||
function snippetOf(content: string, query: string): string {
|
||||
// Window the snippet around the best match (full phrase > longest
|
||||
// contiguous run > densest single-term cluster, per textMatch rules). The
|
||||
// score ranks how well this content matched, so dedupe can keep the best
|
||||
// snippet per thread and the result list can order its tiers.
|
||||
function snippetOf(
|
||||
content: string,
|
||||
query: string,
|
||||
): { text: string; score: number } {
|
||||
const flat = content.replace(/\s+/g, " ").trim();
|
||||
const MAX = 140;
|
||||
if (flat.length <= MAX) return flat;
|
||||
const lower = flat.toLowerCase();
|
||||
let idx = -1;
|
||||
for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
||||
const i = lower.indexOf(t);
|
||||
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
|
||||
}
|
||||
if (idx <= 40) return flat.slice(0, MAX) + "…";
|
||||
const start = idx - 40;
|
||||
const { anchor, score } = bestMatch(flat, query, MAX - 40);
|
||||
|
||||
if (flat.length <= MAX) return { text: flat, score };
|
||||
if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
|
||||
const start = anchor - 40;
|
||||
const end = Math.min(flat.length, start + MAX);
|
||||
return "…" + flat.slice(start, end) + (end < flat.length ? "…" : "");
|
||||
return {
|
||||
text: "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""),
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
|
||||
|
|
@ -48,46 +53,58 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
|
|||
return h !== undefined && (groups.size === 0 || groups.has(h));
|
||||
});
|
||||
|
||||
const byThread = new Map<string, SearchResult>();
|
||||
const opless: SearchResult[] = [];
|
||||
type Candidate = SearchResult & { score: number };
|
||||
const byThread = new Map<string, Candidate>();
|
||||
const needTitle: Candidate[] = [];
|
||||
for (const e of events) {
|
||||
if (e.kind === 11) {
|
||||
const existing = byThread.get(e.id);
|
||||
// An OP match wins over a reply match on the same thread
|
||||
if (existing && existing.matchKind === "thread") continue;
|
||||
byThread.set(e.id, {
|
||||
threadId: e.id,
|
||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||
snippet: snippetOf(e.content, query),
|
||||
matchKind: "thread",
|
||||
createdAt: e.created_at,
|
||||
});
|
||||
} else {
|
||||
const root = e.tags.find((t) => t[0] === "E")?.[1];
|
||||
if (!root || byThread.has(root)) continue;
|
||||
const r: SearchResult = {
|
||||
threadId: root,
|
||||
title: "",
|
||||
snippet: snippetOf(e.content, query),
|
||||
matchKind: "reply",
|
||||
const isOp = e.kind === 11;
|
||||
const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
||||
if (!threadId) continue;
|
||||
const title = isOp
|
||||
? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
|
||||
: "";
|
||||
const { text, score } = snippetOf(e.content, query);
|
||||
const existing = byThread.get(threadId);
|
||||
if (!existing) {
|
||||
const r: Candidate = {
|
||||
threadId,
|
||||
title,
|
||||
snippet: text,
|
||||
matchKind: isOp ? "thread" : "reply",
|
||||
createdAt: e.created_at,
|
||||
score,
|
||||
};
|
||||
byThread.set(root, r);
|
||||
opless.push(r);
|
||||
byThread.set(threadId, r);
|
||||
if (!isOp) needTitle.push(r);
|
||||
} else {
|
||||
// Keep the snippet of whichever event matched the query best
|
||||
if (score > existing.score) {
|
||||
existing.snippet = text;
|
||||
existing.matchKind = isOp ? "thread" : "reply";
|
||||
existing.score = score;
|
||||
}
|
||||
if (isOp && !existing.title) existing.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill titles for reply-only matches; the group relay truncates
|
||||
// multi-id queries, so fetch each OP on its own
|
||||
await Promise.all(
|
||||
opless.map(async (r) => {
|
||||
needTitle.map(async (r) => {
|
||||
if (r.title) return; // The OP appeared in the same result set
|
||||
const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
|
||||
const op: Event | undefined = ops[0];
|
||||
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||
}),
|
||||
);
|
||||
|
||||
// Keep the relay's relevance order (arrival order); a thread keeps the
|
||||
// position of its best-ranked match
|
||||
return [...byThread.values()];
|
||||
// Tiered ordering: full-phrase matches, then contiguous multi-word runs
|
||||
// (longest first), then single-word matches capped to keep noise down.
|
||||
// Within a tier the relay's relevance (arrival) order is preserved —
|
||||
// Array.prototype.sort is stable.
|
||||
const SINGLES_LIMIT = 5;
|
||||
let singles = 0;
|
||||
return [...byThread.values()]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.filter((r) => r.score >= 100 || ++singles <= SINGLES_LIMIT);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { searchThreads, type SearchResult } from "$lib/search";
|
||||
import { findPhraseSpans, findMatchSpans } from "$lib/textMatch";
|
||||
|
||||
// Debounced-search state machine shared by the inline (homepage) and modal
|
||||
// search shells, so behavior lives in one place and the shells only differ
|
||||
|
|
@ -12,10 +13,6 @@ export function createSearchState() {
|
|||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const terms = $derived(
|
||||
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
||||
);
|
||||
|
||||
function schedule() {
|
||||
const q = query.trim();
|
||||
if (timer) clearTimeout(timer);
|
||||
|
|
@ -41,19 +38,26 @@ export function createSearchState() {
|
|||
}, 300);
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Split into alternating plain/matched segments for <mark> rendering
|
||||
// Split into alternating plain/matched segments for <mark> rendering.
|
||||
// When the text contains the whole query as a contiguous phrase, only the
|
||||
// phrase is marked; otherwise contiguous runs (as single blocks) and
|
||||
// single words are marked as a fallback, to show why the text matched.
|
||||
function highlight(text: string): { text: string; hit: boolean }[] {
|
||||
if (!text || terms.length === 0) return [{ text, hit: false }];
|
||||
const alts = terms.map(escapeRe).join("|");
|
||||
const exact = new RegExp(`^(${alts})$`, "i");
|
||||
return text
|
||||
.split(new RegExp(`(${alts})`, "gi"))
|
||||
.filter((s) => s !== "")
|
||||
.map((s) => ({ text: s, hit: exact.test(s) }));
|
||||
if (!text) return [{ text, hit: false }];
|
||||
const phrase = findPhraseSpans(text, resultsQuery);
|
||||
const spans =
|
||||
phrase.length > 0 ? phrase : findMatchSpans(text, resultsQuery);
|
||||
if (spans.length === 0) return [{ text, hit: false }];
|
||||
const out: { text: string; hit: boolean }[] = [];
|
||||
let pos = 0;
|
||||
for (const s of spans) {
|
||||
if (s.start > pos)
|
||||
out.push({ text: text.slice(pos, s.start), hit: false });
|
||||
out.push({ text: text.slice(s.start, s.end), hit: true });
|
||||
pos = s.end;
|
||||
}
|
||||
if (pos < text.length) out.push({ text: text.slice(pos), hit: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Arrow/Enter handling; returns true when the event was consumed
|
||||
|
|
@ -98,6 +102,13 @@ export function createSearchState() {
|
|||
get active() {
|
||||
return resultsQuery.trim().length >= 2;
|
||||
},
|
||||
get resultsQuery() {
|
||||
return resultsQuery;
|
||||
},
|
||||
// Thread link carrying the query, so the page can highlight the terms
|
||||
threadUrl(threadId: string) {
|
||||
return `/thread/${threadId}?q=${encodeURIComponent(resultsQuery.trim())}`;
|
||||
},
|
||||
schedule,
|
||||
highlight,
|
||||
navigate,
|
||||
|
|
|
|||
47
src/lib/seo.ts
Normal file
47
src/lib/seo.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Markdown inline syntax reduced to plain text.
|
||||
function stripInline(text: string): string {
|
||||
return text
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
|
||||
.replace(/[*_`~]+/g, "")
|
||||
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||
}
|
||||
|
||||
function isProse(line: string): boolean {
|
||||
return !!line && !line.startsWith("#") && !/^https?:\/\/\S+$/.test(line);
|
||||
}
|
||||
|
||||
// Plain-text excerpt of a markdown body for description meta tags: the first
|
||||
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
|
||||
export function excerpt(markdown: string, max = 160): string {
|
||||
const line = markdown
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find(isProse);
|
||||
return line ? truncate(stripInline(line), max) : "";
|
||||
}
|
||||
|
||||
// The whole body flattened to one plain-text line, trimmed to `max` chars.
|
||||
// Headings, bare media URLs, code blocks, quoted text and list markers are
|
||||
// dropped, so a reply reads as its own words.
|
||||
export function summarize(markdown: string, max = 200): string {
|
||||
let inCode = false;
|
||||
const lines: string[] = [];
|
||||
for (const raw of markdown.split("\n")) {
|
||||
const l = raw.trim();
|
||||
if (l.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode || l.startsWith(">")) continue;
|
||||
const line = l.replace(/^(?:[-*+]\s+|\d+[.)]\s+)+/, "");
|
||||
if (isProse(line)) lines.push(line);
|
||||
}
|
||||
return truncate(stripInline(lines.join(" ")), max);
|
||||
}
|
||||
26
src/lib/ssr/endpoint.ts
Normal file
26
src/lib/ssr/endpoint.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { error, type RequestEvent } from "@sveltejs/kit";
|
||||
import { CACHE_CONTROL } from "$lib/config";
|
||||
import { RelayUnavailable } from "./relay";
|
||||
|
||||
// Wraps a snapshot endpoint: answers carry the shared cache policy, while a
|
||||
// relay that could not be asked becomes an uncached 503, which snapshot()
|
||||
// treats as "no snapshot" so the page hydrates empty and the client takes the
|
||||
// same path as a client-only build (probe, gate or live fetch).
|
||||
export function snapshotHandler<E extends RequestEvent>(
|
||||
handler: (event: E) => Promise<Response>,
|
||||
): (event: E) => Promise<Response> {
|
||||
return async (event) => {
|
||||
try {
|
||||
const res = await handler(event);
|
||||
event.setHeaders({ "cache-control": CACHE_CONTROL });
|
||||
return res;
|
||||
} catch (e) {
|
||||
const unavailable = e instanceof RelayUnavailable;
|
||||
event.setHeaders({
|
||||
"cache-control": unavailable ? "no-store" : CACHE_CONTROL,
|
||||
});
|
||||
if (unavailable) error(503, "Relay unavailable");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
171
src/lib/ssr/relay.ts
Normal file
171
src/lib/ssr/relay.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { SimplePool } from "@nostr/tools";
|
||||
import type { Event } from "@nostr/tools/core";
|
||||
import type { Filter } from "@nostr/tools/filter";
|
||||
import { RELAY_URL, SSR_CACHE_FRESH, SSR_CACHE_STALE } from "$lib/config";
|
||||
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||
import type { Query } from "$lib/forum/query";
|
||||
|
||||
// Anonymous relay access for server rendering. The server has no signer, so
|
||||
// it only ever sees what the relay serves the public; members get their
|
||||
// private rooms once the client takes over. Every query is bounded so a slow
|
||||
// relay degrades to an empty page, never a hung request.
|
||||
//
|
||||
// A relay that is down or refuses anonymous reads (NIP-42 auth-required) is
|
||||
// not the same as an empty result: the query rejects with RelayUnavailable so
|
||||
// endpoints answer 503 rather than 404, and the client renders as it would
|
||||
// without SSR (probing the relay and showing the gate when it must).
|
||||
//
|
||||
// Results are cached in memory with the fresh/stale windows from config:
|
||||
// fresh entries are returned as they are, stale ones are returned at once and
|
||||
// refreshed in the background, expired ones are fetched again. Profiles
|
||||
// rarely change, so their fresh window is at least an hour.
|
||||
const QUERY_TIMEOUT = 2500;
|
||||
const PROFILE_TIMEOUT = 1500;
|
||||
const CONNECT_TIMEOUT = 1500;
|
||||
const FORUM_FRESH = SSR_CACHE_FRESH * 1000;
|
||||
const PROFILE_FRESH = Math.max(FORUM_FRESH, 60 * 60_000);
|
||||
const STALE = Math.max(SSR_CACHE_STALE * 1000, FORUM_FRESH);
|
||||
const DEAD_RELAY_TTL = 5 * 60_000;
|
||||
const CACHE_MAX = 2000;
|
||||
|
||||
export class RelayUnavailable extends Error {
|
||||
constructor() {
|
||||
super("No relay answered");
|
||||
this.name = "RelayUnavailable";
|
||||
}
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
|
||||
process.on("sveltekit:shutdown", () => pool.destroy());
|
||||
type Entry = { at: number; result: Promise<Event[]>; refreshing: boolean };
|
||||
const cache = new Map<string, Entry>();
|
||||
// Relays that failed to connect are skipped for a while, so a dead profile
|
||||
// relay doesn't add its connect timeout to every cold page.
|
||||
const deadUntil = new Map<string, number>();
|
||||
|
||||
// Ask every reachable relay and resolve as soon as each has answered (EOSE or
|
||||
// closed), or at the deadline, whichever comes first — always with whatever
|
||||
// arrived, so one stalled relay never costs the events the others delivered.
|
||||
// Rejects when no relay reached EOSE: a CLOSED (auth-required), a failed
|
||||
// connection or the deadline are not answers.
|
||||
function boundedQuery(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
maxWait: number,
|
||||
): Promise<Event[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: Event[] = [];
|
||||
const subs: { close(): void }[] = [];
|
||||
let done = false;
|
||||
let answered = false;
|
||||
let pending = 0;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
for (const s of subs) {
|
||||
try {
|
||||
s.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
if (answered) resolve(events);
|
||||
else reject(new RelayUnavailable());
|
||||
};
|
||||
const timer = setTimeout(finish, maxWait);
|
||||
|
||||
const now = Date.now();
|
||||
const live = relays.filter((url) => (deadUntil.get(url) ?? 0) <= now);
|
||||
pending = live.length;
|
||||
if (pending === 0) return finish();
|
||||
|
||||
for (const url of live) {
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (--pending <= 0) finish();
|
||||
};
|
||||
pool.ensureRelay(url, { connectionTimeout: CONNECT_TIMEOUT }).then(
|
||||
(relay) => {
|
||||
if (done) return settle();
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent: (e) => events.push(e),
|
||||
oneose: () => {
|
||||
answered = true;
|
||||
sub.close();
|
||||
settle();
|
||||
},
|
||||
onclose: settle,
|
||||
// The library fakes an EOSE after its own timeout; the deadline
|
||||
// above must win so a silent relay does not count as an answer
|
||||
eoseTimeout: maxWait * 2,
|
||||
});
|
||||
subs.push(sub);
|
||||
},
|
||||
() => {
|
||||
deadUntil.set(url, Date.now() + DEAD_RELAY_TTL);
|
||||
settle();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cachedQuery(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
maxWait: number,
|
||||
fresh: number,
|
||||
): Promise<Event[]> {
|
||||
const key = relays.join(",") + "|" + JSON.stringify(filter);
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
const age = now - hit.at;
|
||||
if (age < fresh) return hit.result;
|
||||
if (age < STALE) {
|
||||
if (!hit.refreshing) {
|
||||
hit.refreshing = true;
|
||||
boundedQuery(relays, filter, maxWait).then(
|
||||
(events) => {
|
||||
cache.set(key, {
|
||||
at: Date.now(),
|
||||
result: Promise.resolve(events),
|
||||
refreshing: false,
|
||||
});
|
||||
},
|
||||
// Keep serving the stale entry, try again next time
|
||||
() => {
|
||||
hit.refreshing = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
return hit.result;
|
||||
}
|
||||
}
|
||||
if (cache.size >= CACHE_MAX) cache.clear();
|
||||
const result = boundedQuery(relays, filter, maxWait);
|
||||
const entry: Entry = { at: now, result, refreshing: false };
|
||||
cache.set(key, entry);
|
||||
// A failure is never cached, so the next request asks the relay again
|
||||
result.catch(() => {
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export const forumQuery: Query = (filter) =>
|
||||
cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT, FORUM_FRESH);
|
||||
|
||||
// Profiles mostly live off the forum relay; ask it too for members who only
|
||||
// published there.
|
||||
export const profileQuery: Query = (filter) =>
|
||||
cachedQuery(
|
||||
[RELAY_URL, ...PROFILE_RELAYS],
|
||||
filter,
|
||||
PROFILE_TIMEOUT,
|
||||
PROFILE_FRESH,
|
||||
);
|
||||
29
src/lib/ssr/shell.ts
Normal file
29
src/lib/ssr/shell.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroup, fetchGroups, fetchRoomAdmins } from "$lib/forum/groups";
|
||||
import { fetchResources } from "$lib/forum/resources";
|
||||
import { fetchPartials } from "$lib/forum/partials";
|
||||
import type { ForumShell } from "$lib/forum/snapshot";
|
||||
import { forumQuery } from "./relay";
|
||||
|
||||
// Everything the layout needs on every page: the group(s), the admin set,
|
||||
// sidebar resources and partials.
|
||||
export async function loadShell(): Promise<ForumShell> {
|
||||
const [group, groups, resources, partials] = await Promise.all([
|
||||
MODE === "simple" ? fetchGroup(forumQuery, GROUP_ID) : null,
|
||||
MODE === "full" ? fetchGroups(forumQuery) : [],
|
||||
fetchResources(forumQuery),
|
||||
fetchPartials(forumQuery),
|
||||
]);
|
||||
const adminsByRoom =
|
||||
MODE === "full"
|
||||
? await fetchRoomAdmins(
|
||||
forumQuery,
|
||||
groups.map((g) => g.id),
|
||||
)
|
||||
: {};
|
||||
const admins =
|
||||
MODE === "full"
|
||||
? [...new Set(Object.values(adminsByRoom).flat())]
|
||||
: (group?.admins ?? []);
|
||||
return { group, groups, adminsByRoom, admins, resources, partials };
|
||||
}
|
||||
121
src/lib/textMatch.ts
Normal file
121
src/lib/textMatch.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Shared matching rules for search highlighting and ranking. The query is
|
||||
// tokenized on any non-alphanumeric character (so "-_|!./" all act as word
|
||||
// separators, in the query and in the text), and a token matches where a
|
||||
// word starts or ends with it — "id" marks "id" or "identify" but never the
|
||||
// middle of "gravida", while still allowing prefix searches like the first
|
||||
// characters of an npub.
|
||||
|
||||
export type Span = { start: number; end: number };
|
||||
|
||||
// Separator between adjacent tokens of a run: one or more non-word chars
|
||||
const SEP = "[^\\p{L}\\p{N}]+";
|
||||
|
||||
export function tokenize(query: string): string[] {
|
||||
return query
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((t) => t.length >= 2);
|
||||
}
|
||||
|
||||
function isWordChar(ch: string | undefined): boolean {
|
||||
return ch !== undefined && /[\p{L}\p{N}]/u.test(ch);
|
||||
}
|
||||
|
||||
// Start-with or end-with: the match must begin at a word start or finish at
|
||||
// a word end.
|
||||
function validBoundaries(text: string, span: Span): boolean {
|
||||
return !isWordChar(text[span.start - 1]) || !isWordChar(text[span.end]);
|
||||
}
|
||||
|
||||
function overlaps(taken: Span[], s: Span): boolean {
|
||||
return taken.some((t) => s.start < t.end && s.end > t.start);
|
||||
}
|
||||
|
||||
// All spans where the given tokens appear consecutively (any separators
|
||||
// between them). Tokens are alphanumeric-only, so no regex escaping needed.
|
||||
function findRuns(text: string, tokens: string[]): Span[] {
|
||||
const re = new RegExp(tokens.join(SEP), "giu");
|
||||
const out: Span[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
const span = { start: m.index, end: m.index + m[0].length };
|
||||
if (validBoundaries(text, span)) out.push(span);
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Occurrences of the whole query as one contiguous phrase
|
||||
export function findPhraseSpans(text: string, query: string): Span[] {
|
||||
const tokens = tokenize(query);
|
||||
if (tokens.length < 2) return [];
|
||||
return findRuns(text, tokens);
|
||||
}
|
||||
|
||||
// Fallback matches: longest contiguous multi-token runs claim their spans
|
||||
// first (each painted as one block), then single tokens outside them.
|
||||
export function findMatchSpans(text: string, query: string): Span[] {
|
||||
const tokens = tokenize(query);
|
||||
if (tokens.length === 0) return [];
|
||||
const spans: Span[] = [];
|
||||
for (let len = tokens.length; len >= 2; len--) {
|
||||
for (let s = 0; s + len <= tokens.length; s++) {
|
||||
for (const span of findRuns(text, tokens.slice(s, s + len))) {
|
||||
if (!overlaps(spans, span)) spans.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const t of new Set(tokens)) {
|
||||
for (const span of findRuns(text, [t])) {
|
||||
if (!overlaps(spans, span)) spans.push(span);
|
||||
}
|
||||
}
|
||||
return spans.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
// Rank how well a text matches the query, and where to anchor an excerpt:
|
||||
// the full query (1000) beats any shorter contiguous run (100 + length),
|
||||
// which beats scattered singles (count of distinct tokens inside a window of
|
||||
// `windowSpan` chars, always < 100).
|
||||
export function bestMatch(
|
||||
text: string,
|
||||
query: string,
|
||||
windowSpan: number,
|
||||
): { anchor: number; score: number } {
|
||||
const tokens = tokenize(query);
|
||||
if (tokens.length === 0) return { anchor: -1, score: 0 };
|
||||
|
||||
for (let len = tokens.length; len >= 2; len--) {
|
||||
for (let s = 0; s + len <= tokens.length; s++) {
|
||||
const runs = findRuns(text, tokens.slice(s, s + len));
|
||||
if (runs.length > 0)
|
||||
return {
|
||||
anchor: runs[0].start,
|
||||
score: len === tokens.length ? 1000 : 100 + len,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const singles: { start: number; token: string }[] = [];
|
||||
for (const t of new Set(tokens)) {
|
||||
for (const span of findRuns(text, [t]))
|
||||
singles.push({ start: span.start, token: t });
|
||||
}
|
||||
if (singles.length === 0) return { anchor: -1, score: 0 };
|
||||
singles.sort((a, b) => a.start - b.start);
|
||||
|
||||
let anchor = singles[0].start;
|
||||
let score = 0;
|
||||
for (const o of singles) {
|
||||
const seen = new Set<string>();
|
||||
for (const p of singles) {
|
||||
if (p.start >= o.start && p.start <= o.start + windowSpan)
|
||||
seen.add(p.token);
|
||||
}
|
||||
if (seen.size > score) {
|
||||
score = seen.size;
|
||||
anchor = o.start;
|
||||
}
|
||||
}
|
||||
return { anchor, score };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import { page } from "$app/state";
|
||||
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||
import { queryForum, publishForum } from "$lib/relay";
|
||||
import { threads as mockThreads } from "$lib/mock";
|
||||
|
|
@ -10,40 +11,34 @@ import {
|
|||
buildPTagHints,
|
||||
} from "$lib/mentions";
|
||||
import { convertForumUrls } from "$lib/linkify";
|
||||
import {
|
||||
fetchThread,
|
||||
type PostData,
|
||||
type ThreadDetail,
|
||||
} from "$lib/forum/thread";
|
||||
|
||||
export type { PostData };
|
||||
|
||||
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
||||
|
||||
export type PostData = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
createdAt: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type ThreadDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
labels: string[];
|
||||
groupId: string; // the thread's NIP-29 group (its `h` tag)
|
||||
op: PostData;
|
||||
replies: PostData[];
|
||||
};
|
||||
|
||||
let detail = $state<ThreadDetail | null>(null);
|
||||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
// "notfound" means the relay returned nothing — either no such thread or it sits
|
||||
// in a private group the current (non-member) user can't read.
|
||||
let status = $state<"loading" | "ready" | "notfound">("loading");
|
||||
|
||||
// Until the live fetch lands, the server snapshot (if any) stands in. Its
|
||||
// profiles stay as a base layer: the live ones arrive one by one.
|
||||
export const threadDetailStore = {
|
||||
get detail() {
|
||||
return detail;
|
||||
return detail ?? page.data.thread ?? null;
|
||||
},
|
||||
get profiles() {
|
||||
return profiles;
|
||||
const base = page.data.profiles;
|
||||
return base ? { ...base, ...profiles } : profiles;
|
||||
},
|
||||
get status() {
|
||||
return status;
|
||||
return status === "loading" && page.data.thread ? "ready" : status;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -104,39 +99,13 @@ export async function loadThread(id: string) {
|
|||
return;
|
||||
}
|
||||
|
||||
const [threadEvents, replyEvents] = await Promise.all([
|
||||
queryForum({ kinds: [11], ids: [id] }),
|
||||
queryForum({ kinds: [1111], "#E": [id] }),
|
||||
]);
|
||||
|
||||
const event = threadEvents[0];
|
||||
if (!event) {
|
||||
const next = await fetchThread(queryForum, id, GROUP_ID);
|
||||
if (!next) {
|
||||
status = "notfound";
|
||||
return;
|
||||
}
|
||||
|
||||
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
||||
|
||||
detail = {
|
||||
id: event.id,
|
||||
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
||||
groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID,
|
||||
op: {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
content: event.content,
|
||||
},
|
||||
replies: replies.map((r) => ({
|
||||
id: r.id,
|
||||
pubkey: r.pubkey,
|
||||
createdAt: r.created_at,
|
||||
content: r.content,
|
||||
})),
|
||||
};
|
||||
|
||||
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||
detail = next;
|
||||
[next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||
status = "ready";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,15 @@
|
|||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||
import type { Event } from "@nostr/tools/core";
|
||||
import type { Filter } from "@nostr/tools/filter";
|
||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { page } from "$app/state";
|
||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||
import { ensureForumRelay } from "$lib/relay";
|
||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||
import { relayQuery } from "$lib/forum/query";
|
||||
import {
|
||||
fetchThreadPage,
|
||||
type SortMode,
|
||||
type ThreadData,
|
||||
} from "$lib/forum/threads";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
|
||||
const WALK_MAX_ITERS = 12;
|
||||
|
||||
export type SortMode = "active" | "new";
|
||||
|
||||
export type ThreadData = {
|
||||
id: string;
|
||||
title: string;
|
||||
labels: string[];
|
||||
authorPubkey: string;
|
||||
createdAt: number;
|
||||
replyCount: number;
|
||||
latestAt: number;
|
||||
latestPubkey: string;
|
||||
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
|
||||
};
|
||||
export type { SortMode, ThreadData };
|
||||
|
||||
let threads = $state<ThreadData[]>([]);
|
||||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
|
|
@ -34,15 +22,34 @@ let cursor: number | null = null; // sort-key value of the last loaded thread
|
|||
let snapshotAt = 0; // upper time bound, frozen at initial load
|
||||
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
||||
|
||||
// Which listing the client asked for, and whether its first page is in.
|
||||
let requested = $state<{ groupId: string; sort: SortMode } | null>(null);
|
||||
let settled = $state(false);
|
||||
|
||||
// The server snapshot stands in until the live listing lands, but only for
|
||||
// the listing that was requested: a different room or sort shows "Loading…".
|
||||
function fallback() {
|
||||
const s = page.data.threads;
|
||||
if (!s) return null;
|
||||
if (
|
||||
requested &&
|
||||
(requested.groupId !== s.groupId || requested.sort !== s.sort)
|
||||
)
|
||||
return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
export const threadStore = {
|
||||
get threads() {
|
||||
return threads;
|
||||
if (settled || threads.length > 0) return threads;
|
||||
return fallback()?.threads ?? threads;
|
||||
},
|
||||
get profiles() {
|
||||
return profiles;
|
||||
const base = fallback()?.profiles;
|
||||
return base ? { ...base, ...profiles } : profiles;
|
||||
},
|
||||
get exhausted() {
|
||||
return exhausted;
|
||||
return settled ? exhausted : (fallback()?.done ?? exhausted);
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
|
|
@ -59,238 +66,30 @@ async function loadProfile(pubkey: string) {
|
|||
ingestNostrUser(user);
|
||||
}
|
||||
|
||||
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: Event[] = [];
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(e) {
|
||||
events.push(e);
|
||||
},
|
||||
oneose() {
|
||||
sub.close();
|
||||
resolve(events);
|
||||
},
|
||||
onclose() {
|
||||
resolve(events);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function threadIdOf(e: Event): string | undefined {
|
||||
return e.kind === 11 ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
||||
}
|
||||
|
||||
type SliceItem = {
|
||||
id: string;
|
||||
latestAt: number;
|
||||
latestPubkey: string;
|
||||
op?: Event;
|
||||
};
|
||||
|
||||
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
|
||||
// threads not already shown. The first event seen for a thread defines its
|
||||
// activity timestamp. Returns the slice plus the cursor for the next call.
|
||||
async function fetchActivitySlice(
|
||||
relay: AbstractRelay,
|
||||
groupId: string,
|
||||
until: number,
|
||||
exclude: Set<string>,
|
||||
n: number,
|
||||
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
|
||||
const collected = new Map<string, SliceItem>();
|
||||
let cur = until;
|
||||
let done = false;
|
||||
|
||||
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
|
||||
const events = await querySync(relay, {
|
||||
kinds: [11, 1111],
|
||||
"#h": [groupId],
|
||||
until: cur,
|
||||
limit: WALK_LIMIT,
|
||||
});
|
||||
if (events.length === 0) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.created_at - a.created_at);
|
||||
let oldest = cur;
|
||||
for (const e of events) {
|
||||
oldest = Math.min(oldest, e.created_at);
|
||||
const id = threadIdOf(e);
|
||||
if (!id || exclude.has(id)) continue;
|
||||
const seen = collected.get(id);
|
||||
if (seen) {
|
||||
// Grab the OP from the stream when a thread was first seen via a reply,
|
||||
// so we avoid the by-id backfill the group relay truncates
|
||||
if (!seen.op && e.kind === 11) seen.op = e;
|
||||
continue;
|
||||
}
|
||||
if (collected.size >= n) continue; // Page full; keep scanning for OPs
|
||||
collected.set(id, {
|
||||
id,
|
||||
latestAt: e.created_at,
|
||||
latestPubkey: e.pubkey,
|
||||
op: e.kind === 11 ? e : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (events.length < WALK_LIMIT) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
if (oldest >= cur) break; // No progress (single timestamp floods the window)
|
||||
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
|
||||
}
|
||||
|
||||
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
|
||||
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
|
||||
return { items, nextCursor, done };
|
||||
}
|
||||
|
||||
// Chronological-by-creation slice: just OPs ordered by created_at. No reply
|
||||
// data is needed to order them (stable cursor), keeping the path cheap; reply
|
||||
// counts are still attached later via enrichment.
|
||||
async function fetchNewSlice(
|
||||
relay: AbstractRelay,
|
||||
groupId: string,
|
||||
until: number,
|
||||
exclude: Set<string>,
|
||||
n: number,
|
||||
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
|
||||
const ops = await querySync(relay, {
|
||||
kinds: [11],
|
||||
"#h": [groupId],
|
||||
until,
|
||||
limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor
|
||||
});
|
||||
ops.sort((a, b) => b.created_at - a.created_at);
|
||||
const fresh = ops.filter((e) => !exclude.has(e.id));
|
||||
const slice = fresh.slice(0, n);
|
||||
|
||||
const items: SliceItem[] = slice.map((op) => ({
|
||||
id: op.id,
|
||||
latestAt: op.created_at,
|
||||
latestPubkey: op.pubkey,
|
||||
op,
|
||||
}));
|
||||
const done = ops.length < n + 10;
|
||||
const last = slice[slice.length - 1] ?? ops[ops.length - 1];
|
||||
const nextCursor = last ? last.created_at : null;
|
||||
return { items, nextCursor, done };
|
||||
}
|
||||
|
||||
// Reply enrichment (exact counts + sampled repliers), bounded by the frozen
|
||||
// snapshot. Isolated so the future creation-by-date view can skip it entirely.
|
||||
async function enrichWithReplies(
|
||||
relay: AbstractRelay,
|
||||
groupId: string,
|
||||
items: SliceItem[],
|
||||
): Promise<Map<string, { count: number; repliers: string[] }>> {
|
||||
const result = new Map<string, { count: number; repliers: string[] }>();
|
||||
if (items.length === 0) return result;
|
||||
|
||||
const replies = await querySync(relay, {
|
||||
kinds: [1111],
|
||||
"#h": [groupId],
|
||||
"#E": items.map((it) => it.id),
|
||||
until: snapshotAt,
|
||||
limit: 5000,
|
||||
});
|
||||
|
||||
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
|
||||
for (const r of replies) {
|
||||
const root = r.tags.find((t) => t[0] === "E")?.[1];
|
||||
if (!root) continue;
|
||||
let a = acc.get(root);
|
||||
if (!a) {
|
||||
a = { count: 0, pubkeys: new Set() };
|
||||
acc.set(root, a);
|
||||
}
|
||||
a.count++;
|
||||
a.pubkeys.add(r.pubkey);
|
||||
}
|
||||
|
||||
for (const it of items) {
|
||||
const a = acc.get(it.id);
|
||||
const repliers = a
|
||||
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
|
||||
: [];
|
||||
result.set(it.id, { count: a?.count ?? 0, repliers });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function buildThreads(
|
||||
relay: AbstractRelay,
|
||||
groupId: string,
|
||||
items: SliceItem[],
|
||||
): Promise<ThreadData[]> {
|
||||
// Backfill OPs that fell outside the activity window. The group relay
|
||||
// truncates multi-id queries, so fetch each one on its own.
|
||||
const missing = items.filter((it) => !it.op);
|
||||
if (missing.length > 0) {
|
||||
const fetched = await Promise.all(
|
||||
missing.map((it) =>
|
||||
querySync(relay, { kinds: [11], "#h": [groupId], ids: [it.id] }),
|
||||
),
|
||||
);
|
||||
const byId = new Map<string, Event>();
|
||||
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
|
||||
for (const it of items) if (!it.op) it.op = byId.get(it.id);
|
||||
}
|
||||
|
||||
const enriched = await enrichWithReplies(relay, groupId, items);
|
||||
|
||||
const out: ThreadData[] = [];
|
||||
for (const it of items) {
|
||||
const op = it.op;
|
||||
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
|
||||
const e = enriched.get(it.id);
|
||||
out.push({
|
||||
id: it.id,
|
||||
title: op.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||
labels: op.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
||||
authorPubkey: op.pubkey,
|
||||
createdAt: op.created_at,
|
||||
replyCount: e?.count ?? 0,
|
||||
latestAt: it.latestAt,
|
||||
latestPubkey: it.latestPubkey,
|
||||
replierPubkeys: e?.repliers ?? [],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runLoad(append: boolean, groupId: string) {
|
||||
const id = ++reqId;
|
||||
if (append) loadingMore = true;
|
||||
else loading = true;
|
||||
|
||||
const relay = await ensureForumRelay();
|
||||
// Paged loads drive their own subscriptions on the shared connection
|
||||
const q = relayQuery(await ensureForumRelay());
|
||||
try {
|
||||
const until = append ? (cursor ?? snapshotAt) : snapshotAt;
|
||||
const exclude = new Set(threads.map((t) => t.id));
|
||||
const slice =
|
||||
sortMode === "new"
|
||||
? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE)
|
||||
: await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE);
|
||||
const built = await buildThreads(relay, groupId, slice.items);
|
||||
|
||||
const page = await fetchThreadPage(q, groupId, {
|
||||
sort: sortMode,
|
||||
until: append ? (cursor ?? snapshotAt) : snapshotAt,
|
||||
snapshotAt,
|
||||
exclude: new Set(threads.map((t) => t.id)),
|
||||
});
|
||||
if (id !== reqId) return; // Superseded by a newer load — discard results
|
||||
|
||||
threads = append ? [...threads, ...built] : built;
|
||||
cursor = slice.nextCursor ?? cursor;
|
||||
exhausted = slice.done || built.length === 0;
|
||||
|
||||
for (const t of built) {
|
||||
threads = append ? [...threads, ...page.threads] : page.threads;
|
||||
cursor = page.nextCursor ?? cursor;
|
||||
exhausted = page.done || page.threads.length === 0;
|
||||
settled = true;
|
||||
for (const t of page.threads) {
|
||||
loadProfile(t.authorPubkey);
|
||||
loadProfile(t.latestPubkey);
|
||||
for (const p of t.replierPubkeys) loadProfile(p);
|
||||
}
|
||||
} finally {
|
||||
// shared forum connection is long-lived — don't close it here
|
||||
if (id === reqId) {
|
||||
if (append) loadingMore = false;
|
||||
else loading = false;
|
||||
|
|
@ -300,6 +99,8 @@ async function runLoad(append: boolean, groupId: string) {
|
|||
|
||||
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
||||
sortMode = sort;
|
||||
requested = { groupId, sort };
|
||||
settled = false;
|
||||
threads = [];
|
||||
cursor = null;
|
||||
exhausted = false;
|
||||
|
|
|
|||
81
src/lib/userPosts.svelte.ts
Normal file
81
src/lib/userPosts.svelte.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import {
|
||||
fetchUserPosts,
|
||||
fetchUserPostPage,
|
||||
nextCursor,
|
||||
type UserPostKind,
|
||||
type UserPosts,
|
||||
} from "$lib/forum/userPosts";
|
||||
|
||||
export type { UserPosts, UserPostKind };
|
||||
|
||||
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
|
||||
|
||||
let pubkey = $state<string | null>(null);
|
||||
let posts = $state<UserPosts | null>(null);
|
||||
let loadingMore = $state<Record<UserPostKind, boolean>>({
|
||||
roots: false,
|
||||
replies: false,
|
||||
});
|
||||
let initial: Promise<void> | null = null;
|
||||
|
||||
// Posts of the profile last loaded. Consumers check `pubkey` against the
|
||||
// page's, so a stale result never shows under another user's name.
|
||||
export const userPostsStore = {
|
||||
get pubkey() {
|
||||
return pubkey;
|
||||
},
|
||||
get posts() {
|
||||
return posts;
|
||||
},
|
||||
get loadingMore() {
|
||||
return loadingMore;
|
||||
},
|
||||
};
|
||||
|
||||
export function loadUserPosts(pk: string): Promise<void> {
|
||||
pubkey = pk;
|
||||
posts = null;
|
||||
loadingMore = { roots: false, replies: false };
|
||||
initial = (async () => {
|
||||
let result: UserPosts = {
|
||||
roots: { items: [], done: true },
|
||||
replies: { items: [], done: true },
|
||||
};
|
||||
try {
|
||||
result = await fetchUserPosts(queryForum, pk, scope());
|
||||
} catch (e) {
|
||||
console.error("[profile] failed to load posts", e);
|
||||
}
|
||||
if (pubkey === pk) posts = result;
|
||||
})();
|
||||
return initial;
|
||||
}
|
||||
|
||||
// Append the next page of one column. Waits for the initial load when the
|
||||
// page still shows the server snapshot.
|
||||
export async function loadMoreUserPosts(kind: UserPostKind): Promise<void> {
|
||||
const pk = pubkey;
|
||||
if (!pk || loadingMore[kind]) return;
|
||||
loadingMore[kind] = true;
|
||||
try {
|
||||
await initial;
|
||||
const current = posts?.[kind];
|
||||
if (!posts || !current || current.done || pubkey !== pk) return;
|
||||
const next = await fetchUserPostPage(queryForum, pk, kind, {
|
||||
groupId: scope(),
|
||||
until: nextCursor(current),
|
||||
exclude: new Set(current.items.map((p) => p.id)),
|
||||
});
|
||||
if (pubkey !== pk) return;
|
||||
posts[kind] = {
|
||||
items: [...current.items, ...next.items],
|
||||
done: next.done,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[profile] failed to load more posts", e);
|
||||
} finally {
|
||||
loadingMore[kind] = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
||||
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
||||
|
|
@ -12,9 +11,10 @@
|
|||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
||||
import Toast from "$lib/components/Toast.svelte";
|
||||
import RelayGate from "$lib/components/RelayGate.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||
import { loadGroup } from "$lib/group.svelte";
|
||||
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||
|
|
@ -25,12 +25,15 @@
|
|||
import { seedProfiles } from "$lib/profiles.svelte";
|
||||
import { startChat } from "$lib/chat.svelte";
|
||||
import { resetForumConnection } from "$lib/relay";
|
||||
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||
import {
|
||||
MODE,
|
||||
ACCENT_COLOR,
|
||||
SECONDARY_COLOR,
|
||||
SEARCH_ENABLED,
|
||||
FAVICON,
|
||||
RELAY_URL,
|
||||
} from "$lib/config";
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -62,27 +65,43 @@
|
|||
// The main column is its own scroll container (overflow-y-auto) and persists
|
||||
// across navigations, so SvelteKit's window-only scroll restoration never
|
||||
// resets it. Scroll it back to top on forward navigation; leave back/forward
|
||||
// (popstate) alone so returning to a listing keeps its place.
|
||||
// (popstate) alone so returning to a listing keeps its place, and hash
|
||||
// targets (#post-…) alone so the page can place the anchored post itself.
|
||||
let mainEl = $state<HTMLElement | null>(null);
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type !== "popstate") mainEl?.scrollTo(0, 0);
|
||||
if (nav.type !== "popstate" && !nav.to?.url.hash) mainEl?.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Nothing is fetched until the relay confirms it will serve this visitor:
|
||||
// the probe runs once the stored signer is in place (restoreSession sets it
|
||||
// synchronously, before its profile fetch), and every probe that ends open
|
||||
// (re)loads the forum below.
|
||||
onMount(() => {
|
||||
restoreSession();
|
||||
probeRelayAccess();
|
||||
});
|
||||
|
||||
async function loadForum() {
|
||||
loadResources();
|
||||
loadPartials();
|
||||
const tasks = [restoreSession(), loadGroup()];
|
||||
const tasks = [loadGroup()];
|
||||
if (mode === "full") tasks.push(loadGroups());
|
||||
await Promise.all(tasks);
|
||||
// Resources are filtered by the admin set; in full mode that means every
|
||||
// room's admins, needed on every page for the sidebar.
|
||||
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
|
||||
seedProfiles(auth.user?.pubkey ?? null);
|
||||
}
|
||||
|
||||
// Each completed probe that finds the relay open reloads the room views: on
|
||||
// first load, after a login (private/hidden rooms appear) or logout (they
|
||||
// vanish), and after joining a members-only relay.
|
||||
$effect(() => {
|
||||
relayAccess.probes;
|
||||
if (relayAccess.state === "open") untrack(loadForum);
|
||||
});
|
||||
|
||||
// Reload the room views when the user logs in or out: a logged-in member sees
|
||||
// their private/hidden rooms, so the listing must be re-fetched over the now
|
||||
// (de)authenticated connection. sessionEpoch only bumps on explicit
|
||||
// Re-probe when the user logs in or out. sessionEpoch only bumps on explicit
|
||||
// login/logout, never on the silent restore that onMount already covers.
|
||||
let lastEpoch = -1;
|
||||
$effect(() => {
|
||||
|
|
@ -91,17 +110,12 @@
|
|||
const first = lastEpoch === -1;
|
||||
const loggedOut = auth.user === null;
|
||||
lastEpoch = epoch;
|
||||
if (first) return; // initial mount: onMount already loaded everything
|
||||
if (first) return;
|
||||
// On logout, drop the authenticated connection so the relay stops serving
|
||||
// the previous user's private rooms; login reuses the open connection,
|
||||
// which ensureForumReady authenticates via its stored challenge.
|
||||
if (loggedOut) resetForumConnection();
|
||||
loadGroup();
|
||||
if (mode === "full") {
|
||||
loadGroups().then(() =>
|
||||
loadRoomAdmins(groupsStore.list.map((g) => g.id)),
|
||||
);
|
||||
}
|
||||
probeRelayAccess();
|
||||
});
|
||||
|
||||
// Full mode: the room route defines the active group. Thread pages set it
|
||||
|
|
@ -164,9 +178,14 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href={FAVICON} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- 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 ===
|
||||
'chat'
|
||||
|
|
@ -192,10 +211,33 @@
|
|||
it first and it reappears once the top is reached. Desktop only. -->
|
||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||
<div
|
||||
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 dark:bg-neutral-900"
|
||||
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()}
|
||||
</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>
|
||||
{#if showChat}
|
||||
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
||||
|
|
@ -279,6 +321,7 @@
|
|||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<MobileMenu
|
||||
open={menuOpen}
|
||||
|
|
|
|||
|
|
@ -1 +1,15 @@
|
|||
export const ssr = false;
|
||||
import { browser } from "$app/environment";
|
||||
import { CACHE_CONTROL, SSR_ENABLED } from "$lib/config";
|
||||
import { snapshot, type ForumShell } from "$lib/forum/snapshot";
|
||||
|
||||
export const ssr = SSR_ENABLED;
|
||||
|
||||
// Runs for every page, so the cache policy for rendered HTML is set here. A
|
||||
// page rendered without its shell (relay down or refusing anonymous reads) is
|
||||
// an empty placeholder the client fills in, not worth keeping in a shared cache.
|
||||
export async function load({ fetch, setHeaders }) {
|
||||
const shell = await snapshot<ForumShell>(fetch, "/api/shell");
|
||||
if (!browser)
|
||||
setHeaders({ "cache-control": shell ? CACHE_CONTROL : "no-store" });
|
||||
return { shell };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,26 @@
|
|||
import { partialsStore } from "$lib/partials.svelte";
|
||||
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
||||
import SearchInline from "$lib/components/SearchInline.svelte";
|
||||
import Meta, { siteName } from "$lib/components/Meta.svelte";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { excerpt } from "$lib/seo";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
|
||||
const partial = $derived(partialsStore.get("home"));
|
||||
|
||||
const description = $derived(
|
||||
partial
|
||||
? excerpt(partial.content)
|
||||
: MODE === "simple"
|
||||
? (groupStore.data?.about ?? "")
|
||||
: "",
|
||||
);
|
||||
// A partial's leading image doubles as the social preview.
|
||||
const image = $derived(
|
||||
partial?.content.match(/^https?:\/\/\S+\.(?:png|jpe?g|webp|gif)$/im)?.[0] ??
|
||||
groupStore.data?.picture,
|
||||
);
|
||||
|
||||
// Full mode: load per-room activity + recent threads once the rooms are known.
|
||||
$effect(() => {
|
||||
if (MODE !== "full") return;
|
||||
|
|
@ -35,9 +51,11 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
|
||||
</svelte:head>
|
||||
<Meta
|
||||
title={siteName() || (MODE === "full" ? "Rooms" : "Discussions")}
|
||||
{description}
|
||||
{image}
|
||||
/>
|
||||
|
||||
{#snippet pic(a: { name: string; picture?: string })}
|
||||
{#if a.picture}
|
||||
|
|
|
|||
20
src/routes/+page.ts
Normal file
20
src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import {
|
||||
snapshot,
|
||||
type OverviewSnapshot,
|
||||
type ThreadsSnapshot,
|
||||
} from "$lib/forum/snapshot";
|
||||
|
||||
// Simple mode: the single group's listing. Full mode: the room overview.
|
||||
export async function load({ fetch, url }) {
|
||||
if (MODE === "simple") {
|
||||
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||
const threads = await snapshot<ThreadsSnapshot>(
|
||||
fetch,
|
||||
`/api/threads/${GROUP_ID}${sort}`,
|
||||
);
|
||||
return { threads, profiles: threads?.profiles ?? null };
|
||||
}
|
||||
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||
return { overview, profiles: overview?.profiles ?? null };
|
||||
}
|
||||
11
src/routes/api/contacts/+server.ts
Normal file
11
src/routes/api/contacts/+server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import { fetchProfiles } from "$lib/forum/profiles";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { loadShell } from "$lib/ssr/shell";
|
||||
import { profileQuery } from "$lib/ssr/relay";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||
const shell = await loadShell();
|
||||
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
|
||||
});
|
||||
22
src/routes/api/overview/+server.ts
Normal file
22
src/routes/api/overview/+server.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroups } from "$lib/forum/groups";
|
||||
import { fetchOverview, overviewPubkeys } from "$lib/forum/overview";
|
||||
import { fetchProfiles } from "$lib/forum/profiles";
|
||||
import type { OverviewSnapshot } from "$lib/forum/snapshot";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
// Room activity and the latest discussions: every room in full mode, the
|
||||
// single group in simple mode (mirrors LatestDiscussions' own room set).
|
||||
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||
const roomIds =
|
||||
MODE === "full"
|
||||
? (await fetchGroups(forumQuery)).map((g) => g.id)
|
||||
: [GROUP_ID];
|
||||
const overview = await fetchOverview(forumQuery, roomIds);
|
||||
const profiles = await fetchProfiles(profileQuery, overviewPubkeys(overview));
|
||||
const body: OverviewSnapshot = { roomIds, ...overview, profiles };
|
||||
return json(body);
|
||||
});
|
||||
21
src/routes/api/profile/[id]/+server.ts
Normal file
21
src/routes/api/profile/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { error, json } from "@sveltejs/kit";
|
||||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
|
||||
import { fetchUserPosts } from "$lib/forum/userPosts";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||
const pubkey = decodeProfileId(params.id);
|
||||
if (!pubkey) error(404, "Not found");
|
||||
const [profiles, posts] = await Promise.all([
|
||||
fetchProfiles(profileQuery, [pubkey]),
|
||||
fetchUserPosts(
|
||||
forumQuery,
|
||||
pubkey,
|
||||
MODE === "simple" ? GROUP_ID : undefined,
|
||||
),
|
||||
]);
|
||||
return json({ pubkey, posts, profiles });
|
||||
});
|
||||
8
src/routes/api/shell/+server.ts
Normal file
8
src/routes/api/shell/+server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { loadShell } from "$lib/ssr/shell";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = snapshotHandler(async () =>
|
||||
json(await loadShell()),
|
||||
);
|
||||
15
src/routes/api/thread/[id]/+server.ts
Normal file
15
src/routes/api/thread/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { error, json } from "@sveltejs/kit";
|
||||
import { GROUP_ID } from "$lib/config";
|
||||
import { fetchThread, threadAuthors } from "$lib/forum/thread";
|
||||
import { fetchProfiles } from "$lib/forum/profiles";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||
if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found");
|
||||
const thread = await fetchThread(forumQuery, params.id, GROUP_ID);
|
||||
if (!thread) error(404, "Not found");
|
||||
const profiles = await fetchProfiles(profileQuery, threadAuthors(thread));
|
||||
return json({ thread, profiles });
|
||||
});
|
||||
45
src/routes/api/threads/[group]/+server.ts
Normal file
45
src/routes/api/threads/[group]/+server.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { error, json } from "@sveltejs/kit";
|
||||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroups } from "$lib/forum/groups";
|
||||
import {
|
||||
fetchThreadPage,
|
||||
threadPagePubkeys,
|
||||
type SortMode,
|
||||
} from "$lib/forum/threads";
|
||||
import { fetchProfiles } from "$lib/forum/profiles";
|
||||
import type { ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
// First page of a group's listing. Unknown rooms are a 404 so crawlers don't
|
||||
// index empty shells; private rooms are simply empty to the anonymous server.
|
||||
export const GET: RequestHandler = snapshotHandler(async ({ params, url }) => {
|
||||
const groupId = params.group;
|
||||
if (MODE === "simple") {
|
||||
if (groupId !== GROUP_ID) error(404, "Not found");
|
||||
} else {
|
||||
const groups = await fetchGroups(forumQuery);
|
||||
if (!groups.some((g) => g.id === groupId)) error(404, "Not found");
|
||||
}
|
||||
const sort: SortMode =
|
||||
url.searchParams.get("sort") === "new" ? "new" : "active";
|
||||
const snapshotAt = Math.floor(Date.now() / 1000);
|
||||
const page = await fetchThreadPage(forumQuery, groupId, {
|
||||
sort,
|
||||
until: snapshotAt,
|
||||
snapshotAt,
|
||||
});
|
||||
const profiles = await fetchProfiles(
|
||||
profileQuery,
|
||||
threadPagePubkeys(page.threads),
|
||||
);
|
||||
const body: ThreadsSnapshot = {
|
||||
groupId,
|
||||
sort,
|
||||
snapshotAt,
|
||||
...page,
|
||||
profiles,
|
||||
};
|
||||
return json(body);
|
||||
});
|
||||
|
|
@ -11,10 +11,14 @@
|
|||
import {
|
||||
profileStore,
|
||||
ensureProfile,
|
||||
entryFromUser,
|
||||
type ProfileEntry,
|
||||
} from "$lib/profiles.svelte";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import { partialsStore } from "$lib/partials.svelte";
|
||||
import PostContent from "$lib/components/PostContent.svelte";
|
||||
import { page } from "$app/state";
|
||||
import Meta from "$lib/components/Meta.svelte";
|
||||
|
||||
const partial = $derived(partialsStore.get("contacts"));
|
||||
|
||||
|
|
@ -48,11 +52,17 @@
|
|||
|
||||
const contacts = $derived<Contact[]>(
|
||||
adminPubkeys.list.map((pk) => {
|
||||
const entry = profileStore.profiles.get(pk);
|
||||
const entry = profileStore.profiles.get(pk) ?? snapshotEntry(pk);
|
||||
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
||||
}),
|
||||
);
|
||||
|
||||
// Server-rendered profile, used until the live one is fetched.
|
||||
function snapshotEntry(pk: string): ProfileEntry | undefined {
|
||||
const u = page.data.profiles?.[pk];
|
||||
return u ? entryFromUser(u) : undefined;
|
||||
}
|
||||
|
||||
function displayName(c: Contact): string {
|
||||
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
||||
}
|
||||
|
|
@ -66,10 +76,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Contacts</title>
|
||||
<meta name="description" content="Admins and contacts for this community." />
|
||||
</svelte:head>
|
||||
<Meta title="Contacts" description="Admins and contacts for this community." />
|
||||
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
|
||||
|
|
@ -81,34 +88,49 @@
|
|||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||
Loading…
|
||||
</p>
|
||||
{:else if contacts.length === 0}
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">No admins listed.</p>
|
||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||
No admins listed.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-3">
|
||||
{#each contacts as c (c.pubkey)}
|
||||
<li
|
||||
class="rounded-lg border border-neutral-100 dark:border-neutral-800 p-4 shadow-sm"
|
||||
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<a
|
||||
href={profilePath(c.pubkey)}
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if c.entry?.picture}
|
||||
<img
|
||||
src={c.entry.picture}
|
||||
alt=""
|
||||
class="h-14 w-14 shrink-0 rounded-full object-cover"
|
||||
class="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-lg font-semibold text-neutral-500 dark:text-neutral-400"
|
||||
class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
{displayName(c)[0].toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<p
|
||||
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<a href={profilePath(c.pubkey)} class="hover:text-accent">
|
||||
{displayName(c)}
|
||||
</a>
|
||||
</p>
|
||||
{#if c.entry?.nip05}
|
||||
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||
|
|
@ -117,19 +139,19 @@
|
|||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href="https://nostr.basspistol.org/users/{c.npub}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={profilePath(c.pubkey)}
|
||||
class="text-accent shrink-0 text-sm hover:underline"
|
||||
>
|
||||
View profile ↗
|
||||
View profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 md:pl-18">
|
||||
{#if c.entry?.about}
|
||||
<p class="whitespace-pre-line text-neutral-600 dark:text-neutral-400">
|
||||
<p
|
||||
class="whitespace-pre-line text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{c.entry.about}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -147,7 +169,10 @@
|
|||
</a>
|
||||
{/if}
|
||||
{#if c.entry?.lud16}
|
||||
<span class="text-neutral-500 dark:text-neutral-400" title="Lightning address">
|
||||
<span
|
||||
class="text-neutral-500 dark:text-neutral-400"
|
||||
title="Lightning address"
|
||||
>
|
||||
⚡ {c.entry.lud16}
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -158,7 +183,9 @@
|
|||
{@const rooms = roomsOf(c.pubkey)}
|
||||
{#if rooms.length > 0}
|
||||
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||
<span class="text-neutral-400 dark:text-neutral-500">Manages:</span>
|
||||
<span class="text-neutral-400 dark:text-neutral-500"
|
||||
>Manages:</span
|
||||
>
|
||||
{#each rooms as r, i}<a
|
||||
href="/room/{r.id}"
|
||||
class="text-accent hover:underline">{r.name}</a
|
||||
|
|
|
|||
17
src/routes/contacts/+page.ts
Normal file
17
src/routes/contacts/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {
|
||||
snapshot,
|
||||
type OverviewSnapshot,
|
||||
type Profiles,
|
||||
} from "$lib/forum/snapshot";
|
||||
|
||||
// Admin profiles for the cards, plus the latest-discussions panel beside them.
|
||||
export async function load({ fetch }) {
|
||||
const [contacts, overview] = await Promise.all([
|
||||
snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"),
|
||||
snapshot<OverviewSnapshot>(fetch, "/api/overview"),
|
||||
]);
|
||||
return {
|
||||
overview,
|
||||
profiles: { ...overview?.profiles, ...contacts?.profiles },
|
||||
};
|
||||
}
|
||||
|
|
@ -65,6 +65,15 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
/* Search terms painted on thread pages via the CSS Custom Highlight API,
|
||||
matching the <mark> tint used in the search results. */
|
||||
::highlight(search-terms) {
|
||||
background-color: color-mix(in srgb, var(--color-secondary) 40%, transparent);
|
||||
}
|
||||
.dark ::highlight(search-terms) {
|
||||
background-color: color-mix(in srgb, var(--color-secondary) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
||||
typography plugin's em-based heading margins; the first block stays flush. */
|
||||
.prose :is(h1, h2, h3, h4, h5, h6) {
|
||||
|
|
@ -76,6 +85,12 @@ body {
|
|||
font-size: 1.65rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.prose :is(h2) {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.prose :is(h3) {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.prose :is(p) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -107,10 +122,6 @@ body {
|
|||
|
||||
/* setto*/
|
||||
|
||||
a[href*="iapochoopl"] {
|
||||
display:none
|
||||
}
|
||||
|
||||
a[href*="a1aad3a97e1dbec6ca85de96a3fc96249595c545f6085baa75b14761f85e6669"] {
|
||||
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 { adminPubkeys } from "$lib/admins.svelte";
|
||||
import PostContent from "$lib/components/PostContent.svelte";
|
||||
import Meta from "$lib/components/Meta.svelte";
|
||||
import { excerpt } from "$lib/seo";
|
||||
|
||||
const slug = $derived(page.params.slug ?? "");
|
||||
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
||||
|
|
@ -10,23 +12,10 @@
|
|||
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
||||
const notFound = $derived(ready && !resource);
|
||||
|
||||
// First non-empty line of the body, trimmed of markdown markers, for SEO.
|
||||
const description = $derived(
|
||||
(resource?.content ?? "")
|
||||
.replace(/^#+\s*/gm, "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find(Boolean)
|
||||
?.slice(0, 160) ?? "",
|
||||
);
|
||||
const description = $derived(excerpt(resource?.content ?? ""));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{resource?.title ?? "Resource"}</title>
|
||||
{#if description}
|
||||
<meta name="description" content={description} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<Meta title={resource?.title ?? "Resource"} {description} type="article" />
|
||||
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<a
|
||||
|
|
@ -56,8 +45,12 @@
|
|||
<PostContent content={resource.content} headingOffset={0} />
|
||||
</div>
|
||||
{:else if notFound}
|
||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Resource not found.</p>
|
||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||
Resource not found.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
|
||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||
Loading…
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
17
src/routes/resource/[slug]/+page.ts
Normal file
17
src/routes/resource/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { browser } from "$app/environment";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { snapshot, type OverviewSnapshot } from "$lib/forum/snapshot";
|
||||
|
||||
// Articles show the latest-discussions panel next to the text. An unknown
|
||||
// slug is a real 404 on the server; the client keeps its in-page message.
|
||||
export async function load({ fetch, params, parent }) {
|
||||
if (!browser) {
|
||||
const { shell } = await parent();
|
||||
const known = shell?.resources.some(
|
||||
(r) => r.slug === params.slug && shell.admins.includes(r.pubkey),
|
||||
);
|
||||
if (shell && !known) error(404, "Not found");
|
||||
}
|
||||
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||
return { overview, profiles: overview?.profiles ?? null };
|
||||
}
|
||||
13
src/routes/robots.txt/+server.ts
Normal file
13
src/routes/robots.txt/+server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { SSR_ENABLED } from "$lib/config";
|
||||
|
||||
// Static builds bake it in at build time (no sitemap to point at); the server
|
||||
// build answers live so the sitemap URL carries the real origin.
|
||||
export const prerender = !SSR_ENABLED;
|
||||
|
||||
export function GET({ url }) {
|
||||
const lines = ["User-agent: *", "Disallow:"];
|
||||
if (SSR_ENABLED) lines.push(`Sitemap: ${url.origin}/sitemap.xml`);
|
||||
return new Response(lines.join("\n") + "\n", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
10
src/routes/room/[slug]/+page.ts
Normal file
10
src/routes/room/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { snapshot, type ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||
|
||||
export async function load({ fetch, params, url }) {
|
||||
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||
const threads = await snapshot<ThreadsSnapshot>(
|
||||
fetch,
|
||||
`/api/threads/${params.slug}${sort}`,
|
||||
);
|
||||
return { threads, profiles: threads?.profiles ?? null };
|
||||
}
|
||||
57
src/routes/sitemap.xml/+server.ts
Normal file
57
src/routes/sitemap.xml/+server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
import { fetchGroups } from "$lib/forum/groups";
|
||||
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||
import { forumQuery } from "$lib/ssr/relay";
|
||||
import { loadShell } from "$lib/ssr/shell";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
const THREADS_PER_ROOM = 1000;
|
||||
|
||||
type Entry = { path: string; lastmod?: number };
|
||||
|
||||
function xml(origin: string, entries: Entry[]): string {
|
||||
const items = entries.map((e) => {
|
||||
const loc = `<loc>${origin}${e.path}</loc>`;
|
||||
const mod = e.lastmod
|
||||
? `<lastmod>${new Date(e.lastmod * 1000).toISOString()}</lastmod>`
|
||||
: "";
|
||||
return `<url>${loc}${mod}</url>`;
|
||||
});
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
|
||||
items.join("") +
|
||||
"</urlset>"
|
||||
);
|
||||
}
|
||||
|
||||
// Public pages only: rooms the anonymous server can see, their threads, and
|
||||
// the admin-authored resources.
|
||||
export const GET: RequestHandler = snapshotHandler(async ({ url }) => {
|
||||
const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }];
|
||||
const shell = await loadShell();
|
||||
const admins = new Set(shell.admins);
|
||||
for (const r of shell.resources) {
|
||||
if (admins.has(r.pubkey))
|
||||
entries.push({ path: `/resource/${r.slug}`, lastmod: r.createdAt });
|
||||
}
|
||||
const roomIds =
|
||||
MODE === "full"
|
||||
? (await fetchGroups(forumQuery))
|
||||
.filter((g) => !g.flags.includes("private"))
|
||||
.map((g) => g.id)
|
||||
: [GROUP_ID];
|
||||
for (const id of roomIds) {
|
||||
if (MODE === "full") entries.push({ path: `/room/${id}` });
|
||||
const ops = await forumQuery({
|
||||
kinds: [11],
|
||||
"#h": [id],
|
||||
limit: THREADS_PER_ROOM,
|
||||
});
|
||||
for (const e of ops)
|
||||
entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at });
|
||||
}
|
||||
return new Response(xml(url.origin, entries), {
|
||||
headers: { "content-type": "application/xml" },
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate, goto } from "$app/navigation";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import {
|
||||
threadDetailStore,
|
||||
|
|
@ -19,17 +19,21 @@
|
|||
import { showToast } from "$lib/toast.svelte";
|
||||
import {
|
||||
withJoin,
|
||||
joinState,
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
} from "$lib/join.svelte";
|
||||
import { setActiveGroup } from "$lib/active.svelte";
|
||||
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
||||
import { RELAY_URL, MODE } from "$lib/config";
|
||||
import { profilePath } from "$lib/forum/profiles";
|
||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||
import PostContent from "$lib/components/PostContent.svelte";
|
||||
import Tag from "$lib/components/Tag.svelte";
|
||||
import { type NostrUser } from "@nostr/gadgets/metadata";
|
||||
import Meta from "$lib/components/Meta.svelte";
|
||||
import { excerpt } from "$lib/seo";
|
||||
import type { NostrUser } from "$lib/gadgets";
|
||||
|
||||
type Author = { pubkey: string; name: string; picture?: string };
|
||||
|
||||
|
|
@ -119,6 +123,25 @@
|
|||
const detail = $derived(threadDetailStore.detail);
|
||||
const profiles = $derived(threadDetailStore.profiles);
|
||||
|
||||
// Structured data for crawlers: the OP as a forum posting.
|
||||
const jsonLd = $derived(
|
||||
detail
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
headline: detail.title,
|
||||
text: detail.op.content,
|
||||
url: page.url.origin + page.url.pathname,
|
||||
datePublished: new Date(detail.op.createdAt * 1000).toISOString(),
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: resolveAuthor(detail.op.pubkey, profiles).name,
|
||||
},
|
||||
commentCount: detail.replies.length,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Replying needs membership regardless of flags. Only gate a confirmed guest;
|
||||
// while membership resolves the editor stays and submit awaits the check, so a
|
||||
// member never flashes "Join to reply".
|
||||
|
|
@ -135,7 +158,7 @@
|
|||
|
||||
function onJoinToReply() {
|
||||
if (!detail) return;
|
||||
openJoinModal(detail.groupId, async () => {
|
||||
withJoin(detail.groupId, async () => {
|
||||
await tick();
|
||||
editorEl?.focus();
|
||||
});
|
||||
|
|
@ -185,6 +208,20 @@
|
|||
);
|
||||
}
|
||||
|
||||
// The OP shares the thread URL, a reply its anchored URL.
|
||||
async function shareLink(p: PostData, isOp: boolean) {
|
||||
if (!detail) return;
|
||||
openMenuId = null;
|
||||
const url =
|
||||
page.url.origin + `/thread/${detail.id}` + (isOp ? "" : `#post-${p.id}`);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast("Link copied");
|
||||
} catch {
|
||||
showToast("Could not copy the link");
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!openMenuId) return;
|
||||
const close = () => (openMenuId = null);
|
||||
|
|
@ -210,6 +247,89 @@
|
|||
if (page.params.id) loadThread(page.params.id);
|
||||
});
|
||||
|
||||
// Arriving from search (?q=…): paint the terms over the title and post
|
||||
// bodies, and scroll to the first match once per navigation. Re-runs as
|
||||
// replies stream in so late matches get painted too.
|
||||
let threadEl = $state<HTMLElement | null>(null);
|
||||
let scrolledFor = "";
|
||||
$effect(() => {
|
||||
const q = page.url.searchParams.get("q")?.trim() ?? "";
|
||||
allPosts; // Repaint when posts load or change
|
||||
if (!threadEl || !q) {
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]");
|
||||
const first = applyHighlights(roots, q);
|
||||
const key = `${page.params.id}|${q}`;
|
||||
if (first && scrolledFor !== key) {
|
||||
scrolledFor = key;
|
||||
first.startContainer.parentElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
return clearHighlights;
|
||||
});
|
||||
|
||||
// Arriving with a #post-… hash (a reply link): scroll to the post once per
|
||||
// navigation. It may only exist once the replies have loaded, so re-check as
|
||||
// they stream in; on a direct load it is already there and SvelteKit's own
|
||||
// deep-link scroll runs after ours, so afterNavigate applies ours again.
|
||||
let scrolledHashFor = "";
|
||||
function scrollToHash(force = false) {
|
||||
const hash = page.url.hash;
|
||||
if (!threadEl || !hash.startsWith("#post-")) return;
|
||||
const key = `${page.params.id}|${hash}`;
|
||||
if (!force && scrolledHashFor === key) return;
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (!el) return;
|
||||
scrolledHashFor = key;
|
||||
scrollToPost(el);
|
||||
}
|
||||
$effect(() => {
|
||||
allPosts;
|
||||
scrollToHash();
|
||||
});
|
||||
afterNavigate(() => scrollToHash(true));
|
||||
|
||||
// On desktop the thread scrolls inside <main> under a sticky title with a
|
||||
// fade below it, so place the post under both; elsewhere the scroll margin
|
||||
// is enough.
|
||||
const HEADER_FADE = 32; // the h-8 gradient
|
||||
const HEADER_GAP = 12;
|
||||
function scrollToPost(el: HTMLElement) {
|
||||
const main = document.querySelector("main");
|
||||
const header = threadEl?.querySelector("[data-thread-header]");
|
||||
if (!main || !header || !window.matchMedia("(min-width: 768px)").matches) {
|
||||
el.scrollIntoView({ block: "start" });
|
||||
return;
|
||||
}
|
||||
const headerBottom =
|
||||
main.getBoundingClientRect().top + header.getBoundingClientRect().height;
|
||||
const offset = headerBottom + HEADER_FADE + HEADER_GAP;
|
||||
main.scrollTop += el.getBoundingClientRect().top - offset;
|
||||
}
|
||||
|
||||
// In-thread quote links ("… said") get the same placement instead of the
|
||||
// browser's default anchor jump.
|
||||
$effect(() => {
|
||||
const root = threadEl;
|
||||
if (!root) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const a = (e.target as Element).closest("a[href^='#post-']");
|
||||
if (!a) return;
|
||||
const hash = a.getAttribute("href") ?? "";
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
scrolledHashFor = `${page.params.id}|${hash}`;
|
||||
goto(hash, { noScroll: true });
|
||||
scrollToPost(el);
|
||||
};
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
});
|
||||
|
||||
// A thread belongs to its own group; make that the active group so chat and
|
||||
// replies target the right room.
|
||||
$effect(() => {
|
||||
|
|
@ -290,18 +410,34 @@
|
|||
await quotePost(post, text);
|
||||
}
|
||||
|
||||
// The scrubber sticks where the OP starts. Measure independently of the
|
||||
// current scroll (the page may mount already scrolled, e.g. arriving from a
|
||||
// profile with a reply link) and again when the header changes height.
|
||||
$effect(() => {
|
||||
if (!opEl) return;
|
||||
const el = opEl;
|
||||
const header = threadEl?.querySelector("[data-thread-header]");
|
||||
if (!el || !header) return;
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
const measure = () => {
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{detail?.title ?? "Thread"}</title>
|
||||
</svelte:head>
|
||||
<Meta
|
||||
title={detail?.title ?? "Thread"}
|
||||
description={detail ? excerpt(detail.op.content) : ""}
|
||||
type="article"
|
||||
{jsonLd}
|
||||
/>
|
||||
|
||||
{#snippet avatar(author: Author)}
|
||||
{#if author.picture}
|
||||
|
|
@ -326,26 +462,34 @@
|
|||
)}
|
||||
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
||||
<div class="hidden flex-shrink-0 md:block">
|
||||
<a
|
||||
href={profilePath(author.pubkey)}
|
||||
class="hidden flex-shrink-0 md:block"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{@render avatar(author)}
|
||||
</div>
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="flex-shrink-0 md:hidden">
|
||||
<a
|
||||
href={profilePath(author.pubkey)}
|
||||
class="flex-shrink-0 md:hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{@render avatar(author)}
|
||||
</div>
|
||||
<span
|
||||
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>{author.name}</span
|
||||
</a>
|
||||
<a
|
||||
href={profilePath(author.pubkey)}
|
||||
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>{author.name}</a
|
||||
>
|
||||
</div>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||
{#if canDeletePost(p)}
|
||||
{@const tooOld =
|
||||
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => toggleMenu(p.id, e)}
|
||||
|
|
@ -370,6 +514,18 @@
|
|||
role="menu"
|
||||
class="absolute top-7 right-0 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
shareLink(p, index === 0);
|
||||
}}
|
||||
class="w-full px-3 py-1.5 text-left text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>Share link</button
|
||||
>
|
||||
{#if canDeletePost(p)}
|
||||
{@const tooOld =
|
||||
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
|
||||
<button
|
||||
role="menuitem"
|
||||
disabled={tooOld}
|
||||
|
|
@ -380,10 +536,10 @@
|
|||
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:disabled:text-neutral-600"
|
||||
>Delete{tooOld ? " (too old)" : ""}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>{formatDate(p.createdAt)}</span
|
||||
>
|
||||
|
|
@ -421,9 +577,10 @@
|
|||
{/snippet}
|
||||
|
||||
{#if detail}
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex items-start gap-6" bind:this={threadEl}>
|
||||
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
||||
<div
|
||||
data-thread-header
|
||||
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
|
||||
>
|
||||
<a
|
||||
|
|
@ -491,9 +648,10 @@
|
|||
</p>
|
||||
<button
|
||||
onclick={onJoinToReply}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white"
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
Join to reply
|
||||
{joinState.busy ? "Joining…" : "Join to reply"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
10
src/routes/thread/[id]/+page.ts
Normal file
10
src/routes/thread/[id]/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { snapshot, type Profiles } from "$lib/forum/snapshot";
|
||||
import type { ThreadDetail } from "$lib/forum/thread";
|
||||
|
||||
export async function load({ fetch, params }) {
|
||||
const data = await snapshot<{ thread: ThreadDetail; profiles: Profiles }>(
|
||||
fetch,
|
||||
`/api/thread/${params.id}`,
|
||||
);
|
||||
return { thread: data?.thread ?? null, profiles: data?.profiles ?? null };
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1,11 +1,21 @@
|
|||
import adapter from "@sveltejs/adapter-static";
|
||||
import { loadEnv } from "vite";
|
||||
import adapterNode from "@sveltejs/adapter-node";
|
||||
import adapterStatic from "@sveltejs/adapter-static";
|
||||
|
||||
// PUBLIC_SSR picks the deployment target: a Node server that renders pages
|
||||
// (yes) or a static single-page bundle (no, the default). vite.config.ts bakes
|
||||
// the same flag into the app as SSR_ENABLED.
|
||||
const env = loadEnv(
|
||||
process.env.NODE_ENV ?? "production",
|
||||
process.cwd(),
|
||||
"PUBLIC_",
|
||||
);
|
||||
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
adapter: ssr ? adapterNode() : adapterStatic({ fallback: "index.html" }),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||
// PUBLIC_SSR is a build-time decision (it also picks the adapter in
|
||||
// svelte.config.js), so it is compiled in as a constant rather than read from
|
||||
// the environment when the server starts.
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "PUBLIC_");
|
||||
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
define: { __SQUALK_SSR__: JSON.stringify(ssr) },
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue