'
+ curl -fsS http://127.0.0.1:8080/config.json | grep -q '"homeserver_url":""'
+
+ # The client API goes through the proxy, trailing-slash routes too...
+ token="$(curl -fsS http://127.0.0.1:8080/_matrix/client/v3/login -d '{
+ "type": "m.login.password",
+ "identifier": {"type": "m.id.user", "user": "ci-user"},
+ "password": "hunter2hunter2"
+ }' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
+ test -n "$token"
+ curl -fsS 'http://127.0.0.1:8080/_matrix/client/v3/sync?timeout=0' \
+ -H "Authorization: Bearer $token" | grep -q '"next_batch"'
+ curl -fsS http://127.0.0.1:8080/_matrix/client/v3/pushrules/ \
+ -H "Authorization: Bearer $token" | grep -q '"global"'
+
+ # ...and the admin API does not.
+ status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/_warp/admin/v1/stats \
+ -H "Authorization: Bearer $token")"
+ test "$status" = "404"
diff --git a/.gitignore b/.gitignore
index a7d1daa..778b047 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,7 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
+
+# Local planning and design material
+/PLAN/
+/DESIGN/
diff --git a/Makefile b/Makefile
index c47fac9..52eb390 100644
--- a/Makefile
+++ b/Makefile
@@ -128,6 +128,38 @@ admin-test: ## Typecheck the frontend and test warp-admin
admin-docker: ## Build the warp-admin container image
docker build -t $(ADMIN_IMAGE):$(VERSION) -f $(ADMIN_DIR)/Dockerfile .
+# --- Web client (apps/web) --------------------------------------------
+# Optional too: warp-web serves the built client and proxies its Matrix
+# requests to the homeserver.
+
+WEB_DIR := apps/web
+WEB_IMAGE ?= warp-web
+
+.PHONY: web-web
+web-web: ## Build the web client frontend into apps/web/internal/client/build/dist
+ $(PNPM) --filter @warp/web build
+
+.PHONY: web
+web: web-web ## Build bin/warp-web with the client embedded
+ cd $(WEB_DIR) && $(GO) build -ldflags "-X main.version=$(VERSION)" -o ../../$(BIN_DIR)/warp-web$(EXE) ./cmd/warp-web
+
+.PHONY: web-run
+web-run: web ## Run warp-web against http://localhost:8008
+ ./$(BIN_DIR)/warp-web$(EXE) -homeserver http://localhost:8008
+
+.PHONY: web-dev
+web-dev: ## Run the client with hot reload on :5175, proxied to localhost:8008
+ $(PNPM) --filter @warp/web dev
+
+.PHONY: web-test
+web-test: ## Typecheck the frontend and test warp-web
+ $(PNPM) -r typecheck
+ cd $(WEB_DIR) && $(GO) vet ./... && $(GO) test ./...
+
+.PHONY: web-docker
+web-docker: ## Build the warp-web container image
+ docker build -t $(WEB_IMAGE):$(VERSION) -f $(WEB_DIR)/Dockerfile .
+
.PHONY: complement-image
complement-image: ## Build the Complement homeserver image
docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile .
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index 26b3faf..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,418 +0,0 @@
-# Warp — a Matrix homeserver and client suite in Go
-
-Project plan. Warp is a from-scratch [Matrix](https://spec.matrix.org) homeserver
-written in Go, taking architectural lessons from
-[Dendrite](https://github.com/element-hq/dendrite) without copying its code,
-plus a family of first-party clients (admin panel, web, desktop, mobile).
-
----
-
-## 1. Goals and non-goals
-
-**Goals**
-
-- Spec-conformant homeserver: Client-Server API, Server-Server (federation) API,
- Application Service API, Push Gateway client side.
-- **Federation is optional.** Warp must run as a fully isolated, private
- homeserver with federation switched off, and be switchable at runtime.
-- Single binary, single process ("monolith"). Dendrite's polylith mode was
- deprecated for good reason — the internal component boundaries stay, the
- network hops go.
-- Three storage drivers: **PostgreSQL** (production), **SQLite** (dev /
- single-user), **PocketBase** (all-in-one appliance deployments).
-- Pass [Complement](https://github.com/matrix-org/complement) (the official
- integration test suite, itself written in Go).
-- Federate with Synapse, Dendrite, Conduit/Conduwuit/Tuwunel when enabled.
-- First-party clients: admin web panel, web client, desktop client, Android
- app (iOS later), all sharing as much code as possible.
-
-**Non-goals (for now)**
-
-- Identity server, SSO/OIDC provider (delegate to MAS later), VoIP/TURN
- provisioning, legacy Sytest.
-
----
-
-## 2. Key decisions
-
-| Decision | Choice | Why |
-|---|---|---|
-| Go version | 1.26, `net/http` with pattern routing (`GET /_matrix/client/v3/...`) | No router dependency needed since Go 1.22. |
-| Event core (canonical JSON, hashing, ed25519 signing, auth rules, state resolution v2) | **Own implementation**, tested against `gomatrixserverlib` test vectors and Complement | This is the heart of a homeserver and the point of the project. Fallback: import `gomatrixserverlib` if it stalls the schedule. |
-| Internal messaging | Go interfaces + channels, one process | Dendrite embeds NATS JetStream; we don't need durable internal queues until we do. Design the producer/consumer interfaces so NATS can be dropped in later. |
-| Storage | `database/sql` + hand-written SQL behind a per-component `Storage` interface; drivers: `pgx` (Postgres), `modernc.org/sqlite` (pure Go SQLite), PocketBase (embedded `core.App`, see §4) | Same dual-backend model as Dendrite plus one; no ORM. |
-| Migrations | Embedded SQL files, per component, `embed.FS` | Simple, reviewable. |
-| Config | Single YAML file + env overrides; a small set of settings (federation on/off, registration on/off) also mutable at runtime via admin API and persisted in DB | |
-| Logging / metrics / tracing | `log/slog`, Prometheus `/metrics`, OpenTelemetry (later) | |
-| Room versions | Implement the latest stable room version first (the one `m.room.create` defaults to in the current spec), then backfill older versions as needed for federation | Old room versions matter only when joining old rooms elsewhere. |
-| Sync | `/sync` v3 first; Simplified Sliding Sync (MSC4186) as a later phase | Element X and our own mobile app want sliding sync; classic clients require `/sync`. |
-| Frontend stack (admin + web client) | Vue 3 + TypeScript + Vite + Pinia; **Tailwind CSS** with a hand-rolled component set in `apps/ui` (Headless UI / Radix Vue for accessible primitives: dialogs, menus, listboxes) | Same UI code runs in the browser and inside the Wails desktop shell; Tailwind keeps the bundle small and the design system ours. |
-| Desktop shell | **Wails v3** (Go host + webview) reusing the Vue web client | Pure-Go UI toolkits (Fyne/Gio) would mean a second UI codebase. |
-| Mobile | Flutter + `matrix_dart_sdk` (famedly) with vodozemac for E2EE | Mature SDK; same codebase later ships to iOS. |
-| Client protocol/crypto in Go | `mautrix-go` (+ `goolm` pure-Go Olm/Megolm) for the desktop host and any Go-side tooling; `matrix-js-sdk` + `matrix-sdk-crypto-wasm` in the browser | Do not write our own client-side crypto. |
-
----
-
-## 3. Federation switch
-
-`federation.enabled` is a first-class mode, not an afterthought.
-
-```yaml
-federation:
- enabled: false # default off for a new install
- allowlist: [] # if non-empty, only these server names may federate
- denylist: []
- allow_runtime_toggle: true
-```
-
-**When disabled:**
-
-| Area | Behaviour |
-|---|---|
-| Routes | `/_matrix/federation/*`, `/_matrix/key/*`, `/.well-known/matrix/server` are not registered (404). |
-| Outbound | Transaction queue, key fetcher, remote media fetch, remote `keys/query`, remote profile/directory lookups are not started. |
-| Client API | `/join` with `server_name` for a room not known locally → `M_FORBIDDEN` with a clear message; inviting `@user:otherserver` → `M_FORBIDDEN`; `publicRooms?server=` → `M_FORBIDDEN`. |
-| Room IDs / event IDs | Unchanged — still `!id:warp.example`. Rooms created offline federate normally if federation is later enabled. |
-| Signing | Events are still signed with the server key; only publication/verification of *remote* keys is skipped. |
-
-**Runtime toggle:** `PUT /_warp/admin/v1/federation {"enabled": true}` (and the
-admin panel switch). Implemented as a `federation.Module` with `Start(ctx)` /
-`Stop()`; the HTTP mux delegates federation routes through a gate that checks
-the live flag, so no restart is required. The setting is persisted in the
-`server_settings` table and overrides the YAML value on boot if
-`allow_runtime_toggle` is true.
-
-**Allowlist mode** is the middle ground for "federate only with my other
-servers": all remote traffic is filtered by server name at the auth layer and in
-the outbound queue.
-
----
-
-## 4. Storage drivers
-
-All components talk to storage through Go interfaces (e.g.
-`roomserver.Database`, `userapi.Database`). Each driver implements them.
-
-| Driver | Engine | Target use | Notes |
-|---|---|---|---|
-| `postgres` | PostgreSQL 15+ via `pgx` | Production, multi-user, large rooms | Primary; Complement runs against this. |
-| `sqlite` | `modernc.org/sqlite` (no cgo), WAL mode | Dev, tests, single-user | Serialised writer; fine up to a few hundred users. |
-| `pocketbase` | Embedded [PocketBase](https://pocketbase.io) (`core.App`) on its own SQLite file | "Appliance" deployments: one binary that also provides admin auth, file storage (local/S3) for media, and realtime for the admin panel | Records are stored via PocketBase collections; heavier than raw SQLite and its record model is a poor fit for the event DAG, so **the roomserver/syncapi tables bypass PocketBase's collection API and use its underlying `dbx` connection directly**. PocketBase adds value for `mediaapi` (its filesystem abstraction) and for admin-panel auth. Marked experimental until it passes the same test suite as `sqlite`. |
-
-Rules:
-
-- One SQL dialect per driver, kept in `internal/storage/{postgres,sqlite,pocketbase}`.
-- Every storage test runs against all three drivers (`go test -tags=storage_all`).
-- Migrations are per-driver; `warp migrate` runs them.
-
----
-
-## 5. Server architecture
-
-```
- +--------------------------------------------------+
- clients ------> | clientapi (C-S REST handlers, auth, ratelimit) |
- +--------------------------------------------------+
- other HS -----> | federationapi [gated by federation.enabled] |
- +-------------+-------------+----------------------+
- | userapi | roomserver | syncapi |
- | accounts | event DAG | read model / streams|
- | devices | auth rules | /sync, /messages |
- | tokens | state res | filters, tokens |
- | push rules | redactions | |
- +-------------+-------------+----------------------+
- | keyserver | mediaapi | appservice |
- | device keys| upload/dl | registration, txns |
- | OTKs, x-sig| thumbnails | |
- +-------------+-------------+----------------------+
- | adminapi (/_warp/admin/*) + embedded admin SPA |
- +--------------------------------------------------+
- | internal/matrix: event, canonicaljson, signing, |
- | roomversion, auth, stateres |
- +--------------------------------------------------+
- | storage: postgres | sqlite | pocketbase |
- +--------------------------------------------------+
-```
-
-**Data-flow principle (borrowed from Dendrite):** the roomserver is the single
-writer for room events. Every event — local or federated — goes in through the
-roomserver, which authorises it, resolves state, persists it, and then emits an
-"output event" that the syncapi, federationapi (outbound, if enabled),
-appservice, and push consumers subscribe to. Nothing else writes room state.
-
----
-
-## 6. Repository layout (monorepo)
-
-```
-Warp/
-├── go.work Go workspace tying the Go modules together
-├── server/ Go module: the homeserver
-│ ├── cmd/warp/ serve, generate-keys, create-account, migrate
-│ ├── internal/
-│ │ ├── config/
-│ │ ├── httputil/
-│ │ ├── matrix/ canonicaljson, event, signing, roomversion, auth, stateres
-│ │ ├── clientapi/ federationapi/ roomserver/ syncapi/ userapi/
-│ │ ├── keyserver/ mediaapi/ appservice/ pushserver/ adminapi/
-│ │ └── storage/{postgres,sqlite,pocketbase,migrations}/
-│ ├── web/admin/ build output of apps/admin, embedded via embed.FS
-│ └── test/{complement,vectors}/
-├── apps/
-│ ├── ui/ shared Vue 3 component library + Matrix client store (Pinia)
-│ ├── admin/ admin web panel (Vue) — built into server/web/admin
-│ ├── web/ web client (Vue SPA) + Go host (`cmd/warp-web`)
-│ ├── desktop/ Wails v3 app: Go host (mautrix-go) + apps/web frontend
-│ └── mobile/ Flutter app: Android now, iOS later
-├── docs/
-├── Dockerfile
-├── Makefile
-└── warp.yaml.example
-```
-
-Frontend packages are managed with `pnpm` workspaces (`apps/ui`, `apps/admin`,
-`apps/web`); Go modules with `go.work` (`server`, `apps/web`, `apps/desktop`).
-
----
-
-## 7. Server phases
-
-Each phase ends with something runnable and a Complement subset passing.
-
-### Phase 0 — Bootstrap (1 week)
-
-- `go mod init`, layout above, `Makefile` (build/test/lint/complement), `golangci-lint`, GitHub Actions CI.
-- Config loading, `slog` logging, graceful shutdown, `/healthz`, `/metrics`.
-- HTTP server skeleton with Matrix error envelope (`errcode`, `error`), CORS,
- `GET /_matrix/client/versions`, `/.well-known/matrix/client` (and `/server`
- only when federation is enabled).
-- Storage interfaces + migrations runner; `sqlite` driver first, `postgres`
- second, `pocketbase` stub compiled but returning `ErrNotImplemented`.
-- `warp generate-keys` (ed25519 signing key file).
-- `server_settings` table and the runtime-settings service (federation flag,
- registration flag).
-
-### Phase 1 — Accounts & sessions (1–2 weeks)
-
-- **userapi**: users, passwords (argon2id), devices, access tokens,
- refresh tokens, account data, admin flag.
-- C-S endpoints: `register` (UIA with `m.login.dummy` / registration token),
- `login` (`m.login.password`), `logout`, `logout/all`, `account/whoami`,
- `devices/*`, `profile/*`, `user/{id}/account_data/*`, `capabilities`.
-- Auth middleware, per-endpoint rate limiting (`M_LIMIT_EXCEEDED`).
-- `warp create-account [--admin]` CLI.
-- **adminapi v1** skeleton: admin-only auth, `GET/PUT /federation`,
- `GET/PUT /registration`, `GET /users`, `POST /users`, `GET /stats`.
-- Complement: `TestRegistration`, `TestLogin`, `TestDeviceManagement`.
-
-### Phase 2 — Event core (2–3 weeks, the hard part)
-
-Pure library code under `internal/matrix`, no HTTP, no DB.
-
-- Canonical JSON, event/PDU model, hashing, redaction algorithm.
-- ed25519 signing and verification, key IDs.
-- Room version registry.
-- Authorisation rules (full spec list).
-- State resolution v2.
-
-### Phase 3 — Rooms, local only (2–3 weeks)
-
-- **roomserver** storage and input pipeline (validate → auth → resolve →
- persist → emit).
-- C-S: `createRoom`, `join`, `leave`, `invite`, `kick`, `ban`,
- `send/{type}/{txnId}`, `state/*`, `redact`, `rooms/{id}/event/{id}`,
- `members`, `joined_members`, aliases, `publicRooms`.
-- Federation-disabled behaviours from §3 enforced here (remote joins/invites
- rejected).
-- adminapi: room list, room detail, purge room, force-leave.
-- Complement: `TestRoomCreate`, `TestRoomState`, `TestMembership*`, `TestRedactions`.
-
-### Phase 4 — Sync (2 weeks)
-
-- **syncapi** read model, stream tokens, `GET /sync`, `/messages`,
- `/context`, filters, lazy-loading members.
-- EDUs: typing, receipts, presence (config flag), `to_device`.
-- Complement: `TestSync*`, `TestMessagesPagination`, `TestTyping`, `TestReceipts`.
-
-→ **Milestone M1**: Element Web works against Warp with federation off.
- The admin panel and web client (§8) can now be developed against a real server.
-
-### Phase 5 — Federation (3–4 weeks)
-
-- `federation.Module` with `Start/Stop`, route gate, allow/deny lists.
-- Server keys, discovery, `X-Matrix` auth, `/send`, joins/leaves/invites,
- `/state`, `/state_ids`, `/backfill`, `/get_missing_events`, queries.
-- Outbound queue with backoff, blacklist, persistence.
-- Complement federation tests.
-
-→ **Milestone M2**: Warp joins `#matrix:matrix.org`; toggling federation off
- at runtime cleanly stops all remote traffic and back on resumes it.
-
-### Phase 6 — Media (1 week)
-
-- `mediaapi`, thumbnails, authenticated media, remote media cache (federation
- only). PocketBase driver uses PocketBase's filesystem abstraction (local/S3).
-
-### Phase 7 — E2EE support (2 weeks)
-
-- `keyserver`: device keys, OTKs, fallback keys, cross-signing, device list
- tracking (local always; federated only when enabled), key backup,
- `sendToDevice`.
-
-### Phase 8 — Push & notifications (1 week)
-
-- Push rules, pushers, notification counts, push gateway client. Needed by the
- mobile app.
-
-### Phase 9 — Application services & admin completion (1–2 weeks)
-
-- AS registration, namespaces, transactions, masquerading.
-- adminapi: full user management (deactivate, reset password, devices, admin
- flag), server notices, media quota, registration tokens, federation
- allow/deny list editing, audit log.
-
-### Phase 10 — Hardening & ops
-
-- Simplified Sliding Sync (MSC4186), spaces, threads, search.
-- PocketBase driver to parity with `sqlite`.
-- Tracing, dashboards, Dockerfile, compose/Helm examples, load testing.
-
----
-
-## 8. Client sub-projects
-
-All clients speak plain Matrix C-S API, so they also work against Synapse — and
-Element works against Warp. Nothing proprietary in the wire protocol except
-the `/_warp/admin/*` API used by the admin panel.
-
-### 8.1 Admin web panel — Go + Vue (`apps/admin`)
-
-- **Stack**: Vue 3 + TS + Vite + Pinia + Tailwind CSS, components from
- `apps/ui`. Built assets embedded into the `warp` binary and
- served at `/_warp/admin/`; no separate process.
-- **Backend**: `server/internal/adminapi` (Go). Auth = Matrix login of a user
- with the admin flag; the panel uses the normal access token.
-- **Features by priority**:
- 1. Dashboard: users, rooms, events/min, federation status, storage driver, version.
- 2. Federation switch, allow/deny list editor, per-server health (queue depth, backoff).
- 3. Users: list/search, create, deactivate, reset password, devices, admin flag, registration tokens.
- 4. Rooms: list/search, members, state viewer, purge, force-leave, alias management.
- 5. Media: usage per user, quotas, purge remote media.
- 6. Settings: registration on/off, rate limits, server notices, appservice registrations.
- 7. Audit log of admin actions.
-- **Starts**: after server Phase 1 (dashboard + users + federation switch),
- grows with each server phase.
-
-### 8.2 Web client — Go + Vue (`apps/web`)
-
-- **Frontend**: Vue 3 SPA in `apps/web/frontend`, built on the shared
- `apps/ui` component library and a Pinia-based Matrix store wrapping
- `matrix-js-sdk` (sync, rooms, timeline) with `matrix-sdk-crypto-wasm` for E2EE.
- Go is not a good fit for in-browser Matrix crypto; the Rust-Wasm crypto is
- what Element uses and is audited.
-- **Go host** (`apps/web/cmd/warp-web`): serves the SPA, injects runtime config
- (homeserver URL, branding), handles `/.well-known` for client discovery,
- optional reverse proxy to the homeserver to avoid CORS. Can also be embedded
- into the `warp` binary behind `web_client.enabled: true`.
-- **Feature order**: login/register → room list + timeline → send text/media →
- read receipts, typing → invites/joins, room settings → E2EE (device
- verification, key backup) → threads, reactions, spaces → voice messages,
- calls (much later).
-- **Starts**: after server M1.
-
-### 8.3 Desktop client — Go (`apps/desktop`)
-
-- **Stack**: Wails v3. The UI is the same Vue frontend as `apps/web` (imported
- as a workspace package), so web and desktop share every screen.
-- **Go side**: `mautrix-go` client with `goolm` (pure Go Olm/Megolm, no cgo)
- for E2EE, local encrypted SQLite store for sync state and keys, OS keychain
- for the access token, native notifications, tray icon, auto-update.
- Rationale: on desktop, doing sync + crypto in Go instead of in the webview
- gives a persistent local store, background sync, and lets the frontend stay
- thin — the Vue store talks to Go via Wails bindings instead of
- `matrix-js-sdk`. The store therefore has two backends (`js-sdk` in the
- browser, `wails` on desktop) behind one interface.
-- **Targets**: Windows, macOS, Linux.
-- **Starts**: once the web client has a stable room list + timeline.
-
-### 8.4 Android app — Flutter (`apps/mobile`)
-
-- **Stack**: Flutter + `matrix_dart_sdk` (famedly) + `flutter_vodozemac` for
- E2EE, `flutter_secure_storage`, FCM/UnifiedPush for push (via the server's
- push gateway support from Phase 8), Riverpod for state.
-- **Feature order**: login → room list → timeline + send → push notifications
- → E2EE + verification → media → calls (later).
-- Uses Simplified Sliding Sync when the server has it (Phase 10) for fast
- startup; falls back to `/sync`.
-- **Starts**: after server Phase 8 (push) for a useful MVP; UI work can begin
- after M1.
-
-### 8.5 iPhone app — future
-
-- Same Flutter codebase; work items are APNs push (needs a push gateway such as
- Sygnal, or a Warp-native one), iOS-specific secure storage and keychain,
- App Store review. Deferred until the Android app is stable.
-
----
-
-## 9. Testing strategy
-
-| Level | Tool | What |
-|---|---|---|
-| Unit | `go test`, table-driven | `internal/matrix` (100% of spec examples); storage against all three drivers |
-| Component | `httptest` | Each API package with a fake roomserver/userapi; federation gate tests (routes 404 when disabled, resume when enabled) |
-| Integration | **Complement** (Docker) | Two profiles: `federation=off` (C-S tests only) and `federation=on`; track pass counts as the primary progress metric |
-| Interop | Manual + scripted | Federate a dev instance with a Synapse in Docker; join `#matrix:matrix.org` |
-| Fuzz | `go test -fuzz` | Canonical JSON, event parsing, X-Matrix header parsing, federation PDU intake |
-| Frontend | Vitest + Playwright | `apps/ui` components; end-to-end login/send/receive against a Warp container |
-| Mobile | Flutter `test` + `integration_test` | Against the same Warp container |
-
----
-
-## 10. Milestones
-
-| # | Milestone | Definition of done |
-|---|---|---|
-| M1 | "Hello Matrix" | Element Web can register, log in, create a room, send messages, see them after reload — with federation **off** (server Phases 0–4). Admin panel shows dashboard, users, federation switch. |
-| M2 | "On the network" | Federation toggled on from the admin panel; Warp joins a room on matrix.org; Synapse users join a Warp room; toggling off stops remote traffic cleanly (Phase 5). |
-| M3 | "Daily driver" | Warp web client: E2EE rooms with cross-signing and key backup, images/files, push (Phases 6–8). Desktop client runs the same UI via Wails. |
-| M4 | "Pocket server" | Android app with push and E2EE; bridges (mautrix) run via the AS API; sliding sync; PocketBase driver at parity (Phases 9–10). |
-| M5 | "Everywhere" | iOS build of the Flutter app in TestFlight. |
-
----
-
-## 11. Reference material
-
-- Spec: https://spec.matrix.org/latest/ — start with *Room Versions* and the
- *Server-Server API* appendices.
-- Dendrite (architecture reference; read, don't paste): https://github.com/element-hq/dendrite
-- `gomatrixserverlib` (test vectors): https://github.com/matrix-org/gomatrixserverlib
-- Complement: https://github.com/matrix-org/complement
-- Federation tester: https://federationtester.matrix.org
-- PocketBase as a Go framework: https://pocketbase.io/docs/go-overview/
-- `mautrix-go` + `goolm`: https://github.com/mautrix/go
-- `matrix-js-sdk` / `matrix-sdk-crypto-wasm`: https://github.com/matrix-org/matrix-js-sdk
-- Wails v3: https://v3alpha.wails.io
-- `matrix_dart_sdk`: https://github.com/famedly/matrix-dart-sdk
-- Synapse admin API (feature checklist for our admin panel): https://element-hq.github.io/synapse/latest/usage/administration/admin_api/
-
----
-
-## 12. Open questions
-
-1. **State storage model**: Dendrite's snapshot + block NIDs vs. per-event
- full-state rows. Decide in Phase 3 after a spike with a 10k-event room.
-2. **Room version breadth** for federation compatibility (v6+ probably enough).
-3. **Presence** default on/off.
-4. **PocketBase scope**: full storage driver as specified, or only media
- filesystem + admin auth on top of the `sqlite` driver? The latter is far
- less work for most of the benefit. Decide after the `sqlite` driver is done.
- *Decided in Phase 10:* close to the latter. The pocketbase driver runs the
- sqlite schema and store on PocketBase's own database connection and puts
- media in PocketBase's filesystem (local or S3); PocketBase's admin auth
- and realtime are not wired up yet. See docs/deployment.md.
-5. **Desktop sync engine**: Go (`mautrix-go`) as planned, or keep
- `matrix-js-sdk` in the webview for maximum code sharing with web? Prototype
- both in a week and pick.
-6. **Push gateway for mobile**: run Sygnal, or build a small Warp-native
- FCM/APNs gateway in Go?
diff --git a/README.md b/README.md
index 74c89de..96bd2f2 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,10 @@ own process, `warp-admin`, next to the homeserver:
`docker compose --profile admin up -d`, or see
[apps/admin](apps/admin/README.md).
+The web client is a Matrix client for the browser. It is optional in the
+same way, a process of its own, `warp-web`:
+`docker compose --profile web up -d`, or see [apps/web](apps/web/README.md).
+
Also in place: argon2id passwords, hashed access and refresh tokens,
per-endpoint rate limiting, YAML + environment configuration, structured
logging, graceful shutdown, the storage interfaces with SQLite, PostgreSQL
diff --git a/apps/README.md b/apps/README.md
index 692d8fd..03e998c 100644
--- a/apps/README.md
+++ b/apps/README.md
@@ -2,10 +2,10 @@
| Directory | What | State |
|---|---|---|
-| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin; the Matrix store comes with web |
+| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin and web |
| [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API |
-| `web/` | web client (Vue SPA) + Go host | next |
-| `desktop/` | Wails v3 shell reusing the web frontend | once web has a timeline |
+| [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, sending text and files, invites; E2EE next |
+| `desktop/` | Wails v3 shell reusing the web frontend | next: web has a timeline |
| `mobile/` | Flutter app, Android first | after web |
The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the
@@ -14,4 +14,5 @@ repository root); the Go hosts join `go.work`.
```bash
pnpm install
pnpm admin:dev
+pnpm web:dev
```
diff --git a/apps/ui/README.md b/apps/ui/README.md
index 0dddd4c..4d91bcd 100644
--- a/apps/ui/README.md
+++ b/apps/ui/README.md
@@ -46,6 +46,9 @@ The stylesheet brings in:
| `WBanner` | feedback; always icon and words, never colour alone |
| `WDialog` | modal on the native `
` |
| `WSpinner` | progress |
+| `WRoomItem` | a row of the room list: avatar, name, preview, time, unread counter |
+| `WMessageBubble` | a message with sender, time and delivery status (`sending`, `sent`, `read`, `failed`) |
+| `WComposer` | the message field: attach, Enter sends, Shift+Enter adds a line |
## Where things come from
@@ -58,5 +61,7 @@ apps on the same grid: 24px, 1.75 stroke, round caps and joins.
The fonts are Outfit, Figtree and JetBrains Mono under the SIL Open Font
License.
-The Matrix store (Pinia over matrix-js-sdk) joins this package with the web
-client.
+The three chat components are the brand package's `RoomItem`,
+`MessageBubble` and `Composer`. The Matrix state itself is not here: it
+lives with the web client (`apps/web/src/stores`, `apps/web/src/matrix`),
+which the desktop app will reuse as a whole.
diff --git a/apps/ui/src/chat.ts b/apps/ui/src/chat.ts
new file mode 100644
index 0000000..709ece8
--- /dev/null
+++ b/apps/ui/src/chat.ts
@@ -0,0 +1,2 @@
+/** Delivery state of your own message, as the bubble shows it. */
+export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
diff --git a/apps/ui/src/components/WComposer.vue b/apps/ui/src/components/WComposer.vue
new file mode 100644
index 0000000..5ddbd16
--- /dev/null
+++ b/apps/ui/src/components/WComposer.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
diff --git a/apps/ui/src/components/WMessageBubble.vue b/apps/ui/src/components/WMessageBubble.vue
new file mode 100644
index 0000000..48192ee
--- /dev/null
+++ b/apps/ui/src/components/WMessageBubble.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
{{ sender }}
+
+
+ {{ time }} · edited
+
+
+ {{ shown[1] }}
+
+
+
+
+
diff --git a/apps/ui/src/components/WRoomItem.vue b/apps/ui/src/components/WRoomItem.vue
new file mode 100644
index 0000000..3328c36
--- /dev/null
+++ b/apps/ui/src/components/WRoomItem.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+ {{ name }}
+
+ {{ time }}
+
+
+ {{ preview }}
+
+
+
+
+
diff --git a/apps/ui/src/icons/arrow-down.svg b/apps/ui/src/icons/arrow-down.svg
new file mode 100644
index 0000000..0c8840a
--- /dev/null
+++ b/apps/ui/src/icons/arrow-down.svg
@@ -0,0 +1 @@
+arrow-down
diff --git a/apps/ui/src/icons/download.svg b/apps/ui/src/icons/download.svg
new file mode 100644
index 0000000..db0d33d
--- /dev/null
+++ b/apps/ui/src/icons/download.svg
@@ -0,0 +1 @@
+download
diff --git a/apps/ui/src/icons/file.svg b/apps/ui/src/icons/file.svg
new file mode 100644
index 0000000..ea0ff7f
--- /dev/null
+++ b/apps/ui/src/icons/file.svg
@@ -0,0 +1 @@
+file
diff --git a/apps/ui/src/icons/index.ts b/apps/ui/src/icons/index.ts
index e0534b1..1ac0d6c 100644
--- a/apps/ui/src/icons/index.ts
+++ b/apps/ui/src/icons/index.ts
@@ -2,6 +2,7 @@
// on the same grid (24px, 1.75 stroke, round caps, currentColor).
export type IconName =
+ | 'arrow-down'
| 'attach'
| 'bell'
| 'check'
@@ -9,7 +10,9 @@ export type IconName =
| 'chevron-right'
| 'close'
| 'copy'
+ | 'download'
| 'edit'
+ | 'file'
| 'globe'
| 'history'
| 'home'
@@ -38,6 +41,7 @@ export type IconName =
| 'sun'
| 'trash'
| 'user'
+ | 'users'
| 'video'
const files = import.meta.glob('./*.svg', { query: '?raw', import: 'default', eager: true })
diff --git a/apps/ui/src/icons/users.svg b/apps/ui/src/icons/users.svg
new file mode 100644
index 0000000..aed50e5
--- /dev/null
+++ b/apps/ui/src/icons/users.svg
@@ -0,0 +1 @@
+users
diff --git a/apps/ui/src/index.ts b/apps/ui/src/index.ts
index 5d6b486..dbfd214 100644
--- a/apps/ui/src/index.ts
+++ b/apps/ui/src/index.ts
@@ -2,11 +2,14 @@ export { default as WAvatar } from './components/WAvatar.vue'
export { default as WBadge } from './components/WBadge.vue'
export { default as WBanner } from './components/WBanner.vue'
export { default as WButton } from './components/WButton.vue'
+export { default as WComposer } from './components/WComposer.vue'
export { default as WCheckbox } from './components/WCheckbox.vue'
export { default as WDialog } from './components/WDialog.vue'
export { default as WIcon } from './components/WIcon.vue'
export { default as WIconButton } from './components/WIconButton.vue'
export { default as WLogo } from './components/WLogo.vue'
+export { default as WMessageBubble } from './components/WMessageBubble.vue'
+export { default as WRoomItem } from './components/WRoomItem.vue'
export { default as WSelect } from './components/WSelect.vue'
export { default as WSpinner } from './components/WSpinner.vue'
export { default as WSwitch } from './components/WSwitch.vue'
@@ -15,3 +18,4 @@ export { default as WTextField } from './components/WTextField.vue'
export { iconNames, type IconName } from './icons'
export { useTheme, type ThemeChoice } from './theme'
+export type { MessageStatus } from './chat'
diff --git a/apps/ui/src/styles/extras.css b/apps/ui/src/styles/extras.css
index 90d10f9..5132c22 100644
--- a/apps/ui/src/styles/extras.css
+++ b/apps/ui/src/styles/extras.css
@@ -62,6 +62,10 @@
.wp-segmented button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal-text); }
.wp-segmented button:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
+ /* The composer field sits inside a bordered box that shows the focus;
+ a second ring around the field itself would be noise. */
+ .wp-composer-input:focus-visible { box-shadow: none; }
+
/* Danger text button */
.wp-btn-danger-solid { background: var(--danger); color: var(--surface); border-color: var(--danger); }
.wp-btn-danger-solid:hover { filter: brightness(1.06); }
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
new file mode 100644
index 0000000..5406213
--- /dev/null
+++ b/apps/web/Dockerfile
@@ -0,0 +1,52 @@
+# warp-web — the optional web client, as its own container.
+#
+# Build from the repository root (the client uses the shared apps/ui package):
+#
+# docker build -t warp-web -f apps/web/Dockerfile .
+# docker run -p 8080:8080 -e WARP_WEB_HOMESERVER=http://warp:8008 warp-web
+
+FROM node:22-alpine AS web
+
+WORKDIR /src
+RUN corepack enable
+
+# Lockfile and manifests first, so the package store survives source changes.
+COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
+COPY apps/ui/package.json ./apps/ui/
+COPY apps/web/package.json ./apps/web/
+RUN pnpm install --frozen-lockfile --filter @warp/web...
+
+COPY apps/ui ./apps/ui
+COPY apps/web ./apps/web
+RUN pnpm --filter @warp/web build
+
+
+FROM golang:1.26-alpine AS build
+
+WORKDIR /src/apps/web
+COPY apps/web/go.mod ./
+COPY apps/web/cmd ./cmd
+COPY apps/web/internal ./internal
+COPY --from=web /src/apps/web/internal/client/build/dist ./internal/client/build/dist
+
+ARG VERSION=0.0.0-dev
+ENV CGO_ENABLED=0 GOWORK=off
+RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/warp-web ./cmd/warp-web
+
+
+FROM alpine:3.21
+
+RUN apk add --no-cache ca-certificates curl && adduser -D -u 10003 warp-web
+COPY --from=build /out/warp-web /usr/local/bin/warp-web
+USER warp-web
+
+ENV WARP_WEB_LISTEN=":8080" \
+ WARP_WEB_HOMESERVER="http://warp:8008" \
+ WARP_WEB_LOG_FORMAT="json"
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
+
+ENTRYPOINT ["warp-web"]
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000..d5ef2e9
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,178 @@
+# Warp web client
+
+A Matrix client for the browser: the room list, the timeline, sending
+messages and files, invites, creating and joining rooms. It speaks the
+plain Client-Server API through [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk),
+so it works against any Matrix homeserver, and it is built on Warp's design
+system, [`@warp/ui`](../ui/README.md).
+
+The client is **optional and separate from the homeserver**, like the
+[admin panel](../admin/README.md). It ships as its own small binary,
+`warp-web`, with the built Vue app embedded. A Warp server runs the same
+whether or not you install it.
+
+```
+browser ──► warp-web :8080 ──► warp :8008
+ serves the client /_matrix/client/*
+ /config.json /_matrix/media/*
+```
+
+By default warp-web proxies the two path prefixes a client uses and
+answers `404` for everything else (federation, server keys, `/_warp/admin`),
+so it is not a second way into the rest of the homeserver. The browser
+only talks to warp-web: no CORS, and the homeserver URL is runtime
+configuration rather than baked into the build.
+
+If the homeserver is already public behind its own proxy, set
+`-public-homeserver` instead. The browser then calls it directly,
+warp-web proxies nothing, and the Content-Security-Policy allows exactly
+that one origin.
+
+## What it does
+
+- Sign in with a password, and sign up when the homeserver has
+ registration on (with a registration token when it asks for one).
+ "Stay signed in" keeps the session in `localStorage`; without it the
+ session goes with the tab. Signing out logs the session out on the
+ homeserver.
+- Room list with unread counts and mentions, invites, a filter, direct
+ messages with presence.
+- Timeline: grouped messages, day separators, room events as one-line
+ notices, older history on scroll, images, video, audio and files.
+ Media is fetched with the access token (authenticated media, Matrix 1.11)
+ and shown from `blob:` URLs.
+- Sending: text (`/me` for emotes), files by the attach button, paste or
+ drag and drop. Your message shows at once as *Sending*, then *Sent*,
+ then *Read* when someone else has read it; a failed one says *Not sent*
+ with Retry and Delete.
+- Read receipts, typing notifications, deleting messages.
+- End-to-end encryption, with the Rust crypto stack matrix-js-sdk loads
+ (`matrix-sdk-crypto-wasm`): reading and sending in encrypted rooms,
+ files included (encrypted in the browser before they are uploaded).
+ A message this session can't decrypt says why, such as "sent before
+ this session signed in", not just "encrypted".
+- Verification: verify someone's session from the room's member list,
+ or this session from one of your others, by comparing emoji (SAS).
+ Requests from others open a dialog to accept or decline.
+- Encryption setup in Settings: cross-signing keys, secret storage and a
+ key backup in one step, behind a recovery key shown once. A new session
+ verifies itself with the recovery key (or another session) and restores
+ older messages from the backup. Reset encryption when the key is lost.
+- New private rooms and direct messages are end-to-end encrypted by
+ default (a direct message only when the other person has a session with
+ keys); Create room has the switch.
+- Create a room (private, or public with an address), start a direct chat,
+ join by address or from the room directory, accept or decline invites,
+ leave.
+- Room info: topic, members and their roles, invite, edit name and topic.
+- Settings: display name, avatar, theme, encryption.
+- Phone layout: one pane at a time, the room list is the home screen.
+
+**Not yet:** per-message trust shields (a message from an unverified
+session looks like any other), verifying a person's identity in a direct
+message rather than one session at a time, QR-code verification, a warning
+when someone's identity changes, encrypted thumbnails for images you send
+(the receiving side shows the whole image), and a passphrase for the
+crypto store. After that: replies, reactions, threads, spaces,
+notifications.
+
+### Encryption keys in the browser
+
+Each session keeps its keys in IndexedDB under its own name
+(`warp-web-crypto::`), unencrypted at rest like any web
+client's without a passphrase. Signing out deletes them; a session that
+ends some other way (signed out elsewhere, the token revoked) has them
+deleted when the page next starts. A session kept only for the tab
+("Stay signed in" off) leaves its store behind when the tab closes;
+the session can't be reused, but the keys stay until site data is
+cleared. The `.wasm` file is most of the download (about 2 MB
+compressed) and needs `script-src 'wasm-unsafe-eval'`, which warp-web's
+Content-Security-Policy allows; it allows no JavaScript `eval`.
+
+## Run it
+
+### With Docker Compose
+
+```bash
+cd deploy/compose
+docker compose --profile web up -d
+```
+
+The client is on . Leave out `--profile web` and it
+isn't installed.
+
+### As a container
+
+```bash
+docker build -t warp-web -f apps/web/Dockerfile .
+docker run -p 8080:8080 -e WARP_WEB_HOMESERVER=http://warp:8008 warp-web
+```
+
+### As a binary
+
+```bash
+make web
+./bin/warp-web -homeserver http://localhost:8008
+```
+
+| Flag | Environment | Default |
+|---|---|---|
+| `-listen` | `WARP_WEB_LISTEN` | `:8080` |
+| `-homeserver` | `WARP_WEB_HOMESERVER` | `http://localhost:8008` |
+| `-public-homeserver` | `WARP_WEB_PUBLIC_HOMESERVER` | empty: proxy through warp-web |
+| `-log-format` | `WARP_WEB_LOG_FORMAT` | `text` (or `json`) |
+
+`/healthz` answers `ok` for container health checks. `/config.json` is what
+the client reads on start: `{"homeserver_url": "", "version": "..."}`, an
+empty URL meaning "this origin".
+
+## Deploying
+
+- Put warp-web behind your TLS proxy. It sends a strict
+ Content-Security-Policy, `X-Frame-Options: DENY` and
+ `Referrer-Policy: no-referrer` with the client's pages.
+- Keep the proxy's read timeout above 30 seconds: `/sync` long-polls.
+- Allow request bodies as large as the homeserver's
+ `media.max_upload_bytes`; uploads pass through warp-web.
+- warp-web adds `X-Forwarded-For`. The homeserver uses it for rate limits
+ only with `client_api.trust_x_forwarded_for` on; without it, every
+ client counts against warp-web's own address. Turn it on only when every
+ client reaches the homeserver through proxies you control.
+
+## Develop
+
+```bash
+pnpm install
+pnpm web:dev # http://localhost:5175, proxied to localhost:8008
+```
+
+`WARP_WEB_HOMESERVER` points the dev server at another homeserver. Vite
+proxies the same paths warp-web does, so the app code is the same in both.
+
+| Command | What |
+|---|---|
+| `make web-web` | build the frontend into `internal/client/build/dist` |
+| `make web` | build the frontend, then `bin/warp-web` with it embedded |
+| `make web-test` | typecheck the frontend, vet and test the Go host |
+| `make web-docker` | build the container image |
+
+A `warp-web` compiled before the frontend is built still runs. It serves a
+page saying the client is missing.
+
+## Layout
+
+| Path | What |
+|---|---|
+| `src/matrix/` | everything that touches matrix-js-sdk: the client's lifecycle, the timeline projection, sending, media, encryption (`crypto.ts`, `attachments.ts`) |
+| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), profile, toasts (Pinia) |
+| `src/views/` | sign-in, sign-up, home, room |
+| `src/components/` | timeline, message body, room info, dialogs |
+| `cmd/warp-web/` | the binary |
+| `internal/host/` | static serving, `/config.json` and the allow-listed proxy |
+| `internal/client/` | the embedded build |
+
+The SDK's objects change in place and emit events. The stores turn those
+events into a version counter (bumped at most once a frame), and the views
+are computed from the SDK on each bump. Keeping the SDK behind `src/matrix`
+and `src/stores` is what will let the desktop app put its Go backend
+behind the same stores.
diff --git a/apps/web/cmd/warp-web/main.go b/apps/web/cmd/warp-web/main.go
new file mode 100644
index 0000000..9e20ff9
--- /dev/null
+++ b/apps/web/cmd/warp-web/main.go
@@ -0,0 +1,139 @@
+// Command warp-web serves the Warp web client. It is optional: a Warp
+// server runs without it, and anyone who wants the client in the browser
+// runs this next to it.
+//
+// warp-web -homeserver http://localhost:8008 -listen :8080
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/dariuszjaniak/warp/apps/web/internal/client"
+ "github.com/dariuszjaniak/warp/apps/web/internal/host"
+)
+
+// Set with -ldflags "-X main.version=...".
+var version = "0.0.0-dev"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "warp-web:", err)
+ os.Exit(1)
+ }
+}
+
+func env(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func parseURL(name, raw string) (*url.URL, error) {
+ u, err := url.Parse(strings.TrimRight(raw, "/"))
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+ return u, nil
+}
+
+func run() error {
+ listen := flag.String("listen", env("WARP_WEB_LISTEN", ":8080"), "address to listen on (WARP_WEB_LISTEN)")
+ homeserver := flag.String("homeserver", env("WARP_WEB_HOMESERVER", "http://localhost:8008"),
+ "base URL of the homeserver, proxied for the client (WARP_WEB_HOMESERVER)")
+ public := flag.String("public-homeserver", env("WARP_WEB_PUBLIC_HOMESERVER", ""),
+ "public URL of the homeserver; when set, the browser calls it directly and nothing is proxied (WARP_WEB_PUBLIC_HOMESERVER)")
+ logFormat := flag.String("log-format", env("WARP_WEB_LOG_FORMAT", "text"), "text or json (WARP_WEB_LOG_FORMAT)")
+ showVersion := flag.Bool("version", false, "print the version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("warp-web", version)
+ return nil
+ }
+
+ var logger *slog.Logger
+ switch *logFormat {
+ case "json":
+ logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
+ case "text":
+ logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
+ default:
+ return fmt.Errorf("unknown log format %q", *logFormat)
+ }
+ slog.SetDefault(logger)
+
+ opts := host.Options{Logger: logger, Version: version}
+ mode, target := "proxy", *homeserver
+ if *public != "" {
+ u, err := parseURL("public-homeserver", *public)
+ if err != nil {
+ return err
+ }
+ opts.PublicHomeserver = u
+ mode, target = "direct", *public
+ } else {
+ u, err := parseURL("homeserver", *homeserver)
+ if err != nil {
+ return err
+ }
+ opts.Homeserver = u
+ }
+
+ files, built := client.FS()
+ if !built {
+ logger.Warn("the web client was not built into this binary; serving a placeholder page")
+ files = nil
+ }
+ opts.Client = files
+
+ h, err := host.New(opts)
+ if err != nil {
+ return err
+ }
+
+ srv := &http.Server{
+ Addr: *listen,
+ Handler: h,
+ ReadHeaderTimeout: 10 * time.Second,
+ // Uploads and downloads of large media pass through here.
+ ReadTimeout: 15 * time.Minute,
+ WriteTimeout: 15 * time.Minute,
+ IdleTimeout: 2 * time.Minute,
+ MaxHeaderBytes: 64 << 10,
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ errc := make(chan error, 1)
+ go func() {
+ logger.Info("warp-web listening", "addr", *listen, "homeserver", target, "mode", mode, "version", version)
+ errc <- srv.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errc:
+ if !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+ case <-ctx.Done():
+ }
+
+ logger.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/apps/web/go.mod b/apps/web/go.mod
new file mode 100644
index 0000000..5f54aaa
--- /dev/null
+++ b/apps/web/go.mod
@@ -0,0 +1,3 @@
+module github.com/dariuszjaniak/warp/apps/web
+
+go 1.26.4
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..8c0de86
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Warp
+
+
+
+
+
+
diff --git a/apps/web/internal/client/build/README.md b/apps/web/internal/client/build/README.md
new file mode 100644
index 0000000..ea0cdf4
--- /dev/null
+++ b/apps/web/internal/client/build/README.md
@@ -0,0 +1,4 @@
+`pnpm build` in `apps/web` writes the client to `dist/` here, and
+`internal/client` embeds it into the `warp-web` binary. This file keeps the
+directory in git so the Go code compiles before the frontend is built; such a
+binary serves a page saying the client has not been built.
diff --git a/apps/web/internal/client/client.go b/apps/web/internal/client/client.go
new file mode 100644
index 0000000..60b26e9
--- /dev/null
+++ b/apps/web/internal/client/client.go
@@ -0,0 +1,23 @@
+// Package client holds the built web client, embedded into the binary.
+package client
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:build
+var build embed.FS
+
+// FS returns the built client, rooted at its index.html. ok is false when
+// the binary was compiled before `pnpm build`, and the client is missing.
+func FS() (files fs.FS, ok bool) {
+ sub, err := fs.Sub(build, "build/dist")
+ if err != nil {
+ return nil, false
+ }
+ if _, err := fs.Stat(sub, "index.html"); err != nil {
+ return nil, false
+ }
+ return sub, true
+}
diff --git a/apps/web/internal/host/host.go b/apps/web/internal/host/host.go
new file mode 100644
index 0000000..82f4faf
--- /dev/null
+++ b/apps/web/internal/host/host.go
@@ -0,0 +1,273 @@
+// Package host is the warp-web HTTP server: it serves the built web client,
+// its runtime configuration and, by default, proxies the client's Matrix
+// requests to the homeserver.
+//
+// Proxying keeps the client same-origin: no CORS, no homeserver URL baked
+// into the build, and a homeserver that only has to be reachable from
+// warp-web. A deployment that already serves the homeserver publicly can
+// turn the proxy off and send the browser there directly instead.
+package host
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+)
+
+// Options configures the handler. Exactly one of Homeserver and
+// PublicHomeserver is set.
+type Options struct {
+ // Homeserver is where proxied requests go, e.g. http://warp:8008. The
+ // browser then talks only to warp-web.
+ Homeserver *url.URL
+ // PublicHomeserver is the homeserver's public URL, for a deployment
+ // where the browser calls it directly; warp-web then proxies nothing.
+ PublicHomeserver *url.URL
+ // Client is the built web client rooted at its index.html; nil serves
+ // a page saying it has not been built.
+ Client fs.FS
+ // Version is reported in /config.json.
+ Version string
+ // Logger receives proxy errors. Defaults to slog.Default().
+ Logger *slog.Logger
+ // Transport overrides the proxy's round tripper, for tests.
+ Transport http.RoundTripper
+}
+
+// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
+// Client-Server API and media. Federation, server keys and the admin API
+// are not reachable through warp-web.
+var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"}
+
+// Config is the body of /config.json, which the client reads on start.
+type Config struct {
+ // HomeserverURL is where the client sends Matrix requests; empty
+ // means its own origin, through warp-web's proxy.
+ HomeserverURL string `json:"homeserver_url"`
+ Version string `json:"version"`
+}
+
+// New returns the warp-web handler.
+func New(opts Options) (http.Handler, error) {
+ if (opts.Homeserver == nil) == (opts.PublicHomeserver == nil) {
+ return nil, errors.New("host: set exactly one of Homeserver and PublicHomeserver")
+ }
+ for _, u := range []*url.URL{opts.Homeserver, opts.PublicHomeserver} {
+ if u != nil && ((u.Scheme != "http" && u.Scheme != "https") || u.Host == "") {
+ return nil, errors.New("host: the homeserver must be an absolute http(s) URL")
+ }
+ }
+ if opts.Logger == nil {
+ opts.Logger = slog.Default()
+ }
+
+ h := &handler{opts: opts}
+
+ cfg := Config{Version: opts.Version}
+ connect := "'self'"
+ if u := opts.PublicHomeserver; u != nil {
+ cfg.HomeserverURL = strings.TrimRight(u.String(), "/")
+ connect += " " + u.Scheme + "://" + u.Host
+ }
+ body, err := json.Marshal(cfg)
+ if err != nil {
+ return nil, err
+ }
+ h.config = body
+ // Media is fetched with the access token and shown from blob: URLs,
+ // because authenticated media can't be loaded by an . The
+ // end-to-end encryption code is WebAssembly (matrix-sdk-crypto-wasm),
+ // which needs 'wasm-unsafe-eval' to compile; it allows no JavaScript eval.
+ h.csp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src " + connect + "; " +
+ "worker-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
+
+ if opts.Homeserver != nil {
+ h.proxy = newProxy(opts)
+ }
+
+ if opts.Client != nil {
+ index, err := fs.ReadFile(opts.Client, "index.html")
+ if err != nil {
+ return nil, err
+ }
+ h.index = index
+ }
+ return h, nil
+}
+
+func newProxy(opts Options) *httputil.ReverseProxy {
+ transport := opts.Transport
+ if transport == nil {
+ transport = &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 256,
+ MaxIdleConnsPerHost: 256,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ // /sync long-polls for up to its timeout, 30s from this client.
+ ResponseHeaderTimeout: 2 * time.Minute,
+ }
+ }
+ upstream := opts.Homeserver
+ return &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ pr.SetURL(upstream)
+ pr.SetXForwarded()
+ // The client authenticates with its access token, never a
+ // cookie; don't pass on any a shared domain might have set.
+ pr.Out.Header.Del("Cookie")
+ },
+ Transport: transport,
+ // Pass data on as it arrives, so a large download streams.
+ FlushInterval: -1,
+ ModifyResponse: func(res *http.Response) error {
+ res.Header.Del("Set-Cookie")
+ if res.Header.Get("Cache-Control") == "" {
+ res.Header.Set("Cache-Control", "no-store")
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err)
+ writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The web client service can’t reach the homeserver.")
+ },
+ }
+}
+
+type handler struct {
+ opts Options
+ proxy *httputil.ReverseProxy
+ index []byte
+ config []byte
+ csp string
+}
+
+func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("Referrer-Policy", "no-referrer")
+
+ p := r.URL.Path
+ switch {
+ case p == "/healthz":
+ hdr.Set("Content-Type", "text/plain; charset=utf-8")
+ hdr.Set("Cache-Control", "no-store")
+ _, _ = w.Write([]byte("ok\n"))
+ return
+
+ case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"):
+ // A path that cleans to something else (dot segments, doubled
+ // slashes) is refused rather than normalised: what is allowed
+ // here must be exactly what the homeserver routes.
+ if h.proxy == nil || !clean(p) || !proxied(p) {
+ writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the web client service.")
+ return
+ }
+ h.proxy.ServeHTTP(w, r)
+ return
+ }
+
+ hdr.Set("X-Frame-Options", "DENY")
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ hdr.Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if p == "/config.json" {
+ hdr.Set("Content-Type", "application/json")
+ hdr.Set("Cache-Control", "no-cache")
+ http.ServeContent(w, r, "config.json", time.Time{}, bytes.NewReader(h.config))
+ return
+ }
+
+ hdr.Set("Content-Security-Policy", h.csp)
+ h.serveStatic(w, r)
+}
+
+// clean reports whether p is already in canonical form. A trailing slash
+// counts as canonical: the C-S API has routes that end in one, like
+// GET /pushrules/.
+func clean(p string) bool {
+ c := path.Clean(p)
+ if strings.HasSuffix(p, "/") && c != "/" {
+ c += "/"
+ }
+ return c == p
+}
+
+func proxied(p string) bool {
+ for _, prefix := range proxiedPrefixes {
+ if strings.HasPrefix(p, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) {
+ if h.opts.Client == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(notBuiltPage))
+ return
+ }
+
+ name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ if name != "" && name != "index.html" {
+ if st, err := fs.Stat(h.opts.Client, name); err == nil && st.Mode().IsRegular() {
+ if strings.HasPrefix(name, "assets/") {
+ // Vite puts a content hash in every file name under assets/.
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ http.ServeFileFS(w, r, h.opts.Client, name)
+ return
+ }
+ if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") {
+ // A missing asset is a 404, not the app: answering a stale
+ // script URL with HTML only produces a confusing MIME error.
+ // Deeper paths are routes even with a dot in them, like
+ // /room/!abc:warp.example.
+ http.NotFound(w, r)
+ return
+ }
+ }
+
+ // Everything else is a route of the single-page app.
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
+}
+
+func writeError(w http.ResponseWriter, status int, errcode, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg})
+}
+
+const notBuiltPage = `
+
+Warp
+
+The web client isn’t built into this binary
+This warp-web was compiled before the frontend. Build it, then the binary:
+pnpm --filter @warp/web buildmake web
+`
diff --git a/apps/web/internal/host/host_test.go b/apps/web/internal/host/host_test.go
new file mode 100644
index 0000000..fad83ab
--- /dev/null
+++ b/apps/web/internal/host/host_test.go
@@ -0,0 +1,306 @@
+package host
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+// upstream records what reached the homeserver.
+type upstream struct {
+ srv *httptest.Server
+ hits []*http.Request
+}
+
+func newUpstream(t *testing.T) *upstream {
+ t.Helper()
+ u := &upstream{}
+ u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ u.hits = append(u.hits, r.Clone(r.Context()))
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Set-Cookie", "tracker=1")
+ if strings.HasPrefix(r.URL.Path, "/_matrix/client/v1/media/") {
+ w.Header().Set("Cache-Control", "private, max-age=86400")
+ }
+ _, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`))
+ }))
+ t.Cleanup(u.srv.Close)
+ return u
+}
+
+var clientFS = fstest.MapFS{
+ "index.html": {Data: []byte("client ")},
+ "favicon.svg": {Data: []byte(" ")},
+ "assets/index-a1.js": {Data: []byte("console.log(1)")},
+}
+
+func newHandler(t *testing.T, up *upstream, built bool) http.Handler {
+ t.Helper()
+ target, _ := url.Parse(up.srv.URL)
+ opts := Options{Homeserver: target, Version: "1.2.3"}
+ if built {
+ opts.Client = clientFS
+ }
+ h, err := New(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, target, nil)
+ for k, v := range header {
+ req.Header[k] = v
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestProxiesClientAndMediaPaths(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ cases := []struct{ method, path string }{
+ {http.MethodGet, "/_matrix/client/versions"},
+ {http.MethodPost, "/_matrix/client/v3/login"},
+ {http.MethodGet, "/_matrix/client/v3/sync?timeout=30000&since=s1_2"},
+ {http.MethodPut, "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1"},
+ {http.MethodPost, "/_matrix/media/v3/upload?filename=cat.png"},
+ {http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc"},
+ {http.MethodGet, "/_matrix/client/v3/pushrules/"},
+ }
+ for _, c := range cases {
+ rec := do(h, c.method, c.path, nil)
+ if rec.Code != http.StatusOK {
+ t.Errorf("%s %s: status %d, want 200", c.method, c.path, rec.Code)
+ }
+ }
+ if len(up.hits) != len(cases) {
+ t.Fatalf("upstream saw %d requests, want %d", len(up.hits), len(cases))
+ }
+ // Escaped IDs and the query reach the homeserver unchanged.
+ if got := up.hits[3].URL.EscapedPath(); got != "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1" {
+ t.Errorf("escaped path = %q", got)
+ }
+ if got := up.hits[2].URL.RawQuery; got != "timeout=30000&since=s1_2" {
+ t.Errorf("query = %q", got)
+ }
+}
+
+func TestRefusesOtherHomeserverPaths(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ for _, p := range []string{
+ "/_matrix/federation/v1/version",
+ "/_matrix/key/v2/server",
+ "/_matrix/app/v1/transactions/1",
+ "/_warp/admin/v1/stats",
+ "/_synapse/admin/v1/users",
+ "/_matrix/client/../federation/v1/version",
+ "/_matrix/client/v3/../../federation/v1/version",
+ "/_matrix/client//v3/sync",
+ "/_matrix/client/v3/pushrules//",
+ "/_matrix/client/v3/../../federation/",
+ } {
+ rec := do(h, http.MethodGet, p, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s: status %d, want 404", p, rec.Code)
+ }
+ var body map[string]string
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
+ if body["errcode"] != "M_UNRECOGNIZED" {
+ t.Errorf("%s: errcode %q", p, body["errcode"])
+ }
+ }
+ if len(up.hits) != 0 {
+ t.Fatalf("upstream saw %d requests, want none", len(up.hits))
+ }
+}
+
+func TestProxyStripsCookiesAndForwardsToken(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ rec := do(h, http.MethodGet, "/_matrix/client/v3/account/whoami", http.Header{
+ "Authorization": {"Bearer tok"},
+ "Cookie": {"session=secret"},
+ })
+ if rec.Header().Get("Set-Cookie") != "" {
+ t.Error("Set-Cookie from the homeserver was passed to the browser")
+ }
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
+ }
+ got := up.hits[0]
+ if got.Header.Get("Cookie") != "" {
+ t.Error("Cookie was passed to the homeserver")
+ }
+ if got.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
+ }
+ if got.Header.Get("X-Forwarded-For") == "" {
+ t.Error("X-Forwarded-For not set")
+ }
+
+ // The homeserver's own caching of media is kept.
+ rec = do(h, http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc", nil)
+ if got := rec.Header().Get("Cache-Control"); got != "private, max-age=86400" {
+ t.Errorf("media Cache-Control = %q", got)
+ }
+}
+
+func TestUnreachableHomeserverIs502(t *testing.T) {
+ target, _ := url.Parse("http://127.0.0.1:1")
+ h, err := New(Options{Homeserver: target, Client: clientFS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := do(h, http.MethodGet, "/_matrix/client/versions", nil)
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("status %d, want 502", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "M_UNKNOWN") {
+ t.Errorf("body %q", rec.Body.String())
+ }
+}
+
+func TestServesClient(t *testing.T) {
+ up := newUpstream(t)
+ h := newHandler(t, up, true)
+
+ cases := []struct {
+ path, wantBody, wantCache string
+ wantCode int
+ }{
+ {"/", "client", "no-cache", 200},
+ {"/room/!abc:warp.example", "client", "no-cache", 200},
+ {"/room/%21abc%3Awarp.example", "client", "no-cache", 200},
+ {"/index.html", "client", "no-cache", 200},
+ {"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200},
+ {"/favicon.svg", "Warp
\ No newline at end of file
diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue
new file mode 100644
index 0000000..fbf116a
--- /dev/null
+++ b/apps/web/src/App.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/apps/web/src/components/ConfirmDialog.vue b/apps/web/src/components/ConfirmDialog.vue
new file mode 100644
index 0000000..5498e36
--- /dev/null
+++ b/apps/web/src/components/ConfirmDialog.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+ {{ error }}
+
+ Cancel
+
+ {{ confirmLabel }}
+
+
+
+
diff --git a/apps/web/src/components/CreateRoomDialog.vue b/apps/web/src/components/CreateRoomDialog.vue
new file mode 100644
index 0000000..f16331a
--- /dev/null
+++ b/apps/web/src/components/CreateRoomDialog.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
Public room
+
Anyone can find it in the room directory and join. Otherwise people join by invite.
+
+
+
+
+
+
+
+
+ End-to-end encrypted
+
+
+ End-to-end encryption didn’t start in this browser.
+ Only the people in the room can read its messages, not the homeserver. It can’t be turned off later.
+ The homeserver can read this room’s messages.
+
+
+
+
+ {{ error }}
+
+
+ Cancel
+
+ Create room
+
+
+
+
diff --git a/apps/web/src/components/EncryptionDialog.vue b/apps/web/src/components/EncryptionDialog.vue
new file mode 100644
index 0000000..85c8dc9
--- /dev/null
+++ b/apps/web/src/components/EncryptionDialog.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+ Encryption keys for your account let you verify your sessions, and keep a backup of your message keys on the homeserver,
+ locked with a recovery key only you have. A new session can then read your older messages.
+
+
+ This replaces your account’s keys and starts a new key backup. Messages only your old keys could read stay unreadable here.
+
+
+ {{ error }}
+
+
+
+
+
This session is verified, and key backup is on.
+
+ Save your recovery key somewhere safe, like a password manager. You need it to verify a new session when no other session is
+ signed in. Warp can’t show it to you again.
+
+
{{ recoveryKey }}
+
+ Copy
+ Download
+
+
+
+
+
+
+
+
+ Verify this session to read your encrypted messages here, and so others can trust it. Use one of your other sessions, or
+ your recovery key.
+
+ Verify with another session
+
+ The key backup is locked with your recovery key.
+
+ {{ error }}
+
+ Lost your recovery key and other sessions? Reset encryption
+
+
+
+
+
+
+
+ Restoring message keys · {{ progress.done }} of {{ progress.total }} …
+
+
+
+
+
{{ error }}
+
+
+ Restored {{ restored.imported }} of {{ restored.total }} message keys. Older messages decrypt as they load.
+ The key backup has no message keys yet.
+
+
+
+
+
+ Cancel
+
+ {{ step === 'reset' ? 'Reset encryption' : 'Set up encryption' }}
+
+
+ Done
+
+ Cancel
+
+ {{ step === 'unlock' ? 'Verify session' : 'Set up key backup' }}
+
+
+ Done
+
+
+
diff --git a/apps/web/src/components/ImageViewer.vue b/apps/web/src/components/ImageViewer.vue
new file mode 100644
index 0000000..2654ce4
--- /dev/null
+++ b/apps/web/src/components/ImageViewer.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
Can’t load the image.
+
+
+
+
diff --git a/apps/web/src/components/JoinRoomDialog.vue b/apps/web/src/components/JoinRoomDialog.vue
new file mode 100644
index 0000000..1056ec5
--- /dev/null
+++ b/apps/web/src/components/JoinRoomDialog.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+ Join
+
+ {{ error }}
+
+
+
+
Room directory
+
+
+
{{ directoryError }}
+
+
+ {{ search ? 'No public rooms match.' : 'There are no public rooms on this homeserver yet.' }}
+
+
+
+
+
+
{{ r.name ?? r.canonical_alias ?? r.room_id }}
+
+ {{ r.canonical_alias }} · {{ r.num_joined_members }}
+ {{ r.num_joined_members === 1 ? 'member' : 'members' }} · {{ r.topic }}
+
+
+
+ {{ joined.has(r.room_id) ? 'Open' : 'Join' }}
+
+
+
+
+
+
diff --git a/apps/web/src/components/MessageBody.vue b/apps/web/src/components/MessageBody.vue
new file mode 100644
index 0000000..15670d6
--- /dev/null
+++ b/apps/web/src/components/MessageBody.vue
@@ -0,0 +1,124 @@
+
+
+
+ Message deleted
+
+
+
+
+ {{ item.utd }}
+
+
+
+ Decrypting…
+
+
+
+
+
+
+ Can’t load the image
+
+
+
+
+
+
+
+
+
+
+
+
{{ media.filename }}
+
{{ playerError ? 'Can’t load it' : formatBytes(media.size) }}
+
+
Play
+
+
+
+
+
+
+
+
{{ media.filename }}
+
{{ formatBytes(media.size) }}
+
+
Download
+
+
+
+ * {{ item.senderName }} {{ s.text }} {{ s.text }}
+
diff --git a/apps/web/src/components/MxcAvatar.vue b/apps/web/src/components/MxcAvatar.vue
new file mode 100644
index 0000000..f051518
--- /dev/null
+++ b/apps/web/src/components/MxcAvatar.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/apps/web/src/components/PopMenu.vue b/apps/web/src/components/PopMenu.vue
new file mode 100644
index 0000000..5aa63c9
--- /dev/null
+++ b/apps/web/src/components/PopMenu.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
diff --git a/apps/web/src/components/RoomInfoPane.vue b/apps/web/src/components/RoomInfoPane.vue
new file mode 100644
index 0000000..2dcdcce
--- /dev/null
+++ b/apps/web/src/components/RoomInfoPane.vue
@@ -0,0 +1,267 @@
+
+
+
+
+
diff --git a/apps/web/src/components/RoomRow.vue b/apps/web/src/components/RoomRow.vue
new file mode 100644
index 0000000..8d8f9ef
--- /dev/null
+++ b/apps/web/src/components/RoomRow.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/apps/web/src/components/RoomTimeline.vue b/apps/web/src/components/RoomTimeline.vue
new file mode 100644
index 0000000..fd6d303
--- /dev/null
+++ b/apps/web/src/components/RoomTimeline.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
Load older messages
+
This is the beginning of the room.
+
+
+
+
+
+ {{ formatDay(item.ts) }}
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+ (viewing = { source, name })" />
+
+ Retry
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+ Latest
+
+
+
+
+
!v && (deleting = null)"
+ @confirm="confirmDelete"
+ >
+ The message is removed for everyone in the room. This can’t be undone.
+
+
+
diff --git a/apps/web/src/components/SessionsDialog.vue b/apps/web/src/components/SessionsDialog.vue
new file mode 100644
index 0000000..5cd4ef7
--- /dev/null
+++ b/apps/web/src/components/SessionsDialog.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+ Each place you’re signed in has its own keys. Verify your other sessions so they trust each other and share message keys.
+
+
+ Each place {{ userId }} is signed in has its own keys. Verify a session to be sure messages from it are
+ really theirs: you’ll both compare seven emoji, so do it together, in person or on a call.
+
+
+
+ No sessions with encryption keys. They can’t read end-to-end encrypted messages until they sign in with a client that supports it.
+
+
+
+
+
+
{{ s.name || 'Unnamed session' }} (this session)
+
{{ s.deviceId }}
+
+ Verified
+ Verify session
+ Not verified
+
+
+ {{ error }}
+
+ Close
+
+
+
diff --git a/apps/web/src/components/SettingsDialog.vue b/apps/web/src/components/SettingsDialog.vue
new file mode 100644
index 0000000..717c15c
--- /dev/null
+++ b/apps/web/src/components/SettingsDialog.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+ Change avatar
+ Remove
+
+
{{ session.userId }}
+
+
+
+
+
+
+ Save
+
+
+ {{ error }}
+
+
+ Theme
+
+
+
+ {{ t.label }}
+
+
+
+
+
+ Encryption
+
+ This browser can’t read or send end-to-end encrypted messages. Reload the page to try again. ({{ cryptoError() }})
+
+ Checking…
+
+
+
+
+
+
{{ enc.sessionVerified ? 'This session is verified' : 'This session isn’t verified' }}
+
+ Set up encryption to verify your sessions and back up your message keys.
+ Verify it to read your older encrypted messages here, and so others can trust it.
+ Your other sessions and the people who verified you trust it.
+
+
+
Set up encryption
+
Verify session
+
+
+
+
+
+
{{ enc.backupActive ? 'Key backup is on' : enc.backupVersion ? 'Key backup isn’t used here' : 'No key backup' }}
+
+ Your message keys are kept on the homeserver, locked with your recovery key.
+ Verify this session to use your key backup.
+ Without one, a new session can’t read messages sent before it signed in.
+
+
+
Restore
+
Set up key backup
+
+
+ Your sessions
+ Reset encryption
+
+
+
+
+
+ This session
+ Session ID {{ session.deviceId }}
+ Warp web {{ config.version }}
+
+
+
+
+
+
+ Sign out
+
+
+
diff --git a/apps/web/src/components/StartChatDialog.vue b/apps/web/src/components/StartChatDialog.vue
new file mode 100644
index 0000000..ea1e24d
--- /dev/null
+++ b/apps/web/src/components/StartChatDialog.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ {{ error }}
+
+
+ Cancel
+ Start chat
+
+
+
diff --git a/apps/web/src/components/ToastList.vue b/apps/web/src/components/ToastList.vue
new file mode 100644
index 0000000..ec4a9f1
--- /dev/null
+++ b/apps/web/src/components/ToastList.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/apps/web/src/components/VerificationDialog.vue b/apps/web/src/components/VerificationDialog.vue
new file mode 100644
index 0000000..8387038
--- /dev/null
+++ b/apps/web/src/components/VerificationDialog.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+ Open Warp or another Matrix client where you’re already signed in, and accept the request there.
+
+
Waiting for {{ otherName }} to accept.
+
+
+
+
+
+
+
+ Another of your sessions wants to verify this one
+ ({{ req.otherDeviceId }} ) .
+
+
+ {{ otherName }}
+ ({{ req.otherUserId }} ) wants to verify your session.
+
+
+
You’ll both compare seven emoji. Accept only if you expected this.
+
+
+
+
+
+
Getting the emoji ready…
+
+
+
+
+
+ Check that {{ otherName }} sees the same emoji, in the same order.
+
+
+
+ {{ emoji }}
+ {{ name }}
+
+
+
{{ sas.sas.decimal.join(' ') }}
+
+ Waiting for {{ otherName }} to confirm…
+
+
+
+
+
+
+
Verified
+
+ This session is verified. Your other sessions trust it, and it can read your encrypted messages.
+
+ You verified {{ otherName }}’s session {{ req.otherDeviceId }} .
+
+
+
+
+
+ {{ cancelText }}
+
+ {{ error }}
+
+
+
+
+ Decline
+ Accept
+
+
+ They don’t match
+ They match
+
+
+ Done
+
+ Cancel
+
+
+
diff --git a/apps/web/src/components/menu.ts b/apps/web/src/components/menu.ts
new file mode 100644
index 0000000..9512b09
--- /dev/null
+++ b/apps/web/src/components/menu.ts
@@ -0,0 +1,8 @@
+import type { IconName } from '@warp/ui'
+
+export interface MenuItem {
+ label: string
+ icon: IconName
+ danger?: boolean
+ action: () => void
+}
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
new file mode 100644
index 0000000..95d2b7c
--- /dev/null
+++ b/apps/web/src/config.ts
@@ -0,0 +1,25 @@
+// Runtime configuration from warp-web's /config.json. In development there
+// is none, and the defaults (same origin, through Vite's proxy) apply.
+
+export interface Config {
+ /** Where Matrix requests go; the page's own origin when warp-web proxies. */
+ homeserverUrl: string
+ version: string
+}
+
+export const config: Config = {
+ homeserverUrl: window.location.origin,
+ version: 'dev',
+}
+
+export async function loadConfig() {
+ try {
+ const res = await fetch('/config.json', { cache: 'no-cache', credentials: 'omit' })
+ if (!res.ok) return
+ const data = (await res.json()) as { homeserver_url?: string; version?: string }
+ if (data.homeserver_url) config.homeserverUrl = data.homeserver_url.replace(/\/+$/, '')
+ if (data.version) config.version = data.version
+ } catch {
+ // No config: keep the defaults.
+ }
+}
diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts
new file mode 100644
index 0000000..86dd518
--- /dev/null
+++ b/apps/web/src/env.d.ts
@@ -0,0 +1,10 @@
+///
+
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ title?: string
+ public?: boolean
+ }
+}
diff --git a/apps/web/src/layouts/AuthLayout.vue b/apps/web/src/layouts/AuthLayout.vue
new file mode 100644
index 0000000..b727e33
--- /dev/null
+++ b/apps/web/src/layouts/AuthLayout.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+ Homeserver {{ host }}
+
+
+
+
diff --git a/apps/web/src/layouts/ClientShell.vue b/apps/web/src/layouts/ClientShell.vue
new file mode 100644
index 0000000..794e043
--- /dev/null
+++ b/apps/web/src/layouts/ClientShell.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
{{ startError }}
+
+ Try again
+ Sign out
+
+
+
+
+
+
+
+ Loading your rooms
+
+
+
+
+
+
+
+
+
+
+
+ Filter rooms
+
+
+
+
+
+ Can’t reach the homeserver. Messages will send when the connection is back.
+
+
+
+ Use another session or your recovery key to read your encrypted messages here.
+ Verify session
+
+
+
+
+ Invites
+
+ Rooms
+
+
+
+
+
+
+ No rooms match “{{ filter }}”.
+
+
+ No rooms yet
+ Create a room, start a chat with someone, or join a room by its address.
+ Create room
+
+
+
+
+
+
+
+
{{ ownName }}
+
{{ session.userId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/drafts.ts b/apps/web/src/lib/drafts.ts
new file mode 100644
index 0000000..3c23169
--- /dev/null
+++ b/apps/web/src/lib/drafts.ts
@@ -0,0 +1,2 @@
+// Unsent composer text per room. It survives switching rooms, not reloads.
+export const drafts = new Map()
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
new file mode 100644
index 0000000..23d3859
--- /dev/null
+++ b/apps/web/src/lib/format.ts
@@ -0,0 +1,93 @@
+import { MatrixError } from 'matrix-js-sdk'
+
+const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
+const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'short' })
+const dayMonth = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' })
+const fullDay = new Intl.DateTimeFormat(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
+const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+
+function startOfDay(ms: number): number {
+ const d = new Date(ms)
+ d.setHours(0, 0, 0, 0)
+ return d.getTime()
+}
+
+/** Message time: always the clock time. */
+export function formatTime(ms: number): string {
+ return ms ? time.format(ms) : ''
+}
+
+/** Room list time: "14:32" today, weekday this week, "12 Oct" older. */
+export function formatListTime(ms: number, now = Date.now()): string {
+ if (!ms) return ''
+ const today = startOfDay(now)
+ if (ms >= today) return time.format(ms)
+ if (ms >= today - 6 * 86_400_000) return weekday.format(ms)
+ return dayMonth.format(ms)
+}
+
+/** Day separator in the timeline: "Today", "Yesterday", or the full date. */
+export function formatDay(ms: number, now = Date.now()): string {
+ const day = startOfDay(ms)
+ const today = startOfDay(now)
+ if (day === today) return 'Today'
+ if (day === today - 86_400_000) return 'Yesterday'
+ return fullDay.format(ms)
+}
+
+export function formatDateTime(ms: number): string {
+ return ms ? dateTime.format(ms) : ''
+}
+
+export function sameDay(a: number, b: number): boolean {
+ return startOfDay(a) === startOfDay(b)
+}
+
+const units = ['B', 'KB', 'MB', 'GB', 'TB']
+
+export function formatBytes(n: number | undefined): string {
+ if (n === undefined || !Number.isFinite(n)) return ''
+ if (n < 1024) return `${n} B`
+ let v = n
+ let i = 0
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024
+ i++
+ }
+ return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
+}
+
+/** What the person reads when a request fails: the server's words if any. */
+export function errorMessage(e: unknown): string {
+ if (e instanceof MatrixError) {
+ if (e.errcode === 'M_LIMIT_EXCEEDED') return 'Too many requests. Wait a moment and try again.'
+ if (e.data?.error) return e.data.error
+ if (e.httpStatus === 502 || e.httpStatus === 504) return 'Can’t reach the homeserver.'
+ return e.message
+ }
+ if (e instanceof TypeError) return 'Can’t reach the homeserver. Check your connection and try again.'
+ if (e instanceof Error) return e.message
+ return 'Something went wrong.'
+}
+
+/** The localpart of a Matrix ID, for avatars and short labels. */
+export function localpart(mxid: string): string {
+ return mxid.replace(/^[@#!]/, '').split(':')[0] ?? mxid
+}
+
+/** The server part of a Matrix ID. */
+export function serverPart(mxid: string): string {
+ const i = mxid.indexOf(':')
+ return i < 0 ? '' : mxid.slice(i + 1)
+}
+
+/** Turns "alice" into "@alice:server" when a full ID is needed. */
+export function toUserId(input: string, server: string): string {
+ const v = input.trim()
+ if (v.startsWith('@')) return v
+ return `@${v}:${server}`
+}
+
+export function isUserId(v: string): boolean {
+ return /^@[^:\s]+:[^\s]+$/.test(v.trim())
+}
diff --git a/apps/web/src/lib/linkify.ts b/apps/web/src/lib/linkify.ts
new file mode 100644
index 0000000..a4d2134
--- /dev/null
+++ b/apps/web/src/lib/linkify.ts
@@ -0,0 +1,29 @@
+// Splits message text into plain runs and web links, so links can be
+// rendered as anchors without ever putting the text through v-html.
+
+export type Segment = { kind: 'text'; text: string } | { kind: 'link'; text: string; href: string }
+
+const pattern = /\bhttps?:\/\/[^\s<>"']+/gi
+// Punctuation that usually ends the sentence, not the URL.
+const trailing = /[.,;:!?)\]}'"]+$/
+
+export function linkify(text: string): Segment[] {
+ const out: Segment[] = []
+ let last = 0
+ for (const m of text.matchAll(pattern)) {
+ let url = m[0]
+ const start = m.index ?? 0
+ const cut = url.match(trailing)
+ if (cut) {
+ // Keep a closing parenthesis that has its opening one inside the URL.
+ let tail = cut[0]
+ if (tail.startsWith(')') && url.includes('(')) tail = tail.slice(1)
+ url = url.slice(0, url.length - tail.length)
+ }
+ if (start > last) out.push({ kind: 'text', text: text.slice(last, start) })
+ out.push({ kind: 'link', text: url, href: url })
+ last = start + url.length
+ }
+ if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
+ return out
+}
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts
new file mode 100644
index 0000000..41517aa
--- /dev/null
+++ b/apps/web/src/main.ts
@@ -0,0 +1,15 @@
+import { createPinia } from 'pinia'
+import { createApp } from 'vue'
+import App from './App.vue'
+import { loadConfig } from './config'
+import { forgetEndedSessions } from './matrix/client'
+import { router } from './router'
+import './style.css'
+
+// The runtime configuration decides where the homeserver is, so it is read
+// before anything talks to it.
+loadConfig().then(() => {
+ // Encryption keys of sessions that ended while their store was open.
+ void forgetEndedSessions()
+ createApp(App).use(createPinia()).use(router).mount('#app')
+})
diff --git a/apps/web/src/matrix/attachments.ts b/apps/web/src/matrix/attachments.ts
new file mode 100644
index 0000000..b221b5a
--- /dev/null
+++ b/apps/web/src/matrix/attachments.ts
@@ -0,0 +1,66 @@
+// Files in end-to-end encrypted rooms are encrypted before they are
+// uploaded: AES-CTR with a fresh key per file, the key, counter and the
+// ciphertext's SHA-256 travelling inside the (encrypted) message, as the
+// spec's "Sending encrypted attachments" describes. The homeserver only
+// ever holds ciphertext, and can't make thumbnails of it.
+
+export interface EncryptedFile {
+ url: string
+ key: { kty: 'oct'; key_ops: string[]; alg: 'A256CTR'; k: string; ext: true }
+ iv: string
+ hashes: { sha256: string }
+ v: 'v2'
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let s = ''
+ for (const b of bytes) s += String.fromCharCode(b)
+ return btoa(s).replace(/=+$/, '')
+}
+
+function fromBase64(s: string): Uint8Array {
+ // Unpadded, and tolerant of the URL-safe alphabet some clients send.
+ const std = s.replace(/-/g, '+').replace(/_/g, '/')
+ const bin = atob(std + '='.repeat((4 - (std.length % 4)) % 4))
+ const out = new Uint8Array(bin.length)
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
+ return out
+}
+
+export async function encryptAttachment(data: ArrayBuffer): Promise<{ data: ArrayBuffer; file: Omit }> {
+ const subtle = globalThis.crypto.subtle
+ const key = await subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt'])
+ // A random 64-bit nonce and a 64-bit counter starting at zero.
+ const iv = new Uint8Array(16)
+ globalThis.crypto.getRandomValues(iv.subarray(0, 8))
+ const ciphertext = await subtle.encrypt({ name: 'AES-CTR', counter: iv, length: 64 }, key, data)
+ const hash = await subtle.digest('SHA-256', ciphertext)
+ const jwk = await subtle.exportKey('jwk', key)
+ return {
+ data: ciphertext,
+ file: {
+ key: { kty: 'oct', key_ops: ['encrypt', 'decrypt'], alg: 'A256CTR', k: jwk.k!, ext: true },
+ iv: toBase64(iv),
+ hashes: { sha256: toBase64(new Uint8Array(hash)) },
+ v: 'v2',
+ },
+ }
+}
+
+export async function decryptAttachment(data: ArrayBuffer, file: EncryptedFile): Promise {
+ const subtle = globalThis.crypto.subtle
+ if (!file?.key?.k || !file.iv || !file.hashes?.sha256) throw new Error('attachment: missing key')
+ const hash = new Uint8Array(await subtle.digest('SHA-256', data))
+ const want = fromBase64(file.hashes.sha256)
+ if (hash.length !== want.length || hash.some((b, i) => b !== want[i])) throw new Error('attachment: hash mismatch')
+ const key = await subtle.importKey('jwk', { kty: 'oct', alg: 'A256CTR', k: file.key.k, ext: true }, { name: 'AES-CTR' }, false, ['decrypt'])
+ return subtle.decrypt({ name: 'AES-CTR', counter: fromBase64(file.iv), length: 64 }, key, data)
+}
+
+// The sender chooses the mimetype, and a blob: URL shares this page's
+// origin: only types that render inertly are kept.
+const inertTypes = /^(image\/(png|jpeg|gif|webp|avif|bmp)|video\/(mp4|webm|ogg|quicktime)|audio\/(mpeg|mp4|ogg|webm|wav|aac|flac|x-m4a))$/
+
+export function safeMimetype(mimetype: string | undefined): string {
+ return mimetype && inertTypes.test(mimetype) ? mimetype : 'application/octet-stream'
+}
diff --git a/apps/web/src/matrix/client.ts b/apps/web/src/matrix/client.ts
new file mode 100644
index 0000000..940b333
--- /dev/null
+++ b/apps/web/src/matrix/client.ts
@@ -0,0 +1,154 @@
+import { createClient, PendingEventOrdering, type MatrixClient } from 'matrix-js-sdk'
+import { config } from '@/config'
+import { bindCrypto, cryptoCallbacks, cryptoDatabasePrefix } from './crypto'
+
+// The one MatrixClient of a signed-in session. It is kept outside Vue's
+// reactivity on purpose: the SDK's objects mutate themselves and emit
+// events, and the stores turn those events into plain reactive state.
+//
+// Everything that touches matrix-js-sdk lives under src/matrix and
+// src/stores, so the desktop app can later swap in its Go backend behind
+// the same stores.
+
+export interface Credentials {
+ baseUrl: string
+ userId: string
+ deviceId: string
+ accessToken: string
+}
+
+let current: MatrixClient | null = null
+let cryptoFailure = ''
+
+/** The running client. Only call this while signed in. */
+export function client(): MatrixClient {
+ if (!current) throw new Error('Not signed in')
+ return current
+}
+
+export function hasClient(): boolean {
+ return current !== null
+}
+
+/**
+ * Why end-to-end encryption didn't start in this session, or '' when it
+ * runs. Without it the client still works in unencrypted rooms.
+ */
+export function cryptoError(): string {
+ return cryptoFailure
+}
+
+/** A client without a session, for sign-in and registration. */
+export function anonymousClient(): MatrixClient {
+ return createClient({ baseUrl: config.homeserverUrl })
+}
+
+export async function startClient(creds: Credentials): Promise {
+ stopClient()
+ const c = createClient({
+ baseUrl: creds.baseUrl,
+ userId: creds.userId,
+ deviceId: creds.deviceId,
+ accessToken: creds.accessToken,
+ timelineSupport: true,
+ useAuthorizationHeader: true,
+ cryptoCallbacks,
+ // Emoji (SAS) only: there is no camera to scan a QR code with here.
+ verificationMethods: ['m.sas.v1'],
+ })
+ current = c
+ // End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm),
+ // its keys in IndexedDB under this session's own name, so two accounts
+ // in one browser never share a store. It loads before the first sync so
+ // the to-device messages and room keys in it are not missed.
+ cryptoFailure = ''
+ try {
+ await c.initRustCrypto({ cryptoDatabasePrefix: cryptoDatabasePrefix(creds.userId, creds.deviceId) })
+ bindCrypto(c)
+ } catch (e) {
+ console.error('crypto: ', e)
+ cryptoFailure = e instanceof Error ? e.message : String(e)
+ }
+ await c.startClient({
+ initialSyncLimit: 30,
+ lazyLoadMembers: true,
+ // Your own messages that are still sending (or failed) are kept apart
+ // from the timeline, so a failed one doesn't sit between others' replies.
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ threadSupport: false,
+ })
+ return c
+}
+
+export function stopClient() {
+ if (!current) return
+ bindCrypto(null)
+ current.stopClient()
+ current.removeAllListeners()
+ current = null
+}
+
+// Crypto stores of sessions that are over but not deleted yet. Deleting
+// an IndexedDB database waits until every connection to it is closed, and
+// the crypto stack may hold one until the page goes away; whatever is left
+// here is deleted when the next page starts.
+const forgetKey = 'warp.web.crypto.forget'
+
+function pendingForget(): string[] {
+ try {
+ const v = JSON.parse(localStorage.getItem(forgetKey) ?? '[]')
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []
+ } catch {
+ return []
+ }
+}
+
+function setPendingForget(list: string[]) {
+ try {
+ if (list.length) localStorage.setItem(forgetKey, JSON.stringify(list))
+ else localStorage.removeItem(forgetKey)
+ } catch {
+ // Not kept: the store stays until it is deleted by hand.
+ }
+}
+
+/** Deletes a crypto store; false when it is still open after a few seconds. */
+async function deleteStore(c: MatrixClient, prefix: string): Promise {
+ const timeout = new Promise((resolve) => window.setTimeout(() => resolve(false), 3000))
+ const done = c
+ .clearStores({ cryptoDatabasePrefix: prefix })
+ .then(() => true as const)
+ .catch((e) => {
+ console.error('clearStores: ', e)
+ return false as const
+ })
+ const ok = await Promise.race([done, timeout])
+ if (ok) setPendingForget(pendingForget().filter((p) => p !== prefix))
+ return ok
+}
+
+/**
+ * Stops the client and deletes what it kept in this browser, the
+ * session's encryption keys included. For when the session is over: kept,
+ * they could only ever decrypt for a device the homeserver has forgotten.
+ */
+export async function destroyClient(session?: { userId: string; deviceId: string }) {
+ // The session may have no running client (it failed to start), and its
+ // keys still have to go.
+ const c = current ?? anonymousClient()
+ stopClient()
+ const userId = session?.userId ?? c.getUserId()
+ const deviceId = session?.deviceId ?? c.getDeviceId()
+ if (!userId || !deviceId) return
+ const prefix = cryptoDatabasePrefix(userId, deviceId)
+ setPendingForget([...new Set([...pendingForget(), prefix])])
+ await deleteStore(c, prefix)
+}
+
+/** Finishes deleting the crypto stores of sessions that ended. Runs before any client starts. */
+export async function forgetEndedSessions() {
+ const list = pendingForget()
+ if (!list.length) return
+ const c = anonymousClient()
+ for (const prefix of list) await deleteStore(c, prefix)
+}
diff --git a/apps/web/src/matrix/crypto.ts b/apps/web/src/matrix/crypto.ts
new file mode 100644
index 0000000..e5fc6ff
--- /dev/null
+++ b/apps/web/src/matrix/crypto.ts
@@ -0,0 +1,254 @@
+import { MatrixError, type AuthDict, type MatrixClient } from 'matrix-js-sdk'
+import {
+ decodeRecoveryKey,
+ deriveRecoveryKeyFromPassphrase,
+ ImportRoomKeyStage,
+ type CryptoApi,
+ type CryptoCallbacks,
+ type KeyBackupRestoreResult,
+ type VerificationRequest,
+} from 'matrix-js-sdk/lib/crypto-api'
+import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage'
+
+// End-to-end encryption, on top of the SDK's Rust crypto stack: whether
+// this session is verified, the recovery key that unlocks secret storage
+// (cross-signing keys and the key backup's key), setting it all up, and
+// verification requests. The stores and dialogs call these; nothing else
+// touches the crypto API.
+
+let current: MatrixClient | null = null
+
+/** Called by client.ts once the crypto stack is up, and with null when it stops. */
+export function bindCrypto(c: MatrixClient | null) {
+ current = c
+ cachedKey = null
+}
+
+function crypto(): CryptoApi {
+ const api = current?.getCrypto()
+ if (!api) throw new Error('End-to-end encryption isn’t running in this session.')
+ return api
+}
+
+/** The IndexedDB name of a session's crypto store. */
+export function cryptoDatabasePrefix(userId: string, deviceId: string): string {
+ return `warp-web-crypto:${userId}:${deviceId}`
+}
+
+// The secret storage key. The SDK asks for it, often several times in a
+// row, while it reads or writes secret storage; it is kept in memory only
+// for as long as this page runs.
+let cachedKey: { keyId: string; key: Uint8Array } | null = null
+
+export class WrongRecoveryKeyError extends Error {
+ constructor() {
+ super('That isn’t the recovery key for this account. Check it and try again.')
+ }
+}
+
+export const cryptoCallbacks: CryptoCallbacks = {
+ async getSecretStorageKey({ keys }) {
+ // Only a key the person entered (or one just created) is ever returned:
+ // unlockWithRecoveryKey checks it before it is cached.
+ if (cachedKey && keys[cachedKey.keyId]) return [cachedKey.keyId, cachedKey.key]
+ return null
+ },
+ cacheSecretStorageKey(keyId, _info, key) {
+ cachedKey = { keyId, key }
+ },
+}
+
+export interface EncryptionStatus {
+ /** The account has cross-signing keys: sessions can be verified. */
+ crossSigning: boolean
+ /** This session is signed by the account's own identity. */
+ sessionVerified: boolean
+ /** Secret storage holds the cross-signing keys: a recovery key unlocks them. */
+ secretStorage: boolean
+ /** The key backup on the homeserver, if there is one. */
+ backupVersion: string | null
+ /** This session backs its keys up to it. */
+ backupActive: boolean
+ /** This session holds the backup's key, so it can restore from it. */
+ backupKey: boolean
+}
+
+export async function encryptionStatus(): Promise {
+ const api = crypto()
+ const c = current!
+ const userId = c.getUserId()!
+ const deviceId = c.getDeviceId()!
+ const [crossSigning, device, secretStorage, backupInfo, backupActive, backupKey] = await Promise.all([
+ api.userHasCrossSigningKeys(userId, true),
+ api.getDeviceVerificationStatus(userId, deviceId),
+ api.isSecretStorageReady(),
+ api.getKeyBackupInfo().catch(() => null),
+ api.getActiveSessionBackupVersion(),
+ api.getSessionBackupPrivateKey(),
+ ])
+ return {
+ crossSigning,
+ sessionVerified: Boolean(device?.crossSigningVerified),
+ secretStorage,
+ backupVersion: backupInfo?.version ?? null,
+ backupActive: backupActive !== null,
+ backupKey: backupKey !== null,
+ }
+}
+
+/** Raised when the homeserver wants the account password to go on. */
+export class PasswordNeededError extends Error {
+ constructor() {
+ super('Enter your account password to continue.')
+ }
+}
+
+function uiaSession(e: unknown): string | undefined | null {
+ if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) return (e.data.session as string) ?? undefined
+ return null
+}
+
+/**
+ * Sets up encryption for the account: cross-signing keys (this session
+ * signs them, so it is verified), secret storage under a new recovery key,
+ * and a new key backup. Returns the recovery key to show the person once.
+ *
+ * With `reset`, replaces cross-signing keys that already exist: anyone who
+ * verified this account has to verify it again.
+ */
+export async function setUpEncryption(opts: { password?: string; reset?: boolean }): Promise {
+ const api = crypto()
+ const c = current!
+ await api.bootstrapCrossSigning({
+ setupNewCrossSigning: opts.reset ?? false,
+ authUploadDeviceSigningKeys: async (makeRequest) => {
+ try {
+ await makeRequest(null)
+ } catch (e) {
+ const session = uiaSession(e)
+ if (session === null) throw e
+ if (!opts.password) throw new PasswordNeededError()
+ const auth: AuthDict = {
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: c.getUserId()! },
+ password: opts.password,
+ session,
+ }
+ try {
+ await makeRequest(auth)
+ } catch (e2) {
+ if (uiaSession(e2) !== null || (e2 instanceof MatrixError && e2.errcode === 'M_FORBIDDEN')) {
+ throw new Error('That password isn’t right.')
+ }
+ throw e2
+ }
+ }
+ },
+ })
+ let recoveryKey = ''
+ await api.bootstrapSecretStorage({
+ setupNewSecretStorage: true,
+ setupNewKeyBackup: true,
+ createSecretStorageKey: async () => {
+ const key = await api.createRecoveryKeyFromPassphrase()
+ recoveryKey = key.encodedPrivateKey ?? ''
+ return key
+ },
+ })
+ return recoveryKey
+}
+
+async function keyFromInput(input: string, info: SecretStorageKeyDescription): Promise> {
+ const text = input.trim()
+ try {
+ return decodeRecoveryKey(text)
+ } catch {
+ // Not a recovery key: it may be the passphrase the key was made from.
+ }
+ const pp = info.passphrase
+ if (!pp?.salt || !pp.iterations) throw new WrongRecoveryKeyError()
+ return deriveRecoveryKeyFromPassphrase(text, pp.salt, pp.iterations, pp.bits)
+}
+
+/**
+ * Unlocks secret storage with the recovery key (or its passphrase): this
+ * session gets the cross-signing keys and signs itself, which verifies
+ * it, and takes the key backup's key so older messages can be restored.
+ */
+export async function unlockWithRecoveryKey(input: string): Promise {
+ const api = crypto()
+ const c = current!
+ const keyId = await c.secretStorage.getDefaultKeyId()
+ const tuple = keyId ? await c.secretStorage.getKey(keyId) : null
+ if (!tuple) throw new Error('This account has no secret storage. Set up encryption instead.')
+ const key = await keyFromInput(input, tuple[1])
+ if (!(await c.secretStorage.checkKey(key, tuple[1]))) throw new WrongRecoveryKeyError()
+ cachedKey = { keyId: tuple[0], key }
+ await api.bootstrapCrossSigning({})
+ await api.loadSessionBackupPrivateKeyFromSecretStorage().catch(() => undefined)
+ await api.checkKeyBackupAndEnable()
+}
+
+/** Fetches and imports every room key in the backup this session can read. */
+export async function restoreKeyBackup(onProgress?: (done: number, total: number) => void): Promise {
+ const api = crypto()
+ await api.checkKeyBackupAndEnable()
+ return api.restoreKeyBackup({
+ progressCallback: (p) => {
+ if (p.stage === ImportRoomKeyStage.LoadKeys) onProgress?.(p.successes + p.failures, p.total)
+ },
+ })
+}
+
+/** After this session was verified by another, it may have been sent the backup key. */
+export async function enableBackupIfPossible() {
+ const api = current?.getCrypto()
+ if (!api) return
+ await api.checkKeyBackupAndEnable().catch(() => undefined)
+}
+
+export function verifyOwnSession(): Promise {
+ return crypto().requestOwnUserVerification()
+}
+
+export function verifyDevice(userId: string, deviceId: string): Promise {
+ return crypto().requestDeviceVerification(userId, deviceId)
+}
+
+export interface SessionInfo {
+ deviceId: string
+ name?: string
+ verified: boolean
+ /** This browser's own session. */
+ own: boolean
+}
+
+/** Someone's sessions as their keys say, with whether this session trusts each. */
+export async function sessionsOf(userId: string): Promise {
+ const api = crypto()
+ const c = current!
+ const map = await api.getUserDeviceInfo([userId], true)
+ const devices = [...(map.get(userId)?.values() ?? [])].filter((d) => !d.dehydrated)
+ const out = await Promise.all(
+ devices.map(async (d) => {
+ const status = await api.getDeviceVerificationStatus(userId, d.deviceId)
+ return {
+ deviceId: d.deviceId,
+ name: d.displayName,
+ verified: Boolean(status?.isVerified()),
+ own: userId === c.getUserId() && d.deviceId === c.getDeviceId(),
+ }
+ }),
+ )
+ return out.sort((a, b) => Number(b.own) - Number(a.own) || (a.name ?? a.deviceId).localeCompare(b.name ?? b.deviceId))
+}
+
+/** The recovery key was entered (or created) in this page, so secret storage is open. */
+export function recoveryKeyKnown(): boolean {
+ return cachedKey !== null
+}
+
+/** Starts a new key backup, its key kept in secret storage. Needs the recovery key first. */
+export async function createKeyBackup(): Promise {
+ await crypto().bootstrapSecretStorage({ setupNewKeyBackup: true })
+}
diff --git a/apps/web/src/matrix/media.ts b/apps/web/src/matrix/media.ts
new file mode 100644
index 0000000..087763a
--- /dev/null
+++ b/apps/web/src/matrix/media.ts
@@ -0,0 +1,114 @@
+import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
+import { decryptAttachment, safeMimetype, type EncryptedFile } from './attachments'
+import { client } from './client'
+
+// Authenticated media (Matrix 1.11) needs the access token in a header, so
+// an can't load it directly. Files are fetched here and shown from
+// blob: URLs, cached for the session and released on sign-out. A file
+// from an encrypted room is fetched whole and decrypted here.
+
+/** Where media comes from: an mxc:// URI, or an encrypted file and its type. */
+export type MediaSource = string | { file: EncryptedFile; mimetype?: string }
+
+function sourceKey(src: MediaSource | undefined | null): string | undefined {
+ if (!src) return undefined
+ return typeof src === 'string' ? src : src.file?.url
+}
+
+const cache = new Map>()
+
+function key(mxc: string, width?: number, height?: number) {
+ return width ? `${mxc}#${width}x${height}` : mxc
+}
+
+export function httpUrl(mxc: string, width?: number, height?: number): string | null {
+ return client().mxcUrlToHttp(mxc, width, height, width ? 'scale' : undefined, false, true, true)
+}
+
+async function fetchBlob(url: string): Promise {
+ const token = client().getAccessToken()
+ const res = await fetch(url, {
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
+ credentials: 'omit',
+ })
+ if (!res.ok) throw new Error(`media: ${res.status}`)
+ return res.blob()
+}
+
+async function fetchDecrypted(file: EncryptedFile, mimetype?: string): Promise {
+ const url = httpUrl(file.url)
+ if (!url) throw new Error('media: not an mxc:// URL')
+ const data = await (await fetchBlob(url)).arrayBuffer()
+ return new Blob([await decryptAttachment(data, file)], { type: safeMimetype(mimetype) })
+}
+
+/**
+ * A blob: URL for the media, or its thumbnail when a size is given. An
+ * encrypted file has no server-side thumbnail: it is always the whole file.
+ */
+export function mediaUrl(src: MediaSource, width?: number, height?: number): Promise {
+ if (typeof src !== 'string') {
+ width = height = undefined
+ }
+ const mxc = sourceKey(src)
+ if (!mxc) return Promise.reject(new Error('media: no file'))
+ const k = key(mxc, width, height)
+ let p = cache.get(k)
+ if (!p) {
+ const url = httpUrl(mxc, width, height)
+ if (!url) return Promise.reject(new Error('media: not an mxc:// URL'))
+ const blob = typeof src === 'string' ? fetchBlob(url) : fetchDecrypted(src.file, src.mimetype)
+ p = blob.then((b) => URL.createObjectURL(b))
+ // A failure is not cached, so the next view tries again.
+ p.catch(() => cache.delete(k))
+ cache.set(k, p)
+ }
+ return p
+}
+
+/** Saves the file under its own name. */
+export async function downloadMedia(src: MediaSource, filename: string) {
+ const url = await mediaUrl(src)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ a.rel = 'noopener'
+ document.body.append(a)
+ a.click()
+ a.remove()
+}
+
+export function clearMediaCache() {
+ for (const p of cache.values()) p.then((u) => URL.revokeObjectURL(u)).catch(() => undefined)
+ cache.clear()
+}
+
+/** Reactive blob URL for a media source; undefined while loading or on error. */
+export function useMedia(
+ source: () => MediaSource | undefined | null,
+ size?: { width: number; height: number },
+): { url: Ref; failed: Ref } {
+ const url = ref()
+ const failed = ref(false)
+ let alive = true
+ // Watched by URI: the timeline rebuilds its objects on every change.
+ watch(
+ () => sourceKey(source()),
+ (mxc) => {
+ url.value = undefined
+ failed.value = false
+ const src = source()
+ if (!src || !mxc || !mxc.startsWith('mxc://')) return
+ mediaUrl(src, size?.width, size?.height)
+ .then((u) => {
+ if (alive && sourceKey(source()) === mxc) url.value = u
+ })
+ .catch(() => {
+ if (alive) failed.value = true
+ })
+ },
+ { immediate: true },
+ )
+ onBeforeUnmount(() => (alive = false))
+ return { url, failed }
+}
diff --git a/apps/web/src/matrix/send.ts b/apps/web/src/matrix/send.ts
new file mode 100644
index 0000000..29be9ab
--- /dev/null
+++ b/apps/web/src/matrix/send.ts
@@ -0,0 +1,78 @@
+import { MsgType, type MatrixClient, type Room } from 'matrix-js-sdk'
+import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
+import { encryptAttachment } from './attachments'
+
+// Sending: text, and files uploaded to the media repository first. Your own
+// message appears at once as a local echo with status "sending"; the SDK
+// moves it into the timeline when the server has it, or marks it not sent.
+// In an end-to-end encrypted room the SDK encrypts the message itself, and
+// a file is encrypted here before it is uploaded.
+
+export function sendText(c: MatrixClient, roomId: string, text: string) {
+ const body = text.replace(/\s+$/, '')
+ const emote = body.match(/^\/me\s+([\s\S]+)$/)
+ const content: RoomMessageEventContent = emote
+ ? { msgtype: MsgType.Emote, body: emote[1]! }
+ : { msgtype: MsgType.Text, body: body.replace(/^\/\//, '/') }
+ return c.sendMessage(roomId, content)
+}
+
+function imageSize(file: File): Promise<{ w: number; h: number } | undefined> {
+ return new Promise((resolve) => {
+ const url = URL.createObjectURL(file)
+ const img = new Image()
+ img.onload = () => {
+ resolve({ w: img.naturalWidth, h: img.naturalHeight })
+ URL.revokeObjectURL(url)
+ }
+ img.onerror = () => {
+ resolve(undefined)
+ URL.revokeObjectURL(url)
+ }
+ img.src = url
+ })
+}
+
+function msgtypeFor(file: File): MsgType {
+ if (file.type.startsWith('image/')) return MsgType.Image
+ if (file.type.startsWith('video/')) return MsgType.Video
+ if (file.type.startsWith('audio/')) return MsgType.Audio
+ return MsgType.File
+}
+
+export interface UploadProgress {
+ name: string
+ loaded: number
+ total: number
+}
+
+export async function sendFile(
+ c: MatrixClient,
+ room: Room,
+ file: File,
+ onProgress?: (p: UploadProgress) => void,
+) {
+ const msgtype = msgtypeFor(file)
+ const info: Record = { mimetype: file.type || 'application/octet-stream', size: file.size }
+ if (msgtype === MsgType.Image) Object.assign(info, (await imageSize(file)) ?? {})
+
+ const progressHandler = (p: { loaded: number; total: number }) => onProgress?.({ name: file.name, loaded: p.loaded, total: p.total })
+ let source: { url: string } | { file: object }
+ if (room.hasEncryptionStateEvent()) {
+ const { data, file: keys } = await encryptAttachment(await file.arrayBuffer())
+ // The name and type stay inside the encrypted message.
+ const upload = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false, progressHandler })
+ source = { file: { ...keys, url: upload.content_uri } }
+ } else {
+ const upload = await c.uploadContent(file, { name: file.name, type: file.type || 'application/octet-stream', progressHandler })
+ source = { url: upload.content_uri }
+ }
+ const content = {
+ msgtype,
+ body: file.name,
+ filename: file.name,
+ ...source,
+ info,
+ } as RoomMessageEventContent
+ return c.sendMessage(room.roomId, content)
+}
diff --git a/apps/web/src/matrix/timeline.ts b/apps/web/src/matrix/timeline.ts
new file mode 100644
index 0000000..3dd6aef
--- /dev/null
+++ b/apps/web/src/matrix/timeline.ts
@@ -0,0 +1,347 @@
+import {
+ Direction,
+ EventStatus,
+ EventType,
+ MsgType,
+ type MatrixClient,
+ type MatrixEvent,
+ type Room,
+} from 'matrix-js-sdk'
+import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'
+import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
+import type { MessageStatus } from '@warp/ui'
+import { sameDay } from '@/lib/format'
+import type { EncryptedFile } from './attachments'
+import type { MediaSource } from './media'
+
+// Turns a room's live timeline into what the timeline view draws: messages
+// grouped by sender, day separators and one-line notices for state changes.
+// The SDK keeps the events; this is a pure projection of them.
+
+export interface MediaInfo {
+ mxc?: string
+ /** The thumbnail the sender uploaded, when there is one. */
+ thumbMxc?: string
+ /** Set in an encrypted room: the file is ciphertext, this decrypts it. */
+ file?: EncryptedFile
+ thumbFile?: EncryptedFile
+ thumbMimetype?: string
+ mimetype?: string
+ size?: number
+ w?: number
+ h?: number
+ filename: string
+}
+
+export interface MessageItem {
+ kind: 'message'
+ key: string
+ eventId?: string
+ sender: string
+ senderName: string
+ avatarMxc?: string
+ own: boolean
+ ts: number
+ msgtype: string
+ body: string
+ media?: MediaInfo
+ edited: boolean
+ redacted: boolean
+ /** Still ciphertext: being decrypted, or it can't be (see `utd`). */
+ encrypted: boolean
+ /** Why this session can't decrypt it. */
+ utd?: string
+ /** Sent end-to-end encrypted. */
+ e2ee: boolean
+ status?: MessageStatus
+ /** First of a run of messages from one sender: shows the name. */
+ first: boolean
+ /** Last of a run: shows the time and status. */
+ last: boolean
+ event: MatrixEvent
+}
+
+export interface NoticeItem {
+ kind: 'notice'
+ key: string
+ ts: number
+ text: string
+}
+
+export interface DayItem {
+ kind: 'day'
+ key: string
+ ts: number
+}
+
+export type TimelineItem = MessageItem | NoticeItem | DayItem
+
+// Messages from one sender this close together form one group.
+const groupGapMs = 5 * 60_000
+
+function nameOf(room: Room, userId: string): string {
+ return room.getMember(userId)?.name ?? userId
+}
+
+function statusOf(ev: MatrixEvent): MessageStatus | undefined {
+ switch (ev.status) {
+ case EventStatus.NOT_SENT:
+ return 'failed'
+ case EventStatus.SENDING:
+ case EventStatus.QUEUED:
+ case EventStatus.ENCRYPTING:
+ return 'sending'
+ case EventStatus.CANCELLED:
+ return undefined
+ default:
+ return 'sent'
+ }
+}
+
+/** A one-line description of a state change, or null to leave it out. */
+function describeState(ev: MatrixEvent, room: Room): string | null {
+ const sender = nameOf(room, ev.getSender() ?? '')
+ const content = ev.getContent()
+ const prev = ev.getPrevContent()
+ switch (ev.getType()) {
+ case EventType.RoomCreate:
+ return `${sender} created the room`
+ case EventType.RoomName:
+ return content.name ? `${sender} named the room ${content.name}` : `${sender} removed the room name`
+ case EventType.RoomTopic:
+ return content.topic ? `${sender} changed the topic to “${content.topic}”` : `${sender} removed the topic`
+ case EventType.RoomAvatar:
+ return `${sender} changed the room avatar`
+ case EventType.RoomEncryption:
+ return `${sender} turned on end-to-end encryption`
+ case EventType.RoomTombstone:
+ return `${sender} upgraded this room`
+ case EventType.RoomMember: {
+ const target = ev.getStateKey() ?? ''
+ const targetName = content.displayname ?? prev.displayname ?? target
+ const reason = content.reason ? `: ${content.reason}` : ''
+ switch (content.membership) {
+ case 'join':
+ if (prev.membership === 'join') {
+ if (content.displayname !== prev.displayname) {
+ if (!content.displayname) return `${prev.displayname ?? target} removed their display name`
+ if (!prev.displayname) return `${target} set their display name to ${content.displayname}`
+ return `${prev.displayname} changed their name to ${content.displayname}`
+ }
+ if (content.avatar_url !== prev.avatar_url) return `${targetName} changed their avatar`
+ return null
+ }
+ return `${targetName} joined`
+ case 'invite':
+ return `${sender} invited ${targetName}`
+ case 'leave':
+ if (target !== ev.getSender()) {
+ if (prev.membership === 'ban') return `${sender} unbanned ${targetName}`
+ if (prev.membership === 'invite') return `${sender} withdrew ${targetName}’s invitation`
+ return `${sender} removed ${targetName}${reason}`
+ }
+ return prev.membership === 'invite' ? `${targetName} declined the invitation` : `${targetName} left`
+ case 'ban':
+ return `${sender} banned ${targetName}${reason}`
+ case 'knock':
+ return `${targetName} asked to join`
+ }
+ return null
+ }
+ }
+ return null
+}
+
+/** What a message this session can't decrypt says about why, honestly. */
+export function utdText(ev: MatrixEvent, cryptoRunning: boolean): string | undefined {
+ if (!ev.isDecryptionFailure()) return cryptoRunning ? undefined : 'End-to-end encryption didn’t start in this browser, so it can’t read this message.'
+ switch (ev.decryptionFailureReason) {
+ case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
+ case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
+ return 'Sent before this session signed in. Verify this session or restore your key backup to read it.'
+ case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP:
+ return 'Sent before this session signed in, and its key isn’t in your key backup.'
+ case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED:
+ return 'Sent before you joined this room, so you can’t read it.'
+ case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
+ return 'The sender only shares keys with verified sessions. Verify this session to read new messages.'
+ case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
+ return 'The sender’s session didn’t share the key to this message with you.'
+ case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
+ return 'This session only has the key to later messages from this sender.'
+ case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
+ return 'Hidden: the sender’s identity changed since you verified them.'
+ case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
+ return 'Hidden: sent from a session its owner hasn’t verified.'
+ case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
+ return 'Waiting for the key to this message. It comes from the sender’s session, which may be offline.'
+ default:
+ return 'Can’t decrypt this message.'
+ }
+}
+
+/** Where to load a message's file or thumbnail from. */
+export function mediaSource(m: MediaInfo, thumb = false): MediaSource | undefined {
+ if (thumb && m.thumbFile) return { file: m.thumbFile, mimetype: m.thumbMimetype ?? 'image/jpeg' }
+ if (thumb && m.thumbMxc) return m.thumbMxc
+ if (m.file) return { file: m.file, mimetype: m.mimetype }
+ return m.mxc
+}
+
+function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined {
+ const msgtype = content.msgtype as string
+ if (msgtype !== MsgType.Image && msgtype !== MsgType.Video && msgtype !== MsgType.Audio && msgtype !== MsgType.File) {
+ return undefined
+ }
+ const c = content as RoomMessageEventContent & {
+ url?: string
+ file?: EncryptedFile
+ filename?: string
+ info?: {
+ mimetype?: string
+ size?: number
+ w?: number
+ h?: number
+ thumbnail_url?: string
+ thumbnail_file?: EncryptedFile
+ thumbnail_info?: { mimetype?: string }
+ }
+ }
+ return {
+ mxc: c.url ?? c.file?.url,
+ thumbMxc: c.info?.thumbnail_url ?? c.info?.thumbnail_file?.url,
+ file: c.file,
+ thumbFile: c.info?.thumbnail_file,
+ thumbMimetype: c.info?.thumbnail_info?.mimetype,
+ mimetype: c.info?.mimetype,
+ size: c.info?.size,
+ w: c.info?.w,
+ h: c.info?.h,
+ filename: c.filename || c.body || 'file',
+ }
+}
+
+/** Every event the view shows, oldest first: the live timeline then your pending ones. */
+function displayEvents(room: Room): MatrixEvent[] {
+ return [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()]
+}
+
+export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
+ const me = c.getUserId() ?? ''
+ const cryptoRunning = Boolean(c.getCrypto())
+ const events = displayEvents(room)
+
+ // An event is read when someone else's receipt is on it or on anything
+ // after it, or someone else has written after it.
+ const readByOthers = new Set()
+ let seen = false
+ for (let i = events.length - 1; i >= 0; i--) {
+ const ev = events[i]!
+ const id = ev.getId()
+ if (!id || ev.status) continue
+ if (!seen) {
+ const sender = ev.getSender()
+ if (sender && sender !== me) seen = true
+ else if (room.getUsersReadUpTo(ev).some((u) => u !== me)) seen = true
+ }
+ if (seen) readByOthers.add(id)
+ }
+
+ const items: TimelineItem[] = []
+ let lastTs = 0
+ let prev: MessageItem | null = null
+
+ for (const ev of events) {
+ const type = ev.getType()
+ if (ev.isRelation('m.replace') || type === EventType.Reaction || type === EventType.RoomRedaction) continue
+ if (ev.status === EventStatus.CANCELLED) continue
+
+ const ts = ev.getTs()
+ if (!lastTs || !sameDay(lastTs, ts)) {
+ items.push({ kind: 'day', key: `day-${ts}`, ts })
+ prev = null
+ }
+ lastTs = ts
+
+ const key = ev.getTxnId() ?? ev.getId() ?? `${ts}-${items.length}`
+ const isMessage = type === EventType.RoomMessage || type === EventType.Sticker || type === EventType.RoomMessageEncrypted
+
+ if (!isMessage) {
+ if (!ev.isState()) continue
+ const text = describeState(ev, room)
+ if (text) {
+ items.push({ kind: 'notice', key, ts, text })
+ prev = null
+ }
+ continue
+ }
+
+ const sender = ev.getSender() ?? ''
+ const own = sender === me
+ const content = ev.getContent()
+ // Still ciphertext, or failed: the SDK then makes it an m.room.message
+ // with its own "Unable to decrypt" text, which is never shown.
+ const encrypted = type === EventType.RoomMessageEncrypted || ev.isDecryptionFailure()
+ const redacted = ev.isRedacted()
+ const status = own ? statusOf(ev) : undefined
+ const item: MessageItem = {
+ kind: 'message',
+ key,
+ eventId: ev.getId(),
+ sender,
+ senderName: nameOf(room, sender),
+ avatarMxc: room.getMember(sender)?.getMxcAvatarUrl() ?? undefined,
+ own,
+ ts,
+ msgtype: type === EventType.Sticker ? 'm.sticker' : String(content.msgtype ?? ''),
+ body: redacted ? '' : String(content.body ?? ''),
+ media: redacted || encrypted ? undefined : type === EventType.Sticker ? stickerMedia(ev) : mediaOf(content),
+ edited: Boolean(ev.replacingEventId()),
+ redacted,
+ encrypted,
+ utd: encrypted ? utdText(ev, cryptoRunning) : undefined,
+ e2ee: ev.isEncrypted(),
+ status: status === 'sent' && ev.getId() && readByOthers.has(ev.getId()!) ? 'read' : status,
+ first: true,
+ last: true,
+ event: ev,
+ }
+ if (prev && prev.sender === sender && ts - prev.ts < groupGapMs) {
+ item.first = false
+ prev.last = false
+ }
+ items.push(item)
+ prev = item
+ }
+
+ // Only the newest message of a run says "Sent" or "Read"; one that is
+ // still sending or failed always says so.
+ for (const it of items) {
+ if (it.kind === 'message' && it.own && !it.last && (it.status === 'sent' || it.status === 'read')) {
+ it.status = undefined
+ }
+ }
+ return items
+}
+
+function stickerMedia(ev: MatrixEvent): MediaInfo {
+ const c = ev.getContent() as { url?: string; body?: string; info?: { w?: number; h?: number; mimetype?: string } }
+ return { mxc: c.url, w: c.info?.w, h: c.info?.h, mimetype: c.info?.mimetype, filename: c.body ?? 'sticker' }
+}
+
+/** Whether there is older history to page back to. */
+export function canPaginateBack(room: Room): boolean {
+ const tl = room.getLiveTimeline()
+ if (tl.getEvents().some((e) => e.getType() === EventType.RoomCreate)) return false
+ return tl.getPaginationToken(Direction.Backward) !== null
+}
+
+/** The newest event that came from the server, for read receipts. */
+export function lastSyncedEvent(room: Room): MatrixEvent | undefined {
+ const events = room.getLiveTimeline().getEvents()
+ for (let i = events.length - 1; i >= 0; i--) {
+ const ev = events[i]!
+ if (ev.getId() && !ev.status && !ev.getId()!.startsWith('~')) return ev
+ }
+ return undefined
+}
diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts
new file mode 100644
index 0000000..2cc8932
--- /dev/null
+++ b/apps/web/src/router.ts
@@ -0,0 +1,36 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useSession } from './stores/session'
+
+export const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true, title: 'Sign in' } },
+ {
+ path: '/register',
+ name: 'register',
+ component: () => import('./views/RegisterView.vue'),
+ meta: { public: true, title: 'Create account' },
+ },
+ {
+ path: '/',
+ component: () => import('./layouts/ClientShell.vue'),
+ children: [
+ { path: '', name: 'home', component: () => import('./views/HomeView.vue') },
+ { path: 'room/:roomId', name: 'room', component: () => import('./views/RoomView.vue'), props: true },
+ ],
+ },
+ { path: '/:pathMatch(.*)*', redirect: '/' },
+ ],
+})
+
+router.beforeEach((to) => {
+ const session = useSession()
+ if (!to.meta.public && !session.signedIn) {
+ return { name: 'login', query: to.fullPath !== '/' ? { next: to.fullPath } : undefined }
+ }
+ if (to.meta.public && session.signedIn) return { name: 'home' }
+})
+
+router.afterEach((to) => {
+ if (to.meta.title) document.title = `${to.meta.title} · Warp`
+})
diff --git a/apps/web/src/stores/chat.ts b/apps/web/src/stores/chat.ts
new file mode 100644
index 0000000..68e033d
--- /dev/null
+++ b/apps/web/src/stores/chat.ts
@@ -0,0 +1,373 @@
+import {
+ ClientEvent,
+ EventType,
+ KnownMembership,
+ MatrixEventEvent,
+ MsgType,
+ NotificationCountType,
+ Preset,
+ RoomEvent,
+ RoomMemberEvent,
+ RoomStateEvent,
+ SyncState,
+ UserEvent,
+ Visibility,
+ type MatrixClient,
+ type MatrixEvent,
+ type Room,
+} from 'matrix-js-sdk'
+import { defineStore } from 'pinia'
+import { computed, ref, shallowRef, watch } from 'vue'
+import { client } from '@/matrix/client'
+
+// The room list and the client's connection state, as plain reactive data
+// derived from the SDK. The SDK's objects change in place and emit events;
+// every event that can change what is on screen bumps `version` (at most
+// once a frame), and the computed views below re-derive from the SDK.
+
+export interface RoomSummary {
+ roomId: string
+ name: string
+ avatarMxc?: string
+ preview: string
+ ts: number
+ unread: number
+ highlight: number
+ encrypted: boolean
+ isDirect: boolean
+ /** The other person in a direct message. */
+ dmUserId?: string
+ presence?: 'online' | 'away' | 'offline'
+}
+
+export interface InviteSummary {
+ roomId: string
+ name: string
+ avatarMxc?: string
+ inviter?: string
+ inviterName?: string
+ isDirect: boolean
+ ts: number
+}
+
+/** The text a room list shows for an event, or null when it shows nothing. */
+export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSender: boolean): string | null {
+ if (ev.isRedacted()) return 'Message deleted'
+ const type = ev.getType()
+ // A message that failed to decrypt reads as m.room.message with the SDK's
+ // own placeholder text: never show that.
+ if (ev.isDecryptionFailure()) return withSender ? `${senderName(ev, me, room)}: Can’t decrypt this message` : 'Can’t decrypt this message'
+ if (type === EventType.RoomMessageEncrypted) return 'Encrypted message'
+ if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
+ if (ev.isRelation('m.replace')) return null
+ const content = ev.getContent()
+ let text: string
+ switch (content.msgtype) {
+ case MsgType.Image:
+ text = 'Image'
+ break
+ case MsgType.Video:
+ text = 'Video'
+ break
+ case MsgType.Audio:
+ text = 'Audio'
+ break
+ case MsgType.File:
+ text = `File: ${content.body ?? ''}`
+ break
+ default:
+ text = type === EventType.Sticker ? 'Sticker' : String(content.body ?? '')
+ }
+ if (content.msgtype === MsgType.Emote) {
+ return `* ${room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender()} ${text}`
+ }
+ if (!withSender) return text
+ return `${senderName(ev, me, room)}: ${text}`
+}
+
+function senderName(ev: MatrixEvent, me: string, room: Room): string {
+ return ev.getSender() === me ? 'You' : (room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender() ?? '')
+}
+
+function directRooms(c: MatrixClient): Map {
+ // m.direct maps a user to their DM rooms; flip it to room → user.
+ const out = new Map()
+ const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record
+ for (const [user, rooms] of Object.entries(content)) {
+ if (!Array.isArray(rooms)) continue
+ for (const r of rooms) out.set(r, user)
+ }
+ return out
+}
+
+function presenceOf(c: MatrixClient, userId: string): RoomSummary['presence'] {
+ const p = c.getUser(userId)?.presence
+ if (p === 'online') return 'online'
+ if (p === 'unavailable') return 'away'
+ if (p === 'offline') return 'offline'
+ return undefined
+}
+
+function summarize(c: MatrixClient, room: Room, direct: Map): RoomSummary {
+ const me = c.getUserId() ?? ''
+ const dmUserId = direct.get(room.roomId)
+ const isDirect = dmUserId !== undefined
+ const events = room.getLiveTimeline().getEvents()
+ let preview = ''
+ for (let i = events.length - 1; i >= 0; i--) {
+ const text = describeEvent(events[i]!, me, room, !isDirect)
+ if (text !== null) {
+ preview = text
+ break
+ }
+ }
+ let avatarMxc = room.getMxcAvatarUrl() ?? undefined
+ if (!avatarMxc && isDirect) avatarMxc = room.getMember(dmUserId)?.getMxcAvatarUrl() ?? undefined
+ return {
+ roomId: room.roomId,
+ name: room.name,
+ avatarMxc,
+ preview,
+ ts: room.getLastActiveTimestamp(),
+ unread: room.getUnreadNotificationCount(NotificationCountType.Total),
+ highlight: room.getUnreadNotificationCount(NotificationCountType.Highlight),
+ encrypted: room.hasEncryptionStateEvent(),
+ isDirect,
+ dmUserId,
+ presence: isDirect && dmUserId ? presenceOf(c, dmUserId) : undefined,
+ }
+}
+
+function summarizeInvite(c: MatrixClient, room: Room): InviteSummary {
+ const me = c.getUserId() ?? ''
+ const own = room.getMember(me)
+ const memberEvent = own?.events.member
+ const inviter = memberEvent?.getSender() ?? room.getDMInviter()
+ return {
+ roomId: room.roomId,
+ name: room.name,
+ avatarMxc: room.getMxcAvatarUrl() ?? undefined,
+ inviter,
+ inviterName: inviter ? (room.getMember(inviter)?.name ?? inviter) : undefined,
+ isDirect: Boolean(memberEvent?.getContent().is_direct),
+ ts: memberEvent?.getTs() ?? 0,
+ }
+}
+
+const encryptionState = {
+ type: EventType.RoomEncryption,
+ state_key: '',
+ content: { algorithm: 'm.megolm.v1.aes-sha2' },
+}
+
+export const useChat = defineStore('chat', () => {
+ const version = ref(0)
+ const syncState = shallowRef(null)
+ const ready = ref(false)
+ let scheduled = false
+ let attached: MatrixClient | null = null
+
+ function bump() {
+ if (scheduled) return
+ scheduled = true
+ const fire = () => {
+ if (!scheduled) return
+ scheduled = false
+ window.clearTimeout(timer)
+ version.value++
+ }
+ // A background tab gets no animation frames: the timer keeps the
+ // unread count in the tab title (and decryption) moving there.
+ requestAnimationFrame(fire)
+ const timer = window.setTimeout(fire, 250)
+ }
+
+ function onSync(state: SyncState) {
+ syncState.value = state
+ if (state === SyncState.Prepared || state === SyncState.Syncing) ready.value = true
+ bump()
+ }
+
+ /** Starts listening to the running client. */
+ function attach() {
+ const c = client()
+ if (attached === c) return
+ attached = c
+ ready.value = c.isInitialSyncComplete()
+ syncState.value = c.getSyncState()
+ c.on(ClientEvent.Sync, onSync)
+ for (const e of [
+ ClientEvent.Room,
+ ClientEvent.DeleteRoom,
+ ClientEvent.AccountData,
+ RoomEvent.Timeline,
+ RoomEvent.TimelineReset,
+ RoomEvent.Name,
+ RoomEvent.MyMembership,
+ RoomEvent.Receipt,
+ RoomEvent.Redaction,
+ RoomEvent.LocalEchoUpdated,
+ RoomEvent.UnreadNotifications,
+ RoomStateEvent.Events,
+ RoomMemberEvent.Typing,
+ RoomMemberEvent.Name,
+ UserEvent.Presence,
+ // An encrypted event changes in place once it is decrypted.
+ MatrixEventEvent.Decrypted,
+ ] as const) {
+ c.on(e as ClientEvent.Room, bump)
+ }
+ bump()
+ }
+
+ function detach() {
+ attached = null
+ ready.value = false
+ syncState.value = null
+ }
+
+ const rooms = computed(() => {
+ void version.value
+ if (!attached) return []
+ const c = attached
+ const direct = directRooms(c)
+ return c
+ .getRooms()
+ .filter((r) => r.getMyMembership() === KnownMembership.Join && !isReplaced(r))
+ .map((r) => summarize(c, r, direct))
+ .sort((a, b) => b.ts - a.ts)
+ })
+
+ const invites = computed(() => {
+ void version.value
+ if (!attached) return []
+ const c = attached
+ return c
+ .getRooms()
+ .filter((r) => r.getMyMembership() === KnownMembership.Invite)
+ .map((r) => summarizeInvite(c, r))
+ .sort((a, b) => b.ts - a.ts)
+ })
+
+ /** A room that was upgraded to a newer one the person is in. */
+ function isReplaced(room: Room): boolean {
+ const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, '')
+ const next = tombstone?.getContent().replacement_room
+ return Boolean(next && attached?.getRoom(next)?.getMyMembership() === KnownMembership.Join)
+ }
+
+ const totalUnread = computed(() => rooms.value.reduce((n, r) => n + (r.unread > 0 ? 1 : 0), 0) + invites.value.length)
+
+ // Offline once sync has been failing for a few seconds: one failed
+ // request that the next retry recovers from is not worth a banner.
+ const offline = ref(false)
+ let offlineTimer = 0
+ watch(syncState, (state) => {
+ const failing = state === SyncState.Error || state === SyncState.Reconnecting
+ window.clearTimeout(offlineTimer)
+ if (!failing) offline.value = false
+ else if (!offline.value) offlineTimer = window.setTimeout(() => (offline.value = true), 5000)
+ })
+
+ async function createRoom(opts: {
+ name: string
+ topic?: string
+ isPublic: boolean
+ alias?: string
+ encrypted: boolean
+ }): Promise {
+ const res = await client().createRoom({
+ name: opts.name.trim(),
+ topic: opts.topic?.trim() || undefined,
+ preset: opts.isPublic ? Preset.PublicChat : Preset.PrivateChat,
+ visibility: opts.isPublic ? Visibility.Public : Visibility.Private,
+ room_alias_name: opts.isPublic && opts.alias ? opts.alias.trim() : undefined,
+ initial_state: opts.encrypted ? [encryptionState] : undefined,
+ })
+ return res.room_id
+ }
+
+ /** Whether this session can encrypt a new room: the crypto stack is running. */
+ const canEncrypt = computed(() => {
+ void version.value
+ return Boolean(attached?.getCrypto())
+ })
+
+ /** Opens the existing direct message with the person, or starts one. */
+ async function startDirect(userId: string): Promise {
+ const c = client()
+ const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record
+ for (const roomId of content[userId] ?? []) {
+ const room = c.getRoom(roomId)
+ if (room && room.getMyMembership() === KnownMembership.Join) return roomId
+ }
+ // Fails early with a clear error when the person doesn't exist.
+ await c.getProfileInfo(userId)
+ // End-to-end encrypted when the other person has a session that can
+ // read it: encrypting to nobody would only lock them out.
+ let encrypt = false
+ const crypto = c.getCrypto()
+ if (crypto) {
+ const devices = await crypto.getUserDeviceInfo([userId], true).catch(() => null)
+ encrypt = (devices?.get(userId)?.size ?? 0) > 0
+ }
+ const res = await c.createRoom({
+ is_direct: true,
+ invite: [userId],
+ preset: Preset.TrustedPrivateChat,
+ initial_state: encrypt ? [encryptionState] : undefined,
+ })
+ const next = { ...content, [userId]: [...(content[userId] ?? []), res.room_id] }
+ await c.setAccountData(EventType.Direct, next)
+ return res.room_id
+ }
+
+ async function join(idOrAlias: string): Promise {
+ const room = await client().joinRoom(idOrAlias.trim())
+ return room.roomId
+ }
+
+ async function acceptInvite(roomId: string) {
+ const c = client()
+ const room = c.getRoom(roomId)
+ const own = room?.getMember(c.getUserId() ?? '')
+ const memberEvent = own?.events.member
+ await c.joinRoom(roomId)
+ // An accepted direct invite goes into m.direct, so it shows as one.
+ if (memberEvent?.getContent().is_direct) {
+ const inviter = memberEvent.getSender()
+ if (inviter) {
+ const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record
+ if (!(content[inviter] ?? []).includes(roomId)) {
+ await c.setAccountData(EventType.Direct, { ...content, [inviter]: [...(content[inviter] ?? []), roomId] })
+ }
+ }
+ }
+ }
+
+ async function leave(roomId: string) {
+ const c = client()
+ await c.leave(roomId)
+ await c.forget(roomId, true).catch(() => undefined)
+ bump()
+ }
+
+ return {
+ version,
+ ready,
+ syncState,
+ offline,
+ rooms,
+ invites,
+ totalUnread,
+ canEncrypt,
+ attach,
+ detach,
+ bump,
+ createRoom,
+ startDirect,
+ join,
+ acceptInvite,
+ leave,
+ }
+})
diff --git a/apps/web/src/stores/profile.ts b/apps/web/src/stores/profile.ts
new file mode 100644
index 0000000..463fe09
--- /dev/null
+++ b/apps/web/src/stores/profile.ts
@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { client } from '@/matrix/client'
+
+// Your own display name and avatar, as the homeserver has them.
+
+export const useProfile = defineStore('profile', () => {
+ const displayName = ref('')
+ const avatarMxc = ref()
+
+ async function load() {
+ const c = client()
+ try {
+ const p = await c.getProfileInfo(c.getUserId() ?? '')
+ displayName.value = p.displayname ?? ''
+ avatarMxc.value = p.avatar_url ?? undefined
+ } catch {
+ // No profile yet; the localpart stands in.
+ }
+ }
+
+ async function setDisplayName(name: string) {
+ await client().setDisplayName(name.trim())
+ displayName.value = name.trim()
+ }
+
+ async function setAvatar(file: File) {
+ const c = client()
+ const res = await c.uploadContent(file, { name: file.name, type: file.type })
+ await c.setAvatarUrl(res.content_uri)
+ avatarMxc.value = res.content_uri
+ }
+
+ async function removeAvatar() {
+ await client().setAvatarUrl('')
+ avatarMxc.value = undefined
+ }
+
+ return { displayName, avatarMxc, load, setDisplayName, setAvatar, removeAvatar }
+})
diff --git a/apps/web/src/stores/security.ts b/apps/web/src/stores/security.ts
new file mode 100644
index 0000000..f54bcea
--- /dev/null
+++ b/apps/web/src/stores/security.ts
@@ -0,0 +1,124 @@
+import type { MatrixClient } from 'matrix-js-sdk'
+import {
+ CryptoEvent,
+ VerificationPhase,
+ VerificationRequestEvent,
+ type VerificationRequest,
+} from 'matrix-js-sdk/lib/crypto-api'
+import { defineStore } from 'pinia'
+import { ref, shallowRef } from 'vue'
+import { client } from '@/matrix/client'
+import {
+ enableBackupIfPossible,
+ encryptionStatus,
+ verifyDevice,
+ verifyOwnSession,
+ type EncryptionStatus,
+} from '@/matrix/crypto'
+
+// End-to-end encryption as the views see it: whether this session is
+// verified, the key backup, and the one verification on screen at a time,
+// whether this session asked for it or somebody else did.
+
+export const useSecurity = defineStore('security', () => {
+ /** Null until known, and while the crypto stack isn't running. */
+ const status = shallowRef(null)
+ /** The verification the dialog shows. */
+ const request = shallowRef(null)
+ /** Bumps when the request or anybody's trust changes. */
+ const version = ref(0)
+ let attached: MatrixClient | null = null
+ let refreshing: Promise | null = null
+ let again = false
+
+ async function refresh() {
+ if (!attached?.getCrypto()) return
+ // One refresh at a time; changes during one ask for another after it.
+ if (refreshing) {
+ again = true
+ return refreshing
+ }
+ refreshing = (async () => {
+ do {
+ again = false
+ status.value = await encryptionStatus().catch(() => status.value)
+ } while (again)
+ })().finally(() => (refreshing = null))
+ return refreshing
+ }
+
+ function changed() {
+ version.value++
+ void refresh()
+ }
+
+ function onRequest(req: VerificationRequest) {
+ // One verification at a time: a second one while one is running is
+ // turned down rather than stacked.
+ const busy = request.value && request.value.phase !== VerificationPhase.Done && request.value.phase !== VerificationPhase.Cancelled
+ if (busy) {
+ void req.cancel({ reason: 'Already verifying another session', code: 'm.user' }).catch(() => undefined)
+ return
+ }
+ show(req)
+ }
+
+ function onRequestChange() {
+ version.value++
+ const req = request.value
+ if (req?.phase === VerificationPhase.Done) {
+ // Verified by our own other session: it sends the backup key next.
+ if (req.isSelfVerification) void enableBackupIfPossible().then(changed)
+ else changed()
+ }
+ }
+
+ function show(req: VerificationRequest) {
+ request.value?.off(VerificationRequestEvent.Change, onRequestChange)
+ request.value = req
+ req.on(VerificationRequestEvent.Change, onRequestChange)
+ version.value++
+ }
+
+ /** Closes the dialog, cancelling the verification if it is still going. */
+ function dismiss() {
+ const req = request.value
+ if (!req) return
+ req.off(VerificationRequestEvent.Change, onRequestChange)
+ if (req.phase !== VerificationPhase.Done && req.phase !== VerificationPhase.Cancelled) {
+ void req.cancel().catch(() => undefined)
+ }
+ request.value = null
+ }
+
+ /** Starts listening to the running client. */
+ function attach() {
+ const c = client()
+ if (attached === c) return
+ attached = c
+ status.value = null
+ request.value = null
+ if (!c.getCrypto()) return
+ c.on(CryptoEvent.VerificationRequestReceived, onRequest)
+ for (const e of [
+ CryptoEvent.KeyBackupStatus,
+ CryptoEvent.KeyBackupDecryptionKeyCached,
+ CryptoEvent.KeysChanged,
+ CryptoEvent.UserTrustStatusChanged,
+ CryptoEvent.DevicesUpdated,
+ ] as const) {
+ c.on(e as CryptoEvent.KeysChanged, changed)
+ }
+ void refresh()
+ }
+
+ async function verifyThisSession() {
+ show(await verifyOwnSession())
+ }
+
+ async function verifySession(userId: string, deviceId: string) {
+ show(await verifyDevice(userId, deviceId))
+ }
+
+ return { status, request, version, attach, refresh, dismiss, verifyThisSession, verifySession }
+})
diff --git a/apps/web/src/stores/session.ts b/apps/web/src/stores/session.ts
new file mode 100644
index 0000000..6bb5cfa
--- /dev/null
+++ b/apps/web/src/stores/session.ts
@@ -0,0 +1,240 @@
+import { HttpApiEvent, MatrixError, type AuthDict, type LoginResponse, type RegisterResponse } from 'matrix-js-sdk'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import { config } from '@/config'
+import { anonymousClient, client, destroyClient, startClient, stopClient, type Credentials } from '@/matrix/client'
+import { clearMediaCache } from '@/matrix/media'
+
+// The person's Matrix session. It is kept in localStorage while "Stay
+// signed in" is on (the default for a chat client), otherwise in
+// sessionStorage, which goes with the tab. Signing out logs the session out
+// on the homeserver and forgets it here, its encryption keys included.
+
+const storageKey = 'warp.web.session'
+const deviceName = 'Warp Web'
+
+function load(): Credentials | null {
+ for (const store of [localStorage, sessionStorage]) {
+ try {
+ const raw = store.getItem(storageKey)
+ if (raw) return JSON.parse(raw) as Credentials
+ } catch {
+ // Unreadable or blocked: treat as signed out.
+ }
+ }
+ return null
+}
+
+function save(c: Credentials | null, remember: boolean) {
+ for (const store of [localStorage, sessionStorage]) {
+ try {
+ store.removeItem(storageKey)
+ } catch {
+ // Ignore.
+ }
+ }
+ if (!c) return
+ try {
+ ;(remember ? localStorage : sessionStorage).setItem(storageKey, JSON.stringify(c))
+ } catch {
+ // Not kept across reloads; the session still works in this page.
+ }
+}
+
+/** What registration on this homeserver asks for. */
+export interface RegistrationInfo {
+ enabled: boolean
+ /** Every flow on offer needs a registration token. */
+ tokenRequired: boolean
+ /** Some flow takes a token, so offering the field makes sense. */
+ tokenAccepted: boolean
+ /** The server's words when it's off. */
+ reason?: string
+}
+
+interface UiaFlow {
+ stages: string[]
+}
+
+interface UiaState {
+ session?: string
+ flows?: UiaFlow[]
+ completed?: string[]
+}
+
+function uiaState(e: unknown): UiaState | null {
+ if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) {
+ return e.data as UiaState
+ }
+ return null
+}
+
+const supportedStages = new Set(['m.login.dummy', 'm.login.registration_token'])
+
+export const useSession = defineStore('session', () => {
+ const saved = load()
+ const userId = ref(saved?.userId ?? '')
+ const deviceId = ref(saved?.deviceId ?? '')
+ const phase = ref<'signedOut' | 'starting' | 'ready'>(saved ? 'starting' : 'signedOut')
+ /** Set when the session ended on its own, shown on the sign-in page. */
+ const endedReason = ref('')
+
+ const signedIn = computed(() => phase.value !== 'signedOut')
+ const serverName = computed(() => userId.value.slice(userId.value.indexOf(':') + 1))
+
+ function clear(reason = ''): Promise {
+ // The session is gone for good: its keys in IndexedDB go with it.
+ const done = userId.value && deviceId.value ? destroyClient({ userId: userId.value, deviceId: deviceId.value }) : Promise.resolve(stopClient())
+ clearMediaCache()
+ userId.value = ''
+ deviceId.value = ''
+ phase.value = 'signedOut'
+ endedReason.value = reason
+ save(null, false)
+ return done
+ }
+
+ async function begin(creds: Credentials) {
+ userId.value = creds.userId
+ deviceId.value = creds.deviceId
+ phase.value = 'starting'
+ const c = await startClient(creds)
+ c.on(HttpApiEvent.SessionLoggedOut, () => clear('Your session ended. Sign in again to continue.'))
+ phase.value = 'ready'
+ }
+
+ /** Picks the saved session back up on page load. */
+ async function restore() {
+ if (!saved || phase.value === 'ready') return
+ try {
+ await begin(saved)
+ } catch (e) {
+ if (e instanceof MatrixError && e.errcode === 'M_UNKNOWN_TOKEN') {
+ clear('Your session ended. Sign in again to continue.')
+ return
+ }
+ // Anything else (the homeserver unreachable, say) keeps the session
+ // and its encryption keys: the shell offers to try again.
+ stopClient()
+ throw e
+ }
+ }
+
+ async function adopt(res: LoginResponse | RegisterResponse, keep: boolean) {
+ if (!res.access_token || !res.device_id) throw new Error('The homeserver didn’t start a session.')
+ const creds: Credentials = {
+ baseUrl: config.homeserverUrl,
+ userId: res.user_id,
+ deviceId: res.device_id,
+ accessToken: res.access_token,
+ }
+ save(creds, keep)
+ await begin(creds)
+ }
+
+ async function signIn(username: string, password: string, keep: boolean) {
+ endedReason.value = ''
+ const res = await anonymousClient().loginRequest({
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: username.trim() },
+ password,
+ initial_device_display_name: deviceName,
+ })
+ await adopt(res, keep)
+ }
+
+ async function registrationInfo(): Promise {
+ try {
+ await anonymousClient().registerRequest({})
+ // A server that registers without any flow at all.
+ return { enabled: true, tokenRequired: false, tokenAccepted: false }
+ } catch (e) {
+ const uia = uiaState(e)
+ if (uia) {
+ const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
+ if (!flows.length) {
+ return {
+ enabled: false,
+ tokenRequired: false,
+ tokenAccepted: false,
+ reason: 'This homeserver asks for a sign-up step this client doesn’t support yet.',
+ }
+ }
+ const withToken = flows.filter((f) => f.stages.includes('m.login.registration_token'))
+ return { enabled: true, tokenRequired: withToken.length === flows.length, tokenAccepted: withToken.length > 0 }
+ }
+ if (e instanceof MatrixError && e.httpStatus === 403) {
+ return { enabled: false, tokenRequired: false, tokenAccepted: false, reason: e.data?.error }
+ }
+ throw e
+ }
+ }
+
+ /** Registers with the user-interactive auth flow: dummy and token stages. */
+ async function register(username: string, password: string, token: string, keep: boolean) {
+ endedReason.value = ''
+ const c = anonymousClient()
+ const body = {
+ username: username.trim(),
+ password,
+ initial_device_display_name: deviceName,
+ }
+ let auth: AuthDict | undefined
+ for (let step = 0; step < 6; step++) {
+ try {
+ const res = await c.registerRequest({ ...body, auth })
+ await adopt(res, keep)
+ return
+ } catch (e) {
+ const uia = uiaState(e)
+ if (!uia) throw e
+ // A stage that failed comes back with its error alongside the flows.
+ if (auth && e instanceof MatrixError && e.data?.errcode) {
+ throw new Error(e.data.error ?? 'The homeserver refused that sign-up step.')
+ }
+ const done = new Set(uia.completed ?? [])
+ const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
+ const flow =
+ flows.find((f) => (token ? f.stages.includes('m.login.registration_token') : !f.stages.includes('m.login.registration_token'))) ??
+ flows[0]
+ if (!flow) throw new Error('This homeserver asks for a sign-up step this client doesn’t support yet.')
+ const next = flow.stages.find((s) => !done.has(s))
+ if (!next) throw e
+ if (next === 'm.login.registration_token') {
+ if (!token) throw new Error('This homeserver needs a registration token to sign up.')
+ auth = { type: next, token, session: uia.session }
+ } else {
+ auth = { type: next, session: uia.session }
+ }
+ }
+ }
+ throw new Error('Registration didn’t complete. Try again.')
+ }
+
+ async function signOut() {
+ try {
+ await client().logout(true)
+ } catch {
+ // The token may already be gone; forget it either way.
+ }
+ await clear()
+ // A fresh page drops the crypto stack's memory and the store it still
+ // holds open, so the store is deleted now rather than on the next visit.
+ window.location.replace('/login')
+ }
+
+ return {
+ userId,
+ deviceId,
+ serverName,
+ phase,
+ signedIn,
+ endedReason,
+ restore,
+ signIn,
+ register,
+ registrationInfo,
+ signOut,
+ clear,
+ }
+})
diff --git a/apps/web/src/stores/toasts.ts b/apps/web/src/stores/toasts.ts
new file mode 100644
index 0000000..5ffd43f
--- /dev/null
+++ b/apps/web/src/stores/toasts.ts
@@ -0,0 +1,34 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+// Short confirmations after an action ("Password reset for @alice:..."),
+// and errors that have no form to sit next to.
+
+export interface Toast {
+ id: number
+ tone: 'success' | 'danger' | 'info'
+ text: string
+}
+
+export const useToasts = defineStore('toasts', () => {
+ const items = ref([])
+ let next = 1
+
+ function push(tone: Toast['tone'], text: string, ms = 5000) {
+ const id = next++
+ items.value.push({ id, tone, text })
+ window.setTimeout(() => dismiss(id), ms)
+ }
+
+ function dismiss(id: number) {
+ items.value = items.value.filter((t) => t.id !== id)
+ }
+
+ return {
+ items,
+ success: (text: string) => push('success', text),
+ error: (text: string) => push('danger', text, 8000),
+ info: (text: string) => push('info', text),
+ dismiss,
+ }
+})
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
new file mode 100644
index 0000000..f3c256e
--- /dev/null
+++ b/apps/web/src/style.css
@@ -0,0 +1,2 @@
+@import "tailwindcss";
+@import "@warp/ui/styles.css";
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
new file mode 100644
index 0000000..7def84c
--- /dev/null
+++ b/apps/web/src/views/HomeView.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
Pick a room
+
+
+ You have {{ chat.invites.length === 1 ? 'an invite' : `${chat.invites.length} invites` }} waiting in the room list.
+
+ Choose a room from the list, or use + to create one, start a chat or join a room.
+
+
+
diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue
new file mode 100644
index 0000000..1e6e380
--- /dev/null
+++ b/apps/web/src/views/LoginView.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
Sign in
+
With your account on this homeserver.
+
+
+ {{ session.endedReason }}
+ {{ error }}
+
+
+
+
+ Sign in
+
+
+ New here?
+ Create an account
+
+
+
diff --git a/apps/web/src/views/RegisterView.vue b/apps/web/src/views/RegisterView.vue
new file mode 100644
index 0000000..5bd024c
--- /dev/null
+++ b/apps/web/src/views/RegisterView.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
Create an account
+
+ {{ loadError || info?.reason || 'This homeserver isn’t taking new accounts. Ask its admin for one.' }}
+
+ Back to sign in
+
+
+
+
+
+
Create an account
+
+ Your username becomes your Matrix ID, @{{ username || 'name' }} on this homeserver. It
+ can’t be changed later.
+
+
+
+ {{ error }}
+
+
+
+
+
+
+ Create account
+
+
+ Have an account?
+ Sign in
+
+
+
+
diff --git a/apps/web/src/views/RoomView.vue b/apps/web/src/views/RoomView.vue
new file mode 100644
index 0000000..9870df0
--- /dev/null
+++ b/apps/web/src/views/RoomView.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
{{ invite.name }}
+
+
+ {{ invite.inviterName }}
+ ({{ invite.inviter }})
+ invited you {{ invite.isDirect ? 'to chat' : 'to this room' }}.
+
+ You’re invited to this room.
+
+
+
{{ joinError }}
+
+ Decline
+ Accept
+
+
+
+
+
+
+
+
+
You’re not in this room
+
{{ roomId }}
+
{{ joinError }}
+
+ Back
+ Join room
+
+
+
+
+
+
+
+
+
+
+
+ {{ summary?.name ?? room.name }}
+
+
+
+ {{ topic }}
+ {{ memberCount }} {{ memberCount === 1 ? 'member' : 'members' }}
+
+
+
+ Not encrypted
+
+
+
+
+
+
+
+
+
{{ typingText }}
+
+
+
+ Uploading {{ u.name }}
+ {{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%
+
+
+
+ This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
+
+
You don’t have permission to post in this room.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..3cc5123
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "types": ["vite/client", "node"],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..af84200
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,31 @@
+import { fileURLToPath } from 'node:url'
+import tailwindcss from '@tailwindcss/vite'
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+// In development the client talks to a homeserver through Vite's proxy, the
+// same paths warp-web proxies in production, so the code never needs to
+// know where the homeserver is. WARP_WEB_HOMESERVER points it elsewhere.
+const homeserver = process.env.WARP_WEB_HOMESERVER ?? 'http://localhost:8008'
+
+export default defineConfig({
+ plugins: [vue(), tailwindcss()],
+ resolve: {
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
+ },
+ build: {
+ // Embedded into the warp-web binary by internal/client.
+ outDir: 'internal/client/build/dist',
+ emptyOutDir: true,
+ assetsInlineLimit: 0,
+ // matrix-js-sdk is most of the bundle; it is one chunk on purpose.
+ chunkSizeWarningLimit: 1500,
+ },
+ server: {
+ port: 5175,
+ proxy: {
+ '/_matrix/client': { target: homeserver, changeOrigin: true },
+ '/_matrix/media': { target: homeserver, changeOrigin: true },
+ },
+ },
+})
diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml
index 2bf8d3d..966aebe 100644
--- a/deploy/compose/docker-compose.yml
+++ b/deploy/compose/docker-compose.yml
@@ -9,7 +9,8 @@
# docs/deployment.md. With the observability profile, Grafana is on
# http://localhost:3000 (admin / admin, change it), Prometheus on :9090 and
# the Jaeger UI on :16686. With the admin profile, the admin panel is on
-# http://localhost:8009.
+# http://localhost:8009; with the web profile, the web client is on
+# http://localhost:8080.
#
# Create the first administrator once the server is up:
#
@@ -82,6 +83,24 @@ services:
ports:
- "${WARP_ADMIN_PORT:-8009}:8009"
+ # The web client is optional too. Start it with the web profile:
+ # docker compose --profile web up -d
+ # It is on http://localhost:8080 and proxies the client's Matrix requests
+ # to the homeserver over the compose network.
+ web:
+ build:
+ context: ../..
+ dockerfile: apps/web/Dockerfile
+ image: warp-web:latest
+ profiles: [web]
+ restart: unless-stopped
+ depends_on:
+ - warp
+ environment:
+ WARP_WEB_HOMESERVER: http://warp:8008
+ ports:
+ - "${WARP_WEB_PORT:-8080}:8080"
+
prometheus:
image: prom/prometheus:v3.5.0
profiles: [observability]
diff --git a/docs/deployment.md b/docs/deployment.md
index b8e9781..e439a89 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -117,6 +117,24 @@ warp-admin needs `/_warp/admin`, your public proxy can leave that path out
and keep the panel on an internal hostname. See
[apps/admin](../apps/admin/README.md).
+## Web client
+
+The web client is optional too, and separate from both: `warp-web` serves
+it and, by default, proxies the client's Matrix requests
+(`/_matrix/client` and `/_matrix/media`, nothing else) to the homeserver,
+so the browser only ever talks to warp-web.
+
+```sh
+cd deploy/compose
+docker compose --profile web up -d # http://localhost:8080
+```
+
+Or build the image (`make web-docker`) or the binary (`make web`) and point
+it at the homeserver with `WARP_WEB_HOMESERVER`. If your homeserver is
+already public behind its own proxy, set `WARP_WEB_PUBLIC_HOMESERVER` to
+its URL instead: the browser then calls it directly and warp-web serves
+only the client. See [apps/web](../apps/web/README.md).
+
## Reverse proxy
Behind a proxy that terminates TLS:
diff --git a/docs/sync.md b/docs/sync.md
index 2e56c1b..3e032b5 100644
--- a/docs/sync.md
+++ b/docs/sync.md
@@ -42,7 +42,7 @@ second place for them to disagree.
## Stream tokens
-A sync response is assembled from seven streams that move independently:
+A sync response is assembled from eight streams that move independently:
| Stream | Moves when | Position from |
|---|---|---|
@@ -53,18 +53,19 @@ A sync response is assembled from seven streams that move independently:
| presence | somebody's presence actually changes | `sync_sequences` |
| device list | somebody's end-to-end keys change ([e2ee.md](e2ee.md)) | `keyserver_sequences` |
| notification | a user's unread counts in a room move ([push.md](push.md)) | `push_sequences` |
+| typing | somebody starts or stops typing, or a notification runs out | a counter in memory (see [Typing](#typing)) |
A client that has seen receipt 7 but only event 4 has to be able to say so
in the one opaque string the API gives it, so a token names a position in
each:
```
-s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}
+s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}_{typing}
```
Trailing zeroes are left off, so a client that has only ever seen room
-events carries `s12` rather than `s12_0_0_0_0_0_0`. The device list and
-notification positions came later (Phases 7 and 8) and were added at the
+events carries `s12` rather than `s12_0_0_0_0_0_0_0`. The device list,
+notification and typing positions came later and were added at the
end. A token with fewer
positions than this build writes is accepted and the missing ones read as
zero — which is what lets a token stored before a stream existed go on
@@ -167,6 +168,21 @@ cases, which `roomserver.syncState` implements:
A timeline that reaches back to the room's create event reports no state at
all: there is nothing before it.
+### A room joined inside the window
+
+To the client, a room the user has just joined is new: it has none of its
+history and none of its state. So an incremental sync reads such a room
+the way an initial sync does — the recent history the user may see and the
+whole state before it — instead of the events since `since`, which would
+be the join alone with `limited: false`, telling the client it has
+everything.
+
+"Joined inside the window" means the user's latest membership event is in
+the window *and* the membership before it was not already `join`. A
+display name or avatar change is a new join event too, but resending the
+room's history for one would be wrong; `roomserver.MembershipBefore` tells
+the two apart.
+
## Visibility
`roomserver.VisibleRange` answers "what span of this room may this user
@@ -331,9 +347,19 @@ having stopped typing, and a second process would not see them. The second
only matters when Warp stops being one process, which PLAN.md says is not
the plan.
-They therefore have no stream position. A notification that started and
-stopped between two syncs is simply never reported; one that is live is
-reported on every sync until it expires.
+Their stream position is a counter in the tracker that moves on every
+change, with each room remembering the position of its last one. A sync
+reports a room's typing list when it changed inside the window — including
+to empty, which is how a client hears that somebody stopped — and not
+again while it stays the same. Reporting a live notification on every sync
+instead would make every long poll return at once while anybody typed: a
+busy loop that ends in the rate limiter.
+
+The counter starts at zero again after a restart, so a client's typing
+position is compared for difference, never for order: one the tracker has
+never reached is from before the restart, and every room it knows of
+counts as changed. A notification that started and stopped between two
+syncs is reported as the empty list, which is what the client already had.
### Receipts
diff --git a/go.work b/go.work
index 22ea436..a0020ce 100644
--- a/go.work
+++ b/go.work
@@ -2,9 +2,9 @@ go 1.26.4
use (
./apps/admin
+ ./apps/web
./server
)
// The other client sub-projects join the workspace as they are created:
-// use ./apps/web (Go host for the web client, Phase 8.2)
// use ./apps/desktop (Wails v3 shell, Phase 8.3)
diff --git a/package.json b/package.json
index 62ce294..d4b7fc7 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,8 @@
"scripts": {
"admin:dev": "pnpm --filter @warp/admin dev",
"admin:build": "pnpm --filter @warp/admin build",
+ "web:dev": "pnpm --filter @warp/web dev",
+ "web:build": "pnpm --filter @warp/web build",
"typecheck": "pnpm -r typecheck"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 67ee8b8..7870a68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -63,6 +63,46 @@ importers:
specifier: ^3.1.1
version: 3.3.13(typescript@5.9.3)
+ apps/web:
+ dependencies:
+ '@warp/ui':
+ specifier: workspace:*
+ version: link:../ui
+ matrix-js-sdk:
+ specifier: ^44.0.0
+ version: 44.0.0
+ pinia:
+ specifier: ^3.0.3
+ version: 3.0.4(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3))
+ vue:
+ specifier: ^3.5.22
+ version: 3.5.44(typescript@5.9.3)
+ vue-router:
+ specifier: ^4.5.1
+ version: 4.6.4(vue@3.5.44(typescript@5.9.3))
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.14
+ version: 4.3.3(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))
+ '@types/node':
+ specifier: ^22.18.10
+ version: 22.20.5
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.1
+ version: 6.0.10(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.44(typescript@5.9.3))
+ tailwindcss:
+ specifier: ^4.1.14
+ version: 4.3.3
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.1.9
+ version: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
+ vue-tsc:
+ specifier: ^3.1.1
+ version: 3.3.13(typescript@5.9.3)
+
packages:
'@babel/helper-string-parser@7.29.7':
@@ -78,6 +118,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/runtime@8.0.7':
+ resolution: {integrity: sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==}
+
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
@@ -254,6 +297,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
+ resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
+ engines: {node: '>= 18'}
+
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
@@ -481,6 +528,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/events@3.0.3':
+ resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
+
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
@@ -545,9 +595,22 @@ packages:
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
+ another-json@0.2.0:
+ resolution: {integrity: sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==}
+
+ base-x@5.0.1:
+ resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
+
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
+ bs58@6.0.0:
+ resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+
+ content-type@3.1.1:
+ resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
+ engines: {node: '>=22'}
+
copy-anything@4.1.5:
resolution: {integrity: sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==}
engines: {node: '>=18'}
@@ -575,6 +638,10 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ events@3.3.0:
+ resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
+ engines: {node: '>=0.8.x'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -595,6 +662,10 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
+ is-network-error@1.3.2:
+ resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
+ engines: {node: '>=16'}
+
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -669,9 +740,23 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ loglevel@1.9.2:
+ resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
+ engines: {node: '>= 0.6.0'}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ matrix-events-sdk@0.0.1:
+ resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
+
+ matrix-js-sdk@44.0.0:
+ resolution: {integrity: sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==}
+ engines: {node: '>=22.0.0'}
+
+ matrix-widget-api@1.20.0:
+ resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
+
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -683,6 +768,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ p-retry@8.0.1:
+ resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
+ engines: {node: '>=22'}
+
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -717,6 +806,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ sdp-transform@3.0.0:
+ resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
+ hasBin: true
+
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
@@ -748,6 +841,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unhomoglyph@1.0.6:
+ resolution: {integrity: sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==}
+
vite@7.3.7:
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -820,6 +916,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
+ '@babel/runtime@8.0.7': {}
+
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
@@ -922,6 +1020,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
+ '@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
+
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
@@ -1072,6 +1172,8 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/events@3.0.3': {}
+
'@types/node@22.20.5':
dependencies:
undici-types: 6.21.0
@@ -1180,8 +1282,18 @@ snapshots:
alien-signals@3.2.1: {}
+ another-json@0.2.0: {}
+
+ base-x@5.0.1: {}
+
birpc@2.9.0: {}
+ bs58@6.0.0:
+ dependencies:
+ base-x: 5.0.1
+
+ content-type@3.1.1: {}
+
copy-anything@4.1.5: {}
csstype@3.2.3: {}
@@ -1226,6 +1338,8 @@ snapshots:
estree-walker@2.0.2: {}
+ events@3.3.0: {}
+
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
@@ -1237,6 +1351,8 @@ snapshots:
hookable@5.5.3: {}
+ is-network-error@1.3.2: {}
+
jiti@2.7.0: {}
lightningcss-android-arm64@1.32.0:
@@ -1288,16 +1404,43 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ loglevel@1.9.2: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
+ matrix-events-sdk@0.0.1: {}
+
+ matrix-js-sdk@44.0.0:
+ dependencies:
+ '@babel/runtime': 8.0.7
+ '@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
+ another-json: 0.2.0
+ bs58: 6.0.0
+ content-type: 3.1.1
+ loglevel: 1.9.2
+ matrix-events-sdk: 0.0.1
+ matrix-widget-api: 1.20.0
+ p-retry: 8.0.1
+ sdp-transform: 3.0.0
+ unhomoglyph: 1.0.6
+
+ matrix-widget-api@1.20.0:
+ dependencies:
+ '@types/events': 3.0.3
+ events: 3.3.0
+
mitt@3.0.1: {}
muggle-string@0.4.1: {}
nanoid@3.3.20: {}
+ p-retry@8.0.1:
+ dependencies:
+ is-network-error: 1.3.2
+
path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
@@ -1353,6 +1496,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.64.9
fsevents: 2.3.3
+ sdp-transform@3.0.0: {}
+
source-map-js@1.2.2: {}
speakingurl@14.0.1: {}
@@ -1374,6 +1519,8 @@ snapshots:
undici-types@6.21.0: {}
+ unhomoglyph@1.0.6: {}
+
vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.28.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e240ff5..a1f93ba 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
packages:
- apps/ui
- apps/admin
+ - apps/web
onlyBuiltDependencies:
- esbuild
diff --git a/server/internal/clientapi/ephemeral_test.go b/server/internal/clientapi/ephemeral_test.go
index 27d4219..6840672 100644
--- a/server/internal/clientapi/ephemeral_test.go
+++ b/server/internal/clientapi/ephemeral_test.go
@@ -44,15 +44,27 @@ func TestTypingReachesTheOtherMembers(t *testing.T) {
t.Errorf("user_ids = %v, want [%s]", users, alice.UserID)
}
+ // While she goes on typing, the next sync has nothing new to say. A
+ // sync that repeated it would return at once every time: clients would
+ // spin instead of long-polling.
+ again := ts.sync(bob, result.NextBatch)
+ if _, ok := ephemeralEvent(again, roomID, "m.typing"); ok {
+ t.Error("an unchanged m.typing was reported again")
+ }
+
// Stopping is reported as the list without them, not as nothing: a
// client replaces what it had.
expectStatus(t, ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s",
url.PathEscape(roomID), url.PathEscape(alice.UserID)),
alice.AccessToken, map[string]any{"typing": false}), http.StatusOK)
- stopped := ts.sync(bob, result.NextBatch)
- if _, ok := ephemeralEvent(stopped, roomID, "m.typing"); ok {
- t.Error("m.typing was still reported after the notification was withdrawn")
+ stopped := ts.sync(bob, again.NextBatch)
+ typing, ok = ephemeralEvent(stopped, roomID, "m.typing")
+ if !ok {
+ t.Fatal("stopping was not reported")
+ }
+ if users, _ := typing.Content["user_ids"].([]any); len(users) != 0 {
+ t.Errorf("user_ids = %v after she stopped, want none", users)
}
}
diff --git a/server/internal/clientapi/sync_test.go b/server/internal/clientapi/sync_test.go
index de690b6..44ee99d 100644
--- a/server/internal/clientapi/sync_test.go
+++ b/server/internal/clientapi/sync_test.go
@@ -260,7 +260,7 @@ func TestSyncRejectsAnUnreadableToken(t *testing.T) {
// "s-1" is not here: a negative event position is a /messages token
// from inside backfilled history, and a valid ?since=. Only the event
// stream goes below zero.
- for _, since := range []string{"nonsense", "s1_-1", "sx", "s1_2_3_4_5_6_7_8"} {
+ for _, since := range []string{"nonsense", "s1_-1", "sx", "s1_2_3_4_5_6_7_8_9"} {
rec := ts.get("/_matrix/client/v3/sync?since="+url.QueryEscape(since), alice.AccessToken)
expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM")
}
@@ -404,6 +404,44 @@ func TestSyncReportsALeaveAndThenStops(t *testing.T) {
}
}
+func TestSyncIncrementalSendsARoomJoinedInTheWindowInFull(t *testing.T) {
+ ts := newTestServer(t, nil)
+ alice := ts.register("alice", "correct-horse-battery-staple")
+ bob := ts.register("bob", "hunter2-hunter2-hunter2")
+ roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat", "name": "Carthage"})
+ ts.sendMessage(alice, roomID, "before bob joined")
+
+ since := ts.sync(bob, "").NextBatch
+ expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
+ bob.AccessToken, map[string]any{}), http.StatusOK)
+
+ // To bob the room is new: he gets it as an initial sync would give it,
+ // the history he may see and the state, not just his own join.
+ room, ok := ts.sync(bob, since).Rooms.Join[roomID]
+ if !ok {
+ t.Fatal("the joined room is not in the join section")
+ }
+ if got := bodiesOf(room.Timeline.Events); !contains(got, "before bob joined") {
+ t.Errorf("timeline = %v, want the history before the join", got)
+ }
+ all := append(typesOf(room.State.Events), typesOf(room.Timeline.Events)...)
+ for _, want := range []string{"m.room.create", "m.room.name", "m.room.power_levels"} {
+ if !contains(all, want) {
+ t.Errorf("state and timeline = %v, want %s", all, want)
+ }
+ }
+
+ // A later change to bob's own profile moves his membership too, but is
+ // no new join: that sync carries only what is new.
+ since = ts.sync(bob, since).NextBatch
+ expectStatus(t, ts.put("/_matrix/client/v3/profile/"+url.PathEscape(bob.UserID)+"/displayname",
+ bob.AccessToken, map[string]any{"displayname": "Bob"}), http.StatusOK)
+ room = ts.sync(bob, since).Rooms.Join[roomID]
+ if got := bodiesOf(room.Timeline.Events); contains(got, "before bob joined") {
+ t.Errorf("a profile change resent the history: %v", got)
+ }
+}
+
func TestSyncSummaryNamesARoomWithNoName(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
diff --git a/server/internal/roomserver/query.go b/server/internal/roomserver/query.go
index bc2f178..80c9053 100644
--- a/server/internal/roomserver/query.go
+++ b/server/internal/roomserver/query.go
@@ -367,6 +367,22 @@ func (r *API) Membership(ctx context.Context, roomID, userID string) (string, er
return entry.Membership, nil
}
+// MembershipBefore returns a user's membership in a room as of just before
+// an event, empty when they had none. Sync asks it of the user's own latest
+// membership event, to tell a join from a profile change.
+func (r *API) MembershipBefore(ctx context.Context, roomID, userID, eventID string) (string, error) {
+ entries, err := r.store.StateBefore(ctx, roomID, eventID)
+ if err != nil {
+ return "", err
+ }
+ for _, e := range entries {
+ if e.Type == event.TypeMember && e.StateKey == userID {
+ return e.Membership, nil
+ }
+ }
+ return "", nil
+}
+
// roomAndRules reads a room and the rules its events are under.
func (r *API) roomAndRules(ctx context.Context, roomID string) (storage.Room, roomversion.Rules, error) {
room, err := r.store.Room(ctx, roomID)
diff --git a/server/internal/syncapi/sliding.go b/server/internal/syncapi/sliding.go
index 9a1079f..0118bd8 100644
--- a/server/internal/syncapi/sliding.go
+++ b/server/internal/syncapi/sliding.go
@@ -975,7 +975,7 @@ func (s *API) slidingExtensions(
if ext.Typing.on() {
out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}}
for _, roomID := range joinedOnly(extensionRooms(ext.Typing, req.Body, selected)) {
- e := s.typingEvent(roomID)
+ e := s.typingEvent(roomID, false)
current := ""
if e != nil {
current = string(e.Content)
diff --git a/server/internal/syncapi/sync.go b/server/internal/syncapi/sync.go
index 12cfbb4..f55a8a2 100644
--- a/server/internal/syncapi/sync.go
+++ b/server/internal/syncapi/sync.go
@@ -320,6 +320,7 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
return Response{}, false, err
}
to := tokenAt(positions)
+ to.Typing = s.typing.position()
resp := Response{
Rooms: Rooms{
@@ -443,6 +444,13 @@ func (s *API) buildRooms(ctx context.Context, req SyncRequest, filter Filter, si
to: to,
full: since.IsInitial() || req.FullState || room.Position > since.PDU,
}
+ if room.Membership == event.MembershipJoin && !since.IsInitial() && room.Position > since.PDU {
+ joined, err := s.isNewJoin(ctx, req, room)
+ if err != nil {
+ return err
+ }
+ window.joined = joined
+ }
switch room.Membership {
case event.MembershipJoin:
@@ -490,6 +498,23 @@ type roomWindow struct {
// the client's position: an initial sync, ?full_state=true, or a room
// the user has only just joined.
full bool
+
+ // joined is a room the user joined inside the window. To their client
+ // the room is new, so it is read as an initial sync reads it: the
+ // recent history they may see and the state before it, not only the
+ // events since the client's position, which would be their own join.
+ joined bool
+}
+
+// isNewJoin reports whether the user's membership event inside the window
+// is a join into the room, rather than a profile change of a member who
+// was already in it.
+func (s *API) isNewJoin(ctx context.Context, req SyncRequest, room roomserver.MembershipAt) (bool, error) {
+ before, err := s.rooms.MembershipBefore(ctx, room.RoomID, req.User, room.EventID)
+ if err != nil {
+ return false, err
+ }
+ return before != event.MembershipJoin, nil
}
// from is the position a stream is read from, which is the start of it for
@@ -501,6 +526,15 @@ func (w roomWindow) from(pos int64) int64 {
return pos
}
+// timelineSince is the position a room's timeline and state are read from:
+// the client's, or the start of the room for one joined in the window.
+func (w roomWindow) timelineSince() int64 {
+ if w.joined {
+ return 0
+ }
+ return w.since.PDU
+}
+
// changedRooms is the set of rooms this sync has to look at.
//
// On an initial sync that is all of them. On an incremental one it is the
@@ -543,12 +577,13 @@ func (s *API) changedRooms(ctx context.Context, req SyncRequest, filter Filter,
}
}
- // Typing lives in memory and has no position at all: a notification
- // that started and stopped between two syncs is simply never reported,
- // and one that is live is reported every time until it expires.
- for _, roomID := range s.typing.activeRooms() {
- if slices.Contains(joined, roomID) {
- out[roomID] = struct{}{}
+ // Typing lives in memory, with a position of its own that counts
+ // changes: a room is looked at when its typing changed in the window.
+ if to.Typing != since.Typing {
+ for _, roomID := range s.typing.changedRooms(since.Typing, to.Typing) {
+ if slices.Contains(joined, roomID) {
+ out[roomID] = struct{}{}
+ }
}
}
@@ -638,7 +673,7 @@ func (s *API) roomSyncRequest(req SyncRequest, filter Filter, window roomWindow,
return roomserver.RoomSyncRequest{
RoomID: room.RoomID,
User: req.User,
- Since: window.since.PDU,
+ Since: window.timelineSince(),
To: window.to.PDU,
TimelineLimit: filter.TimelineLimit(),
TimelineFilter: filter.TimelineFilter(),
@@ -667,8 +702,17 @@ func (s *API) roomEphemeral(ctx context.Context, userID, roomID string, window r
out := []EphemeralEvent{}
ephemeralFilter := filter.EphemeralFilter()
- if e := s.typingEvent(roomID); e != nil && matchesEventFilter(e.Type, "", ephemeralFilter) {
- out = append(out, *e)
+ // A full read reports whoever is typing now; an incremental one
+ // reports the room's list when it changed, even when it is now empty.
+ var typing *EphemeralEvent
+ if window.full {
+ typing = s.typingEvent(roomID, false)
+ } else if window.to.Typing != window.since.Typing &&
+ s.typing.changedIn(roomID, window.since.Typing, window.to.Typing) {
+ typing = s.typingEvent(roomID, true)
+ }
+ if typing != nil && matchesEventFilter(typing.Type, "", ephemeralFilter) {
+ out = append(out, *typing)
}
byRoom, err := s.roomReceipts(ctx, userID, []string{roomID},
diff --git a/server/internal/syncapi/token.go b/server/internal/syncapi/token.go
index 662cec3..4c32759 100644
--- a/server/internal/syncapi/token.go
+++ b/server/internal/syncapi/token.go
@@ -18,13 +18,13 @@ import (
//
// The wire form is
//
-// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}
+// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}_{typing}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0_0_0". The device list
// position came with Phase 7 and the notification count position with
-// Phase 8, each added at the end, which is what lets every token handed
-// out before them go on parsing. The leading "s" is there
+// Phase 8 and the typing position after them, each added at the end,
+// which is what lets every token handed out before them go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
@@ -42,10 +42,15 @@ type Token struct {
// Notification is the push server's notification count stream.
Notification int64
+
+ // Typing is the typing tracker's position. It lives in memory and
+ // starts again at zero when the server restarts, so it is compared
+ // for difference, never for order.
+ Typing int64
}
// tokenPositions is how many positions a token carries.
-const tokenPositions = 7
+const tokenPositions = 8
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
@@ -56,7 +61,7 @@ func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
- parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification}
+ parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification, t.Typing}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
@@ -123,6 +128,7 @@ func ParseToken(raw string) (Token, error) {
DeviceList: positions[5],
Notification: positions[6],
+ Typing: positions[7],
}, nil
}
diff --git a/server/internal/syncapi/token_test.go b/server/internal/syncapi/token_test.go
index faca98a..a654d8b 100644
--- a/server/internal/syncapi/token_test.go
+++ b/server/internal/syncapi/token_test.go
@@ -17,6 +17,12 @@ func TestTokenRoundTrips(t *testing.T) {
{"events and receipts", syncapi.Token{PDU: 12, Receipt: 3}, "s12_3"},
{
"every stream",
+ syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6, Notification: 7, Typing: 8},
+ "s1_2_3_4_5_6_7_8",
+ },
+ {
+ // A token from before the typing position.
+ "seven streams",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6, Notification: 7},
"s1_2_3_4_5_6_7",
},
@@ -87,13 +93,13 @@ func TestParseTokenAcceptsAShortToken(t *testing.T) {
func TestParseTokenRejectsNonsense(t *testing.T) {
for _, raw := range []string{
- "nonsense", // no prefix
- "t1_2", // a prefix this build does not write
- "s", // a prefix and nothing else
- "s1_-1", // a negative position outside the event stream
- "sx", // not a number
- "s1_", // a trailing separator
- "s1_2_3_4_5_6_7_8", // more positions than there are streams
+ "nonsense", // no prefix
+ "t1_2", // a prefix this build does not write
+ "s", // a prefix and nothing else
+ "s1_-1", // a negative position outside the event stream
+ "sx", // not a number
+ "s1_", // a trailing separator
+ "s1_2_3_4_5_6_7_8_9", // more positions than there are streams
"s1.5",
} {
if _, err := syncapi.ParseToken(raw); err == nil {
diff --git a/server/internal/syncapi/typing.go b/server/internal/syncapi/typing.go
index 98564e1..00ab496 100644
--- a/server/internal/syncapi/typing.go
+++ b/server/internal/syncapi/typing.go
@@ -34,6 +34,14 @@ type typingTracker struct {
// reaches a blocked sync rather than waiting for one to ask.
timers map[typingKey]*time.Timer
closed bool
+
+ // pos counts changes, and changed is room ID -> the position of its
+ // last one. Together they are the typing stream a sync token names:
+ // a sync reports a room's typing when it changed since the client's
+ // position, once, rather than on every sync while anybody types,
+ // which would answer every long poll at once: a busy loop.
+ pos int64
+ changed map[string]int64
}
type typingKey struct {
@@ -48,6 +56,7 @@ func newTypingTracker(timeout time.Duration, notifier *Notifier, now func() time
now: now,
rooms: map[string]map[string]time.Time{},
timers: map[typingKey]*time.Timer{},
+ changed: map[string]int64{},
}
}
@@ -89,6 +98,7 @@ func (t *typingTracker) set(roomID, userID string, typing bool, timeout time.Dur
users[userID] = t.now().Add(timeout)
t.timers[key] = time.AfterFunc(timeout, func() { t.expire(key) })
}
+ t.bump(roomID)
t.mu.Unlock()
// Outside the lock: the notifier takes its own, and a sync woken by it
@@ -106,6 +116,7 @@ func (t *typingTracker) expire(key typingKey) {
if len(users) == 0 {
delete(t.rooms, key.roomID)
}
+ t.bump(key.roomID)
}
t.mu.Unlock()
@@ -136,20 +147,42 @@ func (t *typingTracker) users(roomID string) []string {
return out
}
-// rooms with anybody typing, which is how a sync knows a room has an
-// ephemeral change without asking per room.
-func (t *typingTracker) activeRooms() []string {
+// bump records a change in a room. The caller holds the lock.
+func (t *typingTracker) bump(roomID string) {
+ t.pos++
+ t.changed[roomID] = t.pos
+}
+
+// position is the top of the typing stream.
+func (t *typingTracker) position() int64 {
t.mu.Lock()
defer t.mu.Unlock()
+ return t.pos
+}
- now := t.now()
- out := make([]string, 0, len(t.rooms))
- for roomID, users := range t.rooms {
- for _, expires := range users {
- if expires.After(now) {
- out = append(out, roomID)
- break
- }
+// changedIn reports whether a room's typing changed after since, up to
+// and including to. A since beyond the tracker's position is from before a
+// restart, which forgot everything: then every room it knows of counts.
+func (t *typingTracker) changedIn(roomID string, since, to int64) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ pos, ok := t.changed[roomID]
+ if !ok {
+ return false
+ }
+ return since > t.pos || (pos > since && pos <= to)
+}
+
+// changedRooms lists the rooms whose typing changed after since, up to and
+// including to, which is how a sync knows a room has an ephemeral change
+// without asking per room.
+func (t *typingTracker) changedRooms(since, to int64) []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ out := []string{}
+ for roomID, pos := range t.changed {
+ if since > t.pos || (pos > since && pos <= to) {
+ out = append(out, roomID)
}
}
slices.Sort(out)
@@ -168,6 +201,7 @@ func (t *typingTracker) close() {
delete(t.timers, key)
}
t.rooms = map[string]map[string]time.Time{}
+ t.changed = map[string]int64{}
}
// TypingRequest is PUT /rooms/{roomID}/typing/{userID}.
@@ -202,14 +236,16 @@ func (s *API) SetTyping(ctx context.Context, req TypingRequest) error {
return nil
}
-// typingEvent is the m.typing EDU of a room, or nil when nobody is typing.
+// typingEvent is the m.typing EDU of a room. It is nil when nobody is
+// typing, unless empty asks for the empty list: that is how a client hears
+// that the last person stopped.
//
// The event is the whole list rather than a delta, which is what the spec
// defines: a client replaces what it had, so a missed update corrects
// itself on the next one.
-func (s *API) typingEvent(roomID string) *EphemeralEvent {
+func (s *API) typingEvent(roomID string, empty bool) *EphemeralEvent {
users := s.typing.users(roomID)
- if len(users) == 0 {
+ if len(users) == 0 && !empty {
return nil
}
content, err := json.Marshal(map[string][]string{"user_ids": users})