Initial commit: Nosterm client (terminal-style Nostr chat SPA)
ci / build (push) Successful in 1m3s
ci / image (push) Successful in 41s

This commit is contained in:
Robert Goodall
2026-07-24 13:47:53 -04:00
commit cd2b900639
147 changed files with 16504 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.svelte-kit
build
coverage
test-results
playwright-report
.env
.env.*
!.env.example
.DS_Store
+36
View File
@@ -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
+11
View File
@@ -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)
+110
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
engine-strict=false
+7
View File
@@ -0,0 +1,7 @@
.svelte-kit
build
coverage
test-results
playwright-report
node_modules
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+47
View File
@@ -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.
+75
View File
@@ -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
View File
@@ -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"]
+110
View File
@@ -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`.
+44
View File
@@ -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">&gt;_</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

+33
View File
@@ -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
+36
View File
@@ -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 "$@"
+40
View File
@@ -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/'
]
}
];
+7156
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+101
View File
@@ -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;
}
+19
View File
@@ -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 {};
+31
View File
@@ -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>
+447
View File
@@ -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>
+128
View File
@@ -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);">
&gt;
</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>
+343
View File
@@ -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 &lt;url&gt;</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}
+73
View File
@@ -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>
+144
View File
@@ -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 &lt;text&gt;)</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);">&lt;</span><span
style="color: {entry.author
? usernameColor(entry.author, theme)
: 'var(--color-text-primary)'};">{authorLabel(entry)}</span
><span style="color: var(--color-text-muted);">&gt;</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>
+177
View File
@@ -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>
+8
View File
@@ -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;
+84
View File
@@ -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 };
}
+34
View File
@@ -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 };
}
+65
View File
@@ -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}".`);
}
}
}
+47
View File
@@ -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: [] };
}
};
+27
View File
@@ -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}`)] };
}
};
+23
View File
@@ -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}.`)] };
}
};
+53
View File
@@ -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.')]
};
}
}
};
+46
View File
@@ -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.')
]
};
}
};
}
+35
View File
@@ -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: [] };
}
};
+130
View File
@@ -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.')]
};
}
}
};
+17
View File
@@ -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.')] };
}
};
+32
View File
@@ -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))]
};
}
}
};
+89
View File
@@ -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');
+30
View File
@@ -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.')]
};
}
}
};
+57
View File
@@ -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)}.`)] };
}
};
+35
View File
@@ -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.')]
};
}
}
};
+42
View File
@@ -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))]
};
}
}
};
+66
View File
@@ -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>.')
]
};
}
};
+38
View File
@@ -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] };
}
};
+23
View File
@@ -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] };
}
};
+128
View File
@@ -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 (832)
* /settings lineheight <n> set line height (1.02.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 832.')] };
}
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.02.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;
}
+39
View File
@@ -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}`)
]
};
}
};
+57
View File
@@ -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}.`)] };
}
};
+147
View File
@@ -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.`)]
};
}
};
+47
View File
@@ -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.')]
};
}
}
};
+78
View File
@@ -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)}`))
]
};
}
};
+55
View File
@@ -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 };
}
};
+69
View File
@@ -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;
}
+65
View File
@@ -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}`);
}
+24
View File
@@ -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)
};
+44
View File
@@ -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;
}
+40
View File
@@ -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;
+169
View File
@@ -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;
}
+181
View File
@@ -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);
}
};
+13
View File
@@ -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);
}
};
+99
View File
@@ -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();
+57
View File
@@ -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>;
}
+149
View File
@@ -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();
+80
View File
@@ -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}`;
}
+237
View File
@@ -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;
}
+28
View File
@@ -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;
}
+79
View File
@@ -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
};
}
+57
View File
@@ -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];
}
+162
View File
@@ -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();
+31
View File
@@ -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 ?? [];
}
+32
View File
@@ -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();
+264
View File
@@ -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.';
}
}
+196
View File
@@ -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();
+65
View File
@@ -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
};
}
+276
View File
@@ -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 };
+170
View File
@@ -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();
+32
View File
@@ -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);
}
}
+235
View File
@@ -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();
+66
View File
@@ -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;
}
+39
View File
@@ -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;
}
+34
View File
@@ -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();
+78
View File
@@ -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
);
+30
View File
@@ -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();
+18
View File
@@ -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();
+46
View File
@@ -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();
+69
View File
@@ -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
);
+38
View File
@@ -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