Initial commit: Nosterm client (terminal-style Nostr chat SPA)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
@@ -0,0 +1,36 @@
|
||||
# Nosterm client configuration. Copy to `.env` and edit:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Nothing here is secret — every value is either browser-visible (PUBLIC_*, baked
|
||||
# into the client bundle at build time) or plain deployment wiring. Do NOT put
|
||||
# private keys or credentials in this file.
|
||||
|
||||
# --- Runtime (docker-compose, no rebuild needed) ---------------------------
|
||||
|
||||
# Relay websocket url(s) the client preloads, comma-separated. Written to
|
||||
# /config.json by the container entrypoint on startup. For a same-origin home
|
||||
# relay proxied by Caddy use "ws://localhost:8080/relay". Leave blank to start
|
||||
# with no default relays.
|
||||
NOSTERM_DEFAULT_RELAYS=
|
||||
|
||||
# Upstream address for Caddy's optional /relay proxy. Only used when the client
|
||||
# reaches a relay at ws://<host>/relay. Harmless if unused.
|
||||
RELAY_UPSTREAM=relay:3334
|
||||
|
||||
# --- Build-time (PUBLIC_*, baked into the client bundle) -------------------
|
||||
|
||||
# Fallback relay list used only when no runtime /config.json relays are set.
|
||||
PUBLIC_DEFAULT_RELAYS=
|
||||
|
||||
# Default UI theme id.
|
||||
PUBLIC_DEFAULT_THEME=nord
|
||||
|
||||
# Max lines kept in the terminal scrollback.
|
||||
PUBLIC_MAX_TERMINAL_ENTRIES=1000
|
||||
|
||||
# Max characters allowed in a single message.
|
||||
PUBLIC_MAX_MESSAGE_LENGTH=2000
|
||||
|
||||
# Max messages cached per room in IndexedDB (older ones get pruned).
|
||||
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=500
|
||||
@@ -0,0 +1,11 @@
|
||||
<!-- Describe what this PR changes and why. -->
|
||||
|
||||
## Summary
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run check` passes
|
||||
- [ ] `npm test` passes
|
||||
- [ ] `npm run build` succeeds
|
||||
- [ ] No secrets committed (PUBLIC_* args are browser-visible by design)
|
||||
@@ -0,0 +1,110 @@
|
||||
# CI/CD for the public Nosterm client.
|
||||
#
|
||||
# - On every push/PR: install, lint, type-check, test, and build the SPA.
|
||||
# - On push to main / a v* tag: additionally build the container image and
|
||||
# push it to ECR Public (anonymous pulls) so anyone can `docker pull` it.
|
||||
#
|
||||
# This is the PUBLIC contribution repo — it builds and ships ONLY the client
|
||||
# image to the public registry. It deliberately does not touch private ECR or
|
||||
# redeploy any environment; promoting an image to a live site is handled out of
|
||||
# band by the private ops pipeline.
|
||||
#
|
||||
# Targets Gitea 1.21 Actions on a self-hosted (EC2) runner. AWS auth uses the
|
||||
# runner's ambient IAM role (no stored credentials): `aws ecr-public
|
||||
# get-login-password` mints a short-lived token for `docker login`. The runner
|
||||
# role needs ECR Public push permissions (ecr-public:GetAuthorizationToken,
|
||||
# sts:GetServiceBearerToken, BatchCheckLayerAvailability, Put*/Upload*/Complete*).
|
||||
name: ci
|
||||
|
||||
env:
|
||||
# ECR Public is always authenticated via the us-east-1 endpoint, regardless
|
||||
# of where images are served from.
|
||||
AWS_REGION: us-east-1
|
||||
# public mirror for anonymous `docker pull`. k3k1z1x5 is the account's ECR
|
||||
# Public registry alias; the repo name (nosterm) is provisioned out of band.
|
||||
ECR_PUBLIC_REGISTRY: public.ecr.aws/k3k1z1x5
|
||||
ECR_PUBLIC_REPO: nosterm
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint (prettier + eslint)
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check (svelte-check)
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests (vitest)
|
||||
run: npm test
|
||||
|
||||
- name: Build (static SPA)
|
||||
run: npm run build
|
||||
|
||||
image:
|
||||
needs: build
|
||||
# Publish only from the default branch and version tags, never from PRs.
|
||||
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install AWS CLI
|
||||
# runner image ships without aws; install v2 if it's missing.
|
||||
run: |
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl unzip
|
||||
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
|
||||
unzip -q /tmp/awscliv2.zip -d /tmp
|
||||
/tmp/aws/install
|
||||
fi
|
||||
aws --version
|
||||
|
||||
- name: Log in to Amazon ECR Public
|
||||
# ECR Public auth is a separate endpoint (always us-east-1) and its own
|
||||
# login user (AWS). This is push auth; anonymous pulls need no login.
|
||||
run: |
|
||||
aws ecr-public get-login-password --region us-east-1 \
|
||||
| docker login --username AWS --password-stdin public.ecr.aws
|
||||
|
||||
- name: Build and push image
|
||||
shell: bash
|
||||
run: |
|
||||
set -eo pipefail
|
||||
IMAGE="$ECR_PUBLIC_REGISTRY/$ECR_PUBLIC_REPO"
|
||||
# tag by commit sha for traceability.
|
||||
docker build -t "$IMAGE:${{ gitea.sha }}" .
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
# move :latest only on main.
|
||||
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
|
||||
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:latest"
|
||||
docker push "$IMAGE:latest"
|
||||
fi
|
||||
# on a v* tag, also push that version tag.
|
||||
case "${{ gitea.ref }}" in
|
||||
refs/tags/v*)
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:$VERSION"
|
||||
docker push "$IMAGE:$VERSION"
|
||||
;;
|
||||
esac
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
|
||||
# build output / framework caches
|
||||
.svelte-kit/
|
||||
build/
|
||||
|
||||
# test + coverage artifacts
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# local env (keep .env.example)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,7 @@
|
||||
.svelte-kit
|
||||
build
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Contributing to Nosterm
|
||||
|
||||
Thanks for helping improve the Nosterm client. This repo is the public home for
|
||||
client development — the browser SPA and its container image. There is no
|
||||
backend here; Nostr is a client-side protocol.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Fork the repo (or branch, if you have push access).
|
||||
2. Create a topic branch off `main`.
|
||||
3. Make your change, keeping commits focused.
|
||||
4. Open a pull request against `main`.
|
||||
|
||||
Every push and pull request runs CI: install, lint, type-check, unit tests, and
|
||||
a full static build. Get these green before requesting review:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint # prettier --check + eslint
|
||||
npm run check # svelte-check (type-check)
|
||||
npm test # vitest
|
||||
npm run build # static build → ./build
|
||||
```
|
||||
|
||||
`npm run format` auto-fixes most lint issues.
|
||||
|
||||
## What happens on merge
|
||||
|
||||
Merging to `main` builds the container image and pushes it to ECR Public
|
||||
(`public.ecr.aws/k3k1z1x5/nosterm:latest`), so anyone can pull the latest client
|
||||
anonymously. Tagged releases (`v*`) publish a matching versioned image. This
|
||||
repo does not deploy to any live environment — promotion is handled separately.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Match the surrounding code style; the linter is the source of truth.
|
||||
- Keep pull requests scoped to one change; smaller is easier to review.
|
||||
- Add or update tests when you change behavior.
|
||||
- Never commit secrets. All `PUBLIC_*` build args are browser-visible by design.
|
||||
- Relays are configured at runtime, not baked in — don't hard-code a relay as a
|
||||
dependency.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open an issue with steps to reproduce, what you expected, and what happened.
|
||||
For security-sensitive reports, please disclose privately rather than in a
|
||||
public issue.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Caddy config for serving the standalone Nosterm client SPA.
|
||||
#
|
||||
# In production, replace `:80` with your domain to get automatic HTTPS, e.g.:
|
||||
# nosterm.example.com { ... }
|
||||
#
|
||||
# The `/relay*` proxy is OPTIONAL. It lets the client reach a relay on the same
|
||||
# origin (ws://<host>/relay), which sidesteps mixed-content and CORS concerns.
|
||||
# The upstream is configurable via the RELAY_UPSTREAM env var (default
|
||||
# `relay:3334`, the service name in this dir's docker-compose.yml). If you point
|
||||
# the client directly at an external relay via PUBLIC_DEFAULT_RELAYS, you can
|
||||
# delete the `handle /relay*` block.
|
||||
|
||||
:80 {
|
||||
encode gzip zstd
|
||||
|
||||
# Tor-overlay relay bridge: proxy to a clearnet→onion bridge sidecar that
|
||||
# forwards through Tor to an onion-only relay. Exposing it at a same-origin
|
||||
# wss:// path lets a plain browser reach the onion relay without Tor Browser
|
||||
# and without an HTTPS-page/insecure-ws mixed-content block. Only wired when
|
||||
# RELAY_TOR_UPSTREAM is set (the demo deploy sets it); otherwise it points at
|
||||
# a dead default and simply 502s, which is harmless if unused. MUST precede
|
||||
# the /relay* block below, which would otherwise swallow this prefix.
|
||||
handle /relay-tor* {
|
||||
reverse_proxy {$RELAY_TOR_UPSTREAM:tor-bridge:3335}
|
||||
}
|
||||
|
||||
# Home relay: proxy WebSocket + NIP-11 requests to the configured relay.
|
||||
handle /relay* {
|
||||
reverse_proxy {$RELAY_UPSTREAM:relay:3334}
|
||||
}
|
||||
|
||||
# Runtime client config: regenerated by the entrypoint from env on startup.
|
||||
# Never cache it so per-deployment relay changes take effect immediately.
|
||||
handle /config.json {
|
||||
root * /srv
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
# Everything else: serve the static SPA with client-side-routing fallback.
|
||||
handle {
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# Baseline security headers. No third-party analytics are ever included.
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer
|
||||
|
||||
# Content-Security-Policy for a client-only SPA:
|
||||
# - default-src 'self' only same-origin by default
|
||||
# - connect-src wss:/ws: reach ANY relay the user configures (core)
|
||||
# - connect-src https: NIP-05 verification fetches a domain's
|
||||
# /.well-known/nostr.json over HTTPS. Read-only
|
||||
# JSON GETs; no credentials are ever sent.
|
||||
# - script-src 'unsafe-inline' required for SvelteKit's hydration
|
||||
# bootstrap + the anti-FOUC theme script,
|
||||
# which are inlined into the static fallback
|
||||
# page (not prerendered, so hashes cannot be
|
||||
# pinned at build time).
|
||||
# - script-src 'unsafe-eval' required by NDK's event-emitter dependency
|
||||
# (tseep), which JIT-compiles listener
|
||||
# dispatch via `new Function`. Only triggers
|
||||
# once a relay subscription opens. No REMOTE
|
||||
# scripts are ever allowed, and remote Nostr
|
||||
# content is never rendered as HTML, so it
|
||||
# cannot inject code.
|
||||
# - object-src 'none' no plugins
|
||||
Content-Security-Policy "default-src 'self'; connect-src 'self' wss: ws: https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Standalone Nosterm client image.
|
||||
#
|
||||
# The full SvelteKit source lives alongside this Dockerfile, so the build has no
|
||||
# dependency on anything outside this directory. Build context is THIS
|
||||
# directory:
|
||||
#
|
||||
# docker build -t nosterm:latest .
|
||||
#
|
||||
# The output is a static SPA served by Caddy. All PUBLIC_ build args are baked
|
||||
# into the client bundle (they are browser-visible, so never put secrets here).
|
||||
#
|
||||
# Relays are configured at RUNTIME, not build time: the entrypoint regenerates
|
||||
# /config.json from NOSTERM_DEFAULT_RELAYS on startup, so one prebuilt image can
|
||||
# be deployed with different relays per environment. PUBLIC_DEFAULT_RELAYS
|
||||
# remains a build-time fallback for images run without the runtime var.
|
||||
|
||||
# --- Build stage -----------------------------------------------------------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies against a clean lockfile for reproducible builds.
|
||||
# Copied separately so the dependency layer caches independently of source.
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
# Build the static SPA.
|
||||
COPY . ./
|
||||
ARG PUBLIC_DEFAULT_RELAYS=""
|
||||
ARG PUBLIC_DEFAULT_THEME="nord"
|
||||
ARG PUBLIC_MAX_TERMINAL_ENTRIES="1000"
|
||||
ARG PUBLIC_MAX_MESSAGE_LENGTH="2000"
|
||||
ARG PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM="500"
|
||||
ENV PUBLIC_DEFAULT_RELAYS=$PUBLIC_DEFAULT_RELAYS \
|
||||
PUBLIC_DEFAULT_THEME=$PUBLIC_DEFAULT_THEME \
|
||||
PUBLIC_MAX_TERMINAL_ENTRIES=$PUBLIC_MAX_TERMINAL_ENTRIES \
|
||||
PUBLIC_MAX_MESSAGE_LENGTH=$PUBLIC_MAX_MESSAGE_LENGTH \
|
||||
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=$PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime stage (Caddy serves the static build) -------------------------
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /app/build /srv
|
||||
|
||||
# Entrypoint regenerates /srv/config.json from NOSTERM_DEFAULT_RELAYS at start.
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Liveness check against the served shell.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:80/ || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
@@ -0,0 +1,110 @@
|
||||
# Nosterm
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/nosterm-banner.svg" alt="Nosterm — a terminal-style Nostr chat application" width="640" />
|
||||
</p>
|
||||
|
||||
**A terminal-style Nostr chat client.**
|
||||
|
||||
The Nosterm web client: a terminal-style Nostr chat SPA built with
|
||||
[SvelteKit](https://kit.svelte.dev/) and served as static files by
|
||||
[Caddy](https://caddyserver.com/). Nostr is a client-side protocol, so there is
|
||||
no application backend here — the browser talks directly to relays over
|
||||
WebSocket. Run a relay from the companion
|
||||
[nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay) repository if
|
||||
you want your own home relay.
|
||||
|
||||
This is the **public, contribution-focused** repository for the Nosterm client.
|
||||
Open a pull request against `main` — see [CONTRIBUTING.md](CONTRIBUTING.md). Each
|
||||
merge to `main` builds the container image and publishes it to ECR Public for
|
||||
anonymous pulls (see [Deploy](#deploy)).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
nosterm-client/
|
||||
src/ # SvelteKit source (components, stores, nostr, commands…)
|
||||
static/ # static assets
|
||||
Dockerfile # build the SPA → serve with Caddy
|
||||
Caddyfile # static file server + security headers + optional /relay proxy
|
||||
docker-compose.yml # one-command deploy on port 8080
|
||||
.env.example # copy to .env and configure
|
||||
package.json # dev/build/test scripts
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # vite dev server
|
||||
npm run check # svelte-check (type-check)
|
||||
npm run lint # prettier --check + eslint
|
||||
npm test # vitest
|
||||
npm run test:e2e # playwright
|
||||
npm run build # static build → ./build
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then set NOSTERM_DEFAULT_RELAYS at minimum
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The client is then served at <http://localhost:8080>.
|
||||
|
||||
To run the prebuilt public image with your own relays — no rebuild, no AWS
|
||||
account (anonymous pull from ECR Public):
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e NOSTERM_DEFAULT_RELAYS=wss://relay.example.com \
|
||||
public.ecr.aws/k3k1z1x5/nosterm:latest
|
||||
```
|
||||
|
||||
`NOSTERM_DEFAULT_RELAYS` accepts a comma-separated list. On startup the
|
||||
container's entrypoint writes it to `/config.json`, which the SPA fetches on
|
||||
load — so one shared image serves every deployment with different relays.
|
||||
|
||||
## Configuration
|
||||
|
||||
Relays are configured at **runtime** (`NOSTERM_DEFAULT_RELAYS`), so a single
|
||||
prebuilt image works for any deployment. The remaining `PUBLIC_*` tunables are
|
||||
**build-time** args baked into the browser bundle (client-visible, never
|
||||
secrets) — change one → rebuild the image.
|
||||
|
||||
**Runtime** (set on the running container, no rebuild):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------------------ | ------------ | ------------------------------------------------------------ |
|
||||
| `NOSTERM_DEFAULT_RELAYS` | _empty_ | Comma-separated relay WebSocket URLs to preload |
|
||||
| `RELAY_UPSTREAM` | `relay:3334` | Upstream for Caddy's optional `/relay` reverse-proxy (below) |
|
||||
|
||||
**Build-time** (baked into the bundle; rebuild to change):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------------------------------- | ------- | --------------------------------------------- |
|
||||
| `PUBLIC_DEFAULT_RELAYS` | _empty_ | Fallback relays when no runtime config is set |
|
||||
| `PUBLIC_DEFAULT_THEME` | `nord` | Theme applied before the user picks one |
|
||||
| `PUBLIC_MAX_TERMINAL_ENTRIES` | `1000` | Terminal entries kept in memory / rendered |
|
||||
| `PUBLIC_MAX_MESSAGE_LENGTH` | `2000` | Max outbound message length (characters) |
|
||||
| `PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM` | `500` | Messages retained per room in IndexedDB |
|
||||
|
||||
## Talking to a relay
|
||||
|
||||
The client connects directly to whatever relays are configured in
|
||||
`NOSTERM_DEFAULT_RELAYS` (and any the user adds at runtime). Two common setups:
|
||||
|
||||
- **External relay** — set `NOSTERM_DEFAULT_RELAYS=wss://relay.example.com`.
|
||||
You can delete the `handle /relay*` block from the `Caddyfile`.
|
||||
- **Same-origin home relay** — run
|
||||
[nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay), set
|
||||
`NOSTERM_DEFAULT_RELAYS=ws://localhost:8080/relay` (or `wss://…` behind TLS),
|
||||
and set `RELAY_UPSTREAM` to the relay's address so Caddy proxies `/relay` to
|
||||
it. Put both services on a shared Docker network for name resolution.
|
||||
|
||||
## Production TLS
|
||||
|
||||
For a real domain, edit the `Caddyfile`: replace `:80` with your hostname (e.g.
|
||||
`nosterm.example.com { … }`) and Caddy will obtain and renew a certificate
|
||||
automatically. Expose ports 80 and 443 in `docker-compose.yml`.
|
||||
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="240" viewBox="0 0 820 240" role="img" aria-label="Nosterm — a terminal-style Nostr chat application">
|
||||
<title>Nosterm — a terminal-style Nostr chat application</title>
|
||||
|
||||
<!-- Nord palette: bg #2e3440, surface #3b4252, frame #434c5e,
|
||||
cyan #88c0d0, snow #eceff4, comment #616e88, green #a3be8c -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3b4252" />
|
||||
<stop offset="1" stop-color="#2e3440" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- outer canvas -->
|
||||
<rect width="820" height="240" rx="14" fill="#2e3440" />
|
||||
|
||||
<!-- terminal window -->
|
||||
<rect x="40" y="34" width="740" height="172" rx="10" fill="url(#bg)" stroke="#434c5e" stroke-width="1.5" />
|
||||
|
||||
<!-- title bar -->
|
||||
<rect x="40" y="34" width="740" height="34" rx="10" fill="#434c5e" />
|
||||
<rect x="40" y="58" width="740" height="10" fill="#434c5e" />
|
||||
<circle cx="64" cy="51" r="5.5" fill="#bf616a" />
|
||||
<circle cx="84" cy="51" r="5.5" fill="#ebcb8b" />
|
||||
<circle cx="104" cy="51" r="5.5" fill="#a3be8c" />
|
||||
<text x="410" y="55" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13" fill="#616e88">nosterm — /home/you</text>
|
||||
|
||||
<!-- terminal body -->
|
||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
|
||||
<!-- wordmark: prompt + name -->
|
||||
<text x="70" y="126" font-size="46" font-weight="700" fill="#88c0d0">>_</text>
|
||||
<text x="152" y="126" font-size="46" font-weight="700" fill="#eceff4">nosterm</text>
|
||||
|
||||
<!-- tagline as a shell comment -->
|
||||
<text x="72" y="162" font-size="16" fill="#616e88"># a terminal-style Nostr chat client + home relay</text>
|
||||
|
||||
<!-- a sample command line -->
|
||||
<text x="72" y="190" font-size="16" fill="#a3be8c">$</text>
|
||||
<text x="92" y="190" font-size="16" fill="#d8dee9">/join</text>
|
||||
<text x="150" y="190" font-size="16" fill="#88c0d0">#general</text>
|
||||
<rect x="238" y="177" width="9" height="17" fill="#88c0d0">
|
||||
<animate attributeName="opacity" values="1;1;0;0" dur="1.1s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
# Standalone Nosterm client deployment.
|
||||
#
|
||||
# Serves the static SvelteKit SPA via Caddy on port 8080. This directory deploys
|
||||
# ONLY the client — point it at a relay of your choice with NOSTERM_DEFAULT_RELAYS
|
||||
# (runtime, see .env.example). To also run a home relay, use the nosterm-relay
|
||||
# repo (or set RELAY_UPSTREAM so Caddy's /relay proxy reaches it).
|
||||
#
|
||||
# cp .env.example .env # then edit
|
||||
# docker compose up -d --build
|
||||
|
||||
services:
|
||||
nosterm:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Non-relay tunables are still baked in at build time. Relays are set at
|
||||
# runtime via NOSTERM_DEFAULT_RELAYS below (no rebuild to change them).
|
||||
PUBLIC_DEFAULT_THEME: ${PUBLIC_DEFAULT_THEME:-nord}
|
||||
PUBLIC_MAX_TERMINAL_ENTRIES: ${PUBLIC_MAX_TERMINAL_ENTRIES:-1000}
|
||||
PUBLIC_MAX_MESSAGE_LENGTH: ${PUBLIC_MAX_MESSAGE_LENGTH:-2000}
|
||||
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM: ${PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM:-500}
|
||||
image: nosterm:latest
|
||||
environment:
|
||||
# Relay(s) the client preloads, written to /config.json on startup. For a
|
||||
# same-origin home relay use "ws://localhost:8080/relay" (proxied by Caddy)
|
||||
# and set RELAY_UPSTREAM to its address. Otherwise point at an external relay.
|
||||
NOSTERM_DEFAULT_RELAYS: ${NOSTERM_DEFAULT_RELAYS:-}
|
||||
# Upstream for Caddy's optional /relay proxy. Only used if the client
|
||||
# reaches the relay at ws://<host>/relay. Harmless if unused.
|
||||
RELAY_UPSTREAM: ${RELAY_UPSTREAM:-relay:3334}
|
||||
ports:
|
||||
- '8080:80'
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# regenerate runtime client config from env before caddy starts.
|
||||
#
|
||||
# the SPA fetches /config.json at startup, so this lets one prebuilt image be
|
||||
# deployed with different relays per environment without a rebuild. set
|
||||
# NOSTERM_DEFAULT_RELAYS to a comma-separated list of relay websocket urls.
|
||||
set -eu
|
||||
|
||||
CONFIG_PATH=/srv/config.json
|
||||
RELAYS="${NOSTERM_DEFAULT_RELAYS:-}"
|
||||
|
||||
# build a json array from the comma-separated list, trimming blanks.
|
||||
relays_json=""
|
||||
IFS=','
|
||||
for relay in $RELAYS; do
|
||||
# trim surrounding whitespace.
|
||||
trimmed="$(printf '%s' "$relay" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
[ -z "$trimmed" ] && continue
|
||||
if [ -z "$relays_json" ]; then
|
||||
relays_json="\"$trimmed\""
|
||||
else
|
||||
relays_json="$relays_json,\"$trimmed\""
|
||||
fi
|
||||
done
|
||||
unset IFS
|
||||
|
||||
printf '{\n "relays": [%s]\n}\n' "$relays_json" > "$CONFIG_PATH"
|
||||
|
||||
if [ -n "$relays_json" ]; then
|
||||
echo "[entrypoint] wrote $CONFIG_PATH with relays: $RELAYS"
|
||||
else
|
||||
echo "[entrypoint] no NOSTERM_DEFAULT_RELAYS set; wrote empty relay list"
|
||||
fi
|
||||
|
||||
# hand off to the container command (caddy).
|
||||
exec "$@"
|
||||
@@ -0,0 +1,40 @@
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'.svelte-kit/',
|
||||
'build/',
|
||||
'coverage/',
|
||||
'test-results/',
|
||||
'playwright-report/',
|
||||
'node_modules/'
|
||||
]
|
||||
}
|
||||
];
|
||||
Generated
+7156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "nosterm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "A terminal-style Nostr chat client.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "sirv build --single --port 4173",
|
||||
"prepare": "svelte-kit sync",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.2",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"svelte": "^5.16.0",
|
||||
"svelte-check": "^4.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.19.0",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^2.10.7",
|
||||
"dexie": "^4.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Semantic design tokens. These defaults mirror the Nord theme so the UI is
|
||||
* fully styled before JavaScript applies the persisted theme (no FOUC).
|
||||
* The theme service overwrites these on :root at runtime.
|
||||
*/
|
||||
:root {
|
||||
--color-background: #2e3440;
|
||||
--color-surface: #3b4252;
|
||||
--color-surface-raised: #434c5e;
|
||||
--color-border: #4c566a;
|
||||
|
||||
--color-text-primary: #eceff4;
|
||||
--color-text-secondary: #d8dee9;
|
||||
--color-text-muted: #7b88a1;
|
||||
|
||||
--color-accent: #88c0d0;
|
||||
--color-link: #8fbcbb;
|
||||
--color-focus: #88c0d0;
|
||||
|
||||
--color-success: #a3be8c;
|
||||
--color-warning: #ebcb8b;
|
||||
--color-error: #bf616a;
|
||||
--color-info: #81a1c1;
|
||||
|
||||
--color-terminal-system: #81a1c1;
|
||||
--color-terminal-notice: #88c0d0;
|
||||
--color-terminal-command: #b48ead;
|
||||
--color-terminal-action: #ebcb8b;
|
||||
--color-terminal-timestamp: #7b88a1;
|
||||
|
||||
--color-relay-connected: #a3be8c;
|
||||
--color-relay-connecting: #ebcb8b;
|
||||
--color-relay-disconnected: #7b88a1;
|
||||
--color-relay-error: #bf616a;
|
||||
|
||||
--font-terminal:
|
||||
'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--terminal-font-size: 14px;
|
||||
--terminal-line-height: 1.5;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-terminal);
|
||||
font-size: var(--terminal-font-size);
|
||||
line-height: var(--terminal-line-height);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Themed scrollbars. */
|
||||
* {
|
||||
scrollbar-color: var(--color-border) var(--color-surface);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Visible, theme-aware focus indicator on all interactive elements. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Respect the reduced-motion preference and the app setting (via data attr). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
:root[data-reduced-motion='true'] *,
|
||||
:root[data-reduced-motion='true'] *::before,
|
||||
:root[data-reduced-motion='true'] *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
// see https://svelte.dev/docs/kit/types#app.d.ts
|
||||
import type { Nip07Signer } from '$lib/types/nostr';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
// nip-07 browser extension surface. optional — not every browser has one.
|
||||
interface Window {
|
||||
nostr?: Nip07Signer;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="description" content="Nosterm — A terminal-style Nostr chat client." />
|
||||
<title>Nosterm</title>
|
||||
<!--
|
||||
Anti-FOUC theme bootstrap: apply the persisted theme's CSS tokens to
|
||||
<html> before the app renders, so there's no flash of the default theme.
|
||||
This runs synchronously and only reads a small settings key from
|
||||
localStorage (a fast mirror of the IndexedDB-persisted preference).
|
||||
-->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('nosterm:active-theme');
|
||||
if (raw) document.documentElement.setAttribute('data-theme', raw);
|
||||
} catch (_) {
|
||||
/* localStorage may be unavailable; the app applies the default later. */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authService } from '$lib/nostr/auth-service';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { closeAuth } from '$lib/stores/ui-store';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { BRAND } from '$lib/brand';
|
||||
|
||||
// which sub-view of the sign-in flow is showing.
|
||||
type View = 'choose' | 'bunker' | 'nsec' | 'generated' | 'unlock';
|
||||
let view = $state<View>('choose');
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// field state per flow.
|
||||
let bunkerToken = $state('');
|
||||
let nsecInput = $state('');
|
||||
let remember = $state(false);
|
||||
let passphrase = $state('');
|
||||
|
||||
// one-time key display after generating.
|
||||
let generated = $state<{ npub: string; nsec: string } | null>(null);
|
||||
let copied = $state<'npub' | 'nsec' | null>(null);
|
||||
|
||||
// remembered (encrypted) identity, if there's one on this device.
|
||||
let rememberedPubkey = $state<string | undefined>(undefined);
|
||||
let unlockPassphrase = $state('');
|
||||
|
||||
const hasExtension = authService.hasNip07();
|
||||
|
||||
onMount(async () => {
|
||||
rememberedPubkey = await authService.rememberedPubkey();
|
||||
if (rememberedPubkey) view = 'unlock';
|
||||
});
|
||||
|
||||
function done(pubkey: string, how: string) {
|
||||
terminalStore.system(`Logged in as ${shortenPubkey(pubkey)} (${how}).`, 'notice');
|
||||
// nip-65 outbox: connect the user's advertised relays. skip for throwaway
|
||||
// identities — they won't have a published relay list.
|
||||
if (how !== 'ephemeral') void relayService.discoverAndConnect(pubkey);
|
||||
closeAuth();
|
||||
}
|
||||
|
||||
function fail(e: unknown) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function useExtension() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const pk = await authService.loginNip07();
|
||||
done(pk, 'NIP-07');
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectBunker() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const pk = await authService.loginBunker(bunkerToken);
|
||||
done(pk, 'remote signer');
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importNsec() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const pk = authService.loginNsec(nsecInput);
|
||||
if (remember) {
|
||||
await authService.rememberOnDevice(passphrase);
|
||||
}
|
||||
nsecInput = '';
|
||||
passphrase = '';
|
||||
terminalStore.system(
|
||||
'Your private key is held in this browser. Prefer an extension or bunker when possible.',
|
||||
'warning'
|
||||
);
|
||||
done(pk, remember ? 'nsec, remembered on device' : 'nsec, in-memory');
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
function generate() {
|
||||
error = '';
|
||||
try {
|
||||
authService.generate();
|
||||
generated = authService.exportKeyPair();
|
||||
view = 'generated';
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishGenerated() {
|
||||
// optionally remember the fresh key, encrypted.
|
||||
if (remember && passphrase) {
|
||||
try {
|
||||
await authService.rememberOnDevice(passphrase);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pk = authService.pubkey;
|
||||
generated = null;
|
||||
passphrase = '';
|
||||
if (pk) done(pk, 'new key');
|
||||
}
|
||||
|
||||
async function unlock() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const pk = await authService.unlockRemembered(unlockPassphrase);
|
||||
unlockPassphrase = '';
|
||||
done(pk, 'remembered key');
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetRemembered() {
|
||||
await authService.forgetRemembered();
|
||||
rememberedPubkey = undefined;
|
||||
view = 'choose';
|
||||
}
|
||||
|
||||
async function copy(kind: 'npub' | 'nsec', value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = kind;
|
||||
setTimeout(() => (copied = null), 1500);
|
||||
} catch {
|
||||
/* no clipboard; ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function skip() {
|
||||
// throwaway identity so the user can look around without committing.
|
||||
const pk = authService.loginEphemeral();
|
||||
terminalStore.system('Exploring with a temporary identity.', 'notice');
|
||||
done(pk, 'ephemeral');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="background: color-mix(in srgb, var(--color-background) 82%, transparent);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="sign in to Nosterm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md overflow-hidden rounded border"
|
||||
style="background: var(--color-surface); border-color: var(--color-border);"
|
||||
>
|
||||
<!-- header -->
|
||||
<div class="border-b px-5 py-4" style="border-color: var(--color-border);">
|
||||
<div class="text-lg font-bold" style="color: var(--color-accent);">{BRAND.name}</div>
|
||||
<div class="text-xs" style="color: var(--color-text-muted);">{BRAND.tagline}</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
{#if error}
|
||||
<p class="mb-3 text-xs" style="color: var(--color-error);" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if view === 'choose'}
|
||||
<p class="mb-3 text-sm" style="color: var(--color-text-secondary);">
|
||||
Sign in to start chatting.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||
style="background: var(--color-surface-raised); color: var(--color-text-primary); {hasExtension
|
||||
? 'outline: 1px solid var(--color-accent);'
|
||||
: 'opacity: 0.6;'}"
|
||||
disabled={busy || !hasExtension}
|
||||
onclick={useExtension}
|
||||
>
|
||||
🔐 Browser extension (NIP-07)
|
||||
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||
{hasExtension
|
||||
? 'Recommended — your key stays in the extension.'
|
||||
: 'No extension detected.'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
view = 'bunker';
|
||||
error = '';
|
||||
}}
|
||||
>
|
||||
📡 Remote signer (bunker / NIP-46)
|
||||
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||
Your key stays on a remote signer.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||
disabled={busy}
|
||||
onclick={generate}
|
||||
>
|
||||
✨ Create a new key
|
||||
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||
Generate a fresh Nostr identity.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
view = 'nsec';
|
||||
error = '';
|
||||
}}
|
||||
>
|
||||
🔑 Paste an nsec
|
||||
<span class="block text-xs" style="color: var(--color-warning);">
|
||||
⚠ Advanced — puts your private key in the browser.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs underline"
|
||||
style="color: var(--color-text-muted);"
|
||||
onclick={skip}
|
||||
>
|
||||
Explore with a temporary identity
|
||||
</button>
|
||||
</div>
|
||||
{:else if view === 'bunker'}
|
||||
<label
|
||||
class="mb-1 block text-sm"
|
||||
style="color: var(--color-text-secondary);"
|
||||
for="bunker-in"
|
||||
>
|
||||
Bunker connection string or NIP-05
|
||||
</label>
|
||||
<input
|
||||
id="bunker-in"
|
||||
type="text"
|
||||
bind:value={bunkerToken}
|
||||
placeholder="bunker://… or you@example.com"
|
||||
autocomplete="off"
|
||||
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
/>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1 text-sm"
|
||||
style="background: var(--color-accent); color: var(--color-background);"
|
||||
disabled={busy}
|
||||
onclick={connectBunker}
|
||||
>
|
||||
{busy ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm"
|
||||
style="color: var(--color-text-muted);"
|
||||
onclick={() => (view = 'choose')}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
{:else if view === 'nsec'}
|
||||
<p class="mb-2 text-xs" style="color: var(--color-warning);">
|
||||
⚠ Pasting an nsec puts your private key in this browser tab. A browser extension or remote
|
||||
signer is safer. Only continue if you understand the risk.
|
||||
</p>
|
||||
<label class="mb-1 block text-sm" style="color: var(--color-text-secondary);" for="nsec-in">
|
||||
nsec (or hex private key)
|
||||
</label>
|
||||
<input
|
||||
id="nsec-in"
|
||||
type="password"
|
||||
bind:value={nsecInput}
|
||||
placeholder="nsec1…"
|
||||
autocomplete="off"
|
||||
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
/>
|
||||
<label
|
||||
class="mt-3 flex items-center gap-2 text-xs"
|
||||
style="color: var(--color-text-secondary);"
|
||||
>
|
||||
<input type="checkbox" bind:checked={remember} />
|
||||
Remember on this device (encrypted with a passphrase)
|
||||
</label>
|
||||
{#if remember}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={passphrase}
|
||||
placeholder="encryption passphrase (min 8 chars)"
|
||||
autocomplete="new-password"
|
||||
class="mt-2 w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1 text-sm"
|
||||
style="background: var(--color-accent); color: var(--color-background);"
|
||||
disabled={busy}
|
||||
onclick={importNsec}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm"
|
||||
style="color: var(--color-text-muted);"
|
||||
onclick={() => (view = 'choose')}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
{:else if view === 'generated'}
|
||||
<p class="mb-2 text-sm" style="color: var(--color-text-primary);">Your new identity</p>
|
||||
<p class="mb-3 text-xs" style="color: var(--color-warning);">
|
||||
⚠ Save your nsec now. It is shown once and is not stored unless you choose to remember it
|
||||
below.
|
||||
</p>
|
||||
<div class="space-y-2 text-xs" style="font-family: var(--font-terminal);">
|
||||
<div>
|
||||
<span style="color: var(--color-text-muted);">npub</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 break-all text-left underline"
|
||||
style="color: var(--color-link);"
|
||||
onclick={() => generated && copy('npub', generated.npub)}
|
||||
>
|
||||
{generated?.npub}
|
||||
</button>
|
||||
{#if copied === 'npub'}<span style="color: var(--color-success);"> copied</span>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--color-text-muted);">nsec</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 break-all text-left underline"
|
||||
style="color: var(--color-warning);"
|
||||
onclick={() => generated && copy('nsec', generated.nsec)}
|
||||
>
|
||||
{generated?.nsec}
|
||||
</button>
|
||||
{#if copied === 'nsec'}<span style="color: var(--color-success);"> copied</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="mt-3 flex items-center gap-2 text-xs"
|
||||
style="color: var(--color-text-secondary);"
|
||||
>
|
||||
<input type="checkbox" bind:checked={remember} />
|
||||
Remember on this device (encrypted)
|
||||
</label>
|
||||
{#if remember}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={passphrase}
|
||||
placeholder="encryption passphrase (min 8 chars)"
|
||||
autocomplete="new-password"
|
||||
class="mt-2 w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
/>
|
||||
{/if}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1 text-sm"
|
||||
style="background: var(--color-accent); color: var(--color-background);"
|
||||
onclick={finishGenerated}
|
||||
>
|
||||
I've saved it — continue
|
||||
</button>
|
||||
</div>
|
||||
{:else if view === 'unlock'}
|
||||
<p class="mb-2 text-sm" style="color: var(--color-text-primary);">Welcome back</p>
|
||||
<p class="mb-3 text-xs" style="color: var(--color-text-muted);">
|
||||
Remembered identity: {rememberedPubkey ? shortenPubkey(rememberedPubkey) : ''}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={unlockPassphrase}
|
||||
placeholder="passphrase"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1 text-sm"
|
||||
style="background: var(--color-accent); color: var(--color-background);"
|
||||
disabled={busy}
|
||||
onclick={unlock}
|
||||
>
|
||||
{busy ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs underline"
|
||||
style="color: var(--color-text-muted);"
|
||||
onclick={() => (view = 'choose')}
|
||||
>
|
||||
Use another method
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-xs underline"
|
||||
style="color: var(--color-error);"
|
||||
onclick={forgetRemembered}
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { MAX_COMMAND_HISTORY, MAX_MESSAGE_LENGTH } from '$lib/config';
|
||||
|
||||
// parent hands us the submit handler and an optional completion source.
|
||||
let {
|
||||
onsubmit,
|
||||
complete
|
||||
}: { onsubmit: (raw: string) => void; complete?: (prefix: string) => string[] } = $props();
|
||||
|
||||
let value = $state('');
|
||||
let history = $state<string[]>([]);
|
||||
// -1 means "not in history" (editing the live buffer).
|
||||
let historyIndex = $state(-1);
|
||||
let draft = $state('');
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
// where we are in the completion candidates on repeated tab.
|
||||
let completionCycle = $state(0);
|
||||
|
||||
// length counter for plain messages (not commands). only shows once you
|
||||
// pass 80% of the limit so it stays out of the way.
|
||||
let isCommand = $derived(value.trimStart().startsWith('/'));
|
||||
let showCounter = $derived(!isCommand && value.length >= Math.floor(MAX_MESSAGE_LENGTH * 0.8));
|
||||
let remaining = $derived(MAX_MESSAGE_LENGTH - value.length);
|
||||
|
||||
function submit() {
|
||||
const raw = value.trim();
|
||||
if (raw.length === 0) return;
|
||||
// cap message length (also guards command length).
|
||||
const bounded = raw.slice(0, MAX_MESSAGE_LENGTH);
|
||||
|
||||
history = [...history.slice(-(MAX_COMMAND_HISTORY - 1)), bounded];
|
||||
historyIndex = -1;
|
||||
draft = '';
|
||||
value = '';
|
||||
onsubmit(bounded);
|
||||
}
|
||||
|
||||
function recallHistory(direction: -1 | 1) {
|
||||
if (history.length === 0) return;
|
||||
if (historyIndex === -1) {
|
||||
// stepping into history: stash the current draft first.
|
||||
if (direction === 1) return; // nothing newer than the live draft
|
||||
draft = value;
|
||||
historyIndex = history.length - 1;
|
||||
} else {
|
||||
historyIndex += direction;
|
||||
}
|
||||
if (historyIndex >= history.length) {
|
||||
// past the newest entry: back to the live draft.
|
||||
historyIndex = -1;
|
||||
value = draft;
|
||||
return;
|
||||
}
|
||||
if (historyIndex < 0) historyIndex = 0;
|
||||
value = history[historyIndex] ?? '';
|
||||
}
|
||||
|
||||
function tabComplete() {
|
||||
if (!complete) return;
|
||||
// only complete the first token (the command name).
|
||||
if (/\s/.test(value.trim())) return;
|
||||
const candidates = complete(value.trim());
|
||||
if (candidates.length === 0) return;
|
||||
if (candidates.length === 1) {
|
||||
value = candidates[0] ?? value;
|
||||
completionCycle = 0;
|
||||
return;
|
||||
}
|
||||
// multiple matches: cycle through them on repeated tab presses.
|
||||
value = candidates[completionCycle % candidates.length] ?? value;
|
||||
completionCycle += 1;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') completionCycle = 0;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
recallHistory(-1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
recallHistory(1);
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
tabComplete();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex items-center gap-2 border-t px-3 py-2"
|
||||
style="border-color: var(--color-border); background: var(--color-surface);"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label for="command-input" class="select-none font-bold" style="color: var(--color-accent);">
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value
|
||||
id="command-input"
|
||||
name="command-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
maxlength={MAX_MESSAGE_LENGTH}
|
||||
placeholder="Type a message, or /help for commands"
|
||||
class="flex-1 bg-transparent outline-none"
|
||||
style="color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||
aria-label="command input"
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if showCounter}
|
||||
<span
|
||||
class="shrink-0 select-none text-xs tabular-nums"
|
||||
style="color: {remaining <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};"
|
||||
aria-live="polite"
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script lang="ts">
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { dmStore } from '$lib/stores/dm-store';
|
||||
import { serverStore } from '$lib/stores/server-store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { profileStore } from '$lib/stores/profile-store';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import type { RelayState } from '$lib/types/relay';
|
||||
|
||||
// relay server windows, pinned above rooms (irc-style server buffers). one per
|
||||
// connected/known relay, ordered by url for a stable list.
|
||||
let servers = $derived(Object.values($relayStore).sort((a, b) => a.url.localeCompare(b.url)));
|
||||
let activeServerUrl = $derived($serverStore.activeUrl);
|
||||
|
||||
let rooms = $derived(Object.values($roomStore.rooms));
|
||||
// a room lights up only when no server or dm view is focused.
|
||||
let activeId = $derived(
|
||||
$serverStore.activeUrl || $dmStore.activePubkey ? undefined : $roomStore.activeRoomId
|
||||
);
|
||||
|
||||
let dms = $derived(Object.values($dmStore.conversations));
|
||||
let activeDmPubkey = $derived($serverStore.activeUrl ? undefined : $dmStore.activePubkey);
|
||||
|
||||
function openServer(url: string) {
|
||||
dmStore.close();
|
||||
serverStore.open(url);
|
||||
}
|
||||
|
||||
function switchTo(id: string) {
|
||||
serverStore.close();
|
||||
dmStore.close();
|
||||
roomStore.setActive(id);
|
||||
}
|
||||
|
||||
function openDm(pubkey: string) {
|
||||
serverStore.close();
|
||||
dmStore.open(pubkey);
|
||||
}
|
||||
|
||||
function dmLabel(pubkey: string): string {
|
||||
return $profileStore[pubkey] ?? shortenPubkey(pubkey);
|
||||
}
|
||||
|
||||
// a little connection glyph for a relay server row.
|
||||
function serverGlyph(state: string): string {
|
||||
return state === 'connected'
|
||||
? '●'
|
||||
: state === 'connecting' || state === 'reconnecting'
|
||||
? '◐'
|
||||
: '○';
|
||||
}
|
||||
function serverColor(state: string): string {
|
||||
if (state === 'connected') return 'var(--color-relay-connected)';
|
||||
if (state === 'error' || state === 'disconnected') return 'var(--color-relay-disconnected)';
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
// base label for a relay row: nip-11 name if we know it, else the host.
|
||||
function baseLabel(url: string, name?: string): string {
|
||||
if (name) return name;
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// a telltale suffix (host + path) to tell apart relays that share a base
|
||||
// label — e.g. a clearnet + tor endpoint of the same relay both report the
|
||||
// same nip-11 name and host, differing only by path (/relay vs /relay-tor).
|
||||
function urlSuffix(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const path = u.pathname === '/' ? '' : u.pathname;
|
||||
return `${u.host}${path}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// a relay reached over tor: the /relay-tor bridge path, or a raw .onion host.
|
||||
function isTorRelay(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.pathname.includes('/relay-tor') || u.hostname.endsWith('.onion');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// unique display label per relay: start from the base label, and where two or
|
||||
// more relays collide, tack on their url suffix so every row reads clearly.
|
||||
let serverLabels = $derived.by(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of servers) {
|
||||
const b = baseLabel(s.url, s.info?.name);
|
||||
counts.set(b, (counts.get(b) ?? 0) + 1);
|
||||
}
|
||||
const labels = new Map<string, string>();
|
||||
for (const s of servers) {
|
||||
const b = baseLabel(s.url, s.info?.name);
|
||||
labels.set(s.url, (counts.get(b) ?? 0) > 1 ? `${b} (${urlSuffix(s.url)})` : b);
|
||||
}
|
||||
return labels;
|
||||
});
|
||||
|
||||
// one row per relay. where the same relay (same base label) is reachable both
|
||||
// over clearnet and over tor, squash the two endpoints into one row so the
|
||||
// list shows the relay once, marked with the onion→relay bridge icons.
|
||||
interface RelayRow {
|
||||
key: string; // stable #each key
|
||||
label: string; // short display name
|
||||
primary: RelayState; // server window opened on click (clearnet preferred)
|
||||
tor?: RelayState; // tor endpoint — set only on a bridged row
|
||||
clearnet?: RelayState; // clearnet endpoint, when the relay also has one
|
||||
}
|
||||
let relayRows = $derived.by(() => {
|
||||
// group by base label — the usual "same relay, different endpoint" idea.
|
||||
const groups = new Map<string, RelayState[]>();
|
||||
for (const s of servers) {
|
||||
const b = baseLabel(s.url, s.info?.name);
|
||||
const list = groups.get(b);
|
||||
if (list) list.push(s);
|
||||
else groups.set(b, [s]);
|
||||
}
|
||||
const rows: RelayRow[] = [];
|
||||
for (const [label, list] of groups) {
|
||||
const tor = list.find((s) => isTorRelay(s.url));
|
||||
if (tor) {
|
||||
const clearnet = list.find((s) => !isTorRelay(s.url));
|
||||
// merge clearnet + tor into one row; prefer clearnet as the direct window.
|
||||
rows.push({ key: tor.url, label, primary: clearnet ?? tor, tor, clearnet });
|
||||
// any leftover endpoints in this group get their own rows (rare).
|
||||
for (const s of list) {
|
||||
if (s !== tor && s !== clearnet) {
|
||||
rows.push({ key: s.url, label: serverLabels.get(s.url) ?? label, primary: s });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const s of list) {
|
||||
rows.push({ key: s.url, label: serverLabels.get(s.url) ?? label, primary: s });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => a.label.localeCompare(b.label));
|
||||
});
|
||||
|
||||
// is any of a merged row's endpoints the focused server window?
|
||||
function rowActive(row: RelayRow): boolean {
|
||||
if (activeServerUrl == null) return false;
|
||||
return (
|
||||
row.primary.url === activeServerUrl ||
|
||||
row.tor?.url === activeServerUrl ||
|
||||
row.clearnet?.url === activeServerUrl
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- onion glyph (tor entry): a layered bulb with a sprout. colored by state. -->
|
||||
{#snippet onionIcon(color: string)}
|
||||
<svg
|
||||
class="h-3 w-3 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3c-.6 1.2-1 2-1 3.2" />
|
||||
<path
|
||||
d="M12 21c-3.9 0-6.5-3-6.5-6.8C5.5 10 8 6.2 12 6.2s6.5 3.8 6.5 8C18.5 18 15.9 21 12 21z"
|
||||
/>
|
||||
<path d="M9.5 11.5c0 3.4.8 6.4 2.5 9" />
|
||||
<path d="M14.5 11.5c0 3.4-.8 6.4-2.5 9" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<!-- relay glyph (destination): a broadcast node with signal arcs. colored by state. -->
|
||||
{#snippet relayIcon(color: string)}
|
||||
<svg
|
||||
class="h-3 w-3 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1.6" fill={color} stroke="none" />
|
||||
<path d="M8.5 8.5a5 5 0 000 7" />
|
||||
<path d="M15.5 8.5a5 5 0 010 7" />
|
||||
<path d="M6 6.5a9 9 0 000 11" />
|
||||
<path d="M18 6.5a9 9 0 010 11" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<!-- the bridge arrow between onion and relay. -->
|
||||
{#snippet arrowIcon()}
|
||||
<svg
|
||||
class="h-2.5 w-2.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--color-text-muted)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h13" />
|
||||
<path d="M12 6l6 6-6 6" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<section aria-label="relays" class="mt-4">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Relays
|
||||
</h2>
|
||||
{#if relayRows.length === 0}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">No relays. /connect <url></p>
|
||||
{:else}
|
||||
<ul class="space-y-1">
|
||||
{#each relayRows as row (row.key)}
|
||||
{@const active = rowActive(row)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||
style="color: {active
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-primary)'}; background: {active
|
||||
? 'var(--color-surface-raised)'
|
||||
: 'transparent'};"
|
||||
title={row.tor
|
||||
? `${row.label}${row.clearnet ? `\nclearnet: ${row.clearnet.url}` : ''}\ntor: ${row.tor.url}`
|
||||
: (row.primary.lastError ?? row.primary.url)}
|
||||
onclick={() => openServer(row.primary.url)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
aria-label={row.tor ? `Relay ${row.label}, reachable over Tor` : undefined}
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{#if row.tor}
|
||||
<!-- onion (tor path) → relay (destination), replacing the dot. -->
|
||||
{@render onionIcon(serverColor(row.tor.connection))}
|
||||
{@render arrowIcon()}
|
||||
{@render relayIcon(serverColor(row.primary.connection))}
|
||||
{:else}
|
||||
<span style="color: {serverColor(row.primary.connection)};" aria-hidden="true">
|
||||
{serverGlyph(row.primary.connection)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate">{row.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-label="rooms" class="mt-4">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Rooms
|
||||
</h2>
|
||||
{#if rooms.length === 0}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">No rooms. /join #room</p>
|
||||
{:else}
|
||||
<ul class="space-y-1">
|
||||
{#each rooms as room (room.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||
style="color: {room.id === activeId
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-primary)'}; background: {room.id === activeId
|
||||
? 'var(--color-surface-raised)'
|
||||
: 'transparent'};"
|
||||
title={`${room.address.groupId} @ ${room.address.relayUrl}`}
|
||||
onclick={() => switchTo(room.id)}
|
||||
aria-current={room.id === activeId ? 'true' : undefined}
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="truncate" style={room.unread > 0 ? 'font-weight: 600;' : ''}>
|
||||
#{room.name}
|
||||
</span>
|
||||
{#if room.unread > 0}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded-full px-1.5 text-[10px] font-bold"
|
||||
style="background: var(--color-info); color: var(--color-background);"
|
||||
aria-label={`${room.unread} unread`}
|
||||
>
|
||||
{room.unread > 99 ? '99+' : room.unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if dms.length > 0}
|
||||
<section aria-label="direct messages" class="mt-4">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Direct Messages
|
||||
</h2>
|
||||
<ul class="space-y-1">
|
||||
{#each dms as dm (dm.pubkey)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||
style="color: {dm.pubkey === activeDmPubkey
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-primary)'}; background: {dm.pubkey === activeDmPubkey
|
||||
? 'var(--color-surface-raised)'
|
||||
: 'transparent'};"
|
||||
onclick={() => openDm(dm.pubkey)}
|
||||
aria-current={dm.pubkey === activeDmPubkey ? 'true' : undefined}
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="truncate" style={dm.unread > 0 ? 'font-weight: 600;' : ''}>
|
||||
@{dmLabel(dm.pubkey)}
|
||||
</span>
|
||||
{#if dm.unread > 0}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded-full px-1.5 text-[10px] font-bold"
|
||||
style="background: var(--color-info); color: var(--color-background);"
|
||||
aria-label={`${dm.unread} unread`}
|
||||
>
|
||||
{dm.unread > 99 ? '99+' : dm.unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { activeRoom } from '$lib/stores/room-store';
|
||||
import { activeDm } from '$lib/stores/dm-store';
|
||||
import { serverStore } from '$lib/stores/server-store';
|
||||
import { profileStore } from '$lib/stores/profile-store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { activeThemeId } from '$lib/stores/theme-store';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
|
||||
// the focused-scope label, in precedence order: server window, dm, then room.
|
||||
let scopeLabel = $derived(
|
||||
$serverStore.activeUrl
|
||||
? $serverStore.activeUrl
|
||||
: $activeDm
|
||||
? `@${$profileStore[$activeDm.pubkey] ?? shortenPubkey($activeDm.pubkey)}`
|
||||
: $activeRoom
|
||||
? `#${$activeRoom.name}`
|
||||
: '(none)'
|
||||
);
|
||||
// the scope-kind label in front of it (server:/dm:/room:).
|
||||
let scopeKind = $derived($serverStore.activeUrl ? 'server:' : $activeDm ? 'dm:' : 'room:');
|
||||
|
||||
// count connected relays for a compact status indicator.
|
||||
let connected = $derived(
|
||||
Object.values($relayStore).filter((r) => r.connection === 'connected').length
|
||||
);
|
||||
let total = $derived(Object.keys($relayStore).length);
|
||||
|
||||
// short tag for how the identity is held (nip07/bunker/nsec/temp).
|
||||
const KIND_TAG: Record<string, string> = {
|
||||
nip07: 'nip07',
|
||||
bunker: 'bunker',
|
||||
nsec: 'nsec',
|
||||
ephemeral: 'temp'
|
||||
};
|
||||
let identity = $derived(
|
||||
$authStore.pubkey
|
||||
? `${KIND_TAG[$authStore.kind] ? `${KIND_TAG[$authStore.kind]}:` : ''}${shortenPubkey($authStore.pubkey)}`
|
||||
: 'not logged in'
|
||||
);
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t px-3 py-1 text-xs"
|
||||
style="border-color: var(--color-border); background: var(--color-surface); color: var(--color-text-secondary);"
|
||||
aria-label="status bar"
|
||||
>
|
||||
<span>
|
||||
<span style="color: var(--color-text-muted);">relays:</span>
|
||||
<span
|
||||
style="color: {connected > 0
|
||||
? 'var(--color-relay-connected)'
|
||||
: 'var(--color-relay-disconnected)'};"
|
||||
>
|
||||
{connected}/{total} connected
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style="color: var(--color-text-muted);">{scopeKind}</span>
|
||||
<span style="color: var(--color-text-primary);">
|
||||
{scopeLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style="color: var(--color-text-muted);">id:</span>
|
||||
<span style="color: var(--color-text-primary);">{identity}</span>
|
||||
</span>
|
||||
<span class="ml-auto">
|
||||
<span style="color: var(--color-text-muted);">theme:</span>
|
||||
<span style="color: var(--color-accent);">{$activeThemeId}</span>
|
||||
</span>
|
||||
</footer>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { terminalStore, isEntryVisible } from '$lib/stores/terminal-store';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { dmStore, dmRoomId } from '$lib/stores/dm-store';
|
||||
import { serverStore, relayRoomId } from '$lib/stores/server-store';
|
||||
import { activeThemeId } from '$lib/stores/theme-store';
|
||||
import { getThemeOrDefault } from '$lib/themes/theme-registry';
|
||||
import { usernameColor } from '$lib/themes/username-color';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal';
|
||||
|
||||
let viewport = $state<HTMLDivElement | null>(null);
|
||||
// is the user near the bottom? only auto-scroll if so.
|
||||
let pinnedToBottom = $state(true);
|
||||
|
||||
let theme = $derived(getThemeOrDefault($activeThemeId));
|
||||
|
||||
// the focused conversation, in precedence order: relay server window, then
|
||||
// dm view, then active room. each is scoped by a distinct roomId marker.
|
||||
let activeScope = $derived(
|
||||
$serverStore.activeUrl
|
||||
? relayRoomId($serverStore.activeUrl)
|
||||
: $dmStore.activePubkey
|
||||
? dmRoomId($dmStore.activePubkey)
|
||||
: $roomStore.activeRoomId
|
||||
);
|
||||
|
||||
// irc-style topic bar: only for a real room (not a server/dm window),
|
||||
// when that room has a topic set via nip-29 metadata / the /topic command.
|
||||
let activeRoomForTopic = $derived(
|
||||
!$serverStore.activeUrl && !$dmStore.activePubkey && $roomStore.activeRoomId
|
||||
? $roomStore.rooms[$roomStore.activeRoomId]
|
||||
: undefined
|
||||
);
|
||||
|
||||
// show entries for the active scope (room or dm) plus global lines (system/
|
||||
// notice/command output, which carry no roomId). a scoped entry only shows
|
||||
// while its room/dm is active, so switching shows the right conversation.
|
||||
let entries = $derived($terminalStore.filter((e) => isEntryVisible(e, activeScope)));
|
||||
|
||||
// color for non-message lines. messages/actions color the username instead.
|
||||
const TYPE_COLOR: Record<TerminalEntryType, string> = {
|
||||
message: 'var(--color-text-primary)',
|
||||
action: 'var(--color-terminal-action)',
|
||||
system: 'var(--color-terminal-system)',
|
||||
notice: 'var(--color-terminal-notice)',
|
||||
warning: 'var(--color-warning)',
|
||||
error: 'var(--color-error)',
|
||||
command: 'var(--color-terminal-command)'
|
||||
};
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function authorLabel(entry: TerminalEntry): string {
|
||||
return entry.authorDisplayName ?? (entry.author ? shortenPubkey(entry.author) : 'unknown');
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!viewport) return;
|
||||
const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
pinnedToBottom = distance < 40;
|
||||
}
|
||||
|
||||
// auto-scroll on new entries only when already near the bottom.
|
||||
$effect(() => {
|
||||
// reference length so the effect re-runs on append
|
||||
void entries.length;
|
||||
if (pinnedToBottom) {
|
||||
tick().then(() => {
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col" style="background: var(--color-background);">
|
||||
{#if activeRoomForTopic}
|
||||
<!-- irc-style topic bar: the active room's topic, pinned above the log. -->
|
||||
<div
|
||||
class="shrink-0 truncate border-b px-3 py-1 text-xs"
|
||||
style="border-color: var(--color-border); background: var(--color-surface); color: var(--color-text-secondary);"
|
||||
aria-label="room topic"
|
||||
title={activeRoomForTopic.topic ?? 'No topic set'}
|
||||
>
|
||||
<span style="color: var(--color-text-muted);">Topic for #{activeRoomForTopic.name}:</span>
|
||||
{#if activeRoomForTopic.topic}
|
||||
<span style="color: var(--color-text-primary);">{activeRoomForTopic.topic}</span>
|
||||
{:else}
|
||||
<span style="color: var(--color-text-muted);">(none — set with /topic <text>)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- focusable so the log scrolls with the keyboard (arrow/page keys). -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
bind:this={viewport}
|
||||
onscroll={onScroll}
|
||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-2"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="terminal output"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each entries as entry (entry.id)}
|
||||
<div
|
||||
class="whitespace-pre-wrap break-words leading-relaxed"
|
||||
class:mention={entry.mention}
|
||||
data-entry-type={entry.type}
|
||||
>
|
||||
<span style="color: var(--color-terminal-timestamp);">[{formatTime(entry.timestamp)}]</span>
|
||||
{#if entry.type === 'message'}
|
||||
<span style="color: var(--color-text-muted);"><</span><span
|
||||
style="color: {entry.author
|
||||
? usernameColor(entry.author, theme)
|
||||
: 'var(--color-text-primary)'};">{authorLabel(entry)}</span
|
||||
><span style="color: var(--color-text-muted);">></span>
|
||||
<span style="color: var(--color-text-primary);">{entry.content}</span>
|
||||
{:else if entry.type === 'action'}
|
||||
<span style="color: {TYPE_COLOR.action};">* {authorLabel(entry)} {entry.content}</span>
|
||||
{:else if entry.type === 'command'}
|
||||
<span style="color: {TYPE_COLOR.command};">{entry.content}</span>
|
||||
{:else}
|
||||
<span style="color: {TYPE_COLOR[entry.type]};">*** {entry.content}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* a message that mentions you: accent left-border + faint accent wash, so it
|
||||
stands out without relying on color alone (the border's a shape cue too). */
|
||||
.mention {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding-left: 5px;
|
||||
margin-left: -8px;
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { activeRoom } from '$lib/stores/room-store';
|
||||
import { profileStore } from '$lib/stores/profile-store';
|
||||
import { activeThemeId } from '$lib/stores/theme-store';
|
||||
import { getThemeOrDefault } from '$lib/themes/theme-registry';
|
||||
import { usernameColor } from '$lib/themes/username-color';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { encodeNpub } from '$lib/utils/npub';
|
||||
|
||||
// live display name for the logged-in user (updates after /nick).
|
||||
let myName = $derived($authStore.pubkey ? $profileStore[$authStore.pubkey] : undefined);
|
||||
|
||||
let theme = $derived(getThemeOrDefault($activeThemeId));
|
||||
|
||||
// which member row was just copied, for quick feedback.
|
||||
let copiedPubkey = $state<string | null>(null);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface Participant {
|
||||
pubkey: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
// participants for the active room, admins first then members, deduped.
|
||||
// derived from the relay's nip-29 admin (39001) / member (39002) lists, plus
|
||||
// the logged-in user (if the sidebar shows a room, we've joined it — but the
|
||||
// relay may not have published a 39002 entry for us yet).
|
||||
let participants = $derived.by<Participant[]>(() => {
|
||||
const room = $activeRoom;
|
||||
if (!room) return [];
|
||||
const admins = new Set(room.admins ?? []);
|
||||
const all = new Set<string>([...(room.admins ?? []), ...(room.members ?? [])]);
|
||||
if ($authStore.pubkey) all.add($authStore.pubkey);
|
||||
return [...all]
|
||||
.map((pubkey) => ({ pubkey, isAdmin: admins.has(pubkey) }))
|
||||
.sort((a, b) => {
|
||||
if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1;
|
||||
return label(a.pubkey).localeCompare(label(b.pubkey));
|
||||
});
|
||||
});
|
||||
|
||||
function label(pubkey: string): string {
|
||||
return $profileStore[pubkey] ?? shortenPubkey(pubkey);
|
||||
}
|
||||
|
||||
function flagCopied(pubkey: string) {
|
||||
copiedPubkey = pubkey;
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => (copiedPubkey = null), 1500);
|
||||
}
|
||||
|
||||
async function copyPubkey(pubkey: string) {
|
||||
const npub = encodeNpub(pubkey);
|
||||
// preferred path: async clipboard api (needs a secure context).
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(npub);
|
||||
flagCopied(pubkey);
|
||||
return;
|
||||
} catch {
|
||||
/* fall through to the legacy path (e.g. permission denied). */
|
||||
}
|
||||
}
|
||||
// fallback for non-secure contexts (http on a lan ip): a hidden textarea
|
||||
// + execCommand still works where the clipboard api isn't available.
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = npub;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (ok) flagCopied(pubkey);
|
||||
} catch {
|
||||
/* clipboard genuinely unavailable; nothing more we can do. */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden h-full w-56 shrink-0 overflow-y-auto border-l p-3 lg:block"
|
||||
style="border-color: var(--color-border); background: var(--color-surface);"
|
||||
aria-label="room and identity details"
|
||||
>
|
||||
<section aria-label="room details">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Room
|
||||
</h2>
|
||||
{#if $activeRoom}
|
||||
<p class="text-xs" style="color: var(--color-text-primary);">#{$activeRoom.name}</p>
|
||||
<p class="mt-1 break-all text-xs" style="color: var(--color-text-muted);">
|
||||
{$activeRoom.address.relayUrl}
|
||||
</p>
|
||||
{#if $activeRoom.topic}
|
||||
<p class="mt-2 text-xs" style="color: var(--color-text-secondary);">
|
||||
{$activeRoom.topic}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">No active room.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if $activeRoom}
|
||||
<section aria-label="members" class="mt-4">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Members ({participants.length})
|
||||
</h2>
|
||||
{#if participants.length === 0}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">No member list yet.</p>
|
||||
{:else}
|
||||
<ul class="space-y-0.5">
|
||||
{#each participants as p (p.pubkey)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
onclick={() => copyPubkey(p.pubkey)}
|
||||
title="Click to copy npub"
|
||||
>
|
||||
{#if p.isAdmin}
|
||||
<span
|
||||
title="Admin / operator"
|
||||
aria-label="admin"
|
||||
style="color: var(--color-warning);">★</span
|
||||
>
|
||||
{:else}
|
||||
<span aria-hidden="true" style="color: var(--color-text-muted);">·</span>
|
||||
{/if}
|
||||
<span class="truncate" style="color: {usernameColor(p.pubkey, theme)};"
|
||||
>{label(p.pubkey)}</span
|
||||
>
|
||||
{#if p.pubkey === $authStore.pubkey}
|
||||
<span style="color: var(--color-text-muted);">(you)</span>
|
||||
{/if}
|
||||
{#if copiedPubkey === p.pubkey}
|
||||
<span class="ml-auto" style="color: var(--color-success);">copied</span>
|
||||
{:else if p.isAdmin}
|
||||
<span class="ml-auto" style="color: var(--color-text-muted);">op</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section aria-label="identity" class="mt-4">
|
||||
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||
Identity
|
||||
</h2>
|
||||
{#if $authStore.pubkey}
|
||||
{#if myName}
|
||||
<p class="text-sm" style="color: var(--color-text-primary);">{myName}</p>
|
||||
{/if}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">
|
||||
{$authStore.kind}{$authStore.ephemeral ? ' (temporary)' : ''}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 break-all text-left text-xs underline"
|
||||
style="color: var(--color-link);"
|
||||
onclick={() => copyPubkey($authStore.pubkey!)}
|
||||
title="Click to copy npub"
|
||||
>
|
||||
{copiedPubkey === $authStore.pubkey ? 'copied npub' : shortenPubkey($authStore.pubkey)}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-xs" style="color: var(--color-text-muted);">/login to connect</p>
|
||||
{/if}
|
||||
</section>
|
||||
</aside>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* product naming, kept off on its own so it can change later without touching
|
||||
* protocol or app logic.
|
||||
*/
|
||||
export const BRAND = {
|
||||
name: 'Nosterm',
|
||||
tagline: 'A terminal-style Nostr chat client.'
|
||||
} as const;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { CommandContext } from '$lib/types/command';
|
||||
import { CommandDispatcher } from './dispatcher';
|
||||
import { createRegistry } from './create-registry';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { dmStore } from '$lib/stores/dm-store';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { dmService } from '$lib/nostr/dm-service';
|
||||
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||
import { out } from './output';
|
||||
|
||||
/**
|
||||
* puts the app dispatcher together: a shared registry plus wiring that reads
|
||||
* live context from the stores. plain-text messaging goes to the active room.
|
||||
*/
|
||||
export function createAppDispatcher(): {
|
||||
dispatcher: CommandDispatcher;
|
||||
registry: ReturnType<typeof createRegistry>;
|
||||
} {
|
||||
const registry = createRegistry();
|
||||
// let plugins register commands into this registry.
|
||||
pluginHost.useRegistry(registry);
|
||||
|
||||
const getContext = (): CommandContext => {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const activeRoom = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
// prefer the active room's relay; else the first connected one.
|
||||
const relays = Object.values(get(relayStore));
|
||||
const activeRelay =
|
||||
activeRoom?.address.relayUrl ?? relays.find((r) => r.connection === 'connected')?.url;
|
||||
return {
|
||||
activeRoom: activeRoomId,
|
||||
activeRelay,
|
||||
currentUser: get(authStore).pubkey
|
||||
};
|
||||
};
|
||||
|
||||
const dispatcher = new CommandDispatcher({
|
||||
registry,
|
||||
getContext,
|
||||
async sendMessage(raw) {
|
||||
// let plugins rewrite outgoing text (e.g. :shrug: → ¯\_(ツ)_/¯) on the
|
||||
// send path, for both room messages and dms.
|
||||
const content = pluginHost.applyOutgoing(raw);
|
||||
|
||||
// a focused dm conversation wins: plain text goes to them.
|
||||
const dm = get(dmStore).activePubkey;
|
||||
if (dm) {
|
||||
try {
|
||||
await dmService.send(dm, content);
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('No active room. Join one with /join first, or /dm someone.')]
|
||||
};
|
||||
}
|
||||
try {
|
||||
await roomService.send(active.address, content);
|
||||
// the message echoes back via the room subscription — no local echo.
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { dispatcher, registry };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { tokenize } from './tokenize';
|
||||
|
||||
/** classified user input. plain text is a room message; `/x` is a command. */
|
||||
export type ParsedInput =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'message'; content: string }
|
||||
| { kind: 'command'; name: string; args: string[]; raw: string };
|
||||
|
||||
/**
|
||||
* classify a raw input line without running anything (pure + easy to test).
|
||||
* - empty / whitespace-only input is ignored.
|
||||
* - a leading '/' marks a command; the rest is tokenized (quotes honored).
|
||||
* - a leading '//' is an escaped literal message starting with '/'.
|
||||
* - anything else is a plain room message.
|
||||
*/
|
||||
export function parseInput(raw: string): ParsedInput {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return { kind: 'empty' };
|
||||
|
||||
// escaped slash: "//foo" -> literal message "/foo".
|
||||
if (trimmed.startsWith('//')) {
|
||||
return { kind: 'message', content: trimmed.slice(1) };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const tokens = tokenize(trimmed.slice(1));
|
||||
const name = tokens[0] ?? '';
|
||||
// a lone "/" with no command name counts as a message.
|
||||
if (name.length === 0) return { kind: 'message', content: trimmed };
|
||||
return { kind: 'command', name, args: tokens.slice(1), raw: trimmed };
|
||||
}
|
||||
|
||||
return { kind: 'message', content: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
|
||||
/**
|
||||
* keeps the registered commands and looks up names/aliases without a giant
|
||||
* conditional. add new commands via `register` — no core edits needed.
|
||||
*/
|
||||
export class CommandRegistry {
|
||||
private readonly byName = new Map<string, ChatCommand>();
|
||||
private readonly commands: ChatCommand[] = [];
|
||||
|
||||
register(command: ChatCommand): void {
|
||||
this.assertUnique(command.name);
|
||||
this.byName.set(command.name.toLowerCase(), command);
|
||||
for (const alias of command.aliases ?? []) {
|
||||
this.assertUnique(alias);
|
||||
this.byName.set(alias.toLowerCase(), command);
|
||||
}
|
||||
this.commands.push(command);
|
||||
}
|
||||
|
||||
registerAll(commands: ChatCommand[]): void {
|
||||
for (const command of commands) this.register(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* drop a previously-registered command (by its primary name) plus all its
|
||||
* aliases. used when a plugin is disabled at runtime so its commands stop
|
||||
* resolving. no-op if the name isn't registered.
|
||||
*/
|
||||
unregister(name: string): void {
|
||||
const command = this.byName.get(name.toLowerCase());
|
||||
if (!command) return;
|
||||
this.byName.delete(command.name.toLowerCase());
|
||||
for (const alias of command.aliases ?? []) this.byName.delete(alias.toLowerCase());
|
||||
const idx = this.commands.indexOf(command);
|
||||
if (idx !== -1) this.commands.splice(idx, 1);
|
||||
}
|
||||
|
||||
/** look up a command by its name or any alias (case-insensitive). */
|
||||
resolve(nameOrAlias: string): ChatCommand | undefined {
|
||||
return this.byName.get(nameOrAlias.toLowerCase());
|
||||
}
|
||||
|
||||
has(nameOrAlias: string): boolean {
|
||||
return this.byName.has(nameOrAlias.toLowerCase());
|
||||
}
|
||||
|
||||
/** all registered commands, in registration order (for /help). */
|
||||
list(): readonly ChatCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
/** command names + aliases, for tab completion. */
|
||||
completions(prefix: string): string[] {
|
||||
const p = prefix.toLowerCase();
|
||||
return [...this.byName.keys()].filter((name) => name.startsWith(p)).sort();
|
||||
}
|
||||
|
||||
private assertUnique(key: string): void {
|
||||
const existing = this.byName.get(key.toLowerCase());
|
||||
if (existing) {
|
||||
throw new Error(`Command name/alias "${key}" already registered by "${existing.name}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { messageRepository } from '$lib/db/repositories';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/clear` — wipe the terminal view (leaves stored history alone).
|
||||
* `/clear history` — also purge the active room's cached messages from local
|
||||
* storage (indexeddb), so they don't come back on reload. doesn't touch the
|
||||
* relay's copy; a re-join replays whatever the relay still has.
|
||||
*/
|
||||
export const clearCommand: ChatCommand = {
|
||||
name: 'clear',
|
||||
aliases: ['cls'],
|
||||
description: 'Clear the terminal view, or "/clear history" to purge cached room history.',
|
||||
usage: '/clear [history]',
|
||||
async execute(args) {
|
||||
const sub = (args[0] ?? '').toLowerCase();
|
||||
|
||||
if (sub === 'history') {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return { success: false, output: [out.warning('No active room to clear history for.')] };
|
||||
}
|
||||
await messageRepository.clearRoom(active.id);
|
||||
terminalStore.clearRoom(active.id);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system(
|
||||
`Cleared cached history for #${active.name}. (The relay's copy is unaffected.)`
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
return { success: false, output: [out.warning('Usage: /clear [history]')] };
|
||||
}
|
||||
|
||||
terminalStore.clear();
|
||||
return { success: true, output: [] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/connect <relay-url>` — connect to a relay over websocket. */
|
||||
export const connectCommand: ChatCommand = {
|
||||
name: 'connect',
|
||||
description: 'Connect to a relay.',
|
||||
usage: '/connect <relay-url>',
|
||||
async execute(args) {
|
||||
const url = args[0];
|
||||
if (!url) {
|
||||
return { success: false, output: [out.warning('Usage: /connect <relay-url>')] };
|
||||
}
|
||||
if (!isValidRelayUrl(url)) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`Invalid relay URL "${url}". Use ws:// or wss://.`)]
|
||||
};
|
||||
}
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
// fire-and-forget connect; state changes show up as terminal notices.
|
||||
void relayService.connect(normalized);
|
||||
return { success: true, output: [out.system(`Connecting to ${normalized}…`)] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/disconnect <relay-url>` — drop a relay connection. */
|
||||
export const disconnectCommand: ChatCommand = {
|
||||
name: 'disconnect',
|
||||
description: 'Disconnect from a relay.',
|
||||
usage: '/disconnect <relay-url>',
|
||||
async execute(args) {
|
||||
const url = args[0];
|
||||
if (!url) {
|
||||
return { success: false, output: [out.warning('Usage: /disconnect <relay-url>')] };
|
||||
}
|
||||
if (!isValidRelayUrl(url)) {
|
||||
return { success: false, output: [out.error(`Invalid relay URL "${url}".`)] };
|
||||
}
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
relayService.disconnect(normalized);
|
||||
return { success: true, output: [out.system(`Disconnecting from ${normalized}.`)] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { dmService } from '$lib/nostr/dm-service';
|
||||
import { dmStore } from '$lib/stores/dm-store';
|
||||
import { decodeNpub } from '$lib/utils/npub';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/dm <npub-or-hex> [message]` — open a direct-message conversation.
|
||||
* with a message, sends it too. once open, plain text goes to that person until
|
||||
* you `/switch` to a room or `/dm` someone else. encrypted via nip-17.
|
||||
*/
|
||||
export const dmCommand: ChatCommand = {
|
||||
name: 'dm',
|
||||
description: 'Open (and optionally send to) a direct-message conversation.',
|
||||
usage: '/dm <npub-or-hex> [message]',
|
||||
async execute(args) {
|
||||
const target = args[0];
|
||||
if (!target) {
|
||||
return { success: false, output: [out.warning('Usage: /dm <npub-or-hex> [message]')] };
|
||||
}
|
||||
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(target);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||
};
|
||||
}
|
||||
|
||||
// focus the conversation so its view shows and plain text goes here.
|
||||
dmStore.open(pubkey);
|
||||
|
||||
const message = args.slice(1).join(' ').trim();
|
||||
if (!message) {
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system('Opened DM. Type to send; /switch <room> to leave.')]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await dmService.send(pubkey, message);
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import type { CommandRegistry } from '../command-registry';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/help [command]` — list all commands, or show usage for one. */
|
||||
export function createHelpCommand(registry: () => CommandRegistry): ChatCommand {
|
||||
return {
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'List available commands, or show usage for one command.',
|
||||
usage: '/help [command]',
|
||||
async execute(args) {
|
||||
const reg = registry();
|
||||
const target = args[0];
|
||||
|
||||
if (target) {
|
||||
const cmd = reg.resolve(target.replace(/^\//, ''));
|
||||
if (!cmd) {
|
||||
return { success: false, output: [out.error(`Unknown command: ${target}`)] };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system(`/${cmd.name} — ${cmd.description}`),
|
||||
out.system(` usage: ${cmd.usage}`),
|
||||
...(cmd.aliases && cmd.aliases.length
|
||||
? [out.system(` aliases: ${cmd.aliases.map((a) => `/${a}`).join(', ')}`)]
|
||||
: [])
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const lines = reg
|
||||
.list()
|
||||
.map((cmd) => out.system(` /${cmd.name.padEnd(12)} ${cmd.description}`));
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice('Available commands:'),
|
||||
...lines,
|
||||
out.system('Type /help <command> for usage.')
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { dmStore } from '$lib/stores/dm-store';
|
||||
import { parseRoomReference } from '$lib/nostr/room-address';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/join <room>` — join a nip-29 room.
|
||||
* takes `#general`, `general` (uses active relay), or
|
||||
* `wss://relay.example/#general`.
|
||||
*/
|
||||
export const joinCommand: ChatCommand = {
|
||||
name: 'join',
|
||||
description: 'Join a room.',
|
||||
usage: '/join <#room | room | wss://relay/#room>',
|
||||
async execute(args, context) {
|
||||
const ref = args[0];
|
||||
if (!ref) {
|
||||
return { success: false, output: [out.warning('Usage: /join <room>')] };
|
||||
}
|
||||
let address;
|
||||
try {
|
||||
address = parseRoomReference(ref, context.activeRelay);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
dmStore.close(); // focus the room, leaving any dm view
|
||||
await roomService.join(address);
|
||||
// join emits its own terminal notices via the room service.
|
||||
return { success: true, output: [] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { authService } from '$lib/nostr/auth-service';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { openAuth } from '$lib/stores/ui-store';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/login [nip07|bunker <token>|nsec <key>|generate|dev]` — sign in.
|
||||
* /login open the sign-in screen
|
||||
* /login nip07 use a nip-07 browser extension
|
||||
* /login bunker <token> connect a remote signer (nip-46)
|
||||
* /login nsec <key> import an nsec (discouraged; in-memory only)
|
||||
* /login generate make a brand-new key
|
||||
* /login dev make a throwaway ephemeral identity
|
||||
*/
|
||||
export const loginCommand: ChatCommand = {
|
||||
name: 'login',
|
||||
description: 'Log in (NIP-07, bunker, nsec, or a new key). Run bare to open the sign-in screen.',
|
||||
usage: '/login [nip07 | bunker <token> | nsec <key> | generate | dev]',
|
||||
async execute(args) {
|
||||
const mode = (args[0] ?? '').toLowerCase();
|
||||
|
||||
// bare /login opens the friendly sign-in modal.
|
||||
if (!mode) {
|
||||
openAuth();
|
||||
return { success: true, output: [out.system('Opening the sign-in screen…')] };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (mode) {
|
||||
case 'nip07': {
|
||||
if (!authService.hasNip07()) {
|
||||
return {
|
||||
success: false,
|
||||
output: [
|
||||
out.error('No NIP-07 extension detected.'),
|
||||
out.system('Install a signer extension, or try /login bunker, nsec, or generate.')
|
||||
]
|
||||
};
|
||||
}
|
||||
const pk = await authService.loginNip07();
|
||||
void relayService.discoverAndConnect(pk);
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system(`Logged in as ${shortenPubkey(pk)} (NIP-07).`)]
|
||||
};
|
||||
}
|
||||
|
||||
case 'bunker': {
|
||||
const token = args.slice(1).join(' ').trim();
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('Usage: /login bunker <bunker://… | nip05>')]
|
||||
};
|
||||
}
|
||||
const pk = await authService.loginBunker(token);
|
||||
void relayService.discoverAndConnect(pk);
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system(`Logged in as ${shortenPubkey(pk)} (remote signer).`)]
|
||||
};
|
||||
}
|
||||
|
||||
case 'nsec': {
|
||||
const key = args[1];
|
||||
if (!key) {
|
||||
return { success: false, output: [out.warning('Usage: /login nsec <nsec…>')] };
|
||||
}
|
||||
const pk = authService.loginNsec(key);
|
||||
void relayService.discoverAndConnect(pk);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.warning(
|
||||
'Your private key is now held in this browser tab. Prefer an extension or bunker.'
|
||||
),
|
||||
out.system(`Logged in as ${shortenPubkey(pk)} (nsec, in-memory).`)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
case 'generate': {
|
||||
const pk = authService.generate();
|
||||
const pair = authService.exportKeyPair();
|
||||
const lines = [
|
||||
out.notice('A new identity was generated (kept in memory for this session).'),
|
||||
out.system(` npub: ${pair?.npub ?? '(unknown)'}`)
|
||||
];
|
||||
if (pair?.nsec) {
|
||||
lines.push(out.warning(` nsec: ${pair.nsec}`));
|
||||
lines.push(
|
||||
out.warning(
|
||||
'Save the nsec now — it is shown once and not stored unless you choose to.'
|
||||
)
|
||||
);
|
||||
}
|
||||
lines.push(out.system(`Logged in as ${shortenPubkey(pk)}.`));
|
||||
return { success: true, output: lines };
|
||||
}
|
||||
|
||||
case 'dev':
|
||||
case 'ephemeral': {
|
||||
const pk = authService.loginEphemeral();
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.warning('Using a TEMPORARY ephemeral identity — not for real use.'),
|
||||
out.system(`Logged in as ${shortenPubkey(pk)} (ephemeral).`)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
output: [
|
||||
out.warning('Usage: /login [nip07 | bunker <token> | nsec <key> | generate | dev]')
|
||||
]
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Login failed.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { authService } from '$lib/nostr/auth-service';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/logout` — drop the current identity and detach the signer. */
|
||||
export const logoutCommand: ChatCommand = {
|
||||
name: 'logout',
|
||||
description: 'Log out of the current identity.',
|
||||
usage: '/logout',
|
||||
async execute() {
|
||||
if (!authService.pubkey) {
|
||||
return { success: true, output: [out.notice('Not logged in.')] };
|
||||
}
|
||||
authService.logout();
|
||||
return { success: true, output: [out.system('Logged out.')] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/me <action>` — fire off an action message to the active room. */
|
||||
export const meCommand: ChatCommand = {
|
||||
name: 'me',
|
||||
description: 'Send an action message to the active room.',
|
||||
usage: '/me <action>',
|
||||
async execute(args) {
|
||||
const action = args.join(' ').trim();
|
||||
if (!action) {
|
||||
return { success: false, output: [out.warning('Usage: /me <action>')] };
|
||||
}
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||
}
|
||||
try {
|
||||
await roomService.send(active.address, action, true);
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { decodeNpub } from '$lib/utils/npub';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { out } from '../output';
|
||||
|
||||
/** grab the active room, if there is one. */
|
||||
function activeRoom() {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
return activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* shared guts for the two moderation commands. both target a user in the active
|
||||
* room; the relay enforces that the caller is an admin (we also warn early when
|
||||
* we already know they aren't one).
|
||||
*/
|
||||
function moderationCommand(
|
||||
name: 'invite' | 'kick',
|
||||
action: 'add' | 'remove',
|
||||
verb: string
|
||||
): ChatCommand {
|
||||
return {
|
||||
name,
|
||||
aliases: name === 'kick' ? ['remove'] : ['add'],
|
||||
description:
|
||||
name === 'invite'
|
||||
? 'Admin: add a user to the active room.'
|
||||
: 'Admin: remove a user from the active room.',
|
||||
usage: `/${name} <npub-or-hex-pubkey>`,
|
||||
async execute(args, context) {
|
||||
const target = args[0];
|
||||
if (!target) {
|
||||
return { success: false, output: [out.warning(`Usage: /${name} <npub-or-hex-pubkey>`)] };
|
||||
}
|
||||
|
||||
const room = activeRoom();
|
||||
if (!room) {
|
||||
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||
}
|
||||
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(target);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||
};
|
||||
}
|
||||
|
||||
// friendly early guard: if we already know the caller isn't an admin,
|
||||
// don't bother the relay. (isAdmin returns true when the list isn't
|
||||
// known yet, so the relay stays the final say.)
|
||||
const me = context.currentUser;
|
||||
if (me && !roomService.isAdmin(room.id, me)) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`You are not an admin of #${room.name}.`)]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.moderate(room.address, action, pubkey);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system(
|
||||
`${verb} ${shortenPubkey(pubkey)} ${name === 'invite' ? 'to' : 'from'} #${room.name}.`
|
||||
)
|
||||
]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : `Failed to ${name}.`)]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** `/invite <npub>` — admin adds a user to the active room (nip-29 kind 9000). */
|
||||
export const inviteCommand = moderationCommand('invite', 'add', 'Invited');
|
||||
|
||||
/** `/kick <npub>` — admin removes a user from the active room (nip-29 kind 9001). */
|
||||
export const kickCommand = moderationCommand('kick', 'remove', 'Removed');
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { dmService } from '$lib/nostr/dm-service';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/msg <npub-or-hex> <message>` — send an encrypted direct message (nip-17).
|
||||
* the message is gift-wrapped so relays can't see sender, recipient, or content.
|
||||
*/
|
||||
export const msgCommand: ChatCommand = {
|
||||
name: 'msg',
|
||||
description: 'Send an encrypted direct message (NIP-17).',
|
||||
usage: '/msg <npub-or-hex> <message>',
|
||||
async execute(args) {
|
||||
const recipient = args[0];
|
||||
const message = args.slice(1).join(' ').trim();
|
||||
if (!recipient || !message) {
|
||||
return { success: false, output: [out.warning('Usage: /msg <npub-or-hex> <message>')] };
|
||||
}
|
||||
try {
|
||||
await dmService.send(recipient, message);
|
||||
// the outgoing dm is rendered by the service; no extra output needed.
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { muteStore } from '$lib/stores/mute-store';
|
||||
import { decodeNpub } from '$lib/utils/npub';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/mute <npub>` — hide a user's messages, just for you. */
|
||||
export const muteCommand: ChatCommand = {
|
||||
name: 'mute',
|
||||
description: 'Hide messages from a user (local only).',
|
||||
usage: '/mute <npub-or-hex-pubkey>',
|
||||
async execute(args) {
|
||||
const target = args[0];
|
||||
if (!target) {
|
||||
return { success: false, output: [out.warning('Usage: /mute <npub-or-hex-pubkey>')] };
|
||||
}
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(target);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||
};
|
||||
}
|
||||
muteStore.add(pubkey);
|
||||
return { success: true, output: [out.system(`Muted ${shortenPubkey(pubkey)}.`)] };
|
||||
}
|
||||
};
|
||||
|
||||
/** `/unmute <npub>` — stop hiding a user's messages. */
|
||||
export const unmuteCommand: ChatCommand = {
|
||||
name: 'unmute',
|
||||
description: 'Unhide messages from a previously muted user.',
|
||||
usage: '/unmute <npub-or-hex-pubkey>',
|
||||
async execute(args) {
|
||||
const target = args[0];
|
||||
if (!target) {
|
||||
return { success: false, output: [out.warning('Usage: /unmute <npub-or-hex-pubkey>')] };
|
||||
}
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(target);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||
};
|
||||
}
|
||||
if (!get(muteStore).has(pubkey)) {
|
||||
return { success: true, output: [out.notice(`${shortenPubkey(pubkey)} is not muted.`)] };
|
||||
}
|
||||
muteStore.remove(pubkey);
|
||||
return { success: true, output: [out.system(`Unmuted ${shortenPubkey(pubkey)}.`)] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { profileService } from '$lib/nostr/profile-service';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/nick <name>` — set your public profile display name (nip-01 kind 0).
|
||||
* gets published to connected relays and shown instead of your pubkey.
|
||||
* quotes let you use spaces: `/nick "Ada Lovelace"`.
|
||||
*/
|
||||
export const nickCommand: ChatCommand = {
|
||||
name: 'nick',
|
||||
aliases: ['name'],
|
||||
description: 'Set your public profile display name.',
|
||||
usage: '/nick <name>',
|
||||
async execute(args) {
|
||||
const name = args.join(' ').trim();
|
||||
if (!name) {
|
||||
return { success: false, output: [out.warning('Usage: /nick <name>')] };
|
||||
}
|
||||
try {
|
||||
await profileService.publishName(name);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system(`Display name set to "${name}". It may take a moment to appear for others.`)
|
||||
]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Failed to set display name.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { parseRoomReference } from '$lib/nostr/room-address';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/part [room]` — leave a room, defaulting to the active one. */
|
||||
export const partCommand: ChatCommand = {
|
||||
name: 'part',
|
||||
aliases: ['leave'],
|
||||
description: 'Leave a room (defaults to the active room).',
|
||||
usage: '/part [room]',
|
||||
async execute(args, context) {
|
||||
const ref = args[0];
|
||||
|
||||
// no argument: leave the active room.
|
||||
if (!ref) {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('No active room to part. Usage: /part [room]')]
|
||||
};
|
||||
}
|
||||
await roomService.leave(active.address);
|
||||
return { success: true, output: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const address = parseRoomReference(ref, context.activeRelay);
|
||||
await roomService.leave(address);
|
||||
return { success: true, output: [] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/plugins` — list plugins and whether they're on or off.
|
||||
* `/plugins disable <id>` — turn a plugin off (drops its commands; persisted).
|
||||
* `/plugins enable <id>` — turn a disabled plugin back on.
|
||||
*/
|
||||
export const pluginsCommand: ChatCommand = {
|
||||
name: 'plugins',
|
||||
aliases: ['plugin'],
|
||||
description: 'List plugins, or enable/disable one.',
|
||||
usage: '/plugins [enable|disable <id>]',
|
||||
async execute(args) {
|
||||
const sub = args[0]?.toLowerCase();
|
||||
|
||||
if (sub === 'enable' || sub === 'disable') {
|
||||
const id = args[1];
|
||||
if (!id) {
|
||||
return { success: false, output: [out.warning(`Usage: /plugins ${sub} <id>`)] };
|
||||
}
|
||||
const ok = sub === 'enable' ? await pluginHost.enable(id) : await pluginHost.disable(id);
|
||||
if (!ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`No plugin with id "${id}". Type /plugins to list them.`)]
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system(`Plugin "${id}" ${sub}d.`)]
|
||||
};
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('Usage: /plugins [enable|disable <id>]')]
|
||||
};
|
||||
}
|
||||
|
||||
// no subcommand: list every known plugin with its state.
|
||||
const plugins = pluginHost.status();
|
||||
if (plugins.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: [out.notice('No plugins found. Add one under src/plugins/ — see its README.')]
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice(`Plugins (${plugins.length}):`),
|
||||
...plugins.map(({ plugin, enabled }) =>
|
||||
out.system(
|
||||
` ${enabled ? '[on] ' : '[off]'} ${plugin.id.padEnd(16)} ${plugin.name}${
|
||||
plugin.description ? ` — ${plugin.description}` : ''
|
||||
}`
|
||||
)
|
||||
),
|
||||
out.system('Toggle with /plugins enable <id> or /plugins disable <id>.')
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/relays` — list configured relays and their connection state.
|
||||
* `/relays discover` — grab the logged-in user's nip-65 relay list and connect.
|
||||
*/
|
||||
export const relaysCommand: ChatCommand = {
|
||||
name: 'relays',
|
||||
description: 'List relays, or "/relays discover" to connect your NIP-65 relays.',
|
||||
usage: '/relays [discover]',
|
||||
async execute(args, context) {
|
||||
if ((args[0] ?? '').toLowerCase() === 'discover') {
|
||||
if (!context.currentUser) {
|
||||
return { success: false, output: [out.warning('Log in first to discover your relays.')] };
|
||||
}
|
||||
const added = await relayService.discoverAndConnect(context.currentUser);
|
||||
return {
|
||||
success: true,
|
||||
output: added > 0 ? [] : [out.system('No new relays to connect from your NIP-65 list.')]
|
||||
};
|
||||
}
|
||||
|
||||
const relays = Object.values(get(relayStore));
|
||||
if (relays.length === 0) {
|
||||
return { success: true, output: [out.notice('No relays. Use /connect <relay-url>.')] };
|
||||
}
|
||||
const lines = relays.map((r) => {
|
||||
const nip29 =
|
||||
r.supportsNip29 === undefined ? '' : r.supportsNip29 ? ' [NIP-29]' : ' [no NIP-29]';
|
||||
return out.system(` ${r.connection.padEnd(12)} ${r.url}${nip29}`);
|
||||
});
|
||||
return { success: true, output: [out.notice('Relays:'), ...lines] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/rooms` — list joined rooms across every relay. */
|
||||
export const roomsCommand: ChatCommand = {
|
||||
name: 'rooms',
|
||||
description: 'List joined rooms.',
|
||||
usage: '/rooms',
|
||||
async execute() {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const list = Object.values(rooms);
|
||||
if (list.length === 0) {
|
||||
return { success: true, output: [out.notice('No rooms. Use /join <room>.')] };
|
||||
}
|
||||
const lines = list.map((room) => {
|
||||
const marker = room.id === activeRoomId ? '*' : ' ';
|
||||
return out.system(` ${marker} #${room.name} (${room.address.relayUrl})`);
|
||||
});
|
||||
return { success: true, output: [out.notice('Rooms:'), ...lines] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { settingsStore } from '$lib/stores/settings-store';
|
||||
import { notificationService } from '$lib/notifications/notification-service';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/settings` — view or tweak saved preferences.
|
||||
* /settings show current settings
|
||||
* /settings fontsize <px> set terminal font size (8–32)
|
||||
* /settings lineheight <n> set line height (1.0–2.5)
|
||||
* /settings motion <on|off> toggle reduced-motion
|
||||
* /settings usersidebar <on|off> toggle the right sidebar
|
||||
* /settings notifications <on|off> desktop notifications for dms/mentions
|
||||
* /settings reset back to defaults
|
||||
*/
|
||||
export const settingsCommand: ChatCommand = {
|
||||
name: 'settings',
|
||||
description: 'View or change persisted preferences.',
|
||||
usage:
|
||||
'/settings [fontsize <px> | lineheight <n> | motion <on|off> | usersidebar <on|off> | notifications <on|off> | reset]',
|
||||
async execute(args) {
|
||||
const [key, value] = args;
|
||||
|
||||
if (!key) {
|
||||
const s = get(settingsStore);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice('Settings:'),
|
||||
out.system(` theme : ${s.themeId}`),
|
||||
out.system(` fontsize : ${s.fontSize}px`),
|
||||
out.system(` lineheight : ${s.lineHeight}`),
|
||||
out.system(` motion : ${s.reducedMotion ? 'reduced' : 'normal'}`),
|
||||
out.system(` usersidebar : ${s.showUserSidebar ? 'on' : 'off'}`),
|
||||
out.system(` notifications : ${s.notifications ? 'on' : 'off'}`),
|
||||
out.system('Change with /settings <key> <value>. See /help settings.')
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'fontsize': {
|
||||
const px = Number.parseInt(value ?? '', 10);
|
||||
if (!Number.isFinite(px) || px < 8 || px > 32) {
|
||||
return { success: false, output: [out.warning('fontsize must be 8–32.')] };
|
||||
}
|
||||
settingsStore.patch({ fontSize: px });
|
||||
return { success: true, output: [out.system(`Font size set to ${px}px.`)] };
|
||||
}
|
||||
case 'lineheight': {
|
||||
const n = Number.parseFloat(value ?? '');
|
||||
if (!Number.isFinite(n) || n < 1 || n > 2.5) {
|
||||
return { success: false, output: [out.warning('lineheight must be 1.0–2.5.')] };
|
||||
}
|
||||
settingsStore.patch({ lineHeight: n });
|
||||
return { success: true, output: [out.system(`Line height set to ${n}.`)] };
|
||||
}
|
||||
case 'motion': {
|
||||
const on = parseToggle(value);
|
||||
if (on === null)
|
||||
return { success: false, output: [out.warning('Usage: /settings motion <on|off>')] };
|
||||
settingsStore.patch({ reducedMotion: on });
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system(`Reduced motion ${on ? 'enabled' : 'disabled'}.`)]
|
||||
};
|
||||
}
|
||||
case 'usersidebar': {
|
||||
const on = parseToggle(value);
|
||||
if (on === null)
|
||||
return { success: false, output: [out.warning('Usage: /settings usersidebar <on|off>')] };
|
||||
settingsStore.patch({ showUserSidebar: on });
|
||||
return { success: true, output: [out.system(`User sidebar ${on ? 'shown' : 'hidden'}.`)] };
|
||||
}
|
||||
case 'notifications': {
|
||||
const on = parseToggle(value);
|
||||
if (on === null)
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('Usage: /settings notifications <on|off>')]
|
||||
};
|
||||
if (!on) {
|
||||
settingsStore.patch({ notifications: false });
|
||||
return { success: true, output: [out.system('Desktop notifications disabled.')] };
|
||||
}
|
||||
if (!notificationService.isSupported()) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.warning('This browser does not support desktop notifications.')]
|
||||
};
|
||||
}
|
||||
// turning it on needs os permission (this runs from a user action).
|
||||
const perm = await notificationService.requestPermission();
|
||||
if (perm !== 'granted') {
|
||||
return {
|
||||
success: false,
|
||||
output: [
|
||||
out.warning(`Notification permission ${perm}. Enable it in your browser settings.`)
|
||||
]
|
||||
};
|
||||
}
|
||||
settingsStore.patch({ notifications: true });
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system('Desktop notifications enabled (shown when the tab is in the background).')
|
||||
]
|
||||
};
|
||||
}
|
||||
case 'reset': {
|
||||
settingsStore.reset();
|
||||
return { success: true, output: [out.system('Settings reset to defaults.')] };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`Unknown setting "${key}". See /help settings.`)]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function parseToggle(value: string | undefined): boolean | null {
|
||||
if (value === 'on' || value === 'true' || value === 'reduced') return true;
|
||||
if (value === 'off' || value === 'false' || value === 'normal') return false;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { activeThemeId } from '$lib/stores/theme-store';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/status` — quick rundown of identity, relays, rooms, and theme. */
|
||||
export const statusCommand: ChatCommand = {
|
||||
name: 'status',
|
||||
description: 'Show connection, identity, and session status.',
|
||||
usage: '/status',
|
||||
async execute() {
|
||||
const relays = Object.values(get(relayStore));
|
||||
const connected = relays.filter((r) => r.connection === 'connected').length;
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const auth = get(authStore);
|
||||
const theme = get(activeThemeId);
|
||||
|
||||
const identity = auth.pubkey
|
||||
? `${auth.kind}${auth.ephemeral ? ' (temporary)' : ''} ${shortenPubkey(auth.pubkey)}`
|
||||
: 'not logged in';
|
||||
const active = activeRoomId ? rooms[activeRoomId]?.name : undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice('Status:'),
|
||||
out.system(` identity : ${identity}`),
|
||||
out.system(` relays : ${connected}/${relays.length} connected`),
|
||||
out.system(` rooms : ${Object.keys(rooms).length} joined`),
|
||||
out.system(` active : ${active ? `#${active}` : '(none)'}`),
|
||||
out.system(` theme : ${theme}`)
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { dmStore } from '$lib/stores/dm-store';
|
||||
import { parseRoomReference, roomId } from '$lib/nostr/room-address';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/switch <room>` — make an already-joined room the active one. */
|
||||
export const switchCommand: ChatCommand = {
|
||||
name: 'switch',
|
||||
aliases: ['sw'],
|
||||
description: 'Switch the active room to another joined room.',
|
||||
usage: '/switch <room>',
|
||||
async execute(args, context) {
|
||||
const ref = args[0];
|
||||
if (!ref) {
|
||||
return { success: false, output: [out.warning('Usage: /switch <room>')] };
|
||||
}
|
||||
|
||||
const { rooms } = get(roomStore);
|
||||
|
||||
// first try an exact name match (common case: "/switch general").
|
||||
const byName = Object.values(rooms).filter((r) => r.name === ref.replace(/^#/, ''));
|
||||
let targetId: string | undefined;
|
||||
if (byName.length === 1) {
|
||||
targetId = byName[0]!.id;
|
||||
} else {
|
||||
// fall back to resolving a full/qualified reference to a canonical id.
|
||||
try {
|
||||
targetId = roomId(parseRoomReference(ref, context.activeRelay));
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId || !rooms[targetId]) {
|
||||
if (byName.length > 1) {
|
||||
return {
|
||||
success: false,
|
||||
output: [
|
||||
out.error(
|
||||
`Ambiguous room "${ref}" exists on multiple relays. Use /switch wss://relay/#${ref.replace(/^#/, '')}.`
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
return { success: false, output: [out.error(`Not joined to "${ref}". Use /join first.`)] };
|
||||
}
|
||||
|
||||
dmStore.close(); // drop any focused dm view for a room
|
||||
roomStore.setActive(targetId);
|
||||
return { success: true, output: [out.system(`Now in #${rooms[targetId]!.name}.`)] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import type { ThemeInput } from '$lib/themes/theme-types';
|
||||
import { allThemes, isBuiltInTheme } from '$lib/themes/theme-registry';
|
||||
import {
|
||||
setTheme,
|
||||
previewTheme,
|
||||
restoreTheme,
|
||||
addCustomTheme,
|
||||
removeCustomTheme,
|
||||
activeThemeId
|
||||
} from '$lib/stores/theme-store';
|
||||
import { DEFAULT_THEME_ID } from '$lib/config';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/theme` — manage the active color theme.
|
||||
* /theme show current theme + usage
|
||||
* /theme list list all themes (custom marked +)
|
||||
* /theme current show the active theme
|
||||
* /theme set <name> set and save a theme
|
||||
* /theme preview <name> preview without saving
|
||||
* /theme reset back to the default theme
|
||||
* /theme add <json> add a custom theme from a json definition
|
||||
* /theme remove <id> drop a custom theme
|
||||
*/
|
||||
export const themeCommand: ChatCommand = {
|
||||
name: 'theme',
|
||||
description: 'View, change, or add color themes.',
|
||||
usage: '/theme [list|current|set <name>|preview <name>|reset|add <json>|remove <id>]',
|
||||
async execute(args) {
|
||||
const sub = (args[0] ?? '').toLowerCase();
|
||||
|
||||
if (!sub) {
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice(`Current theme: ${get(activeThemeId)}`),
|
||||
out.system(
|
||||
'Usage: /theme list | set <name> | preview <name> | reset | add <json> | remove <id>'
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const lines = allThemes().map((t) => {
|
||||
const active = t.id === get(activeThemeId) ? '*' : ' ';
|
||||
const custom = isBuiltInTheme(t.id) ? ' ' : '+';
|
||||
return out.system(
|
||||
` ${active}${custom} ${t.id.padEnd(20)} ${t.mode.padEnd(6)} ${t.description}`
|
||||
);
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
output: [out.notice('Themes (* active, + custom):'), ...lines]
|
||||
};
|
||||
}
|
||||
|
||||
if (sub === 'current') {
|
||||
return { success: true, output: [out.notice(`Current theme: ${get(activeThemeId)}`)] };
|
||||
}
|
||||
|
||||
if (sub === 'reset') {
|
||||
const theme = setTheme(DEFAULT_THEME_ID);
|
||||
return { success: true, output: [out.system(`Theme reset to ${theme.name}.`)] };
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
// everything after "add" is a json theme definition.
|
||||
const json = args.slice(1).join(' ').trim();
|
||||
if (!json) {
|
||||
return {
|
||||
success: false,
|
||||
output: [
|
||||
out.warning('Usage: /theme add <json>'),
|
||||
out.system('Provide a TerminalTheme object with id, name, and all colors.*')
|
||||
]
|
||||
};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return { success: false, output: [out.error('Invalid JSON.')] };
|
||||
}
|
||||
try {
|
||||
const theme = await addCustomTheme(parsed as ThemeInput);
|
||||
setTheme(theme.id);
|
||||
return {
|
||||
success: true,
|
||||
output: [out.system(`Added custom theme "${theme.name}" and applied it.`)]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Could not add theme.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const id = args[1];
|
||||
if (!id) return { success: false, output: [out.warning('Usage: /theme remove <id>')] };
|
||||
if (isBuiltInTheme(id)) {
|
||||
return { success: false, output: [out.error(`"${id}" is a built-in theme.`)] };
|
||||
}
|
||||
const removed = await removeCustomTheme(id);
|
||||
return removed
|
||||
? { success: true, output: [out.system(`Removed custom theme "${id}".`)] }
|
||||
: { success: false, output: [out.error(`No custom theme "${id}".`)] };
|
||||
}
|
||||
|
||||
if (sub === 'set' || sub === 'preview') {
|
||||
const name = args.slice(1).join(' ').trim();
|
||||
if (!name) {
|
||||
return { success: false, output: [out.warning(`Usage: /theme ${sub} <name>`)] };
|
||||
}
|
||||
try {
|
||||
if (sub === 'set') {
|
||||
const theme = setTheme(name);
|
||||
return { success: true, output: [out.system(`Theme set to ${theme.name}.`)] };
|
||||
}
|
||||
const theme = previewTheme(name);
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.system(
|
||||
`Previewing ${theme.name}. Use /theme set ${theme.id} to keep it, or /theme current to revert.`
|
||||
)
|
||||
]
|
||||
};
|
||||
} catch (err) {
|
||||
restoreTheme();
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`Unknown /theme subcommand: ${sub}. Try /theme list.`)]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/topic` — show the active room's topic.
|
||||
* `/topic <text>` — set it (admin-only; the relay rejects everyone else).
|
||||
*/
|
||||
export const topicCommand: ChatCommand = {
|
||||
name: 'topic',
|
||||
description: "View or set the active room's topic.",
|
||||
usage: '/topic [text]',
|
||||
async execute(args) {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||
}
|
||||
|
||||
const text = args.join(' ').trim();
|
||||
if (!text) {
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice(
|
||||
active.topic
|
||||
? `Topic for #${active.name}: ${active.topic}`
|
||||
: `#${active.name} has no topic. Set one with /topic <text>.`
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.setTopic(active.address, text);
|
||||
// the relay re-publishes metadata; the room store updates reactively.
|
||||
return { success: true, output: [out.system(`Topic set for #${active.name}.`)] };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Failed to set topic.')]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { profileService } from '$lib/nostr/profile-service';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { out } from '../output';
|
||||
|
||||
/**
|
||||
* `/who` — list the active room's participants.
|
||||
* uses the relay's authoritative nip-29 member (39002) and admin (39001) lists
|
||||
* when we have them (admins marked with ★). falls back to authors seen this
|
||||
* session only when the relay hasn't handed over a member list.
|
||||
*/
|
||||
export const whoCommand: ChatCommand = {
|
||||
name: 'who',
|
||||
description: 'List participants in the active room.',
|
||||
usage: '/who',
|
||||
async execute() {
|
||||
const { rooms, activeRoomId } = get(roomStore);
|
||||
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||
if (!active) {
|
||||
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||
}
|
||||
|
||||
const admins = new Set(active.admins ?? []);
|
||||
const members = active.members ?? [];
|
||||
|
||||
const format = (pubkey: string): string => {
|
||||
const marker = admins.has(pubkey) ? '★ ' : ' ';
|
||||
const name = profileService.cachedName(pubkey);
|
||||
return `${marker}${name ? `${name} ` : ''}${shortenPubkey(pubkey)}`;
|
||||
};
|
||||
|
||||
if (members.length > 0) {
|
||||
// admins first, then roughly alphabetical by display label.
|
||||
const sorted = [...members].sort((a, b) => {
|
||||
const aAdmin = admins.has(a);
|
||||
const bAdmin = admins.has(b);
|
||||
if (aAdmin !== bAdmin) return aAdmin ? -1 : 1;
|
||||
return format(a).localeCompare(format(b));
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice(`Members of #${active.name} (${members.length}):`),
|
||||
...sorted.map((pk) => out.system(` ${format(pk)}`))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// fallback: no relay member list yet — piece it together from messages seen locally.
|
||||
const seen = new Set<string>();
|
||||
const authors: string[] = [];
|
||||
const entries = terminalStore.snapshot();
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i];
|
||||
if (!e || e.roomId !== activeRoomId || !e.author) continue;
|
||||
if (seen.has(e.author)) continue;
|
||||
seen.add(e.author);
|
||||
authors.push(e.author);
|
||||
}
|
||||
|
||||
if (authors.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: [out.notice(`No member list or activity seen yet in #${active.name}.`)]
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: [
|
||||
out.notice(`Recently active in #${active.name} (${authors.length}, from local activity):`),
|
||||
...authors.map((pk) => out.system(` ${format(pk)}`))
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import { profileService } from '$lib/nostr/profile-service';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { decodeNpub } from '$lib/utils/npub';
|
||||
import { out } from '../output';
|
||||
|
||||
/** `/whois <npub-or-hex>` — look up and show a user's profile details. */
|
||||
export const whoisCommand: ChatCommand = {
|
||||
name: 'whois',
|
||||
description: 'Show profile details for a user.',
|
||||
usage: '/whois <npub-or-hex-pubkey>',
|
||||
async execute(args) {
|
||||
const target = args[0];
|
||||
if (!target) {
|
||||
return { success: false, output: [out.warning('Usage: /whois <npub-or-hex-pubkey>')] };
|
||||
}
|
||||
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(target);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||
};
|
||||
}
|
||||
|
||||
await profileService.resolve(pubkey);
|
||||
const name = profileService.cachedName(pubkey);
|
||||
const nip05 = profileService.cachedNip05(pubkey);
|
||||
|
||||
const lines = [
|
||||
out.notice(`whois ${shortenPubkey(pubkey)}`),
|
||||
out.system(` name : ${name ?? '(unknown)'}`)
|
||||
];
|
||||
|
||||
if (!nip05) {
|
||||
lines.push(out.system(' nip05 : (none)'));
|
||||
} else {
|
||||
// a claimed nip-05 means nothing until checked against its domain.
|
||||
const status = await profileService.verifyNip05(pubkey);
|
||||
if (status === 'verified') {
|
||||
lines.push(out.system(` nip05 : ${nip05} (✓ verified)`));
|
||||
} else if (status === 'mismatch') {
|
||||
// the domain points the name at a DIFFERENT key — likely impersonation.
|
||||
lines.push(out.warning(` nip05 : ${nip05} (✗ does NOT match this pubkey)`));
|
||||
} else {
|
||||
lines.push(out.system(` nip05 : ${nip05} (unverified — could not confirm)`));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(out.system(` pubkey: ${pubkey}`));
|
||||
return { success: true, output: lines };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CommandRegistry } from './command-registry';
|
||||
import { createHelpCommand } from './commands/help';
|
||||
import { clearCommand } from './commands/clear';
|
||||
import { statusCommand } from './commands/status';
|
||||
import { relaysCommand } from './commands/relays';
|
||||
import { roomsCommand } from './commands/rooms';
|
||||
import { switchCommand } from './commands/switch';
|
||||
import { themeCommand } from './commands/theme';
|
||||
import { loginCommand } from './commands/login';
|
||||
import { logoutCommand } from './commands/logout';
|
||||
import { connectCommand } from './commands/connect';
|
||||
import { disconnectCommand } from './commands/disconnect';
|
||||
import { joinCommand } from './commands/join';
|
||||
import { partCommand } from './commands/part';
|
||||
import { meCommand } from './commands/me';
|
||||
import { whoCommand } from './commands/who';
|
||||
import { whoisCommand } from './commands/whois';
|
||||
import { muteCommand, unmuteCommand } from './commands/mute';
|
||||
import { settingsCommand } from './commands/settings';
|
||||
import { nickCommand } from './commands/nick';
|
||||
import { inviteCommand, kickCommand } from './commands/moderate';
|
||||
import { msgCommand } from './commands/msg';
|
||||
import { dmCommand } from './commands/dm';
|
||||
import { topicCommand } from './commands/topic';
|
||||
import { pluginsCommand } from './commands/plugins';
|
||||
|
||||
/**
|
||||
* build a registry with all the mvp commands wired up. finished commands work
|
||||
* now; ones slated for later phases return a clear notice.
|
||||
* `/help` gets a getter for the registry so it can list every command.
|
||||
*/
|
||||
export function createRegistry(): CommandRegistry {
|
||||
const registry = new CommandRegistry();
|
||||
registry.registerAll([
|
||||
createHelpCommand(() => registry),
|
||||
clearCommand,
|
||||
statusCommand,
|
||||
relaysCommand,
|
||||
roomsCommand,
|
||||
switchCommand,
|
||||
themeCommand,
|
||||
// identity & relays (phase 3).
|
||||
loginCommand,
|
||||
logoutCommand,
|
||||
connectCommand,
|
||||
disconnectCommand,
|
||||
// rooms & messaging (phase 4).
|
||||
joinCommand,
|
||||
partCommand,
|
||||
meCommand,
|
||||
whoCommand,
|
||||
whoisCommand,
|
||||
// profile & moderation.
|
||||
nickCommand,
|
||||
inviteCommand,
|
||||
kickCommand,
|
||||
// persistence & preferences (phase 5).
|
||||
muteCommand,
|
||||
unmuteCommand,
|
||||
settingsCommand,
|
||||
// direct messages & room topic.
|
||||
msgCommand,
|
||||
dmCommand,
|
||||
topicCommand,
|
||||
// plugins.
|
||||
pluginsCommand
|
||||
]);
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CommandContext, CommandResult } from '$lib/types/command';
|
||||
import type { TerminalEntry } from '$lib/types/terminal';
|
||||
import { CommandRegistry } from './command-registry';
|
||||
import { parseInput } from './command-parser';
|
||||
import { out } from './output';
|
||||
|
||||
/** deps injected into the dispatcher (keeps it unit-testable). */
|
||||
export interface DispatcherDeps {
|
||||
registry: CommandRegistry;
|
||||
/** snapshot the current context (active room/relay/user) at dispatch time. */
|
||||
getContext: () => CommandContext;
|
||||
/** send plain text (non-command) input to the active room. */
|
||||
sendMessage: (content: string, context: CommandContext) => Promise<CommandResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* turns a raw input line into terminal output: classifies it, then either
|
||||
* routes it to the active room or resolves + runs a command.
|
||||
*
|
||||
* errors thrown by a command become a friendly error entry — the dispatcher
|
||||
* never rejects, so the ui can always render a result.
|
||||
*/
|
||||
export class CommandDispatcher {
|
||||
constructor(private readonly deps: DispatcherDeps) {}
|
||||
|
||||
async dispatch(raw: string): Promise<CommandResult> {
|
||||
const parsed = parseInput(raw);
|
||||
const context = this.deps.getContext();
|
||||
|
||||
if (parsed.kind === 'empty') {
|
||||
return { success: true, output: [] };
|
||||
}
|
||||
|
||||
if (parsed.kind === 'message') {
|
||||
try {
|
||||
return await this.deps.sendMessage(parsed.content, context);
|
||||
} catch (err) {
|
||||
return { success: false, output: [out.error(errorMessage(err))] };
|
||||
}
|
||||
}
|
||||
|
||||
const command = this.deps.registry.resolve(parsed.name);
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
output: [out.error(`Unknown command: /${parsed.name}. Type /help for a list.`)]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await command.execute(parsed.args, context);
|
||||
} catch (err) {
|
||||
return { success: false, output: [out.error(errorMessage(err))] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** shared helper: spit out a command's usage as a warning entry. */
|
||||
export function usageError(usage: string): TerminalEntry {
|
||||
return out.warning(`Usage: ${usage}`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal';
|
||||
import { generateId } from '$lib/utils/id';
|
||||
|
||||
/**
|
||||
* build a trusted local terminal entry for command output. all command output
|
||||
* is local/system-originated — remote chat content never flows through here.
|
||||
*/
|
||||
export function entry(type: TerminalEntryType, content: string, roomId?: string): TerminalEntry {
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
roomId,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
/** handy builders for the common command-output entry types. */
|
||||
export const out = {
|
||||
system: (content: string, roomId?: string) => entry('system', content, roomId),
|
||||
notice: (content: string, roomId?: string) => entry('notice', content, roomId),
|
||||
warning: (content: string, roomId?: string) => entry('warning', content, roomId),
|
||||
error: (content: string, roomId?: string) => entry('error', content, roomId)
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* tokenize a command argument string, honoring double and single quotes so
|
||||
* args with spaces can come through as a single token.
|
||||
*
|
||||
* parse('set "Solarized Dark"') -> ['set', 'Solarized Dark']
|
||||
*
|
||||
* an unterminated quote just runs to end-of-input (lenient, so someone
|
||||
* mid-typing never hits a hard error from the tokenizer itself).
|
||||
*/
|
||||
export function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
let hasToken = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
if (hasToken) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
hasToken = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
hasToken = true;
|
||||
}
|
||||
if (hasToken) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
/**
|
||||
* runtime config pulled from PUBLIC_ env vars.
|
||||
* uses `$env/dynamic/public` so every var is optional — the app runs on sane
|
||||
* defaults with no `.env`. product naming lives in `src/lib/brand.ts` so it can
|
||||
* change without touching protocol or app logic.
|
||||
*/
|
||||
|
||||
function parseIntOr(value: string | undefined, fallback: number): number {
|
||||
const n = Number.parseInt(value ?? '', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
// build-time fallback only; runtime /config.json (see runtime-config.ts) wins.
|
||||
export const DEFAULT_RELAYS: string[] = (env.PUBLIC_DEFAULT_RELAYS ?? '')
|
||||
.split(',')
|
||||
.map((r: string) => r.trim())
|
||||
.filter((r: string) => r.length > 0);
|
||||
|
||||
export const DEFAULT_THEME_ID: string = (env.PUBLIC_DEFAULT_THEME ?? 'nord').trim() || 'nord';
|
||||
|
||||
export const MAX_TERMINAL_ENTRIES: number = parseIntOr(env.PUBLIC_MAX_TERMINAL_ENTRIES, 1000);
|
||||
|
||||
export const MAX_MESSAGE_LENGTH: number = parseIntOr(env.PUBLIC_MAX_MESSAGE_LENGTH, 2000);
|
||||
|
||||
/** how many past commands the input box remembers. */
|
||||
export const MAX_COMMAND_HISTORY = 200;
|
||||
|
||||
/** most cached messages kept per room in indexeddb (older ones get pruned). */
|
||||
export const MAX_CACHED_MESSAGES_PER_ROOM: number = parseIntOr(
|
||||
env.PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM,
|
||||
500
|
||||
);
|
||||
|
||||
/** how long (ms) a cached profile stays fresh before we refetch. */
|
||||
export const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** how many recent messages per room we replay into the terminal at startup. */
|
||||
export const RESTORE_MESSAGES_PER_ROOM = 50;
|
||||
@@ -0,0 +1,169 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import type { AppSettings } from '$lib/types/settings';
|
||||
import type { TerminalTheme } from '$lib/themes/theme-types';
|
||||
|
||||
/**
|
||||
* saved record shapes. these are storage models — kept plain on purpose and
|
||||
* separate from the runtime store/ndk types. cached events are UNTRUSTED on
|
||||
* load until the nostr lib re-checks them.
|
||||
*/
|
||||
|
||||
export interface RelayRecord {
|
||||
/** normalized relay url (primary key). */
|
||||
url: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface RoomRecord {
|
||||
/** room id: normalizedRelayUrl#groupId (primary key). */
|
||||
id: string;
|
||||
relayUrl: string;
|
||||
groupId: string;
|
||||
name: string;
|
||||
topic?: string;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
/** nostr event id (primary key). */
|
||||
id: string;
|
||||
/** room id (indexed for per-room queries). */
|
||||
roomId: string;
|
||||
authorPubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
eventKind: number;
|
||||
action: boolean;
|
||||
replyTo?: string;
|
||||
/**
|
||||
* was this event signature-verified WHEN CACHED. just a hint — never shown
|
||||
* as trusted without re-checking on load.
|
||||
*/
|
||||
verifiedWhenCached: boolean;
|
||||
}
|
||||
|
||||
export interface ReadMarkerRecord {
|
||||
/** room id (primary key). */
|
||||
roomId: string;
|
||||
/** createdAt (unix seconds) of the last read message. */
|
||||
lastReadAt: number;
|
||||
}
|
||||
|
||||
export interface MutedPubkeyRecord {
|
||||
pubkey: string;
|
||||
mutedAt: number;
|
||||
}
|
||||
|
||||
export interface ProfileRecord {
|
||||
pubkey: string;
|
||||
name?: string;
|
||||
nip05?: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export interface PendingMessageRecord {
|
||||
/** local id (primary key, auto-incremented). */
|
||||
localId?: number;
|
||||
roomId: string;
|
||||
relayUrl: string;
|
||||
groupId: string;
|
||||
content: string;
|
||||
action: boolean;
|
||||
queuedAt: number;
|
||||
}
|
||||
|
||||
export interface SettingRecord {
|
||||
/** fixed key; we stash one settings blob under "app". */
|
||||
key: string;
|
||||
value: AppSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* a nip-49 encrypted private key the user chose to remember on this device.
|
||||
* we keep ONLY the ncryptsec (passphrase-encrypted) — never a plaintext key.
|
||||
* decrypting needs the user's passphrase and happens in memory at login.
|
||||
*/
|
||||
export interface StoredIdentityRecord {
|
||||
/** fixed key "primary" — the mvp remembers one device identity. */
|
||||
key: string;
|
||||
/** public key (hex) to show before unlock. */
|
||||
pubkey: string;
|
||||
/** nip-49 ncryptsec blob (passphrase-encrypted secret key). */
|
||||
ncryptsec: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** a user- (or plugin-) defined theme saved for reuse across sessions. */
|
||||
export interface CustomThemeRecord {
|
||||
/** theme id (primary key). */
|
||||
id: string;
|
||||
/** the whole TerminalTheme object, stored as-is. */
|
||||
theme: TerminalTheme;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** plugin-scoped saved state, keyed by "<pluginId>:<key>". */
|
||||
export interface PluginStateRecord {
|
||||
/** composite key `${pluginId}:${key}` (primary key). */
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/** handy: rebuild a RoomAddress from a stored room record. */
|
||||
export function recordToAddress(record: RoomRecord): RoomAddress {
|
||||
return { relayUrl: record.relayUrl, groupId: record.groupId };
|
||||
}
|
||||
|
||||
/**
|
||||
* the nosterm indexeddb database. schema is versioned; bump the version and
|
||||
* add a `.stores()` block to run a migration.
|
||||
*/
|
||||
export class NostermDatabase extends Dexie {
|
||||
relays!: Table<RelayRecord, string>;
|
||||
rooms!: Table<RoomRecord, string>;
|
||||
messages!: Table<MessageRecord, string>;
|
||||
readMarkers!: Table<ReadMarkerRecord, string>;
|
||||
mutedPubkeys!: Table<MutedPubkeyRecord, string>;
|
||||
profiles!: Table<ProfileRecord, string>;
|
||||
pendingMessages!: Table<PendingMessageRecord, number>;
|
||||
settings!: Table<SettingRecord, string>;
|
||||
identities!: Table<StoredIdentityRecord, string>;
|
||||
customThemes!: Table<CustomThemeRecord, string>;
|
||||
pluginState!: Table<PluginStateRecord, string>;
|
||||
|
||||
constructor() {
|
||||
super('nosterm');
|
||||
this.version(1).stores({
|
||||
relays: 'url, addedAt',
|
||||
rooms: 'id, relayUrl, joinedAt',
|
||||
// compound index [roomId+createdAt] drives ordered per-room history.
|
||||
messages: 'id, roomId, createdAt, [roomId+createdAt]',
|
||||
readMarkers: 'roomId',
|
||||
mutedPubkeys: 'pubkey, mutedAt',
|
||||
profiles: 'pubkey, fetchedAt',
|
||||
pendingMessages: '++localId, roomId, queuedAt',
|
||||
settings: 'key'
|
||||
});
|
||||
// v2 adds the optional encrypted-identity store (remember-on-device).
|
||||
this.version(2).stores({
|
||||
identities: 'key'
|
||||
});
|
||||
// v3 adds user- / plugin-defined custom themes.
|
||||
this.version(3).stores({
|
||||
customThemes: 'id, savedAt'
|
||||
});
|
||||
// v4 adds plugin-scoped key/value state.
|
||||
this.version(4).stores({
|
||||
pluginState: 'key'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let dbInstance: NostermDatabase | null = null;
|
||||
|
||||
/** lazily-made database singleton (client-only). */
|
||||
export function getDb(): NostermDatabase {
|
||||
if (!dbInstance) dbInstance = new NostermDatabase();
|
||||
return dbInstance;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
relayRepository,
|
||||
roomRepository,
|
||||
messageRepository,
|
||||
muteRepository,
|
||||
profileRepository,
|
||||
settingsRepository
|
||||
} from './repositories';
|
||||
import { recordToAddress } from './database';
|
||||
import { settingsStore } from '$lib/stores/settings-store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { muteStore } from '$lib/stores/mute-store';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { relayService } from '$lib/nostr/relay-service';
|
||||
import { roomService } from '$lib/nostr/room-service';
|
||||
import { profileService } from '$lib/nostr/profile-service';
|
||||
import { initTheme, setTheme, loadCustomThemes } from '$lib/stores/theme-store';
|
||||
import { resolveTheme } from '$lib/themes/theme-registry';
|
||||
import { RESTORE_MESSAGES_PER_ROOM } from '$lib/config';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
|
||||
/**
|
||||
* loads saved state at startup and keeps indexeddb in sync with the runtime
|
||||
* stores after that. lives outside the stores so they stay framework-pure and
|
||||
* unit-testable without indexeddb.
|
||||
*
|
||||
* heads up (security): cached events are UNTRUSTED on load. we show restored
|
||||
* messages as history, but re-validation happens live when the room's sub
|
||||
* re-delivers events; restored records look exactly like remote chat (never
|
||||
* system lines) and their content is plain text only.
|
||||
*/
|
||||
/**
|
||||
* read a `?theme=<id>` query param and return the matching theme id, or null.
|
||||
* checked against the registry (built-in + custom, alias-aware) so an unknown
|
||||
* or junk value just gets ignored. then we strip the param from the url so a
|
||||
* reload/bookmark doesn't keep clobbering the user's later choice.
|
||||
*/
|
||||
function readThemeParam(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = new URLSearchParams(window.location.search).get('theme');
|
||||
if (!raw) return null;
|
||||
const theme = resolveTheme(raw);
|
||||
if (!theme) return null;
|
||||
// drop the param without a navigation/reload.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('theme');
|
||||
window.history.replaceState({}, '', url);
|
||||
return theme.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class PersistenceService {
|
||||
private started = false;
|
||||
|
||||
/** load saved settings, mutes, profiles, relays, and rooms. */
|
||||
async restore(): Promise<void> {
|
||||
// 1) settings first, so the theme lands before the ui is interactive.
|
||||
const savedSettings = await settingsRepository.load();
|
||||
if (savedSettings) settingsStore.set(savedSettings);
|
||||
// register saved custom themes before applying, so a custom active theme
|
||||
// resolves instead of falling back to the default.
|
||||
await loadCustomThemes();
|
||||
// a `?theme=<id>` query param (e.g. from the marketing site's "launch
|
||||
// client" link) beats the saved theme so a visitor's pick follows them
|
||||
// into the app. only a known theme wins; anything else is ignored and the
|
||||
// saved/default theme applies as usual.
|
||||
const urlThemeId = readThemeParam();
|
||||
initTheme(get(settingsStore).themeId);
|
||||
if (urlThemeId) setTheme(urlThemeId); // resolve + apply + save
|
||||
|
||||
// 2) muted pubkeys.
|
||||
const muted = await muteRepository.list();
|
||||
muteStore.setAll(muted);
|
||||
|
||||
// 3) cached profiles (so restored/live messages show names right away).
|
||||
for (const profile of await profileRepository.all()) {
|
||||
profileService.hydrate(profile.pubkey, profile.name, profile.nip05);
|
||||
}
|
||||
|
||||
// 4) reconnect to relays we used before.
|
||||
const relays = await relayRepository.list();
|
||||
for (const relay of relays) {
|
||||
void relayService.connect(relay.url);
|
||||
}
|
||||
|
||||
// 5) rejoin rooms we were in and restore their recent history.
|
||||
const rooms = await roomRepository.list();
|
||||
for (const room of rooms) {
|
||||
await this.restoreRoom(room.id, room.name);
|
||||
// rejoin re-opens the live sub (which re-validates events).
|
||||
void roomService.join(recordToAddress(room));
|
||||
}
|
||||
|
||||
if (relays.length > 0 || rooms.length > 0) {
|
||||
terminalStore.system(
|
||||
`Restored ${relays.length} relay(s) and ${rooms.length} room(s) from your last session.`,
|
||||
'system'
|
||||
);
|
||||
}
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
/** dump a room's cached messages back into the terminal as history. */
|
||||
private async restoreRoom(roomId: string, roomName: string): Promise<void> {
|
||||
const messages = await messageRepository.recent(roomId, RESTORE_MESSAGES_PER_ROOM);
|
||||
if (messages.length === 0) return;
|
||||
terminalStore.system(`--- ${roomName} history (${messages.length}) ---`, 'system', roomId);
|
||||
for (const m of messages) {
|
||||
if (get(muteStore).has(m.authorPubkey)) continue;
|
||||
const name = profileService.cachedName(m.authorPubkey) ?? shortenPubkey(m.authorPubkey);
|
||||
terminalStore.push({
|
||||
type: m.action ? 'action' : 'message',
|
||||
author: m.authorPubkey,
|
||||
authorDisplayName: name,
|
||||
content: m.content,
|
||||
roomId,
|
||||
timestamp: m.createdAt ? m.createdAt * 1000 : Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** start mirroring runtime store changes into indexeddb. idempotent. */
|
||||
private start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
|
||||
// save settings on every change.
|
||||
settingsStore.subscribe((settings) => {
|
||||
void settingsRepository.save(settings);
|
||||
});
|
||||
|
||||
// save the relay set (added/removed) reactively.
|
||||
relayStore.subscribe((relays) => {
|
||||
void this.syncRelays(Object.keys(relays));
|
||||
});
|
||||
|
||||
// save joined rooms reactively.
|
||||
roomStore.subscribe((state) => {
|
||||
void this.syncRooms(state.rooms);
|
||||
});
|
||||
|
||||
// save mute list reactively.
|
||||
muteStore.subscribe((set) => {
|
||||
void this.syncMutes([...set]);
|
||||
});
|
||||
}
|
||||
|
||||
private async syncRelays(current: string[]): Promise<void> {
|
||||
const stored = new Set((await relayRepository.list()).map((r) => r.url));
|
||||
for (const url of current) if (!stored.has(url)) await relayRepository.add(url);
|
||||
for (const url of stored) if (!current.includes(url)) await relayRepository.remove(url);
|
||||
}
|
||||
|
||||
private async syncRooms(
|
||||
rooms: Record<
|
||||
string,
|
||||
{ id: string; address: { relayUrl: string; groupId: string }; name: string; topic?: string }
|
||||
>
|
||||
): Promise<void> {
|
||||
const stored = new Set((await roomRepository.list()).map((r) => r.id));
|
||||
const currentIds = new Set(Object.keys(rooms));
|
||||
for (const room of Object.values(rooms)) {
|
||||
await roomRepository.add(room.address, room.name, room.topic);
|
||||
}
|
||||
for (const id of stored) if (!currentIds.has(id)) await roomRepository.remove(id);
|
||||
}
|
||||
|
||||
private async syncMutes(current: string[]): Promise<void> {
|
||||
const stored = new Set(await muteRepository.list());
|
||||
for (const pk of current) if (!stored.has(pk)) await muteRepository.add(pk);
|
||||
for (const pk of stored) if (!current.includes(pk)) await muteRepository.remove(pk);
|
||||
}
|
||||
}
|
||||
|
||||
export const persistenceService = new PersistenceService();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getDb, type CustomThemeRecord } from '../database';
|
||||
import type { TerminalTheme } from '$lib/themes/theme-types';
|
||||
|
||||
/** storage for user-defined / plugin-provided themes. */
|
||||
export const customThemeRepository = {
|
||||
async list(): Promise<TerminalTheme[]> {
|
||||
return (await getDb().customThemes.orderBy('savedAt').toArray()).map((r) => r.theme);
|
||||
},
|
||||
|
||||
async save(theme: TerminalTheme): Promise<void> {
|
||||
const record: CustomThemeRecord = { id: theme.id, theme, savedAt: Date.now() };
|
||||
await getDb().customThemes.put(record);
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await getDb().customThemes.delete(id);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getDb, type StoredIdentityRecord } from '../database';
|
||||
|
||||
const PRIMARY = 'primary';
|
||||
|
||||
/**
|
||||
* storage for a remembered device identity. keeps ONLY the nip-49 ncryptsec
|
||||
* (passphrase-encrypted secret key) — never a plaintext key. the mvp remembers
|
||||
* a single identity under a fixed key.
|
||||
*/
|
||||
export const identityRepository = {
|
||||
async get(): Promise<StoredIdentityRecord | undefined> {
|
||||
return getDb().identities.get(PRIMARY);
|
||||
},
|
||||
|
||||
async save(pubkey: string, ncryptsec: string): Promise<void> {
|
||||
await getDb().identities.put({
|
||||
key: PRIMARY,
|
||||
pubkey,
|
||||
ncryptsec,
|
||||
savedAt: Date.now()
|
||||
});
|
||||
},
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await getDb().identities.delete(PRIMARY);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { relayRepository } from './relay-repository';
|
||||
export { roomRepository } from './room-repository';
|
||||
export { messageRepository } from './message-repository';
|
||||
export {
|
||||
readMarkerRepository,
|
||||
muteRepository,
|
||||
profileRepository,
|
||||
settingsRepository
|
||||
} from './preference-repository';
|
||||
export { pendingMessageRepository } from './pending-message-repository';
|
||||
export { identityRepository } from './identity-repository';
|
||||
export { customThemeRepository } from './custom-theme-repository';
|
||||
export { pluginStateRepository } from './plugin-state-repository';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getDb, type MessageRecord } from '../database';
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
import { roomId } from '$lib/nostr/room-address';
|
||||
import { MAX_CACHED_MESSAGES_PER_ROOM } from '$lib/config';
|
||||
|
||||
/** storage for cached chat messages, with per-room retention pruning. */
|
||||
export const messageRepository = {
|
||||
/** save a message (idempotent by event id) and prune old ones for its room. */
|
||||
async save(message: ChatMessage): Promise<void> {
|
||||
const id = roomId(message.room);
|
||||
const record: MessageRecord = {
|
||||
id: message.id,
|
||||
roomId: id,
|
||||
authorPubkey: message.authorPubkey,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
eventKind: message.eventKind,
|
||||
action: message.action ?? false,
|
||||
replyTo: message.replyTo,
|
||||
verifiedWhenCached: message.verified
|
||||
};
|
||||
await getDb().messages.put(record);
|
||||
await this.prune(id);
|
||||
},
|
||||
|
||||
/** newest `limit` messages for a room, in chronological order. */
|
||||
async recent(roomIdValue: string, limit: number): Promise<MessageRecord[]> {
|
||||
const rows = await getDb()
|
||||
.messages.where('[roomId+createdAt]')
|
||||
.between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey])
|
||||
.reverse()
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
return rows.reverse();
|
||||
},
|
||||
|
||||
/** keep only the newest MAX_CACHED_MESSAGES_PER_ROOM messages for a room. */
|
||||
async prune(roomIdValue: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const count = await db.messages.where('roomId').equals(roomIdValue).count();
|
||||
if (count <= MAX_CACHED_MESSAGES_PER_ROOM) return;
|
||||
const excess = count - MAX_CACHED_MESSAGES_PER_ROOM;
|
||||
const oldest = await db.messages
|
||||
.where('[roomId+createdAt]')
|
||||
.between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey])
|
||||
.limit(excess)
|
||||
.primaryKeys();
|
||||
await db.messages.bulkDelete(oldest);
|
||||
},
|
||||
|
||||
/** wipe all messages for a room (e.g. on part). */
|
||||
async clearRoom(roomIdValue: string): Promise<void> {
|
||||
await getDb().messages.where('roomId').equals(roomIdValue).delete();
|
||||
}
|
||||
};
|
||||
|
||||
// dexie key bounds for compound-index range queries.
|
||||
const Dexie_minKey = -Infinity;
|
||||
const Dexie_maxKey = Infinity;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDb, type PendingMessageRecord } from '../database';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import { roomId } from '$lib/nostr/room-address';
|
||||
|
||||
/**
|
||||
* storage for outbound messages we couldn't publish (e.g. offline or relay
|
||||
* rejected). lets a later retry/flush pass pick them up.
|
||||
*/
|
||||
export const pendingMessageRepository = {
|
||||
async enqueue(room: RoomAddress, content: string, action: boolean): Promise<number> {
|
||||
return getDb().pendingMessages.add({
|
||||
roomId: roomId(room),
|
||||
relayUrl: room.relayUrl,
|
||||
groupId: room.groupId,
|
||||
content,
|
||||
action,
|
||||
queuedAt: Date.now()
|
||||
});
|
||||
},
|
||||
|
||||
async list(): Promise<PendingMessageRecord[]> {
|
||||
return getDb().pendingMessages.orderBy('queuedAt').toArray();
|
||||
},
|
||||
|
||||
async remove(localId: number): Promise<void> {
|
||||
await getDb().pendingMessages.delete(localId);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getDb } from '../database';
|
||||
|
||||
/** namespaced key/value storage for plugins ("<pluginId>:<key>"). */
|
||||
export const pluginStateRepository = {
|
||||
async get<T>(pluginId: string, key: string): Promise<T | undefined> {
|
||||
const record = await getDb().pluginState.get(`${pluginId}:${key}`);
|
||||
return record?.value as T | undefined;
|
||||
},
|
||||
|
||||
async set<T>(pluginId: string, key: string, value: T): Promise<void> {
|
||||
await getDb().pluginState.put({ key: `${pluginId}:${key}`, value });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getDb, type ProfileRecord } from '../database';
|
||||
import type { AppSettings } from '$lib/types/settings';
|
||||
|
||||
const SETTINGS_KEY = 'app';
|
||||
|
||||
/** storage for read markers, muted pubkeys, profiles, and settings. */
|
||||
|
||||
export const readMarkerRepository = {
|
||||
async set(roomId: string, lastReadAt: number): Promise<void> {
|
||||
await getDb().readMarkers.put({ roomId, lastReadAt });
|
||||
},
|
||||
async get(roomId: string): Promise<number | undefined> {
|
||||
return (await getDb().readMarkers.get(roomId))?.lastReadAt;
|
||||
}
|
||||
};
|
||||
|
||||
export const muteRepository = {
|
||||
async list(): Promise<string[]> {
|
||||
return (await getDb().mutedPubkeys.toArray()).map((r) => r.pubkey);
|
||||
},
|
||||
async add(pubkey: string): Promise<void> {
|
||||
await getDb().mutedPubkeys.put({ pubkey, mutedAt: Date.now() });
|
||||
},
|
||||
async remove(pubkey: string): Promise<void> {
|
||||
await getDb().mutedPubkeys.delete(pubkey);
|
||||
},
|
||||
async has(pubkey: string): Promise<boolean> {
|
||||
return (await getDb().mutedPubkeys.get(pubkey)) !== undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const profileRepository = {
|
||||
async get(pubkey: string): Promise<ProfileRecord | undefined> {
|
||||
return getDb().profiles.get(pubkey);
|
||||
},
|
||||
async save(record: ProfileRecord): Promise<void> {
|
||||
await getDb().profiles.put(record);
|
||||
},
|
||||
async all(): Promise<ProfileRecord[]> {
|
||||
return getDb().profiles.toArray();
|
||||
}
|
||||
};
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<AppSettings | undefined> {
|
||||
return (await getDb().settings.get(SETTINGS_KEY))?.value;
|
||||
},
|
||||
async save(value: AppSettings): Promise<void> {
|
||||
await getDb().settings.put({ key: SETTINGS_KEY, value });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getDb, type RelayRecord } from '../database';
|
||||
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/** storage for the relays the user has added. */
|
||||
export const relayRepository = {
|
||||
async list(): Promise<RelayRecord[]> {
|
||||
return getDb().relays.orderBy('addedAt').toArray();
|
||||
},
|
||||
|
||||
async add(url: string): Promise<void> {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
await getDb().relays.put({ url: normalized, addedAt: Date.now() });
|
||||
},
|
||||
|
||||
async remove(url: string): Promise<void> {
|
||||
await getDb().relays.delete(normalizeRelayUrl(url));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getDb, type RoomRecord } from '../database';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import { roomId } from '$lib/nostr/room-address';
|
||||
|
||||
/** storage for joined rooms. */
|
||||
export const roomRepository = {
|
||||
async list(): Promise<RoomRecord[]> {
|
||||
return getDb().rooms.orderBy('joinedAt').toArray();
|
||||
},
|
||||
|
||||
async add(address: RoomAddress, name: string, topic?: string): Promise<void> {
|
||||
const id = roomId(address);
|
||||
await getDb().rooms.put({
|
||||
id,
|
||||
relayUrl: address.relayUrl,
|
||||
groupId: address.groupId,
|
||||
name,
|
||||
topic,
|
||||
joinedAt: Date.now()
|
||||
});
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await getDb().rooms.delete(id);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { SignerService } from './signer-service';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { identityRepository } from '$lib/db/repositories';
|
||||
|
||||
/**
|
||||
* app-facing identity service. wraps SignerService and keeps the auth store in
|
||||
* sync. the command/ui layers call this — never ndk/signer classes.
|
||||
*/
|
||||
class AuthService {
|
||||
private readonly signer = new SignerService();
|
||||
|
||||
hasNip07(): boolean {
|
||||
return SignerService.hasNip07();
|
||||
}
|
||||
|
||||
async loginNip07(): Promise<string> {
|
||||
const active = await this.signer.loginNip07();
|
||||
authStore.setNip07(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
async loginBunker(connectionToken: string): Promise<string> {
|
||||
const active = await this.signer.loginBunker(connectionToken);
|
||||
authStore.setBunker(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
loginNsec(nsecOrHex: string): string {
|
||||
const active = this.signer.loginNsec(nsecOrHex);
|
||||
authStore.setNsec(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
generate(): string {
|
||||
const active = this.signer.generateAndLogin();
|
||||
authStore.setNsec(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
loginEphemeral(): string {
|
||||
const active = this.signer.loginEphemeral();
|
||||
authStore.setEphemeral(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.signer.logout();
|
||||
authStore.logout();
|
||||
}
|
||||
|
||||
// --- one-time key material access (for the landing ui only) --------------
|
||||
|
||||
/** the active in-memory key as nsec/npub, for one-time display after generate. */
|
||||
exportKeyPair(): { nsec: string; npub: string } | null {
|
||||
const nsec = this.signer.exportNsec();
|
||||
const npub = this.signer.exportNpub();
|
||||
return nsec && npub ? { nsec, npub } : null;
|
||||
}
|
||||
|
||||
// --- remember-on-device (nip-49 encrypted) -------------------------------
|
||||
|
||||
/** encrypt the active in-memory key with a passphrase and stash it locally. */
|
||||
async rememberOnDevice(passphrase: string): Promise<void> {
|
||||
const pubkey = this.signer.current?.pubkey;
|
||||
if (!pubkey) throw new Error('No active in-memory identity to remember.');
|
||||
const ncryptsec = this.signer.exportNcryptsec(passphrase);
|
||||
await identityRepository.save(pubkey, ncryptsec);
|
||||
}
|
||||
|
||||
/** is there a remembered (encrypted) identity on this device? */
|
||||
async hasRememberedIdentity(): Promise<boolean> {
|
||||
return (await identityRepository.get()) !== undefined;
|
||||
}
|
||||
|
||||
/** public key (hex) of the remembered identity, for display before unlock. */
|
||||
async rememberedPubkey(): Promise<string | undefined> {
|
||||
return (await identityRepository.get())?.pubkey;
|
||||
}
|
||||
|
||||
/** unlock and log in with the remembered identity using its passphrase. */
|
||||
async unlockRemembered(passphrase: string): Promise<string> {
|
||||
const record = await identityRepository.get();
|
||||
if (!record) throw new Error('No remembered identity on this device.');
|
||||
const active = this.signer.loginNcryptsec(record.ncryptsec, passphrase);
|
||||
authStore.setNsec(active.pubkey);
|
||||
return active.pubkey;
|
||||
}
|
||||
|
||||
/** forget the remembered identity (doesn't log out the current session). */
|
||||
async forgetRemembered(): Promise<void> {
|
||||
await identityRepository.clear();
|
||||
}
|
||||
|
||||
get pubkey(): string | undefined {
|
||||
return this.signer.current?.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
|
||||
/** a relay-published snapshot of a group's admins/members (from 39001/39002). */
|
||||
export type GroupRoster =
|
||||
| {
|
||||
/** an admin (39001) or member (39002) list update. */
|
||||
kind: 'admins' | 'members';
|
||||
/** pubkeys (hex) in the list. */
|
||||
pubkeys: string[];
|
||||
}
|
||||
| {
|
||||
/** a group metadata (39000) update. */
|
||||
kind: 'metadata';
|
||||
name?: string;
|
||||
/** the group's `about` — shown as the room topic. */
|
||||
about?: string;
|
||||
};
|
||||
|
||||
/** nip-29 moderation actions an admin can do. */
|
||||
export type ModerationAction = 'add' | 'remove';
|
||||
|
||||
/**
|
||||
* the app's chat transport. the ui and command layer lean on this interface
|
||||
* only — never on ndk objects directly. NdkChatTransport in prod, easy to fake
|
||||
* in tests.
|
||||
*/
|
||||
export interface ChatTransport {
|
||||
connectRelay(relayUrl: string): Promise<void>;
|
||||
disconnectRelay(relayUrl: string): Promise<void>;
|
||||
joinRoom(room: RoomAddress): Promise<void>;
|
||||
leaveRoom(room: RoomAddress): Promise<void>;
|
||||
/** publish a chat/action message; resolves with the signed event id. */
|
||||
sendRoomMessage(room: RoomAddress, content: string, action?: boolean): Promise<string>;
|
||||
/**
|
||||
* subscribe to a room's messages. the handler fires for each new, deduped,
|
||||
* verified-or-not message (verification status is on the message). returns
|
||||
* an unsub fn that cleans up the relay sub.
|
||||
*/
|
||||
subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void;
|
||||
/**
|
||||
* subscribe to a room's nip-29 metadata + admin/member lists (kinds
|
||||
* 39000/39001/39002). the handler fires on each update. returns an unsub fn.
|
||||
*/
|
||||
subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void;
|
||||
/**
|
||||
* publish a moderation event: add (kind 9000) or remove (kind 9001) a user.
|
||||
* the relay has the final say — it rejects the event if the sender isn't an
|
||||
* admin, which comes back as a thrown error.
|
||||
*/
|
||||
moderateUser(room: RoomAddress, action: ModerationAction, targetPubkey: string): Promise<void>;
|
||||
/**
|
||||
* set the group's metadata (kind 9002) — used to change the room topic
|
||||
* (`about`). admin-only; the relay rejects non-admins.
|
||||
*/
|
||||
setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import NDK, { NDKEvent, giftWrap, giftUnwrap, NDKUser } from '@nostr-dev-kit/ndk';
|
||||
import type { NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { getNdk } from './ndk-client';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { dmStore, dmRoomId } from '$lib/stores/dm-store';
|
||||
import { profileService } from './profile-service';
|
||||
import { notificationService } from '$lib/notifications/notification-service';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
import { decodeNpub } from '$lib/utils/npub';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// nip-17 private dms. a message is an unsigned kind-14 "rumor", gift-wrapped
|
||||
// (nip-59) into a kind-1059 event addressed to the recipient. the wrapper hides
|
||||
// sender, recipient, and content from relays.
|
||||
const KIND_PRIVATE_DM = 14;
|
||||
const KIND_GIFT_WRAP = 1059;
|
||||
|
||||
/**
|
||||
* dm service. publishes gift-wrapped dms and subscribes to incoming ones,
|
||||
* decrypting and showing them in the terminal. the command/ui layers call
|
||||
* this — never ndk directly.
|
||||
*/
|
||||
class DmService {
|
||||
private subscription: NDKSubscription | null = null;
|
||||
/** gift-wrap event ids we've already handled, for dedup across reconnects. */
|
||||
private readonly seen = new Set<string>();
|
||||
|
||||
private currentPubkey: string | undefined;
|
||||
|
||||
private get ndk(): NDK {
|
||||
return getNdk();
|
||||
}
|
||||
|
||||
/**
|
||||
* tie dm listening to the auth lifecycle: (re)subscribe when the logged-in
|
||||
* identity changes, tear down on logout. call once at app startup.
|
||||
*/
|
||||
init(): void {
|
||||
authStore.subscribe((auth) => {
|
||||
if (auth.pubkey === this.currentPubkey) return;
|
||||
this.currentPubkey = auth.pubkey;
|
||||
if (auth.pubkey) this.start();
|
||||
else this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* start listening for incoming dms addressed to the logged-in user. fine to
|
||||
* call over and over; it re-subscribes for the current identity.
|
||||
*/
|
||||
start(): void {
|
||||
const me = get(authStore).pubkey;
|
||||
if (!me) return;
|
||||
this.stop();
|
||||
// new identity → dms to the old one aren't ours anymore.
|
||||
this.seen.clear();
|
||||
// gift wraps are addressed to us via a `p` tag; content is opaque.
|
||||
this.subscription = this.ndk.subscribe(
|
||||
{ kinds: [KIND_GIFT_WRAP], '#p': [me] },
|
||||
{ closeOnEose: false }
|
||||
);
|
||||
this.subscription.on('event', (event: NDKEvent) => void this.onGiftWrap(event));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.subscription?.stop();
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* send a dm to someone (npub or hex). throws on bad input / no signer.
|
||||
* returns the recipient's hex pubkey so callers can focus the convo.
|
||||
*/
|
||||
async send(recipient: string, content: string): Promise<string> {
|
||||
const text = content.trim();
|
||||
if (!text) throw new Error('Message is empty.');
|
||||
if (!this.ndk.signer) throw new Error('You must /login to send a direct message.');
|
||||
|
||||
let pubkey: string;
|
||||
try {
|
||||
pubkey = decodeNpub(recipient);
|
||||
} catch {
|
||||
throw new Error('Invalid recipient — expected an npub or hex pubkey.');
|
||||
}
|
||||
|
||||
const recipientUser = new NDKUser({ pubkey });
|
||||
// build the UNSIGNED rumor (giftWrap signs the wrapper, not the rumor).
|
||||
// created_at and pubkey have to be set — giftWrap serializes the rumor to
|
||||
// compute its id, and ndk rejects a missing/bad created_at.
|
||||
const me = get(authStore).pubkey;
|
||||
const rumor = new NDKEvent(this.ndk);
|
||||
rumor.kind = KIND_PRIVATE_DM;
|
||||
rumor.content = text;
|
||||
rumor.tags = [['p', pubkey]];
|
||||
rumor.created_at = Math.floor(Date.now() / 1000);
|
||||
if (me) rumor.pubkey = me;
|
||||
|
||||
const wrap = await giftWrap(rumor, recipientUser);
|
||||
try {
|
||||
await wrap.publish();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not deliver the DM: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// make sure the convo exists, then echo our own outgoing dm locally
|
||||
// (gift wraps to self aren't reliably stored, so we just render it).
|
||||
dmStore.ensure(pubkey);
|
||||
this.render(pubkey, text, true);
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
private async onGiftWrap(event: NDKEvent): Promise<void> {
|
||||
if (this.seen.has(event.id)) return;
|
||||
this.seen.add(event.id);
|
||||
try {
|
||||
const rumor = await giftUnwrap(event);
|
||||
if (rumor.kind !== KIND_PRIVATE_DM) return;
|
||||
dmStore.noteIncoming(rumor.pubkey);
|
||||
this.render(rumor.pubkey, rumor.content, false);
|
||||
const name = profileService.cachedName(rumor.pubkey) ?? shortenPubkey(rumor.pubkey);
|
||||
notificationService.notifyDm(name, rumor.content);
|
||||
void profileService.resolve(rumor.pubkey);
|
||||
} catch {
|
||||
// can't decrypt (not for us / malformed) — just ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* render a dm into its per-convo view (roomId = dm:<pubkey>). incoming dm
|
||||
* content is REMOTE and shows as remote chat (a "message" entry), never as a
|
||||
* trusted system/notice line — so it can't be mistaken for local output.
|
||||
*/
|
||||
private render(counterpartyPubkey: string, content: string, outgoing: boolean): void {
|
||||
const name = profileService.cachedName(counterpartyPubkey) ?? shortenPubkey(counterpartyPubkey);
|
||||
const me = get(authStore).pubkey;
|
||||
terminalStore.push({
|
||||
type: 'message',
|
||||
author: outgoing ? me : counterpartyPubkey,
|
||||
authorDisplayName: outgoing ? `you → ${name}` : `${name} → you`,
|
||||
content,
|
||||
roomId: dmRoomId(counterpartyPubkey)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const dmService = new DmService();
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import { NIP29_KINDS } from './nip29';
|
||||
|
||||
/**
|
||||
* the bare-minimum shape of a nostr event we need to map — a structural subset
|
||||
* of NDKEvent, so the mapper is unit-testable without building ndk objects.
|
||||
*/
|
||||
export interface MappableEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at?: number;
|
||||
kind?: number;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
// action ("/me") encoding. nip-29 has no standard for actions. we must NOT use
|
||||
// the classic ctcp \x01 framing here: control chars serialize ambiguously
|
||||
// across JSON implementations ( vs a raw byte), so NDK's client-side event
|
||||
// id would disagree with the relay's recomputed id and the event gets rejected
|
||||
// as "id is computed incorrectly". so we use a plain, json-safe sentinel that
|
||||
// hashes the same everywhere.
|
||||
const ACTION_PREFIX = '/me ';
|
||||
// still-readable handling of the old ctcp form for any already-stored events.
|
||||
const LEGACY_CTCP = String.fromCharCode(1);
|
||||
const LEGACY_PREFIX = `${LEGACY_CTCP}ACTION `;
|
||||
|
||||
/** grab the first `e` tag as a reply reference (nip-10 style, best-effort). */
|
||||
function replyTarget(tags: string[][]): string | undefined {
|
||||
const e = tags.find((t) => t[0] === 'e');
|
||||
return e?.[1];
|
||||
}
|
||||
|
||||
/** spot an action message and return its inner text, or null if it's not one. */
|
||||
function parseAction(content: string): string | null {
|
||||
if (content.startsWith(ACTION_PREFIX)) {
|
||||
return content.slice(ACTION_PREFIX.length);
|
||||
}
|
||||
// legacy ctcp form (\x01ACTION …\x01) — kept readable for cached history.
|
||||
if (content.startsWith(LEGACY_PREFIX)) {
|
||||
const body = content.slice(LEGACY_PREFIX.length);
|
||||
return body.endsWith(LEGACY_CTCP) ? body.slice(0, -1) : body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* map a nostr event into the app's ChatMessage model.
|
||||
*
|
||||
* `verified` must come from the nostr lib's signature/id check — the caller
|
||||
* passes the result of verifying the event. cached or relayed events are
|
||||
* untrusted until that check passes. content is plain text and is never
|
||||
* treated as html by the ui.
|
||||
*/
|
||||
export function mapEventToMessage(
|
||||
event: MappableEvent,
|
||||
room: RoomAddress,
|
||||
verified: boolean
|
||||
): ChatMessage {
|
||||
const content = event.content ?? '';
|
||||
const actionText = parseAction(content);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
room,
|
||||
authorPubkey: event.pubkey,
|
||||
content: actionText ?? content,
|
||||
createdAt: event.created_at ?? 0,
|
||||
eventKind: event.kind ?? NIP29_KINDS.chat,
|
||||
replyTo: replyTarget(event.tags),
|
||||
action: actionText !== null,
|
||||
verified
|
||||
};
|
||||
}
|
||||
|
||||
/** wrap plain text as a `/me` action payload (json-safe, no control chars). */
|
||||
export function encodeAction(text: string): string {
|
||||
return `${ACTION_PREFIX}${text}`;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import NDK, { NDKEvent, NDKRelaySet, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import type { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import type { ChatTransport, GroupRoster, ModerationAction } from './chat-transport';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
import { roomId } from './room-address';
|
||||
import {
|
||||
CHAT_KINDS,
|
||||
GROUP_META_KINDS,
|
||||
NIP29_KINDS,
|
||||
groupTag,
|
||||
pubkeysFromTags,
|
||||
tagValue
|
||||
} from './nip29';
|
||||
import { mapEventToMessage, encodeAction } from './event-mapper';
|
||||
import { relayService } from './relay-service';
|
||||
|
||||
interface RoomSub {
|
||||
subscription: NDKSubscription;
|
||||
/** event ids we've already delivered, for cross-sub dedup. */
|
||||
seen: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ndk-backed ChatTransport for nip-29 managed groups.
|
||||
* owns per-room subs and makes sure they get cleaned up on leave/unsub.
|
||||
*/
|
||||
export class NdkChatTransport implements ChatTransport {
|
||||
private readonly subs = new Map<string, RoomSub>();
|
||||
/** separate roster (39001/39002) subs, keyed by room id. */
|
||||
private readonly rosterSubs = new Map<string, NDKSubscription>();
|
||||
|
||||
constructor(private readonly ndk: NDK) {}
|
||||
|
||||
async connectRelay(relayUrl: string): Promise<void> {
|
||||
await relayService.connect(relayUrl);
|
||||
}
|
||||
|
||||
async disconnectRelay(relayUrl: string): Promise<void> {
|
||||
relayService.disconnect(relayUrl);
|
||||
}
|
||||
|
||||
/** publish a nip-29 join request (kind 9021) tagged with the group id. */
|
||||
async joinRoom(room: RoomAddress): Promise<void> {
|
||||
await this.publish(room, NIP29_KINDS.joinRequest, '');
|
||||
}
|
||||
|
||||
/** publish a nip-29 leave request (kind 9022) and tear the sub down. */
|
||||
async leaveRoom(room: RoomAddress): Promise<void> {
|
||||
try {
|
||||
await this.publish(room, NIP29_KINDS.leaveRequest, '');
|
||||
} finally {
|
||||
this.teardown(roomId(room));
|
||||
}
|
||||
}
|
||||
|
||||
async sendRoomMessage(room: RoomAddress, content: string, action = false): Promise<string> {
|
||||
return this.publish(room, NIP29_KINDS.chat, action ? encodeAction(content) : content);
|
||||
}
|
||||
|
||||
subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void {
|
||||
const id = roomId(room);
|
||||
// drop any sub already open for this room before opening a fresh one.
|
||||
this.teardown(id);
|
||||
|
||||
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||
const filter: NDKFilter = {
|
||||
kinds: CHAT_KINDS,
|
||||
'#h': [room.groupId]
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet);
|
||||
|
||||
subscription.on('event', (event: NDKEvent) => {
|
||||
// dedup across relays/reconnects by event id.
|
||||
if (seen.has(event.id)) return;
|
||||
seen.add(event.id);
|
||||
|
||||
// every event is untrusted until the lib validates it.
|
||||
// verifySignature also checks the id matches the content hash.
|
||||
const verified = event.verifySignature(false) === true;
|
||||
const message = mapEventToMessage(
|
||||
{
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
created_at: event.created_at,
|
||||
kind: event.kind,
|
||||
tags: event.tags
|
||||
},
|
||||
room,
|
||||
verified
|
||||
);
|
||||
handler(message);
|
||||
});
|
||||
|
||||
this.subs.set(id, { subscription, seen });
|
||||
|
||||
return () => this.teardown(id);
|
||||
}
|
||||
|
||||
subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void {
|
||||
const id = roomId(room);
|
||||
this.teardownRoster(id);
|
||||
|
||||
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||
// addressable lists are tagged by group id via `d`, not `h`.
|
||||
const filter: NDKFilter = {
|
||||
kinds: GROUP_META_KINDS,
|
||||
'#d': [room.groupId]
|
||||
};
|
||||
const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet);
|
||||
|
||||
subscription.on('event', (event: NDKEvent) => {
|
||||
if (event.verifySignature(false) !== true) return;
|
||||
if (event.kind === NIP29_KINDS.admins) {
|
||||
handler({ kind: 'admins', pubkeys: pubkeysFromTags(event.tags) });
|
||||
} else if (event.kind === NIP29_KINDS.members) {
|
||||
handler({ kind: 'members', pubkeys: pubkeysFromTags(event.tags) });
|
||||
} else if (event.kind === NIP29_KINDS.metadata) {
|
||||
handler({
|
||||
kind: 'metadata',
|
||||
name: tagValue(event.tags, 'name'),
|
||||
about: tagValue(event.tags, 'about')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.rosterSubs.set(id, subscription);
|
||||
return () => this.teardownRoster(id);
|
||||
}
|
||||
|
||||
async moderateUser(
|
||||
room: RoomAddress,
|
||||
action: ModerationAction,
|
||||
targetPubkey: string
|
||||
): Promise<void> {
|
||||
const kind = action === 'add' ? NIP29_KINDS.putUser : NIP29_KINDS.removeUser;
|
||||
// the `p` tag names the target; the relay does the admin-only check.
|
||||
await this.publish(room, kind, '', [['p', targetPubkey]]);
|
||||
}
|
||||
|
||||
async setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise<void> {
|
||||
const tags: string[][] = [];
|
||||
if (fields.name !== undefined) tags.push(['name', fields.name]);
|
||||
if (fields.about !== undefined) tags.push(['about', fields.about]);
|
||||
// relay does the admin-only check for kind 9002.
|
||||
await this.publish(room, NIP29_KINDS.editMetadata, '', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* build, sign, and publish a nip-29 event with the group `h` tag. returns
|
||||
* the signed event id. on rejection it throws a PublishRejection carrying
|
||||
* that id so the caller can take back an optimistic echo.
|
||||
*/
|
||||
private async publish(
|
||||
room: RoomAddress,
|
||||
kind: number,
|
||||
content: string,
|
||||
extraTags: string[][] = []
|
||||
): Promise<string> {
|
||||
if (!this.ndk.signer) {
|
||||
throw new Error('You must /login before sending to a room.');
|
||||
}
|
||||
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||
const event = new NDKEvent(this.ndk);
|
||||
event.kind = kind;
|
||||
event.content = content;
|
||||
event.tags = [groupTag(room.groupId), ...extraTags];
|
||||
await event.sign();
|
||||
// sign() fills event.id; it's the same id ndk optimistically echoes.
|
||||
const eventId = event.id;
|
||||
try {
|
||||
await event.publish(relaySet);
|
||||
} catch (err) {
|
||||
throw new PublishRejection(rejectionMessage(err), eventId);
|
||||
}
|
||||
return eventId;
|
||||
}
|
||||
|
||||
private teardown(id: string): void {
|
||||
const existing = this.subs.get(id);
|
||||
if (existing) {
|
||||
existing.subscription.stop();
|
||||
this.subs.delete(id);
|
||||
}
|
||||
this.teardownRoster(id);
|
||||
}
|
||||
|
||||
private teardownRoster(id: string): void {
|
||||
const existing = this.rosterSubs.get(id);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
this.rosterSubs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** clean up all subs (identity switch / teardown). */
|
||||
destroy(): void {
|
||||
for (const id of [...this.subs.keys()]) this.teardown(id);
|
||||
for (const id of [...this.rosterSubs.keys()]) this.teardownRoster(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* a publish the relay rejected. carries the (friendly) message plus the signed
|
||||
* event id so the caller can take back an optimistic echo of that event.
|
||||
*/
|
||||
export class PublishRejection extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly eventId: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PublishRejection';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* turn ndk's raw publish failure into something worth showing a user. ndk says
|
||||
* "not enough relays received the event (0 published, 1 required)" when the one
|
||||
* relay rejected the write; the relay's real reason (e.g. a nip-29 "not a
|
||||
* member" message) is usually tacked on, so show that when it's there.
|
||||
*/
|
||||
export function rejectionMessage(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
// ndk tacks on per-relay reasons like "relay rejected: <reason>" or "msg: …".
|
||||
const reason = /(?:rejected:|msg:)\s*(.+)/i.exec(raw)?.[1]?.trim();
|
||||
if (reason && !/^\s*$/.test(reason)) {
|
||||
return `Relay rejected the message: ${reason}`;
|
||||
}
|
||||
if (/0 published/i.test(raw) || /not enough relays/i.test(raw)) {
|
||||
return 'The relay rejected your message. You may not be a member of this room, or the relay is unreachable.';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import NDK, { NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk';
|
||||
|
||||
/**
|
||||
* lazily-made singleton ndk instance (client-only). we run relay connections
|
||||
* ourselves via RelayManager, so ndk starts with no explicit relays and the
|
||||
* outbox model off — it's just for signing, nip-11, and owning the relay pool.
|
||||
*/
|
||||
let ndk: NDK | null = null;
|
||||
|
||||
export function getNdk(): NDK {
|
||||
if (!ndk) {
|
||||
ndk = new NDK({
|
||||
explicitRelayUrls: [],
|
||||
enableOutboxModel: false,
|
||||
autoConnectUserRelays: false
|
||||
});
|
||||
// nip-42: when a relay asks for auth and a signer is present, ndk builds
|
||||
// and sends the signed kind-22242 auth event. RelayManager also surfaces
|
||||
// the challenge as a terminal notice.
|
||||
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
|
||||
}
|
||||
return ndk;
|
||||
}
|
||||
|
||||
/** test/reset hook: drop the singleton so a fresh one gets made. */
|
||||
export function resetNdk(): void {
|
||||
ndk = null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { fetchRelayInformation } from '@nostr-dev-kit/ndk';
|
||||
import type NDK from '@nostr-dev-kit/ndk';
|
||||
import type { NDKRelay } from '@nostr-dev-kit/ndk';
|
||||
import type { RelayConnection, RelayConnectionEvent } from './relay-manager';
|
||||
import type { RelayInformation } from '$lib/types/relay';
|
||||
|
||||
/**
|
||||
* adapts an NDKRelay (from the shared pool) to the driver-agnostic
|
||||
* RelayConnection interface the RelayManager leans on. keeping this thin adapter
|
||||
* separate means the manager's logic never imports ndk directly.
|
||||
*/
|
||||
class NdkRelayConnection implements RelayConnection {
|
||||
private readonly relay: NDKRelay;
|
||||
/** ndk runs its own reconnection loop; the manager mustn't add a second. */
|
||||
readonly selfReconnecting = true;
|
||||
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
ndk: NDK
|
||||
) {
|
||||
// `getRelay(url, false)` returns (or creates) the pool relay without
|
||||
// eagerly connecting — we call connect() ourselves.
|
||||
this.relay = ndk.pool.getRelay(url, false, false);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// reconnect=true lets ndk retry, but our manager owns the higher-level
|
||||
// backoff and loop prevention across the app.
|
||||
await this.relay.connect(undefined, true);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.relay.disconnect();
|
||||
}
|
||||
|
||||
on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void {
|
||||
// map our vocabulary onto NDKRelay events.
|
||||
const wrapped = (payload?: unknown) =>
|
||||
handler(typeof payload === 'string' ? payload : undefined);
|
||||
this.relay.on(event, wrapped as never);
|
||||
return () => this.relay.off(event, wrapped as never);
|
||||
}
|
||||
}
|
||||
|
||||
/** factory used by the app-level RelayManager wiring. */
|
||||
export function createNdkConnection(url: string, ndk: NDK): RelayConnection {
|
||||
return new NdkRelayConnection(url, ndk);
|
||||
}
|
||||
|
||||
/** nip-11 fetch mapped into the app-level RelayInformation model. */
|
||||
export async function fetchNip11(url: string): Promise<RelayInformation> {
|
||||
const info = await fetchRelayInformation(url);
|
||||
// `features` and `motd` are nosterm extensions ndk's typed document doesn't
|
||||
// know about, so read them defensively off the raw object.
|
||||
const raw = info as unknown as { features?: unknown; motd?: unknown };
|
||||
const features = Array.isArray(raw.features)
|
||||
? raw.features.filter((f): f is string => typeof f === 'string')
|
||||
: undefined;
|
||||
const motd = typeof raw.motd === 'string' ? raw.motd : undefined;
|
||||
return {
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
pubkey: info.pubkey,
|
||||
contact: info.contact,
|
||||
software: info.software,
|
||||
version: info.version,
|
||||
supportedNips: info.supported_nips,
|
||||
features,
|
||||
motd,
|
||||
limitation: info.limitation
|
||||
? {
|
||||
maxMessageLength: info.limitation.max_message_length,
|
||||
maxSubscriptions: info.limitation.max_subscriptions,
|
||||
authRequired: info.limitation.auth_required,
|
||||
paymentRequired: info.limitation.payment_required
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* nip-29 (managed relay-based groups) event kinds and tag helpers.
|
||||
* managed groups: the relay keeps membership/metadata and enforces access.
|
||||
*/
|
||||
export const NIP29_KINDS = {
|
||||
/** chat message posted to a group. */
|
||||
chat: 9,
|
||||
/** threaded reply (we show it like chat for the mvp). */
|
||||
threadReply: 11,
|
||||
/** admin adds a user to a group (kind 9000). */
|
||||
putUser: 9000,
|
||||
/** admin removes a user from a group (kind 9001). */
|
||||
removeUser: 9001,
|
||||
/** admin edits group metadata — name/about (kind 9002). */
|
||||
editMetadata: 9002,
|
||||
/** join request (kind 9021). */
|
||||
joinRequest: 9021,
|
||||
/** leave request (kind 9022). */
|
||||
leaveRequest: 9022,
|
||||
/** group metadata (name/about/picture). */
|
||||
metadata: 39000,
|
||||
/** group admins list. */
|
||||
admins: 39001,
|
||||
/** group members list. */
|
||||
members: 39002
|
||||
} as const;
|
||||
|
||||
/** addressable nip-29 list kinds the client subscribes to for group state. */
|
||||
export const GROUP_META_KINDS: number[] = [
|
||||
NIP29_KINDS.metadata,
|
||||
NIP29_KINDS.admins,
|
||||
NIP29_KINDS.members
|
||||
];
|
||||
|
||||
/** pull all `p` tag pubkeys out of an event's tags. */
|
||||
export function pubkeysFromTags(tags: string[][]): string[] {
|
||||
return tags.filter((t) => t[0] === 'p' && typeof t[1] === 'string').map((t) => t[1] as string);
|
||||
}
|
||||
|
||||
/** value of the first tag with the given name, or undefined. */
|
||||
export function tagValue(tags: string[][], name: string): string | undefined {
|
||||
return tags.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
/** kinds we treat as showable chat messages in a room timeline. */
|
||||
export const CHAT_KINDS: number[] = [NIP29_KINDS.chat, NIP29_KINDS.threadReply];
|
||||
|
||||
/** the `h` tag carries the group id on every nip-29 event. */
|
||||
export function groupTag(groupId: string): string[] {
|
||||
return ['h', groupId];
|
||||
}
|
||||
|
||||
/** pull the group id out of an event's tags, if it's there. */
|
||||
export function groupIdFromTags(tags: string[][]): string | undefined {
|
||||
const h = tags.find((t) => t[0] === 'h');
|
||||
return h?.[1];
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { getNdk } from './ndk-client';
|
||||
import { profileRepository } from '$lib/db/repositories';
|
||||
import { profileStore } from '$lib/stores/profile-store';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
|
||||
interface CachedProfile {
|
||||
name?: string;
|
||||
nip05?: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolves and caches display names / nip-05 ids for pubkeys. names and nip-05
|
||||
* are NOT treated as globally unique — callers still tell people apart with
|
||||
* shortened pubkeys. profile metadata never drives styling.
|
||||
*/
|
||||
class ProfileService {
|
||||
/** how long a name-less (negative) cache entry is trusted before we retry. */
|
||||
private static readonly NEGATIVE_TTL_MS = 30_000;
|
||||
|
||||
private readonly cache = new Map<string, CachedProfile>();
|
||||
private readonly inflight = new Map<string, Promise<void>>();
|
||||
|
||||
private currentPubkey: string | undefined;
|
||||
|
||||
/**
|
||||
* watch the auth lifecycle so the logged-in user's own name resolves the
|
||||
* moment they sign in (no room roster needed). call once at app startup.
|
||||
*/
|
||||
init(): void {
|
||||
authStore.subscribe((auth) => {
|
||||
if (auth.pubkey === this.currentPubkey) return;
|
||||
this.currentPubkey = auth.pubkey;
|
||||
if (auth.pubkey) void this.resolve(auth.pubkey);
|
||||
});
|
||||
}
|
||||
|
||||
/** grab a cached display name synchronously, if we have one. */
|
||||
cachedName(pubkey: string): string | undefined {
|
||||
return this.cache.get(pubkey)?.name;
|
||||
}
|
||||
|
||||
/** cached nip-05 id, if any. */
|
||||
cachedNip05(pubkey: string): string | undefined {
|
||||
return this.cache.get(pubkey)?.nip05;
|
||||
}
|
||||
|
||||
/**
|
||||
* check a pubkey's claimed nip-05 against the domain's .well-known/nostr.json.
|
||||
* returns:
|
||||
* 'verified' — the domain maps the name to this exact pubkey,
|
||||
* 'mismatch' — the domain maps it to a DIFFERENT pubkey (impersonation),
|
||||
* 'unverified'— no nip-05 claimed, not found, or network hiccup (can't tell).
|
||||
* a nip-05 is never proof of identity unless this returns 'verified'.
|
||||
*/
|
||||
async verifyNip05(pubkey: string): Promise<'verified' | 'mismatch' | 'unverified'> {
|
||||
const nip05 = this.cachedNip05(pubkey);
|
||||
if (!nip05) return 'unverified';
|
||||
try {
|
||||
const user = getNdk().getUser({ pubkey });
|
||||
const result = await user.validateNip05(nip05);
|
||||
if (result === true) return 'verified';
|
||||
if (result === false) return 'mismatch';
|
||||
return 'unverified';
|
||||
} catch {
|
||||
return 'unverified';
|
||||
}
|
||||
}
|
||||
|
||||
/** seed the in-memory cache from storage (no network fetch). */
|
||||
hydrate(pubkey: string, name?: string, nip05?: string): void {
|
||||
if (!this.cache.has(pubkey)) {
|
||||
this.cache.set(pubkey, { name, nip05, fetchedAt: Date.now() });
|
||||
if (name) profileStore.setName(pubkey, name);
|
||||
}
|
||||
}
|
||||
|
||||
/** force-set a cached display name (e.g. after publishing our own profile). */
|
||||
setName(pubkey: string, name: string): void {
|
||||
const existing = this.cache.get(pubkey);
|
||||
this.cache.set(pubkey, { ...existing, name, fetchedAt: Date.now() });
|
||||
profileStore.setName(pubkey, name);
|
||||
void profileRepository.save({ pubkey, name, nip05: existing?.nip05, fetchedAt: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* publish the logged-in user's display name (nip-01 kind 0). needs an active
|
||||
* signer. merges with any existing profile fields, updates the local cache
|
||||
* right away, and saves it.
|
||||
*/
|
||||
async publishName(name: string): Promise<string> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Enter a display name.');
|
||||
if (trimmed.length > 64) throw new Error('Display name must be 64 characters or fewer.');
|
||||
|
||||
const ndk = getNdk();
|
||||
if (!ndk.signer) throw new Error('You must /login before setting a profile name.');
|
||||
|
||||
const user = await ndk.signer.user();
|
||||
// merge onto any existing profile so we don't stomp other fields.
|
||||
try {
|
||||
await user.fetchProfile();
|
||||
} catch {
|
||||
/* first-time profile; start fresh */
|
||||
}
|
||||
user.profile = { ...(user.profile ?? {}), name: trimmed, displayName: trimmed };
|
||||
await user.publish();
|
||||
|
||||
this.setName(user.pubkey, trimmed);
|
||||
return user.pubkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch a profile once; later calls dedupe/no-op while cached. an entry that
|
||||
* came back with *no name* (profile not found, or fetched before any relay
|
||||
* connected) gets retried after a cooldown so a name that shows up later —
|
||||
* or once relays connect — still lands.
|
||||
*/
|
||||
async resolve(pubkey: string): Promise<void> {
|
||||
if (this.inflight.has(pubkey)) return;
|
||||
const cached = this.cache.get(pubkey);
|
||||
if (cached?.name) return; // got a name already — nothing to do.
|
||||
if (cached && Date.now() - cached.fetchedAt < ProfileService.NEGATIVE_TTL_MS) return;
|
||||
const task = this.fetch(pubkey);
|
||||
this.inflight.set(pubkey, task);
|
||||
try {
|
||||
await task;
|
||||
} finally {
|
||||
this.inflight.delete(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(pubkey: string): Promise<void> {
|
||||
try {
|
||||
const user = getNdk().getUser({ pubkey });
|
||||
const profile = await user.fetchProfile();
|
||||
const entry: CachedProfile = {
|
||||
name: profile?.displayName || profile?.name || undefined,
|
||||
nip05: profile?.nip05,
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
this.cache.set(pubkey, entry);
|
||||
if (entry.name) profileStore.setName(pubkey, entry.name);
|
||||
// save resolved profiles so they show fast next session.
|
||||
void profileRepository.save({
|
||||
pubkey,
|
||||
name: entry.name,
|
||||
nip05: entry.nip05,
|
||||
fetchedAt: entry.fetchedAt
|
||||
});
|
||||
} catch {
|
||||
// cache a negative result briefly so we don't hammer on failure.
|
||||
this.cache.set(pubkey, { fetchedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const profileService = new ProfileService();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import type { RelayInformation } from '$lib/types/relay';
|
||||
|
||||
/**
|
||||
* nosterm relay capability handshake (client side).
|
||||
*
|
||||
* a `nosterm-relay` advertises a `features` array in its nip-11 doc. the ui
|
||||
* feature-gates on these so it can light up extras on a home relay while falling
|
||||
* back gracefully to plain nostr relays (which leave them out).
|
||||
*
|
||||
* known feature flags (extend as the relay implements them):
|
||||
* channels · presence · typing · moderation · search · bots
|
||||
*/
|
||||
export type RelayFeature = 'channels' | 'presence' | 'typing' | 'moderation' | 'search' | 'bots';
|
||||
|
||||
/** true if the relay calls itself a nosterm-relay build. */
|
||||
export function isNostermRelay(info: RelayInformation | undefined): boolean {
|
||||
return info?.software === 'nosterm-relay';
|
||||
}
|
||||
|
||||
/** does a specific relay (by normalized url) advertise a feature? */
|
||||
export function relayHasFeature(url: string, feature: RelayFeature): boolean {
|
||||
const state = get(relayStore)[url];
|
||||
return state?.info?.features?.includes(feature) ?? false;
|
||||
}
|
||||
|
||||
/** all features a relay advertises, or [] if none/plain relay. */
|
||||
export function relayFeatures(url: string): string[] {
|
||||
return get(relayStore)[url]?.info?.features ?? [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getRelayListForUser } from '@nostr-dev-kit/ndk';
|
||||
import { getNdk } from './ndk-client';
|
||||
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/**
|
||||
* nip-65 (kind 10002) relay-list discovery. grabs a user's advertised relays so
|
||||
* the client can connect to their "outbox" — the relays they actually read from
|
||||
* and write to — instead of leaning on defaults alone.
|
||||
*/
|
||||
class RelayListService {
|
||||
/**
|
||||
* fetch a user's advertised relay urls (normalized, ws/wss only).
|
||||
* returns [] if they have no list or it can't be fetched.
|
||||
*/
|
||||
async fetch(pubkey: string): Promise<string[]> {
|
||||
try {
|
||||
const list = await getRelayListForUser(pubkey, getNdk());
|
||||
const urls = list?.relays ?? [];
|
||||
// normalize + validate; drop anything unusable, dedupe.
|
||||
const seen = new Set<string>();
|
||||
for (const raw of urls) {
|
||||
if (!isValidRelayUrl(raw)) continue;
|
||||
seen.add(normalizeRelayUrl(raw));
|
||||
}
|
||||
return [...seen];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const relayListService = new RelayListService();
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { RelayConnectionState, RelayInformation } from '$lib/types/relay';
|
||||
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/**
|
||||
* one relay connection as the manager sees it. an ndk-backed adapter in prod,
|
||||
* fakes in tests — so the manager's state machine and backoff logic can be
|
||||
* tested without real sockets.
|
||||
*/
|
||||
export interface RelayConnection {
|
||||
readonly url: string;
|
||||
connect(): Promise<void>;
|
||||
disconnect(): void;
|
||||
/** register a listener; hands back an unsub fn. */
|
||||
on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void;
|
||||
/**
|
||||
* true if the driver runs its own reconnect loop (e.g. ndk). when set, the
|
||||
* manager tracks state but skips its own backoff, so the two loops don't
|
||||
* fight. defaults to false.
|
||||
*/
|
||||
readonly selfReconnecting?: boolean;
|
||||
}
|
||||
|
||||
export type RelayConnectionEvent =
|
||||
'connect' | 'disconnect' | 'auth' | 'authed' | 'auth:failed' | 'notice';
|
||||
|
||||
/** injected side-effects, so the manager stays pure and testable. */
|
||||
export interface RelayManagerDeps {
|
||||
/** make (or find) a driver connection for a normalized url. */
|
||||
createConnection: (url: string) => RelayConnection;
|
||||
/** grab nip-11 info; may reject if unsupported. */
|
||||
fetchInfo: (url: string) => Promise<RelayInformation>;
|
||||
/** fire an app event (state change, notice, auth, error). */
|
||||
emit: (event: RelayManagerEvent) => void;
|
||||
/** run a callback after `ms`; returns a cancel handle. swappable for tests. */
|
||||
setTimer?: (cb: () => void, ms: number) => number;
|
||||
clearTimer?: (handle: number) => void;
|
||||
}
|
||||
|
||||
export type RelayManagerEvent =
|
||||
| { type: 'state'; url: string; state: RelayConnectionState; error?: string }
|
||||
| { type: 'info'; url: string; info: RelayInformation }
|
||||
| { type: 'auth'; url: string; challenge?: string }
|
||||
| { type: 'authed'; url: string }
|
||||
| { type: 'auth:failed'; url: string; error?: string }
|
||||
| { type: 'notice'; url: string; message: string }
|
||||
// fires when a relay comes back after having been connected before, so
|
||||
// consumers can re-sub and backfill whatever they missed during the outage.
|
||||
| { type: 'reconnected'; url: string };
|
||||
|
||||
interface ManagedRelay {
|
||||
url: string;
|
||||
connection: RelayConnection;
|
||||
state: RelayConnectionState;
|
||||
reconnectAttempts: number;
|
||||
/** pending backoff timer handle, if a reconnect is queued up. */
|
||||
reconnectTimer?: number;
|
||||
/** true once the user hit disconnect on purpose — kills auto-reconnect. */
|
||||
intentionalDisconnect: boolean;
|
||||
/** true once this relay has connected at least once (tells an unexpected
|
||||
* drop-then-reconnect apart from a first-time connect). */
|
||||
everConnected: boolean;
|
||||
/** listener unsub fns, cleaned up on removal. */
|
||||
unsubscribers: (() => void)[];
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 8;
|
||||
const BASE_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
|
||||
/**
|
||||
* owns the relay connection lifecycle: normalization, dedup/reuse, state
|
||||
* tracking, exponential backoff that won't loop forever, nip-11 fetching, and
|
||||
* tidy unsubscribing. never touches raw websockets — that's the driver's job.
|
||||
*/
|
||||
export class RelayManager {
|
||||
private readonly relays = new Map<string, ManagedRelay>();
|
||||
private readonly setTimer: (cb: () => void, ms: number) => number;
|
||||
private readonly clearTimer: (handle: number) => void;
|
||||
|
||||
constructor(private readonly deps: RelayManagerDeps) {
|
||||
this.setTimer = deps.setTimer ?? ((cb, ms) => setTimeout(cb, ms) as unknown as number);
|
||||
this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
||||
}
|
||||
|
||||
/** urls of every managed relay (normalized). */
|
||||
list(): string[] {
|
||||
return [...this.relays.keys()];
|
||||
}
|
||||
|
||||
stateOf(url: string): RelayConnectionState | undefined {
|
||||
return this.relays.get(this.key(url))?.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* connect to a relay. reuses an existing connection unless it errored or
|
||||
* disconnected. returns right after kicking off the async connect.
|
||||
*/
|
||||
async connect(rawUrl: string): Promise<void> {
|
||||
const url = this.key(rawUrl);
|
||||
const existing = this.relays.get(url);
|
||||
|
||||
if (existing) {
|
||||
// reuse: don't double up an in-flight or live connection.
|
||||
if (existing.state === 'connecting' || existing.state === 'connected') {
|
||||
return;
|
||||
}
|
||||
existing.intentionalDisconnect = false;
|
||||
await this.openConnection(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = this.deps.createConnection(url);
|
||||
const managed: ManagedRelay = {
|
||||
url,
|
||||
connection,
|
||||
state: 'disconnected',
|
||||
reconnectAttempts: 0,
|
||||
intentionalDisconnect: false,
|
||||
everConnected: false,
|
||||
unsubscribers: []
|
||||
};
|
||||
this.relays.set(url, managed);
|
||||
this.wireEvents(managed);
|
||||
await this.openConnection(managed);
|
||||
// nip-11 is best-effort — must not block or sink the connection.
|
||||
void this.loadInfo(url);
|
||||
}
|
||||
|
||||
/** disconnect on purpose and stop auto-reconnect for this relay. */
|
||||
disconnect(rawUrl: string): void {
|
||||
const url = this.key(rawUrl);
|
||||
const managed = this.relays.get(url);
|
||||
if (!managed) return;
|
||||
managed.intentionalDisconnect = true;
|
||||
this.cancelReconnect(managed);
|
||||
managed.connection.disconnect();
|
||||
this.setState(managed, 'disconnected');
|
||||
}
|
||||
|
||||
/** drop a relay entirely, cleaning up all listeners and timers. */
|
||||
remove(rawUrl: string): void {
|
||||
const url = this.key(rawUrl);
|
||||
const managed = this.relays.get(url);
|
||||
if (!managed) return;
|
||||
managed.intentionalDisconnect = true;
|
||||
this.cancelReconnect(managed);
|
||||
managed.connection.disconnect();
|
||||
for (const off of managed.unsubscribers) off();
|
||||
managed.unsubscribers = [];
|
||||
this.relays.delete(url);
|
||||
}
|
||||
|
||||
/** clean up every relay (e.g. on identity switch or teardown). */
|
||||
destroy(): void {
|
||||
for (const url of this.list()) this.remove(url);
|
||||
}
|
||||
|
||||
private async openConnection(managed: ManagedRelay): Promise<void> {
|
||||
this.setState(managed, 'connecting');
|
||||
try {
|
||||
await managed.connection.connect();
|
||||
// the driver's 'connect' event is the source of truth for 'connected';
|
||||
// if it never fires, state sits at 'connecting' until timeout/close.
|
||||
} catch (err) {
|
||||
this.setState(managed, 'error', this.friendlyError(err));
|
||||
if (!managed.connection.selfReconnecting) this.scheduleReconnect(managed);
|
||||
}
|
||||
}
|
||||
|
||||
private wireEvents(managed: ManagedRelay): void {
|
||||
const { connection } = managed;
|
||||
managed.unsubscribers.push(
|
||||
connection.on('connect', () => {
|
||||
const wasReconnecting = managed.everConnected;
|
||||
managed.reconnectAttempts = 0;
|
||||
managed.everConnected = true;
|
||||
this.cancelReconnect(managed);
|
||||
this.setState(managed, 'connected');
|
||||
// back after a previous connection → let consumers backfill.
|
||||
if (wasReconnecting) this.deps.emit({ type: 'reconnected', url: managed.url });
|
||||
}),
|
||||
connection.on('disconnect', () => {
|
||||
if (managed.intentionalDisconnect) {
|
||||
this.setState(managed, 'disconnected');
|
||||
return;
|
||||
}
|
||||
// unexpected drop: a reconnect is on the way (the driver's own loop
|
||||
// or our scheduled backoff), so show "reconnecting" instead of a bare
|
||||
// "disconnected" that looks final.
|
||||
this.setState(managed, 'reconnecting');
|
||||
if (!connection.selfReconnecting) this.scheduleReconnect(managed);
|
||||
}),
|
||||
connection.on('auth', (challenge) => {
|
||||
// nip-42: the signer policy does the crypto; we just show a notice.
|
||||
this.deps.emit({ type: 'auth', url: managed.url, challenge });
|
||||
}),
|
||||
connection.on('authed', () => {
|
||||
this.deps.emit({ type: 'authed', url: managed.url });
|
||||
}),
|
||||
connection.on('auth:failed', (error) => {
|
||||
this.deps.emit({ type: 'auth:failed', url: managed.url, error });
|
||||
}),
|
||||
connection.on('notice', (message) => {
|
||||
this.deps.emit({ type: 'notice', url: managed.url, message: message ?? '' });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** exponential backoff with jitter and a hard attempt cap so it can't loop forever. */
|
||||
private scheduleReconnect(managed: ManagedRelay): void {
|
||||
if (managed.intentionalDisconnect) return;
|
||||
if (managed.reconnectTimer !== undefined) return; // already queued
|
||||
if (managed.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
this.setState(
|
||||
managed,
|
||||
'error',
|
||||
`Giving up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = managed.reconnectAttempts;
|
||||
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
managed.reconnectAttempts += 1;
|
||||
|
||||
managed.reconnectTimer = this.setTimer(() => {
|
||||
managed.reconnectTimer = undefined;
|
||||
if (managed.intentionalDisconnect) return;
|
||||
void this.openConnection(managed);
|
||||
}, backoff);
|
||||
}
|
||||
|
||||
private cancelReconnect(managed: ManagedRelay): void {
|
||||
if (managed.reconnectTimer !== undefined) {
|
||||
this.clearTimer(managed.reconnectTimer);
|
||||
managed.reconnectTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadInfo(url: string): Promise<void> {
|
||||
try {
|
||||
const info = await this.deps.fetchInfo(url);
|
||||
this.deps.emit({ type: 'info', url, info });
|
||||
} catch {
|
||||
// nip-11 is optional; just skip. supportsNip29 stays undefined.
|
||||
}
|
||||
}
|
||||
|
||||
private setState(managed: ManagedRelay, state: RelayConnectionState, error?: string): void {
|
||||
if (managed.state === state && !error) return;
|
||||
managed.state = state;
|
||||
this.deps.emit({ type: 'state', url: managed.url, state, error });
|
||||
}
|
||||
|
||||
private key(rawUrl: string): string {
|
||||
return normalizeRelayUrl(rawUrl);
|
||||
}
|
||||
|
||||
private friendlyError(err: unknown): string {
|
||||
// never show raw websocket errors; keep the techy bits short.
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Connection failed.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { RelayManager } from './relay-manager';
|
||||
import type { RelayManagerEvent } from './relay-manager';
|
||||
import { createNdkConnection, fetchNip11 } from './ndk-relay-driver';
|
||||
import { relayListService } from './relay-list-service';
|
||||
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||
import { getNdk } from './ndk-client';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { relayRoomId } from '$lib/stores/server-store';
|
||||
import type { RelayInformation } from '$lib/types/relay';
|
||||
|
||||
/**
|
||||
* the app's relay service. owns one RelayManager wired to the ndk driver and
|
||||
* turns manager events into store updates and trusted terminal notices. the
|
||||
* command layer calls this — never ndk directly.
|
||||
*/
|
||||
class RelayService {
|
||||
private manager: RelayManager | null = null;
|
||||
/** callbacks fired when a relay reconnects after an outage (normalized url). */
|
||||
private readonly reconnectHandlers = new Set<(url: string) => void>();
|
||||
|
||||
/** subscribe to relay-reconnect events; hands back an unsub fn. */
|
||||
onReconnect(handler: (url: string) => void): () => void {
|
||||
this.reconnectHandlers.add(handler);
|
||||
return () => this.reconnectHandlers.delete(handler);
|
||||
}
|
||||
|
||||
private ensure(): RelayManager {
|
||||
if (!this.manager) {
|
||||
const ndk = getNdk();
|
||||
this.manager = new RelayManager({
|
||||
createConnection: (url) => createNdkConnection(url, ndk),
|
||||
fetchInfo: (url) => fetchNip11(url),
|
||||
emit: (event) => this.onEvent(event)
|
||||
});
|
||||
}
|
||||
return this.manager;
|
||||
}
|
||||
|
||||
async connect(url: string): Promise<void> {
|
||||
await this.ensure().connect(url);
|
||||
}
|
||||
|
||||
disconnect(url: string): void {
|
||||
this.ensure().disconnect(url);
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
return this.ensure().list();
|
||||
}
|
||||
|
||||
/**
|
||||
* nip-65 outbox discovery: grab a user's advertised relay list and connect
|
||||
* to any relays we're not on yet. returns how many were newly connected.
|
||||
* best-effort — shows progress as terminal notices.
|
||||
*/
|
||||
async discoverAndConnect(pubkey: string): Promise<number> {
|
||||
const discovered = await relayListService.fetch(pubkey);
|
||||
if (discovered.length === 0) {
|
||||
terminalStore.system('No NIP-65 relay list found for this identity.', 'system');
|
||||
return 0;
|
||||
}
|
||||
const known = new Set(this.list());
|
||||
const toAdd = discovered.filter((url) => !known.has(url));
|
||||
if (toAdd.length === 0) {
|
||||
terminalStore.system(
|
||||
`NIP-65: already connected to all ${discovered.length} advertised relay(s).`,
|
||||
'system'
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
terminalStore.system(`NIP-65: connecting to ${toAdd.length} advertised relay(s)…`, 'notice');
|
||||
for (const url of toAdd) void this.connect(url);
|
||||
return toAdd.length;
|
||||
}
|
||||
|
||||
private onEvent(event: RelayManagerEvent): void {
|
||||
// all relay-lifecycle output goes to that relay's server window
|
||||
// (irc-style server buffer), not the global one.
|
||||
const scope = relayRoomId(event.url);
|
||||
switch (event.type) {
|
||||
case 'state': {
|
||||
relayStore.setState(event.url, event.state, event.error);
|
||||
pluginHost.emit('relayState', event.url, event.state);
|
||||
if (event.state === 'connected') {
|
||||
terminalStore.system(`Connected to ${event.url}`, 'notice', scope);
|
||||
} else if (event.state === 'error') {
|
||||
terminalStore.system(
|
||||
`Relay ${event.url}: ${event.error ?? 'connection error'}`,
|
||||
'error',
|
||||
scope
|
||||
);
|
||||
} else if (event.state === 'reconnecting') {
|
||||
terminalStore.system(
|
||||
`Connection to ${event.url} dropped — reconnecting…`,
|
||||
'warning',
|
||||
scope
|
||||
);
|
||||
} else if (event.state === 'disconnected') {
|
||||
terminalStore.system(`Disconnected from ${event.url}`, 'system', scope);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reconnected': {
|
||||
terminalStore.system(`Reconnected to ${event.url} — syncing…`, 'notice', scope);
|
||||
for (const handler of this.reconnectHandlers) handler(event.url);
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
relayStore.setInfo(event.url, event.info);
|
||||
this.renderMotd(event.url, event.info, scope);
|
||||
break;
|
||||
}
|
||||
case 'auth': {
|
||||
terminalStore.system(
|
||||
`Relay ${event.url} requested authentication (NIP-42).`,
|
||||
'notice',
|
||||
scope
|
||||
);
|
||||
// the signIn policy signs with the active signer; without one, auth
|
||||
// can't finish — tell the user why so it's not a silent flop.
|
||||
if (!getNdk().signer) {
|
||||
terminalStore.system(
|
||||
`Log in (/login) to authenticate with ${event.url}.`,
|
||||
'warning',
|
||||
scope
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'authed': {
|
||||
terminalStore.system(`Authenticated with ${event.url} (NIP-42).`, 'notice', scope);
|
||||
break;
|
||||
}
|
||||
case 'auth:failed': {
|
||||
terminalStore.system(
|
||||
`Authentication with ${event.url} failed${event.error ? `: ${event.error}` : ''}.`,
|
||||
'error',
|
||||
scope
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'notice': {
|
||||
terminalStore.system(`Relay ${event.url}: ${event.message}`, 'notice', scope);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* dump an irc-style message-of-the-day into a relay's server window from its
|
||||
* nip-11 doc: a header, the relay's motd (or description as a fallback),
|
||||
* supported nips, and the nosterm-relay capability handshake.
|
||||
*/
|
||||
private renderMotd(url: string, info: RelayInformation, scope: string): void {
|
||||
const name = info.name?.trim() || url;
|
||||
terminalStore.system(`── ${name} ──`, 'notice', scope);
|
||||
|
||||
// the motd itself: an explicit `motd`, else the nip-11 description.
|
||||
const motd = info.motd?.trim() || info.description?.trim();
|
||||
if (motd) {
|
||||
for (const line of motd.split('\n')) {
|
||||
terminalStore.system(line, 'system', scope);
|
||||
}
|
||||
}
|
||||
|
||||
if (info.software) {
|
||||
const version = info.version ? ` ${info.version}` : '';
|
||||
terminalStore.system(`software: ${info.software}${version}`, 'system', scope);
|
||||
}
|
||||
if (info.contact) {
|
||||
terminalStore.system(`contact: ${info.contact}`, 'system', scope);
|
||||
}
|
||||
|
||||
const nips = info.supportedNips;
|
||||
if (nips && nips.length > 0) {
|
||||
terminalStore.system(`supported NIPs: ${nips.join(', ')}`, 'system', scope);
|
||||
}
|
||||
if (nips && !nips.includes(29)) {
|
||||
terminalStore.system('This relay does not advertise NIP-29 group support.', 'warning', scope);
|
||||
}
|
||||
|
||||
// nosterm capability handshake: announce a home relay's features.
|
||||
if (info.software === 'nosterm-relay') {
|
||||
const features = info.features ?? [];
|
||||
terminalStore.system(
|
||||
`nosterm-relay features: ${features.length ? features.join(', ') : 'none'}`,
|
||||
'notice',
|
||||
scope
|
||||
);
|
||||
}
|
||||
terminalStore.system('── end of MOTD ──', 'notice', scope);
|
||||
}
|
||||
}
|
||||
|
||||
export const relayService = new RelayService();
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/**
|
||||
* internal room id built from BOTH the relay url and the group id. `#general`
|
||||
* on two different relays gives you two different ids.
|
||||
*/
|
||||
export function roomId(address: RoomAddress): string {
|
||||
return `${normalizeRelayUrl(address.relayUrl)}#${address.groupId}`;
|
||||
}
|
||||
|
||||
/** are these two room addresses the same room? */
|
||||
export function roomsEqual(a: RoomAddress, b: RoomAddress): boolean {
|
||||
return roomId(a) === roomId(b);
|
||||
}
|
||||
|
||||
/** strip one leading '#' and any surrounding whitespace off a group name. */
|
||||
function cleanGroupId(raw: string): string {
|
||||
const g = raw.trim().replace(/^#/, '').trim();
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* parse a user-typed room reference into a RoomAddress.
|
||||
*
|
||||
* takes:
|
||||
* - "#general" -> uses activeRelay
|
||||
* - "general" -> uses activeRelay
|
||||
* - "wss://relay.example/#general" -> explicit relay + group
|
||||
* - "wss://relay.example#general" -> explicit relay + group
|
||||
*
|
||||
* if you only give a group name, `activeRelay` is required.
|
||||
* throws with a clear message on bad input.
|
||||
*/
|
||||
export function parseRoomReference(input: string, activeRelay?: string): RoomAddress {
|
||||
const raw = input.trim();
|
||||
if (raw.length === 0) throw new Error('Room name is empty.');
|
||||
|
||||
// explicit relay form: has a ws(s) scheme.
|
||||
if (/^wss?:\/\//i.test(raw)) {
|
||||
// the group rides along as a url fragment (#general).
|
||||
const hashIndex = raw.indexOf('#');
|
||||
if (hashIndex === -1) {
|
||||
throw new Error('Relay-qualified room must include a group, e.g. wss://relay/#general.');
|
||||
}
|
||||
const relayPart = raw.slice(0, hashIndex);
|
||||
const groupPart = cleanGroupId(raw.slice(hashIndex));
|
||||
if (groupPart.length === 0) throw new Error('Group name is empty.');
|
||||
return {
|
||||
relayUrl: normalizeRelayUrl(relayPart),
|
||||
groupId: groupPart
|
||||
};
|
||||
}
|
||||
|
||||
// bare group name form: needs an active relay.
|
||||
const groupId = cleanGroupId(raw);
|
||||
if (groupId.length === 0) throw new Error('Group name is empty.');
|
||||
if (!activeRelay) {
|
||||
throw new Error('No active relay. Connect to a relay first or use wss://relay/#room.');
|
||||
}
|
||||
return {
|
||||
relayUrl: normalizeRelayUrl(activeRelay),
|
||||
groupId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { NdkChatTransport, PublishRejection } from './ndk-chat-transport';
|
||||
import type { ChatTransport } from './chat-transport';
|
||||
import { getNdk } from './ndk-client';
|
||||
import { roomId } from './room-address';
|
||||
import { NIP29_KINDS } from './nip29';
|
||||
import { relayService } from './relay-service';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
import { roomStore } from '$lib/stores/room-store';
|
||||
import { relayStore } from '$lib/stores/relay-store';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { muteStore } from '$lib/stores/mute-store';
|
||||
import { authStore } from '$lib/stores/auth-store';
|
||||
import { profileService } from './profile-service';
|
||||
import { messageRepository, readMarkerRepository } from '$lib/db/repositories';
|
||||
import { notificationService } from '$lib/notifications/notification-service';
|
||||
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||
import { isMention } from '$lib/utils/mention';
|
||||
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||
|
||||
/**
|
||||
* the app's room service. owns the chat transport and maps incoming messages
|
||||
* to terminal entries + store updates. command layer talks to this, never to
|
||||
* ndk or the transport directly.
|
||||
*/
|
||||
class RoomService {
|
||||
private transport: ChatTransport | null = null;
|
||||
/** room id -> unsub fn. */
|
||||
private readonly unsubscribers = new Map<string, () => void>();
|
||||
/** room id -> address, so we can re-sub after a reconnect. */
|
||||
private readonly joinedRooms = new Map<string, RoomAddress>();
|
||||
/**
|
||||
* event ids we've already shown this session. stops dupes when a reconnect
|
||||
* re-subs a room and the relay replays old events.
|
||||
*/
|
||||
private readonly renderedEventIds = new Set<string>();
|
||||
/**
|
||||
* event ids the relay rejected. lets us take back an optimistic echo: ndk
|
||||
* might echo our own event before OR after the rejection lands, so we both
|
||||
* yank an already-shown entry and suppress one that hasn't shown up yet.
|
||||
*/
|
||||
private readonly rejectedEventIds = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
// on reconnect, re-sub every room on that relay so it replays whatever we
|
||||
// missed during the outage (renderedEventIds dedupes).
|
||||
relayService.onReconnect((url) => this.resubscribeRelay(url));
|
||||
}
|
||||
|
||||
private ensure(): ChatTransport {
|
||||
if (!this.transport) {
|
||||
this.transport = new NdkChatTransport(getNdk());
|
||||
}
|
||||
return this.transport;
|
||||
}
|
||||
|
||||
/** join a nip-29 room: warn if unsupported, subscribe, then ask to join. */
|
||||
async join(room: RoomAddress): Promise<void> {
|
||||
const id = roomId(room);
|
||||
if (this.unsubscribers.has(id)) {
|
||||
roomStore.setActive(id);
|
||||
terminalStore.system(`Already joined #${room.groupId}.`, 'notice');
|
||||
return;
|
||||
}
|
||||
|
||||
// warn (but don't block) if the relay doesn't advertise nip-29.
|
||||
const relay = get(relayStore)[room.relayUrl];
|
||||
if (relay?.supportsNip29 === false) {
|
||||
terminalStore.system(
|
||||
`Relay ${room.relayUrl} does not advertise NIP-29; the room may not work.`,
|
||||
'warning'
|
||||
);
|
||||
} else if (!relay || relay.connection !== 'connected') {
|
||||
terminalStore.system(`Not connected to ${room.relayUrl}. Connecting…`, 'notice');
|
||||
await this.ensure().connectRelay(room.relayUrl);
|
||||
}
|
||||
|
||||
roomStore.add(room, room.groupId);
|
||||
this.joinedRooms.set(id, room);
|
||||
this.subscribeRoom(room);
|
||||
|
||||
try {
|
||||
await this.ensure().joinRoom(room);
|
||||
} catch (err) {
|
||||
// reading still works even if the join request flops.
|
||||
terminalStore.system(
|
||||
`Join request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
terminalStore.system(`Joined #${room.groupId}`, 'notice', id);
|
||||
}
|
||||
|
||||
/** leave a room: unsub, send leave request, drop it from the store. */
|
||||
async leave(room: RoomAddress): Promise<void> {
|
||||
const id = roomId(room);
|
||||
const unsub = this.unsubscribers.get(id);
|
||||
if (!unsub) {
|
||||
terminalStore.system(`Not joined to #${room.groupId}.`, 'notice');
|
||||
return;
|
||||
}
|
||||
unsub();
|
||||
this.unsubscribers.delete(id);
|
||||
this.joinedRooms.delete(id);
|
||||
try {
|
||||
await this.ensure().leaveRoom(room);
|
||||
} catch {
|
||||
/* leave request is best-effort, whatever */
|
||||
}
|
||||
roomStore.remove(id);
|
||||
terminalStore.system(`Left #${room.groupId}`, 'system');
|
||||
}
|
||||
|
||||
/** open message + roster subs for a room (used by join and reconnect). */
|
||||
private subscribeRoom(room: RoomAddress): void {
|
||||
const id = roomId(room);
|
||||
// ditch any existing subs first (reconnect re-subs cleanly).
|
||||
this.unsubscribers.get(id)?.();
|
||||
|
||||
const transport = this.ensure();
|
||||
const unsubMsg = transport.subscribeToRoom(room, (message) => this.onMessage(message));
|
||||
// track the relay's metadata + admins/members lists so the ui can show
|
||||
// the topic/participants and gate moderation. relay has the final say.
|
||||
const unsubRoster = transport.subscribeToRoster(room, (roster) => {
|
||||
if (roster.kind === 'metadata') {
|
||||
roomStore.patch(id, {
|
||||
...(roster.name ? { name: roster.name } : {}),
|
||||
topic: roster.about ?? undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
roomStore.patch(
|
||||
id,
|
||||
roster.kind === 'admins' ? { admins: roster.pubkeys } : { members: roster.pubkeys }
|
||||
);
|
||||
// resolve names for listed folks so the member list shows nicks not
|
||||
// hashes (best-effort; updates reactively via profileStore).
|
||||
for (const pubkey of roster.pubkeys) void profileService.resolve(pubkey);
|
||||
});
|
||||
this.unsubscribers.set(id, () => {
|
||||
unsubMsg();
|
||||
unsubRoster();
|
||||
});
|
||||
}
|
||||
|
||||
/** re-sub every joined room on a relay that just came back. */
|
||||
private resubscribeRelay(relayUrl: string): void {
|
||||
for (const room of this.joinedRooms.values()) {
|
||||
if (room.relayUrl === relayUrl) this.subscribeRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* send a chat (or action) message. ndk echoes the event locally right away;
|
||||
* if the relay then rejects the publish we take that echo back (or suppress
|
||||
* it if it hasn't landed yet) so a rejected message never sits there looking
|
||||
* sent.
|
||||
*/
|
||||
async send(room: RoomAddress, content: string, action = false): Promise<void> {
|
||||
try {
|
||||
await this.ensure().sendRoomMessage(room, content, action);
|
||||
} catch (err) {
|
||||
if (err instanceof PublishRejection) {
|
||||
// yank it if already echoed; else mark it so onMessage drops it.
|
||||
if (!terminalStore.removeByEventId(err.eventId)) {
|
||||
this.rejectedEventIds.add(err.eventId);
|
||||
// don't let the set grow forever; a late echo shows up in seconds.
|
||||
setTimeout(() => this.rejectedEventIds.delete(err.eventId), 30_000);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** is this pubkey a known room admin (from the relay's 39001 list)? */
|
||||
isAdmin(id: string, pubkey: string): boolean {
|
||||
return get(roomStore).rooms[id]?.admins?.includes(pubkey) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* invite (add) or kick (remove) someone via nip-29 moderation. the relay
|
||||
* does the admin-only enforcing and rejects non-admins; we just surface it.
|
||||
*/
|
||||
async moderate(room: RoomAddress, action: 'add' | 'remove', targetPubkey: string): Promise<void> {
|
||||
await this.ensure().moderateUser(room, action, targetPubkey);
|
||||
}
|
||||
|
||||
/** set the room topic (nip-29 metadata `about`). admin-only at the relay. */
|
||||
async setTopic(room: RoomAddress, topic: string): Promise<void> {
|
||||
await this.ensure().setMetadata(room, { about: topic });
|
||||
}
|
||||
|
||||
private onMessage(message: ChatMessage): void {
|
||||
const id = roomId(message.room);
|
||||
|
||||
// drop an optimistic echo of an event the relay already rejected
|
||||
// (rejection beat the local echo). don't cache or render it.
|
||||
if (this.rejectedEventIds.delete(message.id)) return;
|
||||
|
||||
// session dedup: a reconnect re-subs and the relay replays stored
|
||||
// events; show each one only once.
|
||||
if (this.renderedEventIds.has(message.id)) return;
|
||||
this.renderedEventIds.add(message.id);
|
||||
|
||||
// cache every message (best-effort) so history survives reloads.
|
||||
// cached events stay untrusted: the flag just records verify-at-cache.
|
||||
void messageRepository.save(message);
|
||||
|
||||
// locally muted authors don't show in the terminal (relay doesn't care).
|
||||
if (get(muteStore).has(message.authorPubkey)) return;
|
||||
|
||||
// let plugins peek at the message (after mute filtering).
|
||||
pluginHost.emit('message', message);
|
||||
|
||||
const active = get(roomStore).activeRoomId;
|
||||
// bump unread when the message is for some other room.
|
||||
if (id !== active) {
|
||||
const room = get(roomStore).rooms[id];
|
||||
if (room) roomStore.patch(id, { unread: room.unread + 1 });
|
||||
} else {
|
||||
// move the read marker for the active room.
|
||||
void readMarkerRepository.set(id, message.createdAt);
|
||||
}
|
||||
|
||||
// grab a display name if we have one; else a shortened pubkey.
|
||||
const displayName =
|
||||
profileService.cachedName(message.authorPubkey) ?? shortenPubkey(message.authorPubkey);
|
||||
|
||||
// flag mentions of the logged-in user (never self-mentions).
|
||||
const me = get(authStore).pubkey;
|
||||
const mention =
|
||||
!!me &&
|
||||
message.authorPubkey !== me &&
|
||||
isMention(message.content, me, profileService.cachedName(me));
|
||||
|
||||
terminalStore.push({
|
||||
type: message.action ? 'action' : 'message',
|
||||
author: message.authorPubkey,
|
||||
authorDisplayName: displayName,
|
||||
content: message.content,
|
||||
roomId: id,
|
||||
eventId: message.id,
|
||||
mention,
|
||||
timestamp: message.createdAt ? message.createdAt * 1000 : Date.now()
|
||||
});
|
||||
|
||||
// desktop ping on a mention while the tab is in the background.
|
||||
if (mention) {
|
||||
const room = get(roomStore).rooms[id];
|
||||
notificationService.notifyMention(displayName, message.content, room?.name ?? id);
|
||||
}
|
||||
|
||||
// resolve the profile in the background; if a name shows up, patch this
|
||||
// (and any earlier) entry so the hash gets swapped in place.
|
||||
void profileService.resolve(message.authorPubkey).then(() => {
|
||||
const resolved = profileService.cachedName(message.authorPubkey);
|
||||
if (resolved) terminalStore.setAuthorName(message.authorPubkey, resolved);
|
||||
});
|
||||
}
|
||||
|
||||
/** rip down all subs (e.g. on logout/identity switch). */
|
||||
destroy(): void {
|
||||
for (const unsub of this.unsubscribers.values()) unsub();
|
||||
this.unsubscribers.clear();
|
||||
this.joinedRooms.clear();
|
||||
this.renderedEventIds.clear();
|
||||
if (this.transport && 'destroy' in this.transport) {
|
||||
(this.transport as NdkChatTransport).destroy();
|
||||
}
|
||||
this.transport = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const roomService = new RoomService();
|
||||
export { NIP29_KINDS };
|
||||
@@ -0,0 +1,170 @@
|
||||
import { NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import type { NDKSigner } from '@nostr-dev-kit/ndk';
|
||||
import { getNdk } from './ndk-client';
|
||||
import type { SignerKind } from '$lib/types/nostr';
|
||||
|
||||
export interface ActiveSigner {
|
||||
kind: Exclude<SignerKind, 'none'>;
|
||||
pubkey: string;
|
||||
signer: NDKSigner;
|
||||
/** true when the raw private key lives in this app's memory (nsec/ephemeral). */
|
||||
ephemeral: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* handles identity/signing only — never relays or messages. on login it hooks
|
||||
* the signer onto the shared ndk instance so publishing and nip-42 auth can
|
||||
* use it; on logout it unhooks.
|
||||
*
|
||||
* heads up (security): on the nsec and ephemeral paths the private key sits in
|
||||
* browser memory. this service never logs it, never ships it anywhere, and
|
||||
* only hands it out through the explicit `exportNsec`/`exportNcryptsec` methods
|
||||
* the ui uses to show or (optionally, encrypted) save it when the user asks.
|
||||
*/
|
||||
export class SignerService {
|
||||
private active: ActiveSigner | null = null;
|
||||
/** kept only for in-memory key signers, to support export/persist. */
|
||||
private privateKeySigner: NDKPrivateKeySigner | null = null;
|
||||
|
||||
/** true if a nip-07 browser extension (window.nostr) is around. */
|
||||
static hasNip07(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||
}
|
||||
|
||||
get current(): ActiveSigner | null {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* log in with a nip-07 browser extension. the extension keeps the private
|
||||
* key — we only ever get the public key and signatures.
|
||||
*/
|
||||
async loginNip07(): Promise<ActiveSigner> {
|
||||
if (!SignerService.hasNip07()) {
|
||||
throw new Error(
|
||||
'No NIP-07 extension found. Install a Nostr signer extension (e.g. Alby, nos2x).'
|
||||
);
|
||||
}
|
||||
const ndk = getNdk();
|
||||
const signer = new NDKNip07Signer(3000, ndk);
|
||||
let user;
|
||||
try {
|
||||
user = await signer.blockUntilReady();
|
||||
} catch {
|
||||
throw new Error('NIP-07 login was rejected or timed out.');
|
||||
}
|
||||
this.attach({ kind: 'nip07', pubkey: user.pubkey, signer, ephemeral: false });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/**
|
||||
* log in with a remote signer (nip-46 "bunker"). the key stays on the remote
|
||||
* signer; we talk to it via a connection token (bunker://…) or a nip-05.
|
||||
*/
|
||||
async loginBunker(connectionToken: string): Promise<ActiveSigner> {
|
||||
const token = connectionToken.trim();
|
||||
if (!token) throw new Error('Enter a bunker:// connection string or a NIP-05 identifier.');
|
||||
|
||||
const ndk = getNdk();
|
||||
let signer: NDKNip46Signer;
|
||||
try {
|
||||
signer = NDKNip46Signer.bunker(ndk, token);
|
||||
} catch {
|
||||
throw new Error('Invalid bunker connection string.');
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await signer.blockUntilReady();
|
||||
} catch {
|
||||
throw new Error('Remote signer did not approve the connection (rejected or timed out).');
|
||||
}
|
||||
this.attach({ kind: 'bunker', pubkey: user.pubkey, signer, ephemeral: false });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/**
|
||||
* log in by importing an nsec (or hex) private key. the key stays ONLY in
|
||||
* memory unless the caller saves it separately via exportNcryptsec.
|
||||
* less safe than extension/bunker — the ui should warn about it.
|
||||
*/
|
||||
loginNsec(nsecOrHex: string): ActiveSigner {
|
||||
const value = nsecOrHex.trim();
|
||||
if (!value) throw new Error('Enter an nsec (or hex) private key.');
|
||||
let signer: NDKPrivateKeySigner;
|
||||
try {
|
||||
// ndk takes nsec bech32 or hex straight up.
|
||||
signer = new NDKPrivateKeySigner(value, getNdk());
|
||||
} catch {
|
||||
throw new Error('That does not look like a valid nsec or hex private key.');
|
||||
}
|
||||
this.privateKeySigner = signer;
|
||||
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/** bring back an nsec identity from a nip-49 encrypted key (ncryptsec) + passphrase. */
|
||||
loginNcryptsec(ncryptsec: string, passphrase: string): ActiveSigner {
|
||||
let signer: NDKPrivateKeySigner;
|
||||
try {
|
||||
signer = NDKPrivateKeySigner.fromNcryptsec(ncryptsec, passphrase, getNdk());
|
||||
} catch {
|
||||
throw new Error('Could not decrypt the stored key — wrong passphrase?');
|
||||
}
|
||||
this.privateKeySigner = signer;
|
||||
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/**
|
||||
* make a brand-new keypair and log in with it. the key lives in memory for
|
||||
* the session; the ui shows the nsec once so the user can stash it.
|
||||
*/
|
||||
generateAndLogin(): ActiveSigner {
|
||||
const signer = NDKPrivateKeySigner.generate();
|
||||
this.privateKeySigner = signer;
|
||||
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/**
|
||||
* spin up a throwaway in-memory identity for dev/testing.
|
||||
* NOT for anything you care about — gone on logout/reload.
|
||||
*/
|
||||
loginEphemeral(): ActiveSigner {
|
||||
const signer = NDKPrivateKeySigner.generate();
|
||||
this.privateKeySigner = signer;
|
||||
this.attach({ kind: 'ephemeral', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||
return this.active as ActiveSigner;
|
||||
}
|
||||
|
||||
/** the active in-memory key as an nsec, if any (for one-time display). */
|
||||
exportNsec(): string | null {
|
||||
return this.privateKeySigner?.nsec ?? null;
|
||||
}
|
||||
|
||||
/** the active in-memory key as an npub, if any. */
|
||||
exportNpub(): string | null {
|
||||
return this.privateKeySigner?.npub ?? null;
|
||||
}
|
||||
|
||||
/** encrypt the active in-memory key with a passphrase (nip-49 ncryptsec). */
|
||||
exportNcryptsec(passphrase: string): string {
|
||||
if (!this.privateKeySigner) throw new Error('No in-memory key to encrypt.');
|
||||
if (passphrase.length < 8) throw new Error('Use a passphrase of at least 8 characters.');
|
||||
return this.privateKeySigner.encryptToNcryptsec(passphrase);
|
||||
}
|
||||
|
||||
/** unhook the signer from ndk and wipe local identity state. */
|
||||
logout(): void {
|
||||
const ndk = getNdk();
|
||||
ndk.signer = undefined;
|
||||
this.active = null;
|
||||
this.privateKeySigner = null;
|
||||
}
|
||||
|
||||
private attach(active: ActiveSigner): void {
|
||||
getNdk().signer = active.signer;
|
||||
this.active = active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { settingsStore } from '$lib/stores/settings-store';
|
||||
import { BRAND } from '$lib/brand';
|
||||
|
||||
/**
|
||||
* desktop notifications (web notifications api) for dms and mentions. fires only
|
||||
* when the tab is backgrounded, notifications are on in settings, and the
|
||||
* browser has granted permission. content is shown truncated; nothing gets sent
|
||||
* anywhere — it's a local os notification, nothing more.
|
||||
*/
|
||||
class NotificationService {
|
||||
private supported =
|
||||
typeof window !== 'undefined' && 'Notification' in window && 'hidden' in document;
|
||||
|
||||
/** can notifications fire right now (enabled + permitted)? */
|
||||
private get active(): boolean {
|
||||
return (
|
||||
this.supported && get(settingsStore).notifications && Notification.permission === 'granted'
|
||||
);
|
||||
}
|
||||
|
||||
/** does this environment support notifications at all? */
|
||||
isSupported(): boolean {
|
||||
return this.supported;
|
||||
}
|
||||
|
||||
/** current browser permission state ('default' | 'granted' | 'denied'). */
|
||||
permission(): NotificationPermission {
|
||||
return this.supported ? Notification.permission : 'denied';
|
||||
}
|
||||
|
||||
/**
|
||||
* ask for os permission (must come from a user gesture, e.g. toggling the
|
||||
* setting). returns the resulting permission state.
|
||||
*/
|
||||
async requestPermission(): Promise<NotificationPermission> {
|
||||
if (!this.supported) return 'denied';
|
||||
try {
|
||||
return await Notification.requestPermission();
|
||||
} catch {
|
||||
return this.permission();
|
||||
}
|
||||
}
|
||||
|
||||
notifyMention(from: string, content: string, room: string): void {
|
||||
this.show(`${from} mentioned you in #${room}`, content);
|
||||
}
|
||||
|
||||
notifyDm(from: string, content: string): void {
|
||||
this.show(`DM from ${from}`, content);
|
||||
}
|
||||
|
||||
/** show a notification, but only when the tab is hidden and we're allowed to. */
|
||||
private show(title: string, body: string): void {
|
||||
if (!this.active || !document.hidden) return;
|
||||
try {
|
||||
const n = new Notification(`${BRAND.name} — ${title}`, {
|
||||
body: body.length > 140 ? `${body.slice(0, 140)}…` : body,
|
||||
// coalesce rapid notifications from the same source.
|
||||
tag: title
|
||||
});
|
||||
// focus the app when the user clicks the notification.
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
n.close();
|
||||
};
|
||||
} catch {
|
||||
/* notification construction can throw in some browsers; ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { pluginHost } from './plugin-host';
|
||||
import type { NostermPlugin } from './plugin-types';
|
||||
|
||||
/**
|
||||
* auto-find and register every plugin in `src/plugins/`. each plugin module
|
||||
* should export a NostermPlugin as its default export or as a named `plugin`
|
||||
* export. dropping a new file into that folder is all it takes to add one
|
||||
* (vite's import.meta.glob picks it up at build time).
|
||||
*
|
||||
* loaded eagerly so plugins can register commands/events before first render.
|
||||
*/
|
||||
export async function loadPlugins(): Promise<void> {
|
||||
// load the user's disabled-plugin set FIRST so disabled plugins get skipped
|
||||
// during registration instead of loaded and immediately torn down.
|
||||
await pluginHost.loadDisabled().catch((err) => {
|
||||
console.error('[plugin] could not load disabled set (all plugins enabled):', err);
|
||||
});
|
||||
|
||||
// vite statically analyzes this glob; the path is relative to THIS file.
|
||||
const modules = import.meta.glob('../../plugins/*.{ts,js}', { eager: true });
|
||||
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
const candidate =
|
||||
(mod as { default?: NostermPlugin; plugin?: NostermPlugin }).default ??
|
||||
(mod as { plugin?: NostermPlugin }).plugin;
|
||||
if (!candidate) {
|
||||
console.warn(`[plugin] ${path} exports no default/plugin — skipped.`);
|
||||
continue;
|
||||
}
|
||||
await pluginHost.register(candidate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import type { TerminalEntryType } from '$lib/types/terminal';
|
||||
import type { CommandRegistry } from '$lib/commands/command-registry';
|
||||
import { terminalStore } from '$lib/stores/terminal-store';
|
||||
import { addCustomTheme } from '$lib/stores/theme-store';
|
||||
import { pluginStateRepository } from '$lib/db/repositories';
|
||||
import type { NostermPlugin, PluginContext, PluginEventName, PluginEvents } from './plugin-types';
|
||||
|
||||
interface LoadedPlugin {
|
||||
plugin: NostermPlugin;
|
||||
unsubscribers: (() => void)[];
|
||||
/** names of commands this plugin registered, so we can yank them on disable. */
|
||||
commandNames: string[];
|
||||
}
|
||||
|
||||
/** pluginStateRepository namespace + key for the saved disabled-plugin set. */
|
||||
const HOST_NS = '__host__';
|
||||
const DISABLED_KEY = 'disabled';
|
||||
|
||||
/**
|
||||
* owns the plugin lifecycle: registration, the event bus plugins subscribe to,
|
||||
* and the PluginContext each plugin's setup() gets. one host per app; the
|
||||
* command registry gets injected once the dispatcher exists.
|
||||
*/
|
||||
class PluginHost {
|
||||
private registry: CommandRegistry | null = null;
|
||||
private readonly loaded = new Map<string, LoadedPlugin>();
|
||||
/**
|
||||
* every plugin we've seen this session, on or off, keyed by id. lets
|
||||
* `/plugins enable` re-run setup for one that was disabled at boot.
|
||||
*/
|
||||
private readonly known = new Map<string, NostermPlugin>();
|
||||
/** ids the user turned off; saved so the choice sticks across reloads. */
|
||||
private disabled = new Set<string>();
|
||||
// event name -> set of handlers.
|
||||
private readonly listeners: { [E in PluginEventName]: Set<PluginEvents[E]> } = {
|
||||
message: new Set(),
|
||||
roomChanged: new Set(),
|
||||
relayState: new Set()
|
||||
};
|
||||
// outgoing-message transforms, applied in registration order on the send path.
|
||||
private readonly outgoingTransforms = new Set<(content: string) => string>();
|
||||
|
||||
/** wire up the command registry (called by the dispatcher factory). */
|
||||
useRegistry(registry: CommandRegistry): void {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
/** list loaded plugins (for `/plugins`). */
|
||||
list(): NostermPlugin[] {
|
||||
return [...this.loaded.values()].map((l) => l.plugin);
|
||||
}
|
||||
|
||||
/** fire an app event at every subscribed handler (never throws). */
|
||||
emit<E extends PluginEventName>(event: E, ...args: Parameters<PluginEvents[E]>): void {
|
||||
for (const handler of this.listeners[event]) {
|
||||
try {
|
||||
(handler as (...a: unknown[]) => void)(...args);
|
||||
} catch (err) {
|
||||
console.error(`[plugin] ${event} handler failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* run every outgoing-message transform in order. never throws: a busted
|
||||
* transform gets logged and skipped so a plugin can't block sending.
|
||||
*/
|
||||
applyOutgoing(content: string): string {
|
||||
let result = content;
|
||||
for (const transform of this.outgoingTransforms) {
|
||||
try {
|
||||
const next = transform(result);
|
||||
if (typeof next === 'string') result = next;
|
||||
} catch (err) {
|
||||
console.error('[plugin] outgoing transform failed:', err);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* tear down a loaded plugin: drop its commands, run its unsubscribers +
|
||||
* optional teardown(). it stays in `known` so it can be re-enabled.
|
||||
*/
|
||||
unregister(id: string): void {
|
||||
const entry = this.loaded.get(id);
|
||||
if (!entry) return;
|
||||
// pull any commands the plugin registered so they stop resolving.
|
||||
if (this.registry) {
|
||||
for (const name of entry.commandNames) this.registry.unregister(name);
|
||||
}
|
||||
for (const off of entry.unsubscribers) {
|
||||
try {
|
||||
off();
|
||||
} catch (err) {
|
||||
console.error(`[plugin] "${id}" unsubscribe failed:`, err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
entry.plugin.teardown?.();
|
||||
} catch (err) {
|
||||
console.error(`[plugin] "${id}" teardown failed:`, err);
|
||||
}
|
||||
this.loaded.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* register and set up one plugin. safe: failures are boxed off + logged.
|
||||
* a disabled plugin is remembered (so it can be turned on later) but its
|
||||
* setup() is skipped.
|
||||
*/
|
||||
async register(plugin: NostermPlugin): Promise<void> {
|
||||
if (!plugin?.id || typeof plugin.setup !== 'function') {
|
||||
console.error('[plugin] ignored: missing id or setup()', plugin);
|
||||
return;
|
||||
}
|
||||
this.known.set(plugin.id, plugin);
|
||||
if (this.disabled.has(plugin.id)) {
|
||||
// known but turned off on purpose; don't run setup.
|
||||
return;
|
||||
}
|
||||
if (this.loaded.has(plugin.id)) {
|
||||
console.warn(`[plugin] "${plugin.id}" already registered; skipping duplicate.`);
|
||||
return;
|
||||
}
|
||||
const entry: LoadedPlugin = { plugin, unsubscribers: [], commandNames: [] };
|
||||
this.loaded.set(plugin.id, entry);
|
||||
try {
|
||||
await plugin.setup(this.makeContext(plugin.id, entry));
|
||||
} catch (err) {
|
||||
console.error(`[plugin] "${plugin.id}" setup failed:`, err);
|
||||
this.loaded.delete(plugin.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* load the saved disabled-plugin set. call once before loadPlugins() so
|
||||
* disabled plugins get skipped at boot instead of loaded then torn down.
|
||||
*/
|
||||
async loadDisabled(): Promise<void> {
|
||||
const stored = await pluginStateRepository.get<string[]>(HOST_NS, DISABLED_KEY);
|
||||
if (Array.isArray(stored)) this.disabled = new Set(stored);
|
||||
}
|
||||
|
||||
private persistDisabled(): void {
|
||||
void pluginStateRepository
|
||||
.set(HOST_NS, DISABLED_KEY, [...this.disabled])
|
||||
.catch((err) => console.error('[plugin] failed to persist disabled set:', err));
|
||||
}
|
||||
|
||||
/** is a known plugin currently loaded (on and set up)? */
|
||||
isEnabled(id: string): boolean {
|
||||
return this.loaded.has(id);
|
||||
}
|
||||
|
||||
/** all known plugins with their current on/off state (for `/plugins`). */
|
||||
status(): { plugin: NostermPlugin; enabled: boolean }[] {
|
||||
return [...this.known.values()].map((plugin) => ({
|
||||
plugin,
|
||||
enabled: this.loaded.has(plugin.id)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* disable a loaded plugin: tear it down and remember the choice. returns
|
||||
* false if the id isn't a known plugin. no-op if it's already off.
|
||||
*/
|
||||
async disable(id: string): Promise<boolean> {
|
||||
if (!this.known.has(id)) return false;
|
||||
this.disabled.add(id);
|
||||
this.persistDisabled();
|
||||
if (this.loaded.has(id)) this.unregister(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* enable a previously-off plugin: clear the flag and re-run its setup.
|
||||
* returns false if the id isn't a known plugin.
|
||||
*/
|
||||
async enable(id: string): Promise<boolean> {
|
||||
const plugin = this.known.get(id);
|
||||
if (!plugin) return false;
|
||||
const wasDisabled = this.disabled.delete(id);
|
||||
if (wasDisabled) this.persistDisabled();
|
||||
if (!this.loaded.has(id)) await this.register(plugin);
|
||||
return true;
|
||||
}
|
||||
|
||||
private makeContext(pluginId: string, entry: LoadedPlugin): PluginContext {
|
||||
return {
|
||||
pluginId,
|
||||
registerCommand: (command: ChatCommand) => {
|
||||
if (!this.registry) {
|
||||
console.error(
|
||||
`[plugin] "${pluginId}" registered a command before the registry was ready.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.registry.register(command);
|
||||
// remember it so disabling the plugin can pull it back out.
|
||||
entry.commandNames.push(command.name);
|
||||
} catch (err) {
|
||||
console.error(`[plugin] "${pluginId}" command "${command.name}" rejected:`, err);
|
||||
}
|
||||
},
|
||||
print: (content: string, type: TerminalEntryType = 'system') => {
|
||||
// plugin output is local/trusted, tagged so you know where it came from.
|
||||
terminalStore.push({ type, content: `[${pluginId}] ${content}` });
|
||||
},
|
||||
on: <E extends PluginEventName>(event: E, handler: PluginEvents[E]) => {
|
||||
this.listeners[event].add(handler);
|
||||
const off = () => this.listeners[event].delete(handler);
|
||||
entry.unsubscribers.push(off);
|
||||
return off;
|
||||
},
|
||||
transformOutgoing: (transform: (content: string) => string) => {
|
||||
this.outgoingTransforms.add(transform);
|
||||
const off = () => this.outgoingTransforms.delete(transform);
|
||||
entry.unsubscribers.push(off);
|
||||
return off;
|
||||
},
|
||||
registerTheme: (theme) => {
|
||||
void addCustomTheme(theme).catch((err) =>
|
||||
console.error(`[plugin] "${pluginId}" theme rejected:`, err)
|
||||
);
|
||||
},
|
||||
getState: <T>(key: string) => pluginStateRepository.get<T>(pluginId, key),
|
||||
setState: <T>(key: string, value: T) => pluginStateRepository.set(pluginId, key, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginHost = new PluginHost();
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ChatCommand } from '$lib/types/command';
|
||||
import type { ChatMessage } from '$lib/types/chat';
|
||||
import type { TerminalEntryType } from '$lib/types/terminal';
|
||||
import type { ThemeInput } from '$lib/themes/theme-types';
|
||||
|
||||
/**
|
||||
* events a plugin can subscribe to. kept small and stable on purpose — this is
|
||||
* what plugins lean on. handlers run synchronously in registration order; a
|
||||
* throwing handler gets caught and logged, never takes the app down.
|
||||
*/
|
||||
export interface PluginEvents {
|
||||
/** a chat message came in (after mute filtering, around render time). */
|
||||
message: (message: ChatMessage) => void;
|
||||
/** the active room changed (room id, or undefined when none). */
|
||||
roomChanged: (roomId: string | undefined) => void;
|
||||
/** a relay connection state changed. */
|
||||
relayState: (url: string, state: string) => void;
|
||||
}
|
||||
|
||||
export type PluginEventName = keyof PluginEvents;
|
||||
|
||||
/**
|
||||
* the api a plugin's `setup()` gets. everything a plugin needs to be useful —
|
||||
* add commands, print to the terminal, react to events, register themes, read
|
||||
* settings — without poking at app internals.
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/** register an irc-style command (same shape as built-ins). */
|
||||
registerCommand(command: ChatCommand): void;
|
||||
/** print a line to the terminal (trusted, local — never remote content). */
|
||||
print(content: string, type?: TerminalEntryType): void;
|
||||
/** subscribe to an app event; hands back an unsub fn. */
|
||||
on<E extends PluginEventName>(event: E, handler: PluginEvents[E]): () => void;
|
||||
/**
|
||||
* register a transform run on outgoing message text right before it's sent
|
||||
* (room messages and dms). transforms run in registration order, each fed
|
||||
* the previous result. return the (maybe rewritten) text. keep it pure and
|
||||
* fast — it's on the send path. hands back an unsub fn.
|
||||
*/
|
||||
transformOutgoing(transform: (content: string) => string): () => void;
|
||||
/** register a custom theme (saved + selectable). */
|
||||
registerTheme(theme: ThemeInput): void;
|
||||
/** read a saved, plugin-scoped value (namespaced by plugin id). */
|
||||
getState<T>(key: string): Promise<T | undefined>;
|
||||
/** save a plugin-scoped value. */
|
||||
setState<T>(key: string, value: T): Promise<void>;
|
||||
/** the plugin's own id, for logging/namespacing. */
|
||||
readonly pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* a nosterm plugin. drop a module exporting one of these (as default or a named
|
||||
* `plugin` export) into `src/plugins/` and it's auto-loaded at startup.
|
||||
*/
|
||||
export interface NostermPlugin {
|
||||
/** unique, stable id (kebab-case). */
|
||||
id: string;
|
||||
/** human-readable name. */
|
||||
name: string;
|
||||
/** one-line description shown by `/plugins`. */
|
||||
description?: string;
|
||||
/** called once at startup. wire up commands/events/themes here. */
|
||||
setup(ctx: PluginContext): void | Promise<void>;
|
||||
/** optional cleanup (e.g. on disable). */
|
||||
teardown?(): void;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_RELAYS } from './config';
|
||||
|
||||
/**
|
||||
* runtime client config fetched from `/config.json`.
|
||||
*
|
||||
* the container entrypoint rewrites this file from env vars at startup, so one
|
||||
* prebuilt image can ship with different relays per environment — no rebuild.
|
||||
* the build-time `PUBLIC_DEFAULT_RELAYS` stays as a fallback for local dev and
|
||||
* for images run without the runtime var set.
|
||||
*/
|
||||
interface RuntimeConfig {
|
||||
relays: string[];
|
||||
}
|
||||
|
||||
// tidy a raw relay list into trimmed, non-empty strings.
|
||||
function normalizeRelays(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((r) => String(r).trim()).filter((r) => r.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* work out the default relays to use. runtime `/config.json` wins when it lists
|
||||
* any; otherwise fall back to the build-time defaults. a missing or busted
|
||||
* config is harmless — we just use the fallback.
|
||||
*/
|
||||
export async function loadDefaultRelays(fetchFn: typeof fetch = fetch): Promise<string[]> {
|
||||
try {
|
||||
// no-store so runtime config changes never come back stale.
|
||||
const res = await fetchFn('/config.json', { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const cfg = (await res.json()) as Partial<RuntimeConfig>;
|
||||
const relays = normalizeRelays(cfg.relays);
|
||||
if (relays.length > 0) return relays;
|
||||
}
|
||||
} catch {
|
||||
// missing/broken config.json: fall back to build-time defaults.
|
||||
}
|
||||
return DEFAULT_RELAYS;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { AuthState } from '$lib/types/nostr';
|
||||
|
||||
const INITIAL: AuthState = { kind: 'none', ephemeral: false };
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>(INITIAL);
|
||||
|
||||
function setNip07(pubkey: string) {
|
||||
set({ kind: 'nip07', pubkey, ephemeral: false });
|
||||
}
|
||||
|
||||
function setBunker(pubkey: string) {
|
||||
// remote signer holds the key; not in this app's memory.
|
||||
set({ kind: 'bunker', pubkey, ephemeral: false });
|
||||
}
|
||||
|
||||
function setNsec(pubkey: string) {
|
||||
// private key sits in this app's memory.
|
||||
set({ kind: 'nsec', pubkey, ephemeral: true });
|
||||
}
|
||||
|
||||
function setEphemeral(pubkey: string) {
|
||||
set({ kind: 'ephemeral', pubkey, ephemeral: true });
|
||||
}
|
||||
|
||||
function logout() {
|
||||
set(INITIAL);
|
||||
}
|
||||
|
||||
return { subscribe, setNip07, setBunker, setNsec, setEphemeral, logout, update };
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
|
||||
/** a dm conversation with one counterparty (their hex pubkey). */
|
||||
export interface DmConversation {
|
||||
/** counterparty pubkey (hex). */
|
||||
pubkey: string;
|
||||
/** terminal roomId used to scope this conversation's entries. */
|
||||
roomId: string;
|
||||
/** unread incoming messages since this conversation was last viewed. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/** terminal roomId marker for a dm conversation with a given pubkey. */
|
||||
export function dmRoomId(pubkey: string): string {
|
||||
return `dm:${pubkey}`;
|
||||
}
|
||||
|
||||
interface DmStoreState {
|
||||
/** pubkey -> conversation. */
|
||||
conversations: Record<string, DmConversation>;
|
||||
/** pubkey of the active dm conversation, if a dm view is focused. */
|
||||
activePubkey?: string;
|
||||
}
|
||||
|
||||
function createDmStore() {
|
||||
const { subscribe, update } = writable<DmStoreState>({ conversations: {} });
|
||||
|
||||
/** make sure a conversation exists for a counterparty; returns its roomId. */
|
||||
function ensure(pubkey: string): string {
|
||||
update((s) => {
|
||||
if (s.conversations[pubkey]) return s;
|
||||
return {
|
||||
...s,
|
||||
conversations: {
|
||||
...s.conversations,
|
||||
[pubkey]: { pubkey, roomId: dmRoomId(pubkey), unread: 0 }
|
||||
}
|
||||
};
|
||||
});
|
||||
return dmRoomId(pubkey);
|
||||
}
|
||||
|
||||
/** focus a dm conversation (making it if needed), clearing its unread. */
|
||||
function open(pubkey: string): void {
|
||||
ensure(pubkey);
|
||||
update((s) => {
|
||||
const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 };
|
||||
return {
|
||||
conversations: { ...s.conversations, [pubkey]: { ...convo, unread: 0 } },
|
||||
activePubkey: pubkey
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** leave the dm view (hand focus back to rooms). */
|
||||
function close(): void {
|
||||
update((s) => ({ ...s, activePubkey: undefined }));
|
||||
}
|
||||
|
||||
/** log an incoming dm; bumps unread unless that conversation is active. */
|
||||
function noteIncoming(pubkey: string): void {
|
||||
ensure(pubkey);
|
||||
update((s) => {
|
||||
const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 };
|
||||
const unread = s.activePubkey === pubkey ? 0 : convo.unread + 1;
|
||||
return { ...s, conversations: { ...s.conversations, [pubkey]: { ...convo, unread } } };
|
||||
});
|
||||
}
|
||||
|
||||
return { subscribe, ensure, open, close, noteIncoming, snapshot: () => get({ subscribe }) };
|
||||
}
|
||||
|
||||
export const dmStore = createDmStore();
|
||||
|
||||
/** the active dm conversation, if any. */
|
||||
export const activeDm = derived(dmStore, ($s) =>
|
||||
$s.activePubkey ? $s.conversations[$s.activePubkey] : undefined
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* set of muted author pubkeys (hex). messages from these pubkeys are hidden from
|
||||
* the terminal. muting is a local, client-side filter — it doesn't touch the
|
||||
* relay or other users.
|
||||
*/
|
||||
function createMuteStore() {
|
||||
const { subscribe, update, set } = writable<Set<string>>(new Set());
|
||||
|
||||
function add(pubkey: string) {
|
||||
update((s) => new Set(s).add(pubkey));
|
||||
}
|
||||
|
||||
function remove(pubkey: string) {
|
||||
update((s) => {
|
||||
const next = new Set(s);
|
||||
next.delete(pubkey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setAll(pubkeys: string[]) {
|
||||
set(new Set(pubkeys));
|
||||
}
|
||||
|
||||
return { subscribe, add, remove, setAll };
|
||||
}
|
||||
|
||||
export const muteStore = createMuteStore();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* reactive map of pubkey (hex) -> resolved display name. mirrors the
|
||||
* ProfileService cache so ui (identity panel, participant lists) updates live
|
||||
* when a name resolves or the user changes their own nick.
|
||||
*/
|
||||
function createProfileStore() {
|
||||
const { subscribe, update } = writable<Record<string, string>>({});
|
||||
|
||||
function setName(pubkey: string, name: string) {
|
||||
update((m) => (m[pubkey] === name ? m : { ...m, [pubkey]: name }));
|
||||
}
|
||||
|
||||
return { subscribe, setName };
|
||||
}
|
||||
|
||||
export const profileStore = createProfileStore();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { RelayState, RelayConnectionState, RelayInformation } from '$lib/types/relay';
|
||||
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/** map of normalized relay url -> RelayState. */
|
||||
function createRelayStore() {
|
||||
const { subscribe, update } = writable<Record<string, RelayState>>({});
|
||||
|
||||
function upsert(url: string, partial: Partial<RelayState>) {
|
||||
const key = normalizeRelayUrl(url);
|
||||
update((relays) => {
|
||||
const existing = relays[key];
|
||||
const next: RelayState = {
|
||||
url: key,
|
||||
connection: partial.connection ?? existing?.connection ?? 'disconnected',
|
||||
info: partial.info ?? existing?.info,
|
||||
supportsNip29: partial.supportsNip29 ?? existing?.supportsNip29,
|
||||
lastError: partial.lastError ?? existing?.lastError,
|
||||
connectedAt: partial.connectedAt ?? existing?.connectedAt,
|
||||
reconnectAttempts: partial.reconnectAttempts ?? existing?.reconnectAttempts ?? 0
|
||||
};
|
||||
return { ...relays, [key]: next };
|
||||
});
|
||||
}
|
||||
|
||||
function setState(url: string, connection: RelayConnectionState, lastError?: string) {
|
||||
upsert(url, { connection, lastError });
|
||||
}
|
||||
|
||||
function setInfo(url: string, info: RelayInformation) {
|
||||
upsert(url, { info, supportsNip29: (info.supportedNips ?? []).includes(29) });
|
||||
}
|
||||
|
||||
function remove(url: string) {
|
||||
const key = normalizeRelayUrl(url);
|
||||
update((relays) => {
|
||||
const next = { ...relays };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return { subscribe, upsert, setState, setInfo, remove };
|
||||
}
|
||||
|
||||
export const relayStore = createRelayStore();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { RoomState } from '$lib/types/room';
|
||||
import type { RoomAddress } from '$lib/types/room';
|
||||
import { roomId } from '$lib/nostr/room-address';
|
||||
|
||||
interface RoomStoreState {
|
||||
/** canonical room id -> RoomState. */
|
||||
rooms: Record<string, RoomState>;
|
||||
/** canonical id of the currently active room, if any. */
|
||||
activeRoomId?: string;
|
||||
}
|
||||
|
||||
function createRoomStore() {
|
||||
const { subscribe, update } = writable<RoomStoreState>({ rooms: {} });
|
||||
|
||||
function add(address: RoomAddress, name?: string): RoomState {
|
||||
const id = roomId(address);
|
||||
const room: RoomState = {
|
||||
id,
|
||||
address,
|
||||
name: name ?? address.groupId,
|
||||
joined: true,
|
||||
joinedAt: Date.now(),
|
||||
unread: 0
|
||||
};
|
||||
update((s) => ({ rooms: { ...s.rooms, [id]: room }, activeRoomId: id }));
|
||||
return room;
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
update((s) => {
|
||||
const rooms = { ...s.rooms };
|
||||
delete rooms[id];
|
||||
const activeRoomId = s.activeRoomId === id ? Object.keys(rooms)[0] : s.activeRoomId;
|
||||
return { rooms, activeRoomId };
|
||||
});
|
||||
}
|
||||
|
||||
function setActive(id: string) {
|
||||
update((s) => {
|
||||
const room = s.rooms[id];
|
||||
if (!room) return s;
|
||||
// opening a room clears its unread count.
|
||||
const rooms = room.unread > 0 ? { ...s.rooms, [id]: { ...room, unread: 0 } } : s.rooms;
|
||||
return { ...s, rooms, activeRoomId: id };
|
||||
});
|
||||
}
|
||||
|
||||
function patch(id: string, partial: Partial<RoomState>) {
|
||||
update((s) => {
|
||||
const existing = s.rooms[id];
|
||||
if (!existing) return s;
|
||||
return { ...s, rooms: { ...s.rooms, [id]: { ...existing, ...partial } } };
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return get({ subscribe });
|
||||
}
|
||||
|
||||
return { subscribe, add, remove, setActive, patch, snapshot };
|
||||
}
|
||||
|
||||
export const roomStore = createRoomStore();
|
||||
|
||||
/** derived: the currently active room, if any. */
|
||||
export const activeRoom = derived(roomStore, ($s) =>
|
||||
$s.activeRoomId ? $s.rooms[$s.activeRoomId] : undefined
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||
|
||||
/**
|
||||
* a relay "server window" — the irc-style server buffer that holds a relay's
|
||||
* connection lifecycle, motd, and nip-11 handshake output. unlike rooms and
|
||||
* dms, servers aren't a persisted or subscribed resource: they derive purely
|
||||
* from the relay set (`relayStore`), so this store only tracks which server
|
||||
* window is focused. terminal lines are scoped to a server via `relayRoomId`.
|
||||
*/
|
||||
|
||||
/** terminal roomId marker for a relay's server window. */
|
||||
export function relayRoomId(url: string): string {
|
||||
return `relay:${normalizeRelayUrl(url)}`;
|
||||
}
|
||||
|
||||
interface ServerStoreState {
|
||||
/** normalized url of the focused server window, if a server view is active. */
|
||||
activeUrl?: string;
|
||||
}
|
||||
|
||||
function createServerStore() {
|
||||
const { subscribe, update } = writable<ServerStoreState>({});
|
||||
|
||||
/** focus a relay's server window. */
|
||||
function open(url: string): void {
|
||||
update(() => ({ activeUrl: normalizeRelayUrl(url) }));
|
||||
}
|
||||
|
||||
/** leave the server view (hand focus back to rooms). */
|
||||
function close(): void {
|
||||
update(() => ({}));
|
||||
}
|
||||
|
||||
return { subscribe, open, close, snapshot: () => get({ subscribe }) };
|
||||
}
|
||||
|
||||
export const serverStore = createServerStore();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user