Initial commit: Nosterm client (terminal-style Nostr chat SPA)
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Nosterm client configuration. Copy to `.env` and edit:
|
||||||
|
#
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# Nothing here is secret — every value is either browser-visible (PUBLIC_*, baked
|
||||||
|
# into the client bundle at build time) or plain deployment wiring. Do NOT put
|
||||||
|
# private keys or credentials in this file.
|
||||||
|
|
||||||
|
# --- Runtime (docker-compose, no rebuild needed) ---------------------------
|
||||||
|
|
||||||
|
# Relay websocket url(s) the client preloads, comma-separated. Written to
|
||||||
|
# /config.json by the container entrypoint on startup. For a same-origin home
|
||||||
|
# relay proxied by Caddy use "ws://localhost:8080/relay". Leave blank to start
|
||||||
|
# with no default relays.
|
||||||
|
NOSTERM_DEFAULT_RELAYS=
|
||||||
|
|
||||||
|
# Upstream address for Caddy's optional /relay proxy. Only used when the client
|
||||||
|
# reaches a relay at ws://<host>/relay. Harmless if unused.
|
||||||
|
RELAY_UPSTREAM=relay:3334
|
||||||
|
|
||||||
|
# --- Build-time (PUBLIC_*, baked into the client bundle) -------------------
|
||||||
|
|
||||||
|
# Fallback relay list used only when no runtime /config.json relays are set.
|
||||||
|
PUBLIC_DEFAULT_RELAYS=
|
||||||
|
|
||||||
|
# Default UI theme id.
|
||||||
|
PUBLIC_DEFAULT_THEME=nord
|
||||||
|
|
||||||
|
# Max lines kept in the terminal scrollback.
|
||||||
|
PUBLIC_MAX_TERMINAL_ENTRIES=1000
|
||||||
|
|
||||||
|
# Max characters allowed in a single message.
|
||||||
|
PUBLIC_MAX_MESSAGE_LENGTH=2000
|
||||||
|
|
||||||
|
# Max messages cached per room in IndexedDB (older ones get pruned).
|
||||||
|
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=500
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!-- Describe what this PR changes and why. -->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `npm run lint` passes
|
||||||
|
- [ ] `npm run check` passes
|
||||||
|
- [ ] `npm test` passes
|
||||||
|
- [ ] `npm run build` succeeds
|
||||||
|
- [ ] No secrets committed (PUBLIC_* args are browser-visible by design)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# CI/CD for the public Nosterm client.
|
||||||
|
#
|
||||||
|
# - On every push/PR: install, lint, type-check, test, and build the SPA.
|
||||||
|
# - On push to main / a v* tag: additionally build the container image and
|
||||||
|
# push it to ECR Public (anonymous pulls) so anyone can `docker pull` it.
|
||||||
|
#
|
||||||
|
# This is the PUBLIC contribution repo — it builds and ships ONLY the client
|
||||||
|
# image to the public registry. It deliberately does not touch private ECR or
|
||||||
|
# redeploy any environment; promoting an image to a live site is handled out of
|
||||||
|
# band by the private ops pipeline.
|
||||||
|
#
|
||||||
|
# Targets Gitea 1.21 Actions on a self-hosted (EC2) runner. AWS auth uses the
|
||||||
|
# runner's ambient IAM role (no stored credentials): `aws ecr-public
|
||||||
|
# get-login-password` mints a short-lived token for `docker login`. The runner
|
||||||
|
# role needs ECR Public push permissions (ecr-public:GetAuthorizationToken,
|
||||||
|
# sts:GetServiceBearerToken, BatchCheckLayerAvailability, Put*/Upload*/Complete*).
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
env:
|
||||||
|
# ECR Public is always authenticated via the us-east-1 endpoint, regardless
|
||||||
|
# of where images are served from.
|
||||||
|
AWS_REGION: us-east-1
|
||||||
|
# public mirror for anonymous `docker pull`. k3k1z1x5 is the account's ECR
|
||||||
|
# Public registry alias; the repo name (nosterm) is provisioned out of band.
|
||||||
|
ECR_PUBLIC_REGISTRY: public.ecr.aws/k3k1z1x5
|
||||||
|
ECR_PUBLIC_REPO: nosterm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint (prettier + eslint)
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Type-check (svelte-check)
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Unit tests (vitest)
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build (static SPA)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
image:
|
||||||
|
needs: build
|
||||||
|
# Publish only from the default branch and version tags, never from PRs.
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install AWS CLI
|
||||||
|
# runner image ships without aws; install v2 if it's missing.
|
||||||
|
run: |
|
||||||
|
if ! command -v aws >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq curl unzip
|
||||||
|
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
|
||||||
|
unzip -q /tmp/awscliv2.zip -d /tmp
|
||||||
|
/tmp/aws/install
|
||||||
|
fi
|
||||||
|
aws --version
|
||||||
|
|
||||||
|
- name: Log in to Amazon ECR Public
|
||||||
|
# ECR Public auth is a separate endpoint (always us-east-1) and its own
|
||||||
|
# login user (AWS). This is push auth; anonymous pulls need no login.
|
||||||
|
run: |
|
||||||
|
aws ecr-public get-login-password --region us-east-1 \
|
||||||
|
| docker login --username AWS --password-stdin public.ecr.aws
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eo pipefail
|
||||||
|
IMAGE="$ECR_PUBLIC_REGISTRY/$ECR_PUBLIC_REPO"
|
||||||
|
# tag by commit sha for traceability.
|
||||||
|
docker build -t "$IMAGE:${{ gitea.sha }}" .
|
||||||
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
|
# move :latest only on main.
|
||||||
|
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
|
||||||
|
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
fi
|
||||||
|
# on a v* tag, also push that version tag.
|
||||||
|
case "${{ gitea.ref }}" in
|
||||||
|
refs/tags/v*)
|
||||||
|
VERSION="${{ gitea.ref_name }}"
|
||||||
|
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:$VERSION"
|
||||||
|
docker push "$IMAGE:$VERSION"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# build output / framework caches
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# test + coverage artifacts
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# local env (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Contributing to Nosterm
|
||||||
|
|
||||||
|
Thanks for helping improve the Nosterm client. This repo is the public home for
|
||||||
|
client development — the browser SPA and its container image. There is no
|
||||||
|
backend here; Nostr is a client-side protocol.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Fork the repo (or branch, if you have push access).
|
||||||
|
2. Create a topic branch off `main`.
|
||||||
|
3. Make your change, keeping commits focused.
|
||||||
|
4. Open a pull request against `main`.
|
||||||
|
|
||||||
|
Every push and pull request runs CI: install, lint, type-check, unit tests, and
|
||||||
|
a full static build. Get these green before requesting review:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run lint # prettier --check + eslint
|
||||||
|
npm run check # svelte-check (type-check)
|
||||||
|
npm test # vitest
|
||||||
|
npm run build # static build → ./build
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run format` auto-fixes most lint issues.
|
||||||
|
|
||||||
|
## What happens on merge
|
||||||
|
|
||||||
|
Merging to `main` builds the container image and pushes it to ECR Public
|
||||||
|
(`public.ecr.aws/k3k1z1x5/nosterm:latest`), so anyone can pull the latest client
|
||||||
|
anonymously. Tagged releases (`v*`) publish a matching versioned image. This
|
||||||
|
repo does not deploy to any live environment — promotion is handled separately.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Match the surrounding code style; the linter is the source of truth.
|
||||||
|
- Keep pull requests scoped to one change; smaller is easier to review.
|
||||||
|
- Add or update tests when you change behavior.
|
||||||
|
- Never commit secrets. All `PUBLIC_*` build args are browser-visible by design.
|
||||||
|
- Relays are configured at runtime, not baked in — don't hard-code a relay as a
|
||||||
|
dependency.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
Open an issue with steps to reproduce, what you expected, and what happened.
|
||||||
|
For security-sensitive reports, please disclose privately rather than in a
|
||||||
|
public issue.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Caddy config for serving the standalone Nosterm client SPA.
|
||||||
|
#
|
||||||
|
# In production, replace `:80` with your domain to get automatic HTTPS, e.g.:
|
||||||
|
# nosterm.example.com { ... }
|
||||||
|
#
|
||||||
|
# The `/relay*` proxy is OPTIONAL. It lets the client reach a relay on the same
|
||||||
|
# origin (ws://<host>/relay), which sidesteps mixed-content and CORS concerns.
|
||||||
|
# The upstream is configurable via the RELAY_UPSTREAM env var (default
|
||||||
|
# `relay:3334`, the service name in this dir's docker-compose.yml). If you point
|
||||||
|
# the client directly at an external relay via PUBLIC_DEFAULT_RELAYS, you can
|
||||||
|
# delete the `handle /relay*` block.
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
# Tor-overlay relay bridge: proxy to a clearnet→onion bridge sidecar that
|
||||||
|
# forwards through Tor to an onion-only relay. Exposing it at a same-origin
|
||||||
|
# wss:// path lets a plain browser reach the onion relay without Tor Browser
|
||||||
|
# and without an HTTPS-page/insecure-ws mixed-content block. Only wired when
|
||||||
|
# RELAY_TOR_UPSTREAM is set (the demo deploy sets it); otherwise it points at
|
||||||
|
# a dead default and simply 502s, which is harmless if unused. MUST precede
|
||||||
|
# the /relay* block below, which would otherwise swallow this prefix.
|
||||||
|
handle /relay-tor* {
|
||||||
|
reverse_proxy {$RELAY_TOR_UPSTREAM:tor-bridge:3335}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Home relay: proxy WebSocket + NIP-11 requests to the configured relay.
|
||||||
|
handle /relay* {
|
||||||
|
reverse_proxy {$RELAY_UPSTREAM:relay:3334}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runtime client config: regenerated by the entrypoint from env on startup.
|
||||||
|
# Never cache it so per-deployment relay changes take effect immediately.
|
||||||
|
handle /config.json {
|
||||||
|
root * /srv
|
||||||
|
header Cache-Control "no-store"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else: serve the static SPA with client-side-routing fallback.
|
||||||
|
handle {
|
||||||
|
root * /srv
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baseline security headers. No third-party analytics are ever included.
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Referrer-Policy no-referrer
|
||||||
|
|
||||||
|
# Content-Security-Policy for a client-only SPA:
|
||||||
|
# - default-src 'self' only same-origin by default
|
||||||
|
# - connect-src wss:/ws: reach ANY relay the user configures (core)
|
||||||
|
# - connect-src https: NIP-05 verification fetches a domain's
|
||||||
|
# /.well-known/nostr.json over HTTPS. Read-only
|
||||||
|
# JSON GETs; no credentials are ever sent.
|
||||||
|
# - script-src 'unsafe-inline' required for SvelteKit's hydration
|
||||||
|
# bootstrap + the anti-FOUC theme script,
|
||||||
|
# which are inlined into the static fallback
|
||||||
|
# page (not prerendered, so hashes cannot be
|
||||||
|
# pinned at build time).
|
||||||
|
# - script-src 'unsafe-eval' required by NDK's event-emitter dependency
|
||||||
|
# (tseep), which JIT-compiles listener
|
||||||
|
# dispatch via `new Function`. Only triggers
|
||||||
|
# once a relay subscription opens. No REMOTE
|
||||||
|
# scripts are ever allowed, and remote Nostr
|
||||||
|
# content is never rendered as HTML, so it
|
||||||
|
# cannot inject code.
|
||||||
|
# - object-src 'none' no plugins
|
||||||
|
Content-Security-Policy "default-src 'self'; connect-src 'self' wss: ws: https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Standalone Nosterm client image.
|
||||||
|
#
|
||||||
|
# The full SvelteKit source lives alongside this Dockerfile, so the build has no
|
||||||
|
# dependency on anything outside this directory. Build context is THIS
|
||||||
|
# directory:
|
||||||
|
#
|
||||||
|
# docker build -t nosterm:latest .
|
||||||
|
#
|
||||||
|
# The output is a static SPA served by Caddy. All PUBLIC_ build args are baked
|
||||||
|
# into the client bundle (they are browser-visible, so never put secrets here).
|
||||||
|
#
|
||||||
|
# Relays are configured at RUNTIME, not build time: the entrypoint regenerates
|
||||||
|
# /config.json from NOSTERM_DEFAULT_RELAYS on startup, so one prebuilt image can
|
||||||
|
# be deployed with different relays per environment. PUBLIC_DEFAULT_RELAYS
|
||||||
|
# remains a build-time fallback for images run without the runtime var.
|
||||||
|
|
||||||
|
# --- Build stage -----------------------------------------------------------
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies against a clean lockfile for reproducible builds.
|
||||||
|
# Copied separately so the dependency layer caches independently of source.
|
||||||
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Build the static SPA.
|
||||||
|
COPY . ./
|
||||||
|
ARG PUBLIC_DEFAULT_RELAYS=""
|
||||||
|
ARG PUBLIC_DEFAULT_THEME="nord"
|
||||||
|
ARG PUBLIC_MAX_TERMINAL_ENTRIES="1000"
|
||||||
|
ARG PUBLIC_MAX_MESSAGE_LENGTH="2000"
|
||||||
|
ARG PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM="500"
|
||||||
|
ENV PUBLIC_DEFAULT_RELAYS=$PUBLIC_DEFAULT_RELAYS \
|
||||||
|
PUBLIC_DEFAULT_THEME=$PUBLIC_DEFAULT_THEME \
|
||||||
|
PUBLIC_MAX_TERMINAL_ENTRIES=$PUBLIC_MAX_TERMINAL_ENTRIES \
|
||||||
|
PUBLIC_MAX_MESSAGE_LENGTH=$PUBLIC_MAX_MESSAGE_LENGTH \
|
||||||
|
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=$PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime stage (Caddy serves the static build) -------------------------
|
||||||
|
FROM caddy:2-alpine AS runtime
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY --from=build /app/build /srv
|
||||||
|
|
||||||
|
# Entrypoint regenerates /srv/config.json from NOSTERM_DEFAULT_RELAYS at start.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# Liveness check against the served shell.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:80/ || exit 1
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Nosterm
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/nosterm-banner.svg" alt="Nosterm — a terminal-style Nostr chat application" width="640" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**A terminal-style Nostr chat client.**
|
||||||
|
|
||||||
|
The Nosterm web client: a terminal-style Nostr chat SPA built with
|
||||||
|
[SvelteKit](https://kit.svelte.dev/) and served as static files by
|
||||||
|
[Caddy](https://caddyserver.com/). Nostr is a client-side protocol, so there is
|
||||||
|
no application backend here — the browser talks directly to relays over
|
||||||
|
WebSocket. Run a relay from the companion
|
||||||
|
[nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay) repository if
|
||||||
|
you want your own home relay.
|
||||||
|
|
||||||
|
This is the **public, contribution-focused** repository for the Nosterm client.
|
||||||
|
Open a pull request against `main` — see [CONTRIBUTING.md](CONTRIBUTING.md). Each
|
||||||
|
merge to `main` builds the container image and publishes it to ECR Public for
|
||||||
|
anonymous pulls (see [Deploy](#deploy)).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
nosterm-client/
|
||||||
|
src/ # SvelteKit source (components, stores, nostr, commands…)
|
||||||
|
static/ # static assets
|
||||||
|
Dockerfile # build the SPA → serve with Caddy
|
||||||
|
Caddyfile # static file server + security headers + optional /relay proxy
|
||||||
|
docker-compose.yml # one-command deploy on port 8080
|
||||||
|
.env.example # copy to .env and configure
|
||||||
|
package.json # dev/build/test scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # vite dev server
|
||||||
|
npm run check # svelte-check (type-check)
|
||||||
|
npm run lint # prettier --check + eslint
|
||||||
|
npm test # vitest
|
||||||
|
npm run test:e2e # playwright
|
||||||
|
npm run build # static build → ./build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then set NOSTERM_DEFAULT_RELAYS at minimum
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The client is then served at <http://localhost:8080>.
|
||||||
|
|
||||||
|
To run the prebuilt public image with your own relays — no rebuild, no AWS
|
||||||
|
account (anonymous pull from ECR Public):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:80 \
|
||||||
|
-e NOSTERM_DEFAULT_RELAYS=wss://relay.example.com \
|
||||||
|
public.ecr.aws/k3k1z1x5/nosterm:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
`NOSTERM_DEFAULT_RELAYS` accepts a comma-separated list. On startup the
|
||||||
|
container's entrypoint writes it to `/config.json`, which the SPA fetches on
|
||||||
|
load — so one shared image serves every deployment with different relays.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Relays are configured at **runtime** (`NOSTERM_DEFAULT_RELAYS`), so a single
|
||||||
|
prebuilt image works for any deployment. The remaining `PUBLIC_*` tunables are
|
||||||
|
**build-time** args baked into the browser bundle (client-visible, never
|
||||||
|
secrets) — change one → rebuild the image.
|
||||||
|
|
||||||
|
**Runtime** (set on the running container, no rebuild):
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| ------------------------ | ------------ | ------------------------------------------------------------ |
|
||||||
|
| `NOSTERM_DEFAULT_RELAYS` | _empty_ | Comma-separated relay WebSocket URLs to preload |
|
||||||
|
| `RELAY_UPSTREAM` | `relay:3334` | Upstream for Caddy's optional `/relay` reverse-proxy (below) |
|
||||||
|
|
||||||
|
**Build-time** (baked into the bundle; rebuild to change):
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| ------------------------------------- | ------- | --------------------------------------------- |
|
||||||
|
| `PUBLIC_DEFAULT_RELAYS` | _empty_ | Fallback relays when no runtime config is set |
|
||||||
|
| `PUBLIC_DEFAULT_THEME` | `nord` | Theme applied before the user picks one |
|
||||||
|
| `PUBLIC_MAX_TERMINAL_ENTRIES` | `1000` | Terminal entries kept in memory / rendered |
|
||||||
|
| `PUBLIC_MAX_MESSAGE_LENGTH` | `2000` | Max outbound message length (characters) |
|
||||||
|
| `PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM` | `500` | Messages retained per room in IndexedDB |
|
||||||
|
|
||||||
|
## Talking to a relay
|
||||||
|
|
||||||
|
The client connects directly to whatever relays are configured in
|
||||||
|
`NOSTERM_DEFAULT_RELAYS` (and any the user adds at runtime). Two common setups:
|
||||||
|
|
||||||
|
- **External relay** — set `NOSTERM_DEFAULT_RELAYS=wss://relay.example.com`.
|
||||||
|
You can delete the `handle /relay*` block from the `Caddyfile`.
|
||||||
|
- **Same-origin home relay** — run
|
||||||
|
[nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay), set
|
||||||
|
`NOSTERM_DEFAULT_RELAYS=ws://localhost:8080/relay` (or `wss://…` behind TLS),
|
||||||
|
and set `RELAY_UPSTREAM` to the relay's address so Caddy proxies `/relay` to
|
||||||
|
it. Put both services on a shared Docker network for name resolution.
|
||||||
|
|
||||||
|
## Production TLS
|
||||||
|
|
||||||
|
For a real domain, edit the `Caddyfile`: replace `:80` with your hostname (e.g.
|
||||||
|
`nosterm.example.com { … }`) and Caddy will obtain and renew a certificate
|
||||||
|
automatically. Expose ports 80 and 443 in `docker-compose.yml`.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="240" viewBox="0 0 820 240" role="img" aria-label="Nosterm — a terminal-style Nostr chat application">
|
||||||
|
<title>Nosterm — a terminal-style Nostr chat application</title>
|
||||||
|
|
||||||
|
<!-- Nord palette: bg #2e3440, surface #3b4252, frame #434c5e,
|
||||||
|
cyan #88c0d0, snow #eceff4, comment #616e88, green #a3be8c -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3b4252" />
|
||||||
|
<stop offset="1" stop-color="#2e3440" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- outer canvas -->
|
||||||
|
<rect width="820" height="240" rx="14" fill="#2e3440" />
|
||||||
|
|
||||||
|
<!-- terminal window -->
|
||||||
|
<rect x="40" y="34" width="740" height="172" rx="10" fill="url(#bg)" stroke="#434c5e" stroke-width="1.5" />
|
||||||
|
|
||||||
|
<!-- title bar -->
|
||||||
|
<rect x="40" y="34" width="740" height="34" rx="10" fill="#434c5e" />
|
||||||
|
<rect x="40" y="58" width="740" height="10" fill="#434c5e" />
|
||||||
|
<circle cx="64" cy="51" r="5.5" fill="#bf616a" />
|
||||||
|
<circle cx="84" cy="51" r="5.5" fill="#ebcb8b" />
|
||||||
|
<circle cx="104" cy="51" r="5.5" fill="#a3be8c" />
|
||||||
|
<text x="410" y="55" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13" fill="#616e88">nosterm — /home/you</text>
|
||||||
|
|
||||||
|
<!-- terminal body -->
|
||||||
|
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
|
||||||
|
<!-- wordmark: prompt + name -->
|
||||||
|
<text x="70" y="126" font-size="46" font-weight="700" fill="#88c0d0">>_</text>
|
||||||
|
<text x="152" y="126" font-size="46" font-weight="700" fill="#eceff4">nosterm</text>
|
||||||
|
|
||||||
|
<!-- tagline as a shell comment -->
|
||||||
|
<text x="72" y="162" font-size="16" fill="#616e88"># a terminal-style Nostr chat client + home relay</text>
|
||||||
|
|
||||||
|
<!-- a sample command line -->
|
||||||
|
<text x="72" y="190" font-size="16" fill="#a3be8c">$</text>
|
||||||
|
<text x="92" y="190" font-size="16" fill="#d8dee9">/join</text>
|
||||||
|
<text x="150" y="190" font-size="16" fill="#88c0d0">#general</text>
|
||||||
|
<rect x="238" y="177" width="9" height="17" fill="#88c0d0">
|
||||||
|
<animate attributeName="opacity" values="1;1;0;0" dur="1.1s" repeatCount="indefinite" />
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
# Standalone Nosterm client deployment.
|
||||||
|
#
|
||||||
|
# Serves the static SvelteKit SPA via Caddy on port 8080. This directory deploys
|
||||||
|
# ONLY the client — point it at a relay of your choice with NOSTERM_DEFAULT_RELAYS
|
||||||
|
# (runtime, see .env.example). To also run a home relay, use the nosterm-relay
|
||||||
|
# repo (or set RELAY_UPSTREAM so Caddy's /relay proxy reaches it).
|
||||||
|
#
|
||||||
|
# cp .env.example .env # then edit
|
||||||
|
# docker compose up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
nosterm:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
# Non-relay tunables are still baked in at build time. Relays are set at
|
||||||
|
# runtime via NOSTERM_DEFAULT_RELAYS below (no rebuild to change them).
|
||||||
|
PUBLIC_DEFAULT_THEME: ${PUBLIC_DEFAULT_THEME:-nord}
|
||||||
|
PUBLIC_MAX_TERMINAL_ENTRIES: ${PUBLIC_MAX_TERMINAL_ENTRIES:-1000}
|
||||||
|
PUBLIC_MAX_MESSAGE_LENGTH: ${PUBLIC_MAX_MESSAGE_LENGTH:-2000}
|
||||||
|
PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM: ${PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM:-500}
|
||||||
|
image: nosterm:latest
|
||||||
|
environment:
|
||||||
|
# Relay(s) the client preloads, written to /config.json on startup. For a
|
||||||
|
# same-origin home relay use "ws://localhost:8080/relay" (proxied by Caddy)
|
||||||
|
# and set RELAY_UPSTREAM to its address. Otherwise point at an external relay.
|
||||||
|
NOSTERM_DEFAULT_RELAYS: ${NOSTERM_DEFAULT_RELAYS:-}
|
||||||
|
# Upstream for Caddy's optional /relay proxy. Only used if the client
|
||||||
|
# reaches the relay at ws://<host>/relay. Harmless if unused.
|
||||||
|
RELAY_UPSTREAM: ${RELAY_UPSTREAM:-relay:3334}
|
||||||
|
ports:
|
||||||
|
- '8080:80'
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# regenerate runtime client config from env before caddy starts.
|
||||||
|
#
|
||||||
|
# the SPA fetches /config.json at startup, so this lets one prebuilt image be
|
||||||
|
# deployed with different relays per environment without a rebuild. set
|
||||||
|
# NOSTERM_DEFAULT_RELAYS to a comma-separated list of relay websocket urls.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG_PATH=/srv/config.json
|
||||||
|
RELAYS="${NOSTERM_DEFAULT_RELAYS:-}"
|
||||||
|
|
||||||
|
# build a json array from the comma-separated list, trimming blanks.
|
||||||
|
relays_json=""
|
||||||
|
IFS=','
|
||||||
|
for relay in $RELAYS; do
|
||||||
|
# trim surrounding whitespace.
|
||||||
|
trimmed="$(printf '%s' "$relay" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||||
|
[ -z "$trimmed" ] && continue
|
||||||
|
if [ -z "$relays_json" ]; then
|
||||||
|
relays_json="\"$trimmed\""
|
||||||
|
else
|
||||||
|
relays_json="$relays_json,\"$trimmed\""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
|
||||||
|
printf '{\n "relays": [%s]\n}\n' "$relays_json" > "$CONFIG_PATH"
|
||||||
|
|
||||||
|
if [ -n "$relays_json" ]; then
|
||||||
|
echo "[entrypoint] wrote $CONFIG_PATH with relays: $RELAYS"
|
||||||
|
else
|
||||||
|
echo "[entrypoint] no NOSTERM_DEFAULT_RELAYS set; wrote empty relay list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# hand off to the container command (caddy).
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
prettier,
|
||||||
|
...svelte.configs['flat/prettier'],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.svelte-kit/',
|
||||||
|
'build/',
|
||||||
|
'coverage/',
|
||||||
|
'test-results/',
|
||||||
|
'playwright-report/',
|
||||||
|
'node_modules/'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
Generated
+7156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "nosterm",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A terminal-style Nostr chat client.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "sirv build --single --port 4173",
|
||||||
|
"prepare": "svelte-kit sync",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.15.1",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
|
"sirv-cli": "^3.0.0",
|
||||||
|
"svelte": "^5.16.0",
|
||||||
|
"svelte-check": "^4.1.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"typescript-eslint": "^8.19.0",
|
||||||
|
"vite": "^5.4.11",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nostr-dev-kit/ndk": "^2.10.7",
|
||||||
|
"dexie": "^4.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Semantic design tokens. These defaults mirror the Nord theme so the UI is
|
||||||
|
* fully styled before JavaScript applies the persisted theme (no FOUC).
|
||||||
|
* The theme service overwrites these on :root at runtime.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--color-background: #2e3440;
|
||||||
|
--color-surface: #3b4252;
|
||||||
|
--color-surface-raised: #434c5e;
|
||||||
|
--color-border: #4c566a;
|
||||||
|
|
||||||
|
--color-text-primary: #eceff4;
|
||||||
|
--color-text-secondary: #d8dee9;
|
||||||
|
--color-text-muted: #7b88a1;
|
||||||
|
|
||||||
|
--color-accent: #88c0d0;
|
||||||
|
--color-link: #8fbcbb;
|
||||||
|
--color-focus: #88c0d0;
|
||||||
|
|
||||||
|
--color-success: #a3be8c;
|
||||||
|
--color-warning: #ebcb8b;
|
||||||
|
--color-error: #bf616a;
|
||||||
|
--color-info: #81a1c1;
|
||||||
|
|
||||||
|
--color-terminal-system: #81a1c1;
|
||||||
|
--color-terminal-notice: #88c0d0;
|
||||||
|
--color-terminal-command: #b48ead;
|
||||||
|
--color-terminal-action: #ebcb8b;
|
||||||
|
--color-terminal-timestamp: #7b88a1;
|
||||||
|
|
||||||
|
--color-relay-connected: #a3be8c;
|
||||||
|
--color-relay-connecting: #ebcb8b;
|
||||||
|
--color-relay-disconnected: #7b88a1;
|
||||||
|
--color-relay-error: #bf616a;
|
||||||
|
|
||||||
|
--font-terminal:
|
||||||
|
'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
--terminal-font-size: 14px;
|
||||||
|
--terminal-line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-terminal);
|
||||||
|
font-size: var(--terminal-font-size);
|
||||||
|
line-height: var(--terminal-line-height);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Themed scrollbars. */
|
||||||
|
* {
|
||||||
|
scrollbar-color: var(--color-border) var(--color-surface);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visible, theme-aware focus indicator on all interactive elements. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect the reduced-motion preference and the app setting (via data attr). */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-reduced-motion='true'] *,
|
||||||
|
:root[data-reduced-motion='true'] *::before,
|
||||||
|
:root[data-reduced-motion='true'] *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// see https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
import type { Nip07Signer } from '$lib/types/nostr';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nip-07 browser extension surface. optional — not every browser has one.
|
||||||
|
interface Window {
|
||||||
|
nostr?: Nip07Signer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<meta name="description" content="Nosterm — A terminal-style Nostr chat client." />
|
||||||
|
<title>Nosterm</title>
|
||||||
|
<!--
|
||||||
|
Anti-FOUC theme bootstrap: apply the persisted theme's CSS tokens to
|
||||||
|
<html> before the app renders, so there's no flash of the default theme.
|
||||||
|
This runs synchronously and only reads a small settings key from
|
||||||
|
localStorage (a fast mirror of the IndexedDB-persisted preference).
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('nosterm:active-theme');
|
||||||
|
if (raw) document.documentElement.setAttribute('data-theme', raw);
|
||||||
|
} catch (_) {
|
||||||
|
/* localStorage may be unavailable; the app applies the default later. */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { authService } from '$lib/nostr/auth-service';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { closeAuth } from '$lib/stores/ui-store';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { BRAND } from '$lib/brand';
|
||||||
|
|
||||||
|
// which sub-view of the sign-in flow is showing.
|
||||||
|
type View = 'choose' | 'bunker' | 'nsec' | 'generated' | 'unlock';
|
||||||
|
let view = $state<View>('choose');
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// field state per flow.
|
||||||
|
let bunkerToken = $state('');
|
||||||
|
let nsecInput = $state('');
|
||||||
|
let remember = $state(false);
|
||||||
|
let passphrase = $state('');
|
||||||
|
|
||||||
|
// one-time key display after generating.
|
||||||
|
let generated = $state<{ npub: string; nsec: string } | null>(null);
|
||||||
|
let copied = $state<'npub' | 'nsec' | null>(null);
|
||||||
|
|
||||||
|
// remembered (encrypted) identity, if there's one on this device.
|
||||||
|
let rememberedPubkey = $state<string | undefined>(undefined);
|
||||||
|
let unlockPassphrase = $state('');
|
||||||
|
|
||||||
|
const hasExtension = authService.hasNip07();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
rememberedPubkey = await authService.rememberedPubkey();
|
||||||
|
if (rememberedPubkey) view = 'unlock';
|
||||||
|
});
|
||||||
|
|
||||||
|
function done(pubkey: string, how: string) {
|
||||||
|
terminalStore.system(`Logged in as ${shortenPubkey(pubkey)} (${how}).`, 'notice');
|
||||||
|
// nip-65 outbox: connect the user's advertised relays. skip for throwaway
|
||||||
|
// identities — they won't have a published relay list.
|
||||||
|
if (how !== 'ephemeral') void relayService.discoverAndConnect(pubkey);
|
||||||
|
closeAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useExtension() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const pk = await authService.loginNip07();
|
||||||
|
done(pk, 'NIP-07');
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBunker() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const pk = await authService.loginBunker(bunkerToken);
|
||||||
|
done(pk, 'remote signer');
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importNsec() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const pk = authService.loginNsec(nsecInput);
|
||||||
|
if (remember) {
|
||||||
|
await authService.rememberOnDevice(passphrase);
|
||||||
|
}
|
||||||
|
nsecInput = '';
|
||||||
|
passphrase = '';
|
||||||
|
terminalStore.system(
|
||||||
|
'Your private key is held in this browser. Prefer an extension or bunker when possible.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
done(pk, remember ? 'nsec, remembered on device' : 'nsec, in-memory');
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
authService.generate();
|
||||||
|
generated = authService.exportKeyPair();
|
||||||
|
view = 'generated';
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishGenerated() {
|
||||||
|
// optionally remember the fresh key, encrypted.
|
||||||
|
if (remember && passphrase) {
|
||||||
|
try {
|
||||||
|
await authService.rememberOnDevice(passphrase);
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pk = authService.pubkey;
|
||||||
|
generated = null;
|
||||||
|
passphrase = '';
|
||||||
|
if (pk) done(pk, 'new key');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const pk = await authService.unlockRemembered(unlockPassphrase);
|
||||||
|
unlockPassphrase = '';
|
||||||
|
done(pk, 'remembered key');
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetRemembered() {
|
||||||
|
await authService.forgetRemembered();
|
||||||
|
rememberedPubkey = undefined;
|
||||||
|
view = 'choose';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(kind: 'npub' | 'nsec', value: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
copied = kind;
|
||||||
|
setTimeout(() => (copied = null), 1500);
|
||||||
|
} catch {
|
||||||
|
/* no clipboard; ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
// throwaway identity so the user can look around without committing.
|
||||||
|
const pk = authService.loginEphemeral();
|
||||||
|
terminalStore.system('Exploring with a temporary identity.', 'notice');
|
||||||
|
done(pk, 'ephemeral');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style="background: color-mix(in srgb, var(--color-background) 82%, transparent);"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="sign in to Nosterm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md overflow-hidden rounded border"
|
||||||
|
style="background: var(--color-surface); border-color: var(--color-border);"
|
||||||
|
>
|
||||||
|
<!-- header -->
|
||||||
|
<div class="border-b px-5 py-4" style="border-color: var(--color-border);">
|
||||||
|
<div class="text-lg font-bold" style="color: var(--color-accent);">{BRAND.name}</div>
|
||||||
|
<div class="text-xs" style="color: var(--color-text-muted);">{BRAND.tagline}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
{#if error}
|
||||||
|
<p class="mb-3 text-xs" style="color: var(--color-error);" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if view === 'choose'}
|
||||||
|
<p class="mb-3 text-sm" style="color: var(--color-text-secondary);">
|
||||||
|
Sign in to start chatting.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||||
|
style="background: var(--color-surface-raised); color: var(--color-text-primary); {hasExtension
|
||||||
|
? 'outline: 1px solid var(--color-accent);'
|
||||||
|
: 'opacity: 0.6;'}"
|
||||||
|
disabled={busy || !hasExtension}
|
||||||
|
onclick={useExtension}
|
||||||
|
>
|
||||||
|
🔐 Browser extension (NIP-07)
|
||||||
|
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||||
|
{hasExtension
|
||||||
|
? 'Recommended — your key stays in the extension.'
|
||||||
|
: 'No extension detected.'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||||
|
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => {
|
||||||
|
view = 'bunker';
|
||||||
|
error = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📡 Remote signer (bunker / NIP-46)
|
||||||
|
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||||
|
Your key stays on a remote signer.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||||
|
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={generate}
|
||||||
|
>
|
||||||
|
✨ Create a new key
|
||||||
|
<span class="block text-xs" style="color: var(--color-text-muted);">
|
||||||
|
Generate a fresh Nostr identity.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-3 py-2 text-left text-sm"
|
||||||
|
style="background: var(--color-surface-raised); color: var(--color-text-primary);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => {
|
||||||
|
view = 'nsec';
|
||||||
|
error = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔑 Paste an nsec
|
||||||
|
<span class="block text-xs" style="color: var(--color-warning);">
|
||||||
|
⚠ Advanced — puts your private key in the browser.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs underline"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
onclick={skip}
|
||||||
|
>
|
||||||
|
Explore with a temporary identity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if view === 'bunker'}
|
||||||
|
<label
|
||||||
|
class="mb-1 block text-sm"
|
||||||
|
style="color: var(--color-text-secondary);"
|
||||||
|
for="bunker-in"
|
||||||
|
>
|
||||||
|
Bunker connection string or NIP-05
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bunker-in"
|
||||||
|
type="text"
|
||||||
|
bind:value={bunkerToken}
|
||||||
|
placeholder="bunker://… or you@example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||||
|
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1 text-sm"
|
||||||
|
style="background: var(--color-accent); color: var(--color-background);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={connectBunker}
|
||||||
|
>
|
||||||
|
{busy ? 'Connecting…' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
onclick={() => (view = 'choose')}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if view === 'nsec'}
|
||||||
|
<p class="mb-2 text-xs" style="color: var(--color-warning);">
|
||||||
|
⚠ Pasting an nsec puts your private key in this browser tab. A browser extension or remote
|
||||||
|
signer is safer. Only continue if you understand the risk.
|
||||||
|
</p>
|
||||||
|
<label class="mb-1 block text-sm" style="color: var(--color-text-secondary);" for="nsec-in">
|
||||||
|
nsec (or hex private key)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nsec-in"
|
||||||
|
type="password"
|
||||||
|
bind:value={nsecInput}
|
||||||
|
placeholder="nsec1…"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||||
|
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="mt-3 flex items-center gap-2 text-xs"
|
||||||
|
style="color: var(--color-text-secondary);"
|
||||||
|
>
|
||||||
|
<input type="checkbox" bind:checked={remember} />
|
||||||
|
Remember on this device (encrypted with a passphrase)
|
||||||
|
</label>
|
||||||
|
{#if remember}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={passphrase}
|
||||||
|
placeholder="encryption passphrase (min 8 chars)"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mt-2 w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||||
|
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1 text-sm"
|
||||||
|
style="background: var(--color-accent); color: var(--color-background);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={importNsec}
|
||||||
|
>
|
||||||
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
onclick={() => (view = 'choose')}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if view === 'generated'}
|
||||||
|
<p class="mb-2 text-sm" style="color: var(--color-text-primary);">Your new identity</p>
|
||||||
|
<p class="mb-3 text-xs" style="color: var(--color-warning);">
|
||||||
|
⚠ Save your nsec now. It is shown once and is not stored unless you choose to remember it
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-xs" style="font-family: var(--font-terminal);">
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--color-text-muted);">npub</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 break-all text-left underline"
|
||||||
|
style="color: var(--color-link);"
|
||||||
|
onclick={() => generated && copy('npub', generated.npub)}
|
||||||
|
>
|
||||||
|
{generated?.npub}
|
||||||
|
</button>
|
||||||
|
{#if copied === 'npub'}<span style="color: var(--color-success);"> copied</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--color-text-muted);">nsec</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 break-all text-left underline"
|
||||||
|
style="color: var(--color-warning);"
|
||||||
|
onclick={() => generated && copy('nsec', generated.nsec)}
|
||||||
|
>
|
||||||
|
{generated?.nsec}
|
||||||
|
</button>
|
||||||
|
{#if copied === 'nsec'}<span style="color: var(--color-success);"> copied</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="mt-3 flex items-center gap-2 text-xs"
|
||||||
|
style="color: var(--color-text-secondary);"
|
||||||
|
>
|
||||||
|
<input type="checkbox" bind:checked={remember} />
|
||||||
|
Remember on this device (encrypted)
|
||||||
|
</label>
|
||||||
|
{#if remember}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={passphrase}
|
||||||
|
placeholder="encryption passphrase (min 8 chars)"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mt-2 w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||||
|
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1 text-sm"
|
||||||
|
style="background: var(--color-accent); color: var(--color-background);"
|
||||||
|
onclick={finishGenerated}
|
||||||
|
>
|
||||||
|
I've saved it — continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if view === 'unlock'}
|
||||||
|
<p class="mb-2 text-sm" style="color: var(--color-text-primary);">Welcome back</p>
|
||||||
|
<p class="mb-3 text-xs" style="color: var(--color-text-muted);">
|
||||||
|
Remembered identity: {rememberedPubkey ? shortenPubkey(rememberedPubkey) : ''}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={unlockPassphrase}
|
||||||
|
placeholder="passphrase"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full rounded border bg-transparent px-2 py-1 text-sm"
|
||||||
|
style="border-color: var(--color-border); color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1 text-sm"
|
||||||
|
style="background: var(--color-accent); color: var(--color-background);"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={unlock}
|
||||||
|
>
|
||||||
|
{busy ? 'Unlocking…' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs underline"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
onclick={() => (view = 'choose')}
|
||||||
|
>
|
||||||
|
Use another method
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto text-xs underline"
|
||||||
|
style="color: var(--color-error);"
|
||||||
|
onclick={forgetRemembered}
|
||||||
|
>
|
||||||
|
Forget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { MAX_COMMAND_HISTORY, MAX_MESSAGE_LENGTH } from '$lib/config';
|
||||||
|
|
||||||
|
// parent hands us the submit handler and an optional completion source.
|
||||||
|
let {
|
||||||
|
onsubmit,
|
||||||
|
complete
|
||||||
|
}: { onsubmit: (raw: string) => void; complete?: (prefix: string) => string[] } = $props();
|
||||||
|
|
||||||
|
let value = $state('');
|
||||||
|
let history = $state<string[]>([]);
|
||||||
|
// -1 means "not in history" (editing the live buffer).
|
||||||
|
let historyIndex = $state(-1);
|
||||||
|
let draft = $state('');
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
// where we are in the completion candidates on repeated tab.
|
||||||
|
let completionCycle = $state(0);
|
||||||
|
|
||||||
|
// length counter for plain messages (not commands). only shows once you
|
||||||
|
// pass 80% of the limit so it stays out of the way.
|
||||||
|
let isCommand = $derived(value.trimStart().startsWith('/'));
|
||||||
|
let showCounter = $derived(!isCommand && value.length >= Math.floor(MAX_MESSAGE_LENGTH * 0.8));
|
||||||
|
let remaining = $derived(MAX_MESSAGE_LENGTH - value.length);
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const raw = value.trim();
|
||||||
|
if (raw.length === 0) return;
|
||||||
|
// cap message length (also guards command length).
|
||||||
|
const bounded = raw.slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
|
||||||
|
history = [...history.slice(-(MAX_COMMAND_HISTORY - 1)), bounded];
|
||||||
|
historyIndex = -1;
|
||||||
|
draft = '';
|
||||||
|
value = '';
|
||||||
|
onsubmit(bounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recallHistory(direction: -1 | 1) {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
// stepping into history: stash the current draft first.
|
||||||
|
if (direction === 1) return; // nothing newer than the live draft
|
||||||
|
draft = value;
|
||||||
|
historyIndex = history.length - 1;
|
||||||
|
} else {
|
||||||
|
historyIndex += direction;
|
||||||
|
}
|
||||||
|
if (historyIndex >= history.length) {
|
||||||
|
// past the newest entry: back to the live draft.
|
||||||
|
historyIndex = -1;
|
||||||
|
value = draft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (historyIndex < 0) historyIndex = 0;
|
||||||
|
value = history[historyIndex] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabComplete() {
|
||||||
|
if (!complete) return;
|
||||||
|
// only complete the first token (the command name).
|
||||||
|
if (/\s/.test(value.trim())) return;
|
||||||
|
const candidates = complete(value.trim());
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
value = candidates[0] ?? value;
|
||||||
|
completionCycle = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// multiple matches: cycle through them on repeated tab presses.
|
||||||
|
value = candidates[completionCycle % candidates.length] ?? value;
|
||||||
|
completionCycle += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Tab') completionCycle = 0;
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
recallHistory(-1);
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
recallHistory(1);
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
tabComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex items-center gap-2 border-t px-3 py-2"
|
||||||
|
style="border-color: var(--color-border); background: var(--color-surface);"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label for="command-input" class="select-none font-bold" style="color: var(--color-accent);">
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
bind:value
|
||||||
|
id="command-input"
|
||||||
|
name="command-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
maxlength={MAX_MESSAGE_LENGTH}
|
||||||
|
placeholder="Type a message, or /help for commands"
|
||||||
|
class="flex-1 bg-transparent outline-none"
|
||||||
|
style="color: var(--color-text-primary); font-family: var(--font-terminal);"
|
||||||
|
aria-label="command input"
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
/>
|
||||||
|
{#if showCounter}
|
||||||
|
<span
|
||||||
|
class="shrink-0 select-none text-xs tabular-nums"
|
||||||
|
style="color: {remaining <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{remaining}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { dmStore } from '$lib/stores/dm-store';
|
||||||
|
import { serverStore } from '$lib/stores/server-store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { profileStore } from '$lib/stores/profile-store';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import type { RelayState } from '$lib/types/relay';
|
||||||
|
|
||||||
|
// relay server windows, pinned above rooms (irc-style server buffers). one per
|
||||||
|
// connected/known relay, ordered by url for a stable list.
|
||||||
|
let servers = $derived(Object.values($relayStore).sort((a, b) => a.url.localeCompare(b.url)));
|
||||||
|
let activeServerUrl = $derived($serverStore.activeUrl);
|
||||||
|
|
||||||
|
let rooms = $derived(Object.values($roomStore.rooms));
|
||||||
|
// a room lights up only when no server or dm view is focused.
|
||||||
|
let activeId = $derived(
|
||||||
|
$serverStore.activeUrl || $dmStore.activePubkey ? undefined : $roomStore.activeRoomId
|
||||||
|
);
|
||||||
|
|
||||||
|
let dms = $derived(Object.values($dmStore.conversations));
|
||||||
|
let activeDmPubkey = $derived($serverStore.activeUrl ? undefined : $dmStore.activePubkey);
|
||||||
|
|
||||||
|
function openServer(url: string) {
|
||||||
|
dmStore.close();
|
||||||
|
serverStore.open(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(id: string) {
|
||||||
|
serverStore.close();
|
||||||
|
dmStore.close();
|
||||||
|
roomStore.setActive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDm(pubkey: string) {
|
||||||
|
serverStore.close();
|
||||||
|
dmStore.open(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dmLabel(pubkey: string): string {
|
||||||
|
return $profileStore[pubkey] ?? shortenPubkey(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a little connection glyph for a relay server row.
|
||||||
|
function serverGlyph(state: string): string {
|
||||||
|
return state === 'connected'
|
||||||
|
? '●'
|
||||||
|
: state === 'connecting' || state === 'reconnecting'
|
||||||
|
? '◐'
|
||||||
|
: '○';
|
||||||
|
}
|
||||||
|
function serverColor(state: string): string {
|
||||||
|
if (state === 'connected') return 'var(--color-relay-connected)';
|
||||||
|
if (state === 'error' || state === 'disconnected') return 'var(--color-relay-disconnected)';
|
||||||
|
return 'var(--color-text-muted)';
|
||||||
|
}
|
||||||
|
// base label for a relay row: nip-11 name if we know it, else the host.
|
||||||
|
function baseLabel(url: string, name?: string): string {
|
||||||
|
if (name) return name;
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a telltale suffix (host + path) to tell apart relays that share a base
|
||||||
|
// label — e.g. a clearnet + tor endpoint of the same relay both report the
|
||||||
|
// same nip-11 name and host, differing only by path (/relay vs /relay-tor).
|
||||||
|
function urlSuffix(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const path = u.pathname === '/' ? '' : u.pathname;
|
||||||
|
return `${u.host}${path}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a relay reached over tor: the /relay-tor bridge path, or a raw .onion host.
|
||||||
|
function isTorRelay(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.pathname.includes('/relay-tor') || u.hostname.endsWith('.onion');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unique display label per relay: start from the base label, and where two or
|
||||||
|
// more relays collide, tack on their url suffix so every row reads clearly.
|
||||||
|
let serverLabels = $derived.by(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const s of servers) {
|
||||||
|
const b = baseLabel(s.url, s.info?.name);
|
||||||
|
counts.set(b, (counts.get(b) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const s of servers) {
|
||||||
|
const b = baseLabel(s.url, s.info?.name);
|
||||||
|
labels.set(s.url, (counts.get(b) ?? 0) > 1 ? `${b} (${urlSuffix(s.url)})` : b);
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
});
|
||||||
|
|
||||||
|
// one row per relay. where the same relay (same base label) is reachable both
|
||||||
|
// over clearnet and over tor, squash the two endpoints into one row so the
|
||||||
|
// list shows the relay once, marked with the onion→relay bridge icons.
|
||||||
|
interface RelayRow {
|
||||||
|
key: string; // stable #each key
|
||||||
|
label: string; // short display name
|
||||||
|
primary: RelayState; // server window opened on click (clearnet preferred)
|
||||||
|
tor?: RelayState; // tor endpoint — set only on a bridged row
|
||||||
|
clearnet?: RelayState; // clearnet endpoint, when the relay also has one
|
||||||
|
}
|
||||||
|
let relayRows = $derived.by(() => {
|
||||||
|
// group by base label — the usual "same relay, different endpoint" idea.
|
||||||
|
const groups = new Map<string, RelayState[]>();
|
||||||
|
for (const s of servers) {
|
||||||
|
const b = baseLabel(s.url, s.info?.name);
|
||||||
|
const list = groups.get(b);
|
||||||
|
if (list) list.push(s);
|
||||||
|
else groups.set(b, [s]);
|
||||||
|
}
|
||||||
|
const rows: RelayRow[] = [];
|
||||||
|
for (const [label, list] of groups) {
|
||||||
|
const tor = list.find((s) => isTorRelay(s.url));
|
||||||
|
if (tor) {
|
||||||
|
const clearnet = list.find((s) => !isTorRelay(s.url));
|
||||||
|
// merge clearnet + tor into one row; prefer clearnet as the direct window.
|
||||||
|
rows.push({ key: tor.url, label, primary: clearnet ?? tor, tor, clearnet });
|
||||||
|
// any leftover endpoints in this group get their own rows (rare).
|
||||||
|
for (const s of list) {
|
||||||
|
if (s !== tor && s !== clearnet) {
|
||||||
|
rows.push({ key: s.url, label: serverLabels.get(s.url) ?? label, primary: s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const s of list) {
|
||||||
|
rows.push({ key: s.url, label: serverLabels.get(s.url) ?? label, primary: s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
});
|
||||||
|
|
||||||
|
// is any of a merged row's endpoints the focused server window?
|
||||||
|
function rowActive(row: RelayRow): boolean {
|
||||||
|
if (activeServerUrl == null) return false;
|
||||||
|
return (
|
||||||
|
row.primary.url === activeServerUrl ||
|
||||||
|
row.tor?.url === activeServerUrl ||
|
||||||
|
row.clearnet?.url === activeServerUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- onion glyph (tor entry): a layered bulb with a sprout. colored by state. -->
|
||||||
|
{#snippet onionIcon(color: string)}
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3c-.6 1.2-1 2-1 3.2" />
|
||||||
|
<path
|
||||||
|
d="M12 21c-3.9 0-6.5-3-6.5-6.8C5.5 10 8 6.2 12 6.2s6.5 3.8 6.5 8C18.5 18 15.9 21 12 21z"
|
||||||
|
/>
|
||||||
|
<path d="M9.5 11.5c0 3.4.8 6.4 2.5 9" />
|
||||||
|
<path d="M14.5 11.5c0 3.4-.8 6.4-2.5 9" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- relay glyph (destination): a broadcast node with signal arcs. colored by state. -->
|
||||||
|
{#snippet relayIcon(color: string)}
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="1.6" fill={color} stroke="none" />
|
||||||
|
<path d="M8.5 8.5a5 5 0 000 7" />
|
||||||
|
<path d="M15.5 8.5a5 5 0 010 7" />
|
||||||
|
<path d="M6 6.5a9 9 0 000 11" />
|
||||||
|
<path d="M18 6.5a9 9 0 010 11" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- the bridge arrow between onion and relay. -->
|
||||||
|
{#snippet arrowIcon()}
|
||||||
|
<svg
|
||||||
|
class="h-2.5 w-2.5 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-text-muted)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 12h13" />
|
||||||
|
<path d="M12 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section aria-label="relays" class="mt-4">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Relays
|
||||||
|
</h2>
|
||||||
|
{#if relayRows.length === 0}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">No relays. /connect <url></p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each relayRows as row (row.key)}
|
||||||
|
{@const active = rowActive(row)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||||
|
style="color: {active
|
||||||
|
? 'var(--color-accent)'
|
||||||
|
: 'var(--color-text-primary)'}; background: {active
|
||||||
|
? 'var(--color-surface-raised)'
|
||||||
|
: 'transparent'};"
|
||||||
|
title={row.tor
|
||||||
|
? `${row.label}${row.clearnet ? `\nclearnet: ${row.clearnet.url}` : ''}\ntor: ${row.tor.url}`
|
||||||
|
: (row.primary.lastError ?? row.primary.url)}
|
||||||
|
onclick={() => openServer(row.primary.url)}
|
||||||
|
aria-current={active ? 'true' : undefined}
|
||||||
|
aria-label={row.tor ? `Relay ${row.label}, reachable over Tor` : undefined}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
{#if row.tor}
|
||||||
|
<!-- onion (tor path) → relay (destination), replacing the dot. -->
|
||||||
|
{@render onionIcon(serverColor(row.tor.connection))}
|
||||||
|
{@render arrowIcon()}
|
||||||
|
{@render relayIcon(serverColor(row.primary.connection))}
|
||||||
|
{:else}
|
||||||
|
<span style="color: {serverColor(row.primary.connection)};" aria-hidden="true">
|
||||||
|
{serverGlyph(row.primary.connection)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{row.label}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="rooms" class="mt-4">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Rooms
|
||||||
|
</h2>
|
||||||
|
{#if rooms.length === 0}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">No rooms. /join #room</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each rooms as room (room.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||||
|
style="color: {room.id === activeId
|
||||||
|
? 'var(--color-accent)'
|
||||||
|
: 'var(--color-text-primary)'}; background: {room.id === activeId
|
||||||
|
? 'var(--color-surface-raised)'
|
||||||
|
: 'transparent'};"
|
||||||
|
title={`${room.address.groupId} @ ${room.address.relayUrl}`}
|
||||||
|
onclick={() => switchTo(room.id)}
|
||||||
|
aria-current={room.id === activeId ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="truncate" style={room.unread > 0 ? 'font-weight: 600;' : ''}>
|
||||||
|
#{room.name}
|
||||||
|
</span>
|
||||||
|
{#if room.unread > 0}
|
||||||
|
<span
|
||||||
|
class="ml-auto shrink-0 rounded-full px-1.5 text-[10px] font-bold"
|
||||||
|
style="background: var(--color-info); color: var(--color-background);"
|
||||||
|
aria-label={`${room.unread} unread`}
|
||||||
|
>
|
||||||
|
{room.unread > 99 ? '99+' : room.unread}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if dms.length > 0}
|
||||||
|
<section aria-label="direct messages" class="mt-4">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Direct Messages
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each dms as dm (dm.pubkey)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded px-1 py-0.5 text-left text-xs"
|
||||||
|
style="color: {dm.pubkey === activeDmPubkey
|
||||||
|
? 'var(--color-accent)'
|
||||||
|
: 'var(--color-text-primary)'}; background: {dm.pubkey === activeDmPubkey
|
||||||
|
? 'var(--color-surface-raised)'
|
||||||
|
: 'transparent'};"
|
||||||
|
onclick={() => openDm(dm.pubkey)}
|
||||||
|
aria-current={dm.pubkey === activeDmPubkey ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="truncate" style={dm.unread > 0 ? 'font-weight: 600;' : ''}>
|
||||||
|
@{dmLabel(dm.pubkey)}
|
||||||
|
</span>
|
||||||
|
{#if dm.unread > 0}
|
||||||
|
<span
|
||||||
|
class="ml-auto shrink-0 rounded-full px-1.5 text-[10px] font-bold"
|
||||||
|
style="background: var(--color-info); color: var(--color-background);"
|
||||||
|
aria-label={`${dm.unread} unread`}
|
||||||
|
>
|
||||||
|
{dm.unread > 99 ? '99+' : dm.unread}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { activeRoom } from '$lib/stores/room-store';
|
||||||
|
import { activeDm } from '$lib/stores/dm-store';
|
||||||
|
import { serverStore } from '$lib/stores/server-store';
|
||||||
|
import { profileStore } from '$lib/stores/profile-store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { activeThemeId } from '$lib/stores/theme-store';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
|
||||||
|
// the focused-scope label, in precedence order: server window, dm, then room.
|
||||||
|
let scopeLabel = $derived(
|
||||||
|
$serverStore.activeUrl
|
||||||
|
? $serverStore.activeUrl
|
||||||
|
: $activeDm
|
||||||
|
? `@${$profileStore[$activeDm.pubkey] ?? shortenPubkey($activeDm.pubkey)}`
|
||||||
|
: $activeRoom
|
||||||
|
? `#${$activeRoom.name}`
|
||||||
|
: '(none)'
|
||||||
|
);
|
||||||
|
// the scope-kind label in front of it (server:/dm:/room:).
|
||||||
|
let scopeKind = $derived($serverStore.activeUrl ? 'server:' : $activeDm ? 'dm:' : 'room:');
|
||||||
|
|
||||||
|
// count connected relays for a compact status indicator.
|
||||||
|
let connected = $derived(
|
||||||
|
Object.values($relayStore).filter((r) => r.connection === 'connected').length
|
||||||
|
);
|
||||||
|
let total = $derived(Object.keys($relayStore).length);
|
||||||
|
|
||||||
|
// short tag for how the identity is held (nip07/bunker/nsec/temp).
|
||||||
|
const KIND_TAG: Record<string, string> = {
|
||||||
|
nip07: 'nip07',
|
||||||
|
bunker: 'bunker',
|
||||||
|
nsec: 'nsec',
|
||||||
|
ephemeral: 'temp'
|
||||||
|
};
|
||||||
|
let identity = $derived(
|
||||||
|
$authStore.pubkey
|
||||||
|
? `${KIND_TAG[$authStore.kind] ? `${KIND_TAG[$authStore.kind]}:` : ''}${shortenPubkey($authStore.pubkey)}`
|
||||||
|
: 'not logged in'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t px-3 py-1 text-xs"
|
||||||
|
style="border-color: var(--color-border); background: var(--color-surface); color: var(--color-text-secondary);"
|
||||||
|
aria-label="status bar"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span style="color: var(--color-text-muted);">relays:</span>
|
||||||
|
<span
|
||||||
|
style="color: {connected > 0
|
||||||
|
? 'var(--color-relay-connected)'
|
||||||
|
: 'var(--color-relay-disconnected)'};"
|
||||||
|
>
|
||||||
|
{connected}/{total} connected
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style="color: var(--color-text-muted);">{scopeKind}</span>
|
||||||
|
<span style="color: var(--color-text-primary);">
|
||||||
|
{scopeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style="color: var(--color-text-muted);">id:</span>
|
||||||
|
<span style="color: var(--color-text-primary);">{identity}</span>
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto">
|
||||||
|
<span style="color: var(--color-text-muted);">theme:</span>
|
||||||
|
<span style="color: var(--color-accent);">{$activeThemeId}</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { terminalStore, isEntryVisible } from '$lib/stores/terminal-store';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { dmStore, dmRoomId } from '$lib/stores/dm-store';
|
||||||
|
import { serverStore, relayRoomId } from '$lib/stores/server-store';
|
||||||
|
import { activeThemeId } from '$lib/stores/theme-store';
|
||||||
|
import { getThemeOrDefault } from '$lib/themes/theme-registry';
|
||||||
|
import { usernameColor } from '$lib/themes/username-color';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal';
|
||||||
|
|
||||||
|
let viewport = $state<HTMLDivElement | null>(null);
|
||||||
|
// is the user near the bottom? only auto-scroll if so.
|
||||||
|
let pinnedToBottom = $state(true);
|
||||||
|
|
||||||
|
let theme = $derived(getThemeOrDefault($activeThemeId));
|
||||||
|
|
||||||
|
// the focused conversation, in precedence order: relay server window, then
|
||||||
|
// dm view, then active room. each is scoped by a distinct roomId marker.
|
||||||
|
let activeScope = $derived(
|
||||||
|
$serverStore.activeUrl
|
||||||
|
? relayRoomId($serverStore.activeUrl)
|
||||||
|
: $dmStore.activePubkey
|
||||||
|
? dmRoomId($dmStore.activePubkey)
|
||||||
|
: $roomStore.activeRoomId
|
||||||
|
);
|
||||||
|
|
||||||
|
// irc-style topic bar: only for a real room (not a server/dm window),
|
||||||
|
// when that room has a topic set via nip-29 metadata / the /topic command.
|
||||||
|
let activeRoomForTopic = $derived(
|
||||||
|
!$serverStore.activeUrl && !$dmStore.activePubkey && $roomStore.activeRoomId
|
||||||
|
? $roomStore.rooms[$roomStore.activeRoomId]
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// show entries for the active scope (room or dm) plus global lines (system/
|
||||||
|
// notice/command output, which carry no roomId). a scoped entry only shows
|
||||||
|
// while its room/dm is active, so switching shows the right conversation.
|
||||||
|
let entries = $derived($terminalStore.filter((e) => isEntryVisible(e, activeScope)));
|
||||||
|
|
||||||
|
// color for non-message lines. messages/actions color the username instead.
|
||||||
|
const TYPE_COLOR: Record<TerminalEntryType, string> = {
|
||||||
|
message: 'var(--color-text-primary)',
|
||||||
|
action: 'var(--color-terminal-action)',
|
||||||
|
system: 'var(--color-terminal-system)',
|
||||||
|
notice: 'var(--color-terminal-notice)',
|
||||||
|
warning: 'var(--color-warning)',
|
||||||
|
error: 'var(--color-error)',
|
||||||
|
command: 'var(--color-terminal-command)'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorLabel(entry: TerminalEntry): string {
|
||||||
|
return entry.authorDisplayName ?? (entry.author ? shortenPubkey(entry.author) : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (!viewport) return;
|
||||||
|
const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
|
pinnedToBottom = distance < 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto-scroll on new entries only when already near the bottom.
|
||||||
|
$effect(() => {
|
||||||
|
// reference length so the effect re-runs on append
|
||||||
|
void entries.length;
|
||||||
|
if (pinnedToBottom) {
|
||||||
|
tick().then(() => {
|
||||||
|
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col" style="background: var(--color-background);">
|
||||||
|
{#if activeRoomForTopic}
|
||||||
|
<!-- irc-style topic bar: the active room's topic, pinned above the log. -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 truncate border-b px-3 py-1 text-xs"
|
||||||
|
style="border-color: var(--color-border); background: var(--color-surface); color: var(--color-text-secondary);"
|
||||||
|
aria-label="room topic"
|
||||||
|
title={activeRoomForTopic.topic ?? 'No topic set'}
|
||||||
|
>
|
||||||
|
<span style="color: var(--color-text-muted);">Topic for #{activeRoomForTopic.name}:</span>
|
||||||
|
{#if activeRoomForTopic.topic}
|
||||||
|
<span style="color: var(--color-text-primary);">{activeRoomForTopic.topic}</span>
|
||||||
|
{:else}
|
||||||
|
<span style="color: var(--color-text-muted);">(none — set with /topic <text>)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- focusable so the log scrolls with the keyboard (arrow/page keys). -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div
|
||||||
|
bind:this={viewport}
|
||||||
|
onscroll={onScroll}
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto px-3 py-2"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="terminal output"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<div
|
||||||
|
class="whitespace-pre-wrap break-words leading-relaxed"
|
||||||
|
class:mention={entry.mention}
|
||||||
|
data-entry-type={entry.type}
|
||||||
|
>
|
||||||
|
<span style="color: var(--color-terminal-timestamp);">[{formatTime(entry.timestamp)}]</span>
|
||||||
|
{#if entry.type === 'message'}
|
||||||
|
<span style="color: var(--color-text-muted);"><</span><span
|
||||||
|
style="color: {entry.author
|
||||||
|
? usernameColor(entry.author, theme)
|
||||||
|
: 'var(--color-text-primary)'};">{authorLabel(entry)}</span
|
||||||
|
><span style="color: var(--color-text-muted);">></span>
|
||||||
|
<span style="color: var(--color-text-primary);">{entry.content}</span>
|
||||||
|
{:else if entry.type === 'action'}
|
||||||
|
<span style="color: {TYPE_COLOR.action};">* {authorLabel(entry)} {entry.content}</span>
|
||||||
|
{:else if entry.type === 'command'}
|
||||||
|
<span style="color: {TYPE_COLOR.command};">{entry.content}</span>
|
||||||
|
{:else}
|
||||||
|
<span style="color: {TYPE_COLOR[entry.type]};">*** {entry.content}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* a message that mentions you: accent left-border + faint accent wash, so it
|
||||||
|
stands out without relying on color alone (the border's a shape cue too). */
|
||||||
|
.mention {
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
padding-left: 5px;
|
||||||
|
margin-left: -8px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { activeRoom } from '$lib/stores/room-store';
|
||||||
|
import { profileStore } from '$lib/stores/profile-store';
|
||||||
|
import { activeThemeId } from '$lib/stores/theme-store';
|
||||||
|
import { getThemeOrDefault } from '$lib/themes/theme-registry';
|
||||||
|
import { usernameColor } from '$lib/themes/username-color';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { encodeNpub } from '$lib/utils/npub';
|
||||||
|
|
||||||
|
// live display name for the logged-in user (updates after /nick).
|
||||||
|
let myName = $derived($authStore.pubkey ? $profileStore[$authStore.pubkey] : undefined);
|
||||||
|
|
||||||
|
let theme = $derived(getThemeOrDefault($activeThemeId));
|
||||||
|
|
||||||
|
// which member row was just copied, for quick feedback.
|
||||||
|
let copiedPubkey = $state<string | null>(null);
|
||||||
|
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
pubkey: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// participants for the active room, admins first then members, deduped.
|
||||||
|
// derived from the relay's nip-29 admin (39001) / member (39002) lists, plus
|
||||||
|
// the logged-in user (if the sidebar shows a room, we've joined it — but the
|
||||||
|
// relay may not have published a 39002 entry for us yet).
|
||||||
|
let participants = $derived.by<Participant[]>(() => {
|
||||||
|
const room = $activeRoom;
|
||||||
|
if (!room) return [];
|
||||||
|
const admins = new Set(room.admins ?? []);
|
||||||
|
const all = new Set<string>([...(room.admins ?? []), ...(room.members ?? [])]);
|
||||||
|
if ($authStore.pubkey) all.add($authStore.pubkey);
|
||||||
|
return [...all]
|
||||||
|
.map((pubkey) => ({ pubkey, isAdmin: admins.has(pubkey) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1;
|
||||||
|
return label(a.pubkey).localeCompare(label(b.pubkey));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function label(pubkey: string): string {
|
||||||
|
return $profileStore[pubkey] ?? shortenPubkey(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagCopied(pubkey: string) {
|
||||||
|
copiedPubkey = pubkey;
|
||||||
|
if (copiedTimer) clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = setTimeout(() => (copiedPubkey = null), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPubkey(pubkey: string) {
|
||||||
|
const npub = encodeNpub(pubkey);
|
||||||
|
// preferred path: async clipboard api (needs a secure context).
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(npub);
|
||||||
|
flagCopied(pubkey);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* fall through to the legacy path (e.g. permission denied). */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback for non-secure contexts (http on a lan ip): a hidden textarea
|
||||||
|
// + execCommand still works where the clipboard api isn't available.
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = npub;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
if (ok) flagCopied(pubkey);
|
||||||
|
} catch {
|
||||||
|
/* clipboard genuinely unavailable; nothing more we can do. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="hidden h-full w-56 shrink-0 overflow-y-auto border-l p-3 lg:block"
|
||||||
|
style="border-color: var(--color-border); background: var(--color-surface);"
|
||||||
|
aria-label="room and identity details"
|
||||||
|
>
|
||||||
|
<section aria-label="room details">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Room
|
||||||
|
</h2>
|
||||||
|
{#if $activeRoom}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-primary);">#{$activeRoom.name}</p>
|
||||||
|
<p class="mt-1 break-all text-xs" style="color: var(--color-text-muted);">
|
||||||
|
{$activeRoom.address.relayUrl}
|
||||||
|
</p>
|
||||||
|
{#if $activeRoom.topic}
|
||||||
|
<p class="mt-2 text-xs" style="color: var(--color-text-secondary);">
|
||||||
|
{$activeRoom.topic}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">No active room.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if $activeRoom}
|
||||||
|
<section aria-label="members" class="mt-4">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Members ({participants.length})
|
||||||
|
</h2>
|
||||||
|
{#if participants.length === 0}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">No member list yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#each participants as p (p.pubkey)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||||
|
onclick={() => copyPubkey(p.pubkey)}
|
||||||
|
title="Click to copy npub"
|
||||||
|
>
|
||||||
|
{#if p.isAdmin}
|
||||||
|
<span
|
||||||
|
title="Admin / operator"
|
||||||
|
aria-label="admin"
|
||||||
|
style="color: var(--color-warning);">★</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span aria-hidden="true" style="color: var(--color-text-muted);">·</span>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate" style="color: {usernameColor(p.pubkey, theme)};"
|
||||||
|
>{label(p.pubkey)}</span
|
||||||
|
>
|
||||||
|
{#if p.pubkey === $authStore.pubkey}
|
||||||
|
<span style="color: var(--color-text-muted);">(you)</span>
|
||||||
|
{/if}
|
||||||
|
{#if copiedPubkey === p.pubkey}
|
||||||
|
<span class="ml-auto" style="color: var(--color-success);">copied</span>
|
||||||
|
{:else if p.isAdmin}
|
||||||
|
<span class="ml-auto" style="color: var(--color-text-muted);">op</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section aria-label="identity" class="mt-4">
|
||||||
|
<h2 class="mb-1 text-xs uppercase tracking-wide" style="color: var(--color-text-muted);">
|
||||||
|
Identity
|
||||||
|
</h2>
|
||||||
|
{#if $authStore.pubkey}
|
||||||
|
{#if myName}
|
||||||
|
<p class="text-sm" style="color: var(--color-text-primary);">{myName}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">
|
||||||
|
{$authStore.kind}{$authStore.ephemeral ? ' (temporary)' : ''}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-1 break-all text-left text-xs underline"
|
||||||
|
style="color: var(--color-link);"
|
||||||
|
onclick={() => copyPubkey($authStore.pubkey!)}
|
||||||
|
title="Click to copy npub"
|
||||||
|
>
|
||||||
|
{copiedPubkey === $authStore.pubkey ? 'copied npub' : shortenPubkey($authStore.pubkey)}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs" style="color: var(--color-text-muted);">/login to connect</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* product naming, kept off on its own so it can change later without touching
|
||||||
|
* protocol or app logic.
|
||||||
|
*/
|
||||||
|
export const BRAND = {
|
||||||
|
name: 'Nosterm',
|
||||||
|
tagline: 'A terminal-style Nostr chat client.'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { CommandContext } from '$lib/types/command';
|
||||||
|
import { CommandDispatcher } from './dispatcher';
|
||||||
|
import { createRegistry } from './create-registry';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { dmStore } from '$lib/stores/dm-store';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { dmService } from '$lib/nostr/dm-service';
|
||||||
|
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||||
|
import { out } from './output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* puts the app dispatcher together: a shared registry plus wiring that reads
|
||||||
|
* live context from the stores. plain-text messaging goes to the active room.
|
||||||
|
*/
|
||||||
|
export function createAppDispatcher(): {
|
||||||
|
dispatcher: CommandDispatcher;
|
||||||
|
registry: ReturnType<typeof createRegistry>;
|
||||||
|
} {
|
||||||
|
const registry = createRegistry();
|
||||||
|
// let plugins register commands into this registry.
|
||||||
|
pluginHost.useRegistry(registry);
|
||||||
|
|
||||||
|
const getContext = (): CommandContext => {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const activeRoom = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
// prefer the active room's relay; else the first connected one.
|
||||||
|
const relays = Object.values(get(relayStore));
|
||||||
|
const activeRelay =
|
||||||
|
activeRoom?.address.relayUrl ?? relays.find((r) => r.connection === 'connected')?.url;
|
||||||
|
return {
|
||||||
|
activeRoom: activeRoomId,
|
||||||
|
activeRelay,
|
||||||
|
currentUser: get(authStore).pubkey
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatcher = new CommandDispatcher({
|
||||||
|
registry,
|
||||||
|
getContext,
|
||||||
|
async sendMessage(raw) {
|
||||||
|
// let plugins rewrite outgoing text (e.g. :shrug: → ¯\_(ツ)_/¯) on the
|
||||||
|
// send path, for both room messages and dms.
|
||||||
|
const content = pluginHost.applyOutgoing(raw);
|
||||||
|
|
||||||
|
// a focused dm conversation wins: plain text goes to them.
|
||||||
|
const dm = get(dmStore).activePubkey;
|
||||||
|
if (dm) {
|
||||||
|
try {
|
||||||
|
await dmService.send(dm, content);
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('No active room. Join one with /join first, or /dm someone.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await roomService.send(active.address, content);
|
||||||
|
// the message echoes back via the room subscription — no local echo.
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { dispatcher, registry };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { tokenize } from './tokenize';
|
||||||
|
|
||||||
|
/** classified user input. plain text is a room message; `/x` is a command. */
|
||||||
|
export type ParsedInput =
|
||||||
|
| { kind: 'empty' }
|
||||||
|
| { kind: 'message'; content: string }
|
||||||
|
| { kind: 'command'; name: string; args: string[]; raw: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* classify a raw input line without running anything (pure + easy to test).
|
||||||
|
* - empty / whitespace-only input is ignored.
|
||||||
|
* - a leading '/' marks a command; the rest is tokenized (quotes honored).
|
||||||
|
* - a leading '//' is an escaped literal message starting with '/'.
|
||||||
|
* - anything else is a plain room message.
|
||||||
|
*/
|
||||||
|
export function parseInput(raw: string): ParsedInput {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.length === 0) return { kind: 'empty' };
|
||||||
|
|
||||||
|
// escaped slash: "//foo" -> literal message "/foo".
|
||||||
|
if (trimmed.startsWith('//')) {
|
||||||
|
return { kind: 'message', content: trimmed.slice(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('/')) {
|
||||||
|
const tokens = tokenize(trimmed.slice(1));
|
||||||
|
const name = tokens[0] ?? '';
|
||||||
|
// a lone "/" with no command name counts as a message.
|
||||||
|
if (name.length === 0) return { kind: 'message', content: trimmed };
|
||||||
|
return { kind: 'command', name, args: tokens.slice(1), raw: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'message', content: trimmed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* keeps the registered commands and looks up names/aliases without a giant
|
||||||
|
* conditional. add new commands via `register` — no core edits needed.
|
||||||
|
*/
|
||||||
|
export class CommandRegistry {
|
||||||
|
private readonly byName = new Map<string, ChatCommand>();
|
||||||
|
private readonly commands: ChatCommand[] = [];
|
||||||
|
|
||||||
|
register(command: ChatCommand): void {
|
||||||
|
this.assertUnique(command.name);
|
||||||
|
this.byName.set(command.name.toLowerCase(), command);
|
||||||
|
for (const alias of command.aliases ?? []) {
|
||||||
|
this.assertUnique(alias);
|
||||||
|
this.byName.set(alias.toLowerCase(), command);
|
||||||
|
}
|
||||||
|
this.commands.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAll(commands: ChatCommand[]): void {
|
||||||
|
for (const command of commands) this.register(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drop a previously-registered command (by its primary name) plus all its
|
||||||
|
* aliases. used when a plugin is disabled at runtime so its commands stop
|
||||||
|
* resolving. no-op if the name isn't registered.
|
||||||
|
*/
|
||||||
|
unregister(name: string): void {
|
||||||
|
const command = this.byName.get(name.toLowerCase());
|
||||||
|
if (!command) return;
|
||||||
|
this.byName.delete(command.name.toLowerCase());
|
||||||
|
for (const alias of command.aliases ?? []) this.byName.delete(alias.toLowerCase());
|
||||||
|
const idx = this.commands.indexOf(command);
|
||||||
|
if (idx !== -1) this.commands.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** look up a command by its name or any alias (case-insensitive). */
|
||||||
|
resolve(nameOrAlias: string): ChatCommand | undefined {
|
||||||
|
return this.byName.get(nameOrAlias.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
has(nameOrAlias: string): boolean {
|
||||||
|
return this.byName.has(nameOrAlias.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** all registered commands, in registration order (for /help). */
|
||||||
|
list(): readonly ChatCommand[] {
|
||||||
|
return this.commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** command names + aliases, for tab completion. */
|
||||||
|
completions(prefix: string): string[] {
|
||||||
|
const p = prefix.toLowerCase();
|
||||||
|
return [...this.byName.keys()].filter((name) => name.startsWith(p)).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUnique(key: string): void {
|
||||||
|
const existing = this.byName.get(key.toLowerCase());
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Command name/alias "${key}" already registered by "${existing.name}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { messageRepository } from '$lib/db/repositories';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/clear` — wipe the terminal view (leaves stored history alone).
|
||||||
|
* `/clear history` — also purge the active room's cached messages from local
|
||||||
|
* storage (indexeddb), so they don't come back on reload. doesn't touch the
|
||||||
|
* relay's copy; a re-join replays whatever the relay still has.
|
||||||
|
*/
|
||||||
|
export const clearCommand: ChatCommand = {
|
||||||
|
name: 'clear',
|
||||||
|
aliases: ['cls'],
|
||||||
|
description: 'Clear the terminal view, or "/clear history" to purge cached room history.',
|
||||||
|
usage: '/clear [history]',
|
||||||
|
async execute(args) {
|
||||||
|
const sub = (args[0] ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (sub === 'history') {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return { success: false, output: [out.warning('No active room to clear history for.')] };
|
||||||
|
}
|
||||||
|
await messageRepository.clearRoom(active.id);
|
||||||
|
terminalStore.clearRoom(active.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system(
|
||||||
|
`Cleared cached history for #${active.name}. (The relay's copy is unaffected.)`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /clear [history]')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalStore.clear();
|
||||||
|
return { success: true, output: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/connect <relay-url>` — connect to a relay over websocket. */
|
||||||
|
export const connectCommand: ChatCommand = {
|
||||||
|
name: 'connect',
|
||||||
|
description: 'Connect to a relay.',
|
||||||
|
usage: '/connect <relay-url>',
|
||||||
|
async execute(args) {
|
||||||
|
const url = args[0];
|
||||||
|
if (!url) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /connect <relay-url>')] };
|
||||||
|
}
|
||||||
|
if (!isValidRelayUrl(url)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`Invalid relay URL "${url}". Use ws:// or wss://.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalized = normalizeRelayUrl(url);
|
||||||
|
// fire-and-forget connect; state changes show up as terminal notices.
|
||||||
|
void relayService.connect(normalized);
|
||||||
|
return { success: true, output: [out.system(`Connecting to ${normalized}…`)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/disconnect <relay-url>` — drop a relay connection. */
|
||||||
|
export const disconnectCommand: ChatCommand = {
|
||||||
|
name: 'disconnect',
|
||||||
|
description: 'Disconnect from a relay.',
|
||||||
|
usage: '/disconnect <relay-url>',
|
||||||
|
async execute(args) {
|
||||||
|
const url = args[0];
|
||||||
|
if (!url) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /disconnect <relay-url>')] };
|
||||||
|
}
|
||||||
|
if (!isValidRelayUrl(url)) {
|
||||||
|
return { success: false, output: [out.error(`Invalid relay URL "${url}".`)] };
|
||||||
|
}
|
||||||
|
const normalized = normalizeRelayUrl(url);
|
||||||
|
relayService.disconnect(normalized);
|
||||||
|
return { success: true, output: [out.system(`Disconnecting from ${normalized}.`)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { dmService } from '$lib/nostr/dm-service';
|
||||||
|
import { dmStore } from '$lib/stores/dm-store';
|
||||||
|
import { decodeNpub } from '$lib/utils/npub';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/dm <npub-or-hex> [message]` — open a direct-message conversation.
|
||||||
|
* with a message, sends it too. once open, plain text goes to that person until
|
||||||
|
* you `/switch` to a room or `/dm` someone else. encrypted via nip-17.
|
||||||
|
*/
|
||||||
|
export const dmCommand: ChatCommand = {
|
||||||
|
name: 'dm',
|
||||||
|
description: 'Open (and optionally send to) a direct-message conversation.',
|
||||||
|
usage: '/dm <npub-or-hex> [message]',
|
||||||
|
async execute(args) {
|
||||||
|
const target = args[0];
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /dm <npub-or-hex> [message]')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// focus the conversation so its view shows and plain text goes here.
|
||||||
|
dmStore.open(pubkey);
|
||||||
|
|
||||||
|
const message = args.slice(1).join(' ').trim();
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system('Opened DM. Type to send; /switch <room> to leave.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dmService.send(pubkey, message);
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import type { CommandRegistry } from '../command-registry';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/help [command]` — list all commands, or show usage for one. */
|
||||||
|
export function createHelpCommand(registry: () => CommandRegistry): ChatCommand {
|
||||||
|
return {
|
||||||
|
name: 'help',
|
||||||
|
aliases: ['h', '?'],
|
||||||
|
description: 'List available commands, or show usage for one command.',
|
||||||
|
usage: '/help [command]',
|
||||||
|
async execute(args) {
|
||||||
|
const reg = registry();
|
||||||
|
const target = args[0];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const cmd = reg.resolve(target.replace(/^\//, ''));
|
||||||
|
if (!cmd) {
|
||||||
|
return { success: false, output: [out.error(`Unknown command: ${target}`)] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system(`/${cmd.name} — ${cmd.description}`),
|
||||||
|
out.system(` usage: ${cmd.usage}`),
|
||||||
|
...(cmd.aliases && cmd.aliases.length
|
||||||
|
? [out.system(` aliases: ${cmd.aliases.map((a) => `/${a}`).join(', ')}`)]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = reg
|
||||||
|
.list()
|
||||||
|
.map((cmd) => out.system(` /${cmd.name.padEnd(12)} ${cmd.description}`));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice('Available commands:'),
|
||||||
|
...lines,
|
||||||
|
out.system('Type /help <command> for usage.')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { dmStore } from '$lib/stores/dm-store';
|
||||||
|
import { parseRoomReference } from '$lib/nostr/room-address';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/join <room>` — join a nip-29 room.
|
||||||
|
* takes `#general`, `general` (uses active relay), or
|
||||||
|
* `wss://relay.example/#general`.
|
||||||
|
*/
|
||||||
|
export const joinCommand: ChatCommand = {
|
||||||
|
name: 'join',
|
||||||
|
description: 'Join a room.',
|
||||||
|
usage: '/join <#room | room | wss://relay/#room>',
|
||||||
|
async execute(args, context) {
|
||||||
|
const ref = args[0];
|
||||||
|
if (!ref) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /join <room>')] };
|
||||||
|
}
|
||||||
|
let address;
|
||||||
|
try {
|
||||||
|
address = parseRoomReference(ref, context.activeRelay);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
dmStore.close(); // focus the room, leaving any dm view
|
||||||
|
await roomService.join(address);
|
||||||
|
// join emits its own terminal notices via the room service.
|
||||||
|
return { success: true, output: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { authService } from '$lib/nostr/auth-service';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { openAuth } from '$lib/stores/ui-store';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/login [nip07|bunker <token>|nsec <key>|generate|dev]` — sign in.
|
||||||
|
* /login open the sign-in screen
|
||||||
|
* /login nip07 use a nip-07 browser extension
|
||||||
|
* /login bunker <token> connect a remote signer (nip-46)
|
||||||
|
* /login nsec <key> import an nsec (discouraged; in-memory only)
|
||||||
|
* /login generate make a brand-new key
|
||||||
|
* /login dev make a throwaway ephemeral identity
|
||||||
|
*/
|
||||||
|
export const loginCommand: ChatCommand = {
|
||||||
|
name: 'login',
|
||||||
|
description: 'Log in (NIP-07, bunker, nsec, or a new key). Run bare to open the sign-in screen.',
|
||||||
|
usage: '/login [nip07 | bunker <token> | nsec <key> | generate | dev]',
|
||||||
|
async execute(args) {
|
||||||
|
const mode = (args[0] ?? '').toLowerCase();
|
||||||
|
|
||||||
|
// bare /login opens the friendly sign-in modal.
|
||||||
|
if (!mode) {
|
||||||
|
openAuth();
|
||||||
|
return { success: true, output: [out.system('Opening the sign-in screen…')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (mode) {
|
||||||
|
case 'nip07': {
|
||||||
|
if (!authService.hasNip07()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [
|
||||||
|
out.error('No NIP-07 extension detected.'),
|
||||||
|
out.system('Install a signer extension, or try /login bunker, nsec, or generate.')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pk = await authService.loginNip07();
|
||||||
|
void relayService.discoverAndConnect(pk);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system(`Logged in as ${shortenPubkey(pk)} (NIP-07).`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bunker': {
|
||||||
|
const token = args.slice(1).join(' ').trim();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('Usage: /login bunker <bunker://… | nip05>')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pk = await authService.loginBunker(token);
|
||||||
|
void relayService.discoverAndConnect(pk);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system(`Logged in as ${shortenPubkey(pk)} (remote signer).`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nsec': {
|
||||||
|
const key = args[1];
|
||||||
|
if (!key) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /login nsec <nsec…>')] };
|
||||||
|
}
|
||||||
|
const pk = authService.loginNsec(key);
|
||||||
|
void relayService.discoverAndConnect(pk);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.warning(
|
||||||
|
'Your private key is now held in this browser tab. Prefer an extension or bunker.'
|
||||||
|
),
|
||||||
|
out.system(`Logged in as ${shortenPubkey(pk)} (nsec, in-memory).`)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'generate': {
|
||||||
|
const pk = authService.generate();
|
||||||
|
const pair = authService.exportKeyPair();
|
||||||
|
const lines = [
|
||||||
|
out.notice('A new identity was generated (kept in memory for this session).'),
|
||||||
|
out.system(` npub: ${pair?.npub ?? '(unknown)'}`)
|
||||||
|
];
|
||||||
|
if (pair?.nsec) {
|
||||||
|
lines.push(out.warning(` nsec: ${pair.nsec}`));
|
||||||
|
lines.push(
|
||||||
|
out.warning(
|
||||||
|
'Save the nsec now — it is shown once and not stored unless you choose to.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(out.system(`Logged in as ${shortenPubkey(pk)}.`));
|
||||||
|
return { success: true, output: lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dev':
|
||||||
|
case 'ephemeral': {
|
||||||
|
const pk = authService.loginEphemeral();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.warning('Using a TEMPORARY ephemeral identity — not for real use.'),
|
||||||
|
out.system(`Logged in as ${shortenPubkey(pk)} (ephemeral).`)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [
|
||||||
|
out.warning('Usage: /login [nip07 | bunker <token> | nsec <key> | generate | dev]')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Login failed.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { authService } from '$lib/nostr/auth-service';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/logout` — drop the current identity and detach the signer. */
|
||||||
|
export const logoutCommand: ChatCommand = {
|
||||||
|
name: 'logout',
|
||||||
|
description: 'Log out of the current identity.',
|
||||||
|
usage: '/logout',
|
||||||
|
async execute() {
|
||||||
|
if (!authService.pubkey) {
|
||||||
|
return { success: true, output: [out.notice('Not logged in.')] };
|
||||||
|
}
|
||||||
|
authService.logout();
|
||||||
|
return { success: true, output: [out.system('Logged out.')] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/me <action>` — fire off an action message to the active room. */
|
||||||
|
export const meCommand: ChatCommand = {
|
||||||
|
name: 'me',
|
||||||
|
description: 'Send an action message to the active room.',
|
||||||
|
usage: '/me <action>',
|
||||||
|
async execute(args) {
|
||||||
|
const action = args.join(' ').trim();
|
||||||
|
if (!action) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /me <action>')] };
|
||||||
|
}
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await roomService.send(active.address, action, true);
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { decodeNpub } from '$lib/utils/npub';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** grab the active room, if there is one. */
|
||||||
|
function activeRoom() {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
return activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shared guts for the two moderation commands. both target a user in the active
|
||||||
|
* room; the relay enforces that the caller is an admin (we also warn early when
|
||||||
|
* we already know they aren't one).
|
||||||
|
*/
|
||||||
|
function moderationCommand(
|
||||||
|
name: 'invite' | 'kick',
|
||||||
|
action: 'add' | 'remove',
|
||||||
|
verb: string
|
||||||
|
): ChatCommand {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
aliases: name === 'kick' ? ['remove'] : ['add'],
|
||||||
|
description:
|
||||||
|
name === 'invite'
|
||||||
|
? 'Admin: add a user to the active room.'
|
||||||
|
: 'Admin: remove a user from the active room.',
|
||||||
|
usage: `/${name} <npub-or-hex-pubkey>`,
|
||||||
|
async execute(args, context) {
|
||||||
|
const target = args[0];
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, output: [out.warning(`Usage: /${name} <npub-or-hex-pubkey>`)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = activeRoom();
|
||||||
|
if (!room) {
|
||||||
|
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// friendly early guard: if we already know the caller isn't an admin,
|
||||||
|
// don't bother the relay. (isAdmin returns true when the list isn't
|
||||||
|
// known yet, so the relay stays the final say.)
|
||||||
|
const me = context.currentUser;
|
||||||
|
if (me && !roomService.isAdmin(room.id, me)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`You are not an admin of #${room.name}.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await roomService.moderate(room.address, action, pubkey);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system(
|
||||||
|
`${verb} ${shortenPubkey(pubkey)} ${name === 'invite' ? 'to' : 'from'} #${room.name}.`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : `Failed to ${name}.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/invite <npub>` — admin adds a user to the active room (nip-29 kind 9000). */
|
||||||
|
export const inviteCommand = moderationCommand('invite', 'add', 'Invited');
|
||||||
|
|
||||||
|
/** `/kick <npub>` — admin removes a user from the active room (nip-29 kind 9001). */
|
||||||
|
export const kickCommand = moderationCommand('kick', 'remove', 'Removed');
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { dmService } from '$lib/nostr/dm-service';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/msg <npub-or-hex> <message>` — send an encrypted direct message (nip-17).
|
||||||
|
* the message is gift-wrapped so relays can't see sender, recipient, or content.
|
||||||
|
*/
|
||||||
|
export const msgCommand: ChatCommand = {
|
||||||
|
name: 'msg',
|
||||||
|
description: 'Send an encrypted direct message (NIP-17).',
|
||||||
|
usage: '/msg <npub-or-hex> <message>',
|
||||||
|
async execute(args) {
|
||||||
|
const recipient = args[0];
|
||||||
|
const message = args.slice(1).join(' ').trim();
|
||||||
|
if (!recipient || !message) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /msg <npub-or-hex> <message>')] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await dmService.send(recipient, message);
|
||||||
|
// the outgoing dm is rendered by the service; no extra output needed.
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { muteStore } from '$lib/stores/mute-store';
|
||||||
|
import { decodeNpub } from '$lib/utils/npub';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/mute <npub>` — hide a user's messages, just for you. */
|
||||||
|
export const muteCommand: ChatCommand = {
|
||||||
|
name: 'mute',
|
||||||
|
description: 'Hide messages from a user (local only).',
|
||||||
|
usage: '/mute <npub-or-hex-pubkey>',
|
||||||
|
async execute(args) {
|
||||||
|
const target = args[0];
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /mute <npub-or-hex-pubkey>')] };
|
||||||
|
}
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
muteStore.add(pubkey);
|
||||||
|
return { success: true, output: [out.system(`Muted ${shortenPubkey(pubkey)}.`)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `/unmute <npub>` — stop hiding a user's messages. */
|
||||||
|
export const unmuteCommand: ChatCommand = {
|
||||||
|
name: 'unmute',
|
||||||
|
description: 'Unhide messages from a previously muted user.',
|
||||||
|
usage: '/unmute <npub-or-hex-pubkey>',
|
||||||
|
async execute(args) {
|
||||||
|
const target = args[0];
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /unmute <npub-or-hex-pubkey>')] };
|
||||||
|
}
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!get(muteStore).has(pubkey)) {
|
||||||
|
return { success: true, output: [out.notice(`${shortenPubkey(pubkey)} is not muted.`)] };
|
||||||
|
}
|
||||||
|
muteStore.remove(pubkey);
|
||||||
|
return { success: true, output: [out.system(`Unmuted ${shortenPubkey(pubkey)}.`)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { profileService } from '$lib/nostr/profile-service';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/nick <name>` — set your public profile display name (nip-01 kind 0).
|
||||||
|
* gets published to connected relays and shown instead of your pubkey.
|
||||||
|
* quotes let you use spaces: `/nick "Ada Lovelace"`.
|
||||||
|
*/
|
||||||
|
export const nickCommand: ChatCommand = {
|
||||||
|
name: 'nick',
|
||||||
|
aliases: ['name'],
|
||||||
|
description: 'Set your public profile display name.',
|
||||||
|
usage: '/nick <name>',
|
||||||
|
async execute(args) {
|
||||||
|
const name = args.join(' ').trim();
|
||||||
|
if (!name) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /nick <name>')] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await profileService.publishName(name);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system(`Display name set to "${name}". It may take a moment to appear for others.`)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Failed to set display name.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { parseRoomReference } from '$lib/nostr/room-address';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/part [room]` — leave a room, defaulting to the active one. */
|
||||||
|
export const partCommand: ChatCommand = {
|
||||||
|
name: 'part',
|
||||||
|
aliases: ['leave'],
|
||||||
|
description: 'Leave a room (defaults to the active room).',
|
||||||
|
usage: '/part [room]',
|
||||||
|
async execute(args, context) {
|
||||||
|
const ref = args[0];
|
||||||
|
|
||||||
|
// no argument: leave the active room.
|
||||||
|
if (!ref) {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('No active room to part. Usage: /part [room]')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await roomService.leave(active.address);
|
||||||
|
return { success: true, output: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = parseRoomReference(ref, context.activeRelay);
|
||||||
|
await roomService.leave(address);
|
||||||
|
return { success: true, output: [] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/plugins` — list plugins and whether they're on or off.
|
||||||
|
* `/plugins disable <id>` — turn a plugin off (drops its commands; persisted).
|
||||||
|
* `/plugins enable <id>` — turn a disabled plugin back on.
|
||||||
|
*/
|
||||||
|
export const pluginsCommand: ChatCommand = {
|
||||||
|
name: 'plugins',
|
||||||
|
aliases: ['plugin'],
|
||||||
|
description: 'List plugins, or enable/disable one.',
|
||||||
|
usage: '/plugins [enable|disable <id>]',
|
||||||
|
async execute(args) {
|
||||||
|
const sub = args[0]?.toLowerCase();
|
||||||
|
|
||||||
|
if (sub === 'enable' || sub === 'disable') {
|
||||||
|
const id = args[1];
|
||||||
|
if (!id) {
|
||||||
|
return { success: false, output: [out.warning(`Usage: /plugins ${sub} <id>`)] };
|
||||||
|
}
|
||||||
|
const ok = sub === 'enable' ? await pluginHost.enable(id) : await pluginHost.disable(id);
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`No plugin with id "${id}". Type /plugins to list them.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system(`Plugin "${id}" ${sub}d.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('Usage: /plugins [enable|disable <id>]')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// no subcommand: list every known plugin with its state.
|
||||||
|
const plugins = pluginHost.status();
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.notice('No plugins found. Add one under src/plugins/ — see its README.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice(`Plugins (${plugins.length}):`),
|
||||||
|
...plugins.map(({ plugin, enabled }) =>
|
||||||
|
out.system(
|
||||||
|
` ${enabled ? '[on] ' : '[off]'} ${plugin.id.padEnd(16)} ${plugin.name}${
|
||||||
|
plugin.description ? ` — ${plugin.description}` : ''
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
out.system('Toggle with /plugins enable <id> or /plugins disable <id>.')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/relays` — list configured relays and their connection state.
|
||||||
|
* `/relays discover` — grab the logged-in user's nip-65 relay list and connect.
|
||||||
|
*/
|
||||||
|
export const relaysCommand: ChatCommand = {
|
||||||
|
name: 'relays',
|
||||||
|
description: 'List relays, or "/relays discover" to connect your NIP-65 relays.',
|
||||||
|
usage: '/relays [discover]',
|
||||||
|
async execute(args, context) {
|
||||||
|
if ((args[0] ?? '').toLowerCase() === 'discover') {
|
||||||
|
if (!context.currentUser) {
|
||||||
|
return { success: false, output: [out.warning('Log in first to discover your relays.')] };
|
||||||
|
}
|
||||||
|
const added = await relayService.discoverAndConnect(context.currentUser);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: added > 0 ? [] : [out.system('No new relays to connect from your NIP-65 list.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const relays = Object.values(get(relayStore));
|
||||||
|
if (relays.length === 0) {
|
||||||
|
return { success: true, output: [out.notice('No relays. Use /connect <relay-url>.')] };
|
||||||
|
}
|
||||||
|
const lines = relays.map((r) => {
|
||||||
|
const nip29 =
|
||||||
|
r.supportsNip29 === undefined ? '' : r.supportsNip29 ? ' [NIP-29]' : ' [no NIP-29]';
|
||||||
|
return out.system(` ${r.connection.padEnd(12)} ${r.url}${nip29}`);
|
||||||
|
});
|
||||||
|
return { success: true, output: [out.notice('Relays:'), ...lines] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/rooms` — list joined rooms across every relay. */
|
||||||
|
export const roomsCommand: ChatCommand = {
|
||||||
|
name: 'rooms',
|
||||||
|
description: 'List joined rooms.',
|
||||||
|
usage: '/rooms',
|
||||||
|
async execute() {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const list = Object.values(rooms);
|
||||||
|
if (list.length === 0) {
|
||||||
|
return { success: true, output: [out.notice('No rooms. Use /join <room>.')] };
|
||||||
|
}
|
||||||
|
const lines = list.map((room) => {
|
||||||
|
const marker = room.id === activeRoomId ? '*' : ' ';
|
||||||
|
return out.system(` ${marker} #${room.name} (${room.address.relayUrl})`);
|
||||||
|
});
|
||||||
|
return { success: true, output: [out.notice('Rooms:'), ...lines] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { settingsStore } from '$lib/stores/settings-store';
|
||||||
|
import { notificationService } from '$lib/notifications/notification-service';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/settings` — view or tweak saved preferences.
|
||||||
|
* /settings show current settings
|
||||||
|
* /settings fontsize <px> set terminal font size (8–32)
|
||||||
|
* /settings lineheight <n> set line height (1.0–2.5)
|
||||||
|
* /settings motion <on|off> toggle reduced-motion
|
||||||
|
* /settings usersidebar <on|off> toggle the right sidebar
|
||||||
|
* /settings notifications <on|off> desktop notifications for dms/mentions
|
||||||
|
* /settings reset back to defaults
|
||||||
|
*/
|
||||||
|
export const settingsCommand: ChatCommand = {
|
||||||
|
name: 'settings',
|
||||||
|
description: 'View or change persisted preferences.',
|
||||||
|
usage:
|
||||||
|
'/settings [fontsize <px> | lineheight <n> | motion <on|off> | usersidebar <on|off> | notifications <on|off> | reset]',
|
||||||
|
async execute(args) {
|
||||||
|
const [key, value] = args;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
const s = get(settingsStore);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice('Settings:'),
|
||||||
|
out.system(` theme : ${s.themeId}`),
|
||||||
|
out.system(` fontsize : ${s.fontSize}px`),
|
||||||
|
out.system(` lineheight : ${s.lineHeight}`),
|
||||||
|
out.system(` motion : ${s.reducedMotion ? 'reduced' : 'normal'}`),
|
||||||
|
out.system(` usersidebar : ${s.showUserSidebar ? 'on' : 'off'}`),
|
||||||
|
out.system(` notifications : ${s.notifications ? 'on' : 'off'}`),
|
||||||
|
out.system('Change with /settings <key> <value>. See /help settings.')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key.toLowerCase()) {
|
||||||
|
case 'fontsize': {
|
||||||
|
const px = Number.parseInt(value ?? '', 10);
|
||||||
|
if (!Number.isFinite(px) || px < 8 || px > 32) {
|
||||||
|
return { success: false, output: [out.warning('fontsize must be 8–32.')] };
|
||||||
|
}
|
||||||
|
settingsStore.patch({ fontSize: px });
|
||||||
|
return { success: true, output: [out.system(`Font size set to ${px}px.`)] };
|
||||||
|
}
|
||||||
|
case 'lineheight': {
|
||||||
|
const n = Number.parseFloat(value ?? '');
|
||||||
|
if (!Number.isFinite(n) || n < 1 || n > 2.5) {
|
||||||
|
return { success: false, output: [out.warning('lineheight must be 1.0–2.5.')] };
|
||||||
|
}
|
||||||
|
settingsStore.patch({ lineHeight: n });
|
||||||
|
return { success: true, output: [out.system(`Line height set to ${n}.`)] };
|
||||||
|
}
|
||||||
|
case 'motion': {
|
||||||
|
const on = parseToggle(value);
|
||||||
|
if (on === null)
|
||||||
|
return { success: false, output: [out.warning('Usage: /settings motion <on|off>')] };
|
||||||
|
settingsStore.patch({ reducedMotion: on });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system(`Reduced motion ${on ? 'enabled' : 'disabled'}.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'usersidebar': {
|
||||||
|
const on = parseToggle(value);
|
||||||
|
if (on === null)
|
||||||
|
return { success: false, output: [out.warning('Usage: /settings usersidebar <on|off>')] };
|
||||||
|
settingsStore.patch({ showUserSidebar: on });
|
||||||
|
return { success: true, output: [out.system(`User sidebar ${on ? 'shown' : 'hidden'}.`)] };
|
||||||
|
}
|
||||||
|
case 'notifications': {
|
||||||
|
const on = parseToggle(value);
|
||||||
|
if (on === null)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('Usage: /settings notifications <on|off>')]
|
||||||
|
};
|
||||||
|
if (!on) {
|
||||||
|
settingsStore.patch({ notifications: false });
|
||||||
|
return { success: true, output: [out.system('Desktop notifications disabled.')] };
|
||||||
|
}
|
||||||
|
if (!notificationService.isSupported()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.warning('This browser does not support desktop notifications.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// turning it on needs os permission (this runs from a user action).
|
||||||
|
const perm = await notificationService.requestPermission();
|
||||||
|
if (perm !== 'granted') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [
|
||||||
|
out.warning(`Notification permission ${perm}. Enable it in your browser settings.`)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
settingsStore.patch({ notifications: true });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system('Desktop notifications enabled (shown when the tab is in the background).')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'reset': {
|
||||||
|
settingsStore.reset();
|
||||||
|
return { success: true, output: [out.system('Settings reset to defaults.')] };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`Unknown setting "${key}". See /help settings.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseToggle(value: string | undefined): boolean | null {
|
||||||
|
if (value === 'on' || value === 'true' || value === 'reduced') return true;
|
||||||
|
if (value === 'off' || value === 'false' || value === 'normal') return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { activeThemeId } from '$lib/stores/theme-store';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/status` — quick rundown of identity, relays, rooms, and theme. */
|
||||||
|
export const statusCommand: ChatCommand = {
|
||||||
|
name: 'status',
|
||||||
|
description: 'Show connection, identity, and session status.',
|
||||||
|
usage: '/status',
|
||||||
|
async execute() {
|
||||||
|
const relays = Object.values(get(relayStore));
|
||||||
|
const connected = relays.filter((r) => r.connection === 'connected').length;
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const auth = get(authStore);
|
||||||
|
const theme = get(activeThemeId);
|
||||||
|
|
||||||
|
const identity = auth.pubkey
|
||||||
|
? `${auth.kind}${auth.ephemeral ? ' (temporary)' : ''} ${shortenPubkey(auth.pubkey)}`
|
||||||
|
: 'not logged in';
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId]?.name : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice('Status:'),
|
||||||
|
out.system(` identity : ${identity}`),
|
||||||
|
out.system(` relays : ${connected}/${relays.length} connected`),
|
||||||
|
out.system(` rooms : ${Object.keys(rooms).length} joined`),
|
||||||
|
out.system(` active : ${active ? `#${active}` : '(none)'}`),
|
||||||
|
out.system(` theme : ${theme}`)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { dmStore } from '$lib/stores/dm-store';
|
||||||
|
import { parseRoomReference, roomId } from '$lib/nostr/room-address';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/switch <room>` — make an already-joined room the active one. */
|
||||||
|
export const switchCommand: ChatCommand = {
|
||||||
|
name: 'switch',
|
||||||
|
aliases: ['sw'],
|
||||||
|
description: 'Switch the active room to another joined room.',
|
||||||
|
usage: '/switch <room>',
|
||||||
|
async execute(args, context) {
|
||||||
|
const ref = args[0];
|
||||||
|
if (!ref) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /switch <room>')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rooms } = get(roomStore);
|
||||||
|
|
||||||
|
// first try an exact name match (common case: "/switch general").
|
||||||
|
const byName = Object.values(rooms).filter((r) => r.name === ref.replace(/^#/, ''));
|
||||||
|
let targetId: string | undefined;
|
||||||
|
if (byName.length === 1) {
|
||||||
|
targetId = byName[0]!.id;
|
||||||
|
} else {
|
||||||
|
// fall back to resolving a full/qualified reference to a canonical id.
|
||||||
|
try {
|
||||||
|
targetId = roomId(parseRoomReference(ref, context.activeRelay));
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetId || !rooms[targetId]) {
|
||||||
|
if (byName.length > 1) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [
|
||||||
|
out.error(
|
||||||
|
`Ambiguous room "${ref}" exists on multiple relays. Use /switch wss://relay/#${ref.replace(/^#/, '')}.`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, output: [out.error(`Not joined to "${ref}". Use /join first.`)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
dmStore.close(); // drop any focused dm view for a room
|
||||||
|
roomStore.setActive(targetId);
|
||||||
|
return { success: true, output: [out.system(`Now in #${rooms[targetId]!.name}.`)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import type { ThemeInput } from '$lib/themes/theme-types';
|
||||||
|
import { allThemes, isBuiltInTheme } from '$lib/themes/theme-registry';
|
||||||
|
import {
|
||||||
|
setTheme,
|
||||||
|
previewTheme,
|
||||||
|
restoreTheme,
|
||||||
|
addCustomTheme,
|
||||||
|
removeCustomTheme,
|
||||||
|
activeThemeId
|
||||||
|
} from '$lib/stores/theme-store';
|
||||||
|
import { DEFAULT_THEME_ID } from '$lib/config';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/theme` — manage the active color theme.
|
||||||
|
* /theme show current theme + usage
|
||||||
|
* /theme list list all themes (custom marked +)
|
||||||
|
* /theme current show the active theme
|
||||||
|
* /theme set <name> set and save a theme
|
||||||
|
* /theme preview <name> preview without saving
|
||||||
|
* /theme reset back to the default theme
|
||||||
|
* /theme add <json> add a custom theme from a json definition
|
||||||
|
* /theme remove <id> drop a custom theme
|
||||||
|
*/
|
||||||
|
export const themeCommand: ChatCommand = {
|
||||||
|
name: 'theme',
|
||||||
|
description: 'View, change, or add color themes.',
|
||||||
|
usage: '/theme [list|current|set <name>|preview <name>|reset|add <json>|remove <id>]',
|
||||||
|
async execute(args) {
|
||||||
|
const sub = (args[0] ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice(`Current theme: ${get(activeThemeId)}`),
|
||||||
|
out.system(
|
||||||
|
'Usage: /theme list | set <name> | preview <name> | reset | add <json> | remove <id>'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const lines = allThemes().map((t) => {
|
||||||
|
const active = t.id === get(activeThemeId) ? '*' : ' ';
|
||||||
|
const custom = isBuiltInTheme(t.id) ? ' ' : '+';
|
||||||
|
return out.system(
|
||||||
|
` ${active}${custom} ${t.id.padEnd(20)} ${t.mode.padEnd(6)} ${t.description}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.notice('Themes (* active, + custom):'), ...lines]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'current') {
|
||||||
|
return { success: true, output: [out.notice(`Current theme: ${get(activeThemeId)}`)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'reset') {
|
||||||
|
const theme = setTheme(DEFAULT_THEME_ID);
|
||||||
|
return { success: true, output: [out.system(`Theme reset to ${theme.name}.`)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'add') {
|
||||||
|
// everything after "add" is a json theme definition.
|
||||||
|
const json = args.slice(1).join(' ').trim();
|
||||||
|
if (!json) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [
|
||||||
|
out.warning('Usage: /theme add <json>'),
|
||||||
|
out.system('Provide a TerminalTheme object with id, name, and all colors.*')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return { success: false, output: [out.error('Invalid JSON.')] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const theme = await addCustomTheme(parsed as ThemeInput);
|
||||||
|
setTheme(theme.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.system(`Added custom theme "${theme.name}" and applied it.`)]
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Could not add theme.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const id = args[1];
|
||||||
|
if (!id) return { success: false, output: [out.warning('Usage: /theme remove <id>')] };
|
||||||
|
if (isBuiltInTheme(id)) {
|
||||||
|
return { success: false, output: [out.error(`"${id}" is a built-in theme.`)] };
|
||||||
|
}
|
||||||
|
const removed = await removeCustomTheme(id);
|
||||||
|
return removed
|
||||||
|
? { success: true, output: [out.system(`Removed custom theme "${id}".`)] }
|
||||||
|
: { success: false, output: [out.error(`No custom theme "${id}".`)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'set' || sub === 'preview') {
|
||||||
|
const name = args.slice(1).join(' ').trim();
|
||||||
|
if (!name) {
|
||||||
|
return { success: false, output: [out.warning(`Usage: /theme ${sub} <name>`)] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (sub === 'set') {
|
||||||
|
const theme = setTheme(name);
|
||||||
|
return { success: true, output: [out.system(`Theme set to ${theme.name}.`)] };
|
||||||
|
}
|
||||||
|
const theme = previewTheme(name);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.system(
|
||||||
|
`Previewing ${theme.name}. Use /theme set ${theme.id} to keep it, or /theme current to revert.`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
restoreTheme();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : String(err))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`Unknown /theme subcommand: ${sub}. Try /theme list.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/topic` — show the active room's topic.
|
||||||
|
* `/topic <text>` — set it (admin-only; the relay rejects everyone else).
|
||||||
|
*/
|
||||||
|
export const topicCommand: ChatCommand = {
|
||||||
|
name: 'topic',
|
||||||
|
description: "View or set the active room's topic.",
|
||||||
|
usage: '/topic [text]',
|
||||||
|
async execute(args) {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = args.join(' ').trim();
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice(
|
||||||
|
active.topic
|
||||||
|
? `Topic for #${active.name}: ${active.topic}`
|
||||||
|
: `#${active.name} has no topic. Set one with /topic <text>.`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await roomService.setTopic(active.address, text);
|
||||||
|
// the relay re-publishes metadata; the room store updates reactively.
|
||||||
|
return { success: true, output: [out.system(`Topic set for #${active.name}.`)] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Failed to set topic.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { profileService } from '$lib/nostr/profile-service';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/who` — list the active room's participants.
|
||||||
|
* uses the relay's authoritative nip-29 member (39002) and admin (39001) lists
|
||||||
|
* when we have them (admins marked with ★). falls back to authors seen this
|
||||||
|
* session only when the relay hasn't handed over a member list.
|
||||||
|
*/
|
||||||
|
export const whoCommand: ChatCommand = {
|
||||||
|
name: 'who',
|
||||||
|
description: 'List participants in the active room.',
|
||||||
|
usage: '/who',
|
||||||
|
async execute() {
|
||||||
|
const { rooms, activeRoomId } = get(roomStore);
|
||||||
|
const active = activeRoomId ? rooms[activeRoomId] : undefined;
|
||||||
|
if (!active) {
|
||||||
|
return { success: false, output: [out.warning('No active room. Join one with /join.')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = new Set(active.admins ?? []);
|
||||||
|
const members = active.members ?? [];
|
||||||
|
|
||||||
|
const format = (pubkey: string): string => {
|
||||||
|
const marker = admins.has(pubkey) ? '★ ' : ' ';
|
||||||
|
const name = profileService.cachedName(pubkey);
|
||||||
|
return `${marker}${name ? `${name} ` : ''}${shortenPubkey(pubkey)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (members.length > 0) {
|
||||||
|
// admins first, then roughly alphabetical by display label.
|
||||||
|
const sorted = [...members].sort((a, b) => {
|
||||||
|
const aAdmin = admins.has(a);
|
||||||
|
const bAdmin = admins.has(b);
|
||||||
|
if (aAdmin !== bAdmin) return aAdmin ? -1 : 1;
|
||||||
|
return format(a).localeCompare(format(b));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice(`Members of #${active.name} (${members.length}):`),
|
||||||
|
...sorted.map((pk) => out.system(` ${format(pk)}`))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: no relay member list yet — piece it together from messages seen locally.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const authors: string[] = [];
|
||||||
|
const entries = terminalStore.snapshot();
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const e = entries[i];
|
||||||
|
if (!e || e.roomId !== activeRoomId || !e.author) continue;
|
||||||
|
if (seen.has(e.author)) continue;
|
||||||
|
seen.add(e.author);
|
||||||
|
authors.push(e.author);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authors.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [out.notice(`No member list or activity seen yet in #${active.name}.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: [
|
||||||
|
out.notice(`Recently active in #${active.name} (${authors.length}, from local activity):`),
|
||||||
|
...authors.map((pk) => out.system(` ${format(pk)}`))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import { profileService } from '$lib/nostr/profile-service';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { decodeNpub } from '$lib/utils/npub';
|
||||||
|
import { out } from '../output';
|
||||||
|
|
||||||
|
/** `/whois <npub-or-hex>` — look up and show a user's profile details. */
|
||||||
|
export const whoisCommand: ChatCommand = {
|
||||||
|
name: 'whois',
|
||||||
|
description: 'Show profile details for a user.',
|
||||||
|
usage: '/whois <npub-or-hex-pubkey>',
|
||||||
|
async execute(args) {
|
||||||
|
const target = args[0];
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, output: [out.warning('Usage: /whois <npub-or-hex-pubkey>')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(target);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileService.resolve(pubkey);
|
||||||
|
const name = profileService.cachedName(pubkey);
|
||||||
|
const nip05 = profileService.cachedNip05(pubkey);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
out.notice(`whois ${shortenPubkey(pubkey)}`),
|
||||||
|
out.system(` name : ${name ?? '(unknown)'}`)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!nip05) {
|
||||||
|
lines.push(out.system(' nip05 : (none)'));
|
||||||
|
} else {
|
||||||
|
// a claimed nip-05 means nothing until checked against its domain.
|
||||||
|
const status = await profileService.verifyNip05(pubkey);
|
||||||
|
if (status === 'verified') {
|
||||||
|
lines.push(out.system(` nip05 : ${nip05} (✓ verified)`));
|
||||||
|
} else if (status === 'mismatch') {
|
||||||
|
// the domain points the name at a DIFFERENT key — likely impersonation.
|
||||||
|
lines.push(out.warning(` nip05 : ${nip05} (✗ does NOT match this pubkey)`));
|
||||||
|
} else {
|
||||||
|
lines.push(out.system(` nip05 : ${nip05} (unverified — could not confirm)`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(out.system(` pubkey: ${pubkey}`));
|
||||||
|
return { success: true, output: lines };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { CommandRegistry } from './command-registry';
|
||||||
|
import { createHelpCommand } from './commands/help';
|
||||||
|
import { clearCommand } from './commands/clear';
|
||||||
|
import { statusCommand } from './commands/status';
|
||||||
|
import { relaysCommand } from './commands/relays';
|
||||||
|
import { roomsCommand } from './commands/rooms';
|
||||||
|
import { switchCommand } from './commands/switch';
|
||||||
|
import { themeCommand } from './commands/theme';
|
||||||
|
import { loginCommand } from './commands/login';
|
||||||
|
import { logoutCommand } from './commands/logout';
|
||||||
|
import { connectCommand } from './commands/connect';
|
||||||
|
import { disconnectCommand } from './commands/disconnect';
|
||||||
|
import { joinCommand } from './commands/join';
|
||||||
|
import { partCommand } from './commands/part';
|
||||||
|
import { meCommand } from './commands/me';
|
||||||
|
import { whoCommand } from './commands/who';
|
||||||
|
import { whoisCommand } from './commands/whois';
|
||||||
|
import { muteCommand, unmuteCommand } from './commands/mute';
|
||||||
|
import { settingsCommand } from './commands/settings';
|
||||||
|
import { nickCommand } from './commands/nick';
|
||||||
|
import { inviteCommand, kickCommand } from './commands/moderate';
|
||||||
|
import { msgCommand } from './commands/msg';
|
||||||
|
import { dmCommand } from './commands/dm';
|
||||||
|
import { topicCommand } from './commands/topic';
|
||||||
|
import { pluginsCommand } from './commands/plugins';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build a registry with all the mvp commands wired up. finished commands work
|
||||||
|
* now; ones slated for later phases return a clear notice.
|
||||||
|
* `/help` gets a getter for the registry so it can list every command.
|
||||||
|
*/
|
||||||
|
export function createRegistry(): CommandRegistry {
|
||||||
|
const registry = new CommandRegistry();
|
||||||
|
registry.registerAll([
|
||||||
|
createHelpCommand(() => registry),
|
||||||
|
clearCommand,
|
||||||
|
statusCommand,
|
||||||
|
relaysCommand,
|
||||||
|
roomsCommand,
|
||||||
|
switchCommand,
|
||||||
|
themeCommand,
|
||||||
|
// identity & relays (phase 3).
|
||||||
|
loginCommand,
|
||||||
|
logoutCommand,
|
||||||
|
connectCommand,
|
||||||
|
disconnectCommand,
|
||||||
|
// rooms & messaging (phase 4).
|
||||||
|
joinCommand,
|
||||||
|
partCommand,
|
||||||
|
meCommand,
|
||||||
|
whoCommand,
|
||||||
|
whoisCommand,
|
||||||
|
// profile & moderation.
|
||||||
|
nickCommand,
|
||||||
|
inviteCommand,
|
||||||
|
kickCommand,
|
||||||
|
// persistence & preferences (phase 5).
|
||||||
|
muteCommand,
|
||||||
|
unmuteCommand,
|
||||||
|
settingsCommand,
|
||||||
|
// direct messages & room topic.
|
||||||
|
msgCommand,
|
||||||
|
dmCommand,
|
||||||
|
topicCommand,
|
||||||
|
// plugins.
|
||||||
|
pluginsCommand
|
||||||
|
]);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { CommandContext, CommandResult } from '$lib/types/command';
|
||||||
|
import type { TerminalEntry } from '$lib/types/terminal';
|
||||||
|
import { CommandRegistry } from './command-registry';
|
||||||
|
import { parseInput } from './command-parser';
|
||||||
|
import { out } from './output';
|
||||||
|
|
||||||
|
/** deps injected into the dispatcher (keeps it unit-testable). */
|
||||||
|
export interface DispatcherDeps {
|
||||||
|
registry: CommandRegistry;
|
||||||
|
/** snapshot the current context (active room/relay/user) at dispatch time. */
|
||||||
|
getContext: () => CommandContext;
|
||||||
|
/** send plain text (non-command) input to the active room. */
|
||||||
|
sendMessage: (content: string, context: CommandContext) => Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* turns a raw input line into terminal output: classifies it, then either
|
||||||
|
* routes it to the active room or resolves + runs a command.
|
||||||
|
*
|
||||||
|
* errors thrown by a command become a friendly error entry — the dispatcher
|
||||||
|
* never rejects, so the ui can always render a result.
|
||||||
|
*/
|
||||||
|
export class CommandDispatcher {
|
||||||
|
constructor(private readonly deps: DispatcherDeps) {}
|
||||||
|
|
||||||
|
async dispatch(raw: string): Promise<CommandResult> {
|
||||||
|
const parsed = parseInput(raw);
|
||||||
|
const context = this.deps.getContext();
|
||||||
|
|
||||||
|
if (parsed.kind === 'empty') {
|
||||||
|
return { success: true, output: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === 'message') {
|
||||||
|
try {
|
||||||
|
return await this.deps.sendMessage(parsed.content, context);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, output: [out.error(errorMessage(err))] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = this.deps.registry.resolve(parsed.name);
|
||||||
|
if (!command) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: [out.error(`Unknown command: /${parsed.name}. Type /help for a list.`)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await command.execute(parsed.args, context);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, output: [out.error(errorMessage(err))] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** shared helper: spit out a command's usage as a warning entry. */
|
||||||
|
export function usageError(usage: string): TerminalEntry {
|
||||||
|
return out.warning(`Usage: ${usage}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal';
|
||||||
|
import { generateId } from '$lib/utils/id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build a trusted local terminal entry for command output. all command output
|
||||||
|
* is local/system-originated — remote chat content never flows through here.
|
||||||
|
*/
|
||||||
|
export function entry(type: TerminalEntryType, content: string, roomId?: string): TerminalEntry {
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
roomId,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** handy builders for the common command-output entry types. */
|
||||||
|
export const out = {
|
||||||
|
system: (content: string, roomId?: string) => entry('system', content, roomId),
|
||||||
|
notice: (content: string, roomId?: string) => entry('notice', content, roomId),
|
||||||
|
warning: (content: string, roomId?: string) => entry('warning', content, roomId),
|
||||||
|
error: (content: string, roomId?: string) => entry('error', content, roomId)
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* tokenize a command argument string, honoring double and single quotes so
|
||||||
|
* args with spaces can come through as a single token.
|
||||||
|
*
|
||||||
|
* parse('set "Solarized Dark"') -> ['set', 'Solarized Dark']
|
||||||
|
*
|
||||||
|
* an unterminated quote just runs to end-of-input (lenient, so someone
|
||||||
|
* mid-typing never hits a hard error from the tokenizer itself).
|
||||||
|
*/
|
||||||
|
export function tokenize(input: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
let hasToken = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
hasToken = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ' ' || ch === '\t') {
|
||||||
|
if (hasToken) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
hasToken = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
hasToken = true;
|
||||||
|
}
|
||||||
|
if (hasToken) tokens.push(current);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime config pulled from PUBLIC_ env vars.
|
||||||
|
* uses `$env/dynamic/public` so every var is optional — the app runs on sane
|
||||||
|
* defaults with no `.env`. product naming lives in `src/lib/brand.ts` so it can
|
||||||
|
* change without touching protocol or app logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseIntOr(value: string | undefined, fallback: number): number {
|
||||||
|
const n = Number.parseInt(value ?? '', 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// build-time fallback only; runtime /config.json (see runtime-config.ts) wins.
|
||||||
|
export const DEFAULT_RELAYS: string[] = (env.PUBLIC_DEFAULT_RELAYS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((r: string) => r.trim())
|
||||||
|
.filter((r: string) => r.length > 0);
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_ID: string = (env.PUBLIC_DEFAULT_THEME ?? 'nord').trim() || 'nord';
|
||||||
|
|
||||||
|
export const MAX_TERMINAL_ENTRIES: number = parseIntOr(env.PUBLIC_MAX_TERMINAL_ENTRIES, 1000);
|
||||||
|
|
||||||
|
export const MAX_MESSAGE_LENGTH: number = parseIntOr(env.PUBLIC_MAX_MESSAGE_LENGTH, 2000);
|
||||||
|
|
||||||
|
/** how many past commands the input box remembers. */
|
||||||
|
export const MAX_COMMAND_HISTORY = 200;
|
||||||
|
|
||||||
|
/** most cached messages kept per room in indexeddb (older ones get pruned). */
|
||||||
|
export const MAX_CACHED_MESSAGES_PER_ROOM: number = parseIntOr(
|
||||||
|
env.PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
/** how long (ms) a cached profile stays fresh before we refetch. */
|
||||||
|
export const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** how many recent messages per room we replay into the terminal at startup. */
|
||||||
|
export const RESTORE_MESSAGES_PER_ROOM = 50;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import type { AppSettings } from '$lib/types/settings';
|
||||||
|
import type { TerminalTheme } from '$lib/themes/theme-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* saved record shapes. these are storage models — kept plain on purpose and
|
||||||
|
* separate from the runtime store/ndk types. cached events are UNTRUSTED on
|
||||||
|
* load until the nostr lib re-checks them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RelayRecord {
|
||||||
|
/** normalized relay url (primary key). */
|
||||||
|
url: string;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomRecord {
|
||||||
|
/** room id: normalizedRelayUrl#groupId (primary key). */
|
||||||
|
id: string;
|
||||||
|
relayUrl: string;
|
||||||
|
groupId: string;
|
||||||
|
name: string;
|
||||||
|
topic?: string;
|
||||||
|
joinedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageRecord {
|
||||||
|
/** nostr event id (primary key). */
|
||||||
|
id: string;
|
||||||
|
/** room id (indexed for per-room queries). */
|
||||||
|
roomId: string;
|
||||||
|
authorPubkey: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: number;
|
||||||
|
eventKind: number;
|
||||||
|
action: boolean;
|
||||||
|
replyTo?: string;
|
||||||
|
/**
|
||||||
|
* was this event signature-verified WHEN CACHED. just a hint — never shown
|
||||||
|
* as trusted without re-checking on load.
|
||||||
|
*/
|
||||||
|
verifiedWhenCached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadMarkerRecord {
|
||||||
|
/** room id (primary key). */
|
||||||
|
roomId: string;
|
||||||
|
/** createdAt (unix seconds) of the last read message. */
|
||||||
|
lastReadAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutedPubkeyRecord {
|
||||||
|
pubkey: string;
|
||||||
|
mutedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileRecord {
|
||||||
|
pubkey: string;
|
||||||
|
name?: string;
|
||||||
|
nip05?: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingMessageRecord {
|
||||||
|
/** local id (primary key, auto-incremented). */
|
||||||
|
localId?: number;
|
||||||
|
roomId: string;
|
||||||
|
relayUrl: string;
|
||||||
|
groupId: string;
|
||||||
|
content: string;
|
||||||
|
action: boolean;
|
||||||
|
queuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingRecord {
|
||||||
|
/** fixed key; we stash one settings blob under "app". */
|
||||||
|
key: string;
|
||||||
|
value: AppSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a nip-49 encrypted private key the user chose to remember on this device.
|
||||||
|
* we keep ONLY the ncryptsec (passphrase-encrypted) — never a plaintext key.
|
||||||
|
* decrypting needs the user's passphrase and happens in memory at login.
|
||||||
|
*/
|
||||||
|
export interface StoredIdentityRecord {
|
||||||
|
/** fixed key "primary" — the mvp remembers one device identity. */
|
||||||
|
key: string;
|
||||||
|
/** public key (hex) to show before unlock. */
|
||||||
|
pubkey: string;
|
||||||
|
/** nip-49 ncryptsec blob (passphrase-encrypted secret key). */
|
||||||
|
ncryptsec: string;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a user- (or plugin-) defined theme saved for reuse across sessions. */
|
||||||
|
export interface CustomThemeRecord {
|
||||||
|
/** theme id (primary key). */
|
||||||
|
id: string;
|
||||||
|
/** the whole TerminalTheme object, stored as-is. */
|
||||||
|
theme: TerminalTheme;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** plugin-scoped saved state, keyed by "<pluginId>:<key>". */
|
||||||
|
export interface PluginStateRecord {
|
||||||
|
/** composite key `${pluginId}:${key}` (primary key). */
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** handy: rebuild a RoomAddress from a stored room record. */
|
||||||
|
export function recordToAddress(record: RoomRecord): RoomAddress {
|
||||||
|
return { relayUrl: record.relayUrl, groupId: record.groupId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the nosterm indexeddb database. schema is versioned; bump the version and
|
||||||
|
* add a `.stores()` block to run a migration.
|
||||||
|
*/
|
||||||
|
export class NostermDatabase extends Dexie {
|
||||||
|
relays!: Table<RelayRecord, string>;
|
||||||
|
rooms!: Table<RoomRecord, string>;
|
||||||
|
messages!: Table<MessageRecord, string>;
|
||||||
|
readMarkers!: Table<ReadMarkerRecord, string>;
|
||||||
|
mutedPubkeys!: Table<MutedPubkeyRecord, string>;
|
||||||
|
profiles!: Table<ProfileRecord, string>;
|
||||||
|
pendingMessages!: Table<PendingMessageRecord, number>;
|
||||||
|
settings!: Table<SettingRecord, string>;
|
||||||
|
identities!: Table<StoredIdentityRecord, string>;
|
||||||
|
customThemes!: Table<CustomThemeRecord, string>;
|
||||||
|
pluginState!: Table<PluginStateRecord, string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('nosterm');
|
||||||
|
this.version(1).stores({
|
||||||
|
relays: 'url, addedAt',
|
||||||
|
rooms: 'id, relayUrl, joinedAt',
|
||||||
|
// compound index [roomId+createdAt] drives ordered per-room history.
|
||||||
|
messages: 'id, roomId, createdAt, [roomId+createdAt]',
|
||||||
|
readMarkers: 'roomId',
|
||||||
|
mutedPubkeys: 'pubkey, mutedAt',
|
||||||
|
profiles: 'pubkey, fetchedAt',
|
||||||
|
pendingMessages: '++localId, roomId, queuedAt',
|
||||||
|
settings: 'key'
|
||||||
|
});
|
||||||
|
// v2 adds the optional encrypted-identity store (remember-on-device).
|
||||||
|
this.version(2).stores({
|
||||||
|
identities: 'key'
|
||||||
|
});
|
||||||
|
// v3 adds user- / plugin-defined custom themes.
|
||||||
|
this.version(3).stores({
|
||||||
|
customThemes: 'id, savedAt'
|
||||||
|
});
|
||||||
|
// v4 adds plugin-scoped key/value state.
|
||||||
|
this.version(4).stores({
|
||||||
|
pluginState: 'key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbInstance: NostermDatabase | null = null;
|
||||||
|
|
||||||
|
/** lazily-made database singleton (client-only). */
|
||||||
|
export function getDb(): NostermDatabase {
|
||||||
|
if (!dbInstance) dbInstance = new NostermDatabase();
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import {
|
||||||
|
relayRepository,
|
||||||
|
roomRepository,
|
||||||
|
messageRepository,
|
||||||
|
muteRepository,
|
||||||
|
profileRepository,
|
||||||
|
settingsRepository
|
||||||
|
} from './repositories';
|
||||||
|
import { recordToAddress } from './database';
|
||||||
|
import { settingsStore } from '$lib/stores/settings-store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { muteStore } from '$lib/stores/mute-store';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { relayService } from '$lib/nostr/relay-service';
|
||||||
|
import { roomService } from '$lib/nostr/room-service';
|
||||||
|
import { profileService } from '$lib/nostr/profile-service';
|
||||||
|
import { initTheme, setTheme, loadCustomThemes } from '$lib/stores/theme-store';
|
||||||
|
import { resolveTheme } from '$lib/themes/theme-registry';
|
||||||
|
import { RESTORE_MESSAGES_PER_ROOM } from '$lib/config';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loads saved state at startup and keeps indexeddb in sync with the runtime
|
||||||
|
* stores after that. lives outside the stores so they stay framework-pure and
|
||||||
|
* unit-testable without indexeddb.
|
||||||
|
*
|
||||||
|
* heads up (security): cached events are UNTRUSTED on load. we show restored
|
||||||
|
* messages as history, but re-validation happens live when the room's sub
|
||||||
|
* re-delivers events; restored records look exactly like remote chat (never
|
||||||
|
* system lines) and their content is plain text only.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* read a `?theme=<id>` query param and return the matching theme id, or null.
|
||||||
|
* checked against the registry (built-in + custom, alias-aware) so an unknown
|
||||||
|
* or junk value just gets ignored. then we strip the param from the url so a
|
||||||
|
* reload/bookmark doesn't keep clobbering the user's later choice.
|
||||||
|
*/
|
||||||
|
function readThemeParam(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = new URLSearchParams(window.location.search).get('theme');
|
||||||
|
if (!raw) return null;
|
||||||
|
const theme = resolveTheme(raw);
|
||||||
|
if (!theme) return null;
|
||||||
|
// drop the param without a navigation/reload.
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('theme');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
return theme.id;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistenceService {
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
/** load saved settings, mutes, profiles, relays, and rooms. */
|
||||||
|
async restore(): Promise<void> {
|
||||||
|
// 1) settings first, so the theme lands before the ui is interactive.
|
||||||
|
const savedSettings = await settingsRepository.load();
|
||||||
|
if (savedSettings) settingsStore.set(savedSettings);
|
||||||
|
// register saved custom themes before applying, so a custom active theme
|
||||||
|
// resolves instead of falling back to the default.
|
||||||
|
await loadCustomThemes();
|
||||||
|
// a `?theme=<id>` query param (e.g. from the marketing site's "launch
|
||||||
|
// client" link) beats the saved theme so a visitor's pick follows them
|
||||||
|
// into the app. only a known theme wins; anything else is ignored and the
|
||||||
|
// saved/default theme applies as usual.
|
||||||
|
const urlThemeId = readThemeParam();
|
||||||
|
initTheme(get(settingsStore).themeId);
|
||||||
|
if (urlThemeId) setTheme(urlThemeId); // resolve + apply + save
|
||||||
|
|
||||||
|
// 2) muted pubkeys.
|
||||||
|
const muted = await muteRepository.list();
|
||||||
|
muteStore.setAll(muted);
|
||||||
|
|
||||||
|
// 3) cached profiles (so restored/live messages show names right away).
|
||||||
|
for (const profile of await profileRepository.all()) {
|
||||||
|
profileService.hydrate(profile.pubkey, profile.name, profile.nip05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) reconnect to relays we used before.
|
||||||
|
const relays = await relayRepository.list();
|
||||||
|
for (const relay of relays) {
|
||||||
|
void relayService.connect(relay.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) rejoin rooms we were in and restore their recent history.
|
||||||
|
const rooms = await roomRepository.list();
|
||||||
|
for (const room of rooms) {
|
||||||
|
await this.restoreRoom(room.id, room.name);
|
||||||
|
// rejoin re-opens the live sub (which re-validates events).
|
||||||
|
void roomService.join(recordToAddress(room));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relays.length > 0 || rooms.length > 0) {
|
||||||
|
terminalStore.system(
|
||||||
|
`Restored ${relays.length} relay(s) and ${rooms.length} room(s) from your last session.`,
|
||||||
|
'system'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dump a room's cached messages back into the terminal as history. */
|
||||||
|
private async restoreRoom(roomId: string, roomName: string): Promise<void> {
|
||||||
|
const messages = await messageRepository.recent(roomId, RESTORE_MESSAGES_PER_ROOM);
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
terminalStore.system(`--- ${roomName} history (${messages.length}) ---`, 'system', roomId);
|
||||||
|
for (const m of messages) {
|
||||||
|
if (get(muteStore).has(m.authorPubkey)) continue;
|
||||||
|
const name = profileService.cachedName(m.authorPubkey) ?? shortenPubkey(m.authorPubkey);
|
||||||
|
terminalStore.push({
|
||||||
|
type: m.action ? 'action' : 'message',
|
||||||
|
author: m.authorPubkey,
|
||||||
|
authorDisplayName: name,
|
||||||
|
content: m.content,
|
||||||
|
roomId,
|
||||||
|
timestamp: m.createdAt ? m.createdAt * 1000 : Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** start mirroring runtime store changes into indexeddb. idempotent. */
|
||||||
|
private start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
// save settings on every change.
|
||||||
|
settingsStore.subscribe((settings) => {
|
||||||
|
void settingsRepository.save(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// save the relay set (added/removed) reactively.
|
||||||
|
relayStore.subscribe((relays) => {
|
||||||
|
void this.syncRelays(Object.keys(relays));
|
||||||
|
});
|
||||||
|
|
||||||
|
// save joined rooms reactively.
|
||||||
|
roomStore.subscribe((state) => {
|
||||||
|
void this.syncRooms(state.rooms);
|
||||||
|
});
|
||||||
|
|
||||||
|
// save mute list reactively.
|
||||||
|
muteStore.subscribe((set) => {
|
||||||
|
void this.syncMutes([...set]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncRelays(current: string[]): Promise<void> {
|
||||||
|
const stored = new Set((await relayRepository.list()).map((r) => r.url));
|
||||||
|
for (const url of current) if (!stored.has(url)) await relayRepository.add(url);
|
||||||
|
for (const url of stored) if (!current.includes(url)) await relayRepository.remove(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncRooms(
|
||||||
|
rooms: Record<
|
||||||
|
string,
|
||||||
|
{ id: string; address: { relayUrl: string; groupId: string }; name: string; topic?: string }
|
||||||
|
>
|
||||||
|
): Promise<void> {
|
||||||
|
const stored = new Set((await roomRepository.list()).map((r) => r.id));
|
||||||
|
const currentIds = new Set(Object.keys(rooms));
|
||||||
|
for (const room of Object.values(rooms)) {
|
||||||
|
await roomRepository.add(room.address, room.name, room.topic);
|
||||||
|
}
|
||||||
|
for (const id of stored) if (!currentIds.has(id)) await roomRepository.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncMutes(current: string[]): Promise<void> {
|
||||||
|
const stored = new Set(await muteRepository.list());
|
||||||
|
for (const pk of current) if (!stored.has(pk)) await muteRepository.add(pk);
|
||||||
|
for (const pk of stored) if (!current.includes(pk)) await muteRepository.remove(pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const persistenceService = new PersistenceService();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getDb, type CustomThemeRecord } from '../database';
|
||||||
|
import type { TerminalTheme } from '$lib/themes/theme-types';
|
||||||
|
|
||||||
|
/** storage for user-defined / plugin-provided themes. */
|
||||||
|
export const customThemeRepository = {
|
||||||
|
async list(): Promise<TerminalTheme[]> {
|
||||||
|
return (await getDb().customThemes.orderBy('savedAt').toArray()).map((r) => r.theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(theme: TerminalTheme): Promise<void> {
|
||||||
|
const record: CustomThemeRecord = { id: theme.id, theme, savedAt: Date.now() };
|
||||||
|
await getDb().customThemes.put(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await getDb().customThemes.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getDb, type StoredIdentityRecord } from '../database';
|
||||||
|
|
||||||
|
const PRIMARY = 'primary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* storage for a remembered device identity. keeps ONLY the nip-49 ncryptsec
|
||||||
|
* (passphrase-encrypted secret key) — never a plaintext key. the mvp remembers
|
||||||
|
* a single identity under a fixed key.
|
||||||
|
*/
|
||||||
|
export const identityRepository = {
|
||||||
|
async get(): Promise<StoredIdentityRecord | undefined> {
|
||||||
|
return getDb().identities.get(PRIMARY);
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(pubkey: string, ncryptsec: string): Promise<void> {
|
||||||
|
await getDb().identities.put({
|
||||||
|
key: PRIMARY,
|
||||||
|
pubkey,
|
||||||
|
ncryptsec,
|
||||||
|
savedAt: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
await getDb().identities.delete(PRIMARY);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { relayRepository } from './relay-repository';
|
||||||
|
export { roomRepository } from './room-repository';
|
||||||
|
export { messageRepository } from './message-repository';
|
||||||
|
export {
|
||||||
|
readMarkerRepository,
|
||||||
|
muteRepository,
|
||||||
|
profileRepository,
|
||||||
|
settingsRepository
|
||||||
|
} from './preference-repository';
|
||||||
|
export { pendingMessageRepository } from './pending-message-repository';
|
||||||
|
export { identityRepository } from './identity-repository';
|
||||||
|
export { customThemeRepository } from './custom-theme-repository';
|
||||||
|
export { pluginStateRepository } from './plugin-state-repository';
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { getDb, type MessageRecord } from '../database';
|
||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
import { roomId } from '$lib/nostr/room-address';
|
||||||
|
import { MAX_CACHED_MESSAGES_PER_ROOM } from '$lib/config';
|
||||||
|
|
||||||
|
/** storage for cached chat messages, with per-room retention pruning. */
|
||||||
|
export const messageRepository = {
|
||||||
|
/** save a message (idempotent by event id) and prune old ones for its room. */
|
||||||
|
async save(message: ChatMessage): Promise<void> {
|
||||||
|
const id = roomId(message.room);
|
||||||
|
const record: MessageRecord = {
|
||||||
|
id: message.id,
|
||||||
|
roomId: id,
|
||||||
|
authorPubkey: message.authorPubkey,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
eventKind: message.eventKind,
|
||||||
|
action: message.action ?? false,
|
||||||
|
replyTo: message.replyTo,
|
||||||
|
verifiedWhenCached: message.verified
|
||||||
|
};
|
||||||
|
await getDb().messages.put(record);
|
||||||
|
await this.prune(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** newest `limit` messages for a room, in chronological order. */
|
||||||
|
async recent(roomIdValue: string, limit: number): Promise<MessageRecord[]> {
|
||||||
|
const rows = await getDb()
|
||||||
|
.messages.where('[roomId+createdAt]')
|
||||||
|
.between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey])
|
||||||
|
.reverse()
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
return rows.reverse();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** keep only the newest MAX_CACHED_MESSAGES_PER_ROOM messages for a room. */
|
||||||
|
async prune(roomIdValue: string): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const count = await db.messages.where('roomId').equals(roomIdValue).count();
|
||||||
|
if (count <= MAX_CACHED_MESSAGES_PER_ROOM) return;
|
||||||
|
const excess = count - MAX_CACHED_MESSAGES_PER_ROOM;
|
||||||
|
const oldest = await db.messages
|
||||||
|
.where('[roomId+createdAt]')
|
||||||
|
.between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey])
|
||||||
|
.limit(excess)
|
||||||
|
.primaryKeys();
|
||||||
|
await db.messages.bulkDelete(oldest);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** wipe all messages for a room (e.g. on part). */
|
||||||
|
async clearRoom(roomIdValue: string): Promise<void> {
|
||||||
|
await getDb().messages.where('roomId').equals(roomIdValue).delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// dexie key bounds for compound-index range queries.
|
||||||
|
const Dexie_minKey = -Infinity;
|
||||||
|
const Dexie_maxKey = Infinity;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { getDb, type PendingMessageRecord } from '../database';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import { roomId } from '$lib/nostr/room-address';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* storage for outbound messages we couldn't publish (e.g. offline or relay
|
||||||
|
* rejected). lets a later retry/flush pass pick them up.
|
||||||
|
*/
|
||||||
|
export const pendingMessageRepository = {
|
||||||
|
async enqueue(room: RoomAddress, content: string, action: boolean): Promise<number> {
|
||||||
|
return getDb().pendingMessages.add({
|
||||||
|
roomId: roomId(room),
|
||||||
|
relayUrl: room.relayUrl,
|
||||||
|
groupId: room.groupId,
|
||||||
|
content,
|
||||||
|
action,
|
||||||
|
queuedAt: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(): Promise<PendingMessageRecord[]> {
|
||||||
|
return getDb().pendingMessages.orderBy('queuedAt').toArray();
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(localId: number): Promise<void> {
|
||||||
|
await getDb().pendingMessages.delete(localId);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getDb } from '../database';
|
||||||
|
|
||||||
|
/** namespaced key/value storage for plugins ("<pluginId>:<key>"). */
|
||||||
|
export const pluginStateRepository = {
|
||||||
|
async get<T>(pluginId: string, key: string): Promise<T | undefined> {
|
||||||
|
const record = await getDb().pluginState.get(`${pluginId}:${key}`);
|
||||||
|
return record?.value as T | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async set<T>(pluginId: string, key: string, value: T): Promise<void> {
|
||||||
|
await getDb().pluginState.put({ key: `${pluginId}:${key}`, value });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { getDb, type ProfileRecord } from '../database';
|
||||||
|
import type { AppSettings } from '$lib/types/settings';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'app';
|
||||||
|
|
||||||
|
/** storage for read markers, muted pubkeys, profiles, and settings. */
|
||||||
|
|
||||||
|
export const readMarkerRepository = {
|
||||||
|
async set(roomId: string, lastReadAt: number): Promise<void> {
|
||||||
|
await getDb().readMarkers.put({ roomId, lastReadAt });
|
||||||
|
},
|
||||||
|
async get(roomId: string): Promise<number | undefined> {
|
||||||
|
return (await getDb().readMarkers.get(roomId))?.lastReadAt;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const muteRepository = {
|
||||||
|
async list(): Promise<string[]> {
|
||||||
|
return (await getDb().mutedPubkeys.toArray()).map((r) => r.pubkey);
|
||||||
|
},
|
||||||
|
async add(pubkey: string): Promise<void> {
|
||||||
|
await getDb().mutedPubkeys.put({ pubkey, mutedAt: Date.now() });
|
||||||
|
},
|
||||||
|
async remove(pubkey: string): Promise<void> {
|
||||||
|
await getDb().mutedPubkeys.delete(pubkey);
|
||||||
|
},
|
||||||
|
async has(pubkey: string): Promise<boolean> {
|
||||||
|
return (await getDb().mutedPubkeys.get(pubkey)) !== undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const profileRepository = {
|
||||||
|
async get(pubkey: string): Promise<ProfileRecord | undefined> {
|
||||||
|
return getDb().profiles.get(pubkey);
|
||||||
|
},
|
||||||
|
async save(record: ProfileRecord): Promise<void> {
|
||||||
|
await getDb().profiles.put(record);
|
||||||
|
},
|
||||||
|
async all(): Promise<ProfileRecord[]> {
|
||||||
|
return getDb().profiles.toArray();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const settingsRepository = {
|
||||||
|
async load(): Promise<AppSettings | undefined> {
|
||||||
|
return (await getDb().settings.get(SETTINGS_KEY))?.value;
|
||||||
|
},
|
||||||
|
async save(value: AppSettings): Promise<void> {
|
||||||
|
await getDb().settings.put({ key: SETTINGS_KEY, value });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getDb, type RelayRecord } from '../database';
|
||||||
|
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/** storage for the relays the user has added. */
|
||||||
|
export const relayRepository = {
|
||||||
|
async list(): Promise<RelayRecord[]> {
|
||||||
|
return getDb().relays.orderBy('addedAt').toArray();
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(url: string): Promise<void> {
|
||||||
|
const normalized = normalizeRelayUrl(url);
|
||||||
|
await getDb().relays.put({ url: normalized, addedAt: Date.now() });
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(url: string): Promise<void> {
|
||||||
|
await getDb().relays.delete(normalizeRelayUrl(url));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getDb, type RoomRecord } from '../database';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import { roomId } from '$lib/nostr/room-address';
|
||||||
|
|
||||||
|
/** storage for joined rooms. */
|
||||||
|
export const roomRepository = {
|
||||||
|
async list(): Promise<RoomRecord[]> {
|
||||||
|
return getDb().rooms.orderBy('joinedAt').toArray();
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(address: RoomAddress, name: string, topic?: string): Promise<void> {
|
||||||
|
const id = roomId(address);
|
||||||
|
await getDb().rooms.put({
|
||||||
|
id,
|
||||||
|
relayUrl: address.relayUrl,
|
||||||
|
groupId: address.groupId,
|
||||||
|
name,
|
||||||
|
topic,
|
||||||
|
joinedAt: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await getDb().rooms.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { SignerService } from './signer-service';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { identityRepository } from '$lib/db/repositories';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app-facing identity service. wraps SignerService and keeps the auth store in
|
||||||
|
* sync. the command/ui layers call this — never ndk/signer classes.
|
||||||
|
*/
|
||||||
|
class AuthService {
|
||||||
|
private readonly signer = new SignerService();
|
||||||
|
|
||||||
|
hasNip07(): boolean {
|
||||||
|
return SignerService.hasNip07();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginNip07(): Promise<string> {
|
||||||
|
const active = await this.signer.loginNip07();
|
||||||
|
authStore.setNip07(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginBunker(connectionToken: string): Promise<string> {
|
||||||
|
const active = await this.signer.loginBunker(connectionToken);
|
||||||
|
authStore.setBunker(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginNsec(nsecOrHex: string): string {
|
||||||
|
const active = this.signer.loginNsec(nsecOrHex);
|
||||||
|
authStore.setNsec(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const active = this.signer.generateAndLogin();
|
||||||
|
authStore.setNsec(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginEphemeral(): string {
|
||||||
|
const active = this.signer.loginEphemeral();
|
||||||
|
authStore.setEphemeral(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.signer.logout();
|
||||||
|
authStore.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- one-time key material access (for the landing ui only) --------------
|
||||||
|
|
||||||
|
/** the active in-memory key as nsec/npub, for one-time display after generate. */
|
||||||
|
exportKeyPair(): { nsec: string; npub: string } | null {
|
||||||
|
const nsec = this.signer.exportNsec();
|
||||||
|
const npub = this.signer.exportNpub();
|
||||||
|
return nsec && npub ? { nsec, npub } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- remember-on-device (nip-49 encrypted) -------------------------------
|
||||||
|
|
||||||
|
/** encrypt the active in-memory key with a passphrase and stash it locally. */
|
||||||
|
async rememberOnDevice(passphrase: string): Promise<void> {
|
||||||
|
const pubkey = this.signer.current?.pubkey;
|
||||||
|
if (!pubkey) throw new Error('No active in-memory identity to remember.');
|
||||||
|
const ncryptsec = this.signer.exportNcryptsec(passphrase);
|
||||||
|
await identityRepository.save(pubkey, ncryptsec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** is there a remembered (encrypted) identity on this device? */
|
||||||
|
async hasRememberedIdentity(): Promise<boolean> {
|
||||||
|
return (await identityRepository.get()) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** public key (hex) of the remembered identity, for display before unlock. */
|
||||||
|
async rememberedPubkey(): Promise<string | undefined> {
|
||||||
|
return (await identityRepository.get())?.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** unlock and log in with the remembered identity using its passphrase. */
|
||||||
|
async unlockRemembered(passphrase: string): Promise<string> {
|
||||||
|
const record = await identityRepository.get();
|
||||||
|
if (!record) throw new Error('No remembered identity on this device.');
|
||||||
|
const active = this.signer.loginNcryptsec(record.ncryptsec, passphrase);
|
||||||
|
authStore.setNsec(active.pubkey);
|
||||||
|
return active.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** forget the remembered identity (doesn't log out the current session). */
|
||||||
|
async forgetRemembered(): Promise<void> {
|
||||||
|
await identityRepository.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get pubkey(): string | undefined {
|
||||||
|
return this.signer.current?.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
|
||||||
|
/** a relay-published snapshot of a group's admins/members (from 39001/39002). */
|
||||||
|
export type GroupRoster =
|
||||||
|
| {
|
||||||
|
/** an admin (39001) or member (39002) list update. */
|
||||||
|
kind: 'admins' | 'members';
|
||||||
|
/** pubkeys (hex) in the list. */
|
||||||
|
pubkeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** a group metadata (39000) update. */
|
||||||
|
kind: 'metadata';
|
||||||
|
name?: string;
|
||||||
|
/** the group's `about` — shown as the room topic. */
|
||||||
|
about?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** nip-29 moderation actions an admin can do. */
|
||||||
|
export type ModerationAction = 'add' | 'remove';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the app's chat transport. the ui and command layer lean on this interface
|
||||||
|
* only — never on ndk objects directly. NdkChatTransport in prod, easy to fake
|
||||||
|
* in tests.
|
||||||
|
*/
|
||||||
|
export interface ChatTransport {
|
||||||
|
connectRelay(relayUrl: string): Promise<void>;
|
||||||
|
disconnectRelay(relayUrl: string): Promise<void>;
|
||||||
|
joinRoom(room: RoomAddress): Promise<void>;
|
||||||
|
leaveRoom(room: RoomAddress): Promise<void>;
|
||||||
|
/** publish a chat/action message; resolves with the signed event id. */
|
||||||
|
sendRoomMessage(room: RoomAddress, content: string, action?: boolean): Promise<string>;
|
||||||
|
/**
|
||||||
|
* subscribe to a room's messages. the handler fires for each new, deduped,
|
||||||
|
* verified-or-not message (verification status is on the message). returns
|
||||||
|
* an unsub fn that cleans up the relay sub.
|
||||||
|
*/
|
||||||
|
subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void;
|
||||||
|
/**
|
||||||
|
* subscribe to a room's nip-29 metadata + admin/member lists (kinds
|
||||||
|
* 39000/39001/39002). the handler fires on each update. returns an unsub fn.
|
||||||
|
*/
|
||||||
|
subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void;
|
||||||
|
/**
|
||||||
|
* publish a moderation event: add (kind 9000) or remove (kind 9001) a user.
|
||||||
|
* the relay has the final say — it rejects the event if the sender isn't an
|
||||||
|
* admin, which comes back as a thrown error.
|
||||||
|
*/
|
||||||
|
moderateUser(room: RoomAddress, action: ModerationAction, targetPubkey: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* set the group's metadata (kind 9002) — used to change the room topic
|
||||||
|
* (`about`). admin-only; the relay rejects non-admins.
|
||||||
|
*/
|
||||||
|
setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import NDK, { NDKEvent, giftWrap, giftUnwrap, NDKUser } from '@nostr-dev-kit/ndk';
|
||||||
|
import type { NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { dmStore, dmRoomId } from '$lib/stores/dm-store';
|
||||||
|
import { profileService } from './profile-service';
|
||||||
|
import { notificationService } from '$lib/notifications/notification-service';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
import { decodeNpub } from '$lib/utils/npub';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
// nip-17 private dms. a message is an unsigned kind-14 "rumor", gift-wrapped
|
||||||
|
// (nip-59) into a kind-1059 event addressed to the recipient. the wrapper hides
|
||||||
|
// sender, recipient, and content from relays.
|
||||||
|
const KIND_PRIVATE_DM = 14;
|
||||||
|
const KIND_GIFT_WRAP = 1059;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dm service. publishes gift-wrapped dms and subscribes to incoming ones,
|
||||||
|
* decrypting and showing them in the terminal. the command/ui layers call
|
||||||
|
* this — never ndk directly.
|
||||||
|
*/
|
||||||
|
class DmService {
|
||||||
|
private subscription: NDKSubscription | null = null;
|
||||||
|
/** gift-wrap event ids we've already handled, for dedup across reconnects. */
|
||||||
|
private readonly seen = new Set<string>();
|
||||||
|
|
||||||
|
private currentPubkey: string | undefined;
|
||||||
|
|
||||||
|
private get ndk(): NDK {
|
||||||
|
return getNdk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tie dm listening to the auth lifecycle: (re)subscribe when the logged-in
|
||||||
|
* identity changes, tear down on logout. call once at app startup.
|
||||||
|
*/
|
||||||
|
init(): void {
|
||||||
|
authStore.subscribe((auth) => {
|
||||||
|
if (auth.pubkey === this.currentPubkey) return;
|
||||||
|
this.currentPubkey = auth.pubkey;
|
||||||
|
if (auth.pubkey) this.start();
|
||||||
|
else this.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* start listening for incoming dms addressed to the logged-in user. fine to
|
||||||
|
* call over and over; it re-subscribes for the current identity.
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
const me = get(authStore).pubkey;
|
||||||
|
if (!me) return;
|
||||||
|
this.stop();
|
||||||
|
// new identity → dms to the old one aren't ours anymore.
|
||||||
|
this.seen.clear();
|
||||||
|
// gift wraps are addressed to us via a `p` tag; content is opaque.
|
||||||
|
this.subscription = this.ndk.subscribe(
|
||||||
|
{ kinds: [KIND_GIFT_WRAP], '#p': [me] },
|
||||||
|
{ closeOnEose: false }
|
||||||
|
);
|
||||||
|
this.subscription.on('event', (event: NDKEvent) => void this.onGiftWrap(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.subscription?.stop();
|
||||||
|
this.subscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* send a dm to someone (npub or hex). throws on bad input / no signer.
|
||||||
|
* returns the recipient's hex pubkey so callers can focus the convo.
|
||||||
|
*/
|
||||||
|
async send(recipient: string, content: string): Promise<string> {
|
||||||
|
const text = content.trim();
|
||||||
|
if (!text) throw new Error('Message is empty.');
|
||||||
|
if (!this.ndk.signer) throw new Error('You must /login to send a direct message.');
|
||||||
|
|
||||||
|
let pubkey: string;
|
||||||
|
try {
|
||||||
|
pubkey = decodeNpub(recipient);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid recipient — expected an npub or hex pubkey.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientUser = new NDKUser({ pubkey });
|
||||||
|
// build the UNSIGNED rumor (giftWrap signs the wrapper, not the rumor).
|
||||||
|
// created_at and pubkey have to be set — giftWrap serializes the rumor to
|
||||||
|
// compute its id, and ndk rejects a missing/bad created_at.
|
||||||
|
const me = get(authStore).pubkey;
|
||||||
|
const rumor = new NDKEvent(this.ndk);
|
||||||
|
rumor.kind = KIND_PRIVATE_DM;
|
||||||
|
rumor.content = text;
|
||||||
|
rumor.tags = [['p', pubkey]];
|
||||||
|
rumor.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
if (me) rumor.pubkey = me;
|
||||||
|
|
||||||
|
const wrap = await giftWrap(rumor, recipientUser);
|
||||||
|
try {
|
||||||
|
await wrap.publish();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not deliver the DM: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the convo exists, then echo our own outgoing dm locally
|
||||||
|
// (gift wraps to self aren't reliably stored, so we just render it).
|
||||||
|
dmStore.ensure(pubkey);
|
||||||
|
this.render(pubkey, text, true);
|
||||||
|
return pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onGiftWrap(event: NDKEvent): Promise<void> {
|
||||||
|
if (this.seen.has(event.id)) return;
|
||||||
|
this.seen.add(event.id);
|
||||||
|
try {
|
||||||
|
const rumor = await giftUnwrap(event);
|
||||||
|
if (rumor.kind !== KIND_PRIVATE_DM) return;
|
||||||
|
dmStore.noteIncoming(rumor.pubkey);
|
||||||
|
this.render(rumor.pubkey, rumor.content, false);
|
||||||
|
const name = profileService.cachedName(rumor.pubkey) ?? shortenPubkey(rumor.pubkey);
|
||||||
|
notificationService.notifyDm(name, rumor.content);
|
||||||
|
void profileService.resolve(rumor.pubkey);
|
||||||
|
} catch {
|
||||||
|
// can't decrypt (not for us / malformed) — just ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* render a dm into its per-convo view (roomId = dm:<pubkey>). incoming dm
|
||||||
|
* content is REMOTE and shows as remote chat (a "message" entry), never as a
|
||||||
|
* trusted system/notice line — so it can't be mistaken for local output.
|
||||||
|
*/
|
||||||
|
private render(counterpartyPubkey: string, content: string, outgoing: boolean): void {
|
||||||
|
const name = profileService.cachedName(counterpartyPubkey) ?? shortenPubkey(counterpartyPubkey);
|
||||||
|
const me = get(authStore).pubkey;
|
||||||
|
terminalStore.push({
|
||||||
|
type: 'message',
|
||||||
|
author: outgoing ? me : counterpartyPubkey,
|
||||||
|
authorDisplayName: outgoing ? `you → ${name}` : `${name} → you`,
|
||||||
|
content,
|
||||||
|
roomId: dmRoomId(counterpartyPubkey)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dmService = new DmService();
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import { NIP29_KINDS } from './nip29';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the bare-minimum shape of a nostr event we need to map — a structural subset
|
||||||
|
* of NDKEvent, so the mapper is unit-testable without building ndk objects.
|
||||||
|
*/
|
||||||
|
export interface MappableEvent {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
content: string;
|
||||||
|
created_at?: number;
|
||||||
|
kind?: number;
|
||||||
|
tags: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
// action ("/me") encoding. nip-29 has no standard for actions. we must NOT use
|
||||||
|
// the classic ctcp \x01 framing here: control chars serialize ambiguously
|
||||||
|
// across JSON implementations ( vs a raw byte), so NDK's client-side event
|
||||||
|
// id would disagree with the relay's recomputed id and the event gets rejected
|
||||||
|
// as "id is computed incorrectly". so we use a plain, json-safe sentinel that
|
||||||
|
// hashes the same everywhere.
|
||||||
|
const ACTION_PREFIX = '/me ';
|
||||||
|
// still-readable handling of the old ctcp form for any already-stored events.
|
||||||
|
const LEGACY_CTCP = String.fromCharCode(1);
|
||||||
|
const LEGACY_PREFIX = `${LEGACY_CTCP}ACTION `;
|
||||||
|
|
||||||
|
/** grab the first `e` tag as a reply reference (nip-10 style, best-effort). */
|
||||||
|
function replyTarget(tags: string[][]): string | undefined {
|
||||||
|
const e = tags.find((t) => t[0] === 'e');
|
||||||
|
return e?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** spot an action message and return its inner text, or null if it's not one. */
|
||||||
|
function parseAction(content: string): string | null {
|
||||||
|
if (content.startsWith(ACTION_PREFIX)) {
|
||||||
|
return content.slice(ACTION_PREFIX.length);
|
||||||
|
}
|
||||||
|
// legacy ctcp form (\x01ACTION …\x01) — kept readable for cached history.
|
||||||
|
if (content.startsWith(LEGACY_PREFIX)) {
|
||||||
|
const body = content.slice(LEGACY_PREFIX.length);
|
||||||
|
return body.endsWith(LEGACY_CTCP) ? body.slice(0, -1) : body;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* map a nostr event into the app's ChatMessage model.
|
||||||
|
*
|
||||||
|
* `verified` must come from the nostr lib's signature/id check — the caller
|
||||||
|
* passes the result of verifying the event. cached or relayed events are
|
||||||
|
* untrusted until that check passes. content is plain text and is never
|
||||||
|
* treated as html by the ui.
|
||||||
|
*/
|
||||||
|
export function mapEventToMessage(
|
||||||
|
event: MappableEvent,
|
||||||
|
room: RoomAddress,
|
||||||
|
verified: boolean
|
||||||
|
): ChatMessage {
|
||||||
|
const content = event.content ?? '';
|
||||||
|
const actionText = parseAction(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
room,
|
||||||
|
authorPubkey: event.pubkey,
|
||||||
|
content: actionText ?? content,
|
||||||
|
createdAt: event.created_at ?? 0,
|
||||||
|
eventKind: event.kind ?? NIP29_KINDS.chat,
|
||||||
|
replyTo: replyTarget(event.tags),
|
||||||
|
action: actionText !== null,
|
||||||
|
verified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wrap plain text as a `/me` action payload (json-safe, no control chars). */
|
||||||
|
export function encodeAction(text: string): string {
|
||||||
|
return `${ACTION_PREFIX}${text}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import NDK, { NDKEvent, NDKRelaySet, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||||
|
import type { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||||
|
import type { ChatTransport, GroupRoster, ModerationAction } from './chat-transport';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
import { roomId } from './room-address';
|
||||||
|
import {
|
||||||
|
CHAT_KINDS,
|
||||||
|
GROUP_META_KINDS,
|
||||||
|
NIP29_KINDS,
|
||||||
|
groupTag,
|
||||||
|
pubkeysFromTags,
|
||||||
|
tagValue
|
||||||
|
} from './nip29';
|
||||||
|
import { mapEventToMessage, encodeAction } from './event-mapper';
|
||||||
|
import { relayService } from './relay-service';
|
||||||
|
|
||||||
|
interface RoomSub {
|
||||||
|
subscription: NDKSubscription;
|
||||||
|
/** event ids we've already delivered, for cross-sub dedup. */
|
||||||
|
seen: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ndk-backed ChatTransport for nip-29 managed groups.
|
||||||
|
* owns per-room subs and makes sure they get cleaned up on leave/unsub.
|
||||||
|
*/
|
||||||
|
export class NdkChatTransport implements ChatTransport {
|
||||||
|
private readonly subs = new Map<string, RoomSub>();
|
||||||
|
/** separate roster (39001/39002) subs, keyed by room id. */
|
||||||
|
private readonly rosterSubs = new Map<string, NDKSubscription>();
|
||||||
|
|
||||||
|
constructor(private readonly ndk: NDK) {}
|
||||||
|
|
||||||
|
async connectRelay(relayUrl: string): Promise<void> {
|
||||||
|
await relayService.connect(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectRelay(relayUrl: string): Promise<void> {
|
||||||
|
relayService.disconnect(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** publish a nip-29 join request (kind 9021) tagged with the group id. */
|
||||||
|
async joinRoom(room: RoomAddress): Promise<void> {
|
||||||
|
await this.publish(room, NIP29_KINDS.joinRequest, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** publish a nip-29 leave request (kind 9022) and tear the sub down. */
|
||||||
|
async leaveRoom(room: RoomAddress): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.publish(room, NIP29_KINDS.leaveRequest, '');
|
||||||
|
} finally {
|
||||||
|
this.teardown(roomId(room));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRoomMessage(room: RoomAddress, content: string, action = false): Promise<string> {
|
||||||
|
return this.publish(room, NIP29_KINDS.chat, action ? encodeAction(content) : content);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void {
|
||||||
|
const id = roomId(room);
|
||||||
|
// drop any sub already open for this room before opening a fresh one.
|
||||||
|
this.teardown(id);
|
||||||
|
|
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
kinds: CHAT_KINDS,
|
||||||
|
'#h': [room.groupId]
|
||||||
|
};
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet);
|
||||||
|
|
||||||
|
subscription.on('event', (event: NDKEvent) => {
|
||||||
|
// dedup across relays/reconnects by event id.
|
||||||
|
if (seen.has(event.id)) return;
|
||||||
|
seen.add(event.id);
|
||||||
|
|
||||||
|
// every event is untrusted until the lib validates it.
|
||||||
|
// verifySignature also checks the id matches the content hash.
|
||||||
|
const verified = event.verifySignature(false) === true;
|
||||||
|
const message = mapEventToMessage(
|
||||||
|
{
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
created_at: event.created_at,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: event.tags
|
||||||
|
},
|
||||||
|
room,
|
||||||
|
verified
|
||||||
|
);
|
||||||
|
handler(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subs.set(id, { subscription, seen });
|
||||||
|
|
||||||
|
return () => this.teardown(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void {
|
||||||
|
const id = roomId(room);
|
||||||
|
this.teardownRoster(id);
|
||||||
|
|
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||||
|
// addressable lists are tagged by group id via `d`, not `h`.
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
kinds: GROUP_META_KINDS,
|
||||||
|
'#d': [room.groupId]
|
||||||
|
};
|
||||||
|
const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet);
|
||||||
|
|
||||||
|
subscription.on('event', (event: NDKEvent) => {
|
||||||
|
if (event.verifySignature(false) !== true) return;
|
||||||
|
if (event.kind === NIP29_KINDS.admins) {
|
||||||
|
handler({ kind: 'admins', pubkeys: pubkeysFromTags(event.tags) });
|
||||||
|
} else if (event.kind === NIP29_KINDS.members) {
|
||||||
|
handler({ kind: 'members', pubkeys: pubkeysFromTags(event.tags) });
|
||||||
|
} else if (event.kind === NIP29_KINDS.metadata) {
|
||||||
|
handler({
|
||||||
|
kind: 'metadata',
|
||||||
|
name: tagValue(event.tags, 'name'),
|
||||||
|
about: tagValue(event.tags, 'about')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rosterSubs.set(id, subscription);
|
||||||
|
return () => this.teardownRoster(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moderateUser(
|
||||||
|
room: RoomAddress,
|
||||||
|
action: ModerationAction,
|
||||||
|
targetPubkey: string
|
||||||
|
): Promise<void> {
|
||||||
|
const kind = action === 'add' ? NIP29_KINDS.putUser : NIP29_KINDS.removeUser;
|
||||||
|
// the `p` tag names the target; the relay does the admin-only check.
|
||||||
|
await this.publish(room, kind, '', [['p', targetPubkey]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise<void> {
|
||||||
|
const tags: string[][] = [];
|
||||||
|
if (fields.name !== undefined) tags.push(['name', fields.name]);
|
||||||
|
if (fields.about !== undefined) tags.push(['about', fields.about]);
|
||||||
|
// relay does the admin-only check for kind 9002.
|
||||||
|
await this.publish(room, NIP29_KINDS.editMetadata, '', tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build, sign, and publish a nip-29 event with the group `h` tag. returns
|
||||||
|
* the signed event id. on rejection it throws a PublishRejection carrying
|
||||||
|
* that id so the caller can take back an optimistic echo.
|
||||||
|
*/
|
||||||
|
private async publish(
|
||||||
|
room: RoomAddress,
|
||||||
|
kind: number,
|
||||||
|
content: string,
|
||||||
|
extraTags: string[][] = []
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.ndk.signer) {
|
||||||
|
throw new Error('You must /login before sending to a room.');
|
||||||
|
}
|
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk);
|
||||||
|
const event = new NDKEvent(this.ndk);
|
||||||
|
event.kind = kind;
|
||||||
|
event.content = content;
|
||||||
|
event.tags = [groupTag(room.groupId), ...extraTags];
|
||||||
|
await event.sign();
|
||||||
|
// sign() fills event.id; it's the same id ndk optimistically echoes.
|
||||||
|
const eventId = event.id;
|
||||||
|
try {
|
||||||
|
await event.publish(relaySet);
|
||||||
|
} catch (err) {
|
||||||
|
throw new PublishRejection(rejectionMessage(err), eventId);
|
||||||
|
}
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown(id: string): void {
|
||||||
|
const existing = this.subs.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.subscription.stop();
|
||||||
|
this.subs.delete(id);
|
||||||
|
}
|
||||||
|
this.teardownRoster(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownRoster(id: string): void {
|
||||||
|
const existing = this.rosterSubs.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.stop();
|
||||||
|
this.rosterSubs.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clean up all subs (identity switch / teardown). */
|
||||||
|
destroy(): void {
|
||||||
|
for (const id of [...this.subs.keys()]) this.teardown(id);
|
||||||
|
for (const id of [...this.rosterSubs.keys()]) this.teardownRoster(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a publish the relay rejected. carries the (friendly) message plus the signed
|
||||||
|
* event id so the caller can take back an optimistic echo of that event.
|
||||||
|
*/
|
||||||
|
export class PublishRejection extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly eventId: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PublishRejection';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* turn ndk's raw publish failure into something worth showing a user. ndk says
|
||||||
|
* "not enough relays received the event (0 published, 1 required)" when the one
|
||||||
|
* relay rejected the write; the relay's real reason (e.g. a nip-29 "not a
|
||||||
|
* member" message) is usually tacked on, so show that when it's there.
|
||||||
|
*/
|
||||||
|
export function rejectionMessage(err: unknown): string {
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
// ndk tacks on per-relay reasons like "relay rejected: <reason>" or "msg: …".
|
||||||
|
const reason = /(?:rejected:|msg:)\s*(.+)/i.exec(raw)?.[1]?.trim();
|
||||||
|
if (reason && !/^\s*$/.test(reason)) {
|
||||||
|
return `Relay rejected the message: ${reason}`;
|
||||||
|
}
|
||||||
|
if (/0 published/i.test(raw) || /not enough relays/i.test(raw)) {
|
||||||
|
return 'The relay rejected your message. You may not be a member of this room, or the relay is unreachable.';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import NDK, { NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lazily-made singleton ndk instance (client-only). we run relay connections
|
||||||
|
* ourselves via RelayManager, so ndk starts with no explicit relays and the
|
||||||
|
* outbox model off — it's just for signing, nip-11, and owning the relay pool.
|
||||||
|
*/
|
||||||
|
let ndk: NDK | null = null;
|
||||||
|
|
||||||
|
export function getNdk(): NDK {
|
||||||
|
if (!ndk) {
|
||||||
|
ndk = new NDK({
|
||||||
|
explicitRelayUrls: [],
|
||||||
|
enableOutboxModel: false,
|
||||||
|
autoConnectUserRelays: false
|
||||||
|
});
|
||||||
|
// nip-42: when a relay asks for auth and a signer is present, ndk builds
|
||||||
|
// and sends the signed kind-22242 auth event. RelayManager also surfaces
|
||||||
|
// the challenge as a terminal notice.
|
||||||
|
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
|
||||||
|
}
|
||||||
|
return ndk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** test/reset hook: drop the singleton so a fresh one gets made. */
|
||||||
|
export function resetNdk(): void {
|
||||||
|
ndk = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { fetchRelayInformation } from '@nostr-dev-kit/ndk';
|
||||||
|
import type NDK from '@nostr-dev-kit/ndk';
|
||||||
|
import type { NDKRelay } from '@nostr-dev-kit/ndk';
|
||||||
|
import type { RelayConnection, RelayConnectionEvent } from './relay-manager';
|
||||||
|
import type { RelayInformation } from '$lib/types/relay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* adapts an NDKRelay (from the shared pool) to the driver-agnostic
|
||||||
|
* RelayConnection interface the RelayManager leans on. keeping this thin adapter
|
||||||
|
* separate means the manager's logic never imports ndk directly.
|
||||||
|
*/
|
||||||
|
class NdkRelayConnection implements RelayConnection {
|
||||||
|
private readonly relay: NDKRelay;
|
||||||
|
/** ndk runs its own reconnection loop; the manager mustn't add a second. */
|
||||||
|
readonly selfReconnecting = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly url: string,
|
||||||
|
ndk: NDK
|
||||||
|
) {
|
||||||
|
// `getRelay(url, false)` returns (or creates) the pool relay without
|
||||||
|
// eagerly connecting — we call connect() ourselves.
|
||||||
|
this.relay = ndk.pool.getRelay(url, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// reconnect=true lets ndk retry, but our manager owns the higher-level
|
||||||
|
// backoff and loop prevention across the app.
|
||||||
|
await this.relay.connect(undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.relay.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void {
|
||||||
|
// map our vocabulary onto NDKRelay events.
|
||||||
|
const wrapped = (payload?: unknown) =>
|
||||||
|
handler(typeof payload === 'string' ? payload : undefined);
|
||||||
|
this.relay.on(event, wrapped as never);
|
||||||
|
return () => this.relay.off(event, wrapped as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factory used by the app-level RelayManager wiring. */
|
||||||
|
export function createNdkConnection(url: string, ndk: NDK): RelayConnection {
|
||||||
|
return new NdkRelayConnection(url, ndk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nip-11 fetch mapped into the app-level RelayInformation model. */
|
||||||
|
export async function fetchNip11(url: string): Promise<RelayInformation> {
|
||||||
|
const info = await fetchRelayInformation(url);
|
||||||
|
// `features` and `motd` are nosterm extensions ndk's typed document doesn't
|
||||||
|
// know about, so read them defensively off the raw object.
|
||||||
|
const raw = info as unknown as { features?: unknown; motd?: unknown };
|
||||||
|
const features = Array.isArray(raw.features)
|
||||||
|
? raw.features.filter((f): f is string => typeof f === 'string')
|
||||||
|
: undefined;
|
||||||
|
const motd = typeof raw.motd === 'string' ? raw.motd : undefined;
|
||||||
|
return {
|
||||||
|
name: info.name,
|
||||||
|
description: info.description,
|
||||||
|
pubkey: info.pubkey,
|
||||||
|
contact: info.contact,
|
||||||
|
software: info.software,
|
||||||
|
version: info.version,
|
||||||
|
supportedNips: info.supported_nips,
|
||||||
|
features,
|
||||||
|
motd,
|
||||||
|
limitation: info.limitation
|
||||||
|
? {
|
||||||
|
maxMessageLength: info.limitation.max_message_length,
|
||||||
|
maxSubscriptions: info.limitation.max_subscriptions,
|
||||||
|
authRequired: info.limitation.auth_required,
|
||||||
|
paymentRequired: info.limitation.payment_required
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* nip-29 (managed relay-based groups) event kinds and tag helpers.
|
||||||
|
* managed groups: the relay keeps membership/metadata and enforces access.
|
||||||
|
*/
|
||||||
|
export const NIP29_KINDS = {
|
||||||
|
/** chat message posted to a group. */
|
||||||
|
chat: 9,
|
||||||
|
/** threaded reply (we show it like chat for the mvp). */
|
||||||
|
threadReply: 11,
|
||||||
|
/** admin adds a user to a group (kind 9000). */
|
||||||
|
putUser: 9000,
|
||||||
|
/** admin removes a user from a group (kind 9001). */
|
||||||
|
removeUser: 9001,
|
||||||
|
/** admin edits group metadata — name/about (kind 9002). */
|
||||||
|
editMetadata: 9002,
|
||||||
|
/** join request (kind 9021). */
|
||||||
|
joinRequest: 9021,
|
||||||
|
/** leave request (kind 9022). */
|
||||||
|
leaveRequest: 9022,
|
||||||
|
/** group metadata (name/about/picture). */
|
||||||
|
metadata: 39000,
|
||||||
|
/** group admins list. */
|
||||||
|
admins: 39001,
|
||||||
|
/** group members list. */
|
||||||
|
members: 39002
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** addressable nip-29 list kinds the client subscribes to for group state. */
|
||||||
|
export const GROUP_META_KINDS: number[] = [
|
||||||
|
NIP29_KINDS.metadata,
|
||||||
|
NIP29_KINDS.admins,
|
||||||
|
NIP29_KINDS.members
|
||||||
|
];
|
||||||
|
|
||||||
|
/** pull all `p` tag pubkeys out of an event's tags. */
|
||||||
|
export function pubkeysFromTags(tags: string[][]): string[] {
|
||||||
|
return tags.filter((t) => t[0] === 'p' && typeof t[1] === 'string').map((t) => t[1] as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** value of the first tag with the given name, or undefined. */
|
||||||
|
export function tagValue(tags: string[][], name: string): string | undefined {
|
||||||
|
return tags.find((t) => t[0] === name)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** kinds we treat as showable chat messages in a room timeline. */
|
||||||
|
export const CHAT_KINDS: number[] = [NIP29_KINDS.chat, NIP29_KINDS.threadReply];
|
||||||
|
|
||||||
|
/** the `h` tag carries the group id on every nip-29 event. */
|
||||||
|
export function groupTag(groupId: string): string[] {
|
||||||
|
return ['h', groupId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pull the group id out of an event's tags, if it's there. */
|
||||||
|
export function groupIdFromTags(tags: string[][]): string | undefined {
|
||||||
|
const h = tags.find((t) => t[0] === 'h');
|
||||||
|
return h?.[1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import { profileRepository } from '$lib/db/repositories';
|
||||||
|
import { profileStore } from '$lib/stores/profile-store';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
|
||||||
|
interface CachedProfile {
|
||||||
|
name?: string;
|
||||||
|
nip05?: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolves and caches display names / nip-05 ids for pubkeys. names and nip-05
|
||||||
|
* are NOT treated as globally unique — callers still tell people apart with
|
||||||
|
* shortened pubkeys. profile metadata never drives styling.
|
||||||
|
*/
|
||||||
|
class ProfileService {
|
||||||
|
/** how long a name-less (negative) cache entry is trusted before we retry. */
|
||||||
|
private static readonly NEGATIVE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
private readonly cache = new Map<string, CachedProfile>();
|
||||||
|
private readonly inflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
private currentPubkey: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* watch the auth lifecycle so the logged-in user's own name resolves the
|
||||||
|
* moment they sign in (no room roster needed). call once at app startup.
|
||||||
|
*/
|
||||||
|
init(): void {
|
||||||
|
authStore.subscribe((auth) => {
|
||||||
|
if (auth.pubkey === this.currentPubkey) return;
|
||||||
|
this.currentPubkey = auth.pubkey;
|
||||||
|
if (auth.pubkey) void this.resolve(auth.pubkey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** grab a cached display name synchronously, if we have one. */
|
||||||
|
cachedName(pubkey: string): string | undefined {
|
||||||
|
return this.cache.get(pubkey)?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cached nip-05 id, if any. */
|
||||||
|
cachedNip05(pubkey: string): string | undefined {
|
||||||
|
return this.cache.get(pubkey)?.nip05;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check a pubkey's claimed nip-05 against the domain's .well-known/nostr.json.
|
||||||
|
* returns:
|
||||||
|
* 'verified' — the domain maps the name to this exact pubkey,
|
||||||
|
* 'mismatch' — the domain maps it to a DIFFERENT pubkey (impersonation),
|
||||||
|
* 'unverified'— no nip-05 claimed, not found, or network hiccup (can't tell).
|
||||||
|
* a nip-05 is never proof of identity unless this returns 'verified'.
|
||||||
|
*/
|
||||||
|
async verifyNip05(pubkey: string): Promise<'verified' | 'mismatch' | 'unverified'> {
|
||||||
|
const nip05 = this.cachedNip05(pubkey);
|
||||||
|
if (!nip05) return 'unverified';
|
||||||
|
try {
|
||||||
|
const user = getNdk().getUser({ pubkey });
|
||||||
|
const result = await user.validateNip05(nip05);
|
||||||
|
if (result === true) return 'verified';
|
||||||
|
if (result === false) return 'mismatch';
|
||||||
|
return 'unverified';
|
||||||
|
} catch {
|
||||||
|
return 'unverified';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** seed the in-memory cache from storage (no network fetch). */
|
||||||
|
hydrate(pubkey: string, name?: string, nip05?: string): void {
|
||||||
|
if (!this.cache.has(pubkey)) {
|
||||||
|
this.cache.set(pubkey, { name, nip05, fetchedAt: Date.now() });
|
||||||
|
if (name) profileStore.setName(pubkey, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** force-set a cached display name (e.g. after publishing our own profile). */
|
||||||
|
setName(pubkey: string, name: string): void {
|
||||||
|
const existing = this.cache.get(pubkey);
|
||||||
|
this.cache.set(pubkey, { ...existing, name, fetchedAt: Date.now() });
|
||||||
|
profileStore.setName(pubkey, name);
|
||||||
|
void profileRepository.save({ pubkey, name, nip05: existing?.nip05, fetchedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* publish the logged-in user's display name (nip-01 kind 0). needs an active
|
||||||
|
* signer. merges with any existing profile fields, updates the local cache
|
||||||
|
* right away, and saves it.
|
||||||
|
*/
|
||||||
|
async publishName(name: string): Promise<string> {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) throw new Error('Enter a display name.');
|
||||||
|
if (trimmed.length > 64) throw new Error('Display name must be 64 characters or fewer.');
|
||||||
|
|
||||||
|
const ndk = getNdk();
|
||||||
|
if (!ndk.signer) throw new Error('You must /login before setting a profile name.');
|
||||||
|
|
||||||
|
const user = await ndk.signer.user();
|
||||||
|
// merge onto any existing profile so we don't stomp other fields.
|
||||||
|
try {
|
||||||
|
await user.fetchProfile();
|
||||||
|
} catch {
|
||||||
|
/* first-time profile; start fresh */
|
||||||
|
}
|
||||||
|
user.profile = { ...(user.profile ?? {}), name: trimmed, displayName: trimmed };
|
||||||
|
await user.publish();
|
||||||
|
|
||||||
|
this.setName(user.pubkey, trimmed);
|
||||||
|
return user.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch a profile once; later calls dedupe/no-op while cached. an entry that
|
||||||
|
* came back with *no name* (profile not found, or fetched before any relay
|
||||||
|
* connected) gets retried after a cooldown so a name that shows up later —
|
||||||
|
* or once relays connect — still lands.
|
||||||
|
*/
|
||||||
|
async resolve(pubkey: string): Promise<void> {
|
||||||
|
if (this.inflight.has(pubkey)) return;
|
||||||
|
const cached = this.cache.get(pubkey);
|
||||||
|
if (cached?.name) return; // got a name already — nothing to do.
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < ProfileService.NEGATIVE_TTL_MS) return;
|
||||||
|
const task = this.fetch(pubkey);
|
||||||
|
this.inflight.set(pubkey, task);
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} finally {
|
||||||
|
this.inflight.delete(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(pubkey: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = getNdk().getUser({ pubkey });
|
||||||
|
const profile = await user.fetchProfile();
|
||||||
|
const entry: CachedProfile = {
|
||||||
|
name: profile?.displayName || profile?.name || undefined,
|
||||||
|
nip05: profile?.nip05,
|
||||||
|
fetchedAt: Date.now()
|
||||||
|
};
|
||||||
|
this.cache.set(pubkey, entry);
|
||||||
|
if (entry.name) profileStore.setName(pubkey, entry.name);
|
||||||
|
// save resolved profiles so they show fast next session.
|
||||||
|
void profileRepository.save({
|
||||||
|
pubkey,
|
||||||
|
name: entry.name,
|
||||||
|
nip05: entry.nip05,
|
||||||
|
fetchedAt: entry.fetchedAt
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// cache a negative result briefly so we don't hammer on failure.
|
||||||
|
this.cache.set(pubkey, { fetchedAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileService = new ProfileService();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import type { RelayInformation } from '$lib/types/relay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nosterm relay capability handshake (client side).
|
||||||
|
*
|
||||||
|
* a `nosterm-relay` advertises a `features` array in its nip-11 doc. the ui
|
||||||
|
* feature-gates on these so it can light up extras on a home relay while falling
|
||||||
|
* back gracefully to plain nostr relays (which leave them out).
|
||||||
|
*
|
||||||
|
* known feature flags (extend as the relay implements them):
|
||||||
|
* channels · presence · typing · moderation · search · bots
|
||||||
|
*/
|
||||||
|
export type RelayFeature = 'channels' | 'presence' | 'typing' | 'moderation' | 'search' | 'bots';
|
||||||
|
|
||||||
|
/** true if the relay calls itself a nosterm-relay build. */
|
||||||
|
export function isNostermRelay(info: RelayInformation | undefined): boolean {
|
||||||
|
return info?.software === 'nosterm-relay';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** does a specific relay (by normalized url) advertise a feature? */
|
||||||
|
export function relayHasFeature(url: string, feature: RelayFeature): boolean {
|
||||||
|
const state = get(relayStore)[url];
|
||||||
|
return state?.info?.features?.includes(feature) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** all features a relay advertises, or [] if none/plain relay. */
|
||||||
|
export function relayFeatures(url: string): string[] {
|
||||||
|
return get(relayStore)[url]?.info?.features ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { getRelayListForUser } from '@nostr-dev-kit/ndk';
|
||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nip-65 (kind 10002) relay-list discovery. grabs a user's advertised relays so
|
||||||
|
* the client can connect to their "outbox" — the relays they actually read from
|
||||||
|
* and write to — instead of leaning on defaults alone.
|
||||||
|
*/
|
||||||
|
class RelayListService {
|
||||||
|
/**
|
||||||
|
* fetch a user's advertised relay urls (normalized, ws/wss only).
|
||||||
|
* returns [] if they have no list or it can't be fetched.
|
||||||
|
*/
|
||||||
|
async fetch(pubkey: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const list = await getRelayListForUser(pubkey, getNdk());
|
||||||
|
const urls = list?.relays ?? [];
|
||||||
|
// normalize + validate; drop anything unusable, dedupe.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of urls) {
|
||||||
|
if (!isValidRelayUrl(raw)) continue;
|
||||||
|
seen.add(normalizeRelayUrl(raw));
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const relayListService = new RelayListService();
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import type { RelayConnectionState, RelayInformation } from '$lib/types/relay';
|
||||||
|
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* one relay connection as the manager sees it. an ndk-backed adapter in prod,
|
||||||
|
* fakes in tests — so the manager's state machine and backoff logic can be
|
||||||
|
* tested without real sockets.
|
||||||
|
*/
|
||||||
|
export interface RelayConnection {
|
||||||
|
readonly url: string;
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
/** register a listener; hands back an unsub fn. */
|
||||||
|
on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void;
|
||||||
|
/**
|
||||||
|
* true if the driver runs its own reconnect loop (e.g. ndk). when set, the
|
||||||
|
* manager tracks state but skips its own backoff, so the two loops don't
|
||||||
|
* fight. defaults to false.
|
||||||
|
*/
|
||||||
|
readonly selfReconnecting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayConnectionEvent =
|
||||||
|
'connect' | 'disconnect' | 'auth' | 'authed' | 'auth:failed' | 'notice';
|
||||||
|
|
||||||
|
/** injected side-effects, so the manager stays pure and testable. */
|
||||||
|
export interface RelayManagerDeps {
|
||||||
|
/** make (or find) a driver connection for a normalized url. */
|
||||||
|
createConnection: (url: string) => RelayConnection;
|
||||||
|
/** grab nip-11 info; may reject if unsupported. */
|
||||||
|
fetchInfo: (url: string) => Promise<RelayInformation>;
|
||||||
|
/** fire an app event (state change, notice, auth, error). */
|
||||||
|
emit: (event: RelayManagerEvent) => void;
|
||||||
|
/** run a callback after `ms`; returns a cancel handle. swappable for tests. */
|
||||||
|
setTimer?: (cb: () => void, ms: number) => number;
|
||||||
|
clearTimer?: (handle: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayManagerEvent =
|
||||||
|
| { type: 'state'; url: string; state: RelayConnectionState; error?: string }
|
||||||
|
| { type: 'info'; url: string; info: RelayInformation }
|
||||||
|
| { type: 'auth'; url: string; challenge?: string }
|
||||||
|
| { type: 'authed'; url: string }
|
||||||
|
| { type: 'auth:failed'; url: string; error?: string }
|
||||||
|
| { type: 'notice'; url: string; message: string }
|
||||||
|
// fires when a relay comes back after having been connected before, so
|
||||||
|
// consumers can re-sub and backfill whatever they missed during the outage.
|
||||||
|
| { type: 'reconnected'; url: string };
|
||||||
|
|
||||||
|
interface ManagedRelay {
|
||||||
|
url: string;
|
||||||
|
connection: RelayConnection;
|
||||||
|
state: RelayConnectionState;
|
||||||
|
reconnectAttempts: number;
|
||||||
|
/** pending backoff timer handle, if a reconnect is queued up. */
|
||||||
|
reconnectTimer?: number;
|
||||||
|
/** true once the user hit disconnect on purpose — kills auto-reconnect. */
|
||||||
|
intentionalDisconnect: boolean;
|
||||||
|
/** true once this relay has connected at least once (tells an unexpected
|
||||||
|
* drop-then-reconnect apart from a first-time connect). */
|
||||||
|
everConnected: boolean;
|
||||||
|
/** listener unsub fns, cleaned up on removal. */
|
||||||
|
unsubscribers: (() => void)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 8;
|
||||||
|
const BASE_BACKOFF_MS = 1000;
|
||||||
|
const MAX_BACKOFF_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* owns the relay connection lifecycle: normalization, dedup/reuse, state
|
||||||
|
* tracking, exponential backoff that won't loop forever, nip-11 fetching, and
|
||||||
|
* tidy unsubscribing. never touches raw websockets — that's the driver's job.
|
||||||
|
*/
|
||||||
|
export class RelayManager {
|
||||||
|
private readonly relays = new Map<string, ManagedRelay>();
|
||||||
|
private readonly setTimer: (cb: () => void, ms: number) => number;
|
||||||
|
private readonly clearTimer: (handle: number) => void;
|
||||||
|
|
||||||
|
constructor(private readonly deps: RelayManagerDeps) {
|
||||||
|
this.setTimer = deps.setTimer ?? ((cb, ms) => setTimeout(cb, ms) as unknown as number);
|
||||||
|
this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** urls of every managed relay (normalized). */
|
||||||
|
list(): string[] {
|
||||||
|
return [...this.relays.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
stateOf(url: string): RelayConnectionState | undefined {
|
||||||
|
return this.relays.get(this.key(url))?.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* connect to a relay. reuses an existing connection unless it errored or
|
||||||
|
* disconnected. returns right after kicking off the async connect.
|
||||||
|
*/
|
||||||
|
async connect(rawUrl: string): Promise<void> {
|
||||||
|
const url = this.key(rawUrl);
|
||||||
|
const existing = this.relays.get(url);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// reuse: don't double up an in-flight or live connection.
|
||||||
|
if (existing.state === 'connecting' || existing.state === 'connected') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.intentionalDisconnect = false;
|
||||||
|
await this.openConnection(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = this.deps.createConnection(url);
|
||||||
|
const managed: ManagedRelay = {
|
||||||
|
url,
|
||||||
|
connection,
|
||||||
|
state: 'disconnected',
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
intentionalDisconnect: false,
|
||||||
|
everConnected: false,
|
||||||
|
unsubscribers: []
|
||||||
|
};
|
||||||
|
this.relays.set(url, managed);
|
||||||
|
this.wireEvents(managed);
|
||||||
|
await this.openConnection(managed);
|
||||||
|
// nip-11 is best-effort — must not block or sink the connection.
|
||||||
|
void this.loadInfo(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** disconnect on purpose and stop auto-reconnect for this relay. */
|
||||||
|
disconnect(rawUrl: string): void {
|
||||||
|
const url = this.key(rawUrl);
|
||||||
|
const managed = this.relays.get(url);
|
||||||
|
if (!managed) return;
|
||||||
|
managed.intentionalDisconnect = true;
|
||||||
|
this.cancelReconnect(managed);
|
||||||
|
managed.connection.disconnect();
|
||||||
|
this.setState(managed, 'disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** drop a relay entirely, cleaning up all listeners and timers. */
|
||||||
|
remove(rawUrl: string): void {
|
||||||
|
const url = this.key(rawUrl);
|
||||||
|
const managed = this.relays.get(url);
|
||||||
|
if (!managed) return;
|
||||||
|
managed.intentionalDisconnect = true;
|
||||||
|
this.cancelReconnect(managed);
|
||||||
|
managed.connection.disconnect();
|
||||||
|
for (const off of managed.unsubscribers) off();
|
||||||
|
managed.unsubscribers = [];
|
||||||
|
this.relays.delete(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clean up every relay (e.g. on identity switch or teardown). */
|
||||||
|
destroy(): void {
|
||||||
|
for (const url of this.list()) this.remove(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openConnection(managed: ManagedRelay): Promise<void> {
|
||||||
|
this.setState(managed, 'connecting');
|
||||||
|
try {
|
||||||
|
await managed.connection.connect();
|
||||||
|
// the driver's 'connect' event is the source of truth for 'connected';
|
||||||
|
// if it never fires, state sits at 'connecting' until timeout/close.
|
||||||
|
} catch (err) {
|
||||||
|
this.setState(managed, 'error', this.friendlyError(err));
|
||||||
|
if (!managed.connection.selfReconnecting) this.scheduleReconnect(managed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private wireEvents(managed: ManagedRelay): void {
|
||||||
|
const { connection } = managed;
|
||||||
|
managed.unsubscribers.push(
|
||||||
|
connection.on('connect', () => {
|
||||||
|
const wasReconnecting = managed.everConnected;
|
||||||
|
managed.reconnectAttempts = 0;
|
||||||
|
managed.everConnected = true;
|
||||||
|
this.cancelReconnect(managed);
|
||||||
|
this.setState(managed, 'connected');
|
||||||
|
// back after a previous connection → let consumers backfill.
|
||||||
|
if (wasReconnecting) this.deps.emit({ type: 'reconnected', url: managed.url });
|
||||||
|
}),
|
||||||
|
connection.on('disconnect', () => {
|
||||||
|
if (managed.intentionalDisconnect) {
|
||||||
|
this.setState(managed, 'disconnected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// unexpected drop: a reconnect is on the way (the driver's own loop
|
||||||
|
// or our scheduled backoff), so show "reconnecting" instead of a bare
|
||||||
|
// "disconnected" that looks final.
|
||||||
|
this.setState(managed, 'reconnecting');
|
||||||
|
if (!connection.selfReconnecting) this.scheduleReconnect(managed);
|
||||||
|
}),
|
||||||
|
connection.on('auth', (challenge) => {
|
||||||
|
// nip-42: the signer policy does the crypto; we just show a notice.
|
||||||
|
this.deps.emit({ type: 'auth', url: managed.url, challenge });
|
||||||
|
}),
|
||||||
|
connection.on('authed', () => {
|
||||||
|
this.deps.emit({ type: 'authed', url: managed.url });
|
||||||
|
}),
|
||||||
|
connection.on('auth:failed', (error) => {
|
||||||
|
this.deps.emit({ type: 'auth:failed', url: managed.url, error });
|
||||||
|
}),
|
||||||
|
connection.on('notice', (message) => {
|
||||||
|
this.deps.emit({ type: 'notice', url: managed.url, message: message ?? '' });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** exponential backoff with jitter and a hard attempt cap so it can't loop forever. */
|
||||||
|
private scheduleReconnect(managed: ManagedRelay): void {
|
||||||
|
if (managed.intentionalDisconnect) return;
|
||||||
|
if (managed.reconnectTimer !== undefined) return; // already queued
|
||||||
|
if (managed.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
this.setState(
|
||||||
|
managed,
|
||||||
|
'error',
|
||||||
|
`Giving up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = managed.reconnectAttempts;
|
||||||
|
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||||
|
managed.reconnectAttempts += 1;
|
||||||
|
|
||||||
|
managed.reconnectTimer = this.setTimer(() => {
|
||||||
|
managed.reconnectTimer = undefined;
|
||||||
|
if (managed.intentionalDisconnect) return;
|
||||||
|
void this.openConnection(managed);
|
||||||
|
}, backoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelReconnect(managed: ManagedRelay): void {
|
||||||
|
if (managed.reconnectTimer !== undefined) {
|
||||||
|
this.clearTimer(managed.reconnectTimer);
|
||||||
|
managed.reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInfo(url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const info = await this.deps.fetchInfo(url);
|
||||||
|
this.deps.emit({ type: 'info', url, info });
|
||||||
|
} catch {
|
||||||
|
// nip-11 is optional; just skip. supportsNip29 stays undefined.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(managed: ManagedRelay, state: RelayConnectionState, error?: string): void {
|
||||||
|
if (managed.state === state && !error) return;
|
||||||
|
managed.state = state;
|
||||||
|
this.deps.emit({ type: 'state', url: managed.url, state, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(rawUrl: string): string {
|
||||||
|
return normalizeRelayUrl(rawUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private friendlyError(err: unknown): string {
|
||||||
|
// never show raw websocket errors; keep the techy bits short.
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return 'Connection failed.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { RelayManager } from './relay-manager';
|
||||||
|
import type { RelayManagerEvent } from './relay-manager';
|
||||||
|
import { createNdkConnection, fetchNip11 } from './ndk-relay-driver';
|
||||||
|
import { relayListService } from './relay-list-service';
|
||||||
|
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { relayRoomId } from '$lib/stores/server-store';
|
||||||
|
import type { RelayInformation } from '$lib/types/relay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the app's relay service. owns one RelayManager wired to the ndk driver and
|
||||||
|
* turns manager events into store updates and trusted terminal notices. the
|
||||||
|
* command layer calls this — never ndk directly.
|
||||||
|
*/
|
||||||
|
class RelayService {
|
||||||
|
private manager: RelayManager | null = null;
|
||||||
|
/** callbacks fired when a relay reconnects after an outage (normalized url). */
|
||||||
|
private readonly reconnectHandlers = new Set<(url: string) => void>();
|
||||||
|
|
||||||
|
/** subscribe to relay-reconnect events; hands back an unsub fn. */
|
||||||
|
onReconnect(handler: (url: string) => void): () => void {
|
||||||
|
this.reconnectHandlers.add(handler);
|
||||||
|
return () => this.reconnectHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(): RelayManager {
|
||||||
|
if (!this.manager) {
|
||||||
|
const ndk = getNdk();
|
||||||
|
this.manager = new RelayManager({
|
||||||
|
createConnection: (url) => createNdkConnection(url, ndk),
|
||||||
|
fetchInfo: (url) => fetchNip11(url),
|
||||||
|
emit: (event) => this.onEvent(event)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(url: string): Promise<void> {
|
||||||
|
await this.ensure().connect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(url: string): void {
|
||||||
|
this.ensure().disconnect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): string[] {
|
||||||
|
return this.ensure().list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nip-65 outbox discovery: grab a user's advertised relay list and connect
|
||||||
|
* to any relays we're not on yet. returns how many were newly connected.
|
||||||
|
* best-effort — shows progress as terminal notices.
|
||||||
|
*/
|
||||||
|
async discoverAndConnect(pubkey: string): Promise<number> {
|
||||||
|
const discovered = await relayListService.fetch(pubkey);
|
||||||
|
if (discovered.length === 0) {
|
||||||
|
terminalStore.system('No NIP-65 relay list found for this identity.', 'system');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const known = new Set(this.list());
|
||||||
|
const toAdd = discovered.filter((url) => !known.has(url));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
terminalStore.system(
|
||||||
|
`NIP-65: already connected to all ${discovered.length} advertised relay(s).`,
|
||||||
|
'system'
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
terminalStore.system(`NIP-65: connecting to ${toAdd.length} advertised relay(s)…`, 'notice');
|
||||||
|
for (const url of toAdd) void this.connect(url);
|
||||||
|
return toAdd.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEvent(event: RelayManagerEvent): void {
|
||||||
|
// all relay-lifecycle output goes to that relay's server window
|
||||||
|
// (irc-style server buffer), not the global one.
|
||||||
|
const scope = relayRoomId(event.url);
|
||||||
|
switch (event.type) {
|
||||||
|
case 'state': {
|
||||||
|
relayStore.setState(event.url, event.state, event.error);
|
||||||
|
pluginHost.emit('relayState', event.url, event.state);
|
||||||
|
if (event.state === 'connected') {
|
||||||
|
terminalStore.system(`Connected to ${event.url}`, 'notice', scope);
|
||||||
|
} else if (event.state === 'error') {
|
||||||
|
terminalStore.system(
|
||||||
|
`Relay ${event.url}: ${event.error ?? 'connection error'}`,
|
||||||
|
'error',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
} else if (event.state === 'reconnecting') {
|
||||||
|
terminalStore.system(
|
||||||
|
`Connection to ${event.url} dropped — reconnecting…`,
|
||||||
|
'warning',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
} else if (event.state === 'disconnected') {
|
||||||
|
terminalStore.system(`Disconnected from ${event.url}`, 'system', scope);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reconnected': {
|
||||||
|
terminalStore.system(`Reconnected to ${event.url} — syncing…`, 'notice', scope);
|
||||||
|
for (const handler of this.reconnectHandlers) handler(event.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'info': {
|
||||||
|
relayStore.setInfo(event.url, event.info);
|
||||||
|
this.renderMotd(event.url, event.info, scope);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'auth': {
|
||||||
|
terminalStore.system(
|
||||||
|
`Relay ${event.url} requested authentication (NIP-42).`,
|
||||||
|
'notice',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
// the signIn policy signs with the active signer; without one, auth
|
||||||
|
// can't finish — tell the user why so it's not a silent flop.
|
||||||
|
if (!getNdk().signer) {
|
||||||
|
terminalStore.system(
|
||||||
|
`Log in (/login) to authenticate with ${event.url}.`,
|
||||||
|
'warning',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'authed': {
|
||||||
|
terminalStore.system(`Authenticated with ${event.url} (NIP-42).`, 'notice', scope);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'auth:failed': {
|
||||||
|
terminalStore.system(
|
||||||
|
`Authentication with ${event.url} failed${event.error ? `: ${event.error}` : ''}.`,
|
||||||
|
'error',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'notice': {
|
||||||
|
terminalStore.system(`Relay ${event.url}: ${event.message}`, 'notice', scope);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dump an irc-style message-of-the-day into a relay's server window from its
|
||||||
|
* nip-11 doc: a header, the relay's motd (or description as a fallback),
|
||||||
|
* supported nips, and the nosterm-relay capability handshake.
|
||||||
|
*/
|
||||||
|
private renderMotd(url: string, info: RelayInformation, scope: string): void {
|
||||||
|
const name = info.name?.trim() || url;
|
||||||
|
terminalStore.system(`── ${name} ──`, 'notice', scope);
|
||||||
|
|
||||||
|
// the motd itself: an explicit `motd`, else the nip-11 description.
|
||||||
|
const motd = info.motd?.trim() || info.description?.trim();
|
||||||
|
if (motd) {
|
||||||
|
for (const line of motd.split('\n')) {
|
||||||
|
terminalStore.system(line, 'system', scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.software) {
|
||||||
|
const version = info.version ? ` ${info.version}` : '';
|
||||||
|
terminalStore.system(`software: ${info.software}${version}`, 'system', scope);
|
||||||
|
}
|
||||||
|
if (info.contact) {
|
||||||
|
terminalStore.system(`contact: ${info.contact}`, 'system', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nips = info.supportedNips;
|
||||||
|
if (nips && nips.length > 0) {
|
||||||
|
terminalStore.system(`supported NIPs: ${nips.join(', ')}`, 'system', scope);
|
||||||
|
}
|
||||||
|
if (nips && !nips.includes(29)) {
|
||||||
|
terminalStore.system('This relay does not advertise NIP-29 group support.', 'warning', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nosterm capability handshake: announce a home relay's features.
|
||||||
|
if (info.software === 'nosterm-relay') {
|
||||||
|
const features = info.features ?? [];
|
||||||
|
terminalStore.system(
|
||||||
|
`nosterm-relay features: ${features.length ? features.join(', ') : 'none'}`,
|
||||||
|
'notice',
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
terminalStore.system('── end of MOTD ──', 'notice', scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const relayService = new RelayService();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* internal room id built from BOTH the relay url and the group id. `#general`
|
||||||
|
* on two different relays gives you two different ids.
|
||||||
|
*/
|
||||||
|
export function roomId(address: RoomAddress): string {
|
||||||
|
return `${normalizeRelayUrl(address.relayUrl)}#${address.groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** are these two room addresses the same room? */
|
||||||
|
export function roomsEqual(a: RoomAddress, b: RoomAddress): boolean {
|
||||||
|
return roomId(a) === roomId(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** strip one leading '#' and any surrounding whitespace off a group name. */
|
||||||
|
function cleanGroupId(raw: string): string {
|
||||||
|
const g = raw.trim().replace(/^#/, '').trim();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse a user-typed room reference into a RoomAddress.
|
||||||
|
*
|
||||||
|
* takes:
|
||||||
|
* - "#general" -> uses activeRelay
|
||||||
|
* - "general" -> uses activeRelay
|
||||||
|
* - "wss://relay.example/#general" -> explicit relay + group
|
||||||
|
* - "wss://relay.example#general" -> explicit relay + group
|
||||||
|
*
|
||||||
|
* if you only give a group name, `activeRelay` is required.
|
||||||
|
* throws with a clear message on bad input.
|
||||||
|
*/
|
||||||
|
export function parseRoomReference(input: string, activeRelay?: string): RoomAddress {
|
||||||
|
const raw = input.trim();
|
||||||
|
if (raw.length === 0) throw new Error('Room name is empty.');
|
||||||
|
|
||||||
|
// explicit relay form: has a ws(s) scheme.
|
||||||
|
if (/^wss?:\/\//i.test(raw)) {
|
||||||
|
// the group rides along as a url fragment (#general).
|
||||||
|
const hashIndex = raw.indexOf('#');
|
||||||
|
if (hashIndex === -1) {
|
||||||
|
throw new Error('Relay-qualified room must include a group, e.g. wss://relay/#general.');
|
||||||
|
}
|
||||||
|
const relayPart = raw.slice(0, hashIndex);
|
||||||
|
const groupPart = cleanGroupId(raw.slice(hashIndex));
|
||||||
|
if (groupPart.length === 0) throw new Error('Group name is empty.');
|
||||||
|
return {
|
||||||
|
relayUrl: normalizeRelayUrl(relayPart),
|
||||||
|
groupId: groupPart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// bare group name form: needs an active relay.
|
||||||
|
const groupId = cleanGroupId(raw);
|
||||||
|
if (groupId.length === 0) throw new Error('Group name is empty.');
|
||||||
|
if (!activeRelay) {
|
||||||
|
throw new Error('No active relay. Connect to a relay first or use wss://relay/#room.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
relayUrl: normalizeRelayUrl(activeRelay),
|
||||||
|
groupId
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { NdkChatTransport, PublishRejection } from './ndk-chat-transport';
|
||||||
|
import type { ChatTransport } from './chat-transport';
|
||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import { roomId } from './room-address';
|
||||||
|
import { NIP29_KINDS } from './nip29';
|
||||||
|
import { relayService } from './relay-service';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
import { roomStore } from '$lib/stores/room-store';
|
||||||
|
import { relayStore } from '$lib/stores/relay-store';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { muteStore } from '$lib/stores/mute-store';
|
||||||
|
import { authStore } from '$lib/stores/auth-store';
|
||||||
|
import { profileService } from './profile-service';
|
||||||
|
import { messageRepository, readMarkerRepository } from '$lib/db/repositories';
|
||||||
|
import { notificationService } from '$lib/notifications/notification-service';
|
||||||
|
import { pluginHost } from '$lib/plugins/plugin-host';
|
||||||
|
import { isMention } from '$lib/utils/mention';
|
||||||
|
import { shortenPubkey } from '$lib/utils/pubkey';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the app's room service. owns the chat transport and maps incoming messages
|
||||||
|
* to terminal entries + store updates. command layer talks to this, never to
|
||||||
|
* ndk or the transport directly.
|
||||||
|
*/
|
||||||
|
class RoomService {
|
||||||
|
private transport: ChatTransport | null = null;
|
||||||
|
/** room id -> unsub fn. */
|
||||||
|
private readonly unsubscribers = new Map<string, () => void>();
|
||||||
|
/** room id -> address, so we can re-sub after a reconnect. */
|
||||||
|
private readonly joinedRooms = new Map<string, RoomAddress>();
|
||||||
|
/**
|
||||||
|
* event ids we've already shown this session. stops dupes when a reconnect
|
||||||
|
* re-subs a room and the relay replays old events.
|
||||||
|
*/
|
||||||
|
private readonly renderedEventIds = new Set<string>();
|
||||||
|
/**
|
||||||
|
* event ids the relay rejected. lets us take back an optimistic echo: ndk
|
||||||
|
* might echo our own event before OR after the rejection lands, so we both
|
||||||
|
* yank an already-shown entry and suppress one that hasn't shown up yet.
|
||||||
|
*/
|
||||||
|
private readonly rejectedEventIds = new Set<string>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// on reconnect, re-sub every room on that relay so it replays whatever we
|
||||||
|
// missed during the outage (renderedEventIds dedupes).
|
||||||
|
relayService.onReconnect((url) => this.resubscribeRelay(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(): ChatTransport {
|
||||||
|
if (!this.transport) {
|
||||||
|
this.transport = new NdkChatTransport(getNdk());
|
||||||
|
}
|
||||||
|
return this.transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** join a nip-29 room: warn if unsupported, subscribe, then ask to join. */
|
||||||
|
async join(room: RoomAddress): Promise<void> {
|
||||||
|
const id = roomId(room);
|
||||||
|
if (this.unsubscribers.has(id)) {
|
||||||
|
roomStore.setActive(id);
|
||||||
|
terminalStore.system(`Already joined #${room.groupId}.`, 'notice');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// warn (but don't block) if the relay doesn't advertise nip-29.
|
||||||
|
const relay = get(relayStore)[room.relayUrl];
|
||||||
|
if (relay?.supportsNip29 === false) {
|
||||||
|
terminalStore.system(
|
||||||
|
`Relay ${room.relayUrl} does not advertise NIP-29; the room may not work.`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
} else if (!relay || relay.connection !== 'connected') {
|
||||||
|
terminalStore.system(`Not connected to ${room.relayUrl}. Connecting…`, 'notice');
|
||||||
|
await this.ensure().connectRelay(room.relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomStore.add(room, room.groupId);
|
||||||
|
this.joinedRooms.set(id, room);
|
||||||
|
this.subscribeRoom(room);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensure().joinRoom(room);
|
||||||
|
} catch (err) {
|
||||||
|
// reading still works even if the join request flops.
|
||||||
|
terminalStore.system(
|
||||||
|
`Join request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
terminalStore.system(`Joined #${room.groupId}`, 'notice', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** leave a room: unsub, send leave request, drop it from the store. */
|
||||||
|
async leave(room: RoomAddress): Promise<void> {
|
||||||
|
const id = roomId(room);
|
||||||
|
const unsub = this.unsubscribers.get(id);
|
||||||
|
if (!unsub) {
|
||||||
|
terminalStore.system(`Not joined to #${room.groupId}.`, 'notice');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsub();
|
||||||
|
this.unsubscribers.delete(id);
|
||||||
|
this.joinedRooms.delete(id);
|
||||||
|
try {
|
||||||
|
await this.ensure().leaveRoom(room);
|
||||||
|
} catch {
|
||||||
|
/* leave request is best-effort, whatever */
|
||||||
|
}
|
||||||
|
roomStore.remove(id);
|
||||||
|
terminalStore.system(`Left #${room.groupId}`, 'system');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** open message + roster subs for a room (used by join and reconnect). */
|
||||||
|
private subscribeRoom(room: RoomAddress): void {
|
||||||
|
const id = roomId(room);
|
||||||
|
// ditch any existing subs first (reconnect re-subs cleanly).
|
||||||
|
this.unsubscribers.get(id)?.();
|
||||||
|
|
||||||
|
const transport = this.ensure();
|
||||||
|
const unsubMsg = transport.subscribeToRoom(room, (message) => this.onMessage(message));
|
||||||
|
// track the relay's metadata + admins/members lists so the ui can show
|
||||||
|
// the topic/participants and gate moderation. relay has the final say.
|
||||||
|
const unsubRoster = transport.subscribeToRoster(room, (roster) => {
|
||||||
|
if (roster.kind === 'metadata') {
|
||||||
|
roomStore.patch(id, {
|
||||||
|
...(roster.name ? { name: roster.name } : {}),
|
||||||
|
topic: roster.about ?? undefined
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomStore.patch(
|
||||||
|
id,
|
||||||
|
roster.kind === 'admins' ? { admins: roster.pubkeys } : { members: roster.pubkeys }
|
||||||
|
);
|
||||||
|
// resolve names for listed folks so the member list shows nicks not
|
||||||
|
// hashes (best-effort; updates reactively via profileStore).
|
||||||
|
for (const pubkey of roster.pubkeys) void profileService.resolve(pubkey);
|
||||||
|
});
|
||||||
|
this.unsubscribers.set(id, () => {
|
||||||
|
unsubMsg();
|
||||||
|
unsubRoster();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** re-sub every joined room on a relay that just came back. */
|
||||||
|
private resubscribeRelay(relayUrl: string): void {
|
||||||
|
for (const room of this.joinedRooms.values()) {
|
||||||
|
if (room.relayUrl === relayUrl) this.subscribeRoom(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* send a chat (or action) message. ndk echoes the event locally right away;
|
||||||
|
* if the relay then rejects the publish we take that echo back (or suppress
|
||||||
|
* it if it hasn't landed yet) so a rejected message never sits there looking
|
||||||
|
* sent.
|
||||||
|
*/
|
||||||
|
async send(room: RoomAddress, content: string, action = false): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.ensure().sendRoomMessage(room, content, action);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PublishRejection) {
|
||||||
|
// yank it if already echoed; else mark it so onMessage drops it.
|
||||||
|
if (!terminalStore.removeByEventId(err.eventId)) {
|
||||||
|
this.rejectedEventIds.add(err.eventId);
|
||||||
|
// don't let the set grow forever; a late echo shows up in seconds.
|
||||||
|
setTimeout(() => this.rejectedEventIds.delete(err.eventId), 30_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** is this pubkey a known room admin (from the relay's 39001 list)? */
|
||||||
|
isAdmin(id: string, pubkey: string): boolean {
|
||||||
|
return get(roomStore).rooms[id]?.admins?.includes(pubkey) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* invite (add) or kick (remove) someone via nip-29 moderation. the relay
|
||||||
|
* does the admin-only enforcing and rejects non-admins; we just surface it.
|
||||||
|
*/
|
||||||
|
async moderate(room: RoomAddress, action: 'add' | 'remove', targetPubkey: string): Promise<void> {
|
||||||
|
await this.ensure().moderateUser(room, action, targetPubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** set the room topic (nip-29 metadata `about`). admin-only at the relay. */
|
||||||
|
async setTopic(room: RoomAddress, topic: string): Promise<void> {
|
||||||
|
await this.ensure().setMetadata(room, { about: topic });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(message: ChatMessage): void {
|
||||||
|
const id = roomId(message.room);
|
||||||
|
|
||||||
|
// drop an optimistic echo of an event the relay already rejected
|
||||||
|
// (rejection beat the local echo). don't cache or render it.
|
||||||
|
if (this.rejectedEventIds.delete(message.id)) return;
|
||||||
|
|
||||||
|
// session dedup: a reconnect re-subs and the relay replays stored
|
||||||
|
// events; show each one only once.
|
||||||
|
if (this.renderedEventIds.has(message.id)) return;
|
||||||
|
this.renderedEventIds.add(message.id);
|
||||||
|
|
||||||
|
// cache every message (best-effort) so history survives reloads.
|
||||||
|
// cached events stay untrusted: the flag just records verify-at-cache.
|
||||||
|
void messageRepository.save(message);
|
||||||
|
|
||||||
|
// locally muted authors don't show in the terminal (relay doesn't care).
|
||||||
|
if (get(muteStore).has(message.authorPubkey)) return;
|
||||||
|
|
||||||
|
// let plugins peek at the message (after mute filtering).
|
||||||
|
pluginHost.emit('message', message);
|
||||||
|
|
||||||
|
const active = get(roomStore).activeRoomId;
|
||||||
|
// bump unread when the message is for some other room.
|
||||||
|
if (id !== active) {
|
||||||
|
const room = get(roomStore).rooms[id];
|
||||||
|
if (room) roomStore.patch(id, { unread: room.unread + 1 });
|
||||||
|
} else {
|
||||||
|
// move the read marker for the active room.
|
||||||
|
void readMarkerRepository.set(id, message.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// grab a display name if we have one; else a shortened pubkey.
|
||||||
|
const displayName =
|
||||||
|
profileService.cachedName(message.authorPubkey) ?? shortenPubkey(message.authorPubkey);
|
||||||
|
|
||||||
|
// flag mentions of the logged-in user (never self-mentions).
|
||||||
|
const me = get(authStore).pubkey;
|
||||||
|
const mention =
|
||||||
|
!!me &&
|
||||||
|
message.authorPubkey !== me &&
|
||||||
|
isMention(message.content, me, profileService.cachedName(me));
|
||||||
|
|
||||||
|
terminalStore.push({
|
||||||
|
type: message.action ? 'action' : 'message',
|
||||||
|
author: message.authorPubkey,
|
||||||
|
authorDisplayName: displayName,
|
||||||
|
content: message.content,
|
||||||
|
roomId: id,
|
||||||
|
eventId: message.id,
|
||||||
|
mention,
|
||||||
|
timestamp: message.createdAt ? message.createdAt * 1000 : Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// desktop ping on a mention while the tab is in the background.
|
||||||
|
if (mention) {
|
||||||
|
const room = get(roomStore).rooms[id];
|
||||||
|
notificationService.notifyMention(displayName, message.content, room?.name ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve the profile in the background; if a name shows up, patch this
|
||||||
|
// (and any earlier) entry so the hash gets swapped in place.
|
||||||
|
void profileService.resolve(message.authorPubkey).then(() => {
|
||||||
|
const resolved = profileService.cachedName(message.authorPubkey);
|
||||||
|
if (resolved) terminalStore.setAuthorName(message.authorPubkey, resolved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rip down all subs (e.g. on logout/identity switch). */
|
||||||
|
destroy(): void {
|
||||||
|
for (const unsub of this.unsubscribers.values()) unsub();
|
||||||
|
this.unsubscribers.clear();
|
||||||
|
this.joinedRooms.clear();
|
||||||
|
this.renderedEventIds.clear();
|
||||||
|
if (this.transport && 'destroy' in this.transport) {
|
||||||
|
(this.transport as NdkChatTransport).destroy();
|
||||||
|
}
|
||||||
|
this.transport = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roomService = new RoomService();
|
||||||
|
export { NIP29_KINDS };
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||||
|
import type { NDKSigner } from '@nostr-dev-kit/ndk';
|
||||||
|
import { getNdk } from './ndk-client';
|
||||||
|
import type { SignerKind } from '$lib/types/nostr';
|
||||||
|
|
||||||
|
export interface ActiveSigner {
|
||||||
|
kind: Exclude<SignerKind, 'none'>;
|
||||||
|
pubkey: string;
|
||||||
|
signer: NDKSigner;
|
||||||
|
/** true when the raw private key lives in this app's memory (nsec/ephemeral). */
|
||||||
|
ephemeral: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles identity/signing only — never relays or messages. on login it hooks
|
||||||
|
* the signer onto the shared ndk instance so publishing and nip-42 auth can
|
||||||
|
* use it; on logout it unhooks.
|
||||||
|
*
|
||||||
|
* heads up (security): on the nsec and ephemeral paths the private key sits in
|
||||||
|
* browser memory. this service never logs it, never ships it anywhere, and
|
||||||
|
* only hands it out through the explicit `exportNsec`/`exportNcryptsec` methods
|
||||||
|
* the ui uses to show or (optionally, encrypted) save it when the user asks.
|
||||||
|
*/
|
||||||
|
export class SignerService {
|
||||||
|
private active: ActiveSigner | null = null;
|
||||||
|
/** kept only for in-memory key signers, to support export/persist. */
|
||||||
|
private privateKeySigner: NDKPrivateKeySigner | null = null;
|
||||||
|
|
||||||
|
/** true if a nip-07 browser extension (window.nostr) is around. */
|
||||||
|
static hasNip07(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
get current(): ActiveSigner | null {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* log in with a nip-07 browser extension. the extension keeps the private
|
||||||
|
* key — we only ever get the public key and signatures.
|
||||||
|
*/
|
||||||
|
async loginNip07(): Promise<ActiveSigner> {
|
||||||
|
if (!SignerService.hasNip07()) {
|
||||||
|
throw new Error(
|
||||||
|
'No NIP-07 extension found. Install a Nostr signer extension (e.g. Alby, nos2x).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ndk = getNdk();
|
||||||
|
const signer = new NDKNip07Signer(3000, ndk);
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await signer.blockUntilReady();
|
||||||
|
} catch {
|
||||||
|
throw new Error('NIP-07 login was rejected or timed out.');
|
||||||
|
}
|
||||||
|
this.attach({ kind: 'nip07', pubkey: user.pubkey, signer, ephemeral: false });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* log in with a remote signer (nip-46 "bunker"). the key stays on the remote
|
||||||
|
* signer; we talk to it via a connection token (bunker://…) or a nip-05.
|
||||||
|
*/
|
||||||
|
async loginBunker(connectionToken: string): Promise<ActiveSigner> {
|
||||||
|
const token = connectionToken.trim();
|
||||||
|
if (!token) throw new Error('Enter a bunker:// connection string or a NIP-05 identifier.');
|
||||||
|
|
||||||
|
const ndk = getNdk();
|
||||||
|
let signer: NDKNip46Signer;
|
||||||
|
try {
|
||||||
|
signer = NDKNip46Signer.bunker(ndk, token);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid bunker connection string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await signer.blockUntilReady();
|
||||||
|
} catch {
|
||||||
|
throw new Error('Remote signer did not approve the connection (rejected or timed out).');
|
||||||
|
}
|
||||||
|
this.attach({ kind: 'bunker', pubkey: user.pubkey, signer, ephemeral: false });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* log in by importing an nsec (or hex) private key. the key stays ONLY in
|
||||||
|
* memory unless the caller saves it separately via exportNcryptsec.
|
||||||
|
* less safe than extension/bunker — the ui should warn about it.
|
||||||
|
*/
|
||||||
|
loginNsec(nsecOrHex: string): ActiveSigner {
|
||||||
|
const value = nsecOrHex.trim();
|
||||||
|
if (!value) throw new Error('Enter an nsec (or hex) private key.');
|
||||||
|
let signer: NDKPrivateKeySigner;
|
||||||
|
try {
|
||||||
|
// ndk takes nsec bech32 or hex straight up.
|
||||||
|
signer = new NDKPrivateKeySigner(value, getNdk());
|
||||||
|
} catch {
|
||||||
|
throw new Error('That does not look like a valid nsec or hex private key.');
|
||||||
|
}
|
||||||
|
this.privateKeySigner = signer;
|
||||||
|
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bring back an nsec identity from a nip-49 encrypted key (ncryptsec) + passphrase. */
|
||||||
|
loginNcryptsec(ncryptsec: string, passphrase: string): ActiveSigner {
|
||||||
|
let signer: NDKPrivateKeySigner;
|
||||||
|
try {
|
||||||
|
signer = NDKPrivateKeySigner.fromNcryptsec(ncryptsec, passphrase, getNdk());
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not decrypt the stored key — wrong passphrase?');
|
||||||
|
}
|
||||||
|
this.privateKeySigner = signer;
|
||||||
|
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* make a brand-new keypair and log in with it. the key lives in memory for
|
||||||
|
* the session; the ui shows the nsec once so the user can stash it.
|
||||||
|
*/
|
||||||
|
generateAndLogin(): ActiveSigner {
|
||||||
|
const signer = NDKPrivateKeySigner.generate();
|
||||||
|
this.privateKeySigner = signer;
|
||||||
|
this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spin up a throwaway in-memory identity for dev/testing.
|
||||||
|
* NOT for anything you care about — gone on logout/reload.
|
||||||
|
*/
|
||||||
|
loginEphemeral(): ActiveSigner {
|
||||||
|
const signer = NDKPrivateKeySigner.generate();
|
||||||
|
this.privateKeySigner = signer;
|
||||||
|
this.attach({ kind: 'ephemeral', pubkey: signer.pubkey, signer, ephemeral: true });
|
||||||
|
return this.active as ActiveSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the active in-memory key as an nsec, if any (for one-time display). */
|
||||||
|
exportNsec(): string | null {
|
||||||
|
return this.privateKeySigner?.nsec ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the active in-memory key as an npub, if any. */
|
||||||
|
exportNpub(): string | null {
|
||||||
|
return this.privateKeySigner?.npub ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** encrypt the active in-memory key with a passphrase (nip-49 ncryptsec). */
|
||||||
|
exportNcryptsec(passphrase: string): string {
|
||||||
|
if (!this.privateKeySigner) throw new Error('No in-memory key to encrypt.');
|
||||||
|
if (passphrase.length < 8) throw new Error('Use a passphrase of at least 8 characters.');
|
||||||
|
return this.privateKeySigner.encryptToNcryptsec(passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** unhook the signer from ndk and wipe local identity state. */
|
||||||
|
logout(): void {
|
||||||
|
const ndk = getNdk();
|
||||||
|
ndk.signer = undefined;
|
||||||
|
this.active = null;
|
||||||
|
this.privateKeySigner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attach(active: ActiveSigner): void {
|
||||||
|
getNdk().signer = active.signer;
|
||||||
|
this.active = active;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { settingsStore } from '$lib/stores/settings-store';
|
||||||
|
import { BRAND } from '$lib/brand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* desktop notifications (web notifications api) for dms and mentions. fires only
|
||||||
|
* when the tab is backgrounded, notifications are on in settings, and the
|
||||||
|
* browser has granted permission. content is shown truncated; nothing gets sent
|
||||||
|
* anywhere — it's a local os notification, nothing more.
|
||||||
|
*/
|
||||||
|
class NotificationService {
|
||||||
|
private supported =
|
||||||
|
typeof window !== 'undefined' && 'Notification' in window && 'hidden' in document;
|
||||||
|
|
||||||
|
/** can notifications fire right now (enabled + permitted)? */
|
||||||
|
private get active(): boolean {
|
||||||
|
return (
|
||||||
|
this.supported && get(settingsStore).notifications && Notification.permission === 'granted'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** does this environment support notifications at all? */
|
||||||
|
isSupported(): boolean {
|
||||||
|
return this.supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** current browser permission state ('default' | 'granted' | 'denied'). */
|
||||||
|
permission(): NotificationPermission {
|
||||||
|
return this.supported ? Notification.permission : 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ask for os permission (must come from a user gesture, e.g. toggling the
|
||||||
|
* setting). returns the resulting permission state.
|
||||||
|
*/
|
||||||
|
async requestPermission(): Promise<NotificationPermission> {
|
||||||
|
if (!this.supported) return 'denied';
|
||||||
|
try {
|
||||||
|
return await Notification.requestPermission();
|
||||||
|
} catch {
|
||||||
|
return this.permission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyMention(from: string, content: string, room: string): void {
|
||||||
|
this.show(`${from} mentioned you in #${room}`, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDm(from: string, content: string): void {
|
||||||
|
this.show(`DM from ${from}`, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** show a notification, but only when the tab is hidden and we're allowed to. */
|
||||||
|
private show(title: string, body: string): void {
|
||||||
|
if (!this.active || !document.hidden) return;
|
||||||
|
try {
|
||||||
|
const n = new Notification(`${BRAND.name} — ${title}`, {
|
||||||
|
body: body.length > 140 ? `${body.slice(0, 140)}…` : body,
|
||||||
|
// coalesce rapid notifications from the same source.
|
||||||
|
tag: title
|
||||||
|
});
|
||||||
|
// focus the app when the user clicks the notification.
|
||||||
|
n.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
n.close();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* notification construction can throw in some browsers; ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = new NotificationService();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { pluginHost } from './plugin-host';
|
||||||
|
import type { NostermPlugin } from './plugin-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auto-find and register every plugin in `src/plugins/`. each plugin module
|
||||||
|
* should export a NostermPlugin as its default export or as a named `plugin`
|
||||||
|
* export. dropping a new file into that folder is all it takes to add one
|
||||||
|
* (vite's import.meta.glob picks it up at build time).
|
||||||
|
*
|
||||||
|
* loaded eagerly so plugins can register commands/events before first render.
|
||||||
|
*/
|
||||||
|
export async function loadPlugins(): Promise<void> {
|
||||||
|
// load the user's disabled-plugin set FIRST so disabled plugins get skipped
|
||||||
|
// during registration instead of loaded and immediately torn down.
|
||||||
|
await pluginHost.loadDisabled().catch((err) => {
|
||||||
|
console.error('[plugin] could not load disabled set (all plugins enabled):', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// vite statically analyzes this glob; the path is relative to THIS file.
|
||||||
|
const modules = import.meta.glob('../../plugins/*.{ts,js}', { eager: true });
|
||||||
|
|
||||||
|
for (const [path, mod] of Object.entries(modules)) {
|
||||||
|
const candidate =
|
||||||
|
(mod as { default?: NostermPlugin; plugin?: NostermPlugin }).default ??
|
||||||
|
(mod as { plugin?: NostermPlugin }).plugin;
|
||||||
|
if (!candidate) {
|
||||||
|
console.warn(`[plugin] ${path} exports no default/plugin — skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await pluginHost.register(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import type { TerminalEntryType } from '$lib/types/terminal';
|
||||||
|
import type { CommandRegistry } from '$lib/commands/command-registry';
|
||||||
|
import { terminalStore } from '$lib/stores/terminal-store';
|
||||||
|
import { addCustomTheme } from '$lib/stores/theme-store';
|
||||||
|
import { pluginStateRepository } from '$lib/db/repositories';
|
||||||
|
import type { NostermPlugin, PluginContext, PluginEventName, PluginEvents } from './plugin-types';
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
plugin: NostermPlugin;
|
||||||
|
unsubscribers: (() => void)[];
|
||||||
|
/** names of commands this plugin registered, so we can yank them on disable. */
|
||||||
|
commandNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pluginStateRepository namespace + key for the saved disabled-plugin set. */
|
||||||
|
const HOST_NS = '__host__';
|
||||||
|
const DISABLED_KEY = 'disabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* owns the plugin lifecycle: registration, the event bus plugins subscribe to,
|
||||||
|
* and the PluginContext each plugin's setup() gets. one host per app; the
|
||||||
|
* command registry gets injected once the dispatcher exists.
|
||||||
|
*/
|
||||||
|
class PluginHost {
|
||||||
|
private registry: CommandRegistry | null = null;
|
||||||
|
private readonly loaded = new Map<string, LoadedPlugin>();
|
||||||
|
/**
|
||||||
|
* every plugin we've seen this session, on or off, keyed by id. lets
|
||||||
|
* `/plugins enable` re-run setup for one that was disabled at boot.
|
||||||
|
*/
|
||||||
|
private readonly known = new Map<string, NostermPlugin>();
|
||||||
|
/** ids the user turned off; saved so the choice sticks across reloads. */
|
||||||
|
private disabled = new Set<string>();
|
||||||
|
// event name -> set of handlers.
|
||||||
|
private readonly listeners: { [E in PluginEventName]: Set<PluginEvents[E]> } = {
|
||||||
|
message: new Set(),
|
||||||
|
roomChanged: new Set(),
|
||||||
|
relayState: new Set()
|
||||||
|
};
|
||||||
|
// outgoing-message transforms, applied in registration order on the send path.
|
||||||
|
private readonly outgoingTransforms = new Set<(content: string) => string>();
|
||||||
|
|
||||||
|
/** wire up the command registry (called by the dispatcher factory). */
|
||||||
|
useRegistry(registry: CommandRegistry): void {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** list loaded plugins (for `/plugins`). */
|
||||||
|
list(): NostermPlugin[] {
|
||||||
|
return [...this.loaded.values()].map((l) => l.plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fire an app event at every subscribed handler (never throws). */
|
||||||
|
emit<E extends PluginEventName>(event: E, ...args: Parameters<PluginEvents[E]>): void {
|
||||||
|
for (const handler of this.listeners[event]) {
|
||||||
|
try {
|
||||||
|
(handler as (...a: unknown[]) => void)(...args);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin] ${event} handler failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* run every outgoing-message transform in order. never throws: a busted
|
||||||
|
* transform gets logged and skipped so a plugin can't block sending.
|
||||||
|
*/
|
||||||
|
applyOutgoing(content: string): string {
|
||||||
|
let result = content;
|
||||||
|
for (const transform of this.outgoingTransforms) {
|
||||||
|
try {
|
||||||
|
const next = transform(result);
|
||||||
|
if (typeof next === 'string') result = next;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[plugin] outgoing transform failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tear down a loaded plugin: drop its commands, run its unsubscribers +
|
||||||
|
* optional teardown(). it stays in `known` so it can be re-enabled.
|
||||||
|
*/
|
||||||
|
unregister(id: string): void {
|
||||||
|
const entry = this.loaded.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
// pull any commands the plugin registered so they stop resolving.
|
||||||
|
if (this.registry) {
|
||||||
|
for (const name of entry.commandNames) this.registry.unregister(name);
|
||||||
|
}
|
||||||
|
for (const off of entry.unsubscribers) {
|
||||||
|
try {
|
||||||
|
off();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin] "${id}" unsubscribe failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
entry.plugin.teardown?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin] "${id}" teardown failed:`, err);
|
||||||
|
}
|
||||||
|
this.loaded.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* register and set up one plugin. safe: failures are boxed off + logged.
|
||||||
|
* a disabled plugin is remembered (so it can be turned on later) but its
|
||||||
|
* setup() is skipped.
|
||||||
|
*/
|
||||||
|
async register(plugin: NostermPlugin): Promise<void> {
|
||||||
|
if (!plugin?.id || typeof plugin.setup !== 'function') {
|
||||||
|
console.error('[plugin] ignored: missing id or setup()', plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.known.set(plugin.id, plugin);
|
||||||
|
if (this.disabled.has(plugin.id)) {
|
||||||
|
// known but turned off on purpose; don't run setup.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.loaded.has(plugin.id)) {
|
||||||
|
console.warn(`[plugin] "${plugin.id}" already registered; skipping duplicate.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry: LoadedPlugin = { plugin, unsubscribers: [], commandNames: [] };
|
||||||
|
this.loaded.set(plugin.id, entry);
|
||||||
|
try {
|
||||||
|
await plugin.setup(this.makeContext(plugin.id, entry));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin] "${plugin.id}" setup failed:`, err);
|
||||||
|
this.loaded.delete(plugin.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load the saved disabled-plugin set. call once before loadPlugins() so
|
||||||
|
* disabled plugins get skipped at boot instead of loaded then torn down.
|
||||||
|
*/
|
||||||
|
async loadDisabled(): Promise<void> {
|
||||||
|
const stored = await pluginStateRepository.get<string[]>(HOST_NS, DISABLED_KEY);
|
||||||
|
if (Array.isArray(stored)) this.disabled = new Set(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistDisabled(): void {
|
||||||
|
void pluginStateRepository
|
||||||
|
.set(HOST_NS, DISABLED_KEY, [...this.disabled])
|
||||||
|
.catch((err) => console.error('[plugin] failed to persist disabled set:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** is a known plugin currently loaded (on and set up)? */
|
||||||
|
isEnabled(id: string): boolean {
|
||||||
|
return this.loaded.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** all known plugins with their current on/off state (for `/plugins`). */
|
||||||
|
status(): { plugin: NostermPlugin; enabled: boolean }[] {
|
||||||
|
return [...this.known.values()].map((plugin) => ({
|
||||||
|
plugin,
|
||||||
|
enabled: this.loaded.has(plugin.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disable a loaded plugin: tear it down and remember the choice. returns
|
||||||
|
* false if the id isn't a known plugin. no-op if it's already off.
|
||||||
|
*/
|
||||||
|
async disable(id: string): Promise<boolean> {
|
||||||
|
if (!this.known.has(id)) return false;
|
||||||
|
this.disabled.add(id);
|
||||||
|
this.persistDisabled();
|
||||||
|
if (this.loaded.has(id)) this.unregister(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enable a previously-off plugin: clear the flag and re-run its setup.
|
||||||
|
* returns false if the id isn't a known plugin.
|
||||||
|
*/
|
||||||
|
async enable(id: string): Promise<boolean> {
|
||||||
|
const plugin = this.known.get(id);
|
||||||
|
if (!plugin) return false;
|
||||||
|
const wasDisabled = this.disabled.delete(id);
|
||||||
|
if (wasDisabled) this.persistDisabled();
|
||||||
|
if (!this.loaded.has(id)) await this.register(plugin);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeContext(pluginId: string, entry: LoadedPlugin): PluginContext {
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
registerCommand: (command: ChatCommand) => {
|
||||||
|
if (!this.registry) {
|
||||||
|
console.error(
|
||||||
|
`[plugin] "${pluginId}" registered a command before the registry was ready.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.registry.register(command);
|
||||||
|
// remember it so disabling the plugin can pull it back out.
|
||||||
|
entry.commandNames.push(command.name);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin] "${pluginId}" command "${command.name}" rejected:`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
print: (content: string, type: TerminalEntryType = 'system') => {
|
||||||
|
// plugin output is local/trusted, tagged so you know where it came from.
|
||||||
|
terminalStore.push({ type, content: `[${pluginId}] ${content}` });
|
||||||
|
},
|
||||||
|
on: <E extends PluginEventName>(event: E, handler: PluginEvents[E]) => {
|
||||||
|
this.listeners[event].add(handler);
|
||||||
|
const off = () => this.listeners[event].delete(handler);
|
||||||
|
entry.unsubscribers.push(off);
|
||||||
|
return off;
|
||||||
|
},
|
||||||
|
transformOutgoing: (transform: (content: string) => string) => {
|
||||||
|
this.outgoingTransforms.add(transform);
|
||||||
|
const off = () => this.outgoingTransforms.delete(transform);
|
||||||
|
entry.unsubscribers.push(off);
|
||||||
|
return off;
|
||||||
|
},
|
||||||
|
registerTheme: (theme) => {
|
||||||
|
void addCustomTheme(theme).catch((err) =>
|
||||||
|
console.error(`[plugin] "${pluginId}" theme rejected:`, err)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getState: <T>(key: string) => pluginStateRepository.get<T>(pluginId, key),
|
||||||
|
setState: <T>(key: string, value: T) => pluginStateRepository.set(pluginId, key, value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginHost = new PluginHost();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ChatCommand } from '$lib/types/command';
|
||||||
|
import type { ChatMessage } from '$lib/types/chat';
|
||||||
|
import type { TerminalEntryType } from '$lib/types/terminal';
|
||||||
|
import type { ThemeInput } from '$lib/themes/theme-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* events a plugin can subscribe to. kept small and stable on purpose — this is
|
||||||
|
* what plugins lean on. handlers run synchronously in registration order; a
|
||||||
|
* throwing handler gets caught and logged, never takes the app down.
|
||||||
|
*/
|
||||||
|
export interface PluginEvents {
|
||||||
|
/** a chat message came in (after mute filtering, around render time). */
|
||||||
|
message: (message: ChatMessage) => void;
|
||||||
|
/** the active room changed (room id, or undefined when none). */
|
||||||
|
roomChanged: (roomId: string | undefined) => void;
|
||||||
|
/** a relay connection state changed. */
|
||||||
|
relayState: (url: string, state: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginEventName = keyof PluginEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the api a plugin's `setup()` gets. everything a plugin needs to be useful —
|
||||||
|
* add commands, print to the terminal, react to events, register themes, read
|
||||||
|
* settings — without poking at app internals.
|
||||||
|
*/
|
||||||
|
export interface PluginContext {
|
||||||
|
/** register an irc-style command (same shape as built-ins). */
|
||||||
|
registerCommand(command: ChatCommand): void;
|
||||||
|
/** print a line to the terminal (trusted, local — never remote content). */
|
||||||
|
print(content: string, type?: TerminalEntryType): void;
|
||||||
|
/** subscribe to an app event; hands back an unsub fn. */
|
||||||
|
on<E extends PluginEventName>(event: E, handler: PluginEvents[E]): () => void;
|
||||||
|
/**
|
||||||
|
* register a transform run on outgoing message text right before it's sent
|
||||||
|
* (room messages and dms). transforms run in registration order, each fed
|
||||||
|
* the previous result. return the (maybe rewritten) text. keep it pure and
|
||||||
|
* fast — it's on the send path. hands back an unsub fn.
|
||||||
|
*/
|
||||||
|
transformOutgoing(transform: (content: string) => string): () => void;
|
||||||
|
/** register a custom theme (saved + selectable). */
|
||||||
|
registerTheme(theme: ThemeInput): void;
|
||||||
|
/** read a saved, plugin-scoped value (namespaced by plugin id). */
|
||||||
|
getState<T>(key: string): Promise<T | undefined>;
|
||||||
|
/** save a plugin-scoped value. */
|
||||||
|
setState<T>(key: string, value: T): Promise<void>;
|
||||||
|
/** the plugin's own id, for logging/namespacing. */
|
||||||
|
readonly pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a nosterm plugin. drop a module exporting one of these (as default or a named
|
||||||
|
* `plugin` export) into `src/plugins/` and it's auto-loaded at startup.
|
||||||
|
*/
|
||||||
|
export interface NostermPlugin {
|
||||||
|
/** unique, stable id (kebab-case). */
|
||||||
|
id: string;
|
||||||
|
/** human-readable name. */
|
||||||
|
name: string;
|
||||||
|
/** one-line description shown by `/plugins`. */
|
||||||
|
description?: string;
|
||||||
|
/** called once at startup. wire up commands/events/themes here. */
|
||||||
|
setup(ctx: PluginContext): void | Promise<void>;
|
||||||
|
/** optional cleanup (e.g. on disable). */
|
||||||
|
teardown?(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { DEFAULT_RELAYS } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime client config fetched from `/config.json`.
|
||||||
|
*
|
||||||
|
* the container entrypoint rewrites this file from env vars at startup, so one
|
||||||
|
* prebuilt image can ship with different relays per environment — no rebuild.
|
||||||
|
* the build-time `PUBLIC_DEFAULT_RELAYS` stays as a fallback for local dev and
|
||||||
|
* for images run without the runtime var set.
|
||||||
|
*/
|
||||||
|
interface RuntimeConfig {
|
||||||
|
relays: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// tidy a raw relay list into trimmed, non-empty strings.
|
||||||
|
function normalizeRelays(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((r) => String(r).trim()).filter((r) => r.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* work out the default relays to use. runtime `/config.json` wins when it lists
|
||||||
|
* any; otherwise fall back to the build-time defaults. a missing or busted
|
||||||
|
* config is harmless — we just use the fallback.
|
||||||
|
*/
|
||||||
|
export async function loadDefaultRelays(fetchFn: typeof fetch = fetch): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// no-store so runtime config changes never come back stale.
|
||||||
|
const res = await fetchFn('/config.json', { cache: 'no-store' });
|
||||||
|
if (res.ok) {
|
||||||
|
const cfg = (await res.json()) as Partial<RuntimeConfig>;
|
||||||
|
const relays = normalizeRelays(cfg.relays);
|
||||||
|
if (relays.length > 0) return relays;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// missing/broken config.json: fall back to build-time defaults.
|
||||||
|
}
|
||||||
|
return DEFAULT_RELAYS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { AuthState } from '$lib/types/nostr';
|
||||||
|
|
||||||
|
const INITIAL: AuthState = { kind: 'none', ephemeral: false };
|
||||||
|
|
||||||
|
function createAuthStore() {
|
||||||
|
const { subscribe, set, update } = writable<AuthState>(INITIAL);
|
||||||
|
|
||||||
|
function setNip07(pubkey: string) {
|
||||||
|
set({ kind: 'nip07', pubkey, ephemeral: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBunker(pubkey: string) {
|
||||||
|
// remote signer holds the key; not in this app's memory.
|
||||||
|
set({ kind: 'bunker', pubkey, ephemeral: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNsec(pubkey: string) {
|
||||||
|
// private key sits in this app's memory.
|
||||||
|
set({ kind: 'nsec', pubkey, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEphemeral(pubkey: string) {
|
||||||
|
set({ kind: 'ephemeral', pubkey, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
set(INITIAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, setNip07, setBunker, setNsec, setEphemeral, logout, update };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = createAuthStore();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
|
||||||
|
/** a dm conversation with one counterparty (their hex pubkey). */
|
||||||
|
export interface DmConversation {
|
||||||
|
/** counterparty pubkey (hex). */
|
||||||
|
pubkey: string;
|
||||||
|
/** terminal roomId used to scope this conversation's entries. */
|
||||||
|
roomId: string;
|
||||||
|
/** unread incoming messages since this conversation was last viewed. */
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** terminal roomId marker for a dm conversation with a given pubkey. */
|
||||||
|
export function dmRoomId(pubkey: string): string {
|
||||||
|
return `dm:${pubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DmStoreState {
|
||||||
|
/** pubkey -> conversation. */
|
||||||
|
conversations: Record<string, DmConversation>;
|
||||||
|
/** pubkey of the active dm conversation, if a dm view is focused. */
|
||||||
|
activePubkey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDmStore() {
|
||||||
|
const { subscribe, update } = writable<DmStoreState>({ conversations: {} });
|
||||||
|
|
||||||
|
/** make sure a conversation exists for a counterparty; returns its roomId. */
|
||||||
|
function ensure(pubkey: string): string {
|
||||||
|
update((s) => {
|
||||||
|
if (s.conversations[pubkey]) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
conversations: {
|
||||||
|
...s.conversations,
|
||||||
|
[pubkey]: { pubkey, roomId: dmRoomId(pubkey), unread: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return dmRoomId(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** focus a dm conversation (making it if needed), clearing its unread. */
|
||||||
|
function open(pubkey: string): void {
|
||||||
|
ensure(pubkey);
|
||||||
|
update((s) => {
|
||||||
|
const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 };
|
||||||
|
return {
|
||||||
|
conversations: { ...s.conversations, [pubkey]: { ...convo, unread: 0 } },
|
||||||
|
activePubkey: pubkey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** leave the dm view (hand focus back to rooms). */
|
||||||
|
function close(): void {
|
||||||
|
update((s) => ({ ...s, activePubkey: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** log an incoming dm; bumps unread unless that conversation is active. */
|
||||||
|
function noteIncoming(pubkey: string): void {
|
||||||
|
ensure(pubkey);
|
||||||
|
update((s) => {
|
||||||
|
const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 };
|
||||||
|
const unread = s.activePubkey === pubkey ? 0 : convo.unread + 1;
|
||||||
|
return { ...s, conversations: { ...s.conversations, [pubkey]: { ...convo, unread } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, ensure, open, close, noteIncoming, snapshot: () => get({ subscribe }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dmStore = createDmStore();
|
||||||
|
|
||||||
|
/** the active dm conversation, if any. */
|
||||||
|
export const activeDm = derived(dmStore, ($s) =>
|
||||||
|
$s.activePubkey ? $s.conversations[$s.activePubkey] : undefined
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set of muted author pubkeys (hex). messages from these pubkeys are hidden from
|
||||||
|
* the terminal. muting is a local, client-side filter — it doesn't touch the
|
||||||
|
* relay or other users.
|
||||||
|
*/
|
||||||
|
function createMuteStore() {
|
||||||
|
const { subscribe, update, set } = writable<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function add(pubkey: string) {
|
||||||
|
update((s) => new Set(s).add(pubkey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(pubkey: string) {
|
||||||
|
update((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
next.delete(pubkey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAll(pubkeys: string[]) {
|
||||||
|
set(new Set(pubkeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, add, remove, setAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const muteStore = createMuteStore();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactive map of pubkey (hex) -> resolved display name. mirrors the
|
||||||
|
* ProfileService cache so ui (identity panel, participant lists) updates live
|
||||||
|
* when a name resolves or the user changes their own nick.
|
||||||
|
*/
|
||||||
|
function createProfileStore() {
|
||||||
|
const { subscribe, update } = writable<Record<string, string>>({});
|
||||||
|
|
||||||
|
function setName(pubkey: string, name: string) {
|
||||||
|
update((m) => (m[pubkey] === name ? m : { ...m, [pubkey]: name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, setName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileStore = createProfileStore();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { RelayState, RelayConnectionState, RelayInformation } from '$lib/types/relay';
|
||||||
|
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/** map of normalized relay url -> RelayState. */
|
||||||
|
function createRelayStore() {
|
||||||
|
const { subscribe, update } = writable<Record<string, RelayState>>({});
|
||||||
|
|
||||||
|
function upsert(url: string, partial: Partial<RelayState>) {
|
||||||
|
const key = normalizeRelayUrl(url);
|
||||||
|
update((relays) => {
|
||||||
|
const existing = relays[key];
|
||||||
|
const next: RelayState = {
|
||||||
|
url: key,
|
||||||
|
connection: partial.connection ?? existing?.connection ?? 'disconnected',
|
||||||
|
info: partial.info ?? existing?.info,
|
||||||
|
supportsNip29: partial.supportsNip29 ?? existing?.supportsNip29,
|
||||||
|
lastError: partial.lastError ?? existing?.lastError,
|
||||||
|
connectedAt: partial.connectedAt ?? existing?.connectedAt,
|
||||||
|
reconnectAttempts: partial.reconnectAttempts ?? existing?.reconnectAttempts ?? 0
|
||||||
|
};
|
||||||
|
return { ...relays, [key]: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(url: string, connection: RelayConnectionState, lastError?: string) {
|
||||||
|
upsert(url, { connection, lastError });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInfo(url: string, info: RelayInformation) {
|
||||||
|
upsert(url, { info, supportsNip29: (info.supportedNips ?? []).includes(29) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(url: string) {
|
||||||
|
const key = normalizeRelayUrl(url);
|
||||||
|
update((relays) => {
|
||||||
|
const next = { ...relays };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, upsert, setState, setInfo, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const relayStore = createRelayStore();
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
import type { RoomState } from '$lib/types/room';
|
||||||
|
import type { RoomAddress } from '$lib/types/room';
|
||||||
|
import { roomId } from '$lib/nostr/room-address';
|
||||||
|
|
||||||
|
interface RoomStoreState {
|
||||||
|
/** canonical room id -> RoomState. */
|
||||||
|
rooms: Record<string, RoomState>;
|
||||||
|
/** canonical id of the currently active room, if any. */
|
||||||
|
activeRoomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoomStore() {
|
||||||
|
const { subscribe, update } = writable<RoomStoreState>({ rooms: {} });
|
||||||
|
|
||||||
|
function add(address: RoomAddress, name?: string): RoomState {
|
||||||
|
const id = roomId(address);
|
||||||
|
const room: RoomState = {
|
||||||
|
id,
|
||||||
|
address,
|
||||||
|
name: name ?? address.groupId,
|
||||||
|
joined: true,
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
unread: 0
|
||||||
|
};
|
||||||
|
update((s) => ({ rooms: { ...s.rooms, [id]: room }, activeRoomId: id }));
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
update((s) => {
|
||||||
|
const rooms = { ...s.rooms };
|
||||||
|
delete rooms[id];
|
||||||
|
const activeRoomId = s.activeRoomId === id ? Object.keys(rooms)[0] : s.activeRoomId;
|
||||||
|
return { rooms, activeRoomId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(id: string) {
|
||||||
|
update((s) => {
|
||||||
|
const room = s.rooms[id];
|
||||||
|
if (!room) return s;
|
||||||
|
// opening a room clears its unread count.
|
||||||
|
const rooms = room.unread > 0 ? { ...s.rooms, [id]: { ...room, unread: 0 } } : s.rooms;
|
||||||
|
return { ...s, rooms, activeRoomId: id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(id: string, partial: Partial<RoomState>) {
|
||||||
|
update((s) => {
|
||||||
|
const existing = s.rooms[id];
|
||||||
|
if (!existing) return s;
|
||||||
|
return { ...s, rooms: { ...s.rooms, [id]: { ...existing, ...partial } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return get({ subscribe });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, add, remove, setActive, patch, snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roomStore = createRoomStore();
|
||||||
|
|
||||||
|
/** derived: the currently active room, if any. */
|
||||||
|
export const activeRoom = derived(roomStore, ($s) =>
|
||||||
|
$s.activeRoomId ? $s.rooms[$s.activeRoomId] : undefined
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { normalizeRelayUrl } from '$lib/utils/relay-url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a relay "server window" — the irc-style server buffer that holds a relay's
|
||||||
|
* connection lifecycle, motd, and nip-11 handshake output. unlike rooms and
|
||||||
|
* dms, servers aren't a persisted or subscribed resource: they derive purely
|
||||||
|
* from the relay set (`relayStore`), so this store only tracks which server
|
||||||
|
* window is focused. terminal lines are scoped to a server via `relayRoomId`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** terminal roomId marker for a relay's server window. */
|
||||||
|
export function relayRoomId(url: string): string {
|
||||||
|
return `relay:${normalizeRelayUrl(url)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerStoreState {
|
||||||
|
/** normalized url of the focused server window, if a server view is active. */
|
||||||
|
activeUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServerStore() {
|
||||||
|
const { subscribe, update } = writable<ServerStoreState>({});
|
||||||
|
|
||||||
|
/** focus a relay's server window. */
|
||||||
|
function open(url: string): void {
|
||||||
|
update(() => ({ activeUrl: normalizeRelayUrl(url) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** leave the server view (hand focus back to rooms). */
|
||||||
|
function close(): void {
|
||||||
|
update(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, open, close, snapshot: () => get({ subscribe }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverStore = createServerStore();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user