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
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user