Compare commits
61 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e63fda9f | ||
|
|
c370a87f12 | ||
|
|
5f8bf2fe7e | ||
|
|
58e81d1dba | ||
|
|
455898f3c9 | ||
|
|
dde46dc5d1 | ||
|
|
70cb4e8c22 | ||
|
|
2396407de6 | ||
|
|
6cc1674279 | ||
|
|
982c20d1fa | ||
|
|
8695d3e40e | ||
|
|
67d461333c | ||
|
|
4a9c3a6454 | ||
|
|
0c7ada6d5b | ||
|
|
1b31695d27 | ||
|
|
9872f33185 | ||
|
|
9ac14fc428 | ||
|
|
53b4865de1 | ||
|
|
272c7d5ce6 | ||
|
|
d4e644a964 | ||
|
|
0f487eb33a | ||
|
|
bfad456d82 | ||
|
|
8bacf1ccbf | ||
|
|
997184cd66 | ||
|
|
7b80afbd6c | ||
|
|
9950c4337a | ||
|
|
c793203d9f | ||
|
|
a9b0702c68 | ||
|
|
f40680cbf9 | ||
|
|
bcbc70ecb8 | ||
|
|
03395adf85 | ||
|
|
f734ff030c | ||
|
|
23ef212320 | ||
|
|
39d8d920b6 | ||
|
|
17f53946f1 | ||
|
|
7e98cb2575 | ||
|
|
2cda3acedb | ||
|
|
b4254ebe23 | ||
|
|
d4c0192dcf | ||
|
|
5329c611b1 | ||
|
|
7883e6df8a | ||
|
|
9ab145f816 | ||
|
|
018c72021f | ||
|
|
5c455bfd50 | ||
|
|
7f0d823d6c | ||
|
|
3b79271874 | ||
|
|
fc7c749856 | ||
|
|
2041f6b899 | ||
|
|
c23428f904 | ||
|
|
40e0be35d9 | ||
|
|
a7b5d8f223 | ||
|
|
54c0682dce | ||
|
|
9cc82dc1fe | ||
|
|
6ecd618bce | ||
|
|
aaa9a0c1e5 | ||
|
|
dadba06f8c | ||
|
|
81fde4c932 | ||
|
|
2c432b57e7 | ||
|
|
2ab69e4cd3 | ||
|
|
46176511f3 | ||
|
|
c9227ee8fb |
95 changed files with 9556 additions and 4895 deletions
12
.env.example
12
.env.example
|
|
@ -2,10 +2,20 @@ PUBLIC_RELAY_URL=ws://localhost:3334 # required — NIP-29 relay hosting the g
|
||||||
PUBLIC_MODE=simple # simple | full (single forum vs. many rooms)
|
PUBLIC_MODE=simple # simple | full (single forum vs. many rooms)
|
||||||
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
|
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
|
||||||
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
|
PUBLIC_TITLE= # top-bar title; empty falls back to the group name
|
||||||
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
|
# Render on the server (Node deploy) instead of shipping a static SPA
|
||||||
|
PUBLIC_SSR=no
|
||||||
|
# With SSR on, warm the server cache from client-side navigations (yes/no)
|
||||||
|
PUBLIC_SSR_WARM=yes
|
||||||
|
# Snapshot cache windows in seconds: served as is / served stale while refreshing
|
||||||
|
PUBLIC_SSR_CACHE_FRESH=300
|
||||||
|
PUBLIC_SSR_CACHE_STALE=21600
|
||||||
|
|
||||||
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
||||||
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
||||||
|
PUBLIC_NOSTRCONNECT_RELAYS= # comma-separated relays for nostrconnect:// (bunker) login; defaults to bucket.coracle.social,relay.primal.net
|
||||||
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
||||||
|
PUBLIC_EXTERNAL_CLIENT= # external client for profiles/events, {e} is the bech32 entity; default https://njump.me/{e}
|
||||||
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
||||||
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
|
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
|
||||||
PUBLIC_SECONDARY_COLOR= # override the secondary color (default #ffaf25), e.g. "#0000ff"; hover shade derived
|
PUBLIC_SECONDARY_COLOR= # override the secondary color (default #ffaf25), e.g. "#0000ff"; hover shade derived
|
||||||
|
PUBLIC_FAVICON= # favicon URL (absolute, or /favicon.png for a file in static/ or .local/<mode>/static/); default is a filled circle in the accent color
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
.local
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
|
|
@ -18,6 +19,10 @@ Thumbs.db
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Deploy: only the example unit is tracked
|
||||||
|
/deploy/*
|
||||||
|
!/deploy/production-example.service
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 dtonon
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
31
README.md
31
README.md
|
|
@ -27,9 +27,14 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI
|
||||||
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
|
| `PUBLIC_GROUP_ID` | in simple mode | — | The single forum's group id. Required when `PUBLIC_MODE=simple`; ignored in full mode, where rooms are selected at runtime. |
|
||||||
| `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
|
| `PUBLIC_TITLE` | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
|
||||||
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
|
| `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). |
|
||||||
|
| `PUBLIC_SSR` | no | `no` | `yes` to render pages on the server (crawlable HTML, real 404s). The build then targets Node (`node build`) instead of a static bundle; see [Deploying](#deploying). |
|
||||||
|
| `PUBLIC_SSR_WARM` | no | `yes` | With `PUBLIC_SSR=yes`, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to `no` to disable. |
|
||||||
|
| `PUBLIC_SSR_CACHE_FRESH` | no | `300` | Seconds a server-rendered snapshot is served as is. Also the edge cache's `s-maxage`. |
|
||||||
|
| `PUBLIC_SSR_CACHE_STALE` | no | `21600` | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's `stale-while-revalidate`. |
|
||||||
| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
|
| `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
|
||||||
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
|
| `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. |
|
||||||
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
| `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. |
|
||||||
|
| `PUBLIC_EXTERNAL_CLIENT` | no | `https://njump.me/{e}` | URL template of the external Nostr client used to open profiles and events outside the forum. `{e}` is replaced with the bech32 entity (npub, nevent, naddr), e.g. `https://nostr.at/{e}`. |
|
||||||
| `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. |
|
| `PUBLIC_ACCENT_COLOR` | no | `#e32a6d` | Override the accent (primary) color. Quote the value (`"#00ff00"`) — an unquoted leading `#` is read as a comment. The hover shade is derived automatically. |
|
||||||
| `PUBLIC_SECONDARY_COLOR` | no | `#ffaf25` | Override the secondary color. Same quoting rule and derived hover shade as above. |
|
| `PUBLIC_SECONDARY_COLOR` | no | `#ffaf25` | Override the secondary color. Same quoting rule and derived hover shade as above. |
|
||||||
|
|
||||||
|
|
@ -89,12 +94,26 @@ npm run dev -- --open
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
To create a production version of your app:
|
Two deployment targets share the same code base, selected by `PUBLIC_SSR`:
|
||||||
|
|
||||||
```sh
|
- **Static (default, `PUBLIC_SSR=no`)** — `npm run build` (or `just build`) writes a single-page bundle to `build/`; serve it from any web server with `index.html` as the fallback for unknown paths. Everything is fetched by the browser.
|
||||||
npm run build
|
- **Server-rendered (`PUBLIC_SSR=yes`)** — `just build-ssr` writes a Node app to `build/`. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live `robots.txt` and `sitemap.xml`, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
Preview a build locally with `npm run preview` (static) or `node --env-file=.env.production build` (server).
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
## Deploying
|
||||||
|
|
||||||
|
`just deploy <host>` rsyncs the static bundle to `~/squalk/` on the host and purges the Cloudflare cache.
|
||||||
|
|
||||||
|
`just deploy-ssr <mode>` builds with `--mode <mode>` (so vite bakes `.env.<mode>` in), ships the Node build, `package.json`/`package-lock.json` and `.env.<mode>` (as `.env` in the app dir, since the server reads the `PUBLIC_*` values at runtime), runs `npm ci --omit=dev` and restarts the instance's systemd unit. Everything instance-specific lives in `.env.<mode>.local` (gitignored, never shipped): `DEPLOY_HOST`, `DEPLOY_DIR` and `DEPLOY_SERVICE` (all required), plus the Cloudflare credentials (`CF_ZONE_ID`/`CF_API_TOKEN`) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. Files an instance needs served at its root (a favicon, say) go in `.local/<mode>/static/` (gitignored): when that folder exists, the build ships it instead of the shared `static/`. On the host you need:
|
||||||
|
|
||||||
|
- Node 22 or newer (the relay client uses the built-in `WebSocket`).
|
||||||
|
- The unit from [`deploy/production-example.service`](deploy/production-example.service), with `ORIGIN` set to the public URL — it feeds canonical links, `robots.txt` and the sitemap.
|
||||||
|
- A reverse proxy in front of the port in `PORT`, replacing whatever served the static files before. With Caddy:
|
||||||
|
|
||||||
|
```
|
||||||
|
forum.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with `Cache-Control: public, max-age=0, s-maxage=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE>` (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. `just deploy-ssr` purges the cache after each release.
|
||||||
|
|
|
||||||
25
deploy/production-example.service
Normal file
25
deploy/production-example.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Example systemd unit for the server-rendered build (PUBLIC_SSR=yes).
|
||||||
|
# Copy to /etc/systemd/system/<instance>.service, adjust the paths, the port
|
||||||
|
# and ORIGIN, then: systemctl daemon-reload && systemctl enable --now <instance>
|
||||||
|
[Unit]
|
||||||
|
Description=Squalk forum
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Ephemeral unprivileged user, created by systemd for the service's lifetime
|
||||||
|
# (implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp,
|
||||||
|
# NoNewPrivileges). The app dir stays root-owned; the service only reads it.
|
||||||
|
DynamicUser=yes
|
||||||
|
WorkingDirectory=/srv/squalk
|
||||||
|
# PUBLIC_* values read at runtime by the server (deploy-ssr copies .env.<mode> here)
|
||||||
|
EnvironmentFile=/srv/squalk/.env
|
||||||
|
# ORIGIN is the public URL: it drives canonical links, robots.txt and the sitemap
|
||||||
|
Environment=NODE_ENV=production PORT=3000 ORIGIN=https://forum.example.com
|
||||||
|
ExecStart=/usr/bin/node build
|
||||||
|
Restart=on-failure
|
||||||
|
TimeoutStopSec=10
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
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:
|
dev:
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# Static single-page bundle (served by any web server)
|
||||||
build:
|
build:
|
||||||
npm run build
|
PUBLIC_SSR=no npm run build
|
||||||
|
|
||||||
|
# Server-rendered bundle (needs Node 22+ on the host, see deploy/production-example.service)
|
||||||
|
build-ssr:
|
||||||
|
PUBLIC_SSR=yes npm run build
|
||||||
|
|
||||||
deploy target: build
|
deploy target: build
|
||||||
rsync -av --delete --progress build/ {{target}}:~/squalk/
|
rsync -av --delete --progress build/ {{target}}:~/squalk/
|
||||||
|
@just purge-web-cache
|
||||||
|
|
||||||
|
# Ships the Node build plus its runtime deps and env, then restarts the unit.
|
||||||
|
# `mode` picks the instance: vite bakes .env.<mode> into the build, and
|
||||||
|
# .env.<mode>.local provides the deployment details (DEPLOY_HOST, DEPLOY_DIR,
|
||||||
|
# DEPLOY_SERVICE) plus the Cloudflare credentials. The remote step runs in a
|
||||||
|
# login shell so the user's PATH (npm, nvm…) applies.
|
||||||
|
deploy-ssr mode:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
[ -f .env.{{mode}}.local ] || { echo "Missing .env.{{mode}}.local"; exit 1; }
|
||||||
|
set -a; source .env.{{mode}}.local; set +a
|
||||||
|
: "${DEPLOY_HOST:?DEPLOY_HOST missing in .env.{{mode}}.local}"
|
||||||
|
: "${DEPLOY_DIR:?DEPLOY_DIR missing in .env.{{mode}}.local}"
|
||||||
|
: "${DEPLOY_SERVICE:?DEPLOY_SERVICE missing in .env.{{mode}}.local}"
|
||||||
|
PUBLIC_SSR=yes npm run build -- --mode {{mode}}
|
||||||
|
rsync -av --delete --progress --exclude node_modules build/ "$DEPLOY_HOST:$DEPLOY_DIR/build/"
|
||||||
|
rsync -av package.json package-lock.json "$DEPLOY_HOST:$DEPLOY_DIR/"
|
||||||
|
rsync -av .env.{{mode}} "$DEPLOY_HOST:$DEPLOY_DIR/.env"
|
||||||
|
ssh "$DEPLOY_HOST" "\$SHELL -l -c 'cd $DEPLOY_DIR && npm ci --omit=dev && sudo systemctl restart $DEPLOY_SERVICE'"
|
||||||
|
just purge-web-cache {{mode}}
|
||||||
|
|
||||||
|
purge-web-cache mode="production":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [ -f .env.{{mode}}.local ]; then set -a; source .env.{{mode}}.local; set +a; fi
|
||||||
|
echo -e "\nPurging Cloudflare cache... for zone ${CF_ZONE_ID:-<unset>}"
|
||||||
|
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID:-}/purge_cache" \
|
||||||
|
-H "Authorization: Bearer ${CF_API_TOKEN:-}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data '{"purge_everything": true}' \
|
||||||
|
| jq -r 'if .success then "✅ Cache purged successfully" else "‼️ Error: " + (.errors[0].message // "Unknown error") end'
|
||||||
|
|
|
||||||
7752
package-lock.json
generated
7752
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -1,7 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "squalk",
|
"name": "squalk",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "^2.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
|
@ -41,6 +42,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djot/djot": "^0.3.2",
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||||
|
"nstart-modal": "^2.1.0",
|
||||||
|
"uqr": "^0.1.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"lightningcss": "^1.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/app.d.ts
vendored
22
src/app.d.ts
vendored
|
|
@ -1,10 +1,30 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
|
import type {
|
||||||
|
ForumShell,
|
||||||
|
OverviewSnapshot,
|
||||||
|
Profiles,
|
||||||
|
ThreadsSnapshot,
|
||||||
|
} from "$lib/forum/snapshot";
|
||||||
|
import type { ThreadDetail } from "$lib/forum/thread";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
// Injected by vite.config.ts from PUBLIC_SSR, package.json and git
|
||||||
|
const __SQUALK_SSR__: boolean;
|
||||||
|
const __SQUALK_VERSION__: string;
|
||||||
|
const __SQUALK_COMMIT__: string;
|
||||||
|
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// Server-rendered snapshots; null in the browser and in client-only builds
|
||||||
|
interface PageData {
|
||||||
|
shell?: ForumShell | null;
|
||||||
|
profiles?: Profiles | null;
|
||||||
|
thread?: ThreadDetail | null;
|
||||||
|
threads?: ThreadsSnapshot | null;
|
||||||
|
overview?: OverviewSnapshot | null;
|
||||||
|
}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/lib/access.svelte.ts
Normal file
109
src/lib/access.svelte.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||||
|
import { auth } from "$lib/auth.svelte";
|
||||||
|
import { ensureForumRelay, forumRelayInfo } from "$lib/relay";
|
||||||
|
import type { ForumRelayInfo } from "$lib/relay";
|
||||||
|
|
||||||
|
// Whether the forum relay will serve this visitor at all. A relay can refuse
|
||||||
|
// every read (auth-required for anonymous visitors, restricted for logged-in
|
||||||
|
// non-members, blocked for bans), in which case the app would otherwise just
|
||||||
|
// look empty; the layout shows a gate page instead until a probe comes back
|
||||||
|
// open.
|
||||||
|
export type RelayAccess =
|
||||||
|
| "checking"
|
||||||
|
| "open"
|
||||||
|
| "auth-required"
|
||||||
|
| "restricted"
|
||||||
|
| "blocked";
|
||||||
|
|
||||||
|
let state = $state<RelayAccess>("checking");
|
||||||
|
let message = $state<string | null>(null);
|
||||||
|
let info = $state<ForumRelayInfo | null>(null);
|
||||||
|
// Bumped when a probe completes, so listeners can react to every result even
|
||||||
|
// when the state itself didn't change (open → open after a login).
|
||||||
|
let probes = $state(0);
|
||||||
|
|
||||||
|
export const relayAccess = {
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
get message() {
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
get info() {
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
get probes() {
|
||||||
|
return probes;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sends a throwaway REQ and reads the CLOSED reason: nostr-tools' query helpers
|
||||||
|
// discard it, and it is the only cross-relay signal for a read gate. No `#h`
|
||||||
|
// in the filter, so a private group's per-group auth-required (handled by the
|
||||||
|
// room itself) can't masquerade as a relay-wide one.
|
||||||
|
function probeRead(relay: AbstractRelay): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (reason: string | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
sub.close();
|
||||||
|
} catch {}
|
||||||
|
resolve(reason);
|
||||||
|
};
|
||||||
|
const sub = relay.subscribe([{ kinds: [39000], limit: 1 }], {
|
||||||
|
onevent() {},
|
||||||
|
oneose() {
|
||||||
|
finish(null);
|
||||||
|
},
|
||||||
|
onclose(reason) {
|
||||||
|
finish(reason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(() => finish(null), 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(reason: string | null): RelayAccess {
|
||||||
|
const r = (reason ?? "").toLowerCase();
|
||||||
|
if (r.startsWith("auth-required:")) return "auth-required";
|
||||||
|
if (r.startsWith("restricted:")) return "restricted";
|
||||||
|
if (r.startsWith("blocked:")) return "blocked";
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
let inFlight: Promise<RelayAccess> | null = null;
|
||||||
|
|
||||||
|
// Re-evaluates access for the current signer. Concurrent calls share one probe.
|
||||||
|
// An anonymous visitor on a relay whose NIP-11 declares auth_required is gated
|
||||||
|
// without waiting for the relay to refuse a request.
|
||||||
|
export function probeRelayAccess(): Promise<RelayAccess> {
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const relayInfo = await forumRelayInfo();
|
||||||
|
info = relayInfo;
|
||||||
|
let next: RelayAccess;
|
||||||
|
let reason: string | null = null;
|
||||||
|
if (!auth.signer && relayInfo.authRequired) {
|
||||||
|
next = "auth-required";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
reason = await probeRead(await ensureForumRelay());
|
||||||
|
} catch {
|
||||||
|
reason = null; // connection failure: the pages surface their own errors
|
||||||
|
}
|
||||||
|
next = classify(reason);
|
||||||
|
}
|
||||||
|
state = next;
|
||||||
|
message =
|
||||||
|
next === "open" ? null : (reason?.replace(/^[a-z-]+:\s*/i, "") ?? null);
|
||||||
|
probes++;
|
||||||
|
return next;
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
@ -24,14 +24,20 @@ export const autogrow: Action<
|
||||||
const vPad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
const vPad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||||
const vBorder =
|
const vBorder =
|
||||||
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||||
|
const min = lh * Math.max(node.rows, 1) + vPad + vBorder;
|
||||||
const max = lh * maxRows + vPad + vBorder;
|
const max = lh * maxRows + vPad + vBorder;
|
||||||
node.style.height = "auto";
|
node.style.height = "auto";
|
||||||
const next = Math.min(node.scrollHeight, max);
|
// scrollHeight is 0 while hidden (display:none); never go below `rows`.
|
||||||
|
const next = Math.min(Math.max(node.scrollHeight, min), max);
|
||||||
node.style.height = `${next}px`;
|
node.style.height = `${next}px`;
|
||||||
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
|
node.style.overflowY = node.scrollHeight > max ? "auto" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
node.addEventListener("input", resize);
|
node.addEventListener("input", resize);
|
||||||
|
// Re-measure when the textarea becomes visible or changes width.
|
||||||
|
const ro =
|
||||||
|
typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
|
||||||
|
ro?.observe(node);
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -43,6 +49,7 @@ export const autogrow: Action<
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("input", resize);
|
node.removeEventListener("input", resize);
|
||||||
|
ro?.disconnect();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { MODE } from "$lib/config";
|
import { MODE } from "$lib/config";
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { fetchRoomAdmins } from "$lib/forum/groups";
|
||||||
|
|
||||||
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
||||||
let byRoom = $state<Record<string, string[]>>({});
|
let byRoom = $state<Record<string, string[]>>({});
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let loadedKey = "";
|
let loadedKey = "";
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const roomAdminsStore = {
|
export const roomAdminsStore = {
|
||||||
get byRoom() {
|
get byRoom() {
|
||||||
return byRoom;
|
return loaded ? byRoom : (page.data.shell?.adminsByRoom ?? {});
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,11 +24,11 @@ export const roomAdminsStore = {
|
||||||
export const adminPubkeys = {
|
export const adminPubkeys = {
|
||||||
get list(): string[] {
|
get list(): string[] {
|
||||||
return MODE === "full"
|
return MODE === "full"
|
||||||
? [...new Set(Object.values(byRoom).flat())]
|
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
|
||||||
: (groupStore.data?.admins ?? []);
|
: (groupStore.data?.admins ?? []);
|
||||||
},
|
},
|
||||||
get loaded(): boolean {
|
get loaded(): boolean {
|
||||||
return MODE === "full" ? loaded : groupStore.loaded;
|
return MODE === "full" ? roomAdminsStore.loaded : groupStore.loaded;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +40,7 @@ export function isGroupAdmin(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!pubkey || !groupId) return false;
|
if (!pubkey || !groupId) return false;
|
||||||
return MODE === "full"
|
return MODE === "full"
|
||||||
? (byRoom[groupId]?.includes(pubkey) ?? false)
|
? (roomAdminsStore.byRoom[groupId]?.includes(pubkey) ?? false)
|
||||||
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,17 +51,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
|
||||||
loadedKey = key;
|
loadedKey = key;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
byRoom = await fetchRoomAdmins(queryForum, roomIds);
|
||||||
kinds: [39001],
|
|
||||||
"#d": roomIds,
|
|
||||||
});
|
|
||||||
const map: Record<string, string[]> = {};
|
|
||||||
for (const e of events) {
|
|
||||||
const d = e.tags.find((t) => t[0] === "d")?.[1];
|
|
||||||
if (!d) continue;
|
|
||||||
map[d] = e.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
|
|
||||||
}
|
|
||||||
byRoom = map;
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,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 * as nip19 from "@nostr/tools/nip19";
|
||||||
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
||||||
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
||||||
|
import {
|
||||||
|
connectBunker,
|
||||||
|
restoreBunker,
|
||||||
|
startNostrConnect,
|
||||||
|
type BunkerSession,
|
||||||
|
type NostrConnect,
|
||||||
|
} from "$lib/bunker";
|
||||||
|
import type { BunkerSigner } from "@nostr/tools/nip46";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -18,6 +26,8 @@ export type Signer = {
|
||||||
|
|
||||||
let user = $state<NostrUser | null>(null);
|
let user = $state<NostrUser | null>(null);
|
||||||
let signer = $state<Signer | null>(null);
|
let signer = $state<Signer | null>(null);
|
||||||
|
// Kept outside $state (see makeExtensionSigner) so it can be closed on logout
|
||||||
|
let bunker: BunkerSigner | null = null;
|
||||||
let loginModalOpen = $state(false);
|
let loginModalOpen = $state(false);
|
||||||
// Optional action to run once login succeeds, so an intent like "post" started
|
// Optional action to run once login succeeds, so an intent like "post" started
|
||||||
// while logged out resumes (login => join => composer) instead of being dropped.
|
// while logged out resumes (login => join => composer) instead of being dropped.
|
||||||
|
|
@ -45,6 +55,8 @@ export const auth = {
|
||||||
const PUBKEY_KEY = "nostr_pubkey";
|
const PUBKEY_KEY = "nostr_pubkey";
|
||||||
const METHOD_KEY = "nostr_login_method";
|
const METHOD_KEY = "nostr_login_method";
|
||||||
const NSEC_KEY = "nostr_nsec";
|
const NSEC_KEY = "nostr_nsec";
|
||||||
|
const BUNKER_KEY = "nostr_bunker";
|
||||||
|
const BUNKER_CLIENT_KEY = "nostr_bunker_client_key";
|
||||||
|
|
||||||
export function openLogin(after?: () => void) {
|
export function openLogin(after?: () => void) {
|
||||||
afterLogin = after ?? null;
|
afterLogin = after ?? null;
|
||||||
|
|
@ -94,6 +106,34 @@ function makeExtensionSigner(): Signer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer {
|
||||||
|
return {
|
||||||
|
async getPublicKey() {
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
async signEvent(template) {
|
||||||
|
return remote.signEvent(template);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBunker() {
|
||||||
|
bunker?.close().catch(() => {});
|
||||||
|
bunker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginMethod(): string | null {
|
||||||
|
return user ? localStorage.getItem(METHOD_KEY) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredSession() {
|
||||||
|
localStorage.removeItem(PUBKEY_KEY);
|
||||||
|
localStorage.removeItem(METHOD_KEY);
|
||||||
|
localStorage.removeItem(NSEC_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_CLIENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
async function setUser(pubkey: string) {
|
async function setUser(pubkey: string) {
|
||||||
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
||||||
user = await loadNostrUser(pubkey);
|
user = await loadNostrUser(pubkey);
|
||||||
|
|
@ -105,10 +145,11 @@ export async function loginWithExtension() {
|
||||||
throw new Error("No Nostr extension found");
|
throw new Error("No Nostr extension found");
|
||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
closeBunker();
|
||||||
signer = makeExtensionSigner();
|
signer = makeExtensionSigner();
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "extension");
|
localStorage.setItem(METHOD_KEY, "extension");
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
await setUser(pubkey);
|
await setUser(pubkey);
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
runAfterLogin();
|
runAfterLogin();
|
||||||
|
|
@ -137,7 +178,9 @@ export async function loginWithNsec(input: string) {
|
||||||
const sk = parseSecretKey(input);
|
const sk = parseSecretKey(input);
|
||||||
const nsec = nip19.nsecEncode(sk);
|
const nsec = nip19.nsecEncode(sk);
|
||||||
const pubkey = getPublicKey(sk);
|
const pubkey = getPublicKey(sk);
|
||||||
|
closeBunker();
|
||||||
signer = makeNsecSigner(sk);
|
signer = makeNsecSigner(sk);
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "nsec");
|
localStorage.setItem(METHOD_KEY, "nsec");
|
||||||
localStorage.setItem(NSEC_KEY, nsec);
|
localStorage.setItem(NSEC_KEY, nsec);
|
||||||
|
|
@ -146,12 +189,47 @@ export async function loginWithNsec(input: string) {
|
||||||
runAfterLogin();
|
runAfterLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithBunkerSession(session: BunkerSession) {
|
||||||
|
closeBunker();
|
||||||
|
bunker = session.signer;
|
||||||
|
signer = makeBunkerSigner(session.signer, session.pubkey);
|
||||||
|
clearStoredSession();
|
||||||
|
localStorage.setItem(PUBKEY_KEY, session.pubkey);
|
||||||
|
localStorage.setItem(METHOD_KEY, "bunker");
|
||||||
|
localStorage.setItem(BUNKER_KEY, session.bunkerUrl);
|
||||||
|
localStorage.setItem(BUNKER_CLIENT_KEY, session.clientSecretKey);
|
||||||
|
await setUser(session.pubkey);
|
||||||
|
sessionEpoch++;
|
||||||
|
runAfterLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pasted bunker:// URL or a NIP-05 that advertises one
|
||||||
|
export async function loginWithBunker(input: string) {
|
||||||
|
await loginWithBunkerSession(await connectBunker(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
|
||||||
|
// waiting when the user leaves the view.
|
||||||
|
// `onAck` reports the signer's first reply so the UI can show progress while
|
||||||
|
// the session is finalized.
|
||||||
|
export function loginWithNostrConnect(onAck?: () => void): {
|
||||||
|
uri: string;
|
||||||
|
done: Promise<void>;
|
||||||
|
cancel: () => void;
|
||||||
|
} {
|
||||||
|
const nc: NostrConnect = startNostrConnect(onAck);
|
||||||
|
return {
|
||||||
|
uri: nc.uri,
|
||||||
|
done: nc.session.then(loginWithBunkerSession),
|
||||||
|
cancel: nc.cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
user = null;
|
user = null;
|
||||||
signer = null;
|
signer = null;
|
||||||
localStorage.removeItem(PUBKEY_KEY);
|
closeBunker();
|
||||||
localStorage.removeItem(METHOD_KEY);
|
clearStoredSession();
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
resetJoinState();
|
resetJoinState();
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +249,16 @@ export async function restoreSession() {
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (method === "bunker") {
|
||||||
|
const url = localStorage.getItem(BUNKER_KEY);
|
||||||
|
const clientKey = localStorage.getItem(BUNKER_CLIENT_KEY);
|
||||||
|
if (!url || !clientKey) return;
|
||||||
|
try {
|
||||||
|
bunker = await restoreBunker(url, clientKey);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signer = makeBunkerSigner(bunker, pubkey);
|
||||||
} else {
|
} else {
|
||||||
signer = makeExtensionSigner();
|
signer = makeExtensionSigner();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
216
src/lib/bunker.ts
Normal file
216
src/lib/bunker.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import {
|
||||||
|
BunkerSigner,
|
||||||
|
createNostrConnectURI,
|
||||||
|
parseBunkerInput,
|
||||||
|
toBunkerURL,
|
||||||
|
type BunkerPointer,
|
||||||
|
} from "@nostr/tools/nip46";
|
||||||
|
import { SimplePool } from "@nostr/tools/pool";
|
||||||
|
import { NostrConnect as NostrConnectKind } from "@nostr/tools/kinds";
|
||||||
|
import { decrypt, getConversationKey } from "@nostr/tools/nip44";
|
||||||
|
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
|
||||||
|
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
|
||||||
|
import { NOSTRCONNECT_RELAYS } from "$lib/config";
|
||||||
|
|
||||||
|
// NIP-46 remote signing. Two ways in: the user pastes a bunker:// URL (or a
|
||||||
|
// NIP-05 that advertises one), or the app shows a nostrconnect:// URI and the
|
||||||
|
// signer app calls back. Either way the result is a live BunkerSigner plus the
|
||||||
|
// data needed to reconnect silently next time: the bunker URL (secret
|
||||||
|
// stripped) and the client key the conversation is bound to.
|
||||||
|
|
||||||
|
export type BunkerSession = {
|
||||||
|
signer: BunkerSigner;
|
||||||
|
pubkey: string;
|
||||||
|
bunkerUrl: string;
|
||||||
|
clientSecretKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The signer app usually lives on the phone, and on mobile the browser tab is
|
||||||
|
// backgrounded while the user approves there, which can drop the socket. A
|
||||||
|
// reconnecting pool keeps the subscriptions alive across that.
|
||||||
|
const pool = new SimplePool({ enableReconnect: true });
|
||||||
|
|
||||||
|
// The approval happens in the signer app, so the tab is backgrounded and its
|
||||||
|
// sockets can drop; nostr-tools only retries after a 10s backoff. Reconnect
|
||||||
|
// the relays this flow has used as soon as the tab or the network is back,
|
||||||
|
// and keep a shorter backoff for drops in between.
|
||||||
|
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
|
||||||
|
const usedRelays = new Set<string>();
|
||||||
|
function touchRelays(urls: string[] = []) {
|
||||||
|
for (const url of urls) usedRelays.add(url);
|
||||||
|
for (const url of usedRelays) {
|
||||||
|
pool
|
||||||
|
.ensureRelay(url)
|
||||||
|
.then((relay) => {
|
||||||
|
relay.resubscribeBackoff = FAST_BACKOFF;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) touchRelays();
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => touchRelays());
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerParams = {
|
||||||
|
pool,
|
||||||
|
onauth: (url: string) => {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripSecret(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.searchParams.delete("secret");
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectBunker(input: string): Promise<BunkerSession> {
|
||||||
|
const bp = await parseBunkerInput(input.trim());
|
||||||
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
|
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
touchRelays(bp.relays);
|
||||||
|
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||||
|
try {
|
||||||
|
await signer.connect();
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
return {
|
||||||
|
signer,
|
||||||
|
pubkey,
|
||||||
|
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||||
|
clientSecretKey: bytesToHex(sk),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
signer.close().catch(() => {});
|
||||||
|
throw new Error(`Bunker error: ${describe(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrConnect = {
|
||||||
|
uri: string;
|
||||||
|
// Resolves once a signer answers the URI; rejects on abort or relay close
|
||||||
|
session: Promise<BunkerSession>;
|
||||||
|
cancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Waits for the signer's reply to a nostrconnect:// URI. Unlike the library's
|
||||||
|
// helper, the filter has no `limit: 0`, so a reply published while the tab was
|
||||||
|
// offline is replayed once the relay reconnects.
|
||||||
|
function waitForConnect(
|
||||||
|
sk: Uint8Array,
|
||||||
|
secret: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<BunkerPointer> {
|
||||||
|
const clientPubkey = getPublicKey(sk);
|
||||||
|
touchRelays(NOSTRCONNECT_RELAYS);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
sub.close();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
const sub = pool.subscribe(
|
||||||
|
NOSTRCONNECT_RELAYS,
|
||||||
|
{
|
||||||
|
kinds: [NostrConnectKind],
|
||||||
|
"#p": [clientPubkey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
let reply: { result?: string; error?: string };
|
||||||
|
try {
|
||||||
|
const key = getConversationKey(sk, event.pubkey);
|
||||||
|
reply = JSON.parse(decrypt(event.content, key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("nostrconnect: undecryptable reply", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reply.result === secret) {
|
||||||
|
finish(() =>
|
||||||
|
resolve({
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
relays: NOSTRCONNECT_RELAYS,
|
||||||
|
secret,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (reply.error) {
|
||||||
|
finish(() => reject(new Error(`Signer refused: ${reply.error}`)));
|
||||||
|
} else {
|
||||||
|
finish(() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Signer replied "${reply.result}" instead of the secret`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose: (reasons) =>
|
||||||
|
finish(() =>
|
||||||
|
reject(new Error(`Relay closed: ${reasons.join("; ")}`)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Closes the subscription on every relay; the pool's own abort handling
|
||||||
|
// only reaches the last one
|
||||||
|
signal.addEventListener("abort", () =>
|
||||||
|
finish(() => reject(new Error("Cancelled"))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `onAck` fires the moment the signer answers the URI, before the follow-up
|
||||||
|
// round-trip that completes the session — the point where "waiting for the
|
||||||
|
// signer" becomes "finishing up".
|
||||||
|
export function startNostrConnect(onAck?: () => void): NostrConnect {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const secret = Math.random().toString(36).substring(2, 10);
|
||||||
|
const uri = createNostrConnectURI({
|
||||||
|
clientPubkey: getPublicKey(sk),
|
||||||
|
relays: NOSTRCONNECT_RELAYS,
|
||||||
|
secret,
|
||||||
|
name: location.host,
|
||||||
|
url: location.origin,
|
||||||
|
});
|
||||||
|
const abort = new AbortController();
|
||||||
|
const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => {
|
||||||
|
onAck?.();
|
||||||
|
// The signer already accepted this client key; no connect round-trip
|
||||||
|
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||||
|
try {
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
return {
|
||||||
|
signer,
|
||||||
|
pubkey,
|
||||||
|
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||||
|
clientSecretKey: bytesToHex(sk),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
signer.close().catch(() => {});
|
||||||
|
throw new Error(`Bunker error: ${describe(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { uri, session, cancel: () => abort.abort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuilds the signer from stored data without a connect round-trip: the
|
||||||
|
// bunker already knows this client key, so the first sign request is enough.
|
||||||
|
export async function restoreBunker(
|
||||||
|
bunkerUrl: string,
|
||||||
|
clientSecretKey: string,
|
||||||
|
): Promise<BunkerSigner> {
|
||||||
|
const bp = await parseBunkerInput(bunkerUrl);
|
||||||
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
|
touchRelays(bp.relays);
|
||||||
|
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Event } from "@nostr/tools";
|
import type { Event } from "@nostr/tools";
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { RELAY_URL } from "$lib/config";
|
import { RELAY_URL } from "$lib/config";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
|
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { tokenizeChat } from "$lib/linkify";
|
import { tokenizeChat } from "$lib/linkify";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -39,9 +40,7 @@
|
||||||
<span class="break-words whitespace-pre-wrap"
|
<span class="break-words whitespace-pre-wrap"
|
||||||
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
||||||
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
||||||
href="https://njump.me/{t.entity}"
|
href={profilePath(t.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||||
>{:else if t.type === "entity"}{@const ref = t.id
|
>{:else if t.type === "entity"}{@const ref = t.id
|
||||||
? resolvedThreads[t.id]
|
? resolvedThreads[t.id]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
import {
|
import {
|
||||||
chatStore,
|
chatStore,
|
||||||
getChatMessage,
|
getChatMessage,
|
||||||
|
|
@ -15,11 +16,12 @@
|
||||||
} from "$lib/moderation.svelte";
|
} from "$lib/moderation.svelte";
|
||||||
import {
|
import {
|
||||||
withJoin,
|
withJoin,
|
||||||
|
joinState,
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { activeGroup } from "$lib/active.svelte";
|
import { activeGroup } from "$lib/active.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||||
|
|
@ -76,7 +78,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function onJoinToChat() {
|
function onJoinToChat() {
|
||||||
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDeleteMessage(msg: ChatMessageData) {
|
function requestDeleteMessage(msg: ChatMessageData) {
|
||||||
|
|
@ -250,14 +252,34 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor to bottom on new messages, unless user is reading older ones.
|
// Anchor to bottom on new messages, unless user is reading older ones.
|
||||||
|
// Also runs when the mobile panel becomes visible: while hidden the list
|
||||||
|
// has no height, so earlier scrolls were no-ops.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
messages.length;
|
messages.length;
|
||||||
|
mobileActive;
|
||||||
if (!listEl || userScrolledUp) return;
|
if (!listEl || userScrolledUp) return;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arriving with a #chat-… hash (a notification link): scroll to the message
|
||||||
|
// once it is loaded, and stop anchoring to the bottom so it stays in view.
|
||||||
|
let scrolledHashFor = "";
|
||||||
|
$effect(() => {
|
||||||
|
const hash = page.url.hash;
|
||||||
|
messages.length;
|
||||||
|
mobileActive;
|
||||||
|
if (!hash.startsWith("#chat-") || scrolledHashFor === hash) return;
|
||||||
|
tick().then(() => {
|
||||||
|
const el = document.getElementById(hash.slice(1));
|
||||||
|
if (!el) return;
|
||||||
|
scrolledHashFor = hash;
|
||||||
|
userScrolledUp = true;
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!expanded) return;
|
if (!expanded) return;
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
|
|
@ -346,23 +368,32 @@
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div id="chat-{msg.id}">
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<div class="mb-1 flex items-center gap-2">
|
||||||
{#if author.picture}
|
<a
|
||||||
<img
|
href={profilePath(msg.pubkey)}
|
||||||
src={author.picture}
|
class="shrink-0"
|
||||||
alt=""
|
aria-hidden="true"
|
||||||
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
tabindex="-1"
|
||||||
/>
|
>
|
||||||
{:else}
|
{#if author.picture}
|
||||||
<span
|
<img
|
||||||
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"
|
src={author.picture}
|
||||||
>
|
alt=""
|
||||||
{author.name[0].toUpperCase()}
|
class="h-6 w-6 rounded-full object-cover"
|
||||||
</span>
|
/>
|
||||||
{/if}
|
{:else}
|
||||||
<span class="font-medium text-neutral-500 dark:text-neutral-400"
|
<span
|
||||||
>{author.name}</span
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{author.name[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={profilePath(msg.pubkey)}
|
||||||
|
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||||
|
>{author.name}</a
|
||||||
>
|
>
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
@ -478,7 +509,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if !auth.user}
|
{#if !auth.user}
|
||||||
<button
|
<button
|
||||||
onclick={openLogin}
|
onclick={() => openLogin()}
|
||||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
||||||
>
|
>
|
||||||
Login to chat
|
Login to chat
|
||||||
|
|
@ -486,9 +517,10 @@
|
||||||
{:else if joinToChat}
|
{:else if joinToChat}
|
||||||
<button
|
<button
|
||||||
onclick={onJoinToChat}
|
onclick={onJoinToChat}
|
||||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Join to chat
|
{joinState.busy ? "Joining…" : "Join to chat"}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
type Author,
|
type Author,
|
||||||
} from "$lib/components/ThreadItem.svelte";
|
} from "$lib/components/ThreadItem.svelte";
|
||||||
import SortToggle from "$lib/components/SortToggle.svelte";
|
import SortToggle from "$lib/components/SortToggle.svelte";
|
||||||
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
import { groupStore } from "$lib/group.svelte";
|
||||||
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { MODE } from "$lib/config";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { openDraft } from "$lib/draft.svelte";
|
import { openDraft } from "$lib/draft.svelte";
|
||||||
|
|
@ -18,7 +22,8 @@
|
||||||
import {
|
import {
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
withJoin,
|
||||||
|
joinState,
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { sortPref } from "$lib/sort.svelte";
|
import { sortPref } from "$lib/sort.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
@ -44,6 +49,12 @@
|
||||||
if (urlSort) sortPref.value = urlSort;
|
if (urlSort) sortPref.value = urlSort;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const about = $derived(
|
||||||
|
MODE === "full"
|
||||||
|
? (groupsStore.list.find((g) => g.id === groupId)?.about ?? "")
|
||||||
|
: (groupStore.data?.about ?? ""),
|
||||||
|
);
|
||||||
|
|
||||||
const flags = $derived(getGroupFlags(groupId));
|
const flags = $derived(getGroupFlags(groupId));
|
||||||
const member = $derived(membershipOf(groupId));
|
const member = $derived(membershipOf(groupId));
|
||||||
|
|
||||||
|
|
@ -71,12 +82,7 @@
|
||||||
openLogin(onNewTopic);
|
openLogin(onNewTopic);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ensureMembershipChecked(groupId);
|
await withJoin(groupId, openDraft);
|
||||||
if (membershipOf(groupId) !== "member") {
|
|
||||||
openJoinModal(groupId, openDraft);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openDraft();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPrivateJoin() {
|
function onPrivateJoin() {
|
||||||
|
|
@ -84,7 +90,7 @@
|
||||||
openLogin();
|
openLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openJoinModal(groupId, () => loadThreads(groupId, sort));
|
withJoin(groupId, () => loadThreads(groupId, sort));
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(ts: number): string {
|
function relativeTime(ts: number): string {
|
||||||
|
|
@ -125,22 +131,36 @@
|
||||||
const rows = $derived(
|
const rows = $derived(
|
||||||
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
|
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const empty = $derived(
|
||||||
|
rows.length === 0 && !threadStore.loading && threadStore.exhausted,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fairlyNew = $derived(
|
||||||
|
rows.length > 0 &&
|
||||||
|
rows.length < 4 &&
|
||||||
|
!threadStore.loading &&
|
||||||
|
threadStore.exhausted,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta {title} description={about} />
|
||||||
<title>{title}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
||||||
{#if !showPrivateGate && !checkingAccess}
|
{#if !showPrivateGate && !checkingAccess && !empty}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={onNewTopic}
|
onclick={onNewTopic}
|
||||||
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white md:px-6 md:text-sm"
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white disabled:opacity-50 md:px-6 md:text-sm"
|
||||||
>
|
>
|
||||||
{joinToPost ? "Join to post" : "New discussion"}
|
{joinState.busy
|
||||||
|
? "Joining…"
|
||||||
|
: joinToPost
|
||||||
|
? "Join to post"
|
||||||
|
: "New discussion"}
|
||||||
</button>
|
</button>
|
||||||
<SortToggle {sort} />
|
<SortToggle {sort} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,9 +185,27 @@
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={onPrivateJoin}
|
onclick={onPrivateJoin}
|
||||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{auth.user ? "Request to join" : "Log in to join"}
|
{joinState.busy
|
||||||
|
? "Joining…"
|
||||||
|
: auth.user
|
||||||
|
? "Request to join"
|
||||||
|
: "Log in to join"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if empty}
|
||||||
|
<div class="flex flex-col items-center gap-6 py-24 text-center">
|
||||||
|
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
|
||||||
|
This room is still empty
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={onNewTopic}
|
||||||
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joinState.busy ? "Joining…" : "Be the first one to open a discussion"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -194,6 +232,19 @@
|
||||||
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if fairlyNew}
|
||||||
|
<div class="flex flex-col items-center gap-6 py-16 text-center">
|
||||||
|
<p class="text-2xl text-neutral-500 md:text-3xl dark:text-neutral-400">
|
||||||
|
This room is fairly new
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={onNewTopic}
|
||||||
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joinState.busy ? "Joining…" : "Start a new discussion"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else if rows.length > 0}
|
{:else if rows.length > 0}
|
||||||
<p
|
<p
|
||||||
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,38 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
|
import { joinState, closeJoinModal, submitJoinCode } from "$lib/join.svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
let code = $state("");
|
let code = $state("");
|
||||||
let codeInput = $state<HTMLInputElement | null>(null);
|
let codeInput = $state<HTMLInputElement | null>(null);
|
||||||
|
// Pending approval hides the code field behind a discreet link, so a group
|
||||||
|
// that doesn't hand out codes doesn't send people hunting for one.
|
||||||
|
let codeRevealed = $state(false);
|
||||||
|
|
||||||
|
const step = $derived(joinState.modalStep);
|
||||||
|
const showCode = $derived(
|
||||||
|
step === "relay-code" ||
|
||||||
|
(step === "group-code" && joinState.modalCodeHinted) ||
|
||||||
|
codeRevealed,
|
||||||
|
);
|
||||||
|
const canOfferCode = $derived(
|
||||||
|
step === "pending" || (step === "group-code" && !joinState.modalCodeHinted),
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (joinState.modalOpen && joinState.needsCode) {
|
if (!joinState.modalOpen) {
|
||||||
|
code = "";
|
||||||
|
codeRevealed = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (joinState.modalOpen && showCode) {
|
||||||
tick().then(() => codeInput?.focus());
|
tick().then(() => codeInput?.focus());
|
||||||
}
|
}
|
||||||
if (!joinState.modalOpen) code = "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
await submitJoin(code.trim() || undefined);
|
await submitJoinCode(code.trim() || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
|
|
@ -56,15 +75,28 @@
|
||||||
id="join-title"
|
id="join-title"
|
||||||
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
Join this group
|
{#if step === "relay-code"}
|
||||||
|
Invite code required
|
||||||
|
{:else if step === "pending"}
|
||||||
|
Request sent
|
||||||
|
{:else if step === "error"}
|
||||||
|
Could not join
|
||||||
|
{:else}
|
||||||
|
Join this group
|
||||||
|
{/if}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{#if joinState.needsCode}
|
{#if step === "relay-code"}
|
||||||
Join this group to participate. If you have an invite code, enter it
|
This relay only accepts members. Enter an invite code to join.
|
||||||
below — otherwise just send the request.
|
{:else if step === "pending"}
|
||||||
|
Your request to join has been sent. An admin needs to approve it
|
||||||
|
before you can participate.
|
||||||
|
{:else if step === "error"}
|
||||||
|
The relay refused the request.
|
||||||
|
{:else if joinState.modalCodeHinted}
|
||||||
|
This group requires an invite code to join.
|
||||||
{:else}
|
{:else}
|
||||||
Join this group to participate. Your request is sent to the relay and,
|
The group refused the join request.
|
||||||
where needed, approved by an admin.
|
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -77,13 +109,17 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if joinState.needsCode}
|
{#if showCode}
|
||||||
<label for="join-code-input" class="sr-only">Invite code</label>
|
<label for="join-code-input" class="sr-only">
|
||||||
|
{step === "relay-code" ? "Relay invite code" : "Group invite code"}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="join-code-input"
|
id="join-code-input"
|
||||||
bind:this={codeInput}
|
bind:this={codeInput}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Invite code (optional)"
|
placeholder={step === "relay-code"
|
||||||
|
? "Relay invite code"
|
||||||
|
: "Group invite code"}
|
||||||
bind:value={code}
|
bind:value={code}
|
||||||
disabled={joinState.busy}
|
disabled={joinState.busy}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -92,6 +128,14 @@
|
||||||
onkeydown={(e) => e.key === "Enter" && onSubmit()}
|
onkeydown={(e) => e.key === "Enter" && onSubmit()}
|
||||||
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||||
/>
|
/>
|
||||||
|
{:else if canOfferCode}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (codeRevealed = true)}
|
||||||
|
class="mb-3 text-sm text-neutral-500 underline hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
Have an invite code?
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
|
|
@ -103,18 +147,16 @@
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
{#if showCode}
|
||||||
type="button"
|
<button
|
||||||
onclick={onSubmit}
|
type="button"
|
||||||
disabled={joinState.busy}
|
onclick={onSubmit}
|
||||||
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"
|
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.busy ? "Joining…" : "Join"}
|
||||||
: joinState.needsCode
|
</button>
|
||||||
? "Request access"
|
{/if}
|
||||||
: "Join"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
import { resourcesStore } from "$lib/resources.svelte";
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
|
|
@ -7,6 +7,15 @@
|
||||||
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
|
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
|
||||||
import { openSearch } from "$lib/searchModal.svelte";
|
import { openSearch } from "$lib/searchModal.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
import UserAvatar from "$lib/components/UserAvatar.svelte";
|
||||||
|
import { notificationsStore } from "$lib/notifications.svelte";
|
||||||
|
|
||||||
|
const unread = $derived(notificationsStore.unreadCount);
|
||||||
|
const profileLabel = $derived(
|
||||||
|
unread > 0
|
||||||
|
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||||
|
: "Your profile",
|
||||||
|
);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: "simple" | "full";
|
mode: "simple" | "full";
|
||||||
|
|
@ -29,120 +38,153 @@
|
||||||
const activeAbout = $derived(
|
const activeAbout = $derived(
|
||||||
groupsStore.list.find((g) => g.id === activeRoom)?.about ?? "",
|
groupsStore.list.find((g) => g.id === activeRoom)?.about ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bottom fade shown while the nav has more content below the fold.
|
||||||
|
let navEl = $state<HTMLElement | null>(null);
|
||||||
|
let moreBelow = $state(false);
|
||||||
|
|
||||||
|
function updateFade() {
|
||||||
|
const el = navEl;
|
||||||
|
if (!el) return;
|
||||||
|
moreBelow = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const el = navEl;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(updateFade);
|
||||||
|
ro.observe(el);
|
||||||
|
for (const child of el.children) ro.observe(child);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="hidden max-w-52 min-w-48 shrink-0 flex-col justify-between pr-1 pb-8 pl-8 md:flex md:pt-6"
|
class="hidden max-w-52 min-w-48 shrink-0 flex-col justify-between pr-1 pb-8 pl-8 md:flex md:min-h-0 md:pt-6"
|
||||||
>
|
>
|
||||||
<div>
|
<!-- Own scroll container so a long about/rooms list never pushes the
|
||||||
<a
|
account row off screen (the page wrapper clips overflow). -->
|
||||||
href="/"
|
<div class="relative min-h-0 flex-1">
|
||||||
class="mt-3 mb-6 flex min-w-0 flex-col hover:opacity-90"
|
<div
|
||||||
aria-label="{name} — home"
|
bind:this={navEl}
|
||||||
|
onscroll={updateFade}
|
||||||
|
class="no-scrollbar h-full overflow-y-auto"
|
||||||
>
|
>
|
||||||
{#if groupStore.data?.picture}
|
<a
|
||||||
<img
|
href="/"
|
||||||
src={groupStore.data.picture}
|
class="mt-3 mb-6 flex min-w-0 flex-col hover:opacity-90"
|
||||||
alt={name}
|
aria-label="{name} — home"
|
||||||
class="max-w-[90%] shrink-0 object-cover"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<span
|
|
||||||
class="mt-2 text-[1.3rem] leading-6 font-medium text-neutral-900 dark:text-neutral-100"
|
|
||||||
>{name}</span
|
|
||||||
>
|
>
|
||||||
</a>
|
{#if groupStore.data?.picture}
|
||||||
|
<img
|
||||||
<a
|
src={groupStore.data.picture}
|
||||||
href="/"
|
alt={name}
|
||||||
class="flex items-center gap-2 py-1 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
class="max-w-[90%] shrink-0 object-cover"
|
||||||
>
|
/>
|
||||||
{mode === "simple" ? "Discussions" : "Home"}
|
{/if}
|
||||||
</a>
|
<span
|
||||||
|
class="mt-2 text-[1.3rem] leading-6 font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
{#if SEARCH_ENABLED}
|
>{name}</span
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={openSearch}
|
|
||||||
class="flex w-full items-center gap-2 py-1 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
<kbd
|
|
||||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded bg-neutral-200 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
|
||||||
aria-hidden="true">/</kbd
|
|
||||||
>
|
>
|
||||||
</button>
|
</a>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === "simple"}
|
<a
|
||||||
<div class="mt-6 flex">
|
href="/"
|
||||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
class="flex items-center gap-2 py-1 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
{groupStore.data?.about ?? ""}
|
>
|
||||||
</p>
|
{mode === "simple" ? "Discussions" : "Home"}
|
||||||
</div>
|
</a>
|
||||||
{:else}
|
|
||||||
{#if activeAbout}
|
{#if SEARCH_ENABLED}
|
||||||
<div class="mt-6">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openSearch}
|
||||||
|
class="flex w-full items-center gap-2 py-1 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
<kbd
|
||||||
|
class="ml-auto flex h-5 w-5 items-center justify-center rounded bg-neutral-200 font-sans text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true">/</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === "simple"}
|
||||||
|
<div class="mt-6 flex">
|
||||||
|
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{groupStore.data?.about ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if activeAbout}
|
||||||
|
<div class="mt-6">
|
||||||
|
<p
|
||||||
|
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{activeAbout}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<nav class="mt-6 flex-auto" aria-label="Rooms">
|
||||||
<p
|
<p
|
||||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||||
>
|
>
|
||||||
About
|
Rooms
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||||
{activeAbout}
|
<p class="py-1 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
</p>
|
Loading rooms…
|
||||||
</div>
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#each groupsStore.list as room}
|
||||||
|
<a
|
||||||
|
href="/room/{room.id}"
|
||||||
|
class="flex items-center gap-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-800
|
||||||
|
{activeRoom === room.id
|
||||||
|
? ' text-accent'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300'}"
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
<nav class="mt-6 flex-auto" aria-label="Rooms">
|
|
||||||
|
<nav class="mt-6 flex-auto" aria-label="Resources">
|
||||||
<p
|
<p
|
||||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||||
>
|
>
|
||||||
Rooms
|
Resources
|
||||||
</p>
|
</p>
|
||||||
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
{#each resourcesStore.list as r (r.slug)}
|
||||||
<p class="py-1 text-sm text-neutral-400 dark:text-neutral-500">
|
|
||||||
Loading rooms…
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#each groupsStore.list as room}
|
|
||||||
<a
|
<a
|
||||||
href="/room/{room.id}"
|
href="/resource/{r.slug}"
|
||||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-800
|
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||||
{activeRoom === room.id
|
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||||
? ' text-accent'
|
|
||||||
: 'text-neutral-700 dark:text-neutral-300'}"
|
|
||||||
>
|
|
||||||
{room.name}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<nav class="mt-6 flex-auto" aria-label="Resources">
|
|
||||||
<p
|
|
||||||
class="pb-1 text-xs font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
|
||||||
>
|
|
||||||
Resources
|
|
||||||
</p>
|
|
||||||
{#each resourcesStore.list as r (r.slug)}
|
|
||||||
<a
|
|
||||||
href="/resource/{r.slug}"
|
|
||||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
|
||||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
|
||||||
{activeResource === r.slug
|
{activeResource === r.slug
|
||||||
? 'text-accent'
|
? 'text-accent'
|
||||||
: 'text-neutral-500 dark:text-neutral-400'}">{r.title}</a
|
: 'text-neutral-500 dark:text-neutral-400'}">{r.title}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
<a
|
<a
|
||||||
href="/contacts"
|
href="/contacts"
|
||||||
aria-current={contactsActive ? "page" : undefined}
|
aria-current={contactsActive ? "page" : undefined}
|
||||||
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
class="block py-1 hover:text-neutral-900 dark:hover:text-neutral-100
|
||||||
{contactsActive
|
{contactsActive
|
||||||
? 'text-accent'
|
? 'text-accent'
|
||||||
: 'text-neutral-500 dark:text-neutral-400'}">Contacts</a
|
: 'text-neutral-500 dark:text-neutral-400'}">Contacts</a
|
||||||
>
|
>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-transparent to-neutral-100 transition-opacity dark:to-neutral-800 {moreBelow
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0'}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
@ -157,29 +199,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
<div class="mt-3 flex items-center gap-1">
|
<div class="mt-3 flex items-center gap-1">
|
||||||
<button
|
<a
|
||||||
onclick={() => {
|
href="/profile/{auth.user.npub}"
|
||||||
if (confirm("Log out?")) logout();
|
|
||||||
}}
|
|
||||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
aria-label="Account options"
|
aria-label={profileLabel}
|
||||||
>
|
>
|
||||||
{#if auth.user.metadata.picture}
|
<UserAvatar />
|
||||||
<img
|
|
||||||
src={auth.user.metadata.picture}
|
|
||||||
alt=""
|
|
||||||
class="h-7 w-7 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-xs font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="truncate font-medium">{auth.user.shortName}</span>
|
<span class="truncate font-medium">{auth.user.shortName}</span>
|
||||||
</button>
|
</a>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,78 @@
|
||||||
import {
|
import {
|
||||||
auth,
|
auth,
|
||||||
closeLogin,
|
closeLogin,
|
||||||
|
loginWithBunker,
|
||||||
loginWithExtension,
|
loginWithExtension,
|
||||||
|
loginWithNostrConnect,
|
||||||
loginWithNsec,
|
loginWithNsec,
|
||||||
} from "$lib/auth.svelte";
|
} from "$lib/auth.svelte";
|
||||||
import { tick } from "svelte";
|
import { TITLE, MODE } from "$lib/config";
|
||||||
|
import { groupStore } from "$lib/group.svelte";
|
||||||
|
import { themeState } from "$lib/theme.svelte";
|
||||||
|
import type { NstartModal } from "nstart-modal";
|
||||||
|
import { onDestroy, tick } from "svelte";
|
||||||
|
import { renderSVG } from "uqr";
|
||||||
|
|
||||||
let view = $state<"extension" | "nsec">("extension");
|
type View = "extension" | "bunker" | "nsec";
|
||||||
|
|
||||||
|
let view = $state<View>("extension");
|
||||||
let nsec = $state("");
|
let nsec = $state("");
|
||||||
|
let bunkerUrl = $state("");
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
let nsecInput = $state<HTMLInputElement | null>(null);
|
let nsecInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let bunkerInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Nstart sign-up wizard (an iframe overlay). Built lazily and reused; it is
|
||||||
|
// rebuilt only when the theme it was built for changes.
|
||||||
|
let wizard: NstartModal | null = null;
|
||||||
|
let wizardTheme: string | null = null;
|
||||||
|
// The login dialog hides while the wizard is up and comes back on cancel
|
||||||
|
let signingUp = $state(false);
|
||||||
|
|
||||||
|
const appName = $derived(
|
||||||
|
TITLE ||
|
||||||
|
(MODE === "simple" ? groupStore.data?.name : "") ||
|
||||||
|
(typeof location !== "undefined" ? location.host : ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client-initiated NIP-46 flow, alive only while the bunker view is shown.
|
||||||
|
// `finalizing` flips when the signer first answers, so the QR gives way to
|
||||||
|
// a progress state while the session completes.
|
||||||
|
// $state.raw so identity checks against the raw object (connect === nc)
|
||||||
|
// hold; the object is replaced wholesale, never mutated.
|
||||||
|
let connect = $state.raw<ReturnType<typeof loginWithNostrConnect> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
let finalizing = $state(false);
|
||||||
|
const qrSvg = $derived(
|
||||||
|
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
|
||||||
|
);
|
||||||
|
|
||||||
const hasExtension = $derived(
|
const hasExtension = $derived(
|
||||||
typeof window !== "undefined" && !!window.nostr,
|
typeof window !== "undefined" && !!window.nostr,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const titles: Record<View, string> = {
|
||||||
|
extension: "Log in",
|
||||||
|
bunker: "Log in with a bunker",
|
||||||
|
nsec: "Log in with nsec",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stopConnect() {
|
||||||
|
connect?.cancel();
|
||||||
|
connect = null;
|
||||||
|
finalizing = false;
|
||||||
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
nsec = "";
|
nsec = "";
|
||||||
|
bunkerUrl = "";
|
||||||
error = null;
|
error = null;
|
||||||
|
copied = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExtension() {
|
async function handleExtension() {
|
||||||
|
|
@ -53,33 +106,151 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBunker() {
|
||||||
|
if (busy || !bunkerUrl.trim()) return;
|
||||||
|
error = null;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await loginWithBunker(bunkerUrl);
|
||||||
|
closeLogin();
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
error =
|
||||||
|
e instanceof Error ? e.message : "Failed to connect to the bunker";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startConnect() {
|
||||||
|
stopConnect();
|
||||||
|
const nc = loginWithNostrConnect(() => {
|
||||||
|
if (connect === nc) finalizing = true;
|
||||||
|
});
|
||||||
|
connect = nc;
|
||||||
|
nc.done
|
||||||
|
.then(() => {
|
||||||
|
if (connect !== nc) return;
|
||||||
|
closeLogin();
|
||||||
|
reset();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (connect !== nc) return; // cancelled by leaving the view
|
||||||
|
connect = null;
|
||||||
|
finalizing = false;
|
||||||
|
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUri() {
|
||||||
|
if (!connect) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(connect.uri);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
} catch {
|
||||||
|
error = "Could not copy to the clipboard";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
closeLogin();
|
closeLogin();
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The accent color as Nstart wants it (hex, no #), so the wizard matches the
|
||||||
|
// forum; env overrides are already applied to the CSS variable.
|
||||||
|
function accentHex(): string | undefined {
|
||||||
|
const v = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--color-accent")
|
||||||
|
.trim();
|
||||||
|
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.slice(1) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignup() {
|
||||||
|
if (busy) return;
|
||||||
|
error = null;
|
||||||
|
stopConnect();
|
||||||
|
const { NstartModal } = await import("nstart-modal");
|
||||||
|
if (!wizard || wizardTheme !== themeState.theme) {
|
||||||
|
wizard?.destroy();
|
||||||
|
wizardTheme = themeState.theme;
|
||||||
|
wizard = new NstartModal({
|
||||||
|
baseUrl: "https://nstart.me",
|
||||||
|
an: appName,
|
||||||
|
aa: accentHex(),
|
||||||
|
am: themeState.theme,
|
||||||
|
// Only bunker:// or nsec come back; ncryptsec has no login here
|
||||||
|
aac: true,
|
||||||
|
onComplete: ({ nostrLogin }) => void completeSignup(nostrLogin),
|
||||||
|
onCancel: () => {
|
||||||
|
signingUp = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
signingUp = true;
|
||||||
|
wizard.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSignup(nostrLogin: string | null) {
|
||||||
|
signingUp = false;
|
||||||
|
if (!nostrLogin) {
|
||||||
|
error = "Sign-up finished without a login credential";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if (nostrLogin.startsWith("bunker://")) {
|
||||||
|
await loginWithBunker(nostrLogin);
|
||||||
|
} else {
|
||||||
|
await loginWithNsec(nostrLogin);
|
||||||
|
}
|
||||||
|
closeLogin();
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Failed to login";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function showNsecView() {
|
async function showNsecView() {
|
||||||
|
stopConnect();
|
||||||
view = "nsec";
|
view = "nsec";
|
||||||
error = null;
|
error = null;
|
||||||
await tick();
|
await tick();
|
||||||
nsecInput?.focus();
|
nsecInput?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showBunkerView() {
|
||||||
|
view = "bunker";
|
||||||
|
error = null;
|
||||||
|
startConnect();
|
||||||
|
await tick();
|
||||||
|
bunkerInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function showExtensionView() {
|
function showExtensionView() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (!auth.loginModalOpen) return;
|
if (!auth.loginModalOpen || signingUp) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopConnect();
|
||||||
|
wizard?.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
{#if auth.loginModalOpen}
|
{#if auth.loginModalOpen && !signingUp}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -88,7 +259,7 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-sm rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
|
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="login-title"
|
aria-labelledby="login-title"
|
||||||
|
|
@ -97,13 +268,16 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
<h2
|
||||||
{view === "extension" ? "Log in" : "Log in with nsec"}
|
id="login-title"
|
||||||
|
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{titles[view]}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -128,6 +302,14 @@
|
||||||
No Nostr extension detected in this browser.
|
No Nostr extension detected in this browser.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={showBunkerView}
|
||||||
|
disabled={busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Log in with a bunker
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={showNsecView}
|
onclick={showNsecView}
|
||||||
|
|
@ -135,6 +317,116 @@
|
||||||
>
|
>
|
||||||
Or log in using your nsec
|
Or log in using your nsec
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="my-5 border-t border-neutral-200 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="mb-3 text-center text-sm text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
Don't have an account yet?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSignup}
|
||||||
|
disabled={busy}
|
||||||
|
class="border-accent text-accent hover:bg-accent/10 w-full rounded border px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
{:else if view === "bunker"}
|
||||||
|
{#if finalizing}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 py-10"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600 dark:border-neutral-600 dark:border-t-neutral-300"
|
||||||
|
></span>
|
||||||
|
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Connected to your signer — finishing login…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if connect}
|
||||||
|
<a
|
||||||
|
href={connect.uri}
|
||||||
|
aria-label="Open in your signer app"
|
||||||
|
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
|
||||||
|
>
|
||||||
|
{@html qrSvg}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyUri}
|
||||||
|
aria-label={copied ? "Copied" : "Copy the connection string"}
|
||||||
|
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono"
|
||||||
|
>{connect.uri}</span
|
||||||
|
>
|
||||||
|
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Scan or paste this in your signer app, then approve the
|
||||||
|
connection.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="my-4 flex items-center gap-3" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
<span class="text-xs text-neutral-400">or</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
for="bunker-input"
|
||||||
|
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Paste a bunker URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bunker-input"
|
||||||
|
bind:this={bunkerInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://…"
|
||||||
|
bind:value={bunkerUrl}
|
||||||
|
disabled={busy}
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleBunker()}
|
||||||
|
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleBunker}
|
||||||
|
disabled={busy || !bunkerUrl.trim()}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Connecting…" : "Log in"}
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
A NIP-05 address that points to a bunker works too.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={showExtensionView}
|
||||||
|
disabled={busy}
|
||||||
|
class="text-accent mt-4 block w-full text-center text-sm hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<label for="nsec-input" class="sr-only">nsec</label>
|
<label for="nsec-input" class="sr-only">nsec</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -149,7 +441,7 @@
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
||||||
class="focus:ring-accent w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
|
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={handleNsec}
|
onclick={handleNsec}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
import { loadRelayList } from "$lib/gadgets";
|
||||||
import {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
searchLocalProfiles,
|
searchLocalProfiles,
|
||||||
|
|
|
||||||
64
src/lib/components/Meta.svelte
Normal file
64
src/lib/components/Meta.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
import { TITLE, GROUP_ID, MODE } from "$lib/config";
|
||||||
|
import { groupStore } from "$lib/group.svelte";
|
||||||
|
|
||||||
|
// Forum name from the env, or the group name in simple mode.
|
||||||
|
export function siteName(): string {
|
||||||
|
return (
|
||||||
|
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type?: "website" | "article";
|
||||||
|
image?: string;
|
||||||
|
jsonLd?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description = "",
|
||||||
|
type = "website",
|
||||||
|
image,
|
||||||
|
jsonLd,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Escape "<" so the JSON can never close the script tag early.
|
||||||
|
const ld = $derived(
|
||||||
|
jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const site = $derived(siteName());
|
||||||
|
const canonical = $derived(page.url.origin + page.url.pathname);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
{#if description}
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
{/if}
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
{#if site}
|
||||||
|
<meta property="og:site_name" content={site} />
|
||||||
|
{/if}
|
||||||
|
{#if image}
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
{/if}
|
||||||
|
<meta
|
||||||
|
name="twitter:card"
|
||||||
|
content={image ? "summary_large_image" : "summary"}
|
||||||
|
/>
|
||||||
|
{#if ld}
|
||||||
|
{@html `<script type="application/ld+json">${ld}</script>`}
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
@ -7,6 +7,15 @@
|
||||||
import { SEARCH_ENABLED } from "$lib/config";
|
import { SEARCH_ENABLED } from "$lib/config";
|
||||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
import UserAvatar from "$lib/components/UserAvatar.svelte";
|
||||||
|
import { notificationsStore } from "$lib/notifications.svelte";
|
||||||
|
|
||||||
|
const unread = $derived(notificationsStore.unreadCount);
|
||||||
|
const profileLabel = $derived(
|
||||||
|
unread > 0
|
||||||
|
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||||
|
: "Your profile",
|
||||||
|
);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -83,7 +92,7 @@
|
||||||
transition:fade={{ duration: 150 }}
|
transition:fade={{ duration: 150 }}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white dark:bg-neutral-900 px-6 py-4 shadow-xl"
|
class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white px-6 py-4 shadow-xl dark:bg-neutral-900"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
|
|
@ -97,7 +106,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="-mr-1 rounded p-1 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
class="-mr-1 rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -142,19 +151,25 @@
|
||||||
{#if mode === "full"}
|
{#if mode === "full"}
|
||||||
<nav class="mt-4" aria-label="Rooms">
|
<nav class="mt-4" aria-label="Rooms">
|
||||||
<p
|
<p
|
||||||
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase"
|
class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||||
>
|
>
|
||||||
Rooms
|
Rooms
|
||||||
</p>
|
</p>
|
||||||
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||||
<p class="py-1.5 text-base text-neutral-400 dark:text-neutral-500">Loading rooms…</p>
|
<p
|
||||||
|
class="py-1.5 text-base text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
Loading rooms…
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each groupsStore.list as room}
|
{#each groupsStore.list as room}
|
||||||
<a
|
<a
|
||||||
href="/room/{room.id}"
|
href="/room/{room.id}"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="block py-1.5 text-xl
|
class="block py-1.5 text-xl
|
||||||
{activeRoom === room.id ? 'text-accent' : 'hover:text-accent text-neutral-700 dark:text-neutral-300'}"
|
{activeRoom === room.id
|
||||||
|
? 'text-accent'
|
||||||
|
: 'hover:text-accent text-neutral-700 dark:text-neutral-300'}"
|
||||||
>
|
>
|
||||||
{room.name}
|
{room.name}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -164,7 +179,7 @@
|
||||||
|
|
||||||
<nav class="mt-4" aria-label="Resources">
|
<nav class="mt-4" aria-label="Resources">
|
||||||
<p
|
<p
|
||||||
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase"
|
class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
|
||||||
>
|
>
|
||||||
Resources
|
Resources
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -174,8 +189,9 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||||
class="hover:text-accent block py-1.5 text-xl
|
class="hover:text-accent block py-1.5 text-xl
|
||||||
{activeResource === r.slug ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}"
|
{activeResource === r.slug
|
||||||
>{r.title}</a
|
? 'text-accent'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300'}">{r.title}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
<a
|
<a
|
||||||
|
|
@ -183,11 +199,25 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-current={contactsActive ? "page" : undefined}
|
aria-current={contactsActive ? "page" : undefined}
|
||||||
class="hover:text-accent block py-1.5 text-lg
|
class="hover:text-accent block py-1.5 text-lg
|
||||||
{contactsActive ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}">Contacts</a
|
{contactsActive
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300'}">Contacts</a
|
||||||
>
|
>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 dark:border-neutral-800 pt-4">
|
{#if auth.user}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onLogout}
|
||||||
|
class="hover:text-accent mt-4 py-1.5 text-left text-lg text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4 dark:border-neutral-800"
|
||||||
|
>
|
||||||
{#if draftState.iconized}
|
{#if draftState.iconized}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -199,34 +229,17 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="/profile/{auth.user.npub}"
|
||||||
onclick={onLogout}
|
onclick={onClose}
|
||||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-2 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-2 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
aria-label="Log out"
|
aria-label={profileLabel}
|
||||||
>
|
>
|
||||||
{#if auth.user.metadata.picture}
|
<UserAvatar size="md" />
|
||||||
<img
|
|
||||||
src={auth.user.metadata.picture}
|
|
||||||
alt=""
|
|
||||||
class="h-8 w-8 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-sm font-semibold text-neutral-600 dark:text-neutral-400 dark:bg-neutral-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="truncate text-lg font-medium"
|
<span class="truncate text-lg font-medium"
|
||||||
>{auth.user.shortName}</span
|
>{auth.user.shortName}</span
|
||||||
>
|
>
|
||||||
<span
|
</a>
|
||||||
class="ml-auto shrink-0 text-sm text-neutral-400 dark:text-neutral-500"
|
|
||||||
>Log out</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<ThemeToggle size="md" />
|
<ThemeToggle size="md" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
|
import { notificationsStore } from "$lib/notifications.svelte";
|
||||||
import { GROUP_ID, TITLE, MODE } from "$lib/config";
|
import { GROUP_ID, TITLE, MODE } from "$lib/config";
|
||||||
|
|
||||||
type Props = { onMenuToggle: () => void };
|
type Props = { onMenuToggle: () => void };
|
||||||
let { onMenuToggle }: Props = $props();
|
let { onMenuToggle }: Props = $props();
|
||||||
|
|
||||||
|
// The unread notifications count rides on the menu button; the drawer's
|
||||||
|
// profile link repeats it and leads to the list.
|
||||||
|
const unread = $derived(notificationsStore.unreadCount);
|
||||||
|
const menuLabel = $derived(
|
||||||
|
unread > 0
|
||||||
|
? `Open menu, ${unread} new notification${unread === 1 ? "" : "s"}`
|
||||||
|
: "Open menu",
|
||||||
|
);
|
||||||
|
|
||||||
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
|
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
|
||||||
// own name; full mode has no single room, so it falls back to GROUP_ID.
|
// own name; full mode has no single room, so it falls back to GROUP_ID.
|
||||||
const name = $derived(
|
const name = $derived(
|
||||||
|
|
@ -15,7 +25,7 @@
|
||||||
|
|
||||||
<!-- Mobile only: on desktop the logo lives at the top of the left sidebar. -->
|
<!-- Mobile only: on desktop the logo lives at the top of the left sidebar. -->
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 dark:bg-neutral-800 px-4 md:hidden"
|
class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 px-4 md:hidden dark:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
|
|
@ -36,16 +46,24 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100 md:text-[1.7rem]"
|
class="truncate text-2xl font-medium text-neutral-900 md:text-[1.7rem] dark:text-neutral-100"
|
||||||
>{name}</span
|
>{name}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onMenuToggle}
|
onclick={onMenuToggle}
|
||||||
class="-mr-1 shrink-0 rounded p-1.5 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 md:hidden"
|
class="relative -mr-1 shrink-0 rounded p-1.5 text-neutral-700 hover:bg-neutral-200 md:hidden dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
aria-label="Open menu"
|
aria-label={menuLabel}
|
||||||
>
|
>
|
||||||
|
{#if unread > 0}
|
||||||
|
<span
|
||||||
|
class="bg-accent absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{unread > 99 ? "99+" : unread}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
|
|
|
||||||
210
src/lib/components/Notifications.svelte
Normal file
210
src/lib/components/Notifications.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { MODE } from "$lib/config";
|
||||||
|
import { getGroupName } from "$lib/group.svelte";
|
||||||
|
import { profileStore } from "$lib/profiles.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
|
import {
|
||||||
|
notificationsStore,
|
||||||
|
markNotificationsSeen,
|
||||||
|
loadMoreNotifications,
|
||||||
|
type Notification,
|
||||||
|
} from "$lib/notifications.svelte";
|
||||||
|
import { summarize } from "$lib/seo";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
|
||||||
|
const PAGE = 5;
|
||||||
|
|
||||||
|
const items = $derived(notificationsStore.items);
|
||||||
|
// Visible slice; "Show more" reveals another page from what is loaded and
|
||||||
|
// fetches older ones once the loaded list runs out.
|
||||||
|
let shown = $state(PAGE);
|
||||||
|
const visible = $derived(items?.slice(0, shown) ?? null);
|
||||||
|
const hasMore = $derived(
|
||||||
|
!!items && (items.length > shown || !notificationsStore.done),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
if (items && items.length <= shown) await loadMoreNotifications();
|
||||||
|
shown += PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread threshold frozen when the list first shows, so new items keep their
|
||||||
|
// mark while on screen even though they are counted as seen right away.
|
||||||
|
let openedAt = $state<number | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!items) return;
|
||||||
|
if (openedAt === null) {
|
||||||
|
const seenAt = untrack(() => notificationsStore.seenAt);
|
||||||
|
openedAt = seenAt;
|
||||||
|
// Every new item is visible at once; the page size is only a floor
|
||||||
|
const unread = items.filter((n) => n.createdAt > seenAt).length;
|
||||||
|
shown = Math.max(PAGE, unread);
|
||||||
|
}
|
||||||
|
markNotificationsSeen();
|
||||||
|
});
|
||||||
|
|
||||||
|
function authorName(pubkey: string): string {
|
||||||
|
const p = profileStore.profiles.get(pubkey);
|
||||||
|
return (
|
||||||
|
p?.displayName ||
|
||||||
|
p?.name ||
|
||||||
|
`${(p?.npub ?? nip19.npubEncode(pubkey)).slice(0, 12)}…`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorPicture(pubkey: string): string | undefined {
|
||||||
|
return profileStore.profiles.get(pubkey)?.picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verb(n: Notification): string {
|
||||||
|
switch (n.kind) {
|
||||||
|
case "quote":
|
||||||
|
case "reply-op":
|
||||||
|
return "replied to you in";
|
||||||
|
case "reply":
|
||||||
|
return "wrote in";
|
||||||
|
case "mention":
|
||||||
|
return "mentioned you in";
|
||||||
|
case "chat-reply":
|
||||||
|
return "replied to you in chat";
|
||||||
|
case "chat-mention":
|
||||||
|
return MODE === "full" ? "mentioned you in" : "mentioned you in chat";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function href(n: Notification): string {
|
||||||
|
if (n.threadId) return `/thread/${n.threadId}#post-${n.id}`;
|
||||||
|
const room = MODE === "full" ? `/room/${n.groupId}` : "/";
|
||||||
|
return `${room}#chat-${n.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||||
|
if (diff < 60) return "now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||||
|
if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d`;
|
||||||
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-labelledby="profile-notifications" class="mt-10">
|
||||||
|
<h2
|
||||||
|
id="profile-notifications"
|
||||||
|
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
{#if !items}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">Loading…</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
No replies or mentions yet.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{#each visible ?? [] as n (n.id)}
|
||||||
|
{@const unread = n.createdAt > (openedAt ?? 0)}
|
||||||
|
{@const name = authorName(n.pubkey)}
|
||||||
|
{@const picture = authorPicture(n.pubkey)}
|
||||||
|
{@const text = summarize(n.content, 160)}
|
||||||
|
<li class="flex gap-3 py-3">
|
||||||
|
<a
|
||||||
|
href={profilePath(n.pubkey)}
|
||||||
|
class="mt-0.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if picture}
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt=""
|
||||||
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{name[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href={href(n)} class="group block">
|
||||||
|
<p
|
||||||
|
class="text-neutral-900 dark:text-neutral-100 {unread
|
||||||
|
? 'font-semibold'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{#if unread}
|
||||||
|
<span
|
||||||
|
class="bg-accent mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span class="sr-only">New: </span>
|
||||||
|
{/if}
|
||||||
|
<span>{name}</span>
|
||||||
|
<span
|
||||||
|
class="font-normal text-neutral-500 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{verb(n)}
|
||||||
|
</span>
|
||||||
|
{#if n.threadId}
|
||||||
|
<span class="group-hover:text-accent">
|
||||||
|
{n.title || "a discussion"}
|
||||||
|
</span>
|
||||||
|
{:else if n.kind === "chat-mention" && MODE === "full"}
|
||||||
|
<span class="group-hover:text-accent"
|
||||||
|
>{getGroupName(n.groupId)}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-normal text-neutral-500 dark:text-neutral-400"
|
||||||
|
>chat</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if text}
|
||||||
|
<p
|
||||||
|
class="mt-0.5 line-clamp-2 text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="mt-1 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
{#if MODE === "full" && n.groupId}
|
||||||
|
<a href="/room/{n.groupId}" class="hover:text-accent truncate"
|
||||||
|
>{getGroupName(n.groupId)}</a
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
{/if}
|
||||||
|
<time datetime={new Date(n.createdAt * 1000).toISOString()}
|
||||||
|
>{formatTime(n.createdAt)}</time
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hasMore}
|
||||||
|
{@const busy = notificationsStore.loadingMore}
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<button
|
||||||
|
onclick={showMore}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={busy}
|
||||||
|
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{busy ? "Loading…" : "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
|
import { externalLink } from "$lib/config";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -310,7 +312,7 @@
|
||||||
const entity = dest.slice(6).toLowerCase();
|
const entity = dest.slice(6).toLowerCase();
|
||||||
try {
|
try {
|
||||||
nip19.decode(entity);
|
nip19.decode(entity);
|
||||||
return { type: "link", href: `https://njump.me/${entity}`, label };
|
return { type: "link", href: externalLink(entity), label };
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: label };
|
return { type: "text", value: label };
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +335,6 @@
|
||||||
case "str":
|
case "str":
|
||||||
return tokenizeInline(node.text);
|
return tokenizeInline(node.text);
|
||||||
case "soft_break":
|
case "soft_break":
|
||||||
return [{ type: "text", value: " " }];
|
|
||||||
case "hard_break":
|
case "hard_break":
|
||||||
return [{ type: "br" }];
|
return [{ type: "br" }];
|
||||||
case "non_breaking_space":
|
case "non_breaking_space":
|
||||||
|
|
@ -611,11 +612,8 @@
|
||||||
>
|
>
|
||||||
{:else if inline.type === "mention"}
|
{:else if inline.type === "mention"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
<a
|
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
|
||||||
href="https://njump.me/{inline.entity}"
|
>@{u?.shortName ?? inline.fallback}</a
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
|
|
||||||
>
|
>
|
||||||
{:else if inline.type === "thread-quote"}
|
{:else if inline.type === "thread-quote"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
|
|
@ -645,7 +643,7 @@
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{inline.entity}"
|
href={externalLink(inline.entity)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-accent break-all hover:underline">{inline.label}</a
|
class="text-accent break-all hover:underline">{inline.label}</a
|
||||||
|
|
@ -700,7 +698,7 @@
|
||||||
src={block.src}
|
src={block.src}
|
||||||
alt={block.alt}
|
alt={block.alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
class="mx-auto block max-h-[80vh] w-full rounded object-contain"
|
||||||
/>
|
/>
|
||||||
{:else if block.type === "blockquote"}
|
{:else if block.type === "blockquote"}
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
@ -773,7 +771,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="prose max-w-none leading-5 text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
|
class="prose max-w-none leading-5 wrap-anywhere text-neutral-700 dark:text-neutral-300 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 dark:[&_:not(pre)>code]:bg-neutral-800 dark:[&_:not(pre)>code]:text-neutral-200 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none [&_img]:my-5 [&_p]:my-3 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 dark:[&_pre]:bg-neutral-800 dark:[&_pre]:text-neutral-200 [&_table]:my-0"
|
||||||
>
|
>
|
||||||
{@render renderBlocks(blocks)}
|
{@render renderBlocks(blocks)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
106
src/lib/components/RelayGate.svelte
Normal file
106
src/lib/components/RelayGate.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||||
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
|
import { joinRelay, joinState } from "$lib/join.svelte";
|
||||||
|
import { TITLE } from "$lib/config";
|
||||||
|
|
||||||
|
// The relay's NIP-11 name is readable even when nothing else is, so the
|
||||||
|
// gate can still say which forum this is.
|
||||||
|
const title = $derived(TITLE || relayAccess.info?.name || "Forum");
|
||||||
|
const description = $derived(relayAccess.info?.description ?? "");
|
||||||
|
const state = $derived(relayAccess.state);
|
||||||
|
// A logged-in user still refused with auth-required means the handshake
|
||||||
|
// failed rather than that they must log in; offer a retry instead.
|
||||||
|
const authFailed = $derived(state === "auth-required" && !!auth.signer);
|
||||||
|
|
||||||
|
function onLogin() {
|
||||||
|
openLogin(() => {
|
||||||
|
probeRelayAccess();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJoin() {
|
||||||
|
joinRelay(() => {
|
||||||
|
probeRelayAccess();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-dvh flex-col">
|
||||||
|
<div class="flex justify-end p-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<main
|
||||||
|
class="flex flex-1 flex-col items-center justify-center px-6 pb-24 text-center"
|
||||||
|
>
|
||||||
|
{#if relayAccess.info?.icon}
|
||||||
|
<img
|
||||||
|
src={relayAccess.info.icon}
|
||||||
|
alt=""
|
||||||
|
class="mb-6 h-20 w-20 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<h1 class="text-accent text-3xl font-semibold">{title}</h1>
|
||||||
|
{#if description}
|
||||||
|
<p class="mt-2 max-w-md text-neutral-600 dark:text-neutral-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-8 max-w-md" role="status" aria-live="polite">
|
||||||
|
{#if state === "checking"}
|
||||||
|
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Checking access…
|
||||||
|
</p>
|
||||||
|
{:else if authFailed}
|
||||||
|
<p class="text-neutral-700 dark:text-neutral-300">
|
||||||
|
The relay did not accept your login.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => probeRelayAccess()}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
{:else if state === "auth-required"}
|
||||||
|
<p class="text-neutral-700 dark:text-neutral-300">
|
||||||
|
You need to log in to access this forum.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onLogin}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
{:else if state === "restricted"}
|
||||||
|
<p class="text-neutral-700 dark:text-neutral-300">
|
||||||
|
This forum is for members only.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onJoin}
|
||||||
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joinState.busy ? "Joining…" : "Join this forum"}
|
||||||
|
</button>
|
||||||
|
{:else if state === "blocked"}
|
||||||
|
<p class="text-neutral-700 dark:text-neutral-300">
|
||||||
|
You can't access this forum.
|
||||||
|
</p>
|
||||||
|
{#if relayAccess.message}
|
||||||
|
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{relayAccess.message}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
function select(r: SearchResult) {
|
function select(r: SearchResult) {
|
||||||
open = false;
|
open = false;
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,26 @@
|
||||||
|
|
||||||
function select(r: SearchResult) {
|
function select(r: SearchResult) {
|
||||||
closeSearch();
|
closeSearch();
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global "/" opens the search from any page, unless typing somewhere else
|
// Global "/" opens the search from any page, unless typing somewhere else
|
||||||
function onWindowKeydown(e: KeyboardEvent) {
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
if (searchModal.open) {
|
if (searchModal.open) {
|
||||||
if (e.key === "Escape") closeSearch();
|
if (e.key === "Escape") {
|
||||||
|
closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Typing fast right after "/" can outrun the async focus; route the
|
||||||
|
// keystroke into the input so no leading characters get lost
|
||||||
|
if (
|
||||||
|
e.key.length === 1 &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
document.activeElement !== inputEl
|
||||||
|
)
|
||||||
|
inputEl?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
{#each search.results as r, i (r.threadId)}
|
{#each search.results as r, i (r.threadId)}
|
||||||
<a
|
<a
|
||||||
id="{idBase}-{i}"
|
id="{idBase}-{i}"
|
||||||
href="/thread/{r.threadId}"
|
href={search.threadUrl(r.threadId)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === search.activeIndex}
|
aria-selected={i === search.activeIndex}
|
||||||
class="block rounded px-4 py-2 {i === search.activeIndex
|
class="block rounded px-4 py-2 {i === search.activeIndex
|
||||||
|
|
|
||||||
40
src/lib/components/UserAvatar.svelte
Normal file
40
src/lib/components/UserAvatar.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from "$lib/auth.svelte";
|
||||||
|
import { notificationsStore } from "$lib/notifications.svelte";
|
||||||
|
|
||||||
|
type Props = { size?: "sm" | "md" };
|
||||||
|
let { size = "sm" }: Props = $props();
|
||||||
|
|
||||||
|
const box = $derived(size === "md" ? "h-8 w-8 text-sm" : "h-7 w-7 text-xs");
|
||||||
|
const count = $derived(notificationsStore.unreadCount);
|
||||||
|
const label = $derived(count > 99 ? "99+" : String(count));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Logged-in user's picture with the unread notifications count. The count is
|
||||||
|
visual only; the wrapping link carries it in its accessible name. -->
|
||||||
|
{#if auth.user}
|
||||||
|
<span class="relative inline-flex shrink-0">
|
||||||
|
{#if auth.user.metadata.picture}
|
||||||
|
<img
|
||||||
|
src={auth.user.metadata.picture}
|
||||||
|
alt=""
|
||||||
|
class="{box} rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="{box} flex items-center justify-center rounded-full bg-neutral-300 font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if count > 0}
|
||||||
|
<span
|
||||||
|
class="bg-accent absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
@ -18,7 +18,38 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
|
||||||
if (MODE === "simple" && !GROUP_ID) {
|
if (MODE === "simple" && !GROUP_ID) {
|
||||||
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
||||||
}
|
}
|
||||||
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
|
// Relays used to hand a nostrconnect:// URI to a remote signer (NIP-46).
|
||||||
|
// Comma-separated override via PUBLIC_NOSTRCONNECT_RELAYS.
|
||||||
|
export const NOSTRCONNECT_RELAYS: string[] = (
|
||||||
|
env.PUBLIC_NOSTRCONNECT_RELAYS ||
|
||||||
|
"wss://bucket.coracle.social,wss://relay.primal.net"
|
||||||
|
)
|
||||||
|
.split(",")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
|
||||||
|
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
|
||||||
|
export const SSR_ENABLED = __SQUALK_SSR__;
|
||||||
|
// With SSR on, a client-side navigation also asks the server for the same
|
||||||
|
// snapshot (without waiting for it) so the page is already cached for the
|
||||||
|
// next refresh, shared link or crawler. Costs one server→relay query per
|
||||||
|
// navigation; opt out with PUBLIC_SSR_WARM=no.
|
||||||
|
export const SSR_WARM = SSR_ENABLED && env.PUBLIC_SSR_WARM !== "no";
|
||||||
|
// Lifetime of server-rendered snapshots, in seconds. Within FRESH a cached
|
||||||
|
// page is served as is; up to STALE it is still served at once but refreshed
|
||||||
|
// in the background; beyond that it is fetched again before answering. Only
|
||||||
|
// crawlers and cold refreshes see the snapshot — the browser always refetches
|
||||||
|
// live data after hydration — so these trade first-paint staleness for speed.
|
||||||
|
function seconds(raw: string | undefined, fallback: number): number {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
export const SSR_CACHE_FRESH = seconds(env.PUBLIC_SSR_CACHE_FRESH, 300);
|
||||||
|
export const SSR_CACHE_STALE = seconds(env.PUBLIC_SSR_CACHE_STALE, 6 * 3600);
|
||||||
|
// The same policy for a shared HTTP cache in front (Cloudflare honours it once
|
||||||
|
// HTML caching is enabled). Browsers always revalidate (max-age=0) so a login
|
||||||
|
// shows its content at once.
|
||||||
|
export const CACHE_CONTROL = `public, max-age=0, s-maxage=${SSR_CACHE_FRESH}, stale-while-revalidate=${SSR_CACHE_STALE}`;
|
||||||
// Requires a relay with NIP-50 support.
|
// Requires a relay with NIP-50 support.
|
||||||
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
|
||||||
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
export const LABELS = (env.PUBLIC_LABELS ?? "")
|
||||||
|
|
@ -31,3 +62,31 @@ export const BLOSSOM_URL = (env.PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, "");
|
||||||
// CSS variables defined in layout.css. Hover shades are derived in the layout.
|
// CSS variables defined in layout.css. Hover shades are derived in the layout.
|
||||||
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
|
export const ACCENT_COLOR = env.PUBLIC_ACCENT_COLOR ?? "";
|
||||||
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
|
export const SECONDARY_COLOR = env.PUBLIC_SECONDARY_COLOR ?? "";
|
||||||
|
|
||||||
|
// Favicon: any URL (absolute, or root-relative for a file in static/). The
|
||||||
|
// default is a ring in the accent color (a circle with a transparent hole of
|
||||||
|
// half its radius), inlined as an SVG data URI.
|
||||||
|
const DEFAULT_ACCENT = "#e32a6d";
|
||||||
|
export const FAVICON =
|
||||||
|
env.PUBLIC_FAVICON ||
|
||||||
|
"data:image/svg+xml," +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill-rule="evenodd" fill="${ACCENT_COLOR || DEFAULT_ACCENT}" d="M16 0a16 16 0 1 0 0 32a16 16 0 1 0 0-32zM16 8a8 8 0 1 1 0 16a8 8 0 1 1 0-16z"/></svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// External Nostr client used to open events and profiles that live outside
|
||||||
|
// the forum. {e} is replaced with the bech32 entity (npub, nevent, naddr...).
|
||||||
|
const DEFAULT_EXTERNAL_CLIENT = "https://njump.me/{e}";
|
||||||
|
export const EXTERNAL_CLIENT = env.PUBLIC_EXTERNAL_CLIENT?.includes("{e}")
|
||||||
|
? env.PUBLIC_EXTERNAL_CLIENT
|
||||||
|
: DEFAULT_EXTERNAL_CLIENT;
|
||||||
|
export const EXTERNAL_CLIENT_NAME = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(EXTERNAL_CLIENT.replace("{e}", "x")).hostname;
|
||||||
|
} catch {
|
||||||
|
return "external client";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
export function externalLink(entity: string): string {
|
||||||
|
return EXTERNAL_CLIENT.replace("{e}", entity);
|
||||||
|
}
|
||||||
|
|
|
||||||
81
src/lib/forum/groups.ts
Normal file
81
src/lib/forum/groups.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import { tag, tags, type Query } from "./query";
|
||||||
|
|
||||||
|
export type GroupSummary = {
|
||||||
|
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
|
||||||
|
name: string;
|
||||||
|
picture?: string;
|
||||||
|
about?: string;
|
||||||
|
createdAt: number;
|
||||||
|
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupMetadata = {
|
||||||
|
name: string;
|
||||||
|
picture?: string;
|
||||||
|
about?: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
isClosed: boolean;
|
||||||
|
isRestricted: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
admins: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
|
||||||
|
|
||||||
|
function parseSummary(e: Event): GroupSummary {
|
||||||
|
const id = tag(e, "d") ?? "";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: tag(e, "name") ?? id,
|
||||||
|
picture: tag(e, "picture"),
|
||||||
|
about: tag(e, "about"),
|
||||||
|
createdAt: e.created_at,
|
||||||
|
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every group the relay serves this visitor (kind 39000), oldest first.
|
||||||
|
export async function fetchGroups(q: Query): Promise<GroupSummary[]> {
|
||||||
|
const events = await q({ kinds: [39000] });
|
||||||
|
return events
|
||||||
|
.map(parseSummary)
|
||||||
|
.filter((g) => g.id)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One group's metadata plus its admins (kinds 39000 + 39001).
|
||||||
|
export async function fetchGroup(
|
||||||
|
q: Query,
|
||||||
|
id: string,
|
||||||
|
): Promise<GroupMetadata | null> {
|
||||||
|
const events = await q({ kinds: [39000, 39001], "#d": [id] });
|
||||||
|
const event = events.find((e) => e.kind === 39000);
|
||||||
|
if (!event) return null;
|
||||||
|
const adminsEvent = events.find((e) => e.kind === 39001);
|
||||||
|
return {
|
||||||
|
name: tag(event, "name") ?? id,
|
||||||
|
picture: tag(event, "picture"),
|
||||||
|
about: tag(event, "about"),
|
||||||
|
isPrivate: event.tags.some((t) => t[0] === "private"),
|
||||||
|
isClosed: event.tags.some((t) => t[0] === "closed"),
|
||||||
|
isRestricted: event.tags.some((t) => t[0] === "restricted"),
|
||||||
|
isHidden: event.tags.some((t) => t[0] === "hidden"),
|
||||||
|
admins: adminsEvent ? tags(adminsEvent, "p") : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// room id -> admin pubkeys (kind 39001 `p` tags) for the given rooms.
|
||||||
|
export async function fetchRoomAdmins(
|
||||||
|
q: Query,
|
||||||
|
roomIds: string[],
|
||||||
|
): Promise<Record<string, string[]>> {
|
||||||
|
if (roomIds.length === 0) return {};
|
||||||
|
const events = await q({ kinds: [39001], "#d": roomIds });
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
for (const e of events) {
|
||||||
|
const d = tag(e, "d");
|
||||||
|
if (d) map[d] = tags(e, "p");
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
116
src/lib/forum/notifications.ts
Normal file
116
src/lib/forum/notifications.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import { extractMentionPubkeys } from "$lib/mentions";
|
||||||
|
import { tag, type Query } from "./query";
|
||||||
|
|
||||||
|
export type NotificationKind =
|
||||||
|
| "quote" // reply quoting one of the user's posts
|
||||||
|
| "mention" // mentioned in a discussion reply
|
||||||
|
| "reply-op" // reply to a discussion the user started
|
||||||
|
| "reply" // reply in a discussion the user took part in, p-tag only
|
||||||
|
| "chat-reply" // chat reply to the user's message
|
||||||
|
| "chat-mention"; // mentioned in chat
|
||||||
|
|
||||||
|
// Everything but plain thread activity is addressed to the user. Not applied
|
||||||
|
// yet: a future setting will let users leave plain activity out of the badge.
|
||||||
|
export function isDirect(kind: NotificationKind): boolean {
|
||||||
|
return kind !== "reply";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
id: string;
|
||||||
|
kind: NotificationKind;
|
||||||
|
pubkey: string; // author
|
||||||
|
createdAt: number;
|
||||||
|
content: string;
|
||||||
|
groupId: string;
|
||||||
|
threadId?: string; // discussion replies only
|
||||||
|
title?: string; // thread title, filled by fetchThreadTitles
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_PAGE = 20;
|
||||||
|
const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor
|
||||||
|
|
||||||
|
export type NotificationPage = {
|
||||||
|
items: Notification[];
|
||||||
|
done: boolean; // no older notifications left
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classify an event that p-tags the user. Own events and anything that is
|
||||||
|
// neither a discussion reply nor a chat message yield null.
|
||||||
|
export function parseNotification(e: Event, me: string): Notification | null {
|
||||||
|
if (e.pubkey === me) return null;
|
||||||
|
const mentioned = extractMentionPubkeys(e.content).includes(me);
|
||||||
|
const base = {
|
||||||
|
id: e.id,
|
||||||
|
pubkey: e.pubkey,
|
||||||
|
createdAt: e.created_at,
|
||||||
|
content: e.content,
|
||||||
|
groupId: tag(e, "h") ?? "",
|
||||||
|
};
|
||||||
|
if (e.kind === 1111) {
|
||||||
|
const threadId = tag(e, "E");
|
||||||
|
if (!threadId) return null;
|
||||||
|
const quoted = e.tags.some((t) => t[0] === "q" && t[3] === me);
|
||||||
|
const kind: NotificationKind = quoted
|
||||||
|
? "quote"
|
||||||
|
: mentioned
|
||||||
|
? "mention"
|
||||||
|
: tag(e, "P") === me
|
||||||
|
? "reply-op"
|
||||||
|
: "reply";
|
||||||
|
return { ...base, kind, threadId };
|
||||||
|
}
|
||||||
|
if (e.kind === 9) {
|
||||||
|
const q = e.tags.find((t) => t[0] === "q");
|
||||||
|
return { ...base, kind: q?.[3] === me ? "chat-reply" : "chat-mention" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill thread titles for discussion replies, in place.
|
||||||
|
export async function fetchThreadTitles(
|
||||||
|
q: Query,
|
||||||
|
items: Notification[],
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = [
|
||||||
|
...new Set(items.flatMap((n) => (n.threadId ? [n.threadId] : []))),
|
||||||
|
];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const roots = await q({ kinds: [11], ids });
|
||||||
|
const titles = new Map(
|
||||||
|
roots.map((e) => [e.id, tag(e, "title") ?? "(untitled)"]),
|
||||||
|
);
|
||||||
|
for (const n of items) {
|
||||||
|
if (n.threadId) n.title = titles.get(n.threadId) ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One page of replies and mentions addressed to the user, newest first,
|
||||||
|
// older than the cursor. Without `groupId` every group the relay serves the
|
||||||
|
// caller counts.
|
||||||
|
export async function fetchNotifications(
|
||||||
|
q: Query,
|
||||||
|
me: string,
|
||||||
|
req: { groupId?: string; until?: number; exclude?: Set<string> } = {},
|
||||||
|
): Promise<NotificationPage> {
|
||||||
|
const n = NOTIFICATIONS_PAGE;
|
||||||
|
const exclude = req.exclude ?? new Set<string>();
|
||||||
|
const events = await q({
|
||||||
|
kinds: [1111, 9],
|
||||||
|
"#p": [me],
|
||||||
|
...(req.groupId ? { "#h": [req.groupId] } : {}),
|
||||||
|
...(req.until ? { until: req.until } : {}),
|
||||||
|
limit: n + CURSOR_HEADROOM,
|
||||||
|
});
|
||||||
|
const fresh = events
|
||||||
|
.filter((e) => !exclude.has(e.id))
|
||||||
|
.map((e) => parseNotification(e, me))
|
||||||
|
.filter((x): x is Notification => x !== null)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
const items = fresh.slice(0, n);
|
||||||
|
// Finished only when everything fresh fits the page and the relay had
|
||||||
|
// nothing beyond the requested window
|
||||||
|
const done = fresh.length <= n && events.length < n + CURSOR_HEADROOM;
|
||||||
|
await fetchThreadTitles(q, items);
|
||||||
|
return { items, done };
|
||||||
|
}
|
||||||
73
src/lib/forum/overview.ts
Normal file
73
src/lib/forum/overview.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { tag, type Query } from "./query";
|
||||||
|
|
||||||
|
export type RoomActivity = {
|
||||||
|
latestAt: number;
|
||||||
|
latestPubkey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentThread = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
groupId: string;
|
||||||
|
authorPubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Overview = {
|
||||||
|
activity: Record<string, RoomActivity>;
|
||||||
|
admins: Record<string, string>; // room id -> first admin pubkey
|
||||||
|
recent: RecentThread[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECENT_LIMIT = 20;
|
||||||
|
|
||||||
|
// Landing data across rooms: each room's last activity and first admin (for
|
||||||
|
// the room cards) and the most recent threads overall (for the side panel).
|
||||||
|
// The group relay truncates multi-value "#h" filters, so rooms are queried
|
||||||
|
// one by one and merged.
|
||||||
|
export async function fetchOverview(
|
||||||
|
q: Query,
|
||||||
|
roomIds: string[],
|
||||||
|
): Promise<Overview> {
|
||||||
|
const latest = await Promise.all(
|
||||||
|
roomIds.map((id) => q({ kinds: [11, 1111], "#h": [id], limit: 1 })),
|
||||||
|
);
|
||||||
|
const activity: Record<string, RoomActivity> = {};
|
||||||
|
roomIds.forEach((id, i) => {
|
||||||
|
const e = latest[i][0];
|
||||||
|
if (e) activity[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEvents = await Promise.all(
|
||||||
|
roomIds.map((id) => q({ kinds: [39001], "#d": [id] })),
|
||||||
|
);
|
||||||
|
const admins: Record<string, string> = {};
|
||||||
|
roomIds.forEach((id, i) => {
|
||||||
|
const pk = adminEvents[i][0] ? tag(adminEvents[i][0], "p") : undefined;
|
||||||
|
if (pk) admins[id] = pk;
|
||||||
|
});
|
||||||
|
|
||||||
|
const perRoom = await Promise.all(
|
||||||
|
roomIds.map((id) => q({ kinds: [11], "#h": [id], limit: RECENT_LIMIT })),
|
||||||
|
);
|
||||||
|
const threads = perRoom.flat().sort((a, b) => b.created_at - a.created_at);
|
||||||
|
const recent = threads.slice(0, RECENT_LIMIT).map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: tag(e, "title") ?? "(untitled)",
|
||||||
|
groupId: tag(e, "h") ?? "",
|
||||||
|
authorPubkey: e.pubkey,
|
||||||
|
createdAt: e.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { activity, admins, recent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewPubkeys(o: Overview): string[] {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
...Object.values(o.activity).map((a) => a.latestPubkey),
|
||||||
|
...Object.values(o.admins),
|
||||||
|
...o.recent.map((t) => t.authorPubkey),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
48
src/lib/forum/partials.ts
Normal file
48
src/lib/forum/partials.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { tag, type Query } from "./query";
|
||||||
|
|
||||||
|
export type PartialSlot = "home" | "contacts";
|
||||||
|
const SLOTS = new Set<string>(["home", "contacts"]);
|
||||||
|
|
||||||
|
export type Partial = {
|
||||||
|
id: string;
|
||||||
|
slot: PartialSlot;
|
||||||
|
title: string;
|
||||||
|
content: string; // markdown body
|
||||||
|
pubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every `squalk-partial` event for a known slot. Not admin-filtered nor
|
||||||
|
// reduced to the newest per slot: both depend on the admin set.
|
||||||
|
export async function fetchPartials(q: Query): Promise<Partial[]> {
|
||||||
|
const events = await q({ kinds: [30023], "#t": ["squalk-partial"] });
|
||||||
|
const out: Partial[] = [];
|
||||||
|
for (const e of events) {
|
||||||
|
const slot = tag(e, "d");
|
||||||
|
if (!slot || !SLOTS.has(slot)) continue;
|
||||||
|
out.push({
|
||||||
|
id: e.id,
|
||||||
|
slot: slot as PartialSlot,
|
||||||
|
title: tag(e, "title") ?? slot,
|
||||||
|
content: e.content,
|
||||||
|
pubkey: e.pubkey,
|
||||||
|
createdAt: e.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The newest admin-authored partial for a slot.
|
||||||
|
export function pickPartial(
|
||||||
|
all: Partial[],
|
||||||
|
slot: PartialSlot,
|
||||||
|
admins: string[],
|
||||||
|
): Partial | undefined {
|
||||||
|
const set = new Set(admins);
|
||||||
|
let best: Partial | undefined;
|
||||||
|
for (const p of all) {
|
||||||
|
if (p.slot !== slot || !set.has(p.pubkey)) continue;
|
||||||
|
if (!best || p.createdAt > best.createdAt) best = p;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
75
src/lib/forum/profiles.ts
Normal file
75
src/lib/forum/profiles.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import type { NostrUser } from "$lib/gadgets";
|
||||||
|
import type { Query } from "./query";
|
||||||
|
|
||||||
|
// Public relays with broad kind-0 coverage.
|
||||||
|
export const PROFILE_RELAYS = [
|
||||||
|
"wss://purplepag.es",
|
||||||
|
"wss://relay.nostr.band",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://nos.lol",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same shape @nostr/gadgets produces, built here from raw kind-0 events so the
|
||||||
|
// server can resolve authors without that (browser-only) library.
|
||||||
|
export function bareUser(pubkey: string): NostrUser {
|
||||||
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
npub,
|
||||||
|
shortName: npub.substring(0, 8) + "…" + npub.substring(59),
|
||||||
|
metadata: {},
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userFromEvent(e: Event): NostrUser {
|
||||||
|
const u = bareUser(e.pubkey);
|
||||||
|
let md: NostrUser["metadata"] = {};
|
||||||
|
try {
|
||||||
|
md = JSON.parse(e.content) ?? {};
|
||||||
|
} catch {
|
||||||
|
// Malformed metadata: keep the bare user
|
||||||
|
}
|
||||||
|
u.metadata = md;
|
||||||
|
u.shortName =
|
||||||
|
md.name || md.display_name || md.nip05?.split("@")?.[0] || u.shortName;
|
||||||
|
u.lastUpdated = e.created_at;
|
||||||
|
if (md.picture) u.image = md.picture;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest kind 0 per pubkey; pubkeys with no metadata are left out.
|
||||||
|
export async function fetchProfiles(
|
||||||
|
q: Query,
|
||||||
|
pubkeys: string[],
|
||||||
|
): Promise<Record<string, NostrUser>> {
|
||||||
|
const out: Record<string, NostrUser> = {};
|
||||||
|
if (pubkeys.length === 0) return out;
|
||||||
|
const events = await q({ kinds: [0], authors: pubkeys });
|
||||||
|
for (const e of events) {
|
||||||
|
const prev = out[e.pubkey];
|
||||||
|
if (prev && prev.lastUpdated >= e.created_at) continue;
|
||||||
|
out[e.pubkey] = userFromEvent(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app profile page, always addressed by npub.
|
||||||
|
export function profilePath(pubkey: string): string {
|
||||||
|
return `/profile/${nip19.npubEncode(pubkey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pubkey behind a profile URL segment: npub, nprofile or bare hex.
|
||||||
|
export function decodeProfileId(id: string): string | null {
|
||||||
|
if (/^[0-9a-f]{64}$/.test(id)) return id;
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(id);
|
||||||
|
if (decoded.type === "npub") return decoded.data;
|
||||||
|
if (decoded.type === "nprofile") return decoded.data.pubkey;
|
||||||
|
} catch {
|
||||||
|
// Not a bech32 entity
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
36
src/lib/forum/query.ts
Normal file
36
src/lib/forum/query.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import type { Filter } from "@nostr/tools/filter";
|
||||||
|
|
||||||
|
// A relay read. The fetchers in this folder only depend on this signature, so
|
||||||
|
// the same code runs behind the browser's authenticated connection and the
|
||||||
|
// server's anonymous one.
|
||||||
|
export type Query = (filter: Filter) => Promise<Event[]>;
|
||||||
|
|
||||||
|
// Drive a subscription on an already-open relay and resolve at EOSE.
|
||||||
|
export function relayQuery(relay: AbstractRelay): Query {
|
||||||
|
return (filter) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const events: Event[] = [];
|
||||||
|
const sub = relay.subscribe([filter], {
|
||||||
|
onevent(e) {
|
||||||
|
events.push(e);
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
sub.close();
|
||||||
|
resolve(events);
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
resolve(events);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tag(e: Event, name: string): string | undefined {
|
||||||
|
return e.tags.find((t) => t[0] === name)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tags(e: Event, name: string): string[] {
|
||||||
|
return e.tags.filter((t) => t[0] === name && t[1]).map((t) => t[1]);
|
||||||
|
}
|
||||||
47
src/lib/forum/resources.ts
Normal file
47
src/lib/forum/resources.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { tag, type Query } from "./query";
|
||||||
|
|
||||||
|
export type Resource = {
|
||||||
|
id: string;
|
||||||
|
slug: string; // NIP-23 `d` tag — also the resource URL slug
|
||||||
|
title: string;
|
||||||
|
content: string; // markdown body
|
||||||
|
position?: number;
|
||||||
|
pubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Positioned resources first (ascending), then alphabetical by title.
|
||||||
|
function compare(a: Resource, b: Resource): number {
|
||||||
|
const ap = a.position;
|
||||||
|
const bp = b.position;
|
||||||
|
if (ap !== undefined && bp !== undefined)
|
||||||
|
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
|
||||||
|
if (ap !== undefined) return -1;
|
||||||
|
if (bp !== undefined) return 1;
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every `squalk-resource` article, newest per slug. Not admin-filtered: the
|
||||||
|
// caller applies the admin set, which may resolve later than this fetch.
|
||||||
|
export async function fetchResources(q: Query): Promise<Resource[]> {
|
||||||
|
const events = await q({ kinds: [30023], "#t": ["squalk-resource"] });
|
||||||
|
const bySlug = new Map<string, Resource>();
|
||||||
|
for (const e of events) {
|
||||||
|
const slug = tag(e, "d");
|
||||||
|
if (!slug) continue;
|
||||||
|
const existing = bySlug.get(slug);
|
||||||
|
if (existing && existing.createdAt >= e.created_at) continue;
|
||||||
|
const posTag = tag(e, "position");
|
||||||
|
const pos = posTag !== undefined ? Number(posTag) : NaN;
|
||||||
|
bySlug.set(slug, {
|
||||||
|
id: e.id,
|
||||||
|
slug,
|
||||||
|
title: tag(e, "title") ?? slug,
|
||||||
|
content: e.content,
|
||||||
|
position: Number.isFinite(pos) ? pos : undefined,
|
||||||
|
pubkey: e.pubkey,
|
||||||
|
createdAt: e.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...bySlug.values()].sort(compare);
|
||||||
|
}
|
||||||
74
src/lib/forum/snapshot.ts
Normal file
74
src/lib/forum/snapshot.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { SSR_WARM } from "$lib/config";
|
||||||
|
import type { NostrUser } from "$lib/gadgets";
|
||||||
|
import type { GroupMetadata, GroupSummary } from "./groups";
|
||||||
|
import type { Resource } from "./resources";
|
||||||
|
import type { Partial } from "./partials";
|
||||||
|
import type { SortMode, ThreadPage } from "./threads";
|
||||||
|
import type { Overview } from "./overview";
|
||||||
|
|
||||||
|
// What the server rendered a page with. Stores treat these as the baseline
|
||||||
|
// (see the `page.data` fallbacks) until their own live fetch lands.
|
||||||
|
export type ForumShell = {
|
||||||
|
group: GroupMetadata | null; // simple mode
|
||||||
|
groups: GroupSummary[]; // full mode
|
||||||
|
adminsByRoom: Record<string, string[]>; // full mode
|
||||||
|
admins: string[]; // the trust gate for resources and partials
|
||||||
|
resources: Resource[];
|
||||||
|
partials: Partial[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Profiles = Record<string, NostrUser>;
|
||||||
|
|
||||||
|
export type ThreadsSnapshot = ThreadPage & {
|
||||||
|
groupId: string;
|
||||||
|
sort: SortMode;
|
||||||
|
snapshotAt: number;
|
||||||
|
profiles: Profiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverviewSnapshot = Overview & {
|
||||||
|
roomIds: string[];
|
||||||
|
profiles: Profiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fetch = typeof fetch;
|
||||||
|
|
||||||
|
// Read a snapshot endpoint. On the server the response is inlined into the
|
||||||
|
// HTML; in the browser it is consumed only while hydrating that HTML. Client
|
||||||
|
// navigations get null, leaving the stores to fetch live (and authenticated)
|
||||||
|
// data as they always did — the same path a client-only build takes — but
|
||||||
|
// may still poke the endpoint so the server has the page cached.
|
||||||
|
// A 404 from the endpoint becomes a real 404 page on the server, so crawlers
|
||||||
|
// never index an empty shell; the client keeps its own not-found handling.
|
||||||
|
export async function snapshot<T>(
|
||||||
|
fetch: Fetch,
|
||||||
|
url: string,
|
||||||
|
): Promise<T | null> {
|
||||||
|
if (browser && !hasInlinedResponse(url)) {
|
||||||
|
if (SSR_WARM) fetch(url).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (res.status === 404 && !browser) error(404, "Not found");
|
||||||
|
if (!res.ok) return null;
|
||||||
|
try {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SvelteKit inlines each SSR fetch as a script tag keyed by URL and removes
|
||||||
|
// it once replayed, so its presence marks the hydration pass.
|
||||||
|
function hasInlinedResponse(url: string): boolean {
|
||||||
|
return !!document.querySelector(
|
||||||
|
`script[data-sveltekit-fetched][data-url=${JSON.stringify(url)}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/lib/forum/thread.ts
Normal file
56
src/lib/forum/thread.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { tag, tags, type Query } from "./query";
|
||||||
|
|
||||||
|
export type PostData = {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
labels: string[];
|
||||||
|
groupId: string; // the thread's NIP-29 group (its `h` tag)
|
||||||
|
op: PostData;
|
||||||
|
replies: PostData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// A thread (kind 11) with its flat replies (kind 1111), oldest first. Null
|
||||||
|
// when the relay returns nothing: no such thread, or one the visitor can't
|
||||||
|
// read. `fallbackGroup` covers OPs missing an `h` tag.
|
||||||
|
export async function fetchThread(
|
||||||
|
q: Query,
|
||||||
|
id: string,
|
||||||
|
fallbackGroup = "",
|
||||||
|
): Promise<ThreadDetail | null> {
|
||||||
|
const [threadEvents, replyEvents] = await Promise.all([
|
||||||
|
q({ kinds: [11], ids: [id] }),
|
||||||
|
q({ kinds: [1111], "#E": [id] }),
|
||||||
|
]);
|
||||||
|
const event = threadEvents[0];
|
||||||
|
if (!event) return null;
|
||||||
|
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: tag(event, "title") ?? "(untitled)",
|
||||||
|
labels: tags(event, "t"),
|
||||||
|
groupId: tag(event, "h") ?? fallbackGroup,
|
||||||
|
op: {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
content: event.content,
|
||||||
|
},
|
||||||
|
replies: replies.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
pubkey: r.pubkey,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
content: r.content,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadAuthors(t: ThreadDetail): string[] {
|
||||||
|
return [...new Set([t.op.pubkey, ...t.replies.map((r) => r.pubkey)])];
|
||||||
|
}
|
||||||
238
src/lib/forum/threads.ts
Normal file
238
src/lib/forum/threads.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import { tag, tags, type Query } from "./query";
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 30;
|
||||||
|
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
|
||||||
|
const WALK_MAX_ITERS = 12;
|
||||||
|
|
||||||
|
export type SortMode = "active" | "new";
|
||||||
|
|
||||||
|
export type ThreadData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
labels: string[];
|
||||||
|
authorPubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
replyCount: number;
|
||||||
|
latestAt: number;
|
||||||
|
latestPubkey: string;
|
||||||
|
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadPage = {
|
||||||
|
threads: ThreadData[];
|
||||||
|
nextCursor: number | null; // sort-key value of the last thread
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageRequest = {
|
||||||
|
sort: SortMode;
|
||||||
|
until: number; // upper bound on the sort key (inclusive)
|
||||||
|
snapshotAt: number; // upper time bound for reply counts, frozen per listing
|
||||||
|
exclude?: Set<string>; // thread ids already shown
|
||||||
|
n?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SliceItem = {
|
||||||
|
id: string;
|
||||||
|
latestAt: number;
|
||||||
|
latestPubkey: string;
|
||||||
|
op?: Event;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Slice = { items: SliceItem[]; nextCursor: number | null; done: boolean };
|
||||||
|
|
||||||
|
function threadIdOf(e: Event): string | undefined {
|
||||||
|
return e.kind === 11 ? e.id : tag(e, "E");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
|
||||||
|
// threads not already shown. The first event seen for a thread defines its
|
||||||
|
// activity timestamp.
|
||||||
|
async function fetchActivitySlice(
|
||||||
|
q: Query,
|
||||||
|
groupId: string,
|
||||||
|
until: number,
|
||||||
|
exclude: Set<string>,
|
||||||
|
n: number,
|
||||||
|
): Promise<Slice> {
|
||||||
|
const collected = new Map<string, SliceItem>();
|
||||||
|
let cur = until;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
|
||||||
|
const events = await q({
|
||||||
|
kinds: [11, 1111],
|
||||||
|
"#h": [groupId],
|
||||||
|
until: cur,
|
||||||
|
limit: WALK_LIMIT,
|
||||||
|
});
|
||||||
|
if (events.length === 0) {
|
||||||
|
done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
let oldest = cur;
|
||||||
|
for (const e of events) {
|
||||||
|
oldest = Math.min(oldest, e.created_at);
|
||||||
|
const id = threadIdOf(e);
|
||||||
|
if (!id || exclude.has(id)) continue;
|
||||||
|
const seen = collected.get(id);
|
||||||
|
if (seen) {
|
||||||
|
// Grab the OP from the stream when a thread was first seen via a reply,
|
||||||
|
// so we avoid the by-id backfill the group relay truncates
|
||||||
|
if (!seen.op && e.kind === 11) seen.op = e;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (collected.size >= n) continue; // Page full; keep scanning for OPs
|
||||||
|
collected.set(id, {
|
||||||
|
id,
|
||||||
|
latestAt: e.created_at,
|
||||||
|
latestPubkey: e.pubkey,
|
||||||
|
op: e.kind === 11 ? e : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length < WALK_LIMIT) {
|
||||||
|
done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (oldest >= cur) break; // No progress (single timestamp floods the window)
|
||||||
|
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
|
||||||
|
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
|
||||||
|
return { items, nextCursor, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chronological-by-creation slice: just OPs ordered by created_at. No reply
|
||||||
|
// data is needed to order them (stable cursor), keeping the path cheap; reply
|
||||||
|
// counts are still attached later via enrichment.
|
||||||
|
async function fetchNewSlice(
|
||||||
|
q: Query,
|
||||||
|
groupId: string,
|
||||||
|
until: number,
|
||||||
|
exclude: Set<string>,
|
||||||
|
n: number,
|
||||||
|
): Promise<Slice> {
|
||||||
|
const ops = await q({
|
||||||
|
kinds: [11],
|
||||||
|
"#h": [groupId],
|
||||||
|
until,
|
||||||
|
limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor
|
||||||
|
});
|
||||||
|
ops.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
const fresh = ops.filter((e) => !exclude.has(e.id));
|
||||||
|
const slice = fresh.slice(0, n);
|
||||||
|
const items: SliceItem[] = slice.map((op) => ({
|
||||||
|
id: op.id,
|
||||||
|
latestAt: op.created_at,
|
||||||
|
latestPubkey: op.pubkey,
|
||||||
|
op,
|
||||||
|
}));
|
||||||
|
const done = ops.length < n + 10;
|
||||||
|
const last = slice[slice.length - 1] ?? ops[ops.length - 1];
|
||||||
|
const nextCursor = last ? last.created_at : null;
|
||||||
|
return { items, nextCursor, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichWithReplies(
|
||||||
|
q: Query,
|
||||||
|
groupId: string,
|
||||||
|
items: SliceItem[],
|
||||||
|
snapshotAt: number,
|
||||||
|
): Promise<Map<string, { count: number; repliers: string[] }>> {
|
||||||
|
const result = new Map<string, { count: number; repliers: string[] }>();
|
||||||
|
if (items.length === 0) return result;
|
||||||
|
const replies = await q({
|
||||||
|
kinds: [1111],
|
||||||
|
"#h": [groupId],
|
||||||
|
"#E": items.map((it) => it.id),
|
||||||
|
until: snapshotAt,
|
||||||
|
limit: 5000,
|
||||||
|
});
|
||||||
|
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
|
||||||
|
for (const r of replies) {
|
||||||
|
const root = tag(r, "E");
|
||||||
|
if (!root) continue;
|
||||||
|
let a = acc.get(root);
|
||||||
|
if (!a) {
|
||||||
|
a = { count: 0, pubkeys: new Set() };
|
||||||
|
acc.set(root, a);
|
||||||
|
}
|
||||||
|
a.count++;
|
||||||
|
a.pubkeys.add(r.pubkey);
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
const a = acc.get(it.id);
|
||||||
|
const repliers = a
|
||||||
|
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
|
||||||
|
: [];
|
||||||
|
result.set(it.id, { count: a?.count ?? 0, repliers });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildThreads(
|
||||||
|
q: Query,
|
||||||
|
groupId: string,
|
||||||
|
items: SliceItem[],
|
||||||
|
snapshotAt: number,
|
||||||
|
): Promise<ThreadData[]> {
|
||||||
|
const missing = items.filter((it) => !it.op);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const fetched = await Promise.all(
|
||||||
|
missing.map((it) => q({ kinds: [11], "#h": [groupId], ids: [it.id] })),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, Event>();
|
||||||
|
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
|
||||||
|
for (const it of items) if (!it.op) it.op = byId.get(it.id);
|
||||||
|
}
|
||||||
|
const enriched = await enrichWithReplies(q, groupId, items, snapshotAt);
|
||||||
|
const out: ThreadData[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const op = it.op;
|
||||||
|
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
|
||||||
|
const e = enriched.get(it.id);
|
||||||
|
out.push({
|
||||||
|
id: it.id,
|
||||||
|
title: tag(op, "title") ?? "(untitled)",
|
||||||
|
labels: tags(op, "t"),
|
||||||
|
authorPubkey: op.pubkey,
|
||||||
|
createdAt: op.created_at,
|
||||||
|
replyCount: e?.count ?? 0,
|
||||||
|
latestAt: it.latestAt,
|
||||||
|
latestPubkey: it.latestPubkey,
|
||||||
|
replierPubkeys: e?.repliers ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One page of a group's thread listing, in the given sort order.
|
||||||
|
export async function fetchThreadPage(
|
||||||
|
q: Query,
|
||||||
|
groupId: string,
|
||||||
|
req: PageRequest,
|
||||||
|
): Promise<ThreadPage> {
|
||||||
|
const exclude = req.exclude ?? new Set<string>();
|
||||||
|
const n = req.n ?? PAGE_SIZE;
|
||||||
|
const slice =
|
||||||
|
req.sort === "new"
|
||||||
|
? await fetchNewSlice(q, groupId, req.until, exclude, n)
|
||||||
|
: await fetchActivitySlice(q, groupId, req.until, exclude, n);
|
||||||
|
const threads = await buildThreads(q, groupId, slice.items, req.snapshotAt);
|
||||||
|
return { threads, nextCursor: slice.nextCursor, done: slice.done };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadPagePubkeys(threads: ThreadData[]): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const t of threads) {
|
||||||
|
set.add(t.authorPubkey);
|
||||||
|
set.add(t.latestPubkey);
|
||||||
|
for (const p of t.replierPubkeys) set.add(p);
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
106
src/lib/forum/userPosts.ts
Normal file
106
src/lib/forum/userPosts.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import { tag, type Query } from "./query";
|
||||||
|
|
||||||
|
export type UserPost = {
|
||||||
|
id: string;
|
||||||
|
threadId: string; // the root thread; equals `id` for a root post
|
||||||
|
title: string; // thread title, empty when the root is unreachable
|
||||||
|
groupId: string;
|
||||||
|
createdAt: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserPostKind = "roots" | "replies";
|
||||||
|
|
||||||
|
export type UserPostPage = {
|
||||||
|
items: UserPost[];
|
||||||
|
done: boolean; // no older posts left
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserPosts = Record<UserPostKind, UserPostPage>;
|
||||||
|
|
||||||
|
// Posts per "Show more" step
|
||||||
|
export const USER_POSTS_PAGE = 20;
|
||||||
|
const CURSOR_HEADROOM = 10; // boundary events re-read at the inclusive cursor
|
||||||
|
|
||||||
|
export type UserPostPageRequest = {
|
||||||
|
groupId?: string; // scope to one group (simple mode)
|
||||||
|
until?: number; // oldest loaded timestamp, inclusive
|
||||||
|
exclude?: Set<string>; // ids already shown
|
||||||
|
n?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function newestFirst(events: Event[]): Event[] {
|
||||||
|
return [...events].sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One page of a user's root posts (kind 11) or replies (kind 1111), newest
|
||||||
|
// first, older than the cursor. Without `groupId` every group the relay
|
||||||
|
// serves the caller counts.
|
||||||
|
export async function fetchUserPostPage(
|
||||||
|
q: Query,
|
||||||
|
pubkey: string,
|
||||||
|
kind: UserPostKind,
|
||||||
|
req: UserPostPageRequest = {},
|
||||||
|
): Promise<UserPostPage> {
|
||||||
|
const n = req.n ?? USER_POSTS_PAGE;
|
||||||
|
const exclude = req.exclude ?? new Set<string>();
|
||||||
|
const events = await q({
|
||||||
|
kinds: [kind === "roots" ? 11 : 1111],
|
||||||
|
authors: [pubkey],
|
||||||
|
...(req.groupId ? { "#h": [req.groupId] } : {}),
|
||||||
|
...(req.until ? { until: req.until } : {}),
|
||||||
|
limit: n + CURSOR_HEADROOM,
|
||||||
|
});
|
||||||
|
const fresh = newestFirst(events).filter(
|
||||||
|
(e) => !exclude.has(e.id) && (kind === "roots" || tag(e, "E")),
|
||||||
|
);
|
||||||
|
const slice = fresh.slice(0, n);
|
||||||
|
// Finished only when everything fresh fits the page and the relay had
|
||||||
|
// nothing beyond the requested window
|
||||||
|
const done = fresh.length <= n && events.length < n + CURSOR_HEADROOM;
|
||||||
|
|
||||||
|
// Replies take their title from the root thread
|
||||||
|
const titles = new Map<string, string>();
|
||||||
|
if (kind === "replies" && slice.length > 0) {
|
||||||
|
const rootIds = [...new Set(slice.map((e) => tag(e, "E")!))];
|
||||||
|
const roots = await q({ kinds: [11], ids: rootIds });
|
||||||
|
for (const e of roots) titles.set(e.id, tag(e, "title") ?? "(untitled)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = slice.map((e) => {
|
||||||
|
const threadId = kind === "roots" ? e.id : tag(e, "E")!;
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
threadId,
|
||||||
|
title:
|
||||||
|
kind === "roots"
|
||||||
|
? (tag(e, "title") ?? "(untitled)")
|
||||||
|
: (titles.get(threadId) ?? ""),
|
||||||
|
groupId: tag(e, "h") ?? req.groupId ?? "",
|
||||||
|
createdAt: e.created_at,
|
||||||
|
content: e.content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { items, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first page of both columns.
|
||||||
|
export async function fetchUserPosts(
|
||||||
|
q: Query,
|
||||||
|
pubkey: string,
|
||||||
|
groupId?: string,
|
||||||
|
): Promise<UserPosts> {
|
||||||
|
const [roots, replies] = await Promise.all([
|
||||||
|
fetchUserPostPage(q, pubkey, "roots", { groupId }),
|
||||||
|
fetchUserPostPage(q, pubkey, "replies", { groupId }),
|
||||||
|
]);
|
||||||
|
return { roots, replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor for the page after `page`: the oldest timestamp shown, inclusive
|
||||||
|
// (the ids already shown are excluded on the next read).
|
||||||
|
export function nextCursor(page: UserPostPage): number | undefined {
|
||||||
|
const last = page.items[page.items.length - 1];
|
||||||
|
return last?.createdAt;
|
||||||
|
}
|
||||||
17
src/lib/gadgets.ts
Normal file
17
src/lib/gadgets.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import type { ListFetcher, RelayItem } from "@nostr/gadgets/lists";
|
||||||
|
|
||||||
|
export type { NostrUser, RelayItem };
|
||||||
|
|
||||||
|
// @nostr/gadgets reads localStorage when imported, so it can't be loaded on
|
||||||
|
// the server. Every consumer goes through these lazy wrappers; the library is
|
||||||
|
// pulled in on first use, in the browser only.
|
||||||
|
export async function loadNostrUser(pubkey: string): Promise<NostrUser> {
|
||||||
|
const m = await import("@nostr/gadgets/metadata");
|
||||||
|
return m.loadNostrUser(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadRelayList: ListFetcher<RelayItem> = async (...args) => {
|
||||||
|
const m = await import("@nostr/gadgets/lists");
|
||||||
|
return m.loadRelayList(...args);
|
||||||
|
};
|
||||||
|
|
@ -1,20 +1,11 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { GROUP_ID, MODE } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { fetchGroup, type GroupMetadata } from "$lib/forum/groups";
|
||||||
|
|
||||||
export type GroupMetadata = {
|
export type { GroupMetadata };
|
||||||
name: string;
|
|
||||||
picture?: string;
|
|
||||||
about?: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
isClosed: boolean;
|
|
||||||
isRestricted: boolean;
|
|
||||||
isHidden: boolean;
|
|
||||||
admins: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// NIP-29 access flags: private = members-only read, restricted = members-only
|
|
||||||
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
|
|
||||||
export type GroupFlags = {
|
export type GroupFlags = {
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
isClosed: boolean;
|
isClosed: boolean;
|
||||||
|
|
@ -25,44 +16,24 @@ export type GroupFlags = {
|
||||||
let group = $state<GroupMetadata | null>(null);
|
let group = $state<GroupMetadata | null>(null);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const groupStore = {
|
export const groupStore = {
|
||||||
get data() {
|
get data() {
|
||||||
return group;
|
return group ?? page.data.shell?.group ?? null;
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadGroup() {
|
export async function loadGroup() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
group = (await fetchGroup(queryForum, GROUP_ID)) ?? group;
|
||||||
kinds: [39000, 39001],
|
|
||||||
"#d": [GROUP_ID],
|
|
||||||
});
|
|
||||||
const event = events.find((e) => e.kind === 39000);
|
|
||||||
if (!event) return;
|
|
||||||
// Admins live in the NIP-29 kind 39001 event as `p` tags.
|
|
||||||
const adminsEvent = events.find((e) => e.kind === 39001);
|
|
||||||
const admins =
|
|
||||||
adminsEvent?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
|
|
||||||
group = {
|
|
||||||
name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID,
|
|
||||||
picture: event.tags.find((t) => t[0] === "picture")?.[1],
|
|
||||||
about: event.tags.find((t) => t[0] === "about")?.[1],
|
|
||||||
isPrivate: event.tags.some((t) => t[0] === "private"),
|
|
||||||
isClosed: event.tags.some((t) => t[0] === "closed"),
|
|
||||||
isRestricted: event.tags.some((t) => t[0] === "restricted"),
|
|
||||||
isHidden: event.tags.some((t) => t[0] === "hidden"),
|
|
||||||
admins,
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A group's access flags from whichever store holds them: full mode keeps every
|
|
||||||
// room in the groups list, simple mode has the single active group's metadata.
|
|
||||||
export function getGroupFlags(groupId: string): GroupFlags | null {
|
export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||||
if (MODE === "full") {
|
if (MODE === "full") {
|
||||||
const g = groupsStore.list.find((x) => x.id === groupId);
|
const g = groupsStore.list.find((x) => x.id === groupId);
|
||||||
|
|
@ -82,3 +53,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||||
isHidden: group.isHidden,
|
isHidden: group.isHidden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display name for a group from whichever store holds it, falling back to the
|
||||||
|
// id when metadata hasn't loaded.
|
||||||
|
export function getGroupName(groupId: string): string {
|
||||||
|
if (MODE === "full") {
|
||||||
|
return groupsStore.list.find((x) => x.id === groupId)?.name ?? groupId;
|
||||||
|
}
|
||||||
|
return group?.name ?? groupId;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,25 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
|
||||||
|
|
||||||
export type GroupSummary = {
|
export type { GroupSummary };
|
||||||
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
|
|
||||||
name: string;
|
|
||||||
picture?: string;
|
|
||||||
about?: string;
|
|
||||||
createdAt: number;
|
|
||||||
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
|
|
||||||
};
|
|
||||||
|
|
||||||
// NIP-29 metadata markers we surface as room tags, in display order.
|
|
||||||
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
|
|
||||||
|
|
||||||
let list = $state<GroupSummary[]>([]);
|
let list = $state<GroupSummary[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const groupsStore = {
|
export const groupsStore = {
|
||||||
get list() {
|
get list() {
|
||||||
return list;
|
return loaded ? list : (page.data.shell?.groups ?? []);
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata
|
|
||||||
// event per group, so an unfiltered query enumerates them all.
|
|
||||||
export async function loadGroups() {
|
export async function loadGroups() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({ kinds: [39000] });
|
list = await fetchGroups(queryForum);
|
||||||
list = events
|
|
||||||
.map((e) => {
|
|
||||||
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? "";
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: e.tags.find((t) => t[0] === "name")?.[1] ?? id,
|
|
||||||
picture: e.tags.find((t) => t[0] === "picture")?.[1],
|
|
||||||
about: e.tags.find((t) => t[0] === "about")?.[1],
|
|
||||||
createdAt: e.created_at,
|
|
||||||
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((g) => g.id)
|
|
||||||
// Oldest first.
|
|
||||||
.sort((a, b) => a.createdAt - b.createdAt);
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,42 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { ensureForumRelay, publishForum } from "$lib/relay";
|
import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
|
||||||
import { getGroupFlags } from "$lib/group.svelte";
|
import { getGroupName } from "$lib/group.svelte";
|
||||||
|
import { showToast } from "$lib/toast.svelte";
|
||||||
|
|
||||||
type Membership = "member" | "guest";
|
type Membership = "member" | "guest";
|
||||||
|
// "none": the relay doesn't support NIP-43, so no relay-level join exists.
|
||||||
|
type RelayMembership = Membership | "none";
|
||||||
|
|
||||||
// Per-group membership, resolved lazily and cached reactively so compose
|
// Writing to a group can be gated at two levels: the relay itself (NIP-43) and
|
||||||
// actions can be gated before the user writes anything. Keyed by pubkey so a
|
// the group (NIP-29). Neither gate is advertised up front, so membership is
|
||||||
|
// probed by reading the relay's membership events and, failing that, by trying
|
||||||
|
// to join and reacting to the rejection. Both caches are keyed by pubkey so a
|
||||||
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
|
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
|
||||||
// "guest" cached before auth.user was populated; the read stays a pure getter
|
// "guest" cached before auth.user was populated.
|
||||||
// (no state writes inside a derivation).
|
|
||||||
let membership = $state<Record<string, Membership>>({});
|
let membership = $state<Record<string, Membership>>({});
|
||||||
|
let relayMembership = $state<Record<string, RelayMembership>>({});
|
||||||
const checking = new Map<string, Promise<void>>();
|
const checking = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
// The modal only appears when a relay refused a step and needs user input.
|
||||||
|
// - relay-code: the relay requires an invite code (NIP-43 claim)
|
||||||
|
// - group-code: the group refused the join; a code may be needed
|
||||||
|
// - pending: the group accepted the request but membership isn't granted yet
|
||||||
|
// (admin approval); an invite code, if any, can still be tried
|
||||||
|
// - error: a terminal refusal (e.g. banned); nothing the user can enter helps
|
||||||
|
export type JoinStep = "relay-code" | "group-code" | "pending" | "error";
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
|
let modalStep = $state<JoinStep>("group-code");
|
||||||
let modalGroup = $state<string | null>(null);
|
let modalGroup = $state<string | null>(null);
|
||||||
let modalError = $state<string | null>(null);
|
let modalError = $state<string | null>(null);
|
||||||
|
// A rejection message that mentions a code makes the code field the primary
|
||||||
|
// affordance rather than a discreet fallback.
|
||||||
|
let modalCodeHinted = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
// Continuation to run once the join succeeds (open the composer, focus the
|
// Continuation to run once access is granted (open the composer, focus the
|
||||||
// reply box, reload a now-visible feed, or retry the original post).
|
// reply box, reload a now-visible feed, or retry the original post).
|
||||||
let onJoined: (() => void | Promise<void>) | null = null;
|
let onJoined: (() => void | Promise<void>) | null = null;
|
||||||
|
|
||||||
|
|
@ -29,26 +47,27 @@ export const joinState = {
|
||||||
get modalOpen() {
|
get modalOpen() {
|
||||||
return modalOpen;
|
return modalOpen;
|
||||||
},
|
},
|
||||||
|
get modalStep() {
|
||||||
|
return modalStep;
|
||||||
|
},
|
||||||
get modalError() {
|
get modalError() {
|
||||||
return modalError;
|
return modalError;
|
||||||
},
|
},
|
||||||
|
get modalCodeHinted() {
|
||||||
|
return modalCodeHinted;
|
||||||
|
},
|
||||||
get busy() {
|
get busy() {
|
||||||
return busy;
|
return busy;
|
||||||
},
|
},
|
||||||
// Closed groups (and a globally configured invite gate) can't be self-joined,
|
|
||||||
// so the modal collects an invite code; open groups just confirm.
|
|
||||||
get needsCode() {
|
|
||||||
if (JOINCODE_REQUIRED) return true;
|
|
||||||
const f = modalGroup ? getGroupFlags(modalGroup) : null;
|
|
||||||
return !!f?.isClosed;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resetJoinState() {
|
export function resetJoinState() {
|
||||||
membership = {};
|
membership = {};
|
||||||
|
relayMembership = {};
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
modalGroup = null;
|
modalGroup = null;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
|
modalCodeHinted = false;
|
||||||
busy = false;
|
busy = false;
|
||||||
onJoined = null;
|
onJoined = null;
|
||||||
}
|
}
|
||||||
|
|
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubFilter = Parameters<AbstractRelay["subscribe"]>[0][number];
|
||||||
|
|
||||||
|
// Collects every event matching the filter until EOSE/close/timeout.
|
||||||
|
function queryEvents(
|
||||||
|
relay: AbstractRelay,
|
||||||
|
filter: SubFilter,
|
||||||
|
timeoutMs = 3000,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const events: Event[] = [];
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
sub.close();
|
||||||
|
} catch {}
|
||||||
|
resolve(events);
|
||||||
|
};
|
||||||
|
const sub = relay.subscribe([filter], {
|
||||||
|
onevent(e) {
|
||||||
|
events.push(e);
|
||||||
|
},
|
||||||
|
oneose: finish,
|
||||||
|
onclose: finish,
|
||||||
|
});
|
||||||
|
setTimeout(finish, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Existence check for a single matching event (resolves true on first event,
|
// Existence check for a single matching event (resolves true on first event,
|
||||||
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
|
// false on EOSE/close/timeout).
|
||||||
function queryHasMatch(
|
function queryHasMatch(
|
||||||
relay: AbstractRelay,
|
relay: AbstractRelay,
|
||||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
filter: SubFilter,
|
||||||
timeoutMs = 3000,
|
timeoutMs = 3000,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -120,39 +169,34 @@ function queryHasMatch(
|
||||||
// Newest `created_at` among events matching the filter, or null if none. We
|
// Newest `created_at` among events matching the filter, or null if none. We
|
||||||
// don't trust the relay to honour limit ordering, so we keep the max across all
|
// don't trust the relay to honour limit ordering, so we keep the max across all
|
||||||
// returned events (the #p filter keeps the set tiny).
|
// returned events (the #p filter keeps the set tiny).
|
||||||
function latestCreatedAt(
|
async function latestCreatedAt(
|
||||||
relay: AbstractRelay,
|
relay: AbstractRelay,
|
||||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
filter: SubFilter,
|
||||||
timeoutMs = 3000,
|
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
return new Promise((resolve) => {
|
const events = await queryEvents(relay, filter);
|
||||||
let settled = false;
|
return events.reduce<number | null>(
|
||||||
let latest: number | null = null;
|
(max, e) => (max === null || e.created_at > max ? e.created_at : max),
|
||||||
const finish = () => {
|
null,
|
||||||
if (settled) return;
|
);
|
||||||
settled = true;
|
|
||||||
try {
|
|
||||||
sub.close();
|
|
||||||
} catch {}
|
|
||||||
resolve(latest);
|
|
||||||
};
|
|
||||||
const sub = relay.subscribe([filter], {
|
|
||||||
onevent(e) {
|
|
||||||
if (latest === null || e.created_at > latest) latest = e.created_at;
|
|
||||||
},
|
|
||||||
oneose: finish,
|
|
||||||
onclose: finish,
|
|
||||||
});
|
|
||||||
setTimeout(finish, timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership signal for one group — i.e. "is this user allowed to write here".
|
// Add/remove events are the freshest signal: the newest of the two decides
|
||||||
// Admins (kind:39001) can post but need not appear in the members list, so they
|
// (a later removal unjoins). When the relay keeps neither, the list decides.
|
||||||
// count as members. Otherwise NIP-29 records membership as moderation events:
|
function decideMembership(
|
||||||
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
|
added: number | null,
|
||||||
// this user decides current membership (a later 9001 unjoins them). When the
|
removed: number | null,
|
||||||
// relay keeps neither, fall back to the kind:39002 members list.
|
listed: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (added !== null || removed !== null) {
|
||||||
|
return added !== null && added >= (removed ?? -Infinity);
|
||||||
|
}
|
||||||
|
return listed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group membership — i.e. "is this user allowed to write here". Admins
|
||||||
|
// (kind:39001) can post but need not appear in the members list, so they count
|
||||||
|
// as members. Otherwise NIP-29 records membership as kind:9000 (add) and
|
||||||
|
// kind:9001 (remove) moderation events, with the kind:39002 list as fallback.
|
||||||
async function checkMembership(
|
async function checkMembership(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
|
@ -163,7 +207,7 @@ async function checkMembership(
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const [isAdmin, added, removed] = await Promise.all([
|
const [isAdmin, added, removed, listed] = await Promise.all([
|
||||||
queryHasMatch(relay, {
|
queryHasMatch(relay, {
|
||||||
kinds: [39001],
|
kinds: [39001],
|
||||||
"#d": [groupId],
|
"#d": [groupId],
|
||||||
|
|
@ -172,25 +216,240 @@ async function checkMembership(
|
||||||
}),
|
}),
|
||||||
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
||||||
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
||||||
|
queryHasMatch(relay, {
|
||||||
|
kinds: [39002],
|
||||||
|
"#d": [groupId],
|
||||||
|
"#p": [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
if (isAdmin) return true;
|
if (isAdmin) return true;
|
||||||
if (added !== null || removed !== null) {
|
return decideMembership(added, removed, listed);
|
||||||
return added !== null && added >= (removed ?? -Infinity);
|
|
||||||
}
|
|
||||||
return queryHasMatch(relay, {
|
|
||||||
kinds: [39002],
|
|
||||||
"#d": [groupId],
|
|
||||||
"#p": [pubkey],
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple mode pre-checks the single configured group at login so the first post
|
// Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
|
||||||
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
// kind:8001 (remove) signed by the relay, with the kind:13534 list as fallback.
|
||||||
|
// The `member` tag isn't filterable, so the list is fetched and scanned.
|
||||||
|
async function checkRelayMembership(
|
||||||
|
pubkey: string,
|
||||||
|
self: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let relay: AbstractRelay;
|
||||||
|
try {
|
||||||
|
relay = await ensureForumRelay();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [added, removed, lists] = await Promise.all([
|
||||||
|
latestCreatedAt(relay, { kinds: [8000], authors: [self], "#p": [pubkey] }),
|
||||||
|
latestCreatedAt(relay, { kinds: [8001], authors: [self], "#p": [pubkey] }),
|
||||||
|
queryEvents(relay, { kinds: [13534], authors: [self], limit: 1 }),
|
||||||
|
]);
|
||||||
|
const listed = lists.some((e) =>
|
||||||
|
e.tags.some((t) => t[0] === "member" && t[1] === pubkey),
|
||||||
|
);
|
||||||
|
return decideMembership(added, removed, listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRelayMembershipChecked(pubkey: string): Promise<void> {
|
||||||
|
if (relayMembership[pubkey]) return Promise.resolve();
|
||||||
|
const k = `relay:${pubkey}`;
|
||||||
|
const existing = checking.get(k);
|
||||||
|
if (existing) return existing;
|
||||||
|
const p = (async () => {
|
||||||
|
try {
|
||||||
|
const self = await nip43Self();
|
||||||
|
if (!self) {
|
||||||
|
relayMembership[pubkey] = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isMember = await checkRelayMembership(pubkey, self);
|
||||||
|
relayMembership[pubkey] = isMember ? "member" : "guest";
|
||||||
|
} finally {
|
||||||
|
checking.delete(k);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
checking.set(k, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probes both membership levels at login so a returning member's first post
|
||||||
|
// needs no join at all. Full mode checks groups lazily per room instead.
|
||||||
export async function initJoinForUser(pubkey: string) {
|
export async function initJoinForUser(pubkey: string) {
|
||||||
if (MODE !== "simple") return;
|
await Promise.all([
|
||||||
if (await checkMembership(pubkey, GROUP_ID)) {
|
ensureRelayMembershipChecked(pubkey),
|
||||||
membership[memberKey(GROUP_ID)] = "member";
|
MODE === "simple" ? ensureMembershipChecked(GROUP_ID) : undefined,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishing rejections carry the relay's NIP-01 OK message; the prefix is the
|
||||||
|
// only cross-relay contract for what went wrong.
|
||||||
|
function reason(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
const prefixed = (msg: string, prefix: string) =>
|
||||||
|
msg.toLowerCase().startsWith(prefix);
|
||||||
|
const stripPrefix = (msg: string) => msg.replace(/^[a-z-]+:\s*/i, "");
|
||||||
|
const mentionsCode = (msg: string) => /\b(code|invite|claim)\b/i.test(msg);
|
||||||
|
|
||||||
|
async function publishSigned(kind: number, tags: string[][]): Promise<void> {
|
||||||
|
if (!auth.signer) throw new Error("Not logged in");
|
||||||
|
const event = await auth.signer.signEvent({
|
||||||
|
kind,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
||||||
|
);
|
||||||
|
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Codes = { relay?: string; group?: string };
|
||||||
|
type Refusal = { step: JoinStep; message: string | null; codeHinted: boolean };
|
||||||
|
type Outcome = { ok: true } | ({ ok: false } & Refusal);
|
||||||
|
|
||||||
|
const fail = (
|
||||||
|
step: JoinStep,
|
||||||
|
message: string | null,
|
||||||
|
codeHinted = false,
|
||||||
|
): Outcome => ({ ok: false, step, message, codeHinted });
|
||||||
|
|
||||||
|
// Bare relay joins the relay declined, per pubkey. A refusal is only a hint:
|
||||||
|
// some relays (pyramid) let non-members into open groups, so the group join
|
||||||
|
// decides whether relay membership was needed. Remembering it spares one
|
||||||
|
// doomed signature per room.
|
||||||
|
const relayRefusals: Record<string, Outcome> = {};
|
||||||
|
|
||||||
|
// Relay-level join (NIP-43). Returns the refusal, if any, without acting on it.
|
||||||
|
async function tryRelayJoin(pubkey: string, codes: Codes): Promise<Outcome> {
|
||||||
|
await ensureRelayMembershipChecked(pubkey);
|
||||||
|
if (relayMembership[pubkey] !== "guest") return { ok: true };
|
||||||
|
if (!codes.relay && relayRefusals[pubkey]) return relayRefusals[pubkey];
|
||||||
|
const tags: string[][] = [["-"]];
|
||||||
|
if (codes.relay) tags.push(["claim", codes.relay]);
|
||||||
|
let outcome: Outcome = { ok: true };
|
||||||
|
let policy = false;
|
||||||
|
try {
|
||||||
|
await publishSigned(28934, tags);
|
||||||
|
relayMembership[pubkey] = "member";
|
||||||
|
} catch (e) {
|
||||||
|
const msg = reason(e);
|
||||||
|
policy = prefixed(msg, "restricted:") || prefixed(msg, "blocked:");
|
||||||
|
if (prefixed(msg, "duplicate:")) {
|
||||||
|
relayMembership[pubkey] = "member";
|
||||||
|
} else if (prefixed(msg, "blocked:")) {
|
||||||
|
outcome = fail("error", stripPrefix(msg));
|
||||||
|
} else {
|
||||||
|
// A bare request refused is the expected probe, not a user error
|
||||||
|
outcome = fail("relay-code", codes.relay ? stripPrefix(msg) : null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outcome.ok) delete relayRefusals[pubkey];
|
||||||
|
else if (!codes.relay && policy) relayRefusals[pubkey] = outcome;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the two-level join: relay first (a group join may need relay
|
||||||
|
// membership), then group. Everything free happens silently; the first
|
||||||
|
// refusal that actually blocks is reported as the step needing user input.
|
||||||
|
async function ensureAccess(groupId: string, codes: Codes): Promise<Outcome> {
|
||||||
|
const pubkey = auth.user?.pubkey;
|
||||||
|
if (!pubkey) return fail("error", "Not logged in");
|
||||||
|
|
||||||
|
await ensureMembershipChecked(groupId);
|
||||||
|
const k = memberKey(groupId);
|
||||||
|
if (membership[k] === "member") return { ok: true };
|
||||||
|
|
||||||
|
const relay = await tryRelayJoin(pubkey, codes);
|
||||||
|
// A code the user typed is judged at once; a bare refusal only counts if
|
||||||
|
// the group join fails too
|
||||||
|
if (!relay.ok && codes.relay) return relay;
|
||||||
|
|
||||||
|
const tags: string[][] = [["h", groupId]];
|
||||||
|
if (codes.group) tags.push(["code", codes.group]);
|
||||||
|
try {
|
||||||
|
await publishSigned(9021, tags);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = reason(e);
|
||||||
|
if (!prefixed(msg, "duplicate:")) {
|
||||||
|
if (prefixed(msg, "restricted:") && !relay.ok) return relay;
|
||||||
|
if (prefixed(msg, "restricted:")) {
|
||||||
|
const hinted = mentionsCode(msg);
|
||||||
|
return fail(
|
||||||
|
"group-code",
|
||||||
|
codes.group || !hinted ? stripPrefix(msg) : null,
|
||||||
|
hinted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fail("error", stripPrefix(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An accepted request doesn't imply membership: closed groups may hold it
|
||||||
|
// for admin approval, so re-read the relay's records before trusting it.
|
||||||
|
if (await checkMembership(pubkey, groupId)) {
|
||||||
|
membership[k] = "member";
|
||||||
|
showToast(`You joined ${getGroupName(groupId)}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return fail("pending", codes.group ? "The code was not accepted" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(
|
||||||
|
groupId: string | null,
|
||||||
|
refusal: Refusal,
|
||||||
|
cb: (() => void | Promise<void>) | null,
|
||||||
|
) {
|
||||||
|
modalGroup = groupId;
|
||||||
|
onJoined = cb;
|
||||||
|
modalStep = refusal.step;
|
||||||
|
modalCodeHinted = refusal.codeHinted;
|
||||||
|
modalError = refusal.message;
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps an action that posts to `groupId`, joining relay and group first when
|
||||||
|
// needed. A write refused for membership reasons (e.g. a stale cache after the
|
||||||
|
// relay changed policy) invalidates both caches and retries once from the top.
|
||||||
|
// Returns false when the modal took over; the action then runs on its success.
|
||||||
|
export async function withJoin(
|
||||||
|
groupId: string,
|
||||||
|
action: () => void | Promise<void>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (busy) return false;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
return await runWithAccess(groupId, action, {}, true);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithAccess(
|
||||||
|
groupId: string,
|
||||||
|
action: () => void | Promise<void>,
|
||||||
|
codes: Codes,
|
||||||
|
retry: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const outcome = await ensureAccess(groupId, codes);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
openModal(groupId, outcome, action);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const pubkey = auth.user?.pubkey;
|
||||||
|
if (retry && pubkey && prefixed(reason(e), "restricted:")) {
|
||||||
|
delete membership[memberKey(groupId)];
|
||||||
|
delete relayRefusals[pubkey];
|
||||||
|
if (relayMembership[pubkey] === "member")
|
||||||
|
relayMembership[pubkey] = "guest";
|
||||||
|
return runWithAccess(groupId, action, codes, false);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,86 +461,51 @@ export function closeJoinModal() {
|
||||||
onJoined = null;
|
onJoined = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens the join modal for a group, with an optional continuation to run after
|
// Re-runs the join with the code entered for the current step. Errors keep the
|
||||||
// a successful join (open the composer, focus the reply box, reload the feed).
|
// modal open for a retry; a further gate switches the modal to that step.
|
||||||
export function openJoinModal(
|
export async function submitJoinCode(code?: string) {
|
||||||
groupId: string,
|
if (!modalOpen || busy) return;
|
||||||
cb?: () => void | Promise<void>,
|
|
||||||
) {
|
|
||||||
modalGroup = groupId;
|
|
||||||
onJoined = cb ?? null;
|
|
||||||
modalError = null;
|
|
||||||
modalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishJoinRequest(groupId: string, code?: string) {
|
|
||||||
if (!auth.signer) throw new Error("Not logged in");
|
|
||||||
const tags: string[][] = [["h", groupId]];
|
|
||||||
if (code) tags.push(["code", code]);
|
|
||||||
const event = await auth.signer.signEvent({
|
|
||||||
kind: 9021,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: "",
|
|
||||||
});
|
|
||||||
const timeout = new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
|
||||||
);
|
|
||||||
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends the kind:9021 for the modal's target group, marks the user joined, and
|
|
||||||
// runs the pending continuation. Errors keep the modal open for a retry.
|
|
||||||
export async function submitJoin(code?: string) {
|
|
||||||
if (!modalGroup || busy) return;
|
|
||||||
busy = true;
|
busy = true;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
const groupId = modalGroup;
|
const groupId = modalGroup;
|
||||||
const cb = onJoined;
|
const cb = onJoined;
|
||||||
|
const codes: Codes =
|
||||||
|
modalStep === "relay-code" ? { relay: code } : { group: code };
|
||||||
try {
|
try {
|
||||||
await publishJoinRequest(groupId, code);
|
const pubkey = auth.user?.pubkey;
|
||||||
membership[memberKey(groupId)] = "member";
|
const outcome = groupId
|
||||||
|
? await ensureAccess(groupId, codes)
|
||||||
|
: pubkey
|
||||||
|
? await tryRelayJoin(pubkey, codes)
|
||||||
|
: fail("error", "Not logged in");
|
||||||
|
if (!outcome.ok) {
|
||||||
|
openModal(groupId, outcome, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
modalGroup = null;
|
modalGroup = null;
|
||||||
onJoined = null;
|
onJoined = null;
|
||||||
if (cb) await cb();
|
if (cb) await cb();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
modalError = reason(e);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps an action that posts to `groupId`. The relay requires membership to
|
// Relay-only join, for a relay that refuses reads from non-members: no group
|
||||||
// write to any group, so a non-member is joined first. Compose entry points
|
// is involved, so the modal (if any) targets the relay alone.
|
||||||
// gate membership up front, making this mostly a safety net: it joins
|
export async function joinRelay(cb: () => void | Promise<void>): Promise<void> {
|
||||||
// transparently if possible, else opens the modal to retry.
|
const pubkey = auth.user?.pubkey;
|
||||||
export async function withJoin(
|
if (!pubkey || busy) return;
|
||||||
groupId: string,
|
|
||||||
action: () => Promise<void>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (membership[memberKey(groupId)] === "member") {
|
|
||||||
await action();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const pubkey = auth.user?.pubkey;
|
const outcome = await tryRelayJoin(pubkey, {});
|
||||||
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
if (!outcome.ok) {
|
||||||
membership[memberKey(groupId)] = "member";
|
openModal(null, outcome, cb);
|
||||||
await action();
|
return;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
await publishJoinRequest(groupId);
|
await cb();
|
||||||
await action();
|
|
||||||
membership[memberKey(groupId)] = "member";
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
modalGroup = groupId;
|
|
||||||
onJoined = action;
|
|
||||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
|
||||||
modalOpen = true;
|
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { RELAY_URL } from "./config";
|
import { RELAY_URL, externalLink } from "./config";
|
||||||
|
|
||||||
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||||
const TLDS = [
|
const TLDS = [
|
||||||
|
|
@ -157,7 +157,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
||||||
return {
|
return {
|
||||||
type: "entity",
|
type: "entity",
|
||||||
entity,
|
entity,
|
||||||
href: `https://njump.me/${entity}`,
|
href: externalLink(entity),
|
||||||
label: shortNostrEntity(entity),
|
label: shortNostrEntity(entity),
|
||||||
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
||||||
};
|
};
|
||||||
|
|
@ -165,7 +165,7 @@ function decodeEntity(entity: string): ChatToken | null {
|
||||||
return {
|
return {
|
||||||
type: "entity",
|
type: "entity",
|
||||||
entity,
|
entity,
|
||||||
href: `https://njump.me/${entity}`,
|
href: externalLink(entity),
|
||||||
label: shortNostrEntity(entity),
|
label: shortNostrEntity(entity),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
import { loadRelayList } from "$lib/gadgets";
|
||||||
|
|
||||||
const RELAY_HINT_TIMEOUT_MS = 1500;
|
const RELAY_HINT_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
|
|
||||||
149
src/lib/notifications.svelte.ts
Normal file
149
src/lib/notifications.svelte.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
|
import { queryForum, subscribeForum } from "$lib/relay";
|
||||||
|
import { ensureProfile } from "$lib/profiles.svelte";
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchThreadTitles,
|
||||||
|
parseNotification,
|
||||||
|
type Notification,
|
||||||
|
type NotificationPage,
|
||||||
|
} from "$lib/forum/notifications";
|
||||||
|
|
||||||
|
export type { Notification };
|
||||||
|
|
||||||
|
const SEEN_KEY = "notifications_seen";
|
||||||
|
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
|
||||||
|
|
||||||
|
let pubkey = $state<string | null>(null);
|
||||||
|
let items = $state<Notification[] | null>(null);
|
||||||
|
let done = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let seenAt = $state(0);
|
||||||
|
let liveSub: { close(): void } | null = null;
|
||||||
|
let req = 0; // supersedes an in-flight load on login/logout
|
||||||
|
|
||||||
|
function readSeen(pk: string): number {
|
||||||
|
try {
|
||||||
|
return Number(localStorage.getItem(`${SEEN_KEY}_${pk}`)) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsStore = {
|
||||||
|
get pubkey() {
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
get items() {
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
get done() {
|
||||||
|
return done;
|
||||||
|
},
|
||||||
|
get loadingMore() {
|
||||||
|
return loadingMore;
|
||||||
|
},
|
||||||
|
get seenAt() {
|
||||||
|
return seenAt;
|
||||||
|
},
|
||||||
|
get unreadCount() {
|
||||||
|
return items?.filter((n) => n.createdAt > seenAt).length ?? 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ingest(n: Notification) {
|
||||||
|
if (!items || items.some((x) => x.id === n.id)) return;
|
||||||
|
items = [n, ...items];
|
||||||
|
ensureProfile(n.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)start for the logged-in user: one authenticated read of the latest
|
||||||
|
// replies/mentions, then a live subscription that prepends new ones.
|
||||||
|
export async function startNotifications(pk: string) {
|
||||||
|
if (pk === pubkey) return;
|
||||||
|
stopNotifications();
|
||||||
|
pubkey = pk;
|
||||||
|
seenAt = readSeen(pk);
|
||||||
|
const current = ++req;
|
||||||
|
const groupId = scope();
|
||||||
|
|
||||||
|
let result: NotificationPage = { items: [], done: true };
|
||||||
|
try {
|
||||||
|
result = await fetchNotifications(queryForum, pk, { groupId });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[notifications] load failed", e);
|
||||||
|
}
|
||||||
|
if (current !== req) return;
|
||||||
|
items = result.items;
|
||||||
|
done = result.done;
|
||||||
|
for (const n of result.items) ensureProfile(n.pubkey);
|
||||||
|
|
||||||
|
liveSub = subscribeForum(
|
||||||
|
{
|
||||||
|
kinds: [1111, 9],
|
||||||
|
"#p": [pk],
|
||||||
|
...(groupId ? { "#h": [groupId] } : {}),
|
||||||
|
since: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onevent: async (ev) => {
|
||||||
|
const n = parseNotification(ev, pk);
|
||||||
|
if (!n) return;
|
||||||
|
if (n.threadId) {
|
||||||
|
try {
|
||||||
|
await fetchThreadTitles(queryForum, [n]);
|
||||||
|
} catch {
|
||||||
|
// Shown without a title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current === req) ingest(n);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopNotifications() {
|
||||||
|
req++;
|
||||||
|
liveSub?.close();
|
||||||
|
liveSub = null;
|
||||||
|
pubkey = null;
|
||||||
|
items = null;
|
||||||
|
done = true;
|
||||||
|
loadingMore = false;
|
||||||
|
seenAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the next page of older notifications.
|
||||||
|
export async function loadMoreNotifications() {
|
||||||
|
const pk = pubkey;
|
||||||
|
const current = req;
|
||||||
|
if (!pk || !items || done || loadingMore) return;
|
||||||
|
loadingMore = true;
|
||||||
|
const oldest = items[items.length - 1];
|
||||||
|
try {
|
||||||
|
const next = await fetchNotifications(queryForum, pk, {
|
||||||
|
groupId: scope(),
|
||||||
|
until: oldest?.createdAt,
|
||||||
|
exclude: new Set(items.map((n) => n.id)),
|
||||||
|
});
|
||||||
|
if (current !== req || !items) return;
|
||||||
|
items = [...items, ...next.items];
|
||||||
|
done = next.done;
|
||||||
|
for (const n of next.items) ensureProfile(n.pubkey);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[notifications] failed to load more", e);
|
||||||
|
} finally {
|
||||||
|
if (current === req) loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything loaded so far counts as read.
|
||||||
|
export function markNotificationsSeen() {
|
||||||
|
if (!pubkey) return;
|
||||||
|
seenAt = Math.floor(Date.now() / 1000);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${SEEN_KEY}_${pubkey}`, String(seenAt));
|
||||||
|
} catch {
|
||||||
|
// Private mode: unread state lasts for the session only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import { page } from "$app/state";
|
||||||
import type { Event } from "@nostr/tools/core";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
import { relayQuery } from "$lib/forum/query";
|
||||||
|
import {
|
||||||
|
fetchOverview,
|
||||||
|
overviewPubkeys,
|
||||||
|
type RecentThread,
|
||||||
|
type RoomActivity,
|
||||||
|
} from "$lib/forum/overview";
|
||||||
|
|
||||||
export type RoomActivity = {
|
export type { RecentThread, RoomActivity };
|
||||||
latestAt: number;
|
|
||||||
latestPubkey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecentThread = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
groupId: string;
|
|
||||||
authorPubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let activity = $state<Record<string, RoomActivity>>({});
|
let activity = $state<Record<string, RoomActivity>>({});
|
||||||
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
|
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
|
||||||
|
|
@ -24,43 +18,32 @@ let recent = $state<RecentThread[]>([]);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
|
||||||
|
let settled = $state(false); // first live load finished
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
|
function fallback() {
|
||||||
|
return settled ? null : (page.data.overview ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
export const overviewStore = {
|
export const overviewStore = {
|
||||||
get activity() {
|
get activity() {
|
||||||
return activity;
|
return fallback()?.activity ?? activity;
|
||||||
},
|
},
|
||||||
get admins() {
|
get admins() {
|
||||||
return admins;
|
return fallback()?.admins ?? admins;
|
||||||
},
|
},
|
||||||
get recent() {
|
get recent() {
|
||||||
return recent;
|
return fallback()?.recent ?? recent;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = page.data.overview?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const events: Event[] = [];
|
|
||||||
const sub = relay.subscribe([filter], {
|
|
||||||
onevent(e) {
|
|
||||||
events.push(e);
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
sub.close();
|
|
||||||
resolve(events);
|
|
||||||
},
|
|
||||||
onclose() {
|
|
||||||
resolve(events);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProfile(pubkey: string) {
|
async function loadProfile(pubkey: string) {
|
||||||
if (profiles[pubkey]) return;
|
if (profiles[pubkey]) return;
|
||||||
const user = await loadNostrUser(pubkey);
|
const user = await loadNostrUser(pubkey);
|
||||||
|
|
@ -77,54 +60,15 @@ export async function loadOverview(roomIds: string[]) {
|
||||||
loadedKey = key;
|
loadedKey = key;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
const relay = await ensureForumRelay();
|
|
||||||
try {
|
try {
|
||||||
// One tiny query per room for its newest event (thread or reply).
|
const q = relayQuery(await ensureForumRelay());
|
||||||
const latest = await Promise.all(
|
const o = await fetchOverview(q, roomIds);
|
||||||
roomIds.map((id) =>
|
activity = o.activity;
|
||||||
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }),
|
admins = o.admins;
|
||||||
),
|
recent = o.recent;
|
||||||
);
|
settled = true;
|
||||||
const act: Record<string, RoomActivity> = {};
|
overviewPubkeys(o).forEach(loadProfile);
|
||||||
roomIds.forEach((id, i) => {
|
|
||||||
const e = latest[i][0];
|
|
||||||
if (e) act[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
|
|
||||||
});
|
|
||||||
activity = act;
|
|
||||||
|
|
||||||
// Each room's admin (NIP-29 kind 39001, first `p` tag) for the card byline.
|
|
||||||
const adminEvents = await Promise.all(
|
|
||||||
roomIds.map((id) => querySync(relay, { kinds: [39001], "#d": [id] })),
|
|
||||||
);
|
|
||||||
const adm: Record<string, string> = {};
|
|
||||||
roomIds.forEach((id, i) => {
|
|
||||||
const pk = adminEvents[i][0]?.tags.find((t) => t[0] === "p")?.[1];
|
|
||||||
if (pk) adm[id] = pk;
|
|
||||||
});
|
|
||||||
admins = adm;
|
|
||||||
|
|
||||||
// Most recent discussions (thread OPs) across all rooms. The group relay
|
|
||||||
// truncates multi-value "#h" filters, so query each room and merge.
|
|
||||||
const perRoom = await Promise.all(
|
|
||||||
roomIds.map((id) =>
|
|
||||||
querySync(relay, { kinds: [11], "#h": [id], limit: 20 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const threads = perRoom.flat();
|
|
||||||
threads.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
recent = threads.slice(0, 20).map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
|
||||||
groupId: e.tags.find((t) => t[0] === "h")?.[1] ?? "",
|
|
||||||
authorPubkey: e.pubkey,
|
|
||||||
createdAt: e.created_at,
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const a of Object.values(act)) loadProfile(a.latestPubkey);
|
|
||||||
for (const pk of Object.values(adm)) loadProfile(pk);
|
|
||||||
for (const t of recent) loadProfile(t.authorPubkey);
|
|
||||||
} finally {
|
} finally {
|
||||||
// shared forum connection is long-lived — don't close it here
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/lib/pageHighlight.ts
Normal file
61
src/lib/pageHighlight.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
findPhraseSpans,
|
||||||
|
findMatchSpans,
|
||||||
|
tokenize,
|
||||||
|
type Span,
|
||||||
|
} from "$lib/textMatch";
|
||||||
|
|
||||||
|
const HIGHLIGHT_NAME = "search-terms";
|
||||||
|
|
||||||
|
function supported(): boolean {
|
||||||
|
return typeof CSS !== "undefined" && "highlights" in CSS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight the query inside the given roots using the CSS Custom Highlight
|
||||||
|
// API, which paints ranges without touching the DOM, so it never conflicts
|
||||||
|
// with Svelte re-renders. When the whole query appears contiguously anywhere
|
||||||
|
// on the page, only those phrase occurrences are painted; otherwise the
|
||||||
|
// textMatch fallback (contiguous runs as blocks, then single words) is used.
|
||||||
|
// Returns the first match in document order (for scrolling), or null. No-op
|
||||||
|
// on unsupported browsers.
|
||||||
|
export function applyHighlights(
|
||||||
|
roots: Iterable<Element>,
|
||||||
|
query: string,
|
||||||
|
): Range | null {
|
||||||
|
if (!supported()) return null;
|
||||||
|
CSS.highlights.delete(HIGHLIGHT_NAME);
|
||||||
|
if (tokenize(query).length === 0) return null;
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) nodes.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
let perNode: Span[][] = nodes.map((n) =>
|
||||||
|
findPhraseSpans(n.textContent ?? "", query),
|
||||||
|
);
|
||||||
|
if (perNode.every((spans) => spans.length === 0)) {
|
||||||
|
perNode = nodes.map((n) => findMatchSpans(n.textContent ?? "", query));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
for (const span of perNode[i]) {
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, span.start);
|
||||||
|
range.setEnd(node, span.end);
|
||||||
|
ranges.push(range);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ranges.length === 0) return null;
|
||||||
|
|
||||||
|
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));
|
||||||
|
ranges.sort((a, b) => a.compareBoundaryPoints(Range.START_TO_START, b));
|
||||||
|
return ranges[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHighlights() {
|
||||||
|
if (supported()) CSS.highlights.delete(HIGHLIGHT_NAME);
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,32 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { adminPubkeys } from "$lib/admins.svelte";
|
import { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import {
|
||||||
|
fetchPartials,
|
||||||
|
pickPartial,
|
||||||
|
type Partial,
|
||||||
|
type PartialSlot,
|
||||||
|
} from "$lib/forum/partials";
|
||||||
|
|
||||||
// Named slots a partial can fill. The NIP-23 `d` tag carries the slot name.
|
export type { Partial, PartialSlot };
|
||||||
export type PartialSlot = "home" | "contacts";
|
|
||||||
const SLOTS = new Set<string>(["home", "contacts"]);
|
|
||||||
|
|
||||||
export type Partial = {
|
|
||||||
id: string;
|
|
||||||
slot: PartialSlot;
|
|
||||||
title: string;
|
|
||||||
content: string; // markdown body
|
|
||||||
pubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let all = $state<Partial[]>([]);
|
let all = $state<Partial[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in.
|
||||||
export const partialsStore = {
|
export const partialsStore = {
|
||||||
// The newest admin-authored partial for a slot, or undefined. The relay query
|
|
||||||
// is open, so the trusted admin set is the gate; resolving the winner here
|
|
||||||
// (rather than at load time) keeps a non-admin from shadowing it with a newer
|
|
||||||
// event, and picks up the admin set once it finishes loading in full mode.
|
|
||||||
get(slot: PartialSlot): Partial | undefined {
|
get(slot: PartialSlot): Partial | undefined {
|
||||||
const admins = new Set(adminPubkeys.list);
|
const source = loaded ? all : (page.data.shell?.partials ?? []);
|
||||||
let best: Partial | undefined;
|
return pickPartial(source, slot, adminPubkeys.list);
|
||||||
for (const p of all) {
|
|
||||||
if (p.slot !== slot || !admins.has(p.pubkey)) continue;
|
|
||||||
if (!best || p.createdAt > best.createdAt) best = p;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"];
|
|
||||||
// the `d` tag names the slot the article fills.
|
|
||||||
export async function loadPartials() {
|
export async function loadPartials() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
all = await fetchPartials(queryForum);
|
||||||
kinds: [30023],
|
|
||||||
"#t": ["squalk-partial"],
|
|
||||||
});
|
|
||||||
const next: Partial[] = [];
|
|
||||||
for (const e of events) {
|
|
||||||
const slot = e.tags.find((t) => t[0] === "d")?.[1];
|
|
||||||
if (!slot || !SLOTS.has(slot)) continue;
|
|
||||||
next.push({
|
|
||||||
id: e.id,
|
|
||||||
slot: slot as PartialSlot,
|
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slot,
|
|
||||||
content: e.content,
|
|
||||||
pubkey: e.pubkey,
|
|
||||||
createdAt: e.created_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
all = next;
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { GROUP_ID } from "$lib/config";
|
import { GROUP_ID } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||||
|
|
||||||
export type ProfileEntry = {
|
export type ProfileEntry = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
|
@ -27,12 +28,6 @@ const SEARCH_RELAYS = [
|
||||||
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
// Indexer relays used for kind:0 / kind:3 lookups. The forum relay almost
|
||||||
// never has these — they live on each author's outbox relays — so we query
|
// never has these — they live on each author's outbox relays — so we query
|
||||||
// a small set of well-known aggregators.
|
// a small set of well-known aggregators.
|
||||||
const PROFILE_RELAYS = [
|
|
||||||
"wss://purplepag.es",
|
|
||||||
"wss://relay.nostr.band",
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://nos.lol",
|
|
||||||
];
|
|
||||||
const FETCH_BATCH_SIZE = 500;
|
const FETCH_BATCH_SIZE = 500;
|
||||||
|
|
||||||
const profiles = new SvelteMap<string, ProfileEntry>();
|
const profiles = new SvelteMap<string, ProfileEntry>();
|
||||||
|
|
@ -310,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
|
||||||
if (entry) upsertProfile(entry);
|
if (entry) upsertProfile(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestNostrUser(user: NostrUser) {
|
export function entryFromUser(user: NostrUser): ProfileEntry {
|
||||||
if (!user.pubkey) return;
|
|
||||||
const md = user.metadata ?? {};
|
const md = user.metadata ?? {};
|
||||||
const entry: ProfileEntry = {
|
return {
|
||||||
pubkey: user.pubkey,
|
pubkey: user.pubkey,
|
||||||
npub: user.npub,
|
npub: user.npub,
|
||||||
name: md.name,
|
name: md.name,
|
||||||
|
|
@ -325,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
|
||||||
lud16: md.lud16,
|
lud16: md.lud16,
|
||||||
fetchedAt: user.lastUpdated || 0,
|
fetchedAt: user.lastUpdated || 0,
|
||||||
};
|
};
|
||||||
upsertProfile(entry);
|
}
|
||||||
|
|
||||||
|
export function ingestNostrUser(user: NostrUser) {
|
||||||
|
if (!user.pubkey) return;
|
||||||
|
upsertProfile(entryFromUser(user));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,29 @@ import { auth } from "$lib/auth.svelte";
|
||||||
// persists so the handshake happens once rather than per request. Auth is
|
// persists so the handshake happens once rather than per request. Auth is
|
||||||
// scoped to the forum relay — external relays (profiles, search) are never
|
// scoped to the forum relay — external relays (profiles, search) are never
|
||||||
// authenticated against.
|
// authenticated against.
|
||||||
const pool = new SimplePool();
|
// Reconnection keeps the live subscriptions (chat, notifications) alive
|
||||||
|
// across a network drop: the library re-sends them with `since` moved past
|
||||||
|
// the last event seen, so what arrived meanwhile is replayed.
|
||||||
|
const pool = new SimplePool({ enableReconnect: true });
|
||||||
|
|
||||||
|
// The library retries after a 10s backoff and gives up after one failed
|
||||||
|
// attempt while still offline, so reconnect as soon as the network or the
|
||||||
|
// tab is back, and keep a shorter backoff for drops in between.
|
||||||
|
const FAST_BACKOFF = [1000, 2000, 5000, 10000];
|
||||||
|
async function forumRelay(): Promise<AbstractRelay> {
|
||||||
|
const relay = await pool.ensureRelay(RELAY_URL);
|
||||||
|
relay.resubscribeBackoff = FAST_BACKOFF;
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
function touchForumRelay() {
|
||||||
|
forumRelay().catch(() => {});
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) touchForumRelay();
|
||||||
|
});
|
||||||
|
window.addEventListener("online", touchForumRelay);
|
||||||
|
}
|
||||||
|
|
||||||
// Scoped to the forum relay — external relays (profiles, search) are never
|
// Scoped to the forum relay — external relays (profiles, search) are never
|
||||||
// authenticated against. Re-evaluated on every connection, so it picks up a
|
// authenticated against. Re-evaluated on every connection, so it picks up a
|
||||||
|
|
@ -26,7 +48,9 @@ export const forumPool = pool;
|
||||||
|
|
||||||
function authParam() {
|
function authParam() {
|
||||||
const signer = auth.signer;
|
const signer = auth.signer;
|
||||||
return signer ? { onauth: (evt: EventTemplate) => signer.signEvent(evt) } : {};
|
return signer
|
||||||
|
? { onauth: (evt: EventTemplate) => signer.signEvent(evt) }
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves once the forum connection is open and, when logged in, NIP-42
|
// Resolves once the forum connection is open and, when logged in, NIP-42
|
||||||
|
|
@ -40,7 +64,7 @@ export async function ensureForumReady(): Promise<void> {
|
||||||
if (!signer) return; // anonymous: only public groups are visible anyway
|
if (!signer) return; // anonymous: only public groups are visible anyway
|
||||||
let relay: AbstractRelay;
|
let relay: AbstractRelay;
|
||||||
try {
|
try {
|
||||||
relay = await pool.ensureRelay(RELAY_URL);
|
relay = await forumRelay();
|
||||||
} catch {
|
} catch {
|
||||||
return; // connection failed; the caller's own query surfaces the error
|
return; // connection failed; the caller's own query surfaces the error
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +120,7 @@ export function subscribeForum(
|
||||||
// membership probe. Never close it — the pool owns its lifecycle.
|
// membership probe. Never close it — the pool owns its lifecycle.
|
||||||
export async function ensureForumRelay(): Promise<AbstractRelay> {
|
export async function ensureForumRelay(): Promise<AbstractRelay> {
|
||||||
await ensureForumReady();
|
await ensureForumReady();
|
||||||
return pool.ensureRelay(RELAY_URL);
|
return forumRelay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the forum connection so the next use reconnects and re-runs the AUTH
|
// Drop the forum connection so the next use reconnects and re-runs the AUTH
|
||||||
|
|
@ -104,3 +128,53 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
|
||||||
export function resetForumConnection(): void {
|
export function resetForumConnection(): void {
|
||||||
pool.close([RELAY_URL]);
|
pool.close([RELAY_URL]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NIP-11 document of the forum relay, fetched once per session. Used for NIP-43
|
||||||
|
// discovery (clients must only send relay join requests to relays advertising
|
||||||
|
// it; `self` signs the membership events we read back) and for the access gate,
|
||||||
|
// which can show the relay's name before anything else is readable.
|
||||||
|
export type ForumRelayInfo = {
|
||||||
|
self: string | null;
|
||||||
|
nips: string[];
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
authRequired: boolean;
|
||||||
|
};
|
||||||
|
let relayInfo: Promise<ForumRelayInfo> | null = null;
|
||||||
|
|
||||||
|
export function forumRelayInfo(): Promise<ForumRelayInfo> {
|
||||||
|
if (!relayInfo) {
|
||||||
|
relayInfo = import("@nostr/tools/nip11")
|
||||||
|
.then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL))
|
||||||
|
.then((info) => {
|
||||||
|
// `self` is a NIP-43 addition the library type doesn't know yet
|
||||||
|
const self = (info as { self?: unknown }).self;
|
||||||
|
return {
|
||||||
|
self: typeof self === "string" ? self : null,
|
||||||
|
// Relays mix numbers and strings in this list
|
||||||
|
nips: (info.supported_nips ?? []).map(String),
|
||||||
|
name: info.name ?? "",
|
||||||
|
description: info.description ?? "",
|
||||||
|
icon: info.icon ?? "",
|
||||||
|
authRequired: info.limitation?.auth_required === true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => ({
|
||||||
|
self: null,
|
||||||
|
nips: [],
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
icon: "",
|
||||||
|
authRequired: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return relayInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The relay pubkey when the forum relay supports NIP-43 relay membership,
|
||||||
|
// otherwise null (no relay-level join is needed or allowed).
|
||||||
|
export async function nip43Self(): Promise<string | null> {
|
||||||
|
const info = await forumRelayInfo();
|
||||||
|
return info.nips.includes("43") ? info.self : null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,30 @@
|
||||||
|
import { page } from "$app/state";
|
||||||
import { adminPubkeys } from "$lib/admins.svelte";
|
import { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { fetchResources, type Resource } from "$lib/forum/resources";
|
||||||
|
|
||||||
export type Resource = {
|
export type { Resource };
|
||||||
id: string;
|
|
||||||
slug: string; // NIP-23 `d` tag — also the resource URL slug
|
|
||||||
title: string;
|
|
||||||
content: string; // markdown body
|
|
||||||
position?: number;
|
|
||||||
pubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let all = $state<Resource[]>([]);
|
let all = $state<Resource[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Only admin-authored resources are surfaced: the relay query is open, so the
|
||||||
|
// admin set is the trust gate. Until the live fetch lands, the server
|
||||||
|
// snapshot (if any) stands in.
|
||||||
export const resourcesStore = {
|
export const resourcesStore = {
|
||||||
// Only resources authored by an admin are surfaced; the relay query is
|
|
||||||
// open, so the trusted admin set is the gate.
|
|
||||||
get list() {
|
get list() {
|
||||||
const admins = new Set(adminPubkeys.list);
|
const admins = new Set(adminPubkeys.list);
|
||||||
return all.filter((r) => admins.has(r.pubkey));
|
const source = loaded ? all : (page.data.shell?.resources ?? []);
|
||||||
|
return source.filter((r) => admins.has(r.pubkey));
|
||||||
},
|
},
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return loaded;
|
return loaded || !!page.data.shell;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Positioned resources come first by ascending `position`; the rest follow
|
|
||||||
// alphabetically by title. Equal positions fall back to title order.
|
|
||||||
function compare(a: Resource, b: Resource): number {
|
|
||||||
const ap = a.position;
|
|
||||||
const bp = b.position;
|
|
||||||
if (ap !== undefined && bp !== undefined)
|
|
||||||
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
|
|
||||||
if (ap !== undefined) return -1;
|
|
||||||
if (bp !== undefined) return 1;
|
|
||||||
return a.title.localeCompare(b.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
|
|
||||||
export async function loadResources() {
|
export async function loadResources() {
|
||||||
try {
|
try {
|
||||||
const events = await queryForum({
|
all = await fetchResources(queryForum);
|
||||||
kinds: [30023],
|
|
||||||
"#t": ["squalk-resource"],
|
|
||||||
});
|
|
||||||
// Addressable events: keep the newest per `d` slug.
|
|
||||||
const bySlug = new Map<string, Resource>();
|
|
||||||
for (const e of events) {
|
|
||||||
const slug = e.tags.find((t) => t[0] === "d")?.[1];
|
|
||||||
if (!slug) continue;
|
|
||||||
const existing = bySlug.get(slug);
|
|
||||||
if (existing && existing.createdAt >= e.created_at) continue;
|
|
||||||
const posTag = e.tags.find((t) => t[0] === "position")?.[1];
|
|
||||||
const pos = posTag !== undefined ? Number(posTag) : NaN;
|
|
||||||
bySlug.set(slug, {
|
|
||||||
id: e.id,
|
|
||||||
slug,
|
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug,
|
|
||||||
content: e.content,
|
|
||||||
position: Number.isFinite(pos) ? pos : undefined,
|
|
||||||
pubkey: e.pubkey,
|
|
||||||
createdAt: e.created_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
all = [...bySlug.values()].sort(compare);
|
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Event } from "@nostr/tools/core";
|
import type { Event } from "@nostr/tools/core";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
import type { Filter } from "@nostr/tools/filter";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { bestMatch } from "$lib/textMatch";
|
||||||
import { GROUP_ID, MODE } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
|
||||||
|
|
@ -12,22 +13,26 @@ export type SearchResult = {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Window the snippet around the first term match so the highlight is visible
|
// Window the snippet around the best match (full phrase > longest
|
||||||
// even when the match sits deep in a long post.
|
// contiguous run > densest single-term cluster, per textMatch rules). The
|
||||||
function snippetOf(content: string, query: string): string {
|
// score ranks how well this content matched, so dedupe can keep the best
|
||||||
|
// snippet per thread and the result list can order its tiers.
|
||||||
|
function snippetOf(
|
||||||
|
content: string,
|
||||||
|
query: string,
|
||||||
|
): { text: string; score: number } {
|
||||||
const flat = content.replace(/\s+/g, " ").trim();
|
const flat = content.replace(/\s+/g, " ").trim();
|
||||||
const MAX = 140;
|
const MAX = 140;
|
||||||
if (flat.length <= MAX) return flat;
|
const { anchor, score } = bestMatch(flat, query, MAX - 40);
|
||||||
const lower = flat.toLowerCase();
|
|
||||||
let idx = -1;
|
if (flat.length <= MAX) return { text: flat, score };
|
||||||
for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
|
||||||
const i = lower.indexOf(t);
|
const start = anchor - 40;
|
||||||
if (i !== -1 && (idx === -1 || i < idx)) idx = i;
|
|
||||||
}
|
|
||||||
if (idx <= 40) return flat.slice(0, MAX) + "…";
|
|
||||||
const start = idx - 40;
|
|
||||||
const end = Math.min(flat.length, start + MAX);
|
const end = Math.min(flat.length, start + MAX);
|
||||||
return "…" + flat.slice(start, end) + (end < flat.length ? "…" : "");
|
return {
|
||||||
|
text: "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""),
|
||||||
|
score,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
|
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies
|
||||||
|
|
@ -48,46 +53,58 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
|
||||||
return h !== undefined && (groups.size === 0 || groups.has(h));
|
return h !== undefined && (groups.size === 0 || groups.has(h));
|
||||||
});
|
});
|
||||||
|
|
||||||
const byThread = new Map<string, SearchResult>();
|
type Candidate = SearchResult & { score: number };
|
||||||
const opless: SearchResult[] = [];
|
const byThread = new Map<string, Candidate>();
|
||||||
|
const needTitle: Candidate[] = [];
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.kind === 11) {
|
const isOp = e.kind === 11;
|
||||||
const existing = byThread.get(e.id);
|
const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
||||||
// An OP match wins over a reply match on the same thread
|
if (!threadId) continue;
|
||||||
if (existing && existing.matchKind === "thread") continue;
|
const title = isOp
|
||||||
byThread.set(e.id, {
|
? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
|
||||||
threadId: e.id,
|
: "";
|
||||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
const { text, score } = snippetOf(e.content, query);
|
||||||
snippet: snippetOf(e.content, query),
|
const existing = byThread.get(threadId);
|
||||||
matchKind: "thread",
|
if (!existing) {
|
||||||
createdAt: e.created_at,
|
const r: Candidate = {
|
||||||
});
|
threadId,
|
||||||
} else {
|
title,
|
||||||
const root = e.tags.find((t) => t[0] === "E")?.[1];
|
snippet: text,
|
||||||
if (!root || byThread.has(root)) continue;
|
matchKind: isOp ? "thread" : "reply",
|
||||||
const r: SearchResult = {
|
|
||||||
threadId: root,
|
|
||||||
title: "",
|
|
||||||
snippet: snippetOf(e.content, query),
|
|
||||||
matchKind: "reply",
|
|
||||||
createdAt: e.created_at,
|
createdAt: e.created_at,
|
||||||
|
score,
|
||||||
};
|
};
|
||||||
byThread.set(root, r);
|
byThread.set(threadId, r);
|
||||||
opless.push(r);
|
if (!isOp) needTitle.push(r);
|
||||||
|
} else {
|
||||||
|
// Keep the snippet of whichever event matched the query best
|
||||||
|
if (score > existing.score) {
|
||||||
|
existing.snippet = text;
|
||||||
|
existing.matchKind = isOp ? "thread" : "reply";
|
||||||
|
existing.score = score;
|
||||||
|
}
|
||||||
|
if (isOp && !existing.title) existing.title = title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backfill titles for reply-only matches; the group relay truncates
|
// Backfill titles for reply-only matches; the group relay truncates
|
||||||
// multi-id queries, so fetch each OP on its own
|
// multi-id queries, so fetch each OP on its own
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
opless.map(async (r) => {
|
needTitle.map(async (r) => {
|
||||||
|
if (r.title) return; // The OP appeared in the same result set
|
||||||
const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
|
const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
|
||||||
const op: Event | undefined = ops[0];
|
const op: Event | undefined = ops[0];
|
||||||
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the relay's relevance order (arrival order); a thread keeps the
|
// Tiered ordering: full-phrase matches, then contiguous multi-word runs
|
||||||
// position of its best-ranked match
|
// (longest first), then single-word matches capped to keep noise down.
|
||||||
return [...byThread.values()];
|
// Within a tier the relay's relevance (arrival) order is preserved —
|
||||||
|
// Array.prototype.sort is stable.
|
||||||
|
const SINGLES_LIMIT = 5;
|
||||||
|
let singles = 0;
|
||||||
|
return [...byThread.values()]
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.filter((r) => r.score >= 100 || ++singles <= SINGLES_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { searchThreads, type SearchResult } from "$lib/search";
|
import { searchThreads, type SearchResult } from "$lib/search";
|
||||||
|
import { findPhraseSpans, findMatchSpans } from "$lib/textMatch";
|
||||||
|
|
||||||
// Debounced-search state machine shared by the inline (homepage) and modal
|
// Debounced-search state machine shared by the inline (homepage) and modal
|
||||||
// search shells, so behavior lives in one place and the shells only differ
|
// search shells, so behavior lives in one place and the shells only differ
|
||||||
|
|
@ -12,10 +13,6 @@ export function createSearchState() {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
const terms = $derived(
|
|
||||||
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
function schedule() {
|
function schedule() {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
|
|
@ -41,19 +38,26 @@ export function createSearchState() {
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRe(s: string): string {
|
// Split into alternating plain/matched segments for <mark> rendering.
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
// When the text contains the whole query as a contiguous phrase, only the
|
||||||
}
|
// phrase is marked; otherwise contiguous runs (as single blocks) and
|
||||||
|
// single words are marked as a fallback, to show why the text matched.
|
||||||
// Split into alternating plain/matched segments for <mark> rendering
|
|
||||||
function highlight(text: string): { text: string; hit: boolean }[] {
|
function highlight(text: string): { text: string; hit: boolean }[] {
|
||||||
if (!text || terms.length === 0) return [{ text, hit: false }];
|
if (!text) return [{ text, hit: false }];
|
||||||
const alts = terms.map(escapeRe).join("|");
|
const phrase = findPhraseSpans(text, resultsQuery);
|
||||||
const exact = new RegExp(`^(${alts})$`, "i");
|
const spans =
|
||||||
return text
|
phrase.length > 0 ? phrase : findMatchSpans(text, resultsQuery);
|
||||||
.split(new RegExp(`(${alts})`, "gi"))
|
if (spans.length === 0) return [{ text, hit: false }];
|
||||||
.filter((s) => s !== "")
|
const out: { text: string; hit: boolean }[] = [];
|
||||||
.map((s) => ({ text: s, hit: exact.test(s) }));
|
let pos = 0;
|
||||||
|
for (const s of spans) {
|
||||||
|
if (s.start > pos)
|
||||||
|
out.push({ text: text.slice(pos, s.start), hit: false });
|
||||||
|
out.push({ text: text.slice(s.start, s.end), hit: true });
|
||||||
|
pos = s.end;
|
||||||
|
}
|
||||||
|
if (pos < text.length) out.push({ text: text.slice(pos), hit: false });
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow/Enter handling; returns true when the event was consumed
|
// Arrow/Enter handling; returns true when the event was consumed
|
||||||
|
|
@ -98,6 +102,13 @@ export function createSearchState() {
|
||||||
get active() {
|
get active() {
|
||||||
return resultsQuery.trim().length >= 2;
|
return resultsQuery.trim().length >= 2;
|
||||||
},
|
},
|
||||||
|
get resultsQuery() {
|
||||||
|
return resultsQuery;
|
||||||
|
},
|
||||||
|
// Thread link carrying the query, so the page can highlight the terms
|
||||||
|
threadUrl(threadId: string) {
|
||||||
|
return `/thread/${threadId}?q=${encodeURIComponent(resultsQuery.trim())}`;
|
||||||
|
},
|
||||||
schedule,
|
schedule,
|
||||||
highlight,
|
highlight,
|
||||||
navigate,
|
navigate,
|
||||||
|
|
|
||||||
47
src/lib/seo.ts
Normal file
47
src/lib/seo.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Markdown inline syntax reduced to plain text.
|
||||||
|
function stripInline(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
|
||||||
|
.replace(/[*_`~]+/g, "")
|
||||||
|
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProse(line: string): boolean {
|
||||||
|
return !!line && !line.startsWith("#") && !/^https?:\/\/\S+$/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text excerpt of a markdown body for description meta tags: the first
|
||||||
|
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
|
||||||
|
export function excerpt(markdown: string, max = 160): string {
|
||||||
|
const line = markdown
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.find(isProse);
|
||||||
|
return line ? truncate(stripInline(line), max) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole body flattened to one plain-text line, trimmed to `max` chars.
|
||||||
|
// Headings, bare media URLs, code blocks, quoted text and list markers are
|
||||||
|
// dropped, so a reply reads as its own words.
|
||||||
|
export function summarize(markdown: string, max = 200): string {
|
||||||
|
let inCode = false;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const raw of markdown.split("\n")) {
|
||||||
|
const l = raw.trim();
|
||||||
|
if (l.startsWith("```")) {
|
||||||
|
inCode = !inCode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode || l.startsWith(">")) continue;
|
||||||
|
const line = l.replace(/^(?:[-*+]\s+|\d+[.)]\s+)+/, "");
|
||||||
|
if (isProse(line)) lines.push(line);
|
||||||
|
}
|
||||||
|
return truncate(stripInline(lines.join(" ")), max);
|
||||||
|
}
|
||||||
26
src/lib/ssr/endpoint.ts
Normal file
26
src/lib/ssr/endpoint.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { error, type RequestEvent } from "@sveltejs/kit";
|
||||||
|
import { CACHE_CONTROL } from "$lib/config";
|
||||||
|
import { RelayUnavailable } from "./relay";
|
||||||
|
|
||||||
|
// Wraps a snapshot endpoint: answers carry the shared cache policy, while a
|
||||||
|
// relay that could not be asked becomes an uncached 503, which snapshot()
|
||||||
|
// treats as "no snapshot" so the page hydrates empty and the client takes the
|
||||||
|
// same path as a client-only build (probe, gate or live fetch).
|
||||||
|
export function snapshotHandler<E extends RequestEvent>(
|
||||||
|
handler: (event: E) => Promise<Response>,
|
||||||
|
): (event: E) => Promise<Response> {
|
||||||
|
return async (event) => {
|
||||||
|
try {
|
||||||
|
const res = await handler(event);
|
||||||
|
event.setHeaders({ "cache-control": CACHE_CONTROL });
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
const unavailable = e instanceof RelayUnavailable;
|
||||||
|
event.setHeaders({
|
||||||
|
"cache-control": unavailable ? "no-store" : CACHE_CONTROL,
|
||||||
|
});
|
||||||
|
if (unavailable) error(503, "Relay unavailable");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
171
src/lib/ssr/relay.ts
Normal file
171
src/lib/ssr/relay.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { SimplePool } from "@nostr/tools";
|
||||||
|
import type { Event } from "@nostr/tools/core";
|
||||||
|
import type { Filter } from "@nostr/tools/filter";
|
||||||
|
import { RELAY_URL, SSR_CACHE_FRESH, SSR_CACHE_STALE } from "$lib/config";
|
||||||
|
import { PROFILE_RELAYS } from "$lib/forum/profiles";
|
||||||
|
import type { Query } from "$lib/forum/query";
|
||||||
|
|
||||||
|
// Anonymous relay access for server rendering. The server has no signer, so
|
||||||
|
// it only ever sees what the relay serves the public; members get their
|
||||||
|
// private rooms once the client takes over. Every query is bounded so a slow
|
||||||
|
// relay degrades to an empty page, never a hung request.
|
||||||
|
//
|
||||||
|
// A relay that is down or refuses anonymous reads (NIP-42 auth-required) is
|
||||||
|
// not the same as an empty result: the query rejects with RelayUnavailable so
|
||||||
|
// endpoints answer 503 rather than 404, and the client renders as it would
|
||||||
|
// without SSR (probing the relay and showing the gate when it must).
|
||||||
|
//
|
||||||
|
// Results are cached in memory with the fresh/stale windows from config:
|
||||||
|
// fresh entries are returned as they are, stale ones are returned at once and
|
||||||
|
// refreshed in the background, expired ones are fetched again. Profiles
|
||||||
|
// rarely change, so their fresh window is at least an hour.
|
||||||
|
const QUERY_TIMEOUT = 2500;
|
||||||
|
const PROFILE_TIMEOUT = 1500;
|
||||||
|
const CONNECT_TIMEOUT = 1500;
|
||||||
|
const FORUM_FRESH = SSR_CACHE_FRESH * 1000;
|
||||||
|
const PROFILE_FRESH = Math.max(FORUM_FRESH, 60 * 60_000);
|
||||||
|
const STALE = Math.max(SSR_CACHE_STALE * 1000, FORUM_FRESH);
|
||||||
|
const DEAD_RELAY_TTL = 5 * 60_000;
|
||||||
|
const CACHE_MAX = 2000;
|
||||||
|
|
||||||
|
export class RelayUnavailable extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("No relay answered");
|
||||||
|
this.name = "RelayUnavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
// Open relay sockets keep the process alive after SIGTERM until systemd kills it
|
||||||
|
process.on("sveltekit:shutdown", () => pool.destroy());
|
||||||
|
type Entry = { at: number; result: Promise<Event[]>; refreshing: boolean };
|
||||||
|
const cache = new Map<string, Entry>();
|
||||||
|
// Relays that failed to connect are skipped for a while, so a dead profile
|
||||||
|
// relay doesn't add its connect timeout to every cold page.
|
||||||
|
const deadUntil = new Map<string, number>();
|
||||||
|
|
||||||
|
// Ask every reachable relay and resolve as soon as each has answered (EOSE or
|
||||||
|
// closed), or at the deadline, whichever comes first — always with whatever
|
||||||
|
// arrived, so one stalled relay never costs the events the others delivered.
|
||||||
|
// Rejects when no relay reached EOSE: a CLOSED (auth-required), a failed
|
||||||
|
// connection or the deadline are not answers.
|
||||||
|
function boundedQuery(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
maxWait: number,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const events: Event[] = [];
|
||||||
|
const subs: { close(): void }[] = [];
|
||||||
|
let done = false;
|
||||||
|
let answered = false;
|
||||||
|
let pending = 0;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
for (const s of subs) {
|
||||||
|
try {
|
||||||
|
s.close();
|
||||||
|
} catch {
|
||||||
|
// Already closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (answered) resolve(events);
|
||||||
|
else reject(new RelayUnavailable());
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, maxWait);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const live = relays.filter((url) => (deadUntil.get(url) ?? 0) <= now);
|
||||||
|
pending = live.length;
|
||||||
|
if (pending === 0) return finish();
|
||||||
|
|
||||||
|
for (const url of live) {
|
||||||
|
let settled = false;
|
||||||
|
const settle = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (--pending <= 0) finish();
|
||||||
|
};
|
||||||
|
pool.ensureRelay(url, { connectionTimeout: CONNECT_TIMEOUT }).then(
|
||||||
|
(relay) => {
|
||||||
|
if (done) return settle();
|
||||||
|
const sub = relay.subscribe([filter], {
|
||||||
|
onevent: (e) => events.push(e),
|
||||||
|
oneose: () => {
|
||||||
|
answered = true;
|
||||||
|
sub.close();
|
||||||
|
settle();
|
||||||
|
},
|
||||||
|
onclose: settle,
|
||||||
|
// The library fakes an EOSE after its own timeout; the deadline
|
||||||
|
// above must win so a silent relay does not count as an answer
|
||||||
|
eoseTimeout: maxWait * 2,
|
||||||
|
});
|
||||||
|
subs.push(sub);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
deadUntil.set(url, Date.now() + DEAD_RELAY_TTL);
|
||||||
|
settle();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedQuery(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
maxWait: number,
|
||||||
|
fresh: number,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
const key = relays.join(",") + "|" + JSON.stringify(filter);
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit) {
|
||||||
|
const age = now - hit.at;
|
||||||
|
if (age < fresh) return hit.result;
|
||||||
|
if (age < STALE) {
|
||||||
|
if (!hit.refreshing) {
|
||||||
|
hit.refreshing = true;
|
||||||
|
boundedQuery(relays, filter, maxWait).then(
|
||||||
|
(events) => {
|
||||||
|
cache.set(key, {
|
||||||
|
at: Date.now(),
|
||||||
|
result: Promise.resolve(events),
|
||||||
|
refreshing: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Keep serving the stale entry, try again next time
|
||||||
|
() => {
|
||||||
|
hit.refreshing = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hit.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cache.size >= CACHE_MAX) cache.clear();
|
||||||
|
const result = boundedQuery(relays, filter, maxWait);
|
||||||
|
const entry: Entry = { at: now, result, refreshing: false };
|
||||||
|
cache.set(key, entry);
|
||||||
|
// A failure is never cached, so the next request asks the relay again
|
||||||
|
result.catch(() => {
|
||||||
|
if (cache.get(key) === entry) cache.delete(key);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const forumQuery: Query = (filter) =>
|
||||||
|
cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT, FORUM_FRESH);
|
||||||
|
|
||||||
|
// Profiles mostly live off the forum relay; ask it too for members who only
|
||||||
|
// published there.
|
||||||
|
export const profileQuery: Query = (filter) =>
|
||||||
|
cachedQuery(
|
||||||
|
[RELAY_URL, ...PROFILE_RELAYS],
|
||||||
|
filter,
|
||||||
|
PROFILE_TIMEOUT,
|
||||||
|
PROFILE_FRESH,
|
||||||
|
);
|
||||||
29
src/lib/ssr/shell.ts
Normal file
29
src/lib/ssr/shell.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
|
import { fetchGroup, fetchGroups, fetchRoomAdmins } from "$lib/forum/groups";
|
||||||
|
import { fetchResources } from "$lib/forum/resources";
|
||||||
|
import { fetchPartials } from "$lib/forum/partials";
|
||||||
|
import type { ForumShell } from "$lib/forum/snapshot";
|
||||||
|
import { forumQuery } from "./relay";
|
||||||
|
|
||||||
|
// Everything the layout needs on every page: the group(s), the admin set,
|
||||||
|
// sidebar resources and partials.
|
||||||
|
export async function loadShell(): Promise<ForumShell> {
|
||||||
|
const [group, groups, resources, partials] = await Promise.all([
|
||||||
|
MODE === "simple" ? fetchGroup(forumQuery, GROUP_ID) : null,
|
||||||
|
MODE === "full" ? fetchGroups(forumQuery) : [],
|
||||||
|
fetchResources(forumQuery),
|
||||||
|
fetchPartials(forumQuery),
|
||||||
|
]);
|
||||||
|
const adminsByRoom =
|
||||||
|
MODE === "full"
|
||||||
|
? await fetchRoomAdmins(
|
||||||
|
forumQuery,
|
||||||
|
groups.map((g) => g.id),
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
const admins =
|
||||||
|
MODE === "full"
|
||||||
|
? [...new Set(Object.values(adminsByRoom).flat())]
|
||||||
|
: (group?.admins ?? []);
|
||||||
|
return { group, groups, adminsByRoom, admins, resources, partials };
|
||||||
|
}
|
||||||
121
src/lib/textMatch.ts
Normal file
121
src/lib/textMatch.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Shared matching rules for search highlighting and ranking. The query is
|
||||||
|
// tokenized on any non-alphanumeric character (so "-_|!./" all act as word
|
||||||
|
// separators, in the query and in the text), and a token matches where a
|
||||||
|
// word starts or ends with it — "id" marks "id" or "identify" but never the
|
||||||
|
// middle of "gravida", while still allowing prefix searches like the first
|
||||||
|
// characters of an npub.
|
||||||
|
|
||||||
|
export type Span = { start: number; end: number };
|
||||||
|
|
||||||
|
// Separator between adjacent tokens of a run: one or more non-word chars
|
||||||
|
const SEP = "[^\\p{L}\\p{N}]+";
|
||||||
|
|
||||||
|
export function tokenize(query: string): string[] {
|
||||||
|
return query
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter((t) => t.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordChar(ch: string | undefined): boolean {
|
||||||
|
return ch !== undefined && /[\p{L}\p{N}]/u.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start-with or end-with: the match must begin at a word start or finish at
|
||||||
|
// a word end.
|
||||||
|
function validBoundaries(text: string, span: Span): boolean {
|
||||||
|
return !isWordChar(text[span.start - 1]) || !isWordChar(text[span.end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(taken: Span[], s: Span): boolean {
|
||||||
|
return taken.some((t) => s.start < t.end && s.end > t.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All spans where the given tokens appear consecutively (any separators
|
||||||
|
// between them). Tokens are alphanumeric-only, so no regex escaping needed.
|
||||||
|
function findRuns(text: string, tokens: string[]): Span[] {
|
||||||
|
const re = new RegExp(tokens.join(SEP), "giu");
|
||||||
|
const out: Span[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const span = { start: m.index, end: m.index + m[0].length };
|
||||||
|
if (validBoundaries(text, span)) out.push(span);
|
||||||
|
if (m[0].length === 0) re.lastIndex++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occurrences of the whole query as one contiguous phrase
|
||||||
|
export function findPhraseSpans(text: string, query: string): Span[] {
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
if (tokens.length < 2) return [];
|
||||||
|
return findRuns(text, tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback matches: longest contiguous multi-token runs claim their spans
|
||||||
|
// first (each painted as one block), then single tokens outside them.
|
||||||
|
export function findMatchSpans(text: string, query: string): Span[] {
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
if (tokens.length === 0) return [];
|
||||||
|
const spans: Span[] = [];
|
||||||
|
for (let len = tokens.length; len >= 2; len--) {
|
||||||
|
for (let s = 0; s + len <= tokens.length; s++) {
|
||||||
|
for (const span of findRuns(text, tokens.slice(s, s + len))) {
|
||||||
|
if (!overlaps(spans, span)) spans.push(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of new Set(tokens)) {
|
||||||
|
for (const span of findRuns(text, [t])) {
|
||||||
|
if (!overlaps(spans, span)) spans.push(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spans.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank how well a text matches the query, and where to anchor an excerpt:
|
||||||
|
// the full query (1000) beats any shorter contiguous run (100 + length),
|
||||||
|
// which beats scattered singles (count of distinct tokens inside a window of
|
||||||
|
// `windowSpan` chars, always < 100).
|
||||||
|
export function bestMatch(
|
||||||
|
text: string,
|
||||||
|
query: string,
|
||||||
|
windowSpan: number,
|
||||||
|
): { anchor: number; score: number } {
|
||||||
|
const tokens = tokenize(query);
|
||||||
|
if (tokens.length === 0) return { anchor: -1, score: 0 };
|
||||||
|
|
||||||
|
for (let len = tokens.length; len >= 2; len--) {
|
||||||
|
for (let s = 0; s + len <= tokens.length; s++) {
|
||||||
|
const runs = findRuns(text, tokens.slice(s, s + len));
|
||||||
|
if (runs.length > 0)
|
||||||
|
return {
|
||||||
|
anchor: runs[0].start,
|
||||||
|
score: len === tokens.length ? 1000 : 100 + len,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singles: { start: number; token: string }[] = [];
|
||||||
|
for (const t of new Set(tokens)) {
|
||||||
|
for (const span of findRuns(text, [t]))
|
||||||
|
singles.push({ start: span.start, token: t });
|
||||||
|
}
|
||||||
|
if (singles.length === 0) return { anchor: -1, score: 0 };
|
||||||
|
singles.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
let anchor = singles[0].start;
|
||||||
|
let score = 0;
|
||||||
|
for (const o of singles) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of singles) {
|
||||||
|
if (p.start >= o.start && p.start <= o.start + windowSpan)
|
||||||
|
seen.add(p.token);
|
||||||
|
}
|
||||||
|
if (seen.size > score) {
|
||||||
|
score = seen.size;
|
||||||
|
anchor = o.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { anchor, score };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
import { queryForum, publishForum } from "$lib/relay";
|
import { queryForum, publishForum } from "$lib/relay";
|
||||||
import { threads as mockThreads } from "$lib/mock";
|
import { threads as mockThreads } from "$lib/mock";
|
||||||
|
|
@ -10,40 +11,34 @@ import {
|
||||||
buildPTagHints,
|
buildPTagHints,
|
||||||
} from "$lib/mentions";
|
} from "$lib/mentions";
|
||||||
import { convertForumUrls } from "$lib/linkify";
|
import { convertForumUrls } from "$lib/linkify";
|
||||||
|
import {
|
||||||
|
fetchThread,
|
||||||
|
type PostData,
|
||||||
|
type ThreadDetail,
|
||||||
|
} from "$lib/forum/thread";
|
||||||
|
|
||||||
|
export type { PostData };
|
||||||
|
|
||||||
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
||||||
|
|
||||||
export type PostData = {
|
|
||||||
id: string;
|
|
||||||
pubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ThreadDetail = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
labels: string[];
|
|
||||||
groupId: string; // the thread's NIP-29 group (its `h` tag)
|
|
||||||
op: PostData;
|
|
||||||
replies: PostData[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let detail = $state<ThreadDetail | null>(null);
|
let detail = $state<ThreadDetail | null>(null);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
// "notfound" means the relay returned nothing — either no such thread or it sits
|
// "notfound" means the relay returned nothing — either no such thread or it sits
|
||||||
// in a private group the current (non-member) user can't read.
|
// in a private group the current (non-member) user can't read.
|
||||||
let status = $state<"loading" | "ready" | "notfound">("loading");
|
let status = $state<"loading" | "ready" | "notfound">("loading");
|
||||||
|
|
||||||
|
// Until the live fetch lands, the server snapshot (if any) stands in. Its
|
||||||
|
// profiles stay as a base layer: the live ones arrive one by one.
|
||||||
export const threadDetailStore = {
|
export const threadDetailStore = {
|
||||||
get detail() {
|
get detail() {
|
||||||
return detail;
|
return detail ?? page.data.thread ?? null;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = page.data.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get status() {
|
get status() {
|
||||||
return status;
|
return status === "loading" && page.data.thread ? "ready" : status;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,39 +99,13 @@ export async function loadThread(id: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [threadEvents, replyEvents] = await Promise.all([
|
const next = await fetchThread(queryForum, id, GROUP_ID);
|
||||||
queryForum({ kinds: [11], ids: [id] }),
|
if (!next) {
|
||||||
queryForum({ kinds: [1111], "#E": [id] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const event = threadEvents[0];
|
|
||||||
if (!event) {
|
|
||||||
status = "notfound";
|
status = "notfound";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
detail = next;
|
||||||
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
[next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||||
|
|
||||||
detail = {
|
|
||||||
id: event.id,
|
|
||||||
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
|
||||||
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
|
||||||
groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID,
|
|
||||||
op: {
|
|
||||||
id: event.id,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
createdAt: event.created_at,
|
|
||||||
content: event.content,
|
|
||||||
},
|
|
||||||
replies: replies.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
pubkey: r.pubkey,
|
|
||||||
createdAt: r.created_at,
|
|
||||||
content: r.content,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
|
|
||||||
status = "ready";
|
status = "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
import { page } from "$app/state";
|
||||||
import type { Event } from "@nostr/tools/core";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import type { Filter } from "@nostr/tools/filter";
|
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
|
||||||
import { ensureForumRelay } from "$lib/relay";
|
import { ensureForumRelay } from "$lib/relay";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
import { relayQuery } from "$lib/forum/query";
|
||||||
|
import {
|
||||||
|
fetchThreadPage,
|
||||||
|
type SortMode,
|
||||||
|
type ThreadData,
|
||||||
|
} from "$lib/forum/threads";
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
export type { SortMode, ThreadData };
|
||||||
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
|
|
||||||
const WALK_MAX_ITERS = 12;
|
|
||||||
|
|
||||||
export type SortMode = "active" | "new";
|
|
||||||
|
|
||||||
export type ThreadData = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
labels: string[];
|
|
||||||
authorPubkey: string;
|
|
||||||
createdAt: number;
|
|
||||||
replyCount: number;
|
|
||||||
latestAt: number;
|
|
||||||
latestPubkey: string;
|
|
||||||
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
|
|
||||||
};
|
|
||||||
|
|
||||||
let threads = $state<ThreadData[]>([]);
|
let threads = $state<ThreadData[]>([]);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
|
|
@ -34,15 +22,34 @@ let cursor: number | null = null; // sort-key value of the last loaded thread
|
||||||
let snapshotAt = 0; // upper time bound, frozen at initial load
|
let snapshotAt = 0; // upper time bound, frozen at initial load
|
||||||
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
let reqId = 0; // supersedes in-flight loads when the sort/group changes
|
||||||
|
|
||||||
|
// Which listing the client asked for, and whether its first page is in.
|
||||||
|
let requested = $state<{ groupId: string; sort: SortMode } | null>(null);
|
||||||
|
let settled = $state(false);
|
||||||
|
|
||||||
|
// The server snapshot stands in until the live listing lands, but only for
|
||||||
|
// the listing that was requested: a different room or sort shows "Loading…".
|
||||||
|
function fallback() {
|
||||||
|
const s = page.data.threads;
|
||||||
|
if (!s) return null;
|
||||||
|
if (
|
||||||
|
requested &&
|
||||||
|
(requested.groupId !== s.groupId || requested.sort !== s.sort)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export const threadStore = {
|
export const threadStore = {
|
||||||
get threads() {
|
get threads() {
|
||||||
return threads;
|
if (settled || threads.length > 0) return threads;
|
||||||
|
return fallback()?.threads ?? threads;
|
||||||
},
|
},
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
const base = fallback()?.profiles;
|
||||||
|
return base ? { ...base, ...profiles } : profiles;
|
||||||
},
|
},
|
||||||
get exhausted() {
|
get exhausted() {
|
||||||
return exhausted;
|
return settled ? exhausted : (fallback()?.done ?? exhausted);
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
|
|
@ -59,238 +66,30 @@ async function loadProfile(pubkey: string) {
|
||||||
ingestNostrUser(user);
|
ingestNostrUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const events: Event[] = [];
|
|
||||||
const sub = relay.subscribe([filter], {
|
|
||||||
onevent(e) {
|
|
||||||
events.push(e);
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
sub.close();
|
|
||||||
resolve(events);
|
|
||||||
},
|
|
||||||
onclose() {
|
|
||||||
resolve(events);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadIdOf(e: Event): string | undefined {
|
|
||||||
return e.kind === 11 ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
type SliceItem = {
|
|
||||||
id: string;
|
|
||||||
latestAt: number;
|
|
||||||
latestPubkey: string;
|
|
||||||
op?: Event;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
|
|
||||||
// threads not already shown. The first event seen for a thread defines its
|
|
||||||
// activity timestamp. Returns the slice plus the cursor for the next call.
|
|
||||||
async function fetchActivitySlice(
|
|
||||||
relay: AbstractRelay,
|
|
||||||
groupId: string,
|
|
||||||
until: number,
|
|
||||||
exclude: Set<string>,
|
|
||||||
n: number,
|
|
||||||
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
|
|
||||||
const collected = new Map<string, SliceItem>();
|
|
||||||
let cur = until;
|
|
||||||
let done = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
|
|
||||||
const events = await querySync(relay, {
|
|
||||||
kinds: [11, 1111],
|
|
||||||
"#h": [groupId],
|
|
||||||
until: cur,
|
|
||||||
limit: WALK_LIMIT,
|
|
||||||
});
|
|
||||||
if (events.length === 0) {
|
|
||||||
done = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
events.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
let oldest = cur;
|
|
||||||
for (const e of events) {
|
|
||||||
oldest = Math.min(oldest, e.created_at);
|
|
||||||
const id = threadIdOf(e);
|
|
||||||
if (!id || exclude.has(id)) continue;
|
|
||||||
const seen = collected.get(id);
|
|
||||||
if (seen) {
|
|
||||||
// Grab the OP from the stream when a thread was first seen via a reply,
|
|
||||||
// so we avoid the by-id backfill the group relay truncates
|
|
||||||
if (!seen.op && e.kind === 11) seen.op = e;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (collected.size >= n) continue; // Page full; keep scanning for OPs
|
|
||||||
collected.set(id, {
|
|
||||||
id,
|
|
||||||
latestAt: e.created_at,
|
|
||||||
latestPubkey: e.pubkey,
|
|
||||||
op: e.kind === 11 ? e : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length < WALK_LIMIT) {
|
|
||||||
done = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (oldest >= cur) break; // No progress (single timestamp floods the window)
|
|
||||||
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
|
|
||||||
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
|
|
||||||
return { items, nextCursor, done };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chronological-by-creation slice: just OPs ordered by created_at. No reply
|
|
||||||
// data is needed to order them (stable cursor), keeping the path cheap; reply
|
|
||||||
// counts are still attached later via enrichment.
|
|
||||||
async function fetchNewSlice(
|
|
||||||
relay: AbstractRelay,
|
|
||||||
groupId: string,
|
|
||||||
until: number,
|
|
||||||
exclude: Set<string>,
|
|
||||||
n: number,
|
|
||||||
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
|
|
||||||
const ops = await querySync(relay, {
|
|
||||||
kinds: [11],
|
|
||||||
"#h": [groupId],
|
|
||||||
until,
|
|
||||||
limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor
|
|
||||||
});
|
|
||||||
ops.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
const fresh = ops.filter((e) => !exclude.has(e.id));
|
|
||||||
const slice = fresh.slice(0, n);
|
|
||||||
|
|
||||||
const items: SliceItem[] = slice.map((op) => ({
|
|
||||||
id: op.id,
|
|
||||||
latestAt: op.created_at,
|
|
||||||
latestPubkey: op.pubkey,
|
|
||||||
op,
|
|
||||||
}));
|
|
||||||
const done = ops.length < n + 10;
|
|
||||||
const last = slice[slice.length - 1] ?? ops[ops.length - 1];
|
|
||||||
const nextCursor = last ? last.created_at : null;
|
|
||||||
return { items, nextCursor, done };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply enrichment (exact counts + sampled repliers), bounded by the frozen
|
|
||||||
// snapshot. Isolated so the future creation-by-date view can skip it entirely.
|
|
||||||
async function enrichWithReplies(
|
|
||||||
relay: AbstractRelay,
|
|
||||||
groupId: string,
|
|
||||||
items: SliceItem[],
|
|
||||||
): Promise<Map<string, { count: number; repliers: string[] }>> {
|
|
||||||
const result = new Map<string, { count: number; repliers: string[] }>();
|
|
||||||
if (items.length === 0) return result;
|
|
||||||
|
|
||||||
const replies = await querySync(relay, {
|
|
||||||
kinds: [1111],
|
|
||||||
"#h": [groupId],
|
|
||||||
"#E": items.map((it) => it.id),
|
|
||||||
until: snapshotAt,
|
|
||||||
limit: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
|
|
||||||
for (const r of replies) {
|
|
||||||
const root = r.tags.find((t) => t[0] === "E")?.[1];
|
|
||||||
if (!root) continue;
|
|
||||||
let a = acc.get(root);
|
|
||||||
if (!a) {
|
|
||||||
a = { count: 0, pubkeys: new Set() };
|
|
||||||
acc.set(root, a);
|
|
||||||
}
|
|
||||||
a.count++;
|
|
||||||
a.pubkeys.add(r.pubkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const it of items) {
|
|
||||||
const a = acc.get(it.id);
|
|
||||||
const repliers = a
|
|
||||||
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
|
|
||||||
: [];
|
|
||||||
result.set(it.id, { count: a?.count ?? 0, repliers });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildThreads(
|
|
||||||
relay: AbstractRelay,
|
|
||||||
groupId: string,
|
|
||||||
items: SliceItem[],
|
|
||||||
): Promise<ThreadData[]> {
|
|
||||||
// Backfill OPs that fell outside the activity window. The group relay
|
|
||||||
// truncates multi-id queries, so fetch each one on its own.
|
|
||||||
const missing = items.filter((it) => !it.op);
|
|
||||||
if (missing.length > 0) {
|
|
||||||
const fetched = await Promise.all(
|
|
||||||
missing.map((it) =>
|
|
||||||
querySync(relay, { kinds: [11], "#h": [groupId], ids: [it.id] }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const byId = new Map<string, Event>();
|
|
||||||
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
|
|
||||||
for (const it of items) if (!it.op) it.op = byId.get(it.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enriched = await enrichWithReplies(relay, groupId, items);
|
|
||||||
|
|
||||||
const out: ThreadData[] = [];
|
|
||||||
for (const it of items) {
|
|
||||||
const op = it.op;
|
|
||||||
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
|
|
||||||
const e = enriched.get(it.id);
|
|
||||||
out.push({
|
|
||||||
id: it.id,
|
|
||||||
title: op.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
|
||||||
labels: op.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
|
||||||
authorPubkey: op.pubkey,
|
|
||||||
createdAt: op.created_at,
|
|
||||||
replyCount: e?.count ?? 0,
|
|
||||||
latestAt: it.latestAt,
|
|
||||||
latestPubkey: it.latestPubkey,
|
|
||||||
replierPubkeys: e?.repliers ?? [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runLoad(append: boolean, groupId: string) {
|
async function runLoad(append: boolean, groupId: string) {
|
||||||
const id = ++reqId;
|
const id = ++reqId;
|
||||||
if (append) loadingMore = true;
|
if (append) loadingMore = true;
|
||||||
else loading = true;
|
else loading = true;
|
||||||
|
// Paged loads drive their own subscriptions on the shared connection
|
||||||
const relay = await ensureForumRelay();
|
const q = relayQuery(await ensureForumRelay());
|
||||||
try {
|
try {
|
||||||
const until = append ? (cursor ?? snapshotAt) : snapshotAt;
|
const page = await fetchThreadPage(q, groupId, {
|
||||||
const exclude = new Set(threads.map((t) => t.id));
|
sort: sortMode,
|
||||||
const slice =
|
until: append ? (cursor ?? snapshotAt) : snapshotAt,
|
||||||
sortMode === "new"
|
snapshotAt,
|
||||||
? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE)
|
exclude: new Set(threads.map((t) => t.id)),
|
||||||
: await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE);
|
});
|
||||||
const built = await buildThreads(relay, groupId, slice.items);
|
|
||||||
|
|
||||||
if (id !== reqId) return; // Superseded by a newer load — discard results
|
if (id !== reqId) return; // Superseded by a newer load — discard results
|
||||||
|
threads = append ? [...threads, ...page.threads] : page.threads;
|
||||||
threads = append ? [...threads, ...built] : built;
|
cursor = page.nextCursor ?? cursor;
|
||||||
cursor = slice.nextCursor ?? cursor;
|
exhausted = page.done || page.threads.length === 0;
|
||||||
exhausted = slice.done || built.length === 0;
|
settled = true;
|
||||||
|
for (const t of page.threads) {
|
||||||
for (const t of built) {
|
|
||||||
loadProfile(t.authorPubkey);
|
loadProfile(t.authorPubkey);
|
||||||
loadProfile(t.latestPubkey);
|
loadProfile(t.latestPubkey);
|
||||||
for (const p of t.replierPubkeys) loadProfile(p);
|
for (const p of t.replierPubkeys) loadProfile(p);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// shared forum connection is long-lived — don't close it here
|
|
||||||
if (id === reqId) {
|
if (id === reqId) {
|
||||||
if (append) loadingMore = false;
|
if (append) loadingMore = false;
|
||||||
else loading = false;
|
else loading = false;
|
||||||
|
|
@ -300,6 +99,8 @@ async function runLoad(append: boolean, groupId: string) {
|
||||||
|
|
||||||
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
export async function loadThreads(groupId: string, sort: SortMode = "active") {
|
||||||
sortMode = sort;
|
sortMode = sort;
|
||||||
|
requested = { groupId, sort };
|
||||||
|
settled = false;
|
||||||
threads = [];
|
threads = [];
|
||||||
cursor = null;
|
cursor = null;
|
||||||
exhausted = false;
|
exhausted = false;
|
||||||
|
|
|
||||||
81
src/lib/userPosts.svelte.ts
Normal file
81
src/lib/userPosts.svelte.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
|
import { queryForum } from "$lib/relay";
|
||||||
|
import {
|
||||||
|
fetchUserPosts,
|
||||||
|
fetchUserPostPage,
|
||||||
|
nextCursor,
|
||||||
|
type UserPostKind,
|
||||||
|
type UserPosts,
|
||||||
|
} from "$lib/forum/userPosts";
|
||||||
|
|
||||||
|
export type { UserPosts, UserPostKind };
|
||||||
|
|
||||||
|
const scope = () => (MODE === "simple" ? GROUP_ID : undefined);
|
||||||
|
|
||||||
|
let pubkey = $state<string | null>(null);
|
||||||
|
let posts = $state<UserPosts | null>(null);
|
||||||
|
let loadingMore = $state<Record<UserPostKind, boolean>>({
|
||||||
|
roots: false,
|
||||||
|
replies: false,
|
||||||
|
});
|
||||||
|
let initial: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Posts of the profile last loaded. Consumers check `pubkey` against the
|
||||||
|
// page's, so a stale result never shows under another user's name.
|
||||||
|
export const userPostsStore = {
|
||||||
|
get pubkey() {
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
get posts() {
|
||||||
|
return posts;
|
||||||
|
},
|
||||||
|
get loadingMore() {
|
||||||
|
return loadingMore;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadUserPosts(pk: string): Promise<void> {
|
||||||
|
pubkey = pk;
|
||||||
|
posts = null;
|
||||||
|
loadingMore = { roots: false, replies: false };
|
||||||
|
initial = (async () => {
|
||||||
|
let result: UserPosts = {
|
||||||
|
roots: { items: [], done: true },
|
||||||
|
replies: { items: [], done: true },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
result = await fetchUserPosts(queryForum, pk, scope());
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[profile] failed to load posts", e);
|
||||||
|
}
|
||||||
|
if (pubkey === pk) posts = result;
|
||||||
|
})();
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the next page of one column. Waits for the initial load when the
|
||||||
|
// page still shows the server snapshot.
|
||||||
|
export async function loadMoreUserPosts(kind: UserPostKind): Promise<void> {
|
||||||
|
const pk = pubkey;
|
||||||
|
if (!pk || loadingMore[kind]) return;
|
||||||
|
loadingMore[kind] = true;
|
||||||
|
try {
|
||||||
|
await initial;
|
||||||
|
const current = posts?.[kind];
|
||||||
|
if (!posts || !current || current.done || pubkey !== pk) return;
|
||||||
|
const next = await fetchUserPostPage(queryForum, pk, kind, {
|
||||||
|
groupId: scope(),
|
||||||
|
until: nextCursor(current),
|
||||||
|
exclude: new Set(current.items.map((p) => p.id)),
|
||||||
|
});
|
||||||
|
if (pubkey !== pk) return;
|
||||||
|
posts[kind] = {
|
||||||
|
items: [...current.items, ...next.items],
|
||||||
|
done: next.done,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[profile] failed to load more posts", e);
|
||||||
|
} finally {
|
||||||
|
loadingMore[kind] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/version.ts
Normal file
23
src/lib/version.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { MODE, RELAY_URL, SSR_ENABLED } from "$lib/config";
|
||||||
|
|
||||||
|
export const VERSION = __SQUALK_VERSION__;
|
||||||
|
export const COMMIT = __SQUALK_COMMIT__;
|
||||||
|
|
||||||
|
const REPO = "https://github.com/dtonon/squalk";
|
||||||
|
// A release build links to its GitHub release, anything else to its commit
|
||||||
|
export const VERSION_URL = VERSION.includes("+")
|
||||||
|
? `${REPO}/commit/${COMMIT}`
|
||||||
|
: `${REPO}/releases/tag/v${VERSION}`;
|
||||||
|
|
||||||
|
// Plain-text block to paste in a bug report
|
||||||
|
export function diagnostics(loginMethod: string | null): string {
|
||||||
|
return [
|
||||||
|
`Squalk ${VERSION}`,
|
||||||
|
`Commit: ${COMMIT || "unknown"}`,
|
||||||
|
`Mode: ${MODE}`,
|
||||||
|
`SSR: ${SSR_ENABLED ? "yes" : "no"}`,
|
||||||
|
`Relay: ${RELAY_URL}`,
|
||||||
|
`Login: ${loginMethod ?? "none"}`,
|
||||||
|
`Browser: ${navigator.userAgent}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.svg";
|
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
||||||
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
||||||
|
|
@ -12,10 +11,13 @@
|
||||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||||
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
||||||
import Toast from "$lib/components/Toast.svelte";
|
import Toast from "$lib/components/Toast.svelte";
|
||||||
|
import RelayGate from "$lib/components/RelayGate.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { afterNavigate } from "$app/navigation";
|
import { afterNavigate } from "$app/navigation";
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession, loginMethod } from "$lib/auth.svelte";
|
||||||
|
import { showToast } from "$lib/toast.svelte";
|
||||||
|
import { VERSION, VERSION_URL, diagnostics } from "$lib/version";
|
||||||
import { loadGroup } from "$lib/group.svelte";
|
import { loadGroup } from "$lib/group.svelte";
|
||||||
import { ensureMembershipChecked } from "$lib/join.svelte";
|
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||||
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
||||||
|
|
@ -24,17 +26,33 @@
|
||||||
import { loadRoomAdmins } from "$lib/admins.svelte";
|
import { loadRoomAdmins } from "$lib/admins.svelte";
|
||||||
import { seedProfiles } from "$lib/profiles.svelte";
|
import { seedProfiles } from "$lib/profiles.svelte";
|
||||||
import { startChat } from "$lib/chat.svelte";
|
import { startChat } from "$lib/chat.svelte";
|
||||||
|
import {
|
||||||
|
startNotifications,
|
||||||
|
stopNotifications,
|
||||||
|
} from "$lib/notifications.svelte";
|
||||||
import { resetForumConnection } from "$lib/relay";
|
import { resetForumConnection } from "$lib/relay";
|
||||||
|
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||||
import {
|
import {
|
||||||
MODE,
|
MODE,
|
||||||
ACCENT_COLOR,
|
ACCENT_COLOR,
|
||||||
SECONDARY_COLOR,
|
SECONDARY_COLOR,
|
||||||
SEARCH_ENABLED,
|
SEARCH_ENABLED,
|
||||||
|
FAVICON,
|
||||||
|
RELAY_URL,
|
||||||
} from "$lib/config";
|
} from "$lib/config";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
async function copyDiagnostics() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(diagnostics(loginMethod()));
|
||||||
|
showToast("Version info copied, paste it in your bug report");
|
||||||
|
} catch {
|
||||||
|
showToast("Could not copy to clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Optional env overrides for the theme colors. Set as inline custom props on
|
// Optional env overrides for the theme colors. Set as inline custom props on
|
||||||
// <html> so they outrank the @theme `:root` defaults; hover shades are derived
|
// <html> so they outrank the @theme `:root` defaults; hover shades are derived
|
||||||
// by darkening the base 15% so admins only set one value per color.
|
// by darkening the base 15% so admins only set one value per color.
|
||||||
|
|
@ -62,27 +80,52 @@
|
||||||
// The main column is its own scroll container (overflow-y-auto) and persists
|
// The main column is its own scroll container (overflow-y-auto) and persists
|
||||||
// across navigations, so SvelteKit's window-only scroll restoration never
|
// across navigations, so SvelteKit's window-only scroll restoration never
|
||||||
// resets it. Scroll it back to top on forward navigation; leave back/forward
|
// resets it. Scroll it back to top on forward navigation; leave back/forward
|
||||||
// (popstate) alone so returning to a listing keeps its place.
|
// (popstate) alone so returning to a listing keeps its place, and hash
|
||||||
|
// targets (#post-…) alone so the page can place the anchored post itself.
|
||||||
let mainEl = $state<HTMLElement | null>(null);
|
let mainEl = $state<HTMLElement | null>(null);
|
||||||
afterNavigate((nav) => {
|
afterNavigate((nav) => {
|
||||||
if (nav.type !== "popstate") mainEl?.scrollTo(0, 0);
|
if (nav.type !== "popstate" && !nav.to?.url.hash) mainEl?.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
// Nothing is fetched until the relay confirms it will serve this visitor:
|
||||||
|
// the probe runs once the stored signer is in place (restoreSession sets it
|
||||||
|
// synchronously, before its profile fetch), and every probe that ends open
|
||||||
|
// (re)loads the forum below.
|
||||||
|
onMount(() => {
|
||||||
|
restoreSession();
|
||||||
|
probeRelayAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadForum() {
|
||||||
loadResources();
|
loadResources();
|
||||||
loadPartials();
|
loadPartials();
|
||||||
const tasks = [restoreSession(), loadGroup()];
|
const tasks = [loadGroup()];
|
||||||
if (mode === "full") tasks.push(loadGroups());
|
if (mode === "full") tasks.push(loadGroups());
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
// Resources are filtered by the admin set; in full mode that means every
|
// Resources are filtered by the admin set; in full mode that means every
|
||||||
// room's admins, needed on every page for the sidebar.
|
// room's admins, needed on every page for the sidebar.
|
||||||
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
|
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
|
||||||
seedProfiles(auth.user?.pubkey ?? null);
|
seedProfiles(auth.user?.pubkey ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies/mentions follow the logged-in user. Keyed on the user rather than
|
||||||
|
// on loadForum: a restored session resolves its profile after the probe.
|
||||||
|
$effect(() => {
|
||||||
|
const pk = auth.user?.pubkey ?? null;
|
||||||
|
if (relayAccess.state !== "open") return;
|
||||||
|
if (pk) startNotifications(pk);
|
||||||
|
else stopNotifications();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload the room views when the user logs in or out: a logged-in member sees
|
// Each completed probe that finds the relay open reloads the room views: on
|
||||||
// their private/hidden rooms, so the listing must be re-fetched over the now
|
// first load, after a login (private/hidden rooms appear) or logout (they
|
||||||
// (de)authenticated connection. sessionEpoch only bumps on explicit
|
// vanish), and after joining a members-only relay.
|
||||||
|
$effect(() => {
|
||||||
|
relayAccess.probes;
|
||||||
|
if (relayAccess.state === "open") untrack(loadForum);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-probe when the user logs in or out. sessionEpoch only bumps on explicit
|
||||||
// login/logout, never on the silent restore that onMount already covers.
|
// login/logout, never on the silent restore that onMount already covers.
|
||||||
let lastEpoch = -1;
|
let lastEpoch = -1;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -91,17 +134,12 @@
|
||||||
const first = lastEpoch === -1;
|
const first = lastEpoch === -1;
|
||||||
const loggedOut = auth.user === null;
|
const loggedOut = auth.user === null;
|
||||||
lastEpoch = epoch;
|
lastEpoch = epoch;
|
||||||
if (first) return; // initial mount: onMount already loaded everything
|
if (first) return;
|
||||||
// On logout, drop the authenticated connection so the relay stops serving
|
// On logout, drop the authenticated connection so the relay stops serving
|
||||||
// the previous user's private rooms; login reuses the open connection,
|
// the previous user's private rooms; login reuses the open connection,
|
||||||
// which ensureForumReady authenticates via its stored challenge.
|
// which ensureForumReady authenticates via its stored challenge.
|
||||||
if (loggedOut) resetForumConnection();
|
if (loggedOut) resetForumConnection();
|
||||||
loadGroup();
|
probeRelayAccess();
|
||||||
if (mode === "full") {
|
|
||||||
loadGroups().then(() =>
|
|
||||||
loadRoomAdmins(groupsStore.list.map((g) => g.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Full mode: the room route defines the active group. Thread pages set it
|
// Full mode: the room route defines the active group. Thread pages set it
|
||||||
|
|
@ -157,128 +195,190 @@
|
||||||
|
|
||||||
// On mobile a route change should always land on the forum pane, so opening
|
// On mobile a route change should always land on the forum pane, so opening
|
||||||
// a thread or room from the menu never leaves the user stranded on chat.
|
// a thread or room from the menu never leaves the user stranded on chat.
|
||||||
|
// A chat message link (#chat-…, e.g. from a notification) opens the chat pane.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
page.url.pathname;
|
page.url.pathname;
|
||||||
mobileView = "forum";
|
mobileView = page.url.hash.startsWith("#chat-") ? "chat" : "forum";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={FAVICON} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<!-- The server and the pre-probe client render the forum; the gate only
|
||||||
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
|
replaces it once a probe confirms the relay refuses this visitor. -->
|
||||||
'chat'
|
{#if relayAccess.state !== "open" && relayAccess.state !== "checking"}
|
||||||
? 'h-dvh overflow-hidden'
|
<RelayGate />
|
||||||
: ''}"
|
{:else}
|
||||||
>
|
|
||||||
<Navbar onMenuToggle={() => (menuOpen = true)} />
|
|
||||||
<div
|
<div
|
||||||
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
|
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
|
||||||
'chat'
|
'chat'
|
||||||
? 'flex flex-1 overflow-hidden'
|
? 'h-dvh overflow-hidden'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
|
<Navbar onMenuToggle={() => (menuOpen = true)} />
|
||||||
<main
|
<div
|
||||||
bind:this={mainEl}
|
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
|
||||||
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
|
'chat'
|
||||||
? 'md:flex-[3]'
|
? 'flex flex-1 overflow-hidden'
|
||||||
: 'md:flex-1'}
|
: ''}"
|
||||||
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
|
|
||||||
>
|
>
|
||||||
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
|
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
|
||||||
|
<main
|
||||||
|
bind:this={mainEl}
|
||||||
|
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
|
||||||
|
? 'md:flex-[3]'
|
||||||
|
: 'md:flex-1'}
|
||||||
|
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
|
||||||
|
>
|
||||||
|
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
|
||||||
it first and it reappears once the top is reached. Desktop only. -->
|
it first and it reappears once the top is reached. Desktop only. -->
|
||||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
|
||||||
<div
|
|
||||||
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 dark:bg-neutral-900"
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{#if showChat}
|
|
||||||
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
|
||||||
<ChatSidebar
|
|
||||||
expanded={chatExpanded}
|
|
||||||
onToggle={() => (chatExpanded = !chatExpanded)}
|
|
||||||
mobileActive={mobileView === "chat"}
|
|
||||||
/>
|
|
||||||
{:else if showDiscussions}
|
|
||||||
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
|
|
||||||
Hidden on mobile — it's supplementary to the main column. -->
|
|
||||||
<div
|
|
||||||
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||||
<div
|
<div
|
||||||
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
|
class="flex min-h-[calc(100dvh_-_4rem)] flex-col bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-[calc(100%_-_1.5rem)] md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 md:pb-6 dark:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<LatestDiscussions />
|
<div class="flex-1">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
<footer
|
||||||
|
class="mt-auto pt-16 pb-4 text-center text-[90%] text-neutral-500/70 dark:text-neutral-400/70"
|
||||||
|
>
|
||||||
|
Hosting relay is {RELAY_URL.replace(/^wss?:\/\//, "")}
|
||||||
|
<br />
|
||||||
|
This forum is built on
|
||||||
|
<a
|
||||||
|
href="https://njump.me"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
|
>Nostr</a
|
||||||
|
>
|
||||||
|
with
|
||||||
|
<a
|
||||||
|
href="https://github.com/dtonon/squalk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
|
>Squalk</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={VERSION_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
|
>v{VERSION}</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyDiagnostics}
|
||||||
|
aria-label="Copy version info for bug reports"
|
||||||
|
title="Copy version info for bug reports"
|
||||||
|
class="focus-visible:ring-accent inline-flex cursor-pointer items-center rounded align-text-bottom hover:text-neutral-700 focus-visible:ring-2 focus-visible:outline-none dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
|
<path
|
||||||
|
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
{#if showChat}
|
||||||
|
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
||||||
|
<ChatSidebar
|
||||||
|
expanded={chatExpanded}
|
||||||
|
onToggle={() => (chatExpanded = !chatExpanded)}
|
||||||
|
mobileActive={mobileView === "chat"}
|
||||||
|
/>
|
||||||
|
{:else if showDiscussions}
|
||||||
|
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
|
||||||
|
Hidden on mobile — it's supplementary to the main column. -->
|
||||||
|
<div
|
||||||
|
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||||
|
<div
|
||||||
|
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<LatestDiscussions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if showChat}
|
||||||
|
<nav
|
||||||
|
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
|
||||||
|
aria-label="Switch view"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (mobileView = "forum")}
|
||||||
|
aria-pressed={mobileView === "forum"}
|
||||||
|
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
||||||
|
{mobileView === 'forum'
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-neutral-500 dark:text-neutral-400'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Forum
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (mobileView = "chat")}
|
||||||
|
aria-pressed={mobileView === "chat"}
|
||||||
|
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
||||||
|
{mobileView === 'chat'
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-neutral-500 dark:text-neutral-400'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if showChat}
|
{/if}
|
||||||
<nav
|
|
||||||
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
|
|
||||||
aria-label="Switch view"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (mobileView = "forum")}
|
|
||||||
aria-pressed={mobileView === "forum"}
|
|
||||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
|
||||||
{mobileView === 'forum'
|
|
||||||
? 'text-accent'
|
|
||||||
: 'text-neutral-500 dark:text-neutral-400'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.8"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Forum
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (mobileView = "chat")}
|
|
||||||
aria-pressed={mobileView === "chat"}
|
|
||||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
|
||||||
{mobileView === 'chat'
|
|
||||||
? 'text-accent'
|
|
||||||
: 'text-neutral-500 dark:text-neutral-400'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.8"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,15 @@
|
||||||
export const ssr = false;
|
import { browser } from "$app/environment";
|
||||||
|
import { CACHE_CONTROL, SSR_ENABLED } from "$lib/config";
|
||||||
|
import { snapshot, type ForumShell } from "$lib/forum/snapshot";
|
||||||
|
|
||||||
|
export const ssr = SSR_ENABLED;
|
||||||
|
|
||||||
|
// Runs for every page, so the cache policy for rendered HTML is set here. A
|
||||||
|
// page rendered without its shell (relay down or refusing anonymous reads) is
|
||||||
|
// an empty placeholder the client fills in, not worth keeping in a shared cache.
|
||||||
|
export async function load({ fetch, setHeaders }) {
|
||||||
|
const shell = await snapshot<ForumShell>(fetch, "/api/shell");
|
||||||
|
if (!browser)
|
||||||
|
setHeaders({ "cache-control": shell ? CACHE_CONTROL : "no-store" });
|
||||||
|
return { shell };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,26 @@
|
||||||
import { partialsStore } from "$lib/partials.svelte";
|
import { partialsStore } from "$lib/partials.svelte";
|
||||||
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config";
|
||||||
import SearchInline from "$lib/components/SearchInline.svelte";
|
import SearchInline from "$lib/components/SearchInline.svelte";
|
||||||
|
import Meta, { siteName } from "$lib/components/Meta.svelte";
|
||||||
|
import { groupStore } from "$lib/group.svelte";
|
||||||
|
import { excerpt } from "$lib/seo";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
const partial = $derived(partialsStore.get("home"));
|
const partial = $derived(partialsStore.get("home"));
|
||||||
|
|
||||||
|
const description = $derived(
|
||||||
|
partial
|
||||||
|
? excerpt(partial.content)
|
||||||
|
: MODE === "simple"
|
||||||
|
? (groupStore.data?.about ?? "")
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
// A partial's leading image doubles as the social preview.
|
||||||
|
const image = $derived(
|
||||||
|
partial?.content.match(/^https?:\/\/\S+\.(?:png|jpe?g|webp|gif)$/im)?.[0] ??
|
||||||
|
groupStore.data?.picture,
|
||||||
|
);
|
||||||
|
|
||||||
// Full mode: load per-room activity + recent threads once the rooms are known.
|
// Full mode: load per-room activity + recent threads once the rooms are known.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (MODE !== "full") return;
|
if (MODE !== "full") return;
|
||||||
|
|
@ -35,9 +51,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta
|
||||||
<title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
|
title={siteName() || (MODE === "full" ? "Rooms" : "Discussions")}
|
||||||
</svelte:head>
|
{description}
|
||||||
|
{image}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet pic(a: { name: string; picture?: string })}
|
{#snippet pic(a: { name: string; picture?: string })}
|
||||||
{#if a.picture}
|
{#if a.picture}
|
||||||
|
|
|
||||||
20
src/routes/+page.ts
Normal file
20
src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
|
import {
|
||||||
|
snapshot,
|
||||||
|
type OverviewSnapshot,
|
||||||
|
type ThreadsSnapshot,
|
||||||
|
} from "$lib/forum/snapshot";
|
||||||
|
|
||||||
|
// Simple mode: the single group's listing. Full mode: the room overview.
|
||||||
|
export async function load({ fetch, url }) {
|
||||||
|
if (MODE === "simple") {
|
||||||
|
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||||
|
const threads = await snapshot<ThreadsSnapshot>(
|
||||||
|
fetch,
|
||||||
|
`/api/threads/${GROUP_ID}${sort}`,
|
||||||
|
);
|
||||||
|
return { threads, profiles: threads?.profiles ?? null };
|
||||||
|
}
|
||||||
|
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||||
|
return { overview, profiles: overview?.profiles ?? null };
|
||||||
|
}
|
||||||
11
src/routes/api/contacts/+server.ts
Normal file
11
src/routes/api/contacts/+server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
|
import { profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||||
|
const shell = await loadShell();
|
||||||
|
return json({ profiles: await fetchProfiles(profileQuery, shell.admins) });
|
||||||
|
});
|
||||||
22
src/routes/api/overview/+server.ts
Normal file
22
src/routes/api/overview/+server.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
|
import { fetchOverview, overviewPubkeys } from "$lib/forum/overview";
|
||||||
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import type { OverviewSnapshot } from "$lib/forum/snapshot";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
// Room activity and the latest discussions: every room in full mode, the
|
||||||
|
// single group in simple mode (mirrors LatestDiscussions' own room set).
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async () => {
|
||||||
|
const roomIds =
|
||||||
|
MODE === "full"
|
||||||
|
? (await fetchGroups(forumQuery)).map((g) => g.id)
|
||||||
|
: [GROUP_ID];
|
||||||
|
const overview = await fetchOverview(forumQuery, roomIds);
|
||||||
|
const profiles = await fetchProfiles(profileQuery, overviewPubkeys(overview));
|
||||||
|
const body: OverviewSnapshot = { roomIds, ...overview, profiles };
|
||||||
|
return json(body);
|
||||||
|
});
|
||||||
21
src/routes/api/profile/[id]/+server.ts
Normal file
21
src/routes/api/profile/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { error, json } from "@sveltejs/kit";
|
||||||
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
|
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import { fetchUserPosts } from "$lib/forum/userPosts";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||||
|
const pubkey = decodeProfileId(params.id);
|
||||||
|
if (!pubkey) error(404, "Not found");
|
||||||
|
const [profiles, posts] = await Promise.all([
|
||||||
|
fetchProfiles(profileQuery, [pubkey]),
|
||||||
|
fetchUserPosts(
|
||||||
|
forumQuery,
|
||||||
|
pubkey,
|
||||||
|
MODE === "simple" ? GROUP_ID : undefined,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return json({ pubkey, posts, profiles });
|
||||||
|
});
|
||||||
8
src/routes/api/shell/+server.ts
Normal file
8
src/routes/api/shell/+server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async () =>
|
||||||
|
json(await loadShell()),
|
||||||
|
);
|
||||||
15
src/routes/api/thread/[id]/+server.ts
Normal file
15
src/routes/api/thread/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { error, json } from "@sveltejs/kit";
|
||||||
|
import { GROUP_ID } from "$lib/config";
|
||||||
|
import { fetchThread, threadAuthors } from "$lib/forum/thread";
|
||||||
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async ({ params }) => {
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found");
|
||||||
|
const thread = await fetchThread(forumQuery, params.id, GROUP_ID);
|
||||||
|
if (!thread) error(404, "Not found");
|
||||||
|
const profiles = await fetchProfiles(profileQuery, threadAuthors(thread));
|
||||||
|
return json({ thread, profiles });
|
||||||
|
});
|
||||||
45
src/routes/api/threads/[group]/+server.ts
Normal file
45
src/routes/api/threads/[group]/+server.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { error, json } from "@sveltejs/kit";
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
|
import {
|
||||||
|
fetchThreadPage,
|
||||||
|
threadPagePubkeys,
|
||||||
|
type SortMode,
|
||||||
|
} from "$lib/forum/threads";
|
||||||
|
import { fetchProfiles } from "$lib/forum/profiles";
|
||||||
|
import type { ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { forumQuery, profileQuery } from "$lib/ssr/relay";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
// First page of a group's listing. Unknown rooms are a 404 so crawlers don't
|
||||||
|
// index empty shells; private rooms are simply empty to the anonymous server.
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async ({ params, url }) => {
|
||||||
|
const groupId = params.group;
|
||||||
|
if (MODE === "simple") {
|
||||||
|
if (groupId !== GROUP_ID) error(404, "Not found");
|
||||||
|
} else {
|
||||||
|
const groups = await fetchGroups(forumQuery);
|
||||||
|
if (!groups.some((g) => g.id === groupId)) error(404, "Not found");
|
||||||
|
}
|
||||||
|
const sort: SortMode =
|
||||||
|
url.searchParams.get("sort") === "new" ? "new" : "active";
|
||||||
|
const snapshotAt = Math.floor(Date.now() / 1000);
|
||||||
|
const page = await fetchThreadPage(forumQuery, groupId, {
|
||||||
|
sort,
|
||||||
|
until: snapshotAt,
|
||||||
|
snapshotAt,
|
||||||
|
});
|
||||||
|
const profiles = await fetchProfiles(
|
||||||
|
profileQuery,
|
||||||
|
threadPagePubkeys(page.threads),
|
||||||
|
);
|
||||||
|
const body: ThreadsSnapshot = {
|
||||||
|
groupId,
|
||||||
|
sort,
|
||||||
|
snapshotAt,
|
||||||
|
...page,
|
||||||
|
profiles,
|
||||||
|
};
|
||||||
|
return json(body);
|
||||||
|
});
|
||||||
|
|
@ -11,10 +11,14 @@
|
||||||
import {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
ensureProfile,
|
ensureProfile,
|
||||||
|
entryFromUser,
|
||||||
type ProfileEntry,
|
type ProfileEntry,
|
||||||
} from "$lib/profiles.svelte";
|
} from "$lib/profiles.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import { partialsStore } from "$lib/partials.svelte";
|
import { partialsStore } from "$lib/partials.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
|
||||||
const partial = $derived(partialsStore.get("contacts"));
|
const partial = $derived(partialsStore.get("contacts"));
|
||||||
|
|
||||||
|
|
@ -48,11 +52,17 @@
|
||||||
|
|
||||||
const contacts = $derived<Contact[]>(
|
const contacts = $derived<Contact[]>(
|
||||||
adminPubkeys.list.map((pk) => {
|
adminPubkeys.list.map((pk) => {
|
||||||
const entry = profileStore.profiles.get(pk);
|
const entry = profileStore.profiles.get(pk) ?? snapshotEntry(pk);
|
||||||
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Server-rendered profile, used until the live one is fetched.
|
||||||
|
function snapshotEntry(pk: string): ProfileEntry | undefined {
|
||||||
|
const u = page.data.profiles?.[pk];
|
||||||
|
return u ? entryFromUser(u) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(c: Contact): string {
|
function displayName(c: Contact): string {
|
||||||
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
|
||||||
}
|
}
|
||||||
|
|
@ -66,10 +76,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta title="Contacts" description="Admins and contacts for this community." />
|
||||||
<title>Contacts</title>
|
|
||||||
<meta name="description" content="Admins and contacts for this community." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
|
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>
|
||||||
|
|
@ -81,34 +88,49 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
|
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
{:else if contacts.length === 0}
|
{:else if contacts.length === 0}
|
||||||
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">No admins listed.</p>
|
<p class="py-6 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
No admins listed.
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="mt-2 space-y-3">
|
<ul class="mt-2 space-y-3">
|
||||||
{#each contacts as c (c.pubkey)}
|
{#each contacts as c (c.pubkey)}
|
||||||
<li
|
<li
|
||||||
class="rounded-lg border border-neutral-100 dark:border-neutral-800 p-4 shadow-sm"
|
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{#if c.entry?.picture}
|
<a
|
||||||
<img
|
href={profilePath(c.pubkey)}
|
||||||
src={c.entry.picture}
|
class="shrink-0"
|
||||||
alt=""
|
aria-hidden="true"
|
||||||
class="h-14 w-14 shrink-0 rounded-full object-cover"
|
tabindex="-1"
|
||||||
/>
|
>
|
||||||
{:else}
|
{#if c.entry?.picture}
|
||||||
<span
|
<img
|
||||||
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"
|
src={c.entry.picture}
|
||||||
>
|
alt=""
|
||||||
{displayName(c)[0].toUpperCase()}
|
class="h-14 w-14 rounded-full object-cover"
|
||||||
</span>
|
/>
|
||||||
{/if}
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{displayName(c)[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100">
|
<p
|
||||||
{displayName(c)}
|
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>
|
</p>
|
||||||
{#if c.entry?.nip05}
|
{#if c.entry?.nip05}
|
||||||
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||||
|
|
@ -117,19 +139,19 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://nostr.basspistol.org/users/{c.npub}"
|
href={profilePath(c.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent shrink-0 text-sm hover:underline"
|
class="text-accent shrink-0 text-sm hover:underline"
|
||||||
>
|
>
|
||||||
View profile ↗
|
View profile
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 md:pl-18">
|
<div class="mt-3 md:pl-18">
|
||||||
{#if c.entry?.about}
|
{#if c.entry?.about}
|
||||||
<p class="whitespace-pre-line text-neutral-600 dark:text-neutral-400">
|
<p
|
||||||
|
class="whitespace-pre-line text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
{c.entry.about}
|
{c.entry.about}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -147,7 +169,10 @@
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.entry?.lud16}
|
{#if c.entry?.lud16}
|
||||||
<span class="text-neutral-500 dark:text-neutral-400" title="Lightning address">
|
<span
|
||||||
|
class="text-neutral-500 dark:text-neutral-400"
|
||||||
|
title="Lightning address"
|
||||||
|
>
|
||||||
⚡ {c.entry.lud16}
|
⚡ {c.entry.lud16}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -158,7 +183,9 @@
|
||||||
{@const rooms = roomsOf(c.pubkey)}
|
{@const rooms = roomsOf(c.pubkey)}
|
||||||
{#if rooms.length > 0}
|
{#if rooms.length > 0}
|
||||||
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||||
<span class="text-neutral-400 dark:text-neutral-500">Manages:</span>
|
<span class="text-neutral-400 dark:text-neutral-500"
|
||||||
|
>Manages:</span
|
||||||
|
>
|
||||||
{#each rooms as r, i}<a
|
{#each rooms as r, i}<a
|
||||||
href="/room/{r.id}"
|
href="/room/{r.id}"
|
||||||
class="text-accent hover:underline">{r.name}</a
|
class="text-accent hover:underline">{r.name}</a
|
||||||
|
|
|
||||||
17
src/routes/contacts/+page.ts
Normal file
17
src/routes/contacts/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import {
|
||||||
|
snapshot,
|
||||||
|
type OverviewSnapshot,
|
||||||
|
type Profiles,
|
||||||
|
} from "$lib/forum/snapshot";
|
||||||
|
|
||||||
|
// Admin profiles for the cards, plus the latest-discussions panel beside them.
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
const [contacts, overview] = await Promise.all([
|
||||||
|
snapshot<{ profiles: Profiles }>(fetch, "/api/contacts"),
|
||||||
|
snapshot<OverviewSnapshot>(fetch, "/api/overview"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
overview,
|
||||||
|
profiles: { ...overview?.profiles, ...contacts?.profiles },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,15 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search terms painted on thread pages via the CSS Custom Highlight API,
|
||||||
|
matching the <mark> tint used in the search results. */
|
||||||
|
::highlight(search-terms) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-secondary) 40%, transparent);
|
||||||
|
}
|
||||||
|
.dark ::highlight(search-terms) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-secondary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
||||||
typography plugin's em-based heading margins; the first block stays flush. */
|
typography plugin's em-based heading margins; the first block stays flush. */
|
||||||
.prose :is(h1, h2, h3, h4, h5, h6) {
|
.prose :is(h1, h2, h3, h4, h5, h6) {
|
||||||
|
|
@ -76,6 +85,12 @@ body {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
.prose :is(h2) {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
.prose :is(h3) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
.prose :is(p) {
|
.prose :is(p) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -104,13 +119,3 @@ body {
|
||||||
--color-secondary: #ffaf25;
|
--color-secondary: #ffaf25;
|
||||||
--color-secondary-hover: #e69e1a;
|
--color-secondary-hover: #e69e1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* setto */
|
|
||||||
|
|
||||||
a[href*="iapochoopl"] {
|
|
||||||
display:none
|
|
||||||
}
|
|
||||||
|
|
||||||
a[href*="a1aad3a97e1dbec6ca85de96a3fc96249595c545f6085baa75b14761f85e6669"] {
|
|
||||||
display:none
|
|
||||||
}
|
|
||||||
|
|
|
||||||
297
src/routes/profile/[id]/+page.svelte
Normal file
297
src/routes/profile/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { MODE, EXTERNAL_CLIENT_NAME, externalLink } from "$lib/config";
|
||||||
|
import { getGroupName } from "$lib/group.svelte";
|
||||||
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import {
|
||||||
|
profileStore,
|
||||||
|
ensureProfile,
|
||||||
|
entryFromUser,
|
||||||
|
type ProfileEntry,
|
||||||
|
} from "$lib/profiles.svelte";
|
||||||
|
import {
|
||||||
|
userPostsStore,
|
||||||
|
loadUserPosts,
|
||||||
|
loadMoreUserPosts,
|
||||||
|
} from "$lib/userPosts.svelte";
|
||||||
|
import type {
|
||||||
|
UserPost,
|
||||||
|
UserPostKind,
|
||||||
|
UserPostPage,
|
||||||
|
} from "$lib/forum/userPosts";
|
||||||
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
import Notifications from "$lib/components/Notifications.svelte";
|
||||||
|
import { auth, logout } from "$lib/auth.svelte";
|
||||||
|
import { summarize } from "$lib/seo";
|
||||||
|
|
||||||
|
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
|
||||||
|
const isOwn = $derived(!!pubkey && auth.user?.pubkey === pubkey);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!pubkey) return;
|
||||||
|
ensureProfile(pubkey);
|
||||||
|
loadUserPosts(pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-rendered profile, used until the live one is fetched.
|
||||||
|
const entry = $derived.by<ProfileEntry | undefined>(() => {
|
||||||
|
if (!pubkey) return undefined;
|
||||||
|
const live = profileStore.profiles.get(pubkey);
|
||||||
|
if (live) return live;
|
||||||
|
const u = page.data.profiles?.[pubkey];
|
||||||
|
return u ? entryFromUser(u) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = $derived(
|
||||||
|
pubkey && userPostsStore.pubkey === pubkey
|
||||||
|
? userPostsStore.posts
|
||||||
|
: (page.data.posts ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rooms the loaded posts come from, in the rooms list order (full mode).
|
||||||
|
const seenOn = $derived.by(() => {
|
||||||
|
if (MODE !== "full" || !posts) return [];
|
||||||
|
const ids = new Set(
|
||||||
|
[...posts.roots.items, ...posts.replies.items].map((p) => p.groupId),
|
||||||
|
);
|
||||||
|
return groupsStore.list.filter((g) => ids.has(g.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const npub = $derived(entry?.npub ?? page.params.id ?? "");
|
||||||
|
const name = $derived(
|
||||||
|
entry?.displayName || entry?.name || `${npub.slice(0, 12)}…`,
|
||||||
|
);
|
||||||
|
const initial = $derived(name[0]?.toUpperCase() ?? "?");
|
||||||
|
|
||||||
|
function websiteHref(url: string): string {
|
||||||
|
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websiteLabel(url: string): string {
|
||||||
|
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number) {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postHref(p: UserPost): string {
|
||||||
|
return p.id === p.threadId
|
||||||
|
? `/thread/${p.threadId}`
|
||||||
|
: `/thread/${p.threadId}#post-${p.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonLd = $derived(
|
||||||
|
pubkey
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
name,
|
||||||
|
url: page.url.origin + page.url.pathname,
|
||||||
|
...(entry?.picture ? { image: entry.picture } : {}),
|
||||||
|
...(entry?.about ? { description: summarize(entry.about, 160) } : {}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta
|
||||||
|
title={name}
|
||||||
|
description={entry?.about ? summarize(entry.about, 160) : ""}
|
||||||
|
image={entry?.picture}
|
||||||
|
{jsonLd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)}
|
||||||
|
{#if list.items.length === 0}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{#each list.items as p (p.id)}
|
||||||
|
{@const text = summarize(p.content, 200)}
|
||||||
|
<li class="py-3">
|
||||||
|
<a href={postHref(p)} class="group block">
|
||||||
|
{#if p.title}
|
||||||
|
<p
|
||||||
|
class="group-hover:text-accent line-clamp-2 text-lg leading-5 text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if text}
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm leading-5 text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
{#if MODE === "full"}
|
||||||
|
<a href="/room/{p.groupId}" class="hover:text-accent truncate"
|
||||||
|
>{getGroupName(p.groupId)}</a
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
{/if}
|
||||||
|
<time datetime={new Date(p.createdAt * 1000).toISOString()}
|
||||||
|
>{formatDate(p.createdAt)}</time
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !list.done}
|
||||||
|
{@const busy = userPostsStore.loadingMore[kind]}
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<button
|
||||||
|
onclick={() => loadMoreUserPosts(kind)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={busy}
|
||||||
|
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{busy ? "Loading…" : "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
{#if !pubkey}
|
||||||
|
<h1 class="text-accent py-2 text-[1.65rem]">Profile</h1>
|
||||||
|
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
Profile not found.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<h1 class="text-accent min-w-0 py-2 text-[1.65rem]">{name}</h1>
|
||||||
|
{#if isOwn}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
if (confirm("Log out?")) logout();
|
||||||
|
}}
|
||||||
|
class="mt-3 shrink-0 rounded px-2 py-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- On mobile the picture floats so the bio wraps around it; from md up
|
||||||
|
it is a flex column on the right. Placed first in the DOM for the float. -->
|
||||||
|
<div class="mt-2 md:flex md:items-start md:gap-6">
|
||||||
|
{#if entry?.picture}
|
||||||
|
<img
|
||||||
|
src={entry.picture}
|
||||||
|
alt=""
|
||||||
|
class="float-right mb-2 ml-4 h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32 md:order-last md:float-none md:mb-0 md:ml-0"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="float-right mb-2 ml-4 flex h-24 w-24 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-3xl font-semibold text-neutral-500 sm:h-32 sm:w-32 md:order-last md:float-none md:mb-0 md:ml-0 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if entry?.nip05}
|
||||||
|
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||||
|
{entry.nip05}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if entry?.about}
|
||||||
|
<p
|
||||||
|
class="mt-2 whitespace-pre-line text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{entry.about}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
{#if entry?.website}
|
||||||
|
<a
|
||||||
|
href={websiteHref(entry.website)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-accent truncate hover:underline"
|
||||||
|
>
|
||||||
|
{websiteLabel(entry.website)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if entry?.lud16}
|
||||||
|
<span
|
||||||
|
class="text-neutral-500 dark:text-neutral-400"
|
||||||
|
title="Lightning address"
|
||||||
|
>
|
||||||
|
⚡ {entry.lud16}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href={externalLink(npub)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-accent text-sm hover:underline"
|
||||||
|
>
|
||||||
|
View on {EXTERNAL_CLIENT_NAME} ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if seenOn.length > 0}
|
||||||
|
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||||
|
<span class="text-neutral-400 dark:text-neutral-500">Seen on:</span>
|
||||||
|
{#each seenOn as r, i (r.id)}<a
|
||||||
|
href="/room/{r.id}"
|
||||||
|
class="text-accent hover:underline">{r.name}</a
|
||||||
|
>{i < seenOn.length - 1 ? ", " : ""}{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isOwn}
|
||||||
|
<Notifications />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
|
||||||
|
<section aria-labelledby="profile-discussions">
|
||||||
|
<h2
|
||||||
|
id="profile-discussions"
|
||||||
|
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
Discussions
|
||||||
|
</h2>
|
||||||
|
{#if !posts}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{@render postList("roots", posts.roots, "No discussions yet.")}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="profile-replies">
|
||||||
|
<h2
|
||||||
|
id="profile-replies"
|
||||||
|
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
Replies
|
||||||
|
</h2>
|
||||||
|
{#if !posts}
|
||||||
|
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{@render postList("replies", posts.replies, "No replies yet.")}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
24
src/routes/profile/[id]/+page.ts
Normal file
24
src/routes/profile/[id]/+page.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { decodeProfileId } from "$lib/forum/profiles";
|
||||||
|
import { snapshot, type Profiles } from "$lib/forum/snapshot";
|
||||||
|
import type { UserPosts } from "$lib/forum/userPosts";
|
||||||
|
|
||||||
|
// nprofile and hex forms redirect to the canonical npub URL. An invalid id
|
||||||
|
// is a server 404 (via the endpoint) and an in-page message on the client.
|
||||||
|
export async function load({ fetch, params }) {
|
||||||
|
const pubkey = decodeProfileId(params.id);
|
||||||
|
if (pubkey) {
|
||||||
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
if (params.id !== npub) redirect(301, `/profile/${npub}`);
|
||||||
|
}
|
||||||
|
const data = await snapshot<{ posts: UserPosts; profiles: Profiles }>(
|
||||||
|
fetch,
|
||||||
|
`/api/profile/${params.id}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
posts: data?.posts ?? null,
|
||||||
|
profiles: data?.profiles ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { resourcesStore } from "$lib/resources.svelte";
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { adminPubkeys } from "$lib/admins.svelte";
|
import { adminPubkeys } from "$lib/admins.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
import { excerpt } from "$lib/seo";
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? "");
|
const slug = $derived(page.params.slug ?? "");
|
||||||
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
||||||
|
|
@ -10,23 +12,10 @@
|
||||||
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
||||||
const notFound = $derived(ready && !resource);
|
const notFound = $derived(ready && !resource);
|
||||||
|
|
||||||
// First non-empty line of the body, trimmed of markdown markers, for SEO.
|
const description = $derived(excerpt(resource?.content ?? ""));
|
||||||
const description = $derived(
|
|
||||||
(resource?.content ?? "")
|
|
||||||
.replace(/^#+\s*/gm, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.find(Boolean)
|
|
||||||
?.slice(0, 160) ?? "",
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta title={resource?.title ?? "Resource"} {description} type="article" />
|
||||||
<title>{resource?.title ?? "Resource"}</title>
|
|
||||||
{#if description}
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<a
|
<a
|
||||||
|
|
@ -56,8 +45,12 @@
|
||||||
<PostContent content={resource.content} headingOffset={0} />
|
<PostContent content={resource.content} headingOffset={0} />
|
||||||
</div>
|
</div>
|
||||||
{:else if notFound}
|
{:else if notFound}
|
||||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Resource not found.</p>
|
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
Resource not found.
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
|
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
17
src/routes/resource/[slug]/+page.ts
Normal file
17
src/routes/resource/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { snapshot, type OverviewSnapshot } from "$lib/forum/snapshot";
|
||||||
|
|
||||||
|
// Articles show the latest-discussions panel next to the text. An unknown
|
||||||
|
// slug is a real 404 on the server; the client keeps its in-page message.
|
||||||
|
export async function load({ fetch, params, parent }) {
|
||||||
|
if (!browser) {
|
||||||
|
const { shell } = await parent();
|
||||||
|
const known = shell?.resources.some(
|
||||||
|
(r) => r.slug === params.slug && shell.admins.includes(r.pubkey),
|
||||||
|
);
|
||||||
|
if (shell && !known) error(404, "Not found");
|
||||||
|
}
|
||||||
|
const overview = await snapshot<OverviewSnapshot>(fetch, "/api/overview");
|
||||||
|
return { overview, profiles: overview?.profiles ?? null };
|
||||||
|
}
|
||||||
13
src/routes/robots.txt/+server.ts
Normal file
13
src/routes/robots.txt/+server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { SSR_ENABLED } from "$lib/config";
|
||||||
|
|
||||||
|
// Static builds bake it in at build time (no sitemap to point at); the server
|
||||||
|
// build answers live so the sitemap URL carries the real origin.
|
||||||
|
export const prerender = !SSR_ENABLED;
|
||||||
|
|
||||||
|
export function GET({ url }) {
|
||||||
|
const lines = ["User-agent: *", "Disallow:"];
|
||||||
|
if (SSR_ENABLED) lines.push(`Sitemap: ${url.origin}/sitemap.xml`);
|
||||||
|
return new Response(lines.join("\n") + "\n", {
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/routes/room/[slug]/+page.ts
Normal file
10
src/routes/room/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { snapshot, type ThreadsSnapshot } from "$lib/forum/snapshot";
|
||||||
|
|
||||||
|
export async function load({ fetch, params, url }) {
|
||||||
|
const sort = url.searchParams.get("sort") === "new" ? "?sort=new" : "";
|
||||||
|
const threads = await snapshot<ThreadsSnapshot>(
|
||||||
|
fetch,
|
||||||
|
`/api/threads/${params.slug}${sort}`,
|
||||||
|
);
|
||||||
|
return { threads, profiles: threads?.profiles ?? null };
|
||||||
|
}
|
||||||
57
src/routes/sitemap.xml/+server.ts
Normal file
57
src/routes/sitemap.xml/+server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
|
import { fetchGroups } from "$lib/forum/groups";
|
||||||
|
import { snapshotHandler } from "$lib/ssr/endpoint";
|
||||||
|
import { forumQuery } from "$lib/ssr/relay";
|
||||||
|
import { loadShell } from "$lib/ssr/shell";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
const THREADS_PER_ROOM = 1000;
|
||||||
|
|
||||||
|
type Entry = { path: string; lastmod?: number };
|
||||||
|
|
||||||
|
function xml(origin: string, entries: Entry[]): string {
|
||||||
|
const items = entries.map((e) => {
|
||||||
|
const loc = `<loc>${origin}${e.path}</loc>`;
|
||||||
|
const mod = e.lastmod
|
||||||
|
? `<lastmod>${new Date(e.lastmod * 1000).toISOString()}</lastmod>`
|
||||||
|
: "";
|
||||||
|
return `<url>${loc}${mod}</url>`;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
|
||||||
|
items.join("") +
|
||||||
|
"</urlset>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public pages only: rooms the anonymous server can see, their threads, and
|
||||||
|
// the admin-authored resources.
|
||||||
|
export const GET: RequestHandler = snapshotHandler(async ({ url }) => {
|
||||||
|
const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }];
|
||||||
|
const shell = await loadShell();
|
||||||
|
const admins = new Set(shell.admins);
|
||||||
|
for (const r of shell.resources) {
|
||||||
|
if (admins.has(r.pubkey))
|
||||||
|
entries.push({ path: `/resource/${r.slug}`, lastmod: r.createdAt });
|
||||||
|
}
|
||||||
|
const roomIds =
|
||||||
|
MODE === "full"
|
||||||
|
? (await fetchGroups(forumQuery))
|
||||||
|
.filter((g) => !g.flags.includes("private"))
|
||||||
|
.map((g) => g.id)
|
||||||
|
: [GROUP_ID];
|
||||||
|
for (const id of roomIds) {
|
||||||
|
if (MODE === "full") entries.push({ path: `/room/${id}` });
|
||||||
|
const ops = await forumQuery({
|
||||||
|
kinds: [11],
|
||||||
|
"#h": [id],
|
||||||
|
limit: THREADS_PER_ROOM,
|
||||||
|
});
|
||||||
|
for (const e of ops)
|
||||||
|
entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at });
|
||||||
|
}
|
||||||
|
return new Response(xml(url.origin, entries), {
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { goto } from "$app/navigation";
|
import { afterNavigate, goto } from "$app/navigation";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import {
|
import {
|
||||||
threadDetailStore,
|
threadDetailStore,
|
||||||
|
|
@ -19,17 +19,21 @@
|
||||||
import { showToast } from "$lib/toast.svelte";
|
import { showToast } from "$lib/toast.svelte";
|
||||||
import {
|
import {
|
||||||
withJoin,
|
withJoin,
|
||||||
|
joinState,
|
||||||
membershipOf,
|
membershipOf,
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
openJoinModal,
|
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { setActiveGroup } from "$lib/active.svelte";
|
import { setActiveGroup } from "$lib/active.svelte";
|
||||||
|
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
import Tag from "$lib/components/Tag.svelte";
|
import Tag from "$lib/components/Tag.svelte";
|
||||||
import { type NostrUser } from "@nostr/gadgets/metadata";
|
import Meta from "$lib/components/Meta.svelte";
|
||||||
|
import { excerpt } from "$lib/seo";
|
||||||
|
import type { NostrUser } from "$lib/gadgets";
|
||||||
|
|
||||||
type Author = { pubkey: string; name: string; picture?: string };
|
type Author = { pubkey: string; name: string; picture?: string };
|
||||||
|
|
||||||
|
|
@ -119,6 +123,25 @@
|
||||||
const detail = $derived(threadDetailStore.detail);
|
const detail = $derived(threadDetailStore.detail);
|
||||||
const profiles = $derived(threadDetailStore.profiles);
|
const profiles = $derived(threadDetailStore.profiles);
|
||||||
|
|
||||||
|
// Structured data for crawlers: the OP as a forum posting.
|
||||||
|
const jsonLd = $derived(
|
||||||
|
detail
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "DiscussionForumPosting",
|
||||||
|
headline: detail.title,
|
||||||
|
text: detail.op.content,
|
||||||
|
url: page.url.origin + page.url.pathname,
|
||||||
|
datePublished: new Date(detail.op.createdAt * 1000).toISOString(),
|
||||||
|
author: {
|
||||||
|
"@type": "Person",
|
||||||
|
name: resolveAuthor(detail.op.pubkey, profiles).name,
|
||||||
|
},
|
||||||
|
commentCount: detail.replies.length,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Replying needs membership regardless of flags. Only gate a confirmed guest;
|
// Replying needs membership regardless of flags. Only gate a confirmed guest;
|
||||||
// while membership resolves the editor stays and submit awaits the check, so a
|
// while membership resolves the editor stays and submit awaits the check, so a
|
||||||
// member never flashes "Join to reply".
|
// member never flashes "Join to reply".
|
||||||
|
|
@ -135,7 +158,7 @@
|
||||||
|
|
||||||
function onJoinToReply() {
|
function onJoinToReply() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
openJoinModal(detail.groupId, async () => {
|
withJoin(detail.groupId, async () => {
|
||||||
await tick();
|
await tick();
|
||||||
editorEl?.focus();
|
editorEl?.focus();
|
||||||
});
|
});
|
||||||
|
|
@ -185,6 +208,20 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The OP shares the thread URL, a reply its anchored URL.
|
||||||
|
async function shareLink(p: PostData, isOp: boolean) {
|
||||||
|
if (!detail) return;
|
||||||
|
openMenuId = null;
|
||||||
|
const url =
|
||||||
|
page.url.origin + `/thread/${detail.id}` + (isOp ? "" : `#post-${p.id}`);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showToast("Link copied");
|
||||||
|
} catch {
|
||||||
|
showToast("Could not copy the link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!openMenuId) return;
|
if (!openMenuId) return;
|
||||||
const close = () => (openMenuId = null);
|
const close = () => (openMenuId = null);
|
||||||
|
|
@ -210,6 +247,89 @@
|
||||||
if (page.params.id) loadThread(page.params.id);
|
if (page.params.id) loadThread(page.params.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arriving from search (?q=…): paint the terms over the title and post
|
||||||
|
// bodies, and scroll to the first match once per navigation. Re-runs as
|
||||||
|
// replies stream in so late matches get painted too.
|
||||||
|
let threadEl = $state<HTMLElement | null>(null);
|
||||||
|
let scrolledFor = "";
|
||||||
|
$effect(() => {
|
||||||
|
const q = page.url.searchParams.get("q")?.trim() ?? "";
|
||||||
|
allPosts; // Repaint when posts load or change
|
||||||
|
if (!threadEl || !q) {
|
||||||
|
clearHighlights();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]");
|
||||||
|
const first = applyHighlights(roots, q);
|
||||||
|
const key = `${page.params.id}|${q}`;
|
||||||
|
if (first && scrolledFor !== key) {
|
||||||
|
scrolledFor = key;
|
||||||
|
first.startContainer.parentElement?.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
return clearHighlights;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arriving with a #post-… hash (a reply link): scroll to the post once per
|
||||||
|
// navigation. It may only exist once the replies have loaded, so re-check as
|
||||||
|
// they stream in; on a direct load it is already there and SvelteKit's own
|
||||||
|
// deep-link scroll runs after ours, so afterNavigate applies ours again.
|
||||||
|
let scrolledHashFor = "";
|
||||||
|
function scrollToHash(force = false) {
|
||||||
|
const hash = page.url.hash;
|
||||||
|
if (!threadEl || !hash.startsWith("#post-")) return;
|
||||||
|
const key = `${page.params.id}|${hash}`;
|
||||||
|
if (!force && scrolledHashFor === key) return;
|
||||||
|
const el = document.getElementById(hash.slice(1));
|
||||||
|
if (!el) return;
|
||||||
|
scrolledHashFor = key;
|
||||||
|
scrollToPost(el);
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
allPosts;
|
||||||
|
scrollToHash();
|
||||||
|
});
|
||||||
|
afterNavigate(() => scrollToHash(true));
|
||||||
|
|
||||||
|
// On desktop the thread scrolls inside <main> under a sticky title with a
|
||||||
|
// fade below it, so place the post under both; elsewhere the scroll margin
|
||||||
|
// is enough.
|
||||||
|
const HEADER_FADE = 32; // the h-8 gradient
|
||||||
|
const HEADER_GAP = 12;
|
||||||
|
function scrollToPost(el: HTMLElement) {
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
const header = threadEl?.querySelector("[data-thread-header]");
|
||||||
|
if (!main || !header || !window.matchMedia("(min-width: 768px)").matches) {
|
||||||
|
el.scrollIntoView({ block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headerBottom =
|
||||||
|
main.getBoundingClientRect().top + header.getBoundingClientRect().height;
|
||||||
|
const offset = headerBottom + HEADER_FADE + HEADER_GAP;
|
||||||
|
main.scrollTop += el.getBoundingClientRect().top - offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-thread quote links ("… said") get the same placement instead of the
|
||||||
|
// browser's default anchor jump.
|
||||||
|
$effect(() => {
|
||||||
|
const root = threadEl;
|
||||||
|
if (!root) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (e.defaultPrevented || e.button !== 0) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
const a = (e.target as Element).closest("a[href^='#post-']");
|
||||||
|
if (!a) return;
|
||||||
|
const hash = a.getAttribute("href") ?? "";
|
||||||
|
const el = document.getElementById(hash.slice(1));
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
scrolledHashFor = `${page.params.id}|${hash}`;
|
||||||
|
goto(hash, { noScroll: true });
|
||||||
|
scrollToPost(el);
|
||||||
|
};
|
||||||
|
root.addEventListener("click", onClick);
|
||||||
|
return () => root.removeEventListener("click", onClick);
|
||||||
|
});
|
||||||
|
|
||||||
// A thread belongs to its own group; make that the active group so chat and
|
// A thread belongs to its own group; make that the active group so chat and
|
||||||
// replies target the right room.
|
// replies target the right room.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -290,18 +410,34 @@
|
||||||
await quotePost(post, text);
|
await quotePost(post, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scrubber sticks where the OP starts. Measure independently of the
|
||||||
|
// current scroll (the page may mount already scrolled, e.g. arriving from a
|
||||||
|
// profile with a reply link) and again when the header changes height.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!opEl) return;
|
const el = opEl;
|
||||||
|
const header = threadEl?.querySelector("[data-thread-header]");
|
||||||
|
if (!el || !header) return;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
opTopOffset =
|
const measure = () => {
|
||||||
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
opTopOffset =
|
||||||
|
el.getBoundingClientRect().top -
|
||||||
|
main.getBoundingClientRect().top +
|
||||||
|
main.scrollTop;
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(header);
|
||||||
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Meta
|
||||||
<title>{detail?.title ?? "Thread"}</title>
|
title={detail?.title ?? "Thread"}
|
||||||
</svelte:head>
|
description={detail ? excerpt(detail.op.content) : ""}
|
||||||
|
type="article"
|
||||||
|
{jsonLd}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet avatar(author: Author)}
|
{#snippet avatar(author: Author)}
|
||||||
{#if author.picture}
|
{#if author.picture}
|
||||||
|
|
@ -326,50 +462,70 @@
|
||||||
)}
|
)}
|
||||||
{@const author = resolveAuthor(p.pubkey, profiles)}
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||||
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
||||||
<div class="hidden flex-shrink-0 md:block">
|
<a
|
||||||
|
href={profilePath(author.pubkey)}
|
||||||
|
class="hidden flex-shrink-0 md:block"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
{@render avatar(author)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<div class="flex-shrink-0 md:hidden">
|
<a
|
||||||
|
href={profilePath(author.pubkey)}
|
||||||
|
class="flex-shrink-0 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
{@render avatar(author)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<span
|
<a
|
||||||
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
|
href={profilePath(author.pubkey)}
|
||||||
>{author.name}</span
|
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||||
|
>{author.name}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||||
{#if canDeletePost(p)}
|
<div class="relative">
|
||||||
{@const tooOld =
|
<button
|
||||||
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
|
onclick={(e) => toggleMenu(p.id, e)}
|
||||||
<div class="relative">
|
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400"
|
||||||
<button
|
aria-label="Post actions"
|
||||||
onclick={(e) => toggleMenu(p.id, e)}
|
aria-haspopup="menu"
|
||||||
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400"
|
aria-expanded={openMenuId === p.id}
|
||||||
aria-label="Post actions"
|
>
|
||||||
aria-haspopup="menu"
|
<svg
|
||||||
aria-expanded={openMenuId === p.id}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
||||||
class="h-4 w-4"
|
/>
|
||||||
viewBox="0 0 20 20"
|
</svg>
|
||||||
fill="currentColor"
|
</button>
|
||||||
>
|
{#if openMenuId === p.id}
|
||||||
<path
|
<div
|
||||||
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
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"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<button
|
||||||
{#if openMenuId === p.id}
|
role="menuitem"
|
||||||
<div
|
onclick={(e) => {
|
||||||
role="menu"
|
e.stopPropagation();
|
||||||
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"
|
shareLink(p, index === 0);
|
||||||
|
}}
|
||||||
|
class="w-full px-3 py-1.5 text-left text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||||
|
>Share link</button
|
||||||
>
|
>
|
||||||
|
{#if canDeletePost(p)}
|
||||||
|
{@const tooOld =
|
||||||
|
!canModerate && !withinSelfDeleteWindow(p.createdAt)}
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
disabled={tooOld}
|
disabled={tooOld}
|
||||||
|
|
@ -380,10 +536,10 @@
|
||||||
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:disabled:text-neutral-600"
|
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:hover:bg-transparent dark:disabled:text-neutral-600"
|
||||||
>Delete{tooOld ? " (too old)" : ""}</button
|
>Delete{tooOld ? " (too old)" : ""}</button
|
||||||
>
|
>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
<span class="text-sm text-neutral-400 dark:text-neutral-500"
|
<span class="text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
>{formatDate(p.createdAt)}</span
|
>{formatDate(p.createdAt)}</span
|
||||||
>
|
>
|
||||||
|
|
@ -421,9 +577,10 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if detail}
|
{#if detail}
|
||||||
<div class="flex items-start gap-6">
|
<div class="flex items-start gap-6" bind:this={threadEl}>
|
||||||
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
||||||
<div
|
<div
|
||||||
|
data-thread-header
|
||||||
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
|
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
|
@ -491,9 +648,10 @@
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={onJoinToReply}
|
onclick={onJoinToReply}
|
||||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white"
|
disabled={joinState.busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Join to reply
|
{joinState.busy ? "Joining…" : "Join to reply"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
10
src/routes/thread/[id]/+page.ts
Normal file
10
src/routes/thread/[id]/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { snapshot, type Profiles } from "$lib/forum/snapshot";
|
||||||
|
import type { ThreadDetail } from "$lib/forum/thread";
|
||||||
|
|
||||||
|
export async function load({ fetch, params }) {
|
||||||
|
const data = await snapshot<{ thread: ThreadDetail; profiles: Profiles }>(
|
||||||
|
fetch,
|
||||||
|
`/api/thread/${params.id}`,
|
||||||
|
);
|
||||||
|
return { thread: data?.thread ?? null, profiles: data?.profiles ?? null };
|
||||||
|
}
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
import adapter from "@sveltejs/adapter-static";
|
import { existsSync } from "node:fs";
|
||||||
|
import { loadEnv } from "vite";
|
||||||
|
import adapterNode from "@sveltejs/adapter-node";
|
||||||
|
import adapterStatic from "@sveltejs/adapter-static";
|
||||||
|
|
||||||
|
// PUBLIC_SSR picks the deployment target: a Node server that renders pages
|
||||||
|
// (yes) or a static single-page bundle (no, the default). vite.config.ts bakes
|
||||||
|
// the same flag into the app as SSR_ENABLED.
|
||||||
|
const env = loadEnv(
|
||||||
|
process.env.NODE_ENV ?? "production",
|
||||||
|
process.cwd(),
|
||||||
|
"PUBLIC_",
|
||||||
|
);
|
||||||
|
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||||
|
|
||||||
|
// Instance assets: a build run with `--mode <mode>` serves the files in
|
||||||
|
// .local/<mode>/static (gitignored) instead of the shared static/ folder.
|
||||||
|
const argv = process.argv;
|
||||||
|
const modeIdx = argv.findIndex((a) => a === "--mode" || a === "-m");
|
||||||
|
const mode =
|
||||||
|
modeIdx !== -1
|
||||||
|
? argv[modeIdx + 1]
|
||||||
|
: argv.find((a) => a.startsWith("--mode="))?.slice(7);
|
||||||
|
const instanceAssets = mode ? `.local/${mode}/static` : undefined;
|
||||||
|
const assets =
|
||||||
|
instanceAssets && existsSync(instanceAssets) ? instanceAssets : "static";
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: ssr ? adapterNode() : adapterStatic({ fallback: "index.html" }),
|
||||||
fallback: "index.html",
|
files: { assets },
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,47 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
function git(cmd: string): string {
|
||||||
|
try {
|
||||||
|
return execSync(`git ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version comes from package.json; a build that is not exactly the tagged
|
||||||
|
// release commit (or has local changes) carries the commit as build metadata,
|
||||||
|
// e.g. 0.3.0+a1b2c3d or 0.3.0+a1b2c3d.dirty, so bug reports tell them apart.
|
||||||
|
function version(): { version: string; commit: string } {
|
||||||
|
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||||
|
const commit = git("rev-parse --short HEAD");
|
||||||
|
const tagged = git("tag --points-at HEAD")
|
||||||
|
.split("\n")
|
||||||
|
.includes(`v${pkg.version}`);
|
||||||
|
const dirty = git("status --porcelain --untracked-files=no") !== "";
|
||||||
|
let v = pkg.version;
|
||||||
|
if (commit && (!tagged || dirty)) v += `+${commit}${dirty ? ".dirty" : ""}`;
|
||||||
|
return { version: v, commit };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUBLIC_SSR is a build-time decision (it also picks the adapter in
|
||||||
|
// svelte.config.js), so it is compiled in as a constant rather than read from
|
||||||
|
// the environment when the server starts.
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "PUBLIC_");
|
||||||
|
const ssr = (process.env.PUBLIC_SSR ?? env.PUBLIC_SSR) === "yes";
|
||||||
|
const build = version();
|
||||||
|
return {
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
define: {
|
||||||
|
__SQUALK_SSR__: JSON.stringify(ssr),
|
||||||
|
__SQUALK_VERSION__: JSON.stringify(build.version),
|
||||||
|
__SQUALK_COMMIT__: JSON.stringify(build.commit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue