Web client (warp-web) with E2EE, and two sync fixes
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s

apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:22:11 +02:00
1 parent adc18e7cf3
commit b6f3be6ac4
90 files changed
+6608 -472

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
**/node_modules
**/dist
!apps/admin/internal/panel/build
!apps/web/internal/client/build
bin
coverage.*
.complement
+86
View File
@@ -289,3 +289,89 @@ jobs:
status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8009/_matrix/client/v3/sync \
-H "Authorization: Bearer $token")"
test "$status" = "404"
web:
name: Web client
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: server/go.sum
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck and build the frontend
run: |
pnpm --filter @warp/web typecheck
pnpm --filter @warp/web build
- name: Verify formatting
working-directory: apps/web
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Vet and test warp-web
working-directory: apps/web
run: |
go vet ./...
go test -count=1 ./...
- name: Smoke test warp-web against the homeserver
run: |
set -euo pipefail
make build
(cd apps/web && go build -o ../../bin/warp-web ./cmd/warp-web)
sed 's/^ server_name: .*/ server_name: ci.example/' warp.yaml.example > warp.yaml
./bin/warp generate-keys
./bin/warp migrate
./bin/warp create-account --username ci-user --password hunter2hunter2
./bin/warp serve &
./bin/warp-web -homeserver http://127.0.0.1:8008 -listen 127.0.0.1:8080 &
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8008/healthz >/dev/null 2>&1 &&
curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
break
fi
sleep 1
done
# The client is embedded, its routes fall back to index.html, and
# its runtime config says to use the proxy.
curl -fsS http://127.0.0.1:8080/ | grep -q '<div id="app">'
curl -fsS 'http://127.0.0.1:8080/room/!abc:ci.example' | grep -q '<div id="app">'
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"
+4
View File
@@ -35,3 +35,7 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
# Local planning and design material
/PLAN/
/DESIGN/
+32
View File
@@ -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 .
-418
View File
@@ -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?
+4
View File
@@ -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
+4 -3
View File
@@ -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
```
+7 -2
View File
@@ -46,6 +46,9 @@ The stylesheet brings in:
| `WBanner` | feedback; always icon and words, never colour alone |
| `WDialog` | modal on the native `<dialog>` |
| `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.
+2
View File
@@ -0,0 +1,2 @@
/** Delivery state of your own message, as the bubble shows it. */
export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { nextTick, ref, useId, watch } from 'vue'
import WIcon from './WIcon.vue'
import WIconButton from './WIconButton.vue'
// The message input at the bottom of a room. Enter sends, Shift+Enter adds
// a line; the field grows with its text up to a few lines. Send stays
// disabled until there is something to send.
const props = withDefaults(
defineProps<{
/** Specific to the room: "Message #homelab" or "Message Maja". */
placeholder?: string
encrypted?: boolean
disabled?: boolean
/** Shows the attach button; the app handles the file. */
attach?: boolean
}>(),
{ placeholder: 'Message', encrypted: false, disabled: false, attach: true },
)
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{ send: [text: string]; attach: [files: FileList] }>()
const id = useId()
const field = ref<HTMLTextAreaElement>()
const picker = ref<HTMLInputElement>()
function grow() {
const el = field.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 176)}px`
}
watch(model, () => nextTick(grow))
function send() {
const text = model.value
if (!text.trim() || props.disabled) return
emit('send', text)
model.value = ''
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
send()
}
}
function onPick() {
const files = picker.value?.files
if (files && files.length) emit('attach', files)
if (picker.value) picker.value.value = ''
}
defineExpose({ focus: () => field.value?.focus() })
</script>
<template>
<form class="wp-composer items-end" @submit.prevent="send">
<template v-if="attach">
<WIconButton icon="attach" label="Attach a file" :disabled="disabled" @click="picker?.click()" />
<input ref="picker" type="file" class="wp-sr" tabindex="-1" aria-hidden="true" multiple @change="onPick" />
</template>
<label :for="id" class="wp-sr">Message</label>
<textarea
:id="id"
ref="field"
v-model="model"
class="wp-composer-input resize-none self-center"
rows="1"
:placeholder="placeholder"
:disabled="disabled"
autocomplete="off"
@keydown="onKey"
/>
<WIcon v-if="encrypted" name="lock" :size="16" class="wp-composer-lock mb-3" label="End-to-end encrypted" />
<button type="submit" class="wp-composer-send" aria-label="Send" :disabled="disabled || !model.trim()">
<WIcon name="send" :size="20" />
</button>
</form>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
import type { MessageStatus } from '../chat'
// A chat message: bubble, sender name on the first of a group, time and,
// for your own messages, the delivery status in an icon and a word.
const props = withDefaults(
defineProps<{
own?: boolean
/** Others' messages, first in a group. */
sender?: string
time?: string
edited?: boolean
status?: MessageStatus
}>(),
{ own: false, sender: undefined, time: '', edited: false, status: undefined },
)
const statuses: Record<MessageStatus, [IconName, string]> = {
sending: ['more', 'Sending'],
sent: ['check', 'Sent'],
read: ['read', 'Read'],
failed: ['shield-alert', 'Not sent'],
}
const shown = computed(() => (props.status ? statuses[props.status] : undefined))
</script>
<template>
<div :class="['wp-msg', own ? 'wp-msg-own' : 'wp-msg-other', status === 'failed' && 'wp-msg-failed']">
<p v-if="!own && sender" class="wp-msg-sender">{{ sender }}</p>
<div class="wp-msg-bubble"><slot /></div>
<p v-if="time || shown || $slots.meta" class="wp-msg-meta">
{{ time }}<template v-if="edited"> · edited</template>
<span v-if="shown" class="wp-msg-status">
<WIcon :name="shown[0]" :size="14" />
{{ shown[1] }}
</span>
<slot name="meta" />
</p>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import WAvatar from './WAvatar.vue'
import WBadge from './WBadge.vue'
import WIcon from './WIcon.vue'
// One row of the room list: avatar, name, last message, time and unread
// counter. Lives on surface-sunken.
withDefaults(
defineProps<{
name: string
preview?: string
/** Already formatted: "14:32" today, weekday this week, "12 Oct" older. */
time?: string
avatar?: string
/** Unread count; 0 or absent shows none. */
unread?: number
/** The counter turns brand when the person was mentioned. */
mention?: boolean
encrypted?: boolean
active?: boolean
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{
preview: '',
time: '',
avatar: undefined,
unread: 0,
mention: false,
encrypted: false,
active: false,
presence: undefined,
},
)
</script>
<template>
<button
type="button"
:class="['wp-room', active && 'wp-room-active', unread > 0 && 'wp-room-unread']"
:aria-current="active ? 'true' : undefined"
>
<WAvatar :name="name" :src="avatar" :size="40" :presence="presence" />
<span class="wp-room-main">
<span class="wp-room-top">
<span class="wp-room-name">{{ name }}</span>
<WIcon v-if="encrypted" name="lock" :size="13" class="wp-room-lock" label="End-to-end encrypted" />
<span class="wp-room-time">{{ time }}</span>
</span>
<span class="wp-room-bottom">
<span class="wp-room-preview">{{ preview }}</span>
<WBadge v-if="unread > 0" :tone="mention ? 'brand' : 'neutral'" :count="unread" />
</span>
</span>
</button>
</template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>arrow-down</title><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 238 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>download</title><path d="M12 4v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 19.5h14"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>file</title><path d="M13.5 3.5H7A1.5 1.5 0 0 0 5.5 5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8.5z"/><path d="M13.5 3.5v5h5"/></svg>

After

Width:  |  Height:  |  Size: 309 B

+4
View File
@@ -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<string>('./*.svg', { query: '?raw', import: 'default', eager: true })
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>users</title><circle cx="9" cy="8.5" r="3.5"/><path d="M3 19.5c1.2-3.4 3.4-5.2 6-5.2s4.8 1.8 6 5.2"/><path d="M15 5.2a3.5 3.5 0 0 1 0 6.6"/><path d="M17.5 14.6c1.6.7 2.8 2.3 3.5 4.9"/></svg>

After

Width:  |  Height:  |  Size: 359 B

+4
View File
@@ -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'
+4
View File
@@ -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); }
+52
View File
@@ -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"]
+178
View File
@@ -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:<user>:<session>`), 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 <http://localhost:8080>. 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.
+139
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/dariuszjaniak/warp/apps/web
go 1.26.4
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Warp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+4
View File
@@ -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.
+23
View File
@@ -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
}
+273
View File
@@ -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 <img src>. 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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warp</title>
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
<body><h1 style="font-size:20px">The web client isn’t built into this binary</h1>
<p>This warp-web was compiled before the frontend. Build it, then the binary:</p>
<p><code>pnpm --filter @warp/web build</code><br><code>make web</code></p></body></html>
`
+306
View File
@@ -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("<!doctype html><title>client</title>")},
"favicon.svg": {Data: []byte("<svg/>")},
"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", "<svg", "public, max-age=3600", 200},
{"/assets/gone-b2.js", "", "", 404},
{"/robots.txt", "", "", 404},
}
for _, c := range cases {
rec := do(h, http.MethodGet, c.path, nil)
if rec.Code != c.wantCode {
t.Errorf("%s: status %d, want %d", c.path, rec.Code, c.wantCode)
continue
}
if c.wantCode != 200 {
continue
}
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(string(body), c.wantBody) {
t.Errorf("%s: body %q", c.path, body)
}
if got := rec.Header().Get("Cache-Control"); got != c.wantCache {
t.Errorf("%s: Cache-Control %q, want %q", c.path, got, c.wantCache)
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self';") {
t.Errorf("%s: CSP %q", c.path, csp)
}
// WebAssembly for end-to-end encryption, and no JavaScript eval.
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self' 'wasm-unsafe-eval';") {
t.Errorf("%s: CSP %q lacks wasm-unsafe-eval", c.path, csp)
}
if rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s: X-Frame-Options %q", c.path, rec.Header().Get("X-Frame-Options"))
}
}
if rec := do(h, http.MethodPost, "/", nil); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /: status %d, want 405", rec.Code)
}
}
func TestConfigProxied(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
rec := do(h, http.MethodGet, "/config.json", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
var cfg Config
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
if cfg.HomeserverURL != "" || cfg.Version != "1.2.3" {
t.Errorf("config = %+v, want same-origin and the version", cfg)
}
if rec.Header().Get("Cache-Control") != "no-cache" {
t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control"))
}
}
func TestDirectModeProxiesNothing(t *testing.T) {
public, _ := url.Parse("https://matrix.warp.example")
h, err := New(Options{PublicHomeserver: public, Client: clientFS})
if err != nil {
t.Fatal(err)
}
rec := do(h, http.MethodGet, "/config.json", nil)
var cfg Config
_ = json.Unmarshal(rec.Body.Bytes(), &cfg)
if cfg.HomeserverURL != "https://matrix.warp.example" {
t.Errorf("homeserver_url = %q", cfg.HomeserverURL)
}
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusNotFound {
t.Errorf("proxy in direct mode: status %d, want 404", rec.Code)
}
// The browser may now connect to the homeserver, and only there.
rec = do(h, http.MethodGet, "/", nil)
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self' https://matrix.warp.example;") {
t.Errorf("CSP %q", csp)
}
}
func TestNotBuiltClient(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, false)
rec := do(h, http.MethodGet, "/", nil)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "isn’t built") {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
// The proxy still works without the client.
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusOK {
t.Errorf("proxy status %d", rec.Code)
}
}
func TestHealthz(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
if rec := do(h, http.MethodGet, "/healthz", nil); rec.Code != 200 || rec.Body.String() != "ok\n" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
}
func TestNewValidatesHomeserver(t *testing.T) {
rel, _ := url.Parse("warp:8008")
ftp, _ := url.Parse("ftp://warp.example")
ok, _ := url.Parse("http://warp:8008")
for name, opts := range map[string]Options{
"relative": {Homeserver: rel},
"scheme": {PublicHomeserver: ftp},
"neither": {},
"both": {Homeserver: ok, PublicHomeserver: ok},
} {
if _, err := New(opts); err == nil {
t.Errorf("%s: want an error", name)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@warp/web",
"version": "0.0.0",
"private": true,
"description": "Warp web client: a Matrix client for the browser",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@warp/ui": "workspace:*",
"matrix-js-sdk": "^44.0.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.18.10",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.9.3",
"vite": "^7.1.9",
"vue-tsc": "^3.1.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><title>Warp</title><g transform="translate(-23.3 -22.11) scale(1.182)"><path fill="#00A389" fill-rule="evenodd" d="M73.5 23.1 L67.4 23.9 L60.1 26 L54.6 28.5 L48.2 32.6 L42.6 37.7 L37.9 43.6 L34.2 50.3 L32.1 56 L30.7 61.9 L30.1 67.9 L30.1 105.8 L30.3 110.3 L31.3 116.3 L33 122.2 L35.5 127.7 L38.8 132.9 L43.7 138.6 L47 141.7 L50.7 144.4 L54.6 146.8 L58.7 148.7 L66 151 L70.4 151.8 L75 152.2 L112.9 152.2 L117.4 152 L124.9 150.7 L130.6 148.7 L136.1 146 L142.3 141.7 L146.8 137.5 L151.5 131.6 L155.2 125 L157.7 117.9 L174.2 117.9 L176.6 118.3 L179.1 119.3 L181.7 121.2 L183.7 123.6 L185 126.5 L185.5 129.6 L185.5 167.6 L184.9 170.3 L183.9 172.4 L182.5 174.4 L180.8 176 L177.7 177.7 L174.2 178.5 L135.9 178.4 L133.6 178 L131.7 177.2 L129.7 176 L128 174.4 L126.6 172.4 L125.8 170.6 L125.1 168.4 L125 166.1 L125 167.2 L117.2 168.2 L90.7 168.4 L91 172.4 L91.9 176.9 L94.2 184.2 L96.1 188.3 L99.3 193.4 L102.2 197 L105.3 200.3 L108.8 203.2 L113.9 206.6 L117.9 208.7 L122.2 210.4 L128 211.9 L135.6 212.8 L173.4 212.8 L180.6 212.3 L225.9 231 L217.3 181.8 L219 175.4 L219.9 167.9 L219.9 130 L219.5 124 L218.3 118 L216.9 113.7 L214.4 108.1 L211.2 103 L206.3 97.2 L202.9 94.1 L199.2 91.4 L195.3 89.1 L191.2 87.1 L184 84.8 L179.5 84 L175 83.6 L137.1 83.6 L132.5 83.8 L125.1 85.2 L119.3 87.1 L112.6 90.6 L107.6 94.1 L102.2 99.4 L98.5 104.2 L94.8 110.9 L92.3 117.9 L75.7 117.9 L73 117.4 L70.4 116.3 L68.2 114.6 L66.4 112.5 L65.2 110.1 L64.5 107.4 L64.4 69.7 L64.8 66.3 L66 63.4 L67.9 60.9 L70.8 58.8 L73.4 57.7 L76.1 57.3 L112.6 57.3 L115.2 57.6 L117.1 58.1 L118.9 59 L120.8 60.3 L122.2 61.8 L123.3 63.4 L124.3 65.6 L124.8 67.5 L125 69.7 L125 68.6 L132.7 67.6 L159.3 67.4 L158.7 61.9 L157.3 56 L155.2 50.3 L152.3 44.9 L148.8 40 L144.6 35.5 L139.9 31.7 L134.7 28.5 L130.6 26.5 L126.3 25 L121.9 23.9 L117.4 23.2 L112.9 23Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTheme } from '@warp/ui'
import ToastList from './components/ToastList.vue'
import { useSession } from './stores/session'
useTheme()
const session = useSession()
const router = useRouter()
// The session can end on its own (signed out elsewhere, token revoked);
// send the person back to sign in.
watch(
() => session.signedIn,
(signedIn) => {
if (!signedIn && !router.currentRoute.value.meta.public) router.replace({ name: 'login' })
},
)
</script>
<template>
<RouterView />
<ToastList />
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WTextField } from '@warp/ui'
// Confirmation for the actions that can't be taken back. With `typeToConfirm`
// the admin types the name of what goes (a room ID, a user ID) before the
// button unlocks, so a slip on a table can't purge the wrong thing.
const props = withDefaults(
defineProps<{
title: string
confirmLabel: string
danger?: boolean
typeToConfirm?: string
busy?: boolean
error?: string
}>(),
{ danger: true, typeToConfirm: undefined, busy: false, error: '' },
)
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ confirm: [] }>()
const typed = ref('')
watch(open, (v) => {
if (v) typed.value = ''
})
const ready = computed(() => !props.typeToConfirm || typed.value.trim() === props.typeToConfirm)
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy">
<div class="body flex flex-col gap-3">
<slot />
</div>
<WTextField
v-if="typeToConfirm"
v-model="typed"
wide
mono
:label="`Type ${typeToConfirm} to confirm`"
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton :variant="danger ? 'danger-solid' : 'primary'" :disabled="!ready" :loading="busy" @click="emit('confirm')">
{{ confirmLabel }}
</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WIcon, WSwitch, WTextArea, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { useChat } from '@/stores/chat'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [roomId: string] }>()
const chat = useChat()
const name = ref('')
const topic = ref('')
const isPublic = ref(false)
const alias = ref('')
const encrypted = ref(true)
const busy = ref(false)
const error = ref('')
watch(open, (v) => {
if (!v) return
name.value = ''
topic.value = ''
isPublic.value = false
alias.value = ''
encrypted.value = chat.canEncrypt
error.value = ''
})
// End-to-end encrypted by default for a private room. A public one is
// readable by whoever joins anyway, and history is lost to newcomers.
watch(isPublic, (v) => (encrypted.value = !v && chat.canEncrypt))
// A public room's address, from its name: "Homelab ops" → homelab-ops.
watch(name, (n, old) => {
const slug = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!alias.value || alias.value === slug(old)) alias.value = slug(n)
})
const aliasError = computed(() =>
isPublic.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined,
)
async function submit() {
if (!name.value.trim() || aliasError.value) return
busy.value = true
error.value = ''
try {
const roomId = await chat.createRoom({
name: name.value,
topic: topic.value,
isPublic: isPublic.value,
alias: alias.value,
encrypted: encrypted.value,
})
open.value = false
emit('created', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<WDialog v-model:open="open" title="Create room" :persistent="busy">
<form id="create-room" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField v-model="name" wide label="Name" maxlength="255" required autofocus />
<WTextArea v-model="topic" label="Topic (optional)" rows="2" />
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0">Public room</p>
<p class="small m-0 text-ink-muted">Anyone can find it in the room directory and join. Otherwise people join by invite.</p>
</div>
<WSwitch v-model="isPublic" label="Public room" />
</div>
<WTextField
v-if="isPublic"
v-model="alias"
wide
mono
label="Address"
hint="People can join with #address:your-server"
:error="aliasError"
autocomplete="off"
spellcheck="false"
/>
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0 flex items-center gap-1">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="16" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'" />
End-to-end encrypted
</p>
<p class="small m-0 text-ink-muted">
<template v-if="!chat.canEncrypt">End-to-end encryption didn’t start in this browser.</template>
<template v-else-if="encrypted">Only the people in the room can read its messages, not the homeserver. It can’t be turned off later.</template>
<template v-else>The homeserver can read this room’s messages.</template>
</p>
</div>
<WSwitch v-model="encrypted" label="End-to-end encrypted" :disabled="!chat.canEncrypt" />
</div>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="create-room" variant="primary" :loading="busy" :disabled="!name.trim() || !!aliasError">
Create room
</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WCheckbox, WDialog, WIcon, WSpinner, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import {
createKeyBackup,
PasswordNeededError,
recoveryKeyKnown,
restoreKeyBackup,
setUpEncryption,
unlockWithRecoveryKey,
} from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
import { useToasts } from '@/stores/toasts'
// Encryption for the account and this session, one task per mode:
// setup – first time: cross-signing, secret storage, key backup; shows the recovery key
// reset – the recovery key is lost: new keys, everyone verifies again
// unlock – verify this session with the recovery key, or with another session
// restore – read older messages from the key backup
// backup – start a key backup on an account that has none
export type EncryptionMode = 'setup' | 'reset' | 'unlock' | 'restore' | 'backup'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ mode: EncryptionMode }>()
const security = useSecurity()
const toasts = useToasts()
const step = ref<EncryptionMode | 'saveKey' | 'restoring' | 'restored'>(props.mode)
const password = ref('')
const needPassword = ref(false)
const recoveryKey = ref('')
const savedKey = ref(false)
const input = ref('')
const busy = ref(false)
const error = ref('')
const progress = ref<{ done: number; total: number } | null>(null)
const restored = ref({ imported: 0, total: 0 })
watch(open, (v) => {
if (!v) return
step.value = props.mode
password.value = ''
needPassword.value = false
recoveryKey.value = ''
savedKey.value = false
input.value = ''
error.value = ''
progress.value = null
if (props.mode === 'restore') void restore()
})
const title = computed(() => {
switch (step.value) {
case 'setup':
case 'saveKey':
return 'Set up encryption'
case 'reset':
return 'Reset encryption'
case 'unlock':
return 'Verify this session'
case 'backup':
return 'Set up key backup'
default:
return 'Restore from key backup'
}
})
async function run(fn: () => Promise<void>) {
busy.value = true
error.value = ''
try {
await fn()
} catch (e) {
if (e instanceof PasswordNeededError) needPassword.value = true
error.value = errorMessage(e)
} finally {
busy.value = false
void security.refresh()
}
}
function setUp() {
return run(async () => {
recoveryKey.value = await setUpEncryption({ password: password.value || undefined, reset: step.value === 'reset' })
step.value = 'saveKey'
})
}
function unlock() {
return run(async () => {
await unlockWithRecoveryKey(input.value)
toasts.success('This session is verified.')
await restore()
})
}
function backup() {
return run(async () => {
if (!recoveryKeyKnown()) await unlockWithRecoveryKey(input.value)
await createKeyBackup()
toasts.success('Key backup is on.')
open.value = false
})
}
async function restore() {
step.value = 'restoring'
progress.value = null
error.value = ''
try {
const res = await restoreKeyBackup((done, total) => (progress.value = { done, total }))
restored.value = { imported: res.imported, total: res.total }
step.value = 'restored'
} catch (e) {
error.value = errorMessage(e)
step.value = 'restored'
restored.value = { imported: 0, total: 0 }
} finally {
void security.refresh()
}
}
async function withOtherSession() {
error.value = ''
try {
await security.verifyThisSession()
open.value = false
} catch (e) {
error.value = errorMessage(e)
}
}
function copyKey() {
navigator.clipboard.writeText(recoveryKey.value).then(() => toasts.success('Copied.'))
}
function downloadKey() {
const blob = new Blob([`Warp recovery key\n\n${recoveryKey.value}\n`], { type: 'text/plain' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'warp-recovery-key.txt'
a.click()
URL.revokeObjectURL(a.href)
}
const needsKey = computed(() => step.value === 'unlock' || (step.value === 'backup' && !recoveryKeyKnown()))
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy || step === 'saveKey' || step === 'restoring'">
<!-- First time, or starting over -->
<form v-if="step === 'setup' || step === 'reset'" id="enc-setup" class="flex flex-col gap-4" @submit.prevent="setUp">
<p v-if="step === 'setup'" class="body m-0">
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.
</p>
<WBanner v-else tone="warning" title="Everyone will need to verify you again">
This replaces your account’s keys and starts a new key backup. Messages only your old keys could read stay unreadable here.
</WBanner>
<WTextField
v-if="needPassword || step === 'reset'"
v-model="password"
wide
type="password"
label="Account password"
autocomplete="current-password"
hint="The homeserver asks for it before it takes new keys."
autofocus
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<!-- The new recovery key, shown once -->
<div v-else-if="step === 'saveKey'" class="flex flex-col gap-4">
<WBanner tone="success" title="Encryption is set up">This session is verified, and key backup is on.</WBanner>
<p class="body m-0">
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.
</p>
<p class="mxid m-0 rounded-md bg-surface-sunken p-3 text-center break-all select-all">{{ recoveryKey }}</p>
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="copy" @click="copyKey">Copy</WButton>
<WButton size="sm" icon="download" @click="downloadKey">Download</WButton>
</div>
<WCheckbox v-model="savedKey" label="I’ve saved my recovery key" />
</div>
<!-- Unlock with the recovery key -->
<form v-else-if="step === 'unlock' || step === 'backup'" id="enc-unlock" class="flex flex-col gap-4" @submit.prevent="step === 'unlock' ? unlock() : backup()">
<template v-if="step === 'unlock'">
<p class="body m-0">
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.
</p>
<WButton icon="shield-check" class="self-start" :disabled="busy" @click="withOtherSession">Verify with another session</WButton>
</template>
<p v-else class="body m-0">The key backup is locked with your recovery key.</p>
<WTextField
v-if="needsKey"
v-model="input"
wide
mono
type="password"
label="Recovery key"
hint="Starts with “Es”, in groups of four. A recovery passphrase works too."
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<button v-if="step === 'unlock'" type="button" class="small self-start text-ink-muted hover:text-ink hover:underline" @click="step = 'reset'">
Lost your recovery key and other sessions? Reset encryption
</button>
</form>
<!-- Restoring -->
<div v-else-if="step === 'restoring'" class="flex flex-col items-center gap-3 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">
Restoring message keys<template v-if="progress && progress.total"> · {{ progress.done }} of {{ progress.total }}</template>…
</p>
</div>
<div v-else-if="step === 'restored'" class="flex flex-col gap-3">
<WBanner v-if="error" tone="danger" title="Can’t restore from the key backup">{{ error }}</WBanner>
<p v-else class="body m-0 flex items-start gap-2">
<WIcon name="shield-check" class="mt-[2px] shrink-0 text-teal-text" />
<span v-if="restored.total">Restored {{ restored.imported }} of {{ restored.total }} message keys. Older messages decrypt as they load.</span>
<span v-else>The key backup has no message keys yet.</span>
</p>
</div>
<template #actions>
<template v-if="step === 'setup' || step === 'reset'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-setup" :variant="step === 'reset' ? 'danger' : 'primary'" :loading="busy" :disabled="step === 'reset' && !password">
{{ step === 'reset' ? 'Reset encryption' : 'Set up encryption' }}
</WButton>
</template>
<WButton v-else-if="step === 'saveKey'" variant="primary" :disabled="!savedKey" @click="open = false">Done</WButton>
<template v-else-if="step === 'unlock' || step === 'backup'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-unlock" variant="primary" icon="key" :loading="busy" :disabled="needsKey && !input.trim()">
{{ step === 'unlock' ? 'Verify session' : 'Set up key backup' }}
</WButton>
</template>
<WButton v-else-if="step === 'restored'" variant="primary" @click="open = false">Done</WButton>
</template>
</WDialog>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { WIconButton, WSpinner } from '@warp/ui'
import { downloadMedia, useMedia, type MediaSource } from '@/matrix/media'
// A full-screen view of one image, closed with Escape or a click outside.
const props = defineProps<{ source: MediaSource; name: string }>()
const emit = defineEmits<{ close: [] }>()
const { url, failed } = useMedia(() => props.source)
const closeBtn = ref<HTMLElement>()
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
document.addEventListener('keydown', onKey)
closeBtn.value?.querySelector('button')?.focus()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div
class="fixed inset-0 z-50 flex flex-col bg-[#0f1213e6]"
role="dialog"
aria-modal="true"
:aria-label="name"
@click.self="emit('close')"
>
<div class="flex items-center gap-2 p-2 text-[#eceeee]">
<p class="body-strong min-w-0 flex-1 truncate px-2">{{ name }}</p>
<WIconButton icon="download" label="Download" class="text-[#eceeee] hover:bg-[#ffffff1a]" @click="downloadMedia(source, name)" />
<span ref="closeBtn"><WIconButton icon="close" label="Close" class="text-[#eceeee] hover:bg-[#ffffff1a]" @click="emit('close')" /></span>
</div>
<div class="flex min-h-0 flex-1 items-center justify-center p-4" @click.self="emit('close')">
<img v-if="url" :src="url" :alt="name" class="max-h-full max-w-full object-contain" />
<p v-else-if="failed" class="body text-[#eceeee]">Can’t load the image.</p>
<WSpinner v-else :size="32" />
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { IPublicRoomsChunkRoom } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WSpinner, WTextField } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { useChat } from '@/stores/chat'
// Join by address or ID, or pick from this homeserver's room directory.
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ joined: [roomId: string] }>()
const chat = useChat()
const address = ref('')
const busy = ref('')
const error = ref('')
const directory = ref<IPublicRoomsChunkRoom[]>()
const directoryError = ref('')
const search = ref('')
const joined = computed(() => new Set(chat.rooms.map((r) => r.roomId)))
async function loadDirectory() {
directoryError.value = ''
try {
const res = await client().publicRooms({ limit: 50, filter: search.value.trim() ? { generic_search_term: search.value.trim() } : undefined })
directory.value = res.chunk
} catch (e) {
directoryError.value = errorMessage(e)
}
}
watch(open, (v) => {
if (!v) return
address.value = ''
error.value = ''
search.value = ''
directory.value = undefined
loadDirectory()
})
let searchTimer = 0
watch(search, () => {
window.clearTimeout(searchTimer)
searchTimer = window.setTimeout(loadDirectory, 300)
})
async function join(target: string) {
const t = target.trim()
if (!t) return
if (joined.value.has(t)) {
open.value = false
emit('joined', t)
return
}
busy.value = t
error.value = ''
try {
const roomId = await chat.join(t)
open.value = false
emit('joined', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = ''
}
}
const validAddress = computed(() => /^[#!][^:\s]+:\S+$/.test(address.value.trim()))
</script>
<template>
<WDialog v-model:open="open" title="Join room" wide :persistent="busy !== ''">
<form class="flex items-end gap-2" @submit.prevent="join(address)">
<WTextField
v-model="address"
class="flex-1"
wide
mono
label="Room address or ID"
placeholder="#homelab-ops:warp.example"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
autofocus
/>
<WButton type="submit" variant="primary" :loading="busy === address.trim() && busy !== ''" :disabled="!validAddress">Join</WButton>
</form>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h3 class="body-strong m-0 flex-1">Room directory</h3>
</div>
<WTextField v-model="search" wide label="Search the directory" type="search" />
<WBanner v-if="directoryError" tone="warning">{{ directoryError }}</WBanner>
<div v-else-if="!directory" class="flex justify-center py-6"><WSpinner /></div>
<p v-else-if="!directory.length" class="body py-4 text-center text-ink-muted">
{{ search ? 'No public rooms match.' : 'There are no public rooms on this homeserver yet.' }}
</p>
<ul v-else class="m-0 flex max-h-80 list-none flex-col overflow-y-auto p-0">
<li v-for="r in directory" :key="r.room_id" class="flex items-center gap-3 rounded-md px-2 py-2">
<MxcAvatar :name="r.name ?? r.canonical_alias ?? r.room_id" :mxc="r.avatar_url" :size="36" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ r.name ?? r.canonical_alias ?? r.room_id }}</p>
<p class="small m-0 truncate text-ink-muted">
<span v-if="r.canonical_alias" class="mxid">{{ r.canonical_alias }} · </span>{{ r.num_joined_members }}
{{ r.num_joined_members === 1 ? 'member' : 'members' }}<template v-if="r.topic"> · {{ r.topic }}</template>
</p>
</div>
<WButton size="sm" :variant="joined.has(r.room_id) ? 'ghost' : 'secondary'" :loading="busy === r.room_id" @click="join(r.room_id)">
{{ joined.has(r.room_id) ? 'Open' : 'Join' }}
</WButton>
</li>
</ul>
</div>
</WDialog>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { WButton, WIcon, WSpinner } from '@warp/ui'
import { formatBytes } from '@/lib/format'
import { linkify } from '@/lib/linkify'
import { downloadMedia, mediaUrl, useMedia, type MediaSource } from '@/matrix/media'
import { mediaSource, type MessageItem } from '@/matrix/timeline'
// What goes inside a bubble: text with links, an image, a player or a file
// card. Message text is never rendered as HTML.
const props = defineProps<{ item: MessageItem }>()
const emit = defineEmits<{ view: [source: MediaSource, name: string] }>()
const segments = computed(() => linkify(props.item.body))
const media = computed(() => props.item.media)
const isImage = computed(() => props.item.msgtype === 'm.image' || props.item.msgtype === 'm.sticker')
// Images: a thumbnail that keeps the sender's aspect ratio, so the timeline
// doesn't jump as it loads.
const box = computed(() => {
const m = media.value
const maxW = props.item.msgtype === 'm.sticker' ? 160 : 320
const maxH = 320
if (!m?.w || !m?.h) return { width: maxW, height: 200 }
const scale = Math.min(1, maxW / m.w, maxH / m.h)
return { width: Math.max(48, Math.round(m.w * scale)), height: Math.max(48, Math.round(m.h * scale)) }
})
const { url: thumb, failed: thumbFailed } = useMedia(
() => (isImage.value && media.value ? mediaSource(media.value, true) : undefined),
{ width: 640, height: 640 },
)
// Video and audio load when asked for: the whole file comes down first.
const playerUrl = ref<string>()
const loadingPlayer = ref(false)
const playerError = ref(false)
async function loadPlayer() {
const src = media.value && mediaSource(media.value)
if (!src) return
loadingPlayer.value = true
playerError.value = false
try {
playerUrl.value = await mediaUrl(src)
} catch {
playerError.value = true
} finally {
loadingPlayer.value = false
}
}
const downloading = ref(false)
async function download() {
const m = media.value
const src = m && mediaSource(m)
if (!m || !src) return
downloading.value = true
try {
await downloadMedia(src, m.filename)
} finally {
downloading.value = false
}
}
</script>
<template>
<p v-if="item.redacted" class="m-0 text-ink-muted italic">Message deleted</p>
<!-- Still ciphertext: says why it can't be read, or that it is being decrypted -->
<p v-else-if="item.encrypted && item.utd" class="m-0 flex items-start gap-2 text-ink-muted italic">
<WIcon name="shield-alert" :size="16" class="mt-[3px] shrink-0" />
<span>{{ item.utd }}</span>
</p>
<p v-else-if="item.encrypted" class="m-0 flex items-center gap-2 text-ink-muted">
<WIcon name="lock" :size="16" class="shrink-0" />
<span>Decrypting…</span>
</p>
<!-- Image or sticker -->
<button
v-else-if="isImage && media?.mxc"
type="button"
class="-mx-2 -my-1 block cursor-zoom-in overflow-hidden rounded-md bg-surface-sunken p-0"
:style="{ width: `${box.width}px`, height: `${box.height}px` }"
:aria-label="`View ${media.filename}`"
@click="emit('view', mediaSource(media)!, media.filename)"
>
<img v-if="thumb" :src="thumb" :alt="media.filename" class="h-full w-full object-cover" />
<span v-else-if="thumbFailed" class="flex h-full items-center justify-center gap-2 text-ink-muted">
<WIcon name="image" /> Can’t load the image
</span>
<span v-else class="flex h-full items-center justify-center"><WSpinner /></span>
</button>
<!-- Video and audio -->
<div v-else-if="(item.msgtype === 'm.video' || item.msgtype === 'm.audio') && media?.mxc" class="flex flex-col gap-2">
<video v-if="playerUrl && item.msgtype === 'm.video'" :src="playerUrl" controls autoplay class="max-h-80 max-w-full rounded-md" />
<audio v-else-if="playerUrl" :src="playerUrl" controls autoplay class="max-w-full" />
<div v-else class="flex items-center gap-3">
<WIcon :name="item.msgtype === 'm.video' ? 'video' : 'file'" class="text-ink-muted" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ media.filename }}</p>
<p class="caption m-0 text-ink-muted">{{ playerError ? 'Can’t load it' : formatBytes(media.size) }}</p>
</div>
<WButton size="sm" :loading="loadingPlayer" @click="loadPlayer">Play</WButton>
</div>
</div>
<!-- Any other file -->
<div v-else-if="media?.mxc" class="flex items-center gap-3">
<WIcon name="file" class="text-ink-muted" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ media.filename }}</p>
<p class="caption m-0 text-ink-muted">{{ formatBytes(media.size) }}</p>
</div>
<WButton size="sm" icon="download" :loading="downloading" @click="download">Download</WButton>
</div>
<!-- Text, notice, emote -->
<p
v-else
:class="['m-0 whitespace-pre-wrap', item.msgtype === 'm.notice' && 'text-ink-muted', item.msgtype === 'm.emote' && 'italic']"
><template v-if="item.msgtype === 'm.emote'">* {{ item.senderName }} </template><template v-for="(s, i) in segments" :key="i"><a v-if="s.kind === 'link'" :href="s.href" target="_blank" rel="noopener noreferrer nofollow">{{ s.text }}</a><template v-else>{{ s.text }}</template></template></p>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { WAvatar } from '@warp/ui'
import { useMedia } from '@/matrix/media'
// WAvatar with its picture loaded from the media repository.
const props = withDefaults(
defineProps<{
name: string
mxc?: string
size?: number
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{ mxc: undefined, size: 40, presence: undefined },
)
// Twice the drawn size for sharp pictures on high-density screens; the
// server picks the nearest thumbnail it has.
const px = Math.max(64, props.size * 2)
const { url } = useMedia(() => props.mxc, { width: px, height: px })
</script>
<template>
<WAvatar :name="name" :src="url" :size="size" :presence="presence" />
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WIcon, WIconButton, type IconName } from '@warp/ui'
import type { MenuItem } from './menu'
// A small menu behind an icon button: the items are actions, the menu
// closes on a choice, Escape or a click outside, and arrow keys move
// between items.
const props = withDefaults(defineProps<{ icon: IconName; label: string; items: MenuItem[]; align?: 'left' | 'right' }>(), {
align: 'right',
})
const open = ref(false)
const root = ref<HTMLElement>()
const list = ref<HTMLElement>()
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
document.addEventListener('mousedown', onDocClick)
await nextTick()
list.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function choose(item: MenuItem) {
open.value = false
item.action()
}
function onKey(e: KeyboardEvent) {
const buttons = Array.from(list.value?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const i = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
buttons[(i + 1) % buttons.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
buttons[(i - 1 + buttons.length) % buttons.length]?.focus()
}
}
</script>
<template>
<div ref="root" class="relative" @keydown="onKey">
<WIconButton :icon="props.icon" :label="label" :aria-expanded="open" aria-haspopup="menu" @click="open = !open" />
<div
v-if="open"
ref="list"
role="menu"
:class="[
'absolute top-full z-30 mt-1 flex min-w-52 flex-col rounded-md border border-line bg-surface-raised p-1 shadow-float',
align === 'right' ? 'right-0' : 'left-0',
]"
>
<button
v-for="item in items"
:key="item.label"
type="button"
role="menuitem"
:class="[
'flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken',
item.danger ? 'text-danger' : 'text-ink',
]"
@click="choose(item)"
>
<WIcon :name="item.icon" :class="item.danger ? 'text-danger' : 'text-ink-muted'" />
{{ item.label }}
</button>
</div>
</div>
</template>
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { EventType, KnownMembership, type Room, type RoomMember } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WDialog, WIcon, WIconButton, WTextArea, WTextField } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import SessionsDialog from '@/components/SessionsDialog.vue'
import { errorMessage, isUserId } from '@/lib/format'
import { client } from '@/matrix/client'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The side pane of a room: what it is, who is in it, and leaving it. On a
// phone it covers the room.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const emit = defineEmits<{ close: [] }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const state = computed(() => {
void chat.version
return props.room.currentState
})
const name = computed(() => {
void chat.version
return props.room.name
})
const topic = computed(() => String(state.value.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? ''))
const alias = computed(() => {
void chat.version
return props.room.getCanonicalAlias()
})
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
})
const canEditName = computed(() => state.value.maySendStateEvent(EventType.RoomName, me))
const canEditTopic = computed(() => state.value.maySendStateEvent(EventType.RoomTopic, me))
const canInvite = computed(() => {
void chat.version
return props.room.canInvite(me)
})
function roleOf(m: RoomMember): string | undefined {
if (m.powerLevel >= 100) return 'Admin'
if (m.powerLevel >= 50) return 'Moderator'
return undefined
}
const members = computed(() => {
void chat.version
const sort = (a: RoomMember, b: RoomMember) => b.powerLevel - a.powerLevel || a.name.localeCompare(b.name)
return {
joined: props.room.getMembersWithMembership(KnownMembership.Join).sort(sort),
invited: props.room.getMembersWithMembership(KnownMembership.Invite).sort(sort),
}
})
// The full member list isn't in the first sync (lazy loading); fetch it
// when the pane opens.
props.room.loadMembersIfNeeded().then(() => chat.bump()).catch(() => undefined)
// Editing name and topic.
const editing = ref(false)
const draftName = ref('')
const draftTopic = ref('')
const saving = ref(false)
const saveError = ref('')
function startEdit() {
draftName.value = name.value
draftTopic.value = topic.value
saveError.value = ''
editing.value = true
}
async function save() {
saving.value = true
saveError.value = ''
try {
const c = client()
if (canEditName.value && draftName.value.trim() !== name.value) await c.setRoomName(props.room.roomId, draftName.value.trim())
if (canEditTopic.value && draftTopic.value.trim() !== topic.value) await c.setRoomTopic(props.room.roomId, draftTopic.value.trim())
editing.value = false
} catch (e) {
saveError.value = errorMessage(e)
} finally {
saving.value = false
}
}
// Inviting.
const inviteOpen = ref(false)
const inviteId = ref('')
const inviting = ref(false)
const inviteError = ref('')
watch(inviteOpen, (v) => {
if (v) {
inviteId.value = ''
inviteError.value = ''
}
})
async function invite() {
inviting.value = true
inviteError.value = ''
try {
await client().invite(props.room.roomId, inviteId.value.trim())
toasts.success(`Invited ${inviteId.value.trim()}.`)
inviteOpen.value = false
} catch (e) {
inviteError.value = errorMessage(e)
} finally {
inviting.value = false
}
}
// Leaving.
const leaveOpen = ref(false)
const leaving = ref(false)
const leaveError = ref('')
async function leave() {
leaving.value = true
leaveError.value = ''
try {
await chat.leave(props.room.roomId)
leaveOpen.value = false
router.replace('/')
} catch (e) {
leaveError.value = errorMessage(e)
} finally {
leaving.value = false
}
}
// Someone's sessions, to verify them.
const sessionsOf = ref<RoomMember | null>(null)
function copy(text: string) {
navigator.clipboard.writeText(text).then(() => toasts.success('Copied.'))
}
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-80 md:shrink-0 md:border-l md:border-line"
aria-label="Room info"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">{{ isDirect ? 'Chat info' : 'Room info' }}</h2>
<WIconButton icon="close" label="Close room info" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto">
<section class="flex flex-col items-center gap-2 px-4 pt-6 pb-4 text-center">
<MxcAvatar :name="name" :mxc="room.getMxcAvatarUrl() ?? undefined" :size="72" />
<h3 class="heading m-0 break-words">{{ name }}</h3>
<button v-if="alias" type="button" class="mxid text-ink-muted hover:text-ink" title="Copy address" @click="copy(alias)">
{{ alias }}
</button>
<p v-if="topic" class="body m-0 whitespace-pre-wrap text-ink-muted">{{ topic }}</p>
<p class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="14" />
{{ encrypted ? 'End-to-end encrypted' : 'Not encrypted' }}
</p>
<WButton v-if="canEditName || canEditTopic" size="sm" icon="edit" class="mt-2" @click="startEdit">Edit</WButton>
</section>
<section class="border-t border-line px-2 py-3">
<div class="flex items-center gap-2 px-2 pb-2">
<h3 class="body-strong m-0 flex-1">Members · {{ members.joined.length }}</h3>
<WButton v-if="canInvite" size="sm" icon="plus" @click="inviteOpen = true">Invite</WButton>
</div>
<ul class="m-0 list-none p-0">
<li v-for="m in members.joined" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}<span v-if="m.userId === me" class="font-normal text-ink-muted"> (you)</span></p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
<WBadge v-if="roleOf(m)" :tone="roleOf(m) === 'Admin' ? 'success' : 'neutral'">{{ roleOf(m) }}</WBadge>
<WIconButton
v-if="encrypted && chat.canEncrypt"
icon="shield-check"
:label="m.userId === me ? 'Your sessions' : `Verify ${m.name}’s sessions`"
@click="sessionsOf = m"
/>
</li>
</ul>
<template v-if="members.invited.length">
<h3 class="caption m-0 px-2 pt-3 pb-1 text-ink-muted">Invited</h3>
<ul class="m-0 list-none p-0">
<li v-for="m in members.invited" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5 opacity-80">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}</p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
</li>
</ul>
</template>
</section>
<section class="flex flex-col gap-2 border-t border-line p-4">
<button type="button" class="caption flex items-center gap-1 text-left text-ink-muted hover:text-ink" title="Copy room ID" @click="copy(room.roomId)">
<WIcon name="copy" :size="14" /> <span class="mxid break-all">{{ room.roomId }}</span>
</button>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">{{ isDirect ? 'Leave chat' : 'Leave room' }}</WButton>
</section>
</div>
<WDialog v-model:open="editing" title="Edit room" :persistent="saving">
<form id="edit-room" class="flex flex-col gap-4" @submit.prevent="save">
<WTextField v-if="canEditName" v-model="draftName" wide label="Name" maxlength="255" />
<WTextArea v-if="canEditTopic" v-model="draftTopic" label="Topic" rows="3" />
<WBanner v-if="saveError" tone="danger">{{ saveError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="saving" @click="editing = false">Cancel</WButton>
<WButton type="submit" form="edit-room" variant="primary" :loading="saving">Save</WButton>
</template>
</WDialog>
<WDialog v-model:open="inviteOpen" title="Invite to room" :persistent="inviting">
<form id="invite-form" class="flex flex-col gap-4" @submit.prevent="invite">
<WTextField
v-model="inviteId"
wide
mono
label="Matrix ID"
placeholder="@maja:warp.example"
autocomplete="off"
spellcheck="false"
autofocus
/>
<WBanner v-if="inviteError" tone="danger">{{ inviteError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="inviting" @click="inviteOpen = false">Cancel</WButton>
<WButton type="submit" form="invite-form" variant="primary" :loading="inviting" :disabled="!isUserId(inviteId)">Invite</WButton>
</template>
</WDialog>
<SessionsDialog
:open="sessionsOf !== null"
:user-id="sessionsOf?.userId ?? ''"
:name="sessionsOf?.name ?? ''"
@update:open="(v) => !v && (sessionsOf = null)"
/>
<ConfirmDialog
v-model:open="leaveOpen"
:title="isDirect ? 'Leave chat' : 'Leave room'"
:confirm-label="isDirect ? 'Leave chat' : 'Leave room'"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
You stop getting messages from <span class="body-strong">{{ name }}</span>.
<template v-if="room.getJoinRule() !== 'public'"> To come back, someone has to invite you again.</template>
</p>
</ConfirmDialog>
</aside>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { WRoomItem } from '@warp/ui'
import { useMedia } from '@/matrix/media'
// WRoomItem with the room's avatar loaded from the media repository.
const props = defineProps<{
name: string
avatarMxc?: string
preview?: string
time?: string
unread?: number
mention?: boolean
encrypted?: boolean
active?: boolean
presence?: 'online' | 'away' | 'busy' | 'offline'
}>()
const { url } = useMedia(() => props.avatarMxc, { width: 80, height: 80 })
</script>
<template>
<WRoomItem
:name="name"
:avatar="url"
:preview="preview"
:time="time"
:unread="unread"
:mention="mention"
:encrypted="encrypted"
:active="active"
:presence="presence"
/>
</template>
+259
View File
@@ -0,0 +1,259 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { WButton, WIcon, WMessageBubble, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageViewer from '@/components/ImageViewer.vue'
import MessageBody from '@/components/MessageBody.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import type { MenuItem } from '@/components/menu'
import { errorMessage, formatDateTime, formatDay, formatTime } from '@/lib/format'
import { client } from '@/matrix/client'
import type { MediaSource } from '@/matrix/media'
import { buildTimeline, canPaginateBack, lastSyncedEvent, type MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The scrolling list of a room's messages. The scroller is laid out bottom
// up (column-reverse), so it starts at the newest message, stays there as
// messages arrive, and older history loading above doesn't move what you
// are reading.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const chat = useChat()
const toasts = useToasts()
const me = client().getUserId() ?? ''
const scroller = ref<HTMLElement>()
const atBottom = ref(true)
const paginating = ref(false)
const paginateError = ref(false)
const viewing = ref<{ source: MediaSource; name: string } | null>(null)
const deleting = ref<MessageItem | null>(null)
const deleteBusy = ref(false)
const deleteError = ref('')
const items = computed(() => {
void chat.version
return buildTimeline(client(), props.room)
})
const hasMore = computed(() => {
void chat.version
return canPaginateBack(props.room)
})
// Distance from the oldest loaded message, given column-reverse's
// negative scrollTop.
function distanceFromTop(el: HTMLElement) {
return el.scrollHeight - el.clientHeight + el.scrollTop
}
async function paginate() {
if (paginating.value || !hasMore.value) return
paginating.value = true
paginateError.value = false
try {
await client().scrollback(props.room, 30)
} catch {
paginateError.value = true
} finally {
paginating.value = false
}
chat.bump()
// Keep going until the view is full or history runs out.
requestAnimationFrame(() => {
const el = scroller.value
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room)) paginate()
})
}
function onScroll() {
const el = scroller.value
if (!el) return
atBottom.value = el.scrollTop > -80
if (distanceFromTop(el) < 400) paginate()
if (atBottom.value) markRead()
}
function jumpToLatest() {
scroller.value?.scrollTo({ top: 0, behavior: 'smooth' })
}
// Read receipts: sent when you are looking at the newest message, with the
// tab in front.
let lastMarked = ''
function markRead() {
if (!atBottom.value || document.visibilityState !== 'visible' || !document.hasFocus()) return
const ev = lastSyncedEvent(props.room)
const id = ev?.getId()
if (!ev || !id || id === lastMarked) return
if (ev.getSender() === me && props.room.getUnreadNotificationCount() === 0) {
lastMarked = id
return
}
lastMarked = id
client()
.setRoomReadMarkers(props.room.roomId, id, ev)
.catch(() => (lastMarked = ''))
}
watch(items, () => requestAnimationFrame(markRead))
const onFocus = () => markRead()
onMounted(() => {
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
requestAnimationFrame(() => {
markRead()
const el = scroller.value
if (el && distanceFromTop(el) < 400) paginate()
})
})
onBeforeUnmount(() => {
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
})
// Message actions.
async function retry(item: MessageItem) {
try {
await client().resendEvent(item.event, props.room)
} catch (e) {
toasts.error(errorMessage(e))
}
}
function discard(item: MessageItem) {
client().cancelPendingEvent(item.event)
chat.bump()
}
const myPower = computed(() => {
void chat.version
return props.room.getMember(me)?.powerLevel ?? 0
})
const redactLevel = computed(() => {
void chat.version
const pl = props.room.currentState.getStateEvents('m.room.power_levels', '')?.getContent() ?? {}
return typeof pl.redact === 'number' ? pl.redact : 50
})
function actions(item: MessageItem): MenuItem[] {
const out: MenuItem[] = []
if (item.body && !item.media && !item.encrypted) {
out.push({
label: 'Copy text',
icon: 'copy',
action: () => navigator.clipboard.writeText(item.body).then(() => toasts.success('Copied.')),
})
}
if (item.eventId && (item.own || myPower.value >= redactLevel.value)) {
out.push({ label: 'Delete message', icon: 'trash', danger: true, action: () => askDelete(item) })
}
return out
}
function askDelete(item: MessageItem) {
deleteError.value = ''
deleting.value = item
}
async function confirmDelete() {
const item = deleting.value
if (!item?.eventId) return
deleteBusy.value = true
deleteError.value = ''
try {
await client().redactEvent(props.room.roomId, item.eventId)
deleting.value = null
} catch (e) {
deleteError.value = errorMessage(e)
} finally {
deleteBusy.value = false
}
}
</script>
<template>
<div class="relative min-h-0 flex-1">
<div ref="scroller" class="flex h-full flex-col-reverse overflow-y-auto" @scroll.passive="onScroll">
<div class="mx-auto flex w-full max-w-4xl flex-col px-4 pt-4 pb-2" role="log" aria-live="polite" aria-relevant="additions">
<div class="flex min-h-10 items-center justify-center py-2">
<WSpinner v-if="paginating" :size="18" label="Loading older messages" />
<WButton v-else-if="paginateError" size="sm" icon="refresh" @click="paginate">Load older messages</WButton>
<p v-else-if="!hasMore" class="caption text-ink-muted">This is the beginning of the room.</p>
</div>
<template v-for="item in items" :key="item.key">
<div v-if="item.kind === 'day'" class="my-3 flex items-center gap-3" role="separator">
<span class="h-px flex-1 bg-line" />
<span class="caption text-ink-muted">{{ formatDay(item.ts) }}</span>
<span class="h-px flex-1 bg-line" />
</div>
<p v-else-if="item.kind === 'notice'" class="caption my-1 text-center text-ink-muted">{{ item.text }}</p>
<div
v-else
:class="['group flex items-end gap-2', item.own ? 'flex-row-reverse' : '', item.first ? 'mt-4' : 'mt-2']"
>
<template v-if="!item.own && !isDirect">
<MxcAvatar v-if="item.last" :name="item.senderName" :mxc="item.avatarMxc" :size="32" class="mb-5" />
<span v-else class="w-8 shrink-0" />
</template>
<WMessageBubble
class="max-w-[min(520px,80%)] min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed'" #meta>
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
</template>
</WMessageBubble>
<PopMenu
v-if="actions(item).length && item.status !== 'sending' && item.status !== 'failed'"
class="-my-2 self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
icon="more"
label="Message actions"
:align="item.own ? 'right' : 'left'"
:items="actions(item)"
/>
</div>
</template>
</div>
</div>
<button
v-if="!atBottom"
type="button"
class="absolute right-4 bottom-4 flex h-10 items-center gap-2 rounded-full border border-line-strong bg-surface-raised px-4 font-semibold text-ink shadow-float hover:bg-surface-sunken"
@click="jumpToLatest"
>
<WIcon name="arrow-down" :size="18" />
Latest
</button>
<ImageViewer v-if="viewing" :source="viewing.source" :name="viewing.name" @close="viewing = null" />
<ConfirmDialog
:open="deleting !== null"
title="Delete message"
confirm-label="Delete message"
:busy="deleteBusy"
:error="deleteError"
@update:open="(v) => !v && (deleting = null)"
@confirm="confirmDelete"
>
<p>The message is removed for everyone in the room. This can’t be undone.</p>
</ConfirmDialog>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBadge, WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { sessionsOf, type SessionInfo } from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
// Someone's sessions, as their published keys list them, and whether this
// session has verified each. Verifying one compares emoji with it.
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ userId: string; name: string }>()
const security = useSecurity()
const sessions = ref<SessionInfo[] | null>(null)
const error = ref('')
const starting = ref('')
const mine = computed(() => props.userId === client().getUserId())
async function load() {
error.value = ''
try {
sessions.value = await sessionsOf(props.userId)
} catch (e) {
error.value = errorMessage(e)
}
}
watch(open, (v) => {
if (!v) return
sessions.value = null
void load()
})
// Trust changes (a verification just finished) show at once.
watch(
() => security.version,
() => open.value && void load(),
)
async function verify(s: SessionInfo) {
starting.value = s.deviceId
error.value = ''
try {
await security.verifySession(props.userId, s.deviceId)
open.value = false
} catch (e) {
error.value = errorMessage(e)
} finally {
starting.value = ''
}
}
</script>
<template>
<WDialog v-model:open="open" :title="mine ? 'Your sessions' : `${name}’s sessions`">
<p v-if="mine" class="small m-0 text-ink-muted">
Each place you’re signed in has its own keys. Verify your other sessions so they trust each other and share message keys.
</p>
<p v-else class="small m-0 text-ink-muted">
Each place <span class="mxid">{{ userId }}</span> 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.
</p>
<div v-if="!sessions && !error" class="flex justify-center py-4"><WSpinner /></div>
<p v-else-if="sessions && !sessions.length" class="body m-0 text-ink-muted">
No sessions with encryption keys. They can’t read end-to-end encrypted messages until they sign in with a client that supports it.
</p>
<ul v-else-if="sessions" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="s in sessions" :key="s.deviceId" class="flex items-center gap-3 rounded-md px-2 py-2">
<WIcon :name="s.verified ? 'shield-check' : 'shield-alert'" :class="s.verified ? 'text-teal-text' : 'text-ink-muted'" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ s.name || 'Unnamed session' }}<span v-if="s.own" class="font-normal text-ink-muted"> (this session)</span></p>
<p class="mxid m-0 truncate text-ink-muted">{{ s.deviceId }}</p>
</div>
<WBadge v-if="s.verified" tone="success">Verified</WBadge>
<WButton v-else-if="!s.own" size="sm" :loading="starting === s.deviceId" :disabled="!!starting" @click="verify(s)">Verify session</WButton>
<WBadge v-else tone="neutral">Not verified</WBadge>
</li>
</ul>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<template #actions>
<WButton variant="ghost" @click="open = false">Close</WButton>
</template>
</WDialog>
</template>
+195
View File
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WDialog, WIcon, WTextField, useTheme, type IconName, type ThemeChoice } from '@warp/ui'
import EncryptionDialog, { type EncryptionMode } from '@/components/EncryptionDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import SessionsDialog from '@/components/SessionsDialog.vue'
import { config } from '@/config'
import { errorMessage, localpart } from '@/lib/format'
import { cryptoError } from '@/matrix/client'
import { useProfile } from '@/stores/profile'
import { useSecurity } from '@/stores/security'
import { useSession } from '@/stores/session'
import { useToasts } from '@/stores/toasts'
const open = defineModel<boolean>('open', { default: false })
const session = useSession()
const profile = useProfile()
const toasts = useToasts()
const router = useRouter()
const theme = useTheme()
const security = useSecurity()
const name = ref('')
const savingName = ref(false)
const avatarBusy = ref(false)
const error = ref('')
const picker = ref<HTMLInputElement>()
const signingOut = ref(false)
watch(open, (v) => {
if (v) {
name.value = profile.displayName
error.value = ''
void security.refresh()
}
})
// Encryption.
const ownName = computed(() => profile.displayName || localpart(session.userId))
const enc = computed(() => security.status)
const encMode = ref<EncryptionMode>('setup')
const encOpen = ref(false)
const sessionsOpen = ref(false)
function openEnc(mode: EncryptionMode) {
encMode.value = mode
encOpen.value = true
}
async function saveName() {
savingName.value = true
error.value = ''
try {
await profile.setDisplayName(name.value)
toasts.success('Display name saved.')
} catch (e) {
error.value = errorMessage(e)
} finally {
savingName.value = false
}
}
async function onAvatar() {
const file = picker.value?.files?.[0]
if (picker.value) picker.value.value = ''
if (!file) return
if (!file.type.startsWith('image/')) {
error.value = 'Choose an image for your avatar.'
return
}
avatarBusy.value = true
error.value = ''
try {
await profile.setAvatar(file)
} catch (e) {
error.value = errorMessage(e)
} finally {
avatarBusy.value = false
}
}
async function removeAvatar() {
avatarBusy.value = true
try {
await profile.removeAvatar()
} catch (e) {
error.value = errorMessage(e)
} finally {
avatarBusy.value = false
}
}
async function signOut() {
signingOut.value = true
await session.signOut()
signingOut.value = false
open.value = false
router.replace({ name: 'login' })
}
const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
{ value: 'light', label: 'Light', icon: 'sun' },
{ value: 'dark', label: 'Dark', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'settings' },
]
</script>
<template>
<WDialog v-model:open="open" title="Settings">
<section class="flex items-center gap-4">
<MxcAvatar :name="profile.displayName || localpart(session.userId)" :mxc="profile.avatarMxc" :size="64" />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="image" :loading="avatarBusy" @click="picker?.click()">Change avatar</WButton>
<WButton v-if="profile.avatarMxc" size="sm" variant="ghost" :disabled="avatarBusy" @click="removeAvatar">Remove</WButton>
</div>
<p class="mxid m-0 text-ink-muted">{{ session.userId }}</p>
</div>
<input ref="picker" type="file" accept="image/*" class="wp-sr" tabindex="-1" aria-hidden="true" @change="onAvatar" />
</section>
<form class="flex items-end gap-2" @submit.prevent="saveName">
<WTextField v-model="name" class="flex-1" wide label="Display name" maxlength="256" />
<WButton type="submit" :loading="savingName" :disabled="name.trim() === profile.displayName">Save</WButton>
</form>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<section class="flex flex-col gap-2">
<p class="body-strong m-0">Theme</p>
<div class="wp-segmented self-start" role="group" aria-label="Theme">
<button v-for="t in themes" :key="t.value" type="button" :aria-pressed="theme === t.value" @click="theme = t.value">
<WIcon :name="t.icon" :size="16" />
{{ t.label }}
</button>
</div>
</section>
<section class="flex flex-col gap-3 border-t border-line pt-4">
<p class="body-strong m-0">Encryption</p>
<WBanner v-if="cryptoError()" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send end-to-end encrypted messages. Reload the page to try again. ({{ cryptoError() }})
</WBanner>
<p v-else-if="!enc" class="small m-0 text-ink-muted">Checking…</p>
<template v-else>
<!-- This session -->
<div class="flex items-start gap-3">
<WIcon :name="enc.sessionVerified ? 'shield-check' : 'shield-alert'" :class="['mt-[2px] shrink-0', enc.sessionVerified ? 'text-teal-text' : 'text-warning']" />
<div class="min-w-0 flex-1">
<p class="body m-0">{{ enc.sessionVerified ? 'This session is verified' : 'This session isn’t verified' }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="!enc.crossSigning">Set up encryption to verify your sessions and back up your message keys.</template>
<template v-else-if="!enc.sessionVerified">Verify it to read your older encrypted messages here, and so others can trust it.</template>
<template v-else>Your other sessions and the people who verified you trust it.</template>
</p>
</div>
<WButton v-if="!enc.crossSigning" size="sm" variant="primary" icon="key" @click="openEnc('setup')">Set up encryption</WButton>
<WButton v-else-if="!enc.sessionVerified" size="sm" variant="primary" icon="shield-check" @click="openEnc('unlock')">Verify session</WButton>
</div>
<!-- Key backup -->
<div v-if="enc.crossSigning" class="flex items-start gap-3">
<WIcon :name="enc.backupActive ? 'shield-check' : 'shield-alert'" :class="['mt-[2px] shrink-0', enc.backupActive ? 'text-teal-text' : 'text-ink-muted']" />
<div class="min-w-0 flex-1">
<p class="body m-0">{{ enc.backupActive ? 'Key backup is on' : enc.backupVersion ? 'Key backup isn’t used here' : 'No key backup' }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="enc.backupActive">Your message keys are kept on the homeserver, locked with your recovery key.</template>
<template v-else-if="enc.backupVersion">Verify this session to use your key backup.</template>
<template v-else>Without one, a new session can’t read messages sent before it signed in.</template>
</p>
</div>
<WButton v-if="enc.backupKey && enc.backupVersion" size="sm" icon="history" @click="openEnc('restore')">Restore</WButton>
<WButton v-else-if="!enc.backupVersion && enc.sessionVerified" size="sm" icon="key" @click="openEnc('backup')">Set up key backup</WButton>
</div>
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="shield-check" @click="sessionsOpen = true">Your sessions</WButton>
<WButton v-if="enc.crossSigning" size="sm" variant="ghost" @click="openEnc('reset')">Reset encryption</WButton>
</div>
</template>
</section>
<section class="flex flex-col gap-1 border-t border-line pt-4">
<p class="body-strong m-0">This session</p>
<p class="small m-0 text-ink-muted">Session ID <span class="mxid text-ink">{{ session.deviceId }}</span></p>
<p class="small m-0 text-ink-muted">Warp web {{ config.version }}</p>
</section>
<EncryptionDialog v-model:open="encOpen" :mode="encMode" />
<SessionsDialog v-model:open="sessionsOpen" :user-id="session.userId" :name="ownName" />
<template #actions>
<WButton variant="danger" icon="logout" :loading="signingOut" @click="signOut">Sign out</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WTextField } from '@warp/ui'
import { errorMessage, isUserId, toUserId } from '@/lib/format'
import { useChat } from '@/stores/chat'
import { useSession } from '@/stores/session'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ started: [roomId: string] }>()
const chat = useChat()
const session = useSession()
const who = ref('')
const busy = ref(false)
const error = ref('')
watch(open, (v) => {
if (v) {
who.value = ''
error.value = ''
}
})
async function submit() {
const userId = toUserId(who.value, session.serverName)
if (!isUserId(userId)) return
if (userId === session.userId) {
error.value = 'That’s your own Matrix ID.'
return
}
busy.value = true
error.value = ''
try {
const roomId = await chat.startDirect(userId)
open.value = false
emit('started', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<WDialog v-model:open="open" title="Start chat" :persistent="busy">
<form id="start-chat" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField
v-model="who"
wide
mono
label="Who"
:hint="`A username on ${session.serverName}, or a full Matrix ID like @maja:warp.example`"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="start-chat" variant="primary" :loading="busy" :disabled="!who.trim()">Start chat</WButton>
</template>
</WDialog>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { WIcon, WIconButton } from '@warp/ui'
import { useToasts } from '@/stores/toasts'
const toasts = useToasts()
const icon = { success: 'shield-check', danger: 'shield-alert', info: 'server' } as const
const tone = {
success: 'bg-teal-soft text-teal-text',
danger: 'bg-danger-soft text-danger',
info: 'bg-info-soft text-info',
} as const
</script>
<template>
<div
class="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex flex-col items-end gap-2 sm:left-auto sm:w-96"
aria-live="polite"
>
<div
v-for="t in toasts.items"
:key="t.id"
:class="[
'pointer-events-auto flex w-full items-center gap-3 rounded-md border border-line py-2 pr-1 pl-4 shadow-float',
tone[t.tone],
]"
:role="t.tone === 'danger' ? 'alert' : 'status'"
>
<WIcon :name="icon[t.tone]" />
<p class="flex-1 text-ink">{{ t.text }}</p>
<WIconButton icon="close" label="Dismiss" @click="toasts.dismiss(t.id)" />
</div>
</div>
</template>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import {
VerificationPhase,
VerifierEvent,
type ShowSasCallbacks,
type Verifier,
} from 'matrix-js-sdk/lib/crypto-api'
import { computed, ref, shallowRef, watch } from 'vue'
import { WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { useSecurity } from '@/stores/security'
// One verification, from the request to the emoji to "Verified": either
// this session asked (Verify session) or someone else did. Both sides see
// the same seven emoji when nobody is in the middle.
const security = useSecurity()
const req = computed(() => {
void security.version
return security.request
})
const phase = computed(() => {
void security.version
return security.request?.phase
})
const open = computed({
get: () => req.value !== null,
set: (v) => {
if (!v) security.dismiss()
},
})
const sas = shallowRef<ShowSasCallbacks | null>(null)
const confirmed = ref(false)
const busy = ref(false)
const error = ref('')
let verifier: Verifier | null = null
function reset() {
sas.value = null
confirmed.value = false
busy.value = false
error.value = ''
verifier = null
}
watch(req, reset)
const otherName = computed(() => {
const r = req.value
if (!r) return ''
if (r.isSelfVerification) return 'your other session'
const c = client()
for (const room of c.getRooms()) {
const m = room.getMember(r.otherUserId)
if (m) return m.name
}
return r.otherUserId
})
// Runs the verifier once there is one: the initiator starts emoji
// verification as soon as the other side is ready.
watch(
[req, phase],
async () => {
const r = req.value
if (!r) return
if (r.phase === VerificationPhase.Ready && r.initiatedByMe && !r.verifier && !busy.value) {
busy.value = true
try {
await r.startVerification('m.sas.v1')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
const v = r.verifier
if (v && v !== verifier) {
verifier = v
v.on(VerifierEvent.ShowSas, (cb) => (sas.value = cb))
if (v.getShowSasCallbacks()) sas.value = v.getShowSasCallbacks()
v.verify().catch(() => undefined)
}
},
{ immediate: true },
)
async function accept() {
busy.value = true
error.value = ''
try {
await req.value?.accept()
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
async function match() {
busy.value = true
error.value = ''
try {
confirmed.value = true
await sas.value?.confirm()
} catch (e) {
confirmed.value = false
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
function mismatch() {
sas.value?.mismatch()
}
const cancelText = computed(() => {
const r = req.value
if (!r) return ''
switch (r.cancellationCode) {
case 'm.mismatch_sas':
case 'm.key_mismatch':
case 'm.mismatched_sas':
return 'The emoji didn’t match, so the session isn’t verified. Someone may be listening in: check with them another way before you trust it.'
case 'm.timeout':
return 'The verification timed out. Start it again when you’re both ready.'
case 'm.accepted':
return 'Another of your sessions answered the request.'
default:
return r.cancellingUserId === client().getUserId() ? 'You cancelled the verification.' : `${otherName.value} cancelled the verification.`
}
})
const title = computed(() => (req.value?.isSelfVerification ? 'Verify this session' : 'Verify session'))
</script>
<template>
<WDialog v-model:open="open" :title="title">
<template v-if="req">
<!-- Waiting for the other side -->
<div v-if="phase === VerificationPhase.Requested && req.initiatedByMe" class="flex flex-col items-center gap-4 py-2 text-center">
<WSpinner :size="28" />
<p v-if="req.isSelfVerification" class="body m-0">
Open Warp or another Matrix client where you’re already signed in, and accept the request there.
</p>
<p v-else class="body m-0">Waiting for <span class="body-strong">{{ otherName }}</span> to accept.</p>
</div>
<!-- Somebody asks -->
<div v-else-if="phase === VerificationPhase.Requested" class="flex flex-col gap-3">
<p class="body m-0 flex items-start gap-2">
<WIcon name="shield-alert" class="mt-[2px] shrink-0 text-ink-muted" />
<span v-if="req.isSelfVerification">
Another of your sessions wants to verify this one<template v-if="req.otherDeviceId">
(<span class="mxid">{{ req.otherDeviceId }}</span>)</template>.
</span>
<span v-else>
<span class="body-strong">{{ otherName }}</span><template v-if="otherName !== req.otherUserId">
(<span class="mxid">{{ req.otherUserId }}</span>)</template> wants to verify your session.
</span>
</p>
<p class="small m-0 text-ink-muted">You’ll both compare seven emoji. Accept only if you expected this.</p>
</div>
<!-- Ready, about to compare -->
<div v-else-if="(phase === VerificationPhase.Ready || phase === VerificationPhase.Started) && !sas" class="flex flex-col items-center gap-4 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">Getting the emoji ready…</p>
</div>
<!-- Compare the emoji -->
<div v-else-if="phase === VerificationPhase.Started && sas" class="flex flex-col gap-4">
<p class="body m-0">
Check that <span class="body-strong">{{ otherName }}</span> sees the same emoji, in the same order.
</p>
<ol class="m-0 grid list-none grid-cols-4 gap-3 p-0 sm:grid-cols-7">
<li v-for="([emoji, name], i) in sas.sas.emoji ?? []" :key="i" class="flex flex-col items-center gap-1 text-center">
<span class="text-[32px] leading-none" aria-hidden="true">{{ emoji }}</span>
<span class="caption text-ink-muted">{{ name }}</span>
</li>
</ol>
<p v-if="!sas.sas.emoji && sas.sas.decimal" class="mxid m-0 text-center text-[24px]">{{ sas.sas.decimal.join(' ') }}</p>
<p v-if="confirmed" class="small m-0 flex items-center gap-2 text-ink-muted">
<WSpinner :size="14" /> Waiting for {{ otherName }} to confirm…
</p>
</div>
<!-- Done -->
<div v-else-if="phase === VerificationPhase.Done" class="flex flex-col items-center gap-3 py-2 text-center">
<WIcon name="shield-check" :size="40" class="text-teal-text" />
<p class="body-strong m-0">Verified</p>
<p class="body m-0 text-ink-muted">
<template v-if="req.isSelfVerification">This session is verified. Your other sessions trust it, and it can read your encrypted messages.</template>
<template v-else>
You verified {{ otherName }}’s session<template v-if="req.otherDeviceId"> <span class="mxid">{{ req.otherDeviceId }}</span></template>.
</template>
</p>
</div>
<!-- Cancelled -->
<WBanner v-else-if="phase === VerificationPhase.Cancelled" tone="warning" title="Not verified">{{ cancelText }}</WBanner>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</template>
<template #actions>
<template v-if="req && phase === VerificationPhase.Requested && !req.initiatedByMe">
<WButton variant="ghost" :disabled="busy" @click="open = false">Decline</WButton>
<WButton variant="primary" :loading="busy" @click="accept">Accept</WButton>
</template>
<template v-else-if="phase === VerificationPhase.Started && sas && !confirmed">
<WButton variant="danger" :disabled="busy" @click="mismatch">They don’t match</WButton>
<WButton variant="primary" icon="check" :loading="busy" @click="match">They match</WButton>
</template>
<WButton v-else-if="phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled" variant="primary" @click="open = false">
Done
</WButton>
<WButton v-else variant="ghost" @click="open = false">Cancel</WButton>
</template>
</WDialog>
</template>
+8
View File
@@ -0,0 +1,8 @@
import type { IconName } from '@warp/ui'
export interface MenuItem {
label: string
icon: IconName
danger?: boolean
action: () => void
}
+25
View File
@@ -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.
}
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
public?: boolean
}
}
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { WLogo } from '@warp/ui'
import { config } from '@/config'
const host = new URL(config.homeserverUrl).host
</script>
<template>
<div class="flex min-h-dvh items-center justify-center bg-surface-sunken px-4 py-12">
<div class="w-full max-w-[400px]">
<div class="mb-8 flex justify-center text-ink">
<WLogo :height="40" />
</div>
<slot />
<p class="small mt-6 flex items-center justify-center gap-1 text-ink-muted">
Homeserver <span class="mxid">{{ host }}</span>
</p>
</div>
</div>
</template>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WIcon, WIconButton, WLogo, WSpinner } from '@warp/ui'
import CreateRoomDialog from '@/components/CreateRoomDialog.vue'
import EncryptionDialog from '@/components/EncryptionDialog.vue'
import JoinRoomDialog from '@/components/JoinRoomDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import RoomRow from '@/components/RoomRow.vue'
import type { MenuItem } from '@/components/menu'
import SettingsDialog from '@/components/SettingsDialog.vue'
import StartChatDialog from '@/components/StartChatDialog.vue'
import VerificationDialog from '@/components/VerificationDialog.vue'
import { formatListTime, localpart } from '@/lib/format'
import { useChat } from '@/stores/chat'
import { useProfile } from '@/stores/profile'
import { useSecurity } from '@/stores/security'
import { useSession } from '@/stores/session'
const session = useSession()
const chat = useChat()
const profile = useProfile()
const security = useSecurity()
const route = useRoute()
const router = useRouter()
const startError = ref('')
const filter = ref('')
const dialog = ref<'create' | 'chat' | 'join' | 'settings' | 'verify' | null>(null)
// The account has encryption keys but this session isn't signed by them:
// it can't read older messages and others see it as unverified.
const unverified = computed(() => Boolean(security.status?.crossSigning && !security.status.sessionVerified))
async function start() {
startError.value = ''
try {
await session.restore()
if (session.phase === 'ready') {
chat.attach()
security.attach()
profile.load()
}
} catch (e) {
startError.value = e instanceof Error ? e.message : 'Can’t start the client.'
}
}
onMounted(start)
const activeRoomId = computed(() => (route.name === 'room' ? String(route.params.roomId) : ''))
const shownRooms = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return chat.rooms
return chat.rooms.filter((r) => r.name.toLowerCase().includes(q) || r.roomId.toLowerCase().includes(q))
})
const shownInvites = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return chat.invites
return chat.invites.filter((r) => r.name.toLowerCase().includes(q))
})
const menu: MenuItem[] = [
{ label: 'New room', icon: 'plus', action: () => (dialog.value = 'create') },
{ label: 'Start chat', icon: 'user', action: () => (dialog.value = 'chat') },
{ label: 'Join room', icon: 'room', action: () => (dialog.value = 'join') },
]
function open(roomId: string) {
router.push({ name: 'room', params: { roomId } })
}
// The tab title counts rooms with unread messages, and invites.
watchEffect(() => {
const n = chat.totalUnread
const room = chat.rooms.find((r) => r.roomId === activeRoomId.value)
const name = room?.name ?? chat.invites.find((r) => r.roomId === activeRoomId.value)?.name
document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · ` : ''}Warp`
})
const ownName = computed(() => profile.displayName || localpart(session.userId))
</script>
<template>
<!-- Starting: restoring the session and the first sync -->
<div v-if="startError" class="flex min-h-dvh items-center justify-center bg-surface-sunken px-4">
<div class="flex w-full max-w-[420px] flex-col gap-4">
<WBanner tone="danger" title="Can’t connect to the homeserver">{{ startError }}</WBanner>
<div class="flex gap-2">
<WButton variant="primary" icon="refresh" @click="start">Try again</WButton>
<WButton variant="ghost" icon="logout" @click="session.signOut()">Sign out</WButton>
</div>
</div>
</div>
<div v-else-if="session.phase !== 'ready' || !chat.ready" class="flex min-h-dvh flex-col items-center justify-center gap-6 bg-surface-sunken">
<WLogo variant="symbol" :height="56" />
<div class="flex items-center gap-3 text-ink-muted">
<WSpinner :size="18" />
<span class="body">Loading your rooms</span>
</div>
</div>
<div v-else class="flex h-dvh overflow-hidden bg-surface">
<!-- Room list. On a phone it is the home screen, hidden while a room is open. -->
<aside
:class="[
'flex w-full shrink-0 flex-col border-r border-line bg-surface-sunken md:w-80',
activeRoomId ? 'hidden md:flex' : 'flex',
]"
aria-label="Rooms"
>
<div class="flex h-16 shrink-0 items-center gap-1 pr-2 pl-4">
<RouterLink to="/" class="mr-auto text-ink" aria-label="Warp, home">
<WLogo :height="28" />
</RouterLink>
<PopMenu icon="plus" label="New" :items="menu" />
</div>
<div class="px-3 pb-2">
<label class="flex h-10 items-center gap-2 rounded-sm border border-line-strong bg-surface-raised px-3 focus-within:border-teal-strong focus-within:shadow-focus">
<WIcon name="search" :size="18" class="text-ink-muted" />
<span class="wp-sr">Filter rooms</span>
<input
v-model="filter"
type="search"
placeholder="Filter rooms"
class="min-w-0 flex-1 border-0 bg-transparent text-ink outline-none placeholder:text-ink-muted"
/>
</label>
</div>
<WBanner v-if="chat.offline" tone="warning" class="mx-3 mb-2" title="Offline">
Can’t reach the homeserver. Messages will send when the connection is back.
</WBanner>
<WBanner v-if="unverified" tone="warning" class="mx-3 mb-2" title="Verify this session">
Use another session or your recovery key to read your encrypted messages here.
<button type="button" class="font-semibold text-teal-text hover:underline" @click="dialog = 'verify'">Verify session</button>
</WBanner>
<nav class="flex-1 overflow-y-auto px-2 pb-3">
<template v-if="shownInvites.length">
<h2 class="caption px-3 pt-2 pb-1 text-ink-muted">Invites</h2>
<RoomRow
v-for="inv in shownInvites"
:key="inv.roomId"
:name="inv.name"
:avatar-mxc="inv.avatarMxc"
:preview="inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited'"
:time="formatListTime(inv.ts)"
:unread="1"
mention
:active="inv.roomId === activeRoomId"
@click="open(inv.roomId)"
/>
<h2 class="caption px-3 pt-3 pb-1 text-ink-muted">Rooms</h2>
</template>
<RoomRow
v-for="r in shownRooms"
:key="r.roomId"
:name="r.name"
:avatar-mxc="r.avatarMxc"
:preview="r.preview"
:time="formatListTime(r.ts)"
:unread="r.unread"
:mention="r.highlight > 0"
:encrypted="r.encrypted"
:presence="r.presence"
:active="r.roomId === activeRoomId"
@click="open(r.roomId)"
/>
<div v-if="!shownRooms.length && !shownInvites.length" class="flex flex-col items-center gap-3 px-4 py-10 text-center">
<template v-if="filter">
<p class="body text-ink-muted">No rooms match “{{ filter }}”.</p>
</template>
<template v-else>
<p class="body-strong">No rooms yet</p>
<p class="body text-ink-muted">Create a room, start a chat with someone, or join a room by its address.</p>
<WButton variant="primary" icon="plus" @click="dialog = 'create'">Create room</WButton>
</template>
</div>
</nav>
<div class="flex shrink-0 items-center gap-3 border-t border-line px-4 py-3">
<MxcAvatar :name="ownName" :mxc="profile.avatarMxc" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong truncate">{{ ownName }}</p>
<p class="mxid truncate text-ink-muted">{{ session.userId }}</p>
</div>
<WIconButton icon="settings" label="Settings" @click="dialog = 'settings'" />
</div>
</aside>
<main :class="['min-w-0 flex-1 flex-col', activeRoomId ? 'flex' : 'hidden md:flex']">
<RouterView :key="activeRoomId" />
</main>
<CreateRoomDialog :open="dialog === 'create'" @update:open="dialog = $event ? 'create' : null" @created="open" />
<StartChatDialog :open="dialog === 'chat'" @update:open="dialog = $event ? 'chat' : null" @started="open" />
<JoinRoomDialog :open="dialog === 'join'" @update:open="dialog = $event ? 'join' : null" @joined="open" />
<SettingsDialog :open="dialog === 'settings'" @update:open="dialog = $event ? 'settings' : null" />
<EncryptionDialog mode="unlock" :open="dialog === 'verify'" @update:open="dialog = $event ? 'verify' : null" />
<VerificationDialog />
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
// Unsent composer text per room. It survives switching rooms, not reloads.
export const drafts = new Map<string, string>()
+93
View File
@@ -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())
}
+29
View File
@@ -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
}
+15
View File
@@ -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')
})
+66
View File
@@ -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<ArrayBuffer> {
// 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<EncryptedFile, 'url'> }> {
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<ArrayBuffer> {
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'
}
+154
View File
@@ -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<MatrixClient> {
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<boolean> {
const timeout = new Promise<false>((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)
}
+254
View File
@@ -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<ArrayBuffer> } | 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<EncryptionStatus> {
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<string> {
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<Uint8Array<ArrayBuffer>> {
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<void> {
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<KeyBackupRestoreResult> {
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<VerificationRequest> {
return crypto().requestOwnUserVerification()
}
export function verifyDevice(userId: string, deviceId: string): Promise<VerificationRequest> {
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<SessionInfo[]> {
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<void> {
await crypto().bootstrapSecretStorage({ setupNewKeyBackup: true })
}
+114
View File
@@ -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 <img src> 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<string, Promise<string>>()
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<Blob> {
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<Blob> {
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<string> {
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<string | undefined>; failed: Ref<boolean> } {
const url = ref<string>()
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 }
}
+78
View File
@@ -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<string, unknown> = { 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)
}
+347
View File
@@ -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<string>()
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<RoomMessageEventContent>()
// 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
}
+36
View File
@@ -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`
})
+373
View File
@@ -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<string, string> {
// m.direct maps a user to their DM rooms; flip it to room → user.
const out = new Map<string, string>()
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
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<string, string>): 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<SyncState | null>(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<RoomSummary[]>(() => {
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<InviteSummary[]>(() => {
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<string> {
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<string> {
const c = client()
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
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<string> {
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<string, string[]>
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,
}
})
+40
View File
@@ -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<string>()
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 }
})
+124
View File
@@ -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<EncryptionStatus | null>(null)
/** The verification the dialog shows. */
const request = shallowRef<VerificationRequest | null>(null)
/** Bumps when the request or anybody's trust changes. */
const version = ref(0)
let attached: MatrixClient | null = null
let refreshing: Promise<void> | 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 }
})
+240
View File
@@ -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<void> {
// 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<RegistrationInfo> {
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,
}
})
+34
View File
@@ -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<Toast[]>([])
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,
}
})
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@warp/ui/styles.css";
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { WLogo } from '@warp/ui'
import { useChat } from '@/stores/chat'
const chat = useChat()
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
<WLogo variant="symbol" :height="64" />
<h1 class="heading m-0">Pick a room</h1>
<p class="body max-w-sm text-ink-muted">
<template v-if="chat.invites.length">
You have {{ chat.invites.length === 1 ? 'an invite' : `${chat.invites.length} invites` }} waiting in the room list.
</template>
<template v-else>Choose a room from the list, or use + to create one, start a chat or join a room.</template>
</p>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WCheckbox, WTextField } from '@warp/ui'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { errorMessage } from '@/lib/format'
import { useSession } from '@/stores/session'
const session = useSession()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const remember = ref(true)
const busy = ref(false)
const error = ref('')
async function submit() {
if (!username.value || !password.value) return
busy.value = true
error.value = ''
try {
await session.signIn(username.value, password.value, remember.value)
password.value = ''
const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') ? route.query.next : '/'
router.replace(next)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<AuthLayout>
<form class="wp-card flex flex-col gap-4 p-6 shadow-raised" @submit.prevent="submit">
<div>
<h1 class="heading m-0">Sign in</h1>
<p class="body mt-1 text-ink-muted">With your account on this homeserver.</p>
</div>
<WBanner v-if="session.endedReason && !error" tone="info">{{ session.endedReason }}</WBanner>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<WTextField
v-model="username"
wide
mono
label="Username"
hint="Your username, like maja, or your full Matrix ID"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WTextField v-model="password" wide label="Password" type="password" autocomplete="current-password" required />
<WCheckbox v-model="remember" label="Stay signed in on this browser" />
<WButton type="submit" variant="primary" :loading="busy" :disabled="!username || !password">Sign in</WButton>
</form>
<p class="body mt-4 text-center text-ink-muted">
New here?
<RouterLink to="/register" class="font-semibold">Create an account</RouterLink>
</p>
</AuthLayout>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WCheckbox, WSpinner, WTextField } from '@warp/ui'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { errorMessage } from '@/lib/format'
import { useSession, type RegistrationInfo } from '@/stores/session'
const session = useSession()
const router = useRouter()
const info = ref<RegistrationInfo>()
const loadError = ref('')
const username = ref('')
const password = ref('')
const confirm = ref('')
const token = ref('')
const remember = ref(true)
const busy = ref(false)
const error = ref('')
onMounted(async () => {
try {
info.value = await session.registrationInfo()
} catch (e) {
loadError.value = errorMessage(e)
}
})
const usernameError = computed(() =>
username.value && !/^[a-z0-9._=\-/]+$/.test(username.value)
? 'Use lowercase letters, digits and . _ = - / only'
: undefined,
)
const confirmError = computed(() =>
confirm.value && confirm.value !== password.value ? 'The passwords don’t match' : undefined,
)
const ready = computed(
() =>
username.value &&
password.value.length >= 8 &&
confirm.value === password.value &&
!usernameError.value &&
(!info.value?.tokenRequired || token.value.trim()),
)
async function submit() {
if (!ready.value) return
busy.value = true
error.value = ''
try {
await session.register(username.value, password.value, token.value.trim(), remember.value)
router.replace('/')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<AuthLayout>
<div v-if="!info && !loadError" class="flex justify-center py-12">
<WSpinner :size="28" label="Checking registration" />
</div>
<div v-else-if="loadError || (info && !info.enabled)" class="wp-card flex flex-col gap-4 p-6 shadow-raised">
<h1 class="heading m-0">Create an account</h1>
<WBanner tone="warning" :title="loadError ? 'Can’t check registration' : 'Registration is off'">
{{ loadError || info?.reason || 'This homeserver isn’t taking new accounts. Ask its admin for one.' }}
</WBanner>
<RouterLink to="/login" class="font-semibold">Back to sign in</RouterLink>
</div>
<template v-else>
<form class="wp-card flex flex-col gap-4 p-6 shadow-raised" @submit.prevent="submit">
<div>
<h1 class="heading m-0">Create an account</h1>
<p class="body mt-1 text-ink-muted">
Your username becomes your Matrix ID, <span class="mxid">@{{ username || 'name' }}</span> on this homeserver. It
can’t be changed later.
</p>
</div>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<WTextField
v-model="username"
wide
mono
label="Username"
:error="usernameError"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WTextField
v-model="password"
wide
label="Password"
type="password"
hint="At least 8 characters"
autocomplete="new-password"
required
/>
<WTextField
v-model="confirm"
wide
label="Confirm password"
type="password"
:error="confirmError"
autocomplete="new-password"
required
/>
<WTextField
v-if="info?.tokenAccepted"
v-model="token"
wide
mono
:label="info.tokenRequired ? 'Registration token' : 'Registration token (optional)'"
hint="From the homeserver’s admin"
autocomplete="off"
spellcheck="false"
:required="info.tokenRequired"
/>
<WCheckbox v-model="remember" label="Stay signed in on this browser" />
<WButton type="submit" variant="primary" :loading="busy" :disabled="!ready">Create account</WButton>
</form>
<p class="body mt-4 text-center text-ink-muted">
Have an account?
<RouterLink to="/login" class="font-semibold">Sign in</RouterLink>
</p>
</template>
</AuthLayout>
</template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { KnownMembership } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import RoomInfoPane from '@/components/RoomInfoPane.vue'
import RoomTimeline from '@/components/RoomTimeline.vue'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { sendFile, sendText } from '@/matrix/send'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
const props = defineProps<{ roomId: string }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const room = computed(() => {
void chat.version
return client().getRoom(props.roomId)
})
const membership = computed(() => {
void chat.version
return room.value?.getMyMembership()
})
const summary = computed(() => chat.rooms.find((r) => r.roomId === props.roomId))
const invite = computed(() => chat.invites.find((r) => r.roomId === props.roomId))
const topic = computed(() => {
void chat.version
return String(room.value?.currentState.getStateEvents('m.room.topic', '')?.getContent().topic ?? '')
})
const encrypted = computed(() => summary.value?.encrypted ?? false)
// An encrypted room can't be written to without the crypto stack.
const cryptoMissing = computed(() => encrypted.value && !chat.canEncrypt)
const memberCount = computed(() => {
void chat.version
return room.value?.getJoinedMemberCount() ?? 0
})
const canSend = computed(() => {
void chat.version
const r = room.value
return r ? r.currentState.maySendMessage(me) : false
})
const infoOpen = ref(false)
const text = ref(drafts.get(props.roomId) ?? '')
watch(text, (v) => (v ? drafts.set(props.roomId, v) : drafts.delete(props.roomId)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
const typingNames = computed(() => {
void chat.version
return (room.value?.getMembers() ?? []).filter((m) => m.typing && m.userId !== me).map((m) => m.name)
})
const typingText = computed(() => {
const n = typingNames.value
if (!n.length) return ''
if (n.length === 1) return `${n[0]} is typing…`
if (n.length === 2) return `${n[0]} and ${n[1]} are typing…`
return `${n.length} people are typing…`
})
function send(body: string) {
setTyping(false)
sendText(client(), props.roomId, body).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
const r = room.value
if (!r) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
for (const file of Array.from(files)) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(client(), r, file, (p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
})
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
const dragging = ref(false)
function onDrop(e: DragEvent) {
dragging.value = false
const files = e.dataTransfer?.files
if (files && files.length && canSend.value && !cryptoMissing.value) sendFiles(files)
}
// Invites and rooms you're not in.
const joining = ref(false)
const joinError = ref('')
async function accept() {
joining.value = true
joinError.value = ''
try {
await chat.acceptInvite(props.roomId)
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
async function decline() {
joining.value = true
try {
await chat.leave(props.roomId)
router.replace('/')
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
async function joinHere() {
joining.value = true
joinError.value = ''
try {
await chat.join(props.roomId)
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'}`)
</script>
<template>
<!-- Invited -->
<div v-if="membership === KnownMembership.Invite && invite" class="flex flex-1 items-center justify-center px-4">
<div class="wp-card flex w-full max-w-[420px] flex-col items-center gap-4 p-6 text-center shadow-raised">
<MxcAvatar :name="invite.name" :mxc="invite.avatarMxc" :size="64" />
<div>
<h1 class="heading m-0">{{ invite.name }}</h1>
<p class="body mt-1 text-ink-muted">
<template v-if="invite.inviter">
<span class="body-strong text-ink">{{ invite.inviterName }}</span>
<span v-if="invite.inviterName !== invite.inviter" class="mxid"> ({{ invite.inviter }})</span>
invited you {{ invite.isDirect ? 'to chat' : 'to this room' }}.
</template>
<template v-else>You’re invited to this room.</template>
</p>
</div>
<WBanner v-if="joinError" tone="danger" class="w-full text-left">{{ joinError }}</WBanner>
<div class="flex gap-2">
<WButton :disabled="joining" @click="decline">Decline</WButton>
<WButton variant="primary" :loading="joining" @click="accept">Accept</WButton>
</div>
</div>
</div>
<!-- Not in the room -->
<div v-else-if="!room || membership !== KnownMembership.Join" class="flex flex-1 items-center justify-center px-4">
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 text-center">
<WIcon name="room" :size="40" class="text-ink-muted" />
<h1 class="heading m-0">You’re not in this room</h1>
<p class="mxid text-ink-muted">{{ roomId }}</p>
<WBanner v-if="joinError" tone="danger" class="w-full text-left">{{ joinError }}</WBanner>
<div class="flex gap-2">
<WButton variant="ghost" @click="router.replace('/')">Back</WButton>
<WButton variant="primary" :loading="joining" @click="joinHere">Join room</WButton>
</div>
</div>
</div>
<div v-else class="flex min-h-0 flex-1">
<div
class="relative flex min-w-0 flex-1 flex-col"
@dragover.prevent="dragging = canSend && !cryptoMissing"
@dragleave.self="dragging = false"
@drop.prevent="onDrop"
>
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-2 md:px-4">
<WIconButton class="md:hidden" icon="chevron-left" label="Back to rooms" @click="router.push('/')" />
<MxcAvatar :name="summary?.name ?? room.name" :mxc="summary?.avatarMxc" :size="36" :presence="summary?.presence" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="infoOpen = !infoOpen">
<span class="flex items-center gap-2">
<h1 class="heading m-0 truncate">{{ summary?.name ?? room.name }}</h1>
<WIcon v-if="encrypted" name="lock" :size="16" class="shrink-0 text-teal-text" label="End-to-end encrypted" />
</span>
<span class="small block truncate text-ink-muted">
<template v-if="topic">{{ topic }}</template>
<template v-else>{{ memberCount }} {{ memberCount === 1 ? 'member' : 'members' }}</template>
</span>
</button>
<span v-if="!encrypted" class="caption hidden items-center gap-1 text-ink-muted sm:flex">
<WIcon name="shield-alert" :size="14" /> Not encrypted
</span>
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
</header>
<RoomTimeline :room="room" :is-direct="summary?.isDirect ?? false" />
<div class="shrink-0 bg-surface-sunken px-4 pt-2 pb-4">
<div class="mx-auto flex max-w-4xl flex-col gap-2">
<p class="caption m-0 h-4 text-ink-muted" aria-live="polite">{{ typingText }}</p>
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else @paste="onPaste">
<WComposer v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
</div>
</div>
</div>
<div
v-if="dragging"
class="pointer-events-none absolute inset-2 z-20 flex items-center justify-center rounded-lg border-2 border-dashed border-teal-strong bg-surface/80"
>
<p class="heading flex items-center gap-2 text-teal-text"><WIcon name="attach" /> Drop to send</p>
</div>
</div>
<RoomInfoPane v-if="infoOpen" :room="room" :is-direct="summary?.isDirect ?? false" @close="infoOpen = false" />
</div>
</template>
+18
View File
@@ -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"]
}
+31
View File
@@ -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 },
},
},
})
+20 -1
View File
@@ -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]
+18
View File
@@ -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:
+33 -7
View File
@@ -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
+1 -1
View File
@@ -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)
+2
View File
@@ -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"
}
}
+147
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
packages:
- apps/ui
- apps/admin
- apps/web
onlyBuiltDependencies:
- esbuild
+15 -3
View File
@@ -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)
}
}
+39 -1
View File
@@ -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")
+16
View File
@@ -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)
+1 -1
View File
@@ -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)
+53 -9
View File
@@ -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},
+11 -5
View File
@@ -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
}
+13 -7
View File
@@ -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 {
+50 -14
View File
@@ -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})