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
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:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
@@ -2,6 +2,7 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
!apps/admin/internal/panel/build
|
||||
!apps/web/internal/client/build
|
||||
bin
|
||||
coverage.*
|
||||
.complement
|
||||
|
||||
@@ -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"
|
||||
@@ -35,3 +35,7 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Local planning and design material
|
||||
/PLAN/
|
||||
/DESIGN/
|
||||
@@ -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 .
|
||||
|
||||
@@ -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?
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Delivery state of your own message, as the bubble shows it. */
|
||||
export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 })
|
||||
|
||||
@@ -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 |
@@ -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'
|
||||
@@ -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); }
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/dariuszjaniak/warp/apps/web
|
||||
|
||||
go 1.26.4
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IconName } from '@warp/ui'
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
icon: IconName
|
||||
danger?: boolean
|
||||
action: () => void
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
public?: boolean
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
// Unsent composer text per room. It survives switching rooms, not reloads.
|
||||
export const drafts = new Map<string, string>()
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@warp/ui/styles.css";
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+147
@@ -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,6 +1,7 @@
|
||||
packages:
|
||||
- apps/ui
|
||||
- apps/admin
|
||||
- apps/web
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user