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

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

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

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

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

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

No files matched your search

+4 -3
View File
@@ -2,10 +2,10 @@
| Directory | What | State |
|---|---|---|
| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin; the Matrix store comes with web |
| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin and web |
| [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API |
| `web/` | web client (Vue SPA) + Go host | next |
| `desktop/` | Wails v3 shell reusing the web frontend | once web has a timeline |
| [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, sending text and files, invites; E2EE next |
| `desktop/` | Wails v3 shell reusing the web frontend | next: web has a timeline |
| `mobile/` | Flutter app, Android first | after web |
The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the
@@ -14,4 +14,5 @@ repository root); the Go hosts join `go.work`.
```bash
pnpm install
pnpm admin:dev
pnpm web:dev
```
+7 -2
View File
@@ -46,6 +46,9 @@ The stylesheet brings in:
| `WBanner` | feedback; always icon and words, never colour alone |
| `WDialog` | modal on the native `<dialog>` |
| `WSpinner` | progress |
| `WRoomItem` | a row of the room list: avatar, name, preview, time, unread counter |
| `WMessageBubble` | a message with sender, time and delivery status (`sending`, `sent`, `read`, `failed`) |
| `WComposer` | the message field: attach, Enter sends, Shift+Enter adds a line |
## Where things come from
@@ -58,5 +61,7 @@ apps on the same grid: 24px, 1.75 stroke, round caps and joins.
The fonts are Outfit, Figtree and JetBrains Mono under the SIL Open Font
License.
The Matrix store (Pinia over matrix-js-sdk) joins this package with the web
client.
The three chat components are the brand package's `RoomItem`,
`MessageBubble` and `Composer`. The Matrix state itself is not here: it
lives with the web client (`apps/web/src/stores`, `apps/web/src/matrix`),
which the desktop app will reuse as a whole.
+2
View File
@@ -0,0 +1,2 @@
/** Delivery state of your own message, as the bubble shows it. */
export type MessageStatus = 'sending' | 'sent' | 'read' | 'failed'
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { nextTick, ref, useId, watch } from 'vue'
import WIcon from './WIcon.vue'
import WIconButton from './WIconButton.vue'
// The message input at the bottom of a room. Enter sends, Shift+Enter adds
// a line; the field grows with its text up to a few lines. Send stays
// disabled until there is something to send.
const props = withDefaults(
defineProps<{
/** Specific to the room: "Message #homelab" or "Message Maja". */
placeholder?: string
encrypted?: boolean
disabled?: boolean
/** Shows the attach button; the app handles the file. */
attach?: boolean
}>(),
{ placeholder: 'Message', encrypted: false, disabled: false, attach: true },
)
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{ send: [text: string]; attach: [files: FileList] }>()
const id = useId()
const field = ref<HTMLTextAreaElement>()
const picker = ref<HTMLInputElement>()
function grow() {
const el = field.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 176)}px`
}
watch(model, () => nextTick(grow))
function send() {
const text = model.value
if (!text.trim() || props.disabled) return
emit('send', text)
model.value = ''
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
send()
}
}
function onPick() {
const files = picker.value?.files
if (files && files.length) emit('attach', files)
if (picker.value) picker.value.value = ''
}
defineExpose({ focus: () => field.value?.focus() })
</script>
<template>
<form class="wp-composer items-end" @submit.prevent="send">
<template v-if="attach">
<WIconButton icon="attach" label="Attach a file" :disabled="disabled" @click="picker?.click()" />
<input ref="picker" type="file" class="wp-sr" tabindex="-1" aria-hidden="true" multiple @change="onPick" />
</template>
<label :for="id" class="wp-sr">Message</label>
<textarea
:id="id"
ref="field"
v-model="model"
class="wp-composer-input resize-none self-center"
rows="1"
:placeholder="placeholder"
:disabled="disabled"
autocomplete="off"
@keydown="onKey"
/>
<WIcon v-if="encrypted" name="lock" :size="16" class="wp-composer-lock mb-3" label="End-to-end encrypted" />
<button type="submit" class="wp-composer-send" aria-label="Send" :disabled="disabled || !model.trim()">
<WIcon name="send" :size="20" />
</button>
</form>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
import type { MessageStatus } from '../chat'
// A chat message: bubble, sender name on the first of a group, time and,
// for your own messages, the delivery status in an icon and a word.
const props = withDefaults(
defineProps<{
own?: boolean
/** Others' messages, first in a group. */
sender?: string
time?: string
edited?: boolean
status?: MessageStatus
}>(),
{ own: false, sender: undefined, time: '', edited: false, status: undefined },
)
const statuses: Record<MessageStatus, [IconName, string]> = {
sending: ['more', 'Sending'],
sent: ['check', 'Sent'],
read: ['read', 'Read'],
failed: ['shield-alert', 'Not sent'],
}
const shown = computed(() => (props.status ? statuses[props.status] : undefined))
</script>
<template>
<div :class="['wp-msg', own ? 'wp-msg-own' : 'wp-msg-other', status === 'failed' && 'wp-msg-failed']">
<p v-if="!own && sender" class="wp-msg-sender">{{ sender }}</p>
<div class="wp-msg-bubble"><slot /></div>
<p v-if="time || shown || $slots.meta" class="wp-msg-meta">
{{ time }}<template v-if="edited"> · edited</template>
<span v-if="shown" class="wp-msg-status">
<WIcon :name="shown[0]" :size="14" />
{{ shown[1] }}
</span>
<slot name="meta" />
</p>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import WAvatar from './WAvatar.vue'
import WBadge from './WBadge.vue'
import WIcon from './WIcon.vue'
// One row of the room list: avatar, name, last message, time and unread
// counter. Lives on surface-sunken.
withDefaults(
defineProps<{
name: string
preview?: string
/** Already formatted: "14:32" today, weekday this week, "12 Oct" older. */
time?: string
avatar?: string
/** Unread count; 0 or absent shows none. */
unread?: number
/** The counter turns brand when the person was mentioned. */
mention?: boolean
encrypted?: boolean
active?: boolean
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{
preview: '',
time: '',
avatar: undefined,
unread: 0,
mention: false,
encrypted: false,
active: false,
presence: undefined,
},
)
</script>
<template>
<button
type="button"
:class="['wp-room', active && 'wp-room-active', unread > 0 && 'wp-room-unread']"
:aria-current="active ? 'true' : undefined"
>
<WAvatar :name="name" :src="avatar" :size="40" :presence="presence" />
<span class="wp-room-main">
<span class="wp-room-top">
<span class="wp-room-name">{{ name }}</span>
<WIcon v-if="encrypted" name="lock" :size="13" class="wp-room-lock" label="End-to-end encrypted" />
<span class="wp-room-time">{{ time }}</span>
</span>
<span class="wp-room-bottom">
<span class="wp-room-preview">{{ preview }}</span>
<WBadge v-if="unread > 0" :tone="mention ? 'brand' : 'neutral'" :count="unread" />
</span>
</span>
</button>
</template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>arrow-down</title><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 238 B

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

After

Width:  |  Height:  |  Size: 268 B

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

After

Width:  |  Height:  |  Size: 309 B

+4
View File
@@ -2,6 +2,7 @@
// on the same grid (24px, 1.75 stroke, round caps, currentColor).
export type IconName =
| 'arrow-down'
| 'attach'
| 'bell'
| 'check'
@@ -9,7 +10,9 @@ export type IconName =
| 'chevron-right'
| 'close'
| 'copy'
| 'download'
| 'edit'
| 'file'
| 'globe'
| 'history'
| 'home'
@@ -38,6 +41,7 @@ export type IconName =
| 'sun'
| 'trash'
| 'user'
| 'users'
| 'video'
const files = import.meta.glob<string>('./*.svg', { query: '?raw', import: 'default', eager: true })
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>users</title><circle cx="9" cy="8.5" r="3.5"/><path d="M3 19.5c1.2-3.4 3.4-5.2 6-5.2s4.8 1.8 6 5.2"/><path d="M15 5.2a3.5 3.5 0 0 1 0 6.6"/><path d="M17.5 14.6c1.6.7 2.8 2.3 3.5 4.9"/></svg>

After

Width:  |  Height:  |  Size: 359 B

+4
View File
@@ -2,11 +2,14 @@ export { default as WAvatar } from './components/WAvatar.vue'
export { default as WBadge } from './components/WBadge.vue'
export { default as WBanner } from './components/WBanner.vue'
export { default as WButton } from './components/WButton.vue'
export { default as WComposer } from './components/WComposer.vue'
export { default as WCheckbox } from './components/WCheckbox.vue'
export { default as WDialog } from './components/WDialog.vue'
export { default as WIcon } from './components/WIcon.vue'
export { default as WIconButton } from './components/WIconButton.vue'
export { default as WLogo } from './components/WLogo.vue'
export { default as WMessageBubble } from './components/WMessageBubble.vue'
export { default as WRoomItem } from './components/WRoomItem.vue'
export { default as WSelect } from './components/WSelect.vue'
export { default as WSpinner } from './components/WSpinner.vue'
export { default as WSwitch } from './components/WSwitch.vue'
@@ -15,3 +18,4 @@ export { default as WTextField } from './components/WTextField.vue'
export { iconNames, type IconName } from './icons'
export { useTheme, type ThemeChoice } from './theme'
export type { MessageStatus } from './chat'
+4
View File
@@ -62,6 +62,10 @@
.wp-segmented button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal-text); }
.wp-segmented button:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
/* The composer field sits inside a bordered box that shows the focus;
a second ring around the field itself would be noise. */
.wp-composer-input:focus-visible { box-shadow: none; }
/* Danger text button */
.wp-btn-danger-solid { background: var(--danger); color: var(--surface); border-color: var(--danger); }
.wp-btn-danger-solid:hover { filter: brightness(1.06); }
+52
View File
@@ -0,0 +1,52 @@
# warp-web — the optional web client, as its own container.
#
# Build from the repository root (the client uses the shared apps/ui package):
#
# docker build -t warp-web -f apps/web/Dockerfile .
# docker run -p 8080:8080 -e WARP_WEB_HOMESERVER=http://warp:8008 warp-web
FROM node:22-alpine AS web
WORKDIR /src
RUN corepack enable
# Lockfile and manifests first, so the package store survives source changes.
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/ui/package.json ./apps/ui/
COPY apps/web/package.json ./apps/web/
RUN pnpm install --frozen-lockfile --filter @warp/web...
COPY apps/ui ./apps/ui
COPY apps/web ./apps/web
RUN pnpm --filter @warp/web build
FROM golang:1.26-alpine AS build
WORKDIR /src/apps/web
COPY apps/web/go.mod ./
COPY apps/web/cmd ./cmd
COPY apps/web/internal ./internal
COPY --from=web /src/apps/web/internal/client/build/dist ./internal/client/build/dist
ARG VERSION=0.0.0-dev
ENV CGO_ENABLED=0 GOWORK=off
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/warp-web ./cmd/warp-web
FROM alpine:3.21
RUN apk add --no-cache ca-certificates curl && adduser -D -u 10003 warp-web
COPY --from=build /out/warp-web /usr/local/bin/warp-web
USER warp-web
ENV WARP_WEB_LISTEN=":8080" \
WARP_WEB_HOMESERVER="http://warp:8008" \
WARP_WEB_LOG_FORMAT="json"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["warp-web"]
+178
View File
@@ -0,0 +1,178 @@
# Warp web client
A Matrix client for the browser: the room list, the timeline, sending
messages and files, invites, creating and joining rooms. It speaks the
plain Client-Server API through [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk),
so it works against any Matrix homeserver, and it is built on Warp's design
system, [`@warp/ui`](../ui/README.md).
The client is **optional and separate from the homeserver**, like the
[admin panel](../admin/README.md). It ships as its own small binary,
`warp-web`, with the built Vue app embedded. A Warp server runs the same
whether or not you install it.
```
browser ──► warp-web :8080 ──► warp :8008
serves the client /_matrix/client/*
/config.json /_matrix/media/*
```
By default warp-web proxies the two path prefixes a client uses and
answers `404` for everything else (federation, server keys, `/_warp/admin`),
so it is not a second way into the rest of the homeserver. The browser
only talks to warp-web: no CORS, and the homeserver URL is runtime
configuration rather than baked into the build.
If the homeserver is already public behind its own proxy, set
`-public-homeserver` instead. The browser then calls it directly,
warp-web proxies nothing, and the Content-Security-Policy allows exactly
that one origin.
## What it does
- Sign in with a password, and sign up when the homeserver has
registration on (with a registration token when it asks for one).
"Stay signed in" keeps the session in `localStorage`; without it the
session goes with the tab. Signing out logs the session out on the
homeserver.
- Room list with unread counts and mentions, invites, a filter, direct
messages with presence.
- Timeline: grouped messages, day separators, room events as one-line
notices, older history on scroll, images, video, audio and files.
Media is fetched with the access token (authenticated media, Matrix 1.11)
and shown from `blob:` URLs.
- Sending: text (`/me` for emotes), files by the attach button, paste or
drag and drop. Your message shows at once as *Sending*, then *Sent*,
then *Read* when someone else has read it; a failed one says *Not sent*
with Retry and Delete.
- Read receipts, typing notifications, deleting messages.
- End-to-end encryption, with the Rust crypto stack matrix-js-sdk loads
(`matrix-sdk-crypto-wasm`): reading and sending in encrypted rooms,
files included (encrypted in the browser before they are uploaded).
A message this session can't decrypt says why, such as "sent before
this session signed in", not just "encrypted".
- Verification: verify someone's session from the room's member list,
or this session from one of your others, by comparing emoji (SAS).
Requests from others open a dialog to accept or decline.
- Encryption setup in Settings: cross-signing keys, secret storage and a
key backup in one step, behind a recovery key shown once. A new session
verifies itself with the recovery key (or another session) and restores
older messages from the backup. Reset encryption when the key is lost.
- New private rooms and direct messages are end-to-end encrypted by
default (a direct message only when the other person has a session with
keys); Create room has the switch.
- Create a room (private, or public with an address), start a direct chat,
join by address or from the room directory, accept or decline invites,
leave.
- Room info: topic, members and their roles, invite, edit name and topic.
- Settings: display name, avatar, theme, encryption.
- Phone layout: one pane at a time, the room list is the home screen.
**Not yet:** per-message trust shields (a message from an unverified
session looks like any other), verifying a person's identity in a direct
message rather than one session at a time, QR-code verification, a warning
when someone's identity changes, encrypted thumbnails for images you send
(the receiving side shows the whole image), and a passphrase for the
crypto store. After that: replies, reactions, threads, spaces,
notifications.
### Encryption keys in the browser
Each session keeps its keys in IndexedDB under its own name
(`warp-web-crypto:<user>:<session>`), unencrypted at rest like any web
client's without a passphrase. Signing out deletes them; a session that
ends some other way (signed out elsewhere, the token revoked) has them
deleted when the page next starts. A session kept only for the tab
("Stay signed in" off) leaves its store behind when the tab closes;
the session can't be reused, but the keys stay until site data is
cleared. The `.wasm` file is most of the download (about 2 MB
compressed) and needs `script-src 'wasm-unsafe-eval'`, which warp-web's
Content-Security-Policy allows; it allows no JavaScript `eval`.
## Run it
### With Docker Compose
```bash
cd deploy/compose
docker compose --profile web up -d
```
The client is on <http://localhost:8080>. Leave out `--profile web` and it
isn't installed.
### As a container
```bash
docker build -t warp-web -f apps/web/Dockerfile .
docker run -p 8080:8080 -e WARP_WEB_HOMESERVER=http://warp:8008 warp-web
```
### As a binary
```bash
make web
./bin/warp-web -homeserver http://localhost:8008
```
| Flag | Environment | Default |
|---|---|---|
| `-listen` | `WARP_WEB_LISTEN` | `:8080` |
| `-homeserver` | `WARP_WEB_HOMESERVER` | `http://localhost:8008` |
| `-public-homeserver` | `WARP_WEB_PUBLIC_HOMESERVER` | empty: proxy through warp-web |
| `-log-format` | `WARP_WEB_LOG_FORMAT` | `text` (or `json`) |
`/healthz` answers `ok` for container health checks. `/config.json` is what
the client reads on start: `{"homeserver_url": "", "version": "..."}`, an
empty URL meaning "this origin".
## Deploying
- Put warp-web behind your TLS proxy. It sends a strict
Content-Security-Policy, `X-Frame-Options: DENY` and
`Referrer-Policy: no-referrer` with the client's pages.
- Keep the proxy's read timeout above 30 seconds: `/sync` long-polls.
- Allow request bodies as large as the homeserver's
`media.max_upload_bytes`; uploads pass through warp-web.
- warp-web adds `X-Forwarded-For`. The homeserver uses it for rate limits
only with `client_api.trust_x_forwarded_for` on; without it, every
client counts against warp-web's own address. Turn it on only when every
client reaches the homeserver through proxies you control.
## Develop
```bash
pnpm install
pnpm web:dev # http://localhost:5175, proxied to localhost:8008
```
`WARP_WEB_HOMESERVER` points the dev server at another homeserver. Vite
proxies the same paths warp-web does, so the app code is the same in both.
| Command | What |
|---|---|
| `make web-web` | build the frontend into `internal/client/build/dist` |
| `make web` | build the frontend, then `bin/warp-web` with it embedded |
| `make web-test` | typecheck the frontend, vet and test the Go host |
| `make web-docker` | build the container image |
A `warp-web` compiled before the frontend is built still runs. It serves a
page saying the client is missing.
## Layout
| Path | What |
|---|---|
| `src/matrix/` | everything that touches matrix-js-sdk: the client's lifecycle, the timeline projection, sending, media, encryption (`crypto.ts`, `attachments.ts`) |
| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), profile, toasts (Pinia) |
| `src/views/` | sign-in, sign-up, home, room |
| `src/components/` | timeline, message body, room info, dialogs |
| `cmd/warp-web/` | the binary |
| `internal/host/` | static serving, `/config.json` and the allow-listed proxy |
| `internal/client/` | the embedded build |
The SDK's objects change in place and emit events. The stores turn those
events into a version counter (bumped at most once a frame), and the views
are computed from the SDK on each bump. Keeping the SDK behind `src/matrix`
and `src/stores` is what will let the desktop app put its Go backend
behind the same stores.
+139
View File
@@ -0,0 +1,139 @@
// Command warp-web serves the Warp web client. It is optional: a Warp
// server runs without it, and anyone who wants the client in the browser
// runs this next to it.
//
// warp-web -homeserver http://localhost:8008 -listen :8080
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/dariuszjaniak/warp/apps/web/internal/client"
"github.com/dariuszjaniak/warp/apps/web/internal/host"
)
// Set with -ldflags "-X main.version=...".
var version = "0.0.0-dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "warp-web:", err)
os.Exit(1)
}
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func parseURL(name, raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimRight(raw, "/"))
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
return u, nil
}
func run() error {
listen := flag.String("listen", env("WARP_WEB_LISTEN", ":8080"), "address to listen on (WARP_WEB_LISTEN)")
homeserver := flag.String("homeserver", env("WARP_WEB_HOMESERVER", "http://localhost:8008"),
"base URL of the homeserver, proxied for the client (WARP_WEB_HOMESERVER)")
public := flag.String("public-homeserver", env("WARP_WEB_PUBLIC_HOMESERVER", ""),
"public URL of the homeserver; when set, the browser calls it directly and nothing is proxied (WARP_WEB_PUBLIC_HOMESERVER)")
logFormat := flag.String("log-format", env("WARP_WEB_LOG_FORMAT", "text"), "text or json (WARP_WEB_LOG_FORMAT)")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println("warp-web", version)
return nil
}
var logger *slog.Logger
switch *logFormat {
case "json":
logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
case "text":
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
default:
return fmt.Errorf("unknown log format %q", *logFormat)
}
slog.SetDefault(logger)
opts := host.Options{Logger: logger, Version: version}
mode, target := "proxy", *homeserver
if *public != "" {
u, err := parseURL("public-homeserver", *public)
if err != nil {
return err
}
opts.PublicHomeserver = u
mode, target = "direct", *public
} else {
u, err := parseURL("homeserver", *homeserver)
if err != nil {
return err
}
opts.Homeserver = u
}
files, built := client.FS()
if !built {
logger.Warn("the web client was not built into this binary; serving a placeholder page")
files = nil
}
opts.Client = files
h, err := host.New(opts)
if err != nil {
return err
}
srv := &http.Server{
Addr: *listen,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
// Uploads and downloads of large media pass through here.
ReadTimeout: 15 * time.Minute,
WriteTimeout: 15 * time.Minute,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errc := make(chan error, 1)
go func() {
logger.Info("warp-web listening", "addr", *listen, "homeserver", target, "mode", mode, "version", version)
errc <- srv.ListenAndServe()
}()
select {
case err := <-errc:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
case <-ctx.Done():
}
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/dariuszjaniak/warp/apps/web
go 1.26.4
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Warp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
`pnpm build` in `apps/web` writes the client to `dist/` here, and
`internal/client` embeds it into the `warp-web` binary. This file keeps the
directory in git so the Go code compiles before the frontend is built; such a
binary serves a page saying the client has not been built.
+23
View File
@@ -0,0 +1,23 @@
// Package client holds the built web client, embedded into the binary.
package client
import (
"embed"
"io/fs"
)
//go:embed all:build
var build embed.FS
// FS returns the built client, rooted at its index.html. ok is false when
// the binary was compiled before `pnpm build`, and the client is missing.
func FS() (files fs.FS, ok bool) {
sub, err := fs.Sub(build, "build/dist")
if err != nil {
return nil, false
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil, false
}
return sub, true
}
+273
View File
@@ -0,0 +1,273 @@
// Package host is the warp-web HTTP server: it serves the built web client,
// its runtime configuration and, by default, proxies the client's Matrix
// requests to the homeserver.
//
// Proxying keeps the client same-origin: no CORS, no homeserver URL baked
// into the build, and a homeserver that only has to be reachable from
// warp-web. A deployment that already serves the homeserver publicly can
// turn the proxy off and send the browser there directly instead.
package host
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strings"
"time"
)
// Options configures the handler. Exactly one of Homeserver and
// PublicHomeserver is set.
type Options struct {
// Homeserver is where proxied requests go, e.g. http://warp:8008. The
// browser then talks only to warp-web.
Homeserver *url.URL
// PublicHomeserver is the homeserver's public URL, for a deployment
// where the browser calls it directly; warp-web then proxies nothing.
PublicHomeserver *url.URL
// Client is the built web client rooted at its index.html; nil serves
// a page saying it has not been built.
Client fs.FS
// Version is reported in /config.json.
Version string
// Logger receives proxy errors. Defaults to slog.Default().
Logger *slog.Logger
// Transport overrides the proxy's round tripper, for tests.
Transport http.RoundTripper
}
// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
// Client-Server API and media. Federation, server keys and the admin API
// are not reachable through warp-web.
var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"}
// Config is the body of /config.json, which the client reads on start.
type Config struct {
// HomeserverURL is where the client sends Matrix requests; empty
// means its own origin, through warp-web's proxy.
HomeserverURL string `json:"homeserver_url"`
Version string `json:"version"`
}
// New returns the warp-web handler.
func New(opts Options) (http.Handler, error) {
if (opts.Homeserver == nil) == (opts.PublicHomeserver == nil) {
return nil, errors.New("host: set exactly one of Homeserver and PublicHomeserver")
}
for _, u := range []*url.URL{opts.Homeserver, opts.PublicHomeserver} {
if u != nil && ((u.Scheme != "http" && u.Scheme != "https") || u.Host == "") {
return nil, errors.New("host: the homeserver must be an absolute http(s) URL")
}
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
h := &handler{opts: opts}
cfg := Config{Version: opts.Version}
connect := "'self'"
if u := opts.PublicHomeserver; u != nil {
cfg.HomeserverURL = strings.TrimRight(u.String(), "/")
connect += " " + u.Scheme + "://" + u.Host
}
body, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
h.config = body
// Media is fetched with the access token and shown from blob: URLs,
// because authenticated media can't be loaded by an <img src>. The
// end-to-end encryption code is WebAssembly (matrix-sdk-crypto-wasm),
// which needs 'wasm-unsafe-eval' to compile; it allows no JavaScript eval.
h.csp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src " + connect + "; " +
"worker-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
if opts.Homeserver != nil {
h.proxy = newProxy(opts)
}
if opts.Client != nil {
index, err := fs.ReadFile(opts.Client, "index.html")
if err != nil {
return nil, err
}
h.index = index
}
return h, nil
}
func newProxy(opts Options) *httputil.ReverseProxy {
transport := opts.Transport
if transport == nil {
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 256,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
// /sync long-polls for up to its timeout, 30s from this client.
ResponseHeaderTimeout: 2 * time.Minute,
}
}
upstream := opts.Homeserver
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(upstream)
pr.SetXForwarded()
// The client authenticates with its access token, never a
// cookie; don't pass on any a shared domain might have set.
pr.Out.Header.Del("Cookie")
},
Transport: transport,
// Pass data on as it arrives, so a large download streams.
FlushInterval: -1,
ModifyResponse: func(res *http.Response) error {
res.Header.Del("Set-Cookie")
if res.Header.Get("Cache-Control") == "" {
res.Header.Set("Cache-Control", "no-store")
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err)
writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The web client service can’t reach the homeserver.")
},
}
}
type handler struct {
opts Options
proxy *httputil.ReverseProxy
index []byte
config []byte
csp string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hdr := w.Header()
hdr.Set("X-Content-Type-Options", "nosniff")
hdr.Set("Referrer-Policy", "no-referrer")
p := r.URL.Path
switch {
case p == "/healthz":
hdr.Set("Content-Type", "text/plain; charset=utf-8")
hdr.Set("Cache-Control", "no-store")
_, _ = w.Write([]byte("ok\n"))
return
case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"):
// A path that cleans to something else (dot segments, doubled
// slashes) is refused rather than normalised: what is allowed
// here must be exactly what the homeserver routes.
if h.proxy == nil || !clean(p) || !proxied(p) {
writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the web client service.")
return
}
h.proxy.ServeHTTP(w, r)
return
}
hdr.Set("X-Frame-Options", "DENY")
if r.Method != http.MethodGet && r.Method != http.MethodHead {
hdr.Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if p == "/config.json" {
hdr.Set("Content-Type", "application/json")
hdr.Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "config.json", time.Time{}, bytes.NewReader(h.config))
return
}
hdr.Set("Content-Security-Policy", h.csp)
h.serveStatic(w, r)
}
// clean reports whether p is already in canonical form. A trailing slash
// counts as canonical: the C-S API has routes that end in one, like
// GET /pushrules/.
func clean(p string) bool {
c := path.Clean(p)
if strings.HasSuffix(p, "/") && c != "/" {
c += "/"
}
return c == p
}
func proxied(p string) bool {
for _, prefix := range proxiedPrefixes {
if strings.HasPrefix(p, prefix) {
return true
}
}
return false
}
func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) {
if h.opts.Client == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(notBuiltPage))
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name != "" && name != "index.html" {
if st, err := fs.Stat(h.opts.Client, name); err == nil && st.Mode().IsRegular() {
if strings.HasPrefix(name, "assets/") {
// Vite puts a content hash in every file name under assets/.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
http.ServeFileFS(w, r, h.opts.Client, name)
return
}
if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") {
// A missing asset is a 404, not the app: answering a stale
// script URL with HTML only produces a confusing MIME error.
// Deeper paths are routes even with a dot in them, like
// /room/!abc:warp.example.
http.NotFound(w, r)
return
}
}
// Everything else is a route of the single-page app.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
func writeError(w http.ResponseWriter, status int, errcode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg})
}
const notBuiltPage = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warp</title>
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
<body><h1 style="font-size:20px">The web client isn’t built into this binary</h1>
<p>This warp-web was compiled before the frontend. Build it, then the binary:</p>
<p><code>pnpm --filter @warp/web build</code><br><code>make web</code></p></body></html>
`
+306
View File
@@ -0,0 +1,306 @@
package host
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/fstest"
)
// upstream records what reached the homeserver.
type upstream struct {
srv *httptest.Server
hits []*http.Request
}
func newUpstream(t *testing.T) *upstream {
t.Helper()
u := &upstream{}
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits = append(u.hits, r.Clone(r.Context()))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", "tracker=1")
if strings.HasPrefix(r.URL.Path, "/_matrix/client/v1/media/") {
w.Header().Set("Cache-Control", "private, max-age=86400")
}
_, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`))
}))
t.Cleanup(u.srv.Close)
return u
}
var clientFS = fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><title>client</title>")},
"favicon.svg": {Data: []byte("<svg/>")},
"assets/index-a1.js": {Data: []byte("console.log(1)")},
}
func newHandler(t *testing.T, up *upstream, built bool) http.Handler {
t.Helper()
target, _ := url.Parse(up.srv.URL)
opts := Options{Homeserver: target, Version: "1.2.3"}
if built {
opts.Client = clientFS
}
h, err := New(opts)
if err != nil {
t.Fatal(err)
}
return h
}
func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for k, v := range header {
req.Header[k] = v
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestProxiesClientAndMediaPaths(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
cases := []struct{ method, path string }{
{http.MethodGet, "/_matrix/client/versions"},
{http.MethodPost, "/_matrix/client/v3/login"},
{http.MethodGet, "/_matrix/client/v3/sync?timeout=30000&since=s1_2"},
{http.MethodPut, "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1"},
{http.MethodPost, "/_matrix/media/v3/upload?filename=cat.png"},
{http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc"},
{http.MethodGet, "/_matrix/client/v3/pushrules/"},
}
for _, c := range cases {
rec := do(h, c.method, c.path, nil)
if rec.Code != http.StatusOK {
t.Errorf("%s %s: status %d, want 200", c.method, c.path, rec.Code)
}
}
if len(up.hits) != len(cases) {
t.Fatalf("upstream saw %d requests, want %d", len(up.hits), len(cases))
}
// Escaped IDs and the query reach the homeserver unchanged.
if got := up.hits[3].URL.EscapedPath(); got != "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1" {
t.Errorf("escaped path = %q", got)
}
if got := up.hits[2].URL.RawQuery; got != "timeout=30000&since=s1_2" {
t.Errorf("query = %q", got)
}
}
func TestRefusesOtherHomeserverPaths(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
for _, p := range []string{
"/_matrix/federation/v1/version",
"/_matrix/key/v2/server",
"/_matrix/app/v1/transactions/1",
"/_warp/admin/v1/stats",
"/_synapse/admin/v1/users",
"/_matrix/client/../federation/v1/version",
"/_matrix/client/v3/../../federation/v1/version",
"/_matrix/client//v3/sync",
"/_matrix/client/v3/pushrules//",
"/_matrix/client/v3/../../federation/",
} {
rec := do(h, http.MethodGet, p, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s: status %d, want 404", p, rec.Code)
}
var body map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["errcode"] != "M_UNRECOGNIZED" {
t.Errorf("%s: errcode %q", p, body["errcode"])
}
}
if len(up.hits) != 0 {
t.Fatalf("upstream saw %d requests, want none", len(up.hits))
}
}
func TestProxyStripsCookiesAndForwardsToken(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
rec := do(h, http.MethodGet, "/_matrix/client/v3/account/whoami", http.Header{
"Authorization": {"Bearer tok"},
"Cookie": {"session=secret"},
})
if rec.Header().Get("Set-Cookie") != "" {
t.Error("Set-Cookie from the homeserver was passed to the browser")
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
}
got := up.hits[0]
if got.Header.Get("Cookie") != "" {
t.Error("Cookie was passed to the homeserver")
}
if got.Header.Get("Authorization") != "Bearer tok" {
t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
}
if got.Header.Get("X-Forwarded-For") == "" {
t.Error("X-Forwarded-For not set")
}
// The homeserver's own caching of media is kept.
rec = do(h, http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc", nil)
if got := rec.Header().Get("Cache-Control"); got != "private, max-age=86400" {
t.Errorf("media Cache-Control = %q", got)
}
}
func TestUnreachableHomeserverIs502(t *testing.T) {
target, _ := url.Parse("http://127.0.0.1:1")
h, err := New(Options{Homeserver: target, Client: clientFS})
if err != nil {
t.Fatal(err)
}
rec := do(h, http.MethodGet, "/_matrix/client/versions", nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status %d, want 502", rec.Code)
}
if !strings.Contains(rec.Body.String(), "M_UNKNOWN") {
t.Errorf("body %q", rec.Body.String())
}
}
func TestServesClient(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
cases := []struct {
path, wantBody, wantCache string
wantCode int
}{
{"/", "client", "no-cache", 200},
{"/room/!abc:warp.example", "client", "no-cache", 200},
{"/room/%21abc%3Awarp.example", "client", "no-cache", 200},
{"/index.html", "client", "no-cache", 200},
{"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200},
{"/favicon.svg", "<svg", "public, max-age=3600", 200},
{"/assets/gone-b2.js", "", "", 404},
{"/robots.txt", "", "", 404},
}
for _, c := range cases {
rec := do(h, http.MethodGet, c.path, nil)
if rec.Code != c.wantCode {
t.Errorf("%s: status %d, want %d", c.path, rec.Code, c.wantCode)
continue
}
if c.wantCode != 200 {
continue
}
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(string(body), c.wantBody) {
t.Errorf("%s: body %q", c.path, body)
}
if got := rec.Header().Get("Cache-Control"); got != c.wantCache {
t.Errorf("%s: Cache-Control %q, want %q", c.path, got, c.wantCache)
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self';") {
t.Errorf("%s: CSP %q", c.path, csp)
}
// WebAssembly for end-to-end encryption, and no JavaScript eval.
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self' 'wasm-unsafe-eval';") {
t.Errorf("%s: CSP %q lacks wasm-unsafe-eval", c.path, csp)
}
if rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s: X-Frame-Options %q", c.path, rec.Header().Get("X-Frame-Options"))
}
}
if rec := do(h, http.MethodPost, "/", nil); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /: status %d, want 405", rec.Code)
}
}
func TestConfigProxied(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
rec := do(h, http.MethodGet, "/config.json", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
var cfg Config
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
if cfg.HomeserverURL != "" || cfg.Version != "1.2.3" {
t.Errorf("config = %+v, want same-origin and the version", cfg)
}
if rec.Header().Get("Cache-Control") != "no-cache" {
t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control"))
}
}
func TestDirectModeProxiesNothing(t *testing.T) {
public, _ := url.Parse("https://matrix.warp.example")
h, err := New(Options{PublicHomeserver: public, Client: clientFS})
if err != nil {
t.Fatal(err)
}
rec := do(h, http.MethodGet, "/config.json", nil)
var cfg Config
_ = json.Unmarshal(rec.Body.Bytes(), &cfg)
if cfg.HomeserverURL != "https://matrix.warp.example" {
t.Errorf("homeserver_url = %q", cfg.HomeserverURL)
}
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusNotFound {
t.Errorf("proxy in direct mode: status %d, want 404", rec.Code)
}
// The browser may now connect to the homeserver, and only there.
rec = do(h, http.MethodGet, "/", nil)
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self' https://matrix.warp.example;") {
t.Errorf("CSP %q", csp)
}
}
func TestNotBuiltClient(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, false)
rec := do(h, http.MethodGet, "/", nil)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "isn’t built") {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
// The proxy still works without the client.
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusOK {
t.Errorf("proxy status %d", rec.Code)
}
}
func TestHealthz(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
if rec := do(h, http.MethodGet, "/healthz", nil); rec.Code != 200 || rec.Body.String() != "ok\n" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
}
func TestNewValidatesHomeserver(t *testing.T) {
rel, _ := url.Parse("warp:8008")
ftp, _ := url.Parse("ftp://warp.example")
ok, _ := url.Parse("http://warp:8008")
for name, opts := range map[string]Options{
"relative": {Homeserver: rel},
"scheme": {PublicHomeserver: ftp},
"neither": {},
"both": {Homeserver: ok, PublicHomeserver: ok},
} {
if _, err := New(opts); err == nil {
t.Errorf("%s: want an error", name)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@warp/web",
"version": "0.0.0",
"private": true,
"description": "Warp web client: a Matrix client for the browser",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@warp/ui": "workspace:*",
"matrix-js-sdk": "^44.0.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.18.10",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.9.3",
"vite": "^7.1.9",
"vue-tsc": "^3.1.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><title>Warp</title><g transform="translate(-23.3 -22.11) scale(1.182)"><path fill="#00A389" fill-rule="evenodd" d="M73.5 23.1 L67.4 23.9 L60.1 26 L54.6 28.5 L48.2 32.6 L42.6 37.7 L37.9 43.6 L34.2 50.3 L32.1 56 L30.7 61.9 L30.1 67.9 L30.1 105.8 L30.3 110.3 L31.3 116.3 L33 122.2 L35.5 127.7 L38.8 132.9 L43.7 138.6 L47 141.7 L50.7 144.4 L54.6 146.8 L58.7 148.7 L66 151 L70.4 151.8 L75 152.2 L112.9 152.2 L117.4 152 L124.9 150.7 L130.6 148.7 L136.1 146 L142.3 141.7 L146.8 137.5 L151.5 131.6 L155.2 125 L157.7 117.9 L174.2 117.9 L176.6 118.3 L179.1 119.3 L181.7 121.2 L183.7 123.6 L185 126.5 L185.5 129.6 L185.5 167.6 L184.9 170.3 L183.9 172.4 L182.5 174.4 L180.8 176 L177.7 177.7 L174.2 178.5 L135.9 178.4 L133.6 178 L131.7 177.2 L129.7 176 L128 174.4 L126.6 172.4 L125.8 170.6 L125.1 168.4 L125 166.1 L125 167.2 L117.2 168.2 L90.7 168.4 L91 172.4 L91.9 176.9 L94.2 184.2 L96.1 188.3 L99.3 193.4 L102.2 197 L105.3 200.3 L108.8 203.2 L113.9 206.6 L117.9 208.7 L122.2 210.4 L128 211.9 L135.6 212.8 L173.4 212.8 L180.6 212.3 L225.9 231 L217.3 181.8 L219 175.4 L219.9 167.9 L219.9 130 L219.5 124 L218.3 118 L216.9 113.7 L214.4 108.1 L211.2 103 L206.3 97.2 L202.9 94.1 L199.2 91.4 L195.3 89.1 L191.2 87.1 L184 84.8 L179.5 84 L175 83.6 L137.1 83.6 L132.5 83.8 L125.1 85.2 L119.3 87.1 L112.6 90.6 L107.6 94.1 L102.2 99.4 L98.5 104.2 L94.8 110.9 L92.3 117.9 L75.7 117.9 L73 117.4 L70.4 116.3 L68.2 114.6 L66.4 112.5 L65.2 110.1 L64.5 107.4 L64.4 69.7 L64.8 66.3 L66 63.4 L67.9 60.9 L70.8 58.8 L73.4 57.7 L76.1 57.3 L112.6 57.3 L115.2 57.6 L117.1 58.1 L118.9 59 L120.8 60.3 L122.2 61.8 L123.3 63.4 L124.3 65.6 L124.8 67.5 L125 69.7 L125 68.6 L132.7 67.6 L159.3 67.4 L158.7 61.9 L157.3 56 L155.2 50.3 L152.3 44.9 L148.8 40 L144.6 35.5 L139.9 31.7 L134.7 28.5 L130.6 26.5 L126.3 25 L121.9 23.9 L117.4 23.2 L112.9 23Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTheme } from '@warp/ui'
import ToastList from './components/ToastList.vue'
import { useSession } from './stores/session'
useTheme()
const session = useSession()
const router = useRouter()
// The session can end on its own (signed out elsewhere, token revoked);
// send the person back to sign in.
watch(
() => session.signedIn,
(signedIn) => {
if (!signedIn && !router.currentRoute.value.meta.public) router.replace({ name: 'login' })
},
)
</script>
<template>
<RouterView />
<ToastList />
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WTextField } from '@warp/ui'
// Confirmation for the actions that can't be taken back. With `typeToConfirm`
// the admin types the name of what goes (a room ID, a user ID) before the
// button unlocks, so a slip on a table can't purge the wrong thing.
const props = withDefaults(
defineProps<{
title: string
confirmLabel: string
danger?: boolean
typeToConfirm?: string
busy?: boolean
error?: string
}>(),
{ danger: true, typeToConfirm: undefined, busy: false, error: '' },
)
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ confirm: [] }>()
const typed = ref('')
watch(open, (v) => {
if (v) typed.value = ''
})
const ready = computed(() => !props.typeToConfirm || typed.value.trim() === props.typeToConfirm)
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy">
<div class="body flex flex-col gap-3">
<slot />
</div>
<WTextField
v-if="typeToConfirm"
v-model="typed"
wide
mono
:label="`Type ${typeToConfirm} to confirm`"
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton :variant="danger ? 'danger-solid' : 'primary'" :disabled="!ready" :loading="busy" @click="emit('confirm')">
{{ confirmLabel }}
</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WIcon, WSwitch, WTextArea, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { useChat } from '@/stores/chat'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [roomId: string] }>()
const chat = useChat()
const name = ref('')
const topic = ref('')
const isPublic = ref(false)
const alias = ref('')
const encrypted = ref(true)
const busy = ref(false)
const error = ref('')
watch(open, (v) => {
if (!v) return
name.value = ''
topic.value = ''
isPublic.value = false
alias.value = ''
encrypted.value = chat.canEncrypt
error.value = ''
})
// End-to-end encrypted by default for a private room. A public one is
// readable by whoever joins anyway, and history is lost to newcomers.
watch(isPublic, (v) => (encrypted.value = !v && chat.canEncrypt))
// A public room's address, from its name: "Homelab ops" → homelab-ops.
watch(name, (n, old) => {
const slug = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!alias.value || alias.value === slug(old)) alias.value = slug(n)
})
const aliasError = computed(() =>
isPublic.value && alias.value && !/^[a-z0-9._=-]+$/.test(alias.value) ? 'Use lowercase letters, digits and . _ = -' : undefined,
)
async function submit() {
if (!name.value.trim() || aliasError.value) return
busy.value = true
error.value = ''
try {
const roomId = await chat.createRoom({
name: name.value,
topic: topic.value,
isPublic: isPublic.value,
alias: alias.value,
encrypted: encrypted.value,
})
open.value = false
emit('created', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<WDialog v-model:open="open" title="Create room" :persistent="busy">
<form id="create-room" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField v-model="name" wide label="Name" maxlength="255" required autofocus />
<WTextArea v-model="topic" label="Topic (optional)" rows="2" />
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0">Public room</p>
<p class="small m-0 text-ink-muted">Anyone can find it in the room directory and join. Otherwise people join by invite.</p>
</div>
<WSwitch v-model="isPublic" label="Public room" />
</div>
<WTextField
v-if="isPublic"
v-model="alias"
wide
mono
label="Address"
hint="People can join with #address:your-server"
:error="aliasError"
autocomplete="off"
spellcheck="false"
/>
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="body-strong m-0 flex items-center gap-1">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="16" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'" />
End-to-end encrypted
</p>
<p class="small m-0 text-ink-muted">
<template v-if="!chat.canEncrypt">End-to-end encryption didn’t start in this browser.</template>
<template v-else-if="encrypted">Only the people in the room can read its messages, not the homeserver. It can’t be turned off later.</template>
<template v-else>The homeserver can read this room’s messages.</template>
</p>
</div>
<WSwitch v-model="encrypted" label="End-to-end encrypted" :disabled="!chat.canEncrypt" />
</div>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="create-room" variant="primary" :loading="busy" :disabled="!name.trim() || !!aliasError">
Create room
</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WCheckbox, WDialog, WIcon, WSpinner, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import {
createKeyBackup,
PasswordNeededError,
recoveryKeyKnown,
restoreKeyBackup,
setUpEncryption,
unlockWithRecoveryKey,
} from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
import { useToasts } from '@/stores/toasts'
// Encryption for the account and this session, one task per mode:
// setup – first time: cross-signing, secret storage, key backup; shows the recovery key
// reset – the recovery key is lost: new keys, everyone verifies again
// unlock – verify this session with the recovery key, or with another session
// restore – read older messages from the key backup
// backup – start a key backup on an account that has none
export type EncryptionMode = 'setup' | 'reset' | 'unlock' | 'restore' | 'backup'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ mode: EncryptionMode }>()
const security = useSecurity()
const toasts = useToasts()
const step = ref<EncryptionMode | 'saveKey' | 'restoring' | 'restored'>(props.mode)
const password = ref('')
const needPassword = ref(false)
const recoveryKey = ref('')
const savedKey = ref(false)
const input = ref('')
const busy = ref(false)
const error = ref('')
const progress = ref<{ done: number; total: number } | null>(null)
const restored = ref({ imported: 0, total: 0 })
watch(open, (v) => {
if (!v) return
step.value = props.mode
password.value = ''
needPassword.value = false
recoveryKey.value = ''
savedKey.value = false
input.value = ''
error.value = ''
progress.value = null
if (props.mode === 'restore') void restore()
})
const title = computed(() => {
switch (step.value) {
case 'setup':
case 'saveKey':
return 'Set up encryption'
case 'reset':
return 'Reset encryption'
case 'unlock':
return 'Verify this session'
case 'backup':
return 'Set up key backup'
default:
return 'Restore from key backup'
}
})
async function run(fn: () => Promise<void>) {
busy.value = true
error.value = ''
try {
await fn()
} catch (e) {
if (e instanceof PasswordNeededError) needPassword.value = true
error.value = errorMessage(e)
} finally {
busy.value = false
void security.refresh()
}
}
function setUp() {
return run(async () => {
recoveryKey.value = await setUpEncryption({ password: password.value || undefined, reset: step.value === 'reset' })
step.value = 'saveKey'
})
}
function unlock() {
return run(async () => {
await unlockWithRecoveryKey(input.value)
toasts.success('This session is verified.')
await restore()
})
}
function backup() {
return run(async () => {
if (!recoveryKeyKnown()) await unlockWithRecoveryKey(input.value)
await createKeyBackup()
toasts.success('Key backup is on.')
open.value = false
})
}
async function restore() {
step.value = 'restoring'
progress.value = null
error.value = ''
try {
const res = await restoreKeyBackup((done, total) => (progress.value = { done, total }))
restored.value = { imported: res.imported, total: res.total }
step.value = 'restored'
} catch (e) {
error.value = errorMessage(e)
step.value = 'restored'
restored.value = { imported: 0, total: 0 }
} finally {
void security.refresh()
}
}
async function withOtherSession() {
error.value = ''
try {
await security.verifyThisSession()
open.value = false
} catch (e) {
error.value = errorMessage(e)
}
}
function copyKey() {
navigator.clipboard.writeText(recoveryKey.value).then(() => toasts.success('Copied.'))
}
function downloadKey() {
const blob = new Blob([`Warp recovery key\n\n${recoveryKey.value}\n`], { type: 'text/plain' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'warp-recovery-key.txt'
a.click()
URL.revokeObjectURL(a.href)
}
const needsKey = computed(() => step.value === 'unlock' || (step.value === 'backup' && !recoveryKeyKnown()))
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy || step === 'saveKey' || step === 'restoring'">
<!-- First time, or starting over -->
<form v-if="step === 'setup' || step === 'reset'" id="enc-setup" class="flex flex-col gap-4" @submit.prevent="setUp">
<p v-if="step === 'setup'" class="body m-0">
Encryption keys for your account let you verify your sessions, and keep a backup of your message keys on the homeserver,
locked with a recovery key only you have. A new session can then read your older messages.
</p>
<WBanner v-else tone="warning" title="Everyone will need to verify you again">
This replaces your account’s keys and starts a new key backup. Messages only your old keys could read stay unreadable here.
</WBanner>
<WTextField
v-if="needPassword || step === 'reset'"
v-model="password"
wide
type="password"
label="Account password"
autocomplete="current-password"
hint="The homeserver asks for it before it takes new keys."
autofocus
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<!-- The new recovery key, shown once -->
<div v-else-if="step === 'saveKey'" class="flex flex-col gap-4">
<WBanner tone="success" title="Encryption is set up">This session is verified, and key backup is on.</WBanner>
<p class="body m-0">
Save your recovery key somewhere safe, like a password manager. You need it to verify a new session when no other session is
signed in. Warp can’t show it to you again.
</p>
<p class="mxid m-0 rounded-md bg-surface-sunken p-3 text-center break-all select-all">{{ recoveryKey }}</p>
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="copy" @click="copyKey">Copy</WButton>
<WButton size="sm" icon="download" @click="downloadKey">Download</WButton>
</div>
<WCheckbox v-model="savedKey" label="I’ve saved my recovery key" />
</div>
<!-- Unlock with the recovery key -->
<form v-else-if="step === 'unlock' || step === 'backup'" id="enc-unlock" class="flex flex-col gap-4" @submit.prevent="step === 'unlock' ? unlock() : backup()">
<template v-if="step === 'unlock'">
<p class="body m-0">
Verify this session to read your encrypted messages here, and so others can trust it. Use one of your other sessions, or
your recovery key.
</p>
<WButton icon="shield-check" class="self-start" :disabled="busy" @click="withOtherSession">Verify with another session</WButton>
</template>
<p v-else class="body m-0">The key backup is locked with your recovery key.</p>
<WTextField
v-if="needsKey"
v-model="input"
wide
mono
type="password"
label="Recovery key"
hint="Starts with “Es”, in groups of four. A recovery passphrase works too."
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<button v-if="step === 'unlock'" type="button" class="small self-start text-ink-muted hover:text-ink hover:underline" @click="step = 'reset'">
Lost your recovery key and other sessions? Reset encryption
</button>
</form>
<!-- Restoring -->
<div v-else-if="step === 'restoring'" class="flex flex-col items-center gap-3 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">
Restoring message keys<template v-if="progress && progress.total"> · {{ progress.done }} of {{ progress.total }}</template>…
</p>
</div>
<div v-else-if="step === 'restored'" class="flex flex-col gap-3">
<WBanner v-if="error" tone="danger" title="Can’t restore from the key backup">{{ error }}</WBanner>
<p v-else class="body m-0 flex items-start gap-2">
<WIcon name="shield-check" class="mt-[2px] shrink-0 text-teal-text" />
<span v-if="restored.total">Restored {{ restored.imported }} of {{ restored.total }} message keys. Older messages decrypt as they load.</span>
<span v-else>The key backup has no message keys yet.</span>
</p>
</div>
<template #actions>
<template v-if="step === 'setup' || step === 'reset'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-setup" :variant="step === 'reset' ? 'danger' : 'primary'" :loading="busy" :disabled="step === 'reset' && !password">
{{ step === 'reset' ? 'Reset encryption' : 'Set up encryption' }}
</WButton>
</template>
<WButton v-else-if="step === 'saveKey'" variant="primary" :disabled="!savedKey" @click="open = false">Done</WButton>
<template v-else-if="step === 'unlock' || step === 'backup'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-unlock" variant="primary" icon="key" :loading="busy" :disabled="needsKey && !input.trim()">
{{ step === 'unlock' ? 'Verify session' : 'Set up key backup' }}
</WButton>
</template>
<WButton v-else-if="step === 'restored'" variant="primary" @click="open = false">Done</WButton>
</template>
</WDialog>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { WIconButton, WSpinner } from '@warp/ui'
import { downloadMedia, useMedia, type MediaSource } from '@/matrix/media'
// A full-screen view of one image, closed with Escape or a click outside.
const props = defineProps<{ source: MediaSource; name: string }>()
const emit = defineEmits<{ close: [] }>()
const { url, failed } = useMedia(() => props.source)
const closeBtn = ref<HTMLElement>()
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
document.addEventListener('keydown', onKey)
closeBtn.value?.querySelector('button')?.focus()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div
class="fixed inset-0 z-50 flex flex-col bg-[#0f1213e6]"
role="dialog"
aria-modal="true"
:aria-label="name"
@click.self="emit('close')"
>
<div class="flex items-center gap-2 p-2 text-[#eceeee]">
<p class="body-strong min-w-0 flex-1 truncate px-2">{{ name }}</p>
<WIconButton icon="download" label="Download" class="text-[#eceeee] hover:bg-[#ffffff1a]" @click="downloadMedia(source, name)" />
<span ref="closeBtn"><WIconButton icon="close" label="Close" class="text-[#eceeee] hover:bg-[#ffffff1a]" @click="emit('close')" /></span>
</div>
<div class="flex min-h-0 flex-1 items-center justify-center p-4" @click.self="emit('close')">
<img v-if="url" :src="url" :alt="name" class="max-h-full max-w-full object-contain" />
<p v-else-if="failed" class="body text-[#eceeee]">Can’t load the image.</p>
<WSpinner v-else :size="32" />
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { IPublicRoomsChunkRoom } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WSpinner, WTextField } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { useChat } from '@/stores/chat'
// Join by address or ID, or pick from this homeserver's room directory.
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ joined: [roomId: string] }>()
const chat = useChat()
const address = ref('')
const busy = ref('')
const error = ref('')
const directory = ref<IPublicRoomsChunkRoom[]>()
const directoryError = ref('')
const search = ref('')
const joined = computed(() => new Set(chat.rooms.map((r) => r.roomId)))
async function loadDirectory() {
directoryError.value = ''
try {
const res = await client().publicRooms({ limit: 50, filter: search.value.trim() ? { generic_search_term: search.value.trim() } : undefined })
directory.value = res.chunk
} catch (e) {
directoryError.value = errorMessage(e)
}
}
watch(open, (v) => {
if (!v) return
address.value = ''
error.value = ''
search.value = ''
directory.value = undefined
loadDirectory()
})
let searchTimer = 0
watch(search, () => {
window.clearTimeout(searchTimer)
searchTimer = window.setTimeout(loadDirectory, 300)
})
async function join(target: string) {
const t = target.trim()
if (!t) return
if (joined.value.has(t)) {
open.value = false
emit('joined', t)
return
}
busy.value = t
error.value = ''
try {
const roomId = await chat.join(t)
open.value = false
emit('joined', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = ''
}
}
const validAddress = computed(() => /^[#!][^:\s]+:\S+$/.test(address.value.trim()))
</script>
<template>
<WDialog v-model:open="open" title="Join room" wide :persistent="busy !== ''">
<form class="flex items-end gap-2" @submit.prevent="join(address)">
<WTextField
v-model="address"
class="flex-1"
wide
mono
label="Room address or ID"
placeholder="#homelab-ops:warp.example"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
autofocus
/>
<WButton type="submit" variant="primary" :loading="busy === address.trim() && busy !== ''" :disabled="!validAddress">Join</WButton>
</form>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h3 class="body-strong m-0 flex-1">Room directory</h3>
</div>
<WTextField v-model="search" wide label="Search the directory" type="search" />
<WBanner v-if="directoryError" tone="warning">{{ directoryError }}</WBanner>
<div v-else-if="!directory" class="flex justify-center py-6"><WSpinner /></div>
<p v-else-if="!directory.length" class="body py-4 text-center text-ink-muted">
{{ search ? 'No public rooms match.' : 'There are no public rooms on this homeserver yet.' }}
</p>
<ul v-else class="m-0 flex max-h-80 list-none flex-col overflow-y-auto p-0">
<li v-for="r in directory" :key="r.room_id" class="flex items-center gap-3 rounded-md px-2 py-2">
<MxcAvatar :name="r.name ?? r.canonical_alias ?? r.room_id" :mxc="r.avatar_url" :size="36" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ r.name ?? r.canonical_alias ?? r.room_id }}</p>
<p class="small m-0 truncate text-ink-muted">
<span v-if="r.canonical_alias" class="mxid">{{ r.canonical_alias }} · </span>{{ r.num_joined_members }}
{{ r.num_joined_members === 1 ? 'member' : 'members' }}<template v-if="r.topic"> · {{ r.topic }}</template>
</p>
</div>
<WButton size="sm" :variant="joined.has(r.room_id) ? 'ghost' : 'secondary'" :loading="busy === r.room_id" @click="join(r.room_id)">
{{ joined.has(r.room_id) ? 'Open' : 'Join' }}
</WButton>
</li>
</ul>
</div>
</WDialog>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { WButton, WIcon, WSpinner } from '@warp/ui'
import { formatBytes } from '@/lib/format'
import { linkify } from '@/lib/linkify'
import { downloadMedia, mediaUrl, useMedia, type MediaSource } from '@/matrix/media'
import { mediaSource, type MessageItem } from '@/matrix/timeline'
// What goes inside a bubble: text with links, an image, a player or a file
// card. Message text is never rendered as HTML.
const props = defineProps<{ item: MessageItem }>()
const emit = defineEmits<{ view: [source: MediaSource, name: string] }>()
const segments = computed(() => linkify(props.item.body))
const media = computed(() => props.item.media)
const isImage = computed(() => props.item.msgtype === 'm.image' || props.item.msgtype === 'm.sticker')
// Images: a thumbnail that keeps the sender's aspect ratio, so the timeline
// doesn't jump as it loads.
const box = computed(() => {
const m = media.value
const maxW = props.item.msgtype === 'm.sticker' ? 160 : 320
const maxH = 320
if (!m?.w || !m?.h) return { width: maxW, height: 200 }
const scale = Math.min(1, maxW / m.w, maxH / m.h)
return { width: Math.max(48, Math.round(m.w * scale)), height: Math.max(48, Math.round(m.h * scale)) }
})
const { url: thumb, failed: thumbFailed } = useMedia(
() => (isImage.value && media.value ? mediaSource(media.value, true) : undefined),
{ width: 640, height: 640 },
)
// Video and audio load when asked for: the whole file comes down first.
const playerUrl = ref<string>()
const loadingPlayer = ref(false)
const playerError = ref(false)
async function loadPlayer() {
const src = media.value && mediaSource(media.value)
if (!src) return
loadingPlayer.value = true
playerError.value = false
try {
playerUrl.value = await mediaUrl(src)
} catch {
playerError.value = true
} finally {
loadingPlayer.value = false
}
}
const downloading = ref(false)
async function download() {
const m = media.value
const src = m && mediaSource(m)
if (!m || !src) return
downloading.value = true
try {
await downloadMedia(src, m.filename)
} finally {
downloading.value = false
}
}
</script>
<template>
<p v-if="item.redacted" class="m-0 text-ink-muted italic">Message deleted</p>
<!-- Still ciphertext: says why it can't be read, or that it is being decrypted -->
<p v-else-if="item.encrypted && item.utd" class="m-0 flex items-start gap-2 text-ink-muted italic">
<WIcon name="shield-alert" :size="16" class="mt-[3px] shrink-0" />
<span>{{ item.utd }}</span>
</p>
<p v-else-if="item.encrypted" class="m-0 flex items-center gap-2 text-ink-muted">
<WIcon name="lock" :size="16" class="shrink-0" />
<span>Decrypting…</span>
</p>
<!-- Image or sticker -->
<button
v-else-if="isImage && media?.mxc"
type="button"
class="-mx-2 -my-1 block cursor-zoom-in overflow-hidden rounded-md bg-surface-sunken p-0"
:style="{ width: `${box.width}px`, height: `${box.height}px` }"
:aria-label="`View ${media.filename}`"
@click="emit('view', mediaSource(media)!, media.filename)"
>
<img v-if="thumb" :src="thumb" :alt="media.filename" class="h-full w-full object-cover" />
<span v-else-if="thumbFailed" class="flex h-full items-center justify-center gap-2 text-ink-muted">
<WIcon name="image" /> Can’t load the image
</span>
<span v-else class="flex h-full items-center justify-center"><WSpinner /></span>
</button>
<!-- Video and audio -->
<div v-else-if="(item.msgtype === 'm.video' || item.msgtype === 'm.audio') && media?.mxc" class="flex flex-col gap-2">
<video v-if="playerUrl && item.msgtype === 'm.video'" :src="playerUrl" controls autoplay class="max-h-80 max-w-full rounded-md" />
<audio v-else-if="playerUrl" :src="playerUrl" controls autoplay class="max-w-full" />
<div v-else class="flex items-center gap-3">
<WIcon :name="item.msgtype === 'm.video' ? 'video' : 'file'" class="text-ink-muted" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ media.filename }}</p>
<p class="caption m-0 text-ink-muted">{{ playerError ? 'Can’t load it' : formatBytes(media.size) }}</p>
</div>
<WButton size="sm" :loading="loadingPlayer" @click="loadPlayer">Play</WButton>
</div>
</div>
<!-- Any other file -->
<div v-else-if="media?.mxc" class="flex items-center gap-3">
<WIcon name="file" class="text-ink-muted" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ media.filename }}</p>
<p class="caption m-0 text-ink-muted">{{ formatBytes(media.size) }}</p>
</div>
<WButton size="sm" icon="download" :loading="downloading" @click="download">Download</WButton>
</div>
<!-- Text, notice, emote -->
<p
v-else
:class="['m-0 whitespace-pre-wrap', item.msgtype === 'm.notice' && 'text-ink-muted', item.msgtype === 'm.emote' && 'italic']"
><template v-if="item.msgtype === 'm.emote'">* {{ item.senderName }} </template><template v-for="(s, i) in segments" :key="i"><a v-if="s.kind === 'link'" :href="s.href" target="_blank" rel="noopener noreferrer nofollow">{{ s.text }}</a><template v-else>{{ s.text }}</template></template></p>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { WAvatar } from '@warp/ui'
import { useMedia } from '@/matrix/media'
// WAvatar with its picture loaded from the media repository.
const props = withDefaults(
defineProps<{
name: string
mxc?: string
size?: number
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{ mxc: undefined, size: 40, presence: undefined },
)
// Twice the drawn size for sharp pictures on high-density screens; the
// server picks the nearest thumbnail it has.
const px = Math.max(64, props.size * 2)
const { url } = useMedia(() => props.mxc, { width: px, height: px })
</script>
<template>
<WAvatar :name="name" :src="url" :size="size" :presence="presence" />
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WIcon, WIconButton, type IconName } from '@warp/ui'
import type { MenuItem } from './menu'
// A small menu behind an icon button: the items are actions, the menu
// closes on a choice, Escape or a click outside, and arrow keys move
// between items.
const props = withDefaults(defineProps<{ icon: IconName; label: string; items: MenuItem[]; align?: 'left' | 'right' }>(), {
align: 'right',
})
const open = ref(false)
const root = ref<HTMLElement>()
const list = ref<HTMLElement>()
function onDocClick(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
watch(open, async (v) => {
if (v) {
document.addEventListener('mousedown', onDocClick)
await nextTick()
list.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else {
document.removeEventListener('mousedown', onDocClick)
}
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
function choose(item: MenuItem) {
open.value = false
item.action()
}
function onKey(e: KeyboardEvent) {
const buttons = Array.from(list.value?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const i = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
open.value = false
root.value?.querySelector<HTMLButtonElement>('button')?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
buttons[(i + 1) % buttons.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
buttons[(i - 1 + buttons.length) % buttons.length]?.focus()
}
}
</script>
<template>
<div ref="root" class="relative" @keydown="onKey">
<WIconButton :icon="props.icon" :label="label" :aria-expanded="open" aria-haspopup="menu" @click="open = !open" />
<div
v-if="open"
ref="list"
role="menu"
:class="[
'absolute top-full z-30 mt-1 flex min-w-52 flex-col rounded-md border border-line bg-surface-raised p-1 shadow-float',
align === 'right' ? 'right-0' : 'left-0',
]"
>
<button
v-for="item in items"
:key="item.label"
type="button"
role="menuitem"
:class="[
'flex h-10 items-center gap-3 rounded-sm px-3 text-left font-semibold hover:bg-surface-sunken focus-visible:bg-surface-sunken',
item.danger ? 'text-danger' : 'text-ink',
]"
@click="choose(item)"
>
<WIcon :name="item.icon" :class="item.danger ? 'text-danger' : 'text-ink-muted'" />
{{ item.label }}
</button>
</div>
</div>
</template>
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { EventType, KnownMembership, type Room, type RoomMember } from 'matrix-js-sdk'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WDialog, WIcon, WIconButton, WTextArea, WTextField } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import SessionsDialog from '@/components/SessionsDialog.vue'
import { errorMessage, isUserId } from '@/lib/format'
import { client } from '@/matrix/client'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The side pane of a room: what it is, who is in it, and leaving it. On a
// phone it covers the room.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const emit = defineEmits<{ close: [] }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const state = computed(() => {
void chat.version
return props.room.currentState
})
const name = computed(() => {
void chat.version
return props.room.name
})
const topic = computed(() => String(state.value.getStateEvents(EventType.RoomTopic, '')?.getContent().topic ?? ''))
const alias = computed(() => {
void chat.version
return props.room.getCanonicalAlias()
})
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
})
const canEditName = computed(() => state.value.maySendStateEvent(EventType.RoomName, me))
const canEditTopic = computed(() => state.value.maySendStateEvent(EventType.RoomTopic, me))
const canInvite = computed(() => {
void chat.version
return props.room.canInvite(me)
})
function roleOf(m: RoomMember): string | undefined {
if (m.powerLevel >= 100) return 'Admin'
if (m.powerLevel >= 50) return 'Moderator'
return undefined
}
const members = computed(() => {
void chat.version
const sort = (a: RoomMember, b: RoomMember) => b.powerLevel - a.powerLevel || a.name.localeCompare(b.name)
return {
joined: props.room.getMembersWithMembership(KnownMembership.Join).sort(sort),
invited: props.room.getMembersWithMembership(KnownMembership.Invite).sort(sort),
}
})
// The full member list isn't in the first sync (lazy loading); fetch it
// when the pane opens.
props.room.loadMembersIfNeeded().then(() => chat.bump()).catch(() => undefined)
// Editing name and topic.
const editing = ref(false)
const draftName = ref('')
const draftTopic = ref('')
const saving = ref(false)
const saveError = ref('')
function startEdit() {
draftName.value = name.value
draftTopic.value = topic.value
saveError.value = ''
editing.value = true
}
async function save() {
saving.value = true
saveError.value = ''
try {
const c = client()
if (canEditName.value && draftName.value.trim() !== name.value) await c.setRoomName(props.room.roomId, draftName.value.trim())
if (canEditTopic.value && draftTopic.value.trim() !== topic.value) await c.setRoomTopic(props.room.roomId, draftTopic.value.trim())
editing.value = false
} catch (e) {
saveError.value = errorMessage(e)
} finally {
saving.value = false
}
}
// Inviting.
const inviteOpen = ref(false)
const inviteId = ref('')
const inviting = ref(false)
const inviteError = ref('')
watch(inviteOpen, (v) => {
if (v) {
inviteId.value = ''
inviteError.value = ''
}
})
async function invite() {
inviting.value = true
inviteError.value = ''
try {
await client().invite(props.room.roomId, inviteId.value.trim())
toasts.success(`Invited ${inviteId.value.trim()}.`)
inviteOpen.value = false
} catch (e) {
inviteError.value = errorMessage(e)
} finally {
inviting.value = false
}
}
// Leaving.
const leaveOpen = ref(false)
const leaving = ref(false)
const leaveError = ref('')
async function leave() {
leaving.value = true
leaveError.value = ''
try {
await chat.leave(props.room.roomId)
leaveOpen.value = false
router.replace('/')
} catch (e) {
leaveError.value = errorMessage(e)
} finally {
leaving.value = false
}
}
// Someone's sessions, to verify them.
const sessionsOf = ref<RoomMember | null>(null)
function copy(text: string) {
navigator.clipboard.writeText(text).then(() => toasts.success('Copied.'))
}
</script>
<template>
<aside
class="fixed inset-0 z-30 flex flex-col bg-surface-sunken md:static md:z-auto md:w-80 md:shrink-0 md:border-l md:border-line"
aria-label="Room info"
>
<div class="flex h-16 shrink-0 items-center gap-2 border-b border-line px-4">
<h2 class="subheading m-0 flex-1">{{ isDirect ? 'Chat info' : 'Room info' }}</h2>
<WIconButton icon="close" label="Close room info" @click="emit('close')" />
</div>
<div class="flex-1 overflow-y-auto">
<section class="flex flex-col items-center gap-2 px-4 pt-6 pb-4 text-center">
<MxcAvatar :name="name" :mxc="room.getMxcAvatarUrl() ?? undefined" :size="72" />
<h3 class="heading m-0 break-words">{{ name }}</h3>
<button v-if="alias" type="button" class="mxid text-ink-muted hover:text-ink" title="Copy address" @click="copy(alias)">
{{ alias }}
</button>
<p v-if="topic" class="body m-0 whitespace-pre-wrap text-ink-muted">{{ topic }}</p>
<p class="caption m-0 flex items-center gap-1" :class="encrypted ? 'text-teal-text' : 'text-ink-muted'">
<WIcon :name="encrypted ? 'lock' : 'shield-alert'" :size="14" />
{{ encrypted ? 'End-to-end encrypted' : 'Not encrypted' }}
</p>
<WButton v-if="canEditName || canEditTopic" size="sm" icon="edit" class="mt-2" @click="startEdit">Edit</WButton>
</section>
<section class="border-t border-line px-2 py-3">
<div class="flex items-center gap-2 px-2 pb-2">
<h3 class="body-strong m-0 flex-1">Members · {{ members.joined.length }}</h3>
<WButton v-if="canInvite" size="sm" icon="plus" @click="inviteOpen = true">Invite</WButton>
</div>
<ul class="m-0 list-none p-0">
<li v-for="m in members.joined" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}<span v-if="m.userId === me" class="font-normal text-ink-muted"> (you)</span></p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
<WBadge v-if="roleOf(m)" :tone="roleOf(m) === 'Admin' ? 'success' : 'neutral'">{{ roleOf(m) }}</WBadge>
<WIconButton
v-if="encrypted && chat.canEncrypt"
icon="shield-check"
:label="m.userId === me ? 'Your sessions' : `Verify ${m.name}’s sessions`"
@click="sessionsOf = m"
/>
</li>
</ul>
<template v-if="members.invited.length">
<h3 class="caption m-0 px-2 pt-3 pb-1 text-ink-muted">Invited</h3>
<ul class="m-0 list-none p-0">
<li v-for="m in members.invited" :key="m.userId" class="flex items-center gap-3 rounded-md px-2 py-1.5 opacity-80">
<MxcAvatar :name="m.name" :mxc="m.getMxcAvatarUrl() ?? undefined" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ m.name }}</p>
<p class="mxid m-0 truncate text-ink-muted">{{ m.userId }}</p>
</div>
</li>
</ul>
</template>
</section>
<section class="flex flex-col gap-2 border-t border-line p-4">
<button type="button" class="caption flex items-center gap-1 text-left text-ink-muted hover:text-ink" title="Copy room ID" @click="copy(room.roomId)">
<WIcon name="copy" :size="14" /> <span class="mxid break-all">{{ room.roomId }}</span>
</button>
<WButton variant="danger" icon="logout" @click="leaveOpen = true">{{ isDirect ? 'Leave chat' : 'Leave room' }}</WButton>
</section>
</div>
<WDialog v-model:open="editing" title="Edit room" :persistent="saving">
<form id="edit-room" class="flex flex-col gap-4" @submit.prevent="save">
<WTextField v-if="canEditName" v-model="draftName" wide label="Name" maxlength="255" />
<WTextArea v-if="canEditTopic" v-model="draftTopic" label="Topic" rows="3" />
<WBanner v-if="saveError" tone="danger">{{ saveError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="saving" @click="editing = false">Cancel</WButton>
<WButton type="submit" form="edit-room" variant="primary" :loading="saving">Save</WButton>
</template>
</WDialog>
<WDialog v-model:open="inviteOpen" title="Invite to room" :persistent="inviting">
<form id="invite-form" class="flex flex-col gap-4" @submit.prevent="invite">
<WTextField
v-model="inviteId"
wide
mono
label="Matrix ID"
placeholder="@maja:warp.example"
autocomplete="off"
spellcheck="false"
autofocus
/>
<WBanner v-if="inviteError" tone="danger">{{ inviteError }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="inviting" @click="inviteOpen = false">Cancel</WButton>
<WButton type="submit" form="invite-form" variant="primary" :loading="inviting" :disabled="!isUserId(inviteId)">Invite</WButton>
</template>
</WDialog>
<SessionsDialog
:open="sessionsOf !== null"
:user-id="sessionsOf?.userId ?? ''"
:name="sessionsOf?.name ?? ''"
@update:open="(v) => !v && (sessionsOf = null)"
/>
<ConfirmDialog
v-model:open="leaveOpen"
:title="isDirect ? 'Leave chat' : 'Leave room'"
:confirm-label="isDirect ? 'Leave chat' : 'Leave room'"
:busy="leaving"
:error="leaveError"
@confirm="leave"
>
<p>
You stop getting messages from <span class="body-strong">{{ name }}</span>.
<template v-if="room.getJoinRule() !== 'public'"> To come back, someone has to invite you again.</template>
</p>
</ConfirmDialog>
</aside>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { WRoomItem } from '@warp/ui'
import { useMedia } from '@/matrix/media'
// WRoomItem with the room's avatar loaded from the media repository.
const props = defineProps<{
name: string
avatarMxc?: string
preview?: string
time?: string
unread?: number
mention?: boolean
encrypted?: boolean
active?: boolean
presence?: 'online' | 'away' | 'busy' | 'offline'
}>()
const { url } = useMedia(() => props.avatarMxc, { width: 80, height: 80 })
</script>
<template>
<WRoomItem
:name="name"
:avatar="url"
:preview="preview"
:time="time"
:unread="unread"
:mention="mention"
:encrypted="encrypted"
:active="active"
:presence="presence"
/>
</template>
+259
View File
@@ -0,0 +1,259 @@
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { WButton, WIcon, WMessageBubble, WSpinner } from '@warp/ui'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageViewer from '@/components/ImageViewer.vue'
import MessageBody from '@/components/MessageBody.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import type { MenuItem } from '@/components/menu'
import { errorMessage, formatDateTime, formatDay, formatTime } from '@/lib/format'
import { client } from '@/matrix/client'
import type { MediaSource } from '@/matrix/media'
import { buildTimeline, canPaginateBack, lastSyncedEvent, type MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// The scrolling list of a room's messages. The scroller is laid out bottom
// up (column-reverse), so it starts at the newest message, stays there as
// messages arrive, and older history loading above doesn't move what you
// are reading.
const props = defineProps<{ room: Room; isDirect: boolean }>()
const chat = useChat()
const toasts = useToasts()
const me = client().getUserId() ?? ''
const scroller = ref<HTMLElement>()
const atBottom = ref(true)
const paginating = ref(false)
const paginateError = ref(false)
const viewing = ref<{ source: MediaSource; name: string } | null>(null)
const deleting = ref<MessageItem | null>(null)
const deleteBusy = ref(false)
const deleteError = ref('')
const items = computed(() => {
void chat.version
return buildTimeline(client(), props.room)
})
const hasMore = computed(() => {
void chat.version
return canPaginateBack(props.room)
})
// Distance from the oldest loaded message, given column-reverse's
// negative scrollTop.
function distanceFromTop(el: HTMLElement) {
return el.scrollHeight - el.clientHeight + el.scrollTop
}
async function paginate() {
if (paginating.value || !hasMore.value) return
paginating.value = true
paginateError.value = false
try {
await client().scrollback(props.room, 30)
} catch {
paginateError.value = true
} finally {
paginating.value = false
}
chat.bump()
// Keep going until the view is full or history runs out.
requestAnimationFrame(() => {
const el = scroller.value
if (el && !paginateError.value && distanceFromTop(el) < 400 && canPaginateBack(props.room)) paginate()
})
}
function onScroll() {
const el = scroller.value
if (!el) return
atBottom.value = el.scrollTop > -80
if (distanceFromTop(el) < 400) paginate()
if (atBottom.value) markRead()
}
function jumpToLatest() {
scroller.value?.scrollTo({ top: 0, behavior: 'smooth' })
}
// Read receipts: sent when you are looking at the newest message, with the
// tab in front.
let lastMarked = ''
function markRead() {
if (!atBottom.value || document.visibilityState !== 'visible' || !document.hasFocus()) return
const ev = lastSyncedEvent(props.room)
const id = ev?.getId()
if (!ev || !id || id === lastMarked) return
if (ev.getSender() === me && props.room.getUnreadNotificationCount() === 0) {
lastMarked = id
return
}
lastMarked = id
client()
.setRoomReadMarkers(props.room.roomId, id, ev)
.catch(() => (lastMarked = ''))
}
watch(items, () => requestAnimationFrame(markRead))
const onFocus = () => markRead()
onMounted(() => {
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
requestAnimationFrame(() => {
markRead()
const el = scroller.value
if (el && distanceFromTop(el) < 400) paginate()
})
})
onBeforeUnmount(() => {
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
})
// Message actions.
async function retry(item: MessageItem) {
try {
await client().resendEvent(item.event, props.room)
} catch (e) {
toasts.error(errorMessage(e))
}
}
function discard(item: MessageItem) {
client().cancelPendingEvent(item.event)
chat.bump()
}
const myPower = computed(() => {
void chat.version
return props.room.getMember(me)?.powerLevel ?? 0
})
const redactLevel = computed(() => {
void chat.version
const pl = props.room.currentState.getStateEvents('m.room.power_levels', '')?.getContent() ?? {}
return typeof pl.redact === 'number' ? pl.redact : 50
})
function actions(item: MessageItem): MenuItem[] {
const out: MenuItem[] = []
if (item.body && !item.media && !item.encrypted) {
out.push({
label: 'Copy text',
icon: 'copy',
action: () => navigator.clipboard.writeText(item.body).then(() => toasts.success('Copied.')),
})
}
if (item.eventId && (item.own || myPower.value >= redactLevel.value)) {
out.push({ label: 'Delete message', icon: 'trash', danger: true, action: () => askDelete(item) })
}
return out
}
function askDelete(item: MessageItem) {
deleteError.value = ''
deleting.value = item
}
async function confirmDelete() {
const item = deleting.value
if (!item?.eventId) return
deleteBusy.value = true
deleteError.value = ''
try {
await client().redactEvent(props.room.roomId, item.eventId)
deleting.value = null
} catch (e) {
deleteError.value = errorMessage(e)
} finally {
deleteBusy.value = false
}
}
</script>
<template>
<div class="relative min-h-0 flex-1">
<div ref="scroller" class="flex h-full flex-col-reverse overflow-y-auto" @scroll.passive="onScroll">
<div class="mx-auto flex w-full max-w-4xl flex-col px-4 pt-4 pb-2" role="log" aria-live="polite" aria-relevant="additions">
<div class="flex min-h-10 items-center justify-center py-2">
<WSpinner v-if="paginating" :size="18" label="Loading older messages" />
<WButton v-else-if="paginateError" size="sm" icon="refresh" @click="paginate">Load older messages</WButton>
<p v-else-if="!hasMore" class="caption text-ink-muted">This is the beginning of the room.</p>
</div>
<template v-for="item in items" :key="item.key">
<div v-if="item.kind === 'day'" class="my-3 flex items-center gap-3" role="separator">
<span class="h-px flex-1 bg-line" />
<span class="caption text-ink-muted">{{ formatDay(item.ts) }}</span>
<span class="h-px flex-1 bg-line" />
</div>
<p v-else-if="item.kind === 'notice'" class="caption my-1 text-center text-ink-muted">{{ item.text }}</p>
<div
v-else
:class="['group flex items-end gap-2', item.own ? 'flex-row-reverse' : '', item.first ? 'mt-4' : 'mt-2']"
>
<template v-if="!item.own && !isDirect">
<MxcAvatar v-if="item.last" :name="item.senderName" :mxc="item.avatarMxc" :size="32" class="mb-5" />
<span v-else class="w-8 shrink-0" />
</template>
<WMessageBubble
class="max-w-[min(520px,80%)] min-w-0"
:own="item.own"
:sender="item.first && !isDirect ? item.senderName : undefined"
:time="item.last || item.status === 'failed' ? formatTime(item.ts) : ''"
:edited="item.edited"
:status="item.status"
:title="formatDateTime(item.ts)"
>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed'" #meta>
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
</template>
</WMessageBubble>
<PopMenu
v-if="actions(item).length && item.status !== 'sending' && item.status !== 'failed'"
class="-my-2 self-center opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
icon="more"
label="Message actions"
:align="item.own ? 'right' : 'left'"
:items="actions(item)"
/>
</div>
</template>
</div>
</div>
<button
v-if="!atBottom"
type="button"
class="absolute right-4 bottom-4 flex h-10 items-center gap-2 rounded-full border border-line-strong bg-surface-raised px-4 font-semibold text-ink shadow-float hover:bg-surface-sunken"
@click="jumpToLatest"
>
<WIcon name="arrow-down" :size="18" />
Latest
</button>
<ImageViewer v-if="viewing" :source="viewing.source" :name="viewing.name" @close="viewing = null" />
<ConfirmDialog
:open="deleting !== null"
title="Delete message"
confirm-label="Delete message"
:busy="deleteBusy"
:error="deleteError"
@update:open="(v) => !v && (deleting = null)"
@confirm="confirmDelete"
>
<p>The message is removed for everyone in the room. This can’t be undone.</p>
</ConfirmDialog>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBadge, WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { sessionsOf, type SessionInfo } from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
// Someone's sessions, as their published keys list them, and whether this
// session has verified each. Verifying one compares emoji with it.
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ userId: string; name: string }>()
const security = useSecurity()
const sessions = ref<SessionInfo[] | null>(null)
const error = ref('')
const starting = ref('')
const mine = computed(() => props.userId === client().getUserId())
async function load() {
error.value = ''
try {
sessions.value = await sessionsOf(props.userId)
} catch (e) {
error.value = errorMessage(e)
}
}
watch(open, (v) => {
if (!v) return
sessions.value = null
void load()
})
// Trust changes (a verification just finished) show at once.
watch(
() => security.version,
() => open.value && void load(),
)
async function verify(s: SessionInfo) {
starting.value = s.deviceId
error.value = ''
try {
await security.verifySession(props.userId, s.deviceId)
open.value = false
} catch (e) {
error.value = errorMessage(e)
} finally {
starting.value = ''
}
}
</script>
<template>
<WDialog v-model:open="open" :title="mine ? 'Your sessions' : `${name}’s sessions`">
<p v-if="mine" class="small m-0 text-ink-muted">
Each place you’re signed in has its own keys. Verify your other sessions so they trust each other and share message keys.
</p>
<p v-else class="small m-0 text-ink-muted">
Each place <span class="mxid">{{ userId }}</span> is signed in has its own keys. Verify a session to be sure messages from it are
really theirs: you’ll both compare seven emoji, so do it together, in person or on a call.
</p>
<div v-if="!sessions && !error" class="flex justify-center py-4"><WSpinner /></div>
<p v-else-if="sessions && !sessions.length" class="body m-0 text-ink-muted">
No sessions with encryption keys. They can’t read end-to-end encrypted messages until they sign in with a client that supports it.
</p>
<ul v-else-if="sessions" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="s in sessions" :key="s.deviceId" class="flex items-center gap-3 rounded-md px-2 py-2">
<WIcon :name="s.verified ? 'shield-check' : 'shield-alert'" :class="s.verified ? 'text-teal-text' : 'text-ink-muted'" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0 truncate">{{ s.name || 'Unnamed session' }}<span v-if="s.own" class="font-normal text-ink-muted"> (this session)</span></p>
<p class="mxid m-0 truncate text-ink-muted">{{ s.deviceId }}</p>
</div>
<WBadge v-if="s.verified" tone="success">Verified</WBadge>
<WButton v-else-if="!s.own" size="sm" :loading="starting === s.deviceId" :disabled="!!starting" @click="verify(s)">Verify session</WButton>
<WBadge v-else tone="neutral">Not verified</WBadge>
</li>
</ul>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<template #actions>
<WButton variant="ghost" @click="open = false">Close</WButton>
</template>
</WDialog>
</template>
+195
View File
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WDialog, WIcon, WTextField, useTheme, type IconName, type ThemeChoice } from '@warp/ui'
import EncryptionDialog, { type EncryptionMode } from '@/components/EncryptionDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import SessionsDialog from '@/components/SessionsDialog.vue'
import { config } from '@/config'
import { errorMessage, localpart } from '@/lib/format'
import { cryptoError } from '@/matrix/client'
import { useProfile } from '@/stores/profile'
import { useSecurity } from '@/stores/security'
import { useSession } from '@/stores/session'
import { useToasts } from '@/stores/toasts'
const open = defineModel<boolean>('open', { default: false })
const session = useSession()
const profile = useProfile()
const toasts = useToasts()
const router = useRouter()
const theme = useTheme()
const security = useSecurity()
const name = ref('')
const savingName = ref(false)
const avatarBusy = ref(false)
const error = ref('')
const picker = ref<HTMLInputElement>()
const signingOut = ref(false)
watch(open, (v) => {
if (v) {
name.value = profile.displayName
error.value = ''
void security.refresh()
}
})
// Encryption.
const ownName = computed(() => profile.displayName || localpart(session.userId))
const enc = computed(() => security.status)
const encMode = ref<EncryptionMode>('setup')
const encOpen = ref(false)
const sessionsOpen = ref(false)
function openEnc(mode: EncryptionMode) {
encMode.value = mode
encOpen.value = true
}
async function saveName() {
savingName.value = true
error.value = ''
try {
await profile.setDisplayName(name.value)
toasts.success('Display name saved.')
} catch (e) {
error.value = errorMessage(e)
} finally {
savingName.value = false
}
}
async function onAvatar() {
const file = picker.value?.files?.[0]
if (picker.value) picker.value.value = ''
if (!file) return
if (!file.type.startsWith('image/')) {
error.value = 'Choose an image for your avatar.'
return
}
avatarBusy.value = true
error.value = ''
try {
await profile.setAvatar(file)
} catch (e) {
error.value = errorMessage(e)
} finally {
avatarBusy.value = false
}
}
async function removeAvatar() {
avatarBusy.value = true
try {
await profile.removeAvatar()
} catch (e) {
error.value = errorMessage(e)
} finally {
avatarBusy.value = false
}
}
async function signOut() {
signingOut.value = true
await session.signOut()
signingOut.value = false
open.value = false
router.replace({ name: 'login' })
}
const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
{ value: 'light', label: 'Light', icon: 'sun' },
{ value: 'dark', label: 'Dark', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'settings' },
]
</script>
<template>
<WDialog v-model:open="open" title="Settings">
<section class="flex items-center gap-4">
<MxcAvatar :name="profile.displayName || localpart(session.userId)" :mxc="profile.avatarMxc" :size="64" />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="image" :loading="avatarBusy" @click="picker?.click()">Change avatar</WButton>
<WButton v-if="profile.avatarMxc" size="sm" variant="ghost" :disabled="avatarBusy" @click="removeAvatar">Remove</WButton>
</div>
<p class="mxid m-0 text-ink-muted">{{ session.userId }}</p>
</div>
<input ref="picker" type="file" accept="image/*" class="wp-sr" tabindex="-1" aria-hidden="true" @change="onAvatar" />
</section>
<form class="flex items-end gap-2" @submit.prevent="saveName">
<WTextField v-model="name" class="flex-1" wide label="Display name" maxlength="256" />
<WButton type="submit" :loading="savingName" :disabled="name.trim() === profile.displayName">Save</WButton>
</form>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<section class="flex flex-col gap-2">
<p class="body-strong m-0">Theme</p>
<div class="wp-segmented self-start" role="group" aria-label="Theme">
<button v-for="t in themes" :key="t.value" type="button" :aria-pressed="theme === t.value" @click="theme = t.value">
<WIcon :name="t.icon" :size="16" />
{{ t.label }}
</button>
</div>
</section>
<section class="flex flex-col gap-3 border-t border-line pt-4">
<p class="body-strong m-0">Encryption</p>
<WBanner v-if="cryptoError()" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send end-to-end encrypted messages. Reload the page to try again. ({{ cryptoError() }})
</WBanner>
<p v-else-if="!enc" class="small m-0 text-ink-muted">Checking…</p>
<template v-else>
<!-- This session -->
<div class="flex items-start gap-3">
<WIcon :name="enc.sessionVerified ? 'shield-check' : 'shield-alert'" :class="['mt-[2px] shrink-0', enc.sessionVerified ? 'text-teal-text' : 'text-warning']" />
<div class="min-w-0 flex-1">
<p class="body m-0">{{ enc.sessionVerified ? 'This session is verified' : 'This session isn’t verified' }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="!enc.crossSigning">Set up encryption to verify your sessions and back up your message keys.</template>
<template v-else-if="!enc.sessionVerified">Verify it to read your older encrypted messages here, and so others can trust it.</template>
<template v-else>Your other sessions and the people who verified you trust it.</template>
</p>
</div>
<WButton v-if="!enc.crossSigning" size="sm" variant="primary" icon="key" @click="openEnc('setup')">Set up encryption</WButton>
<WButton v-else-if="!enc.sessionVerified" size="sm" variant="primary" icon="shield-check" @click="openEnc('unlock')">Verify session</WButton>
</div>
<!-- Key backup -->
<div v-if="enc.crossSigning" class="flex items-start gap-3">
<WIcon :name="enc.backupActive ? 'shield-check' : 'shield-alert'" :class="['mt-[2px] shrink-0', enc.backupActive ? 'text-teal-text' : 'text-ink-muted']" />
<div class="min-w-0 flex-1">
<p class="body m-0">{{ enc.backupActive ? 'Key backup is on' : enc.backupVersion ? 'Key backup isn’t used here' : 'No key backup' }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="enc.backupActive">Your message keys are kept on the homeserver, locked with your recovery key.</template>
<template v-else-if="enc.backupVersion">Verify this session to use your key backup.</template>
<template v-else>Without one, a new session can’t read messages sent before it signed in.</template>
</p>
</div>
<WButton v-if="enc.backupKey && enc.backupVersion" size="sm" icon="history" @click="openEnc('restore')">Restore</WButton>
<WButton v-else-if="!enc.backupVersion && enc.sessionVerified" size="sm" icon="key" @click="openEnc('backup')">Set up key backup</WButton>
</div>
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="shield-check" @click="sessionsOpen = true">Your sessions</WButton>
<WButton v-if="enc.crossSigning" size="sm" variant="ghost" @click="openEnc('reset')">Reset encryption</WButton>
</div>
</template>
</section>
<section class="flex flex-col gap-1 border-t border-line pt-4">
<p class="body-strong m-0">This session</p>
<p class="small m-0 text-ink-muted">Session ID <span class="mxid text-ink">{{ session.deviceId }}</span></p>
<p class="small m-0 text-ink-muted">Warp web {{ config.version }}</p>
</section>
<EncryptionDialog v-model:open="encOpen" :mode="encMode" />
<SessionsDialog v-model:open="sessionsOpen" :user-id="session.userId" :name="ownName" />
<template #actions>
<WButton variant="danger" icon="logout" :loading="signingOut" @click="signOut">Sign out</WButton>
</template>
</WDialog>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WTextField } from '@warp/ui'
import { errorMessage, isUserId, toUserId } from '@/lib/format'
import { useChat } from '@/stores/chat'
import { useSession } from '@/stores/session'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ started: [roomId: string] }>()
const chat = useChat()
const session = useSession()
const who = ref('')
const busy = ref(false)
const error = ref('')
watch(open, (v) => {
if (v) {
who.value = ''
error.value = ''
}
})
async function submit() {
const userId = toUserId(who.value, session.serverName)
if (!isUserId(userId)) return
if (userId === session.userId) {
error.value = 'That’s your own Matrix ID.'
return
}
busy.value = true
error.value = ''
try {
const roomId = await chat.startDirect(userId)
open.value = false
emit('started', roomId)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<WDialog v-model:open="open" title="Start chat" :persistent="busy">
<form id="start-chat" class="flex flex-col gap-4" @submit.prevent="submit">
<WTextField
v-model="who"
wide
mono
label="Who"
:hint="`A username on ${session.serverName}, or a full Matrix ID like @maja:warp.example`"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="start-chat" variant="primary" :loading="busy" :disabled="!who.trim()">Start chat</WButton>
</template>
</WDialog>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { WIcon, WIconButton } from '@warp/ui'
import { useToasts } from '@/stores/toasts'
const toasts = useToasts()
const icon = { success: 'shield-check', danger: 'shield-alert', info: 'server' } as const
const tone = {
success: 'bg-teal-soft text-teal-text',
danger: 'bg-danger-soft text-danger',
info: 'bg-info-soft text-info',
} as const
</script>
<template>
<div
class="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex flex-col items-end gap-2 sm:left-auto sm:w-96"
aria-live="polite"
>
<div
v-for="t in toasts.items"
:key="t.id"
:class="[
'pointer-events-auto flex w-full items-center gap-3 rounded-md border border-line py-2 pr-1 pl-4 shadow-float',
tone[t.tone],
]"
:role="t.tone === 'danger' ? 'alert' : 'status'"
>
<WIcon :name="icon[t.tone]" />
<p class="flex-1 text-ink">{{ t.text }}</p>
<WIconButton icon="close" label="Dismiss" @click="toasts.dismiss(t.id)" />
</div>
</div>
</template>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import {
VerificationPhase,
VerifierEvent,
type ShowSasCallbacks,
type Verifier,
} from 'matrix-js-sdk/lib/crypto-api'
import { computed, ref, shallowRef, watch } from 'vue'
import { WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { useSecurity } from '@/stores/security'
// One verification, from the request to the emoji to "Verified": either
// this session asked (Verify session) or someone else did. Both sides see
// the same seven emoji when nobody is in the middle.
const security = useSecurity()
const req = computed(() => {
void security.version
return security.request
})
const phase = computed(() => {
void security.version
return security.request?.phase
})
const open = computed({
get: () => req.value !== null,
set: (v) => {
if (!v) security.dismiss()
},
})
const sas = shallowRef<ShowSasCallbacks | null>(null)
const confirmed = ref(false)
const busy = ref(false)
const error = ref('')
let verifier: Verifier | null = null
function reset() {
sas.value = null
confirmed.value = false
busy.value = false
error.value = ''
verifier = null
}
watch(req, reset)
const otherName = computed(() => {
const r = req.value
if (!r) return ''
if (r.isSelfVerification) return 'your other session'
const c = client()
for (const room of c.getRooms()) {
const m = room.getMember(r.otherUserId)
if (m) return m.name
}
return r.otherUserId
})
// Runs the verifier once there is one: the initiator starts emoji
// verification as soon as the other side is ready.
watch(
[req, phase],
async () => {
const r = req.value
if (!r) return
if (r.phase === VerificationPhase.Ready && r.initiatedByMe && !r.verifier && !busy.value) {
busy.value = true
try {
await r.startVerification('m.sas.v1')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
const v = r.verifier
if (v && v !== verifier) {
verifier = v
v.on(VerifierEvent.ShowSas, (cb) => (sas.value = cb))
if (v.getShowSasCallbacks()) sas.value = v.getShowSasCallbacks()
v.verify().catch(() => undefined)
}
},
{ immediate: true },
)
async function accept() {
busy.value = true
error.value = ''
try {
await req.value?.accept()
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
async function match() {
busy.value = true
error.value = ''
try {
confirmed.value = true
await sas.value?.confirm()
} catch (e) {
confirmed.value = false
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
function mismatch() {
sas.value?.mismatch()
}
const cancelText = computed(() => {
const r = req.value
if (!r) return ''
switch (r.cancellationCode) {
case 'm.mismatch_sas':
case 'm.key_mismatch':
case 'm.mismatched_sas':
return 'The emoji didn’t match, so the session isn’t verified. Someone may be listening in: check with them another way before you trust it.'
case 'm.timeout':
return 'The verification timed out. Start it again when you’re both ready.'
case 'm.accepted':
return 'Another of your sessions answered the request.'
default:
return r.cancellingUserId === client().getUserId() ? 'You cancelled the verification.' : `${otherName.value} cancelled the verification.`
}
})
const title = computed(() => (req.value?.isSelfVerification ? 'Verify this session' : 'Verify session'))
</script>
<template>
<WDialog v-model:open="open" :title="title">
<template v-if="req">
<!-- Waiting for the other side -->
<div v-if="phase === VerificationPhase.Requested && req.initiatedByMe" class="flex flex-col items-center gap-4 py-2 text-center">
<WSpinner :size="28" />
<p v-if="req.isSelfVerification" class="body m-0">
Open Warp or another Matrix client where you’re already signed in, and accept the request there.
</p>
<p v-else class="body m-0">Waiting for <span class="body-strong">{{ otherName }}</span> to accept.</p>
</div>
<!-- Somebody asks -->
<div v-else-if="phase === VerificationPhase.Requested" class="flex flex-col gap-3">
<p class="body m-0 flex items-start gap-2">
<WIcon name="shield-alert" class="mt-[2px] shrink-0 text-ink-muted" />
<span v-if="req.isSelfVerification">
Another of your sessions wants to verify this one<template v-if="req.otherDeviceId">
(<span class="mxid">{{ req.otherDeviceId }}</span>)</template>.
</span>
<span v-else>
<span class="body-strong">{{ otherName }}</span><template v-if="otherName !== req.otherUserId">
(<span class="mxid">{{ req.otherUserId }}</span>)</template> wants to verify your session.
</span>
</p>
<p class="small m-0 text-ink-muted">You’ll both compare seven emoji. Accept only if you expected this.</p>
</div>
<!-- Ready, about to compare -->
<div v-else-if="(phase === VerificationPhase.Ready || phase === VerificationPhase.Started) && !sas" class="flex flex-col items-center gap-4 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">Getting the emoji ready…</p>
</div>
<!-- Compare the emoji -->
<div v-else-if="phase === VerificationPhase.Started && sas" class="flex flex-col gap-4">
<p class="body m-0">
Check that <span class="body-strong">{{ otherName }}</span> sees the same emoji, in the same order.
</p>
<ol class="m-0 grid list-none grid-cols-4 gap-3 p-0 sm:grid-cols-7">
<li v-for="([emoji, name], i) in sas.sas.emoji ?? []" :key="i" class="flex flex-col items-center gap-1 text-center">
<span class="text-[32px] leading-none" aria-hidden="true">{{ emoji }}</span>
<span class="caption text-ink-muted">{{ name }}</span>
</li>
</ol>
<p v-if="!sas.sas.emoji && sas.sas.decimal" class="mxid m-0 text-center text-[24px]">{{ sas.sas.decimal.join(' ') }}</p>
<p v-if="confirmed" class="small m-0 flex items-center gap-2 text-ink-muted">
<WSpinner :size="14" /> Waiting for {{ otherName }} to confirm…
</p>
</div>
<!-- Done -->
<div v-else-if="phase === VerificationPhase.Done" class="flex flex-col items-center gap-3 py-2 text-center">
<WIcon name="shield-check" :size="40" class="text-teal-text" />
<p class="body-strong m-0">Verified</p>
<p class="body m-0 text-ink-muted">
<template v-if="req.isSelfVerification">This session is verified. Your other sessions trust it, and it can read your encrypted messages.</template>
<template v-else>
You verified {{ otherName }}’s session<template v-if="req.otherDeviceId"> <span class="mxid">{{ req.otherDeviceId }}</span></template>.
</template>
</p>
</div>
<!-- Cancelled -->
<WBanner v-else-if="phase === VerificationPhase.Cancelled" tone="warning" title="Not verified">{{ cancelText }}</WBanner>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</template>
<template #actions>
<template v-if="req && phase === VerificationPhase.Requested && !req.initiatedByMe">
<WButton variant="ghost" :disabled="busy" @click="open = false">Decline</WButton>
<WButton variant="primary" :loading="busy" @click="accept">Accept</WButton>
</template>
<template v-else-if="phase === VerificationPhase.Started && sas && !confirmed">
<WButton variant="danger" :disabled="busy" @click="mismatch">They don’t match</WButton>
<WButton variant="primary" icon="check" :loading="busy" @click="match">They match</WButton>
</template>
<WButton v-else-if="phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled" variant="primary" @click="open = false">
Done
</WButton>
<WButton v-else variant="ghost" @click="open = false">Cancel</WButton>
</template>
</WDialog>
</template>
+8
View File
@@ -0,0 +1,8 @@
import type { IconName } from '@warp/ui'
export interface MenuItem {
label: string
icon: IconName
danger?: boolean
action: () => void
}
+25
View File
@@ -0,0 +1,25 @@
// Runtime configuration from warp-web's /config.json. In development there
// is none, and the defaults (same origin, through Vite's proxy) apply.
export interface Config {
/** Where Matrix requests go; the page's own origin when warp-web proxies. */
homeserverUrl: string
version: string
}
export const config: Config = {
homeserverUrl: window.location.origin,
version: 'dev',
}
export async function loadConfig() {
try {
const res = await fetch('/config.json', { cache: 'no-cache', credentials: 'omit' })
if (!res.ok) return
const data = (await res.json()) as { homeserver_url?: string; version?: string }
if (data.homeserver_url) config.homeserverUrl = data.homeserver_url.replace(/\/+$/, '')
if (data.version) config.version = data.version
} catch {
// No config: keep the defaults.
}
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
public?: boolean
}
}
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { WLogo } from '@warp/ui'
import { config } from '@/config'
const host = new URL(config.homeserverUrl).host
</script>
<template>
<div class="flex min-h-dvh items-center justify-center bg-surface-sunken px-4 py-12">
<div class="w-full max-w-[400px]">
<div class="mb-8 flex justify-center text-ink">
<WLogo :height="40" />
</div>
<slot />
<p class="small mt-6 flex items-center justify-center gap-1 text-ink-muted">
Homeserver <span class="mxid">{{ host }}</span>
</p>
</div>
</div>
</template>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WIcon, WIconButton, WLogo, WSpinner } from '@warp/ui'
import CreateRoomDialog from '@/components/CreateRoomDialog.vue'
import EncryptionDialog from '@/components/EncryptionDialog.vue'
import JoinRoomDialog from '@/components/JoinRoomDialog.vue'
import MxcAvatar from '@/components/MxcAvatar.vue'
import PopMenu from '@/components/PopMenu.vue'
import RoomRow from '@/components/RoomRow.vue'
import type { MenuItem } from '@/components/menu'
import SettingsDialog from '@/components/SettingsDialog.vue'
import StartChatDialog from '@/components/StartChatDialog.vue'
import VerificationDialog from '@/components/VerificationDialog.vue'
import { formatListTime, localpart } from '@/lib/format'
import { useChat } from '@/stores/chat'
import { useProfile } from '@/stores/profile'
import { useSecurity } from '@/stores/security'
import { useSession } from '@/stores/session'
const session = useSession()
const chat = useChat()
const profile = useProfile()
const security = useSecurity()
const route = useRoute()
const router = useRouter()
const startError = ref('')
const filter = ref('')
const dialog = ref<'create' | 'chat' | 'join' | 'settings' | 'verify' | null>(null)
// The account has encryption keys but this session isn't signed by them:
// it can't read older messages and others see it as unverified.
const unverified = computed(() => Boolean(security.status?.crossSigning && !security.status.sessionVerified))
async function start() {
startError.value = ''
try {
await session.restore()
if (session.phase === 'ready') {
chat.attach()
security.attach()
profile.load()
}
} catch (e) {
startError.value = e instanceof Error ? e.message : 'Can’t start the client.'
}
}
onMounted(start)
const activeRoomId = computed(() => (route.name === 'room' ? String(route.params.roomId) : ''))
const shownRooms = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return chat.rooms
return chat.rooms.filter((r) => r.name.toLowerCase().includes(q) || r.roomId.toLowerCase().includes(q))
})
const shownInvites = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return chat.invites
return chat.invites.filter((r) => r.name.toLowerCase().includes(q))
})
const menu: MenuItem[] = [
{ label: 'New room', icon: 'plus', action: () => (dialog.value = 'create') },
{ label: 'Start chat', icon: 'user', action: () => (dialog.value = 'chat') },
{ label: 'Join room', icon: 'room', action: () => (dialog.value = 'join') },
]
function open(roomId: string) {
router.push({ name: 'room', params: { roomId } })
}
// The tab title counts rooms with unread messages, and invites.
watchEffect(() => {
const n = chat.totalUnread
const room = chat.rooms.find((r) => r.roomId === activeRoomId.value)
const name = room?.name ?? chat.invites.find((r) => r.roomId === activeRoomId.value)?.name
document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · ` : ''}Warp`
})
const ownName = computed(() => profile.displayName || localpart(session.userId))
</script>
<template>
<!-- Starting: restoring the session and the first sync -->
<div v-if="startError" class="flex min-h-dvh items-center justify-center bg-surface-sunken px-4">
<div class="flex w-full max-w-[420px] flex-col gap-4">
<WBanner tone="danger" title="Can’t connect to the homeserver">{{ startError }}</WBanner>
<div class="flex gap-2">
<WButton variant="primary" icon="refresh" @click="start">Try again</WButton>
<WButton variant="ghost" icon="logout" @click="session.signOut()">Sign out</WButton>
</div>
</div>
</div>
<div v-else-if="session.phase !== 'ready' || !chat.ready" class="flex min-h-dvh flex-col items-center justify-center gap-6 bg-surface-sunken">
<WLogo variant="symbol" :height="56" />
<div class="flex items-center gap-3 text-ink-muted">
<WSpinner :size="18" />
<span class="body">Loading your rooms</span>
</div>
</div>
<div v-else class="flex h-dvh overflow-hidden bg-surface">
<!-- Room list. On a phone it is the home screen, hidden while a room is open. -->
<aside
:class="[
'flex w-full shrink-0 flex-col border-r border-line bg-surface-sunken md:w-80',
activeRoomId ? 'hidden md:flex' : 'flex',
]"
aria-label="Rooms"
>
<div class="flex h-16 shrink-0 items-center gap-1 pr-2 pl-4">
<RouterLink to="/" class="mr-auto text-ink" aria-label="Warp, home">
<WLogo :height="28" />
</RouterLink>
<PopMenu icon="plus" label="New" :items="menu" />
</div>
<div class="px-3 pb-2">
<label class="flex h-10 items-center gap-2 rounded-sm border border-line-strong bg-surface-raised px-3 focus-within:border-teal-strong focus-within:shadow-focus">
<WIcon name="search" :size="18" class="text-ink-muted" />
<span class="wp-sr">Filter rooms</span>
<input
v-model="filter"
type="search"
placeholder="Filter rooms"
class="min-w-0 flex-1 border-0 bg-transparent text-ink outline-none placeholder:text-ink-muted"
/>
</label>
</div>
<WBanner v-if="chat.offline" tone="warning" class="mx-3 mb-2" title="Offline">
Can’t reach the homeserver. Messages will send when the connection is back.
</WBanner>
<WBanner v-if="unverified" tone="warning" class="mx-3 mb-2" title="Verify this session">
Use another session or your recovery key to read your encrypted messages here.
<button type="button" class="font-semibold text-teal-text hover:underline" @click="dialog = 'verify'">Verify session</button>
</WBanner>
<nav class="flex-1 overflow-y-auto px-2 pb-3">
<template v-if="shownInvites.length">
<h2 class="caption px-3 pt-2 pb-1 text-ink-muted">Invites</h2>
<RoomRow
v-for="inv in shownInvites"
:key="inv.roomId"
:name="inv.name"
:avatar-mxc="inv.avatarMxc"
:preview="inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited'"
:time="formatListTime(inv.ts)"
:unread="1"
mention
:active="inv.roomId === activeRoomId"
@click="open(inv.roomId)"
/>
<h2 class="caption px-3 pt-3 pb-1 text-ink-muted">Rooms</h2>
</template>
<RoomRow
v-for="r in shownRooms"
:key="r.roomId"
:name="r.name"
:avatar-mxc="r.avatarMxc"
:preview="r.preview"
:time="formatListTime(r.ts)"
:unread="r.unread"
:mention="r.highlight > 0"
:encrypted="r.encrypted"
:presence="r.presence"
:active="r.roomId === activeRoomId"
@click="open(r.roomId)"
/>
<div v-if="!shownRooms.length && !shownInvites.length" class="flex flex-col items-center gap-3 px-4 py-10 text-center">
<template v-if="filter">
<p class="body text-ink-muted">No rooms match “{{ filter }}”.</p>
</template>
<template v-else>
<p class="body-strong">No rooms yet</p>
<p class="body text-ink-muted">Create a room, start a chat with someone, or join a room by its address.</p>
<WButton variant="primary" icon="plus" @click="dialog = 'create'">Create room</WButton>
</template>
</div>
</nav>
<div class="flex shrink-0 items-center gap-3 border-t border-line px-4 py-3">
<MxcAvatar :name="ownName" :mxc="profile.avatarMxc" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong truncate">{{ ownName }}</p>
<p class="mxid truncate text-ink-muted">{{ session.userId }}</p>
</div>
<WIconButton icon="settings" label="Settings" @click="dialog = 'settings'" />
</div>
</aside>
<main :class="['min-w-0 flex-1 flex-col', activeRoomId ? 'flex' : 'hidden md:flex']">
<RouterView :key="activeRoomId" />
</main>
<CreateRoomDialog :open="dialog === 'create'" @update:open="dialog = $event ? 'create' : null" @created="open" />
<StartChatDialog :open="dialog === 'chat'" @update:open="dialog = $event ? 'chat' : null" @started="open" />
<JoinRoomDialog :open="dialog === 'join'" @update:open="dialog = $event ? 'join' : null" @joined="open" />
<SettingsDialog :open="dialog === 'settings'" @update:open="dialog = $event ? 'settings' : null" />
<EncryptionDialog mode="unlock" :open="dialog === 'verify'" @update:open="dialog = $event ? 'verify' : null" />
<VerificationDialog />
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
// Unsent composer text per room. It survives switching rooms, not reloads.
export const drafts = new Map<string, string>()
+93
View File
@@ -0,0 +1,93 @@
import { MatrixError } from 'matrix-js-sdk'
const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'short' })
const dayMonth = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' })
const fullDay = new Intl.DateTimeFormat(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
function startOfDay(ms: number): number {
const d = new Date(ms)
d.setHours(0, 0, 0, 0)
return d.getTime()
}
/** Message time: always the clock time. */
export function formatTime(ms: number): string {
return ms ? time.format(ms) : ''
}
/** Room list time: "14:32" today, weekday this week, "12 Oct" older. */
export function formatListTime(ms: number, now = Date.now()): string {
if (!ms) return ''
const today = startOfDay(now)
if (ms >= today) return time.format(ms)
if (ms >= today - 6 * 86_400_000) return weekday.format(ms)
return dayMonth.format(ms)
}
/** Day separator in the timeline: "Today", "Yesterday", or the full date. */
export function formatDay(ms: number, now = Date.now()): string {
const day = startOfDay(ms)
const today = startOfDay(now)
if (day === today) return 'Today'
if (day === today - 86_400_000) return 'Yesterday'
return fullDay.format(ms)
}
export function formatDateTime(ms: number): string {
return ms ? dateTime.format(ms) : ''
}
export function sameDay(a: number, b: number): boolean {
return startOfDay(a) === startOfDay(b)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
export function formatBytes(n: number | undefined): string {
if (n === undefined || !Number.isFinite(n)) return ''
if (n < 1024) return `${n} B`
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
/** What the person reads when a request fails: the server's words if any. */
export function errorMessage(e: unknown): string {
if (e instanceof MatrixError) {
if (e.errcode === 'M_LIMIT_EXCEEDED') return 'Too many requests. Wait a moment and try again.'
if (e.data?.error) return e.data.error
if (e.httpStatus === 502 || e.httpStatus === 504) return 'Can’t reach the homeserver.'
return e.message
}
if (e instanceof TypeError) return 'Can’t reach the homeserver. Check your connection and try again.'
if (e instanceof Error) return e.message
return 'Something went wrong.'
}
/** The localpart of a Matrix ID, for avatars and short labels. */
export function localpart(mxid: string): string {
return mxid.replace(/^[@#!]/, '').split(':')[0] ?? mxid
}
/** The server part of a Matrix ID. */
export function serverPart(mxid: string): string {
const i = mxid.indexOf(':')
return i < 0 ? '' : mxid.slice(i + 1)
}
/** Turns "alice" into "@alice:server" when a full ID is needed. */
export function toUserId(input: string, server: string): string {
const v = input.trim()
if (v.startsWith('@')) return v
return `@${v}:${server}`
}
export function isUserId(v: string): boolean {
return /^@[^:\s]+:[^\s]+$/.test(v.trim())
}
+29
View File
@@ -0,0 +1,29 @@
// Splits message text into plain runs and web links, so links can be
// rendered as anchors without ever putting the text through v-html.
export type Segment = { kind: 'text'; text: string } | { kind: 'link'; text: string; href: string }
const pattern = /\bhttps?:\/\/[^\s<>"']+/gi
// Punctuation that usually ends the sentence, not the URL.
const trailing = /[.,;:!?)\]}'"]+$/
export function linkify(text: string): Segment[] {
const out: Segment[] = []
let last = 0
for (const m of text.matchAll(pattern)) {
let url = m[0]
const start = m.index ?? 0
const cut = url.match(trailing)
if (cut) {
// Keep a closing parenthesis that has its opening one inside the URL.
let tail = cut[0]
if (tail.startsWith(')') && url.includes('(')) tail = tail.slice(1)
url = url.slice(0, url.length - tail.length)
}
if (start > last) out.push({ kind: 'text', text: text.slice(last, start) })
out.push({ kind: 'link', text: url, href: url })
last = start + url.length
}
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
return out
}
+15
View File
@@ -0,0 +1,15 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import { loadConfig } from './config'
import { forgetEndedSessions } from './matrix/client'
import { router } from './router'
import './style.css'
// The runtime configuration decides where the homeserver is, so it is read
// before anything talks to it.
loadConfig().then(() => {
// Encryption keys of sessions that ended while their store was open.
void forgetEndedSessions()
createApp(App).use(createPinia()).use(router).mount('#app')
})
+66
View File
@@ -0,0 +1,66 @@
// Files in end-to-end encrypted rooms are encrypted before they are
// uploaded: AES-CTR with a fresh key per file, the key, counter and the
// ciphertext's SHA-256 travelling inside the (encrypted) message, as the
// spec's "Sending encrypted attachments" describes. The homeserver only
// ever holds ciphertext, and can't make thumbnails of it.
export interface EncryptedFile {
url: string
key: { kty: 'oct'; key_ops: string[]; alg: 'A256CTR'; k: string; ext: true }
iv: string
hashes: { sha256: string }
v: 'v2'
}
function toBase64(bytes: Uint8Array): string {
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s).replace(/=+$/, '')
}
function fromBase64(s: string): Uint8Array<ArrayBuffer> {
// Unpadded, and tolerant of the URL-safe alphabet some clients send.
const std = s.replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(std + '='.repeat((4 - (std.length % 4)) % 4))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
export async function encryptAttachment(data: ArrayBuffer): Promise<{ data: ArrayBuffer; file: Omit<EncryptedFile, 'url'> }> {
const subtle = globalThis.crypto.subtle
const key = await subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt'])
// A random 64-bit nonce and a 64-bit counter starting at zero.
const iv = new Uint8Array(16)
globalThis.crypto.getRandomValues(iv.subarray(0, 8))
const ciphertext = await subtle.encrypt({ name: 'AES-CTR', counter: iv, length: 64 }, key, data)
const hash = await subtle.digest('SHA-256', ciphertext)
const jwk = await subtle.exportKey('jwk', key)
return {
data: ciphertext,
file: {
key: { kty: 'oct', key_ops: ['encrypt', 'decrypt'], alg: 'A256CTR', k: jwk.k!, ext: true },
iv: toBase64(iv),
hashes: { sha256: toBase64(new Uint8Array(hash)) },
v: 'v2',
},
}
}
export async function decryptAttachment(data: ArrayBuffer, file: EncryptedFile): Promise<ArrayBuffer> {
const subtle = globalThis.crypto.subtle
if (!file?.key?.k || !file.iv || !file.hashes?.sha256) throw new Error('attachment: missing key')
const hash = new Uint8Array(await subtle.digest('SHA-256', data))
const want = fromBase64(file.hashes.sha256)
if (hash.length !== want.length || hash.some((b, i) => b !== want[i])) throw new Error('attachment: hash mismatch')
const key = await subtle.importKey('jwk', { kty: 'oct', alg: 'A256CTR', k: file.key.k, ext: true }, { name: 'AES-CTR' }, false, ['decrypt'])
return subtle.decrypt({ name: 'AES-CTR', counter: fromBase64(file.iv), length: 64 }, key, data)
}
// The sender chooses the mimetype, and a blob: URL shares this page's
// origin: only types that render inertly are kept.
const inertTypes = /^(image\/(png|jpeg|gif|webp|avif|bmp)|video\/(mp4|webm|ogg|quicktime)|audio\/(mpeg|mp4|ogg|webm|wav|aac|flac|x-m4a))$/
export function safeMimetype(mimetype: string | undefined): string {
return mimetype && inertTypes.test(mimetype) ? mimetype : 'application/octet-stream'
}
+154
View File
@@ -0,0 +1,154 @@
import { createClient, PendingEventOrdering, type MatrixClient } from 'matrix-js-sdk'
import { config } from '@/config'
import { bindCrypto, cryptoCallbacks, cryptoDatabasePrefix } from './crypto'
// The one MatrixClient of a signed-in session. It is kept outside Vue's
// reactivity on purpose: the SDK's objects mutate themselves and emit
// events, and the stores turn those events into plain reactive state.
//
// Everything that touches matrix-js-sdk lives under src/matrix and
// src/stores, so the desktop app can later swap in its Go backend behind
// the same stores.
export interface Credentials {
baseUrl: string
userId: string
deviceId: string
accessToken: string
}
let current: MatrixClient | null = null
let cryptoFailure = ''
/** The running client. Only call this while signed in. */
export function client(): MatrixClient {
if (!current) throw new Error('Not signed in')
return current
}
export function hasClient(): boolean {
return current !== null
}
/**
* Why end-to-end encryption didn't start in this session, or '' when it
* runs. Without it the client still works in unencrypted rooms.
*/
export function cryptoError(): string {
return cryptoFailure
}
/** A client without a session, for sign-in and registration. */
export function anonymousClient(): MatrixClient {
return createClient({ baseUrl: config.homeserverUrl })
}
export async function startClient(creds: Credentials): Promise<MatrixClient> {
stopClient()
const c = createClient({
baseUrl: creds.baseUrl,
userId: creds.userId,
deviceId: creds.deviceId,
accessToken: creds.accessToken,
timelineSupport: true,
useAuthorizationHeader: true,
cryptoCallbacks,
// Emoji (SAS) only: there is no camera to scan a QR code with here.
verificationMethods: ['m.sas.v1'],
})
current = c
// End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm),
// its keys in IndexedDB under this session's own name, so two accounts
// in one browser never share a store. It loads before the first sync so
// the to-device messages and room keys in it are not missed.
cryptoFailure = ''
try {
await c.initRustCrypto({ cryptoDatabasePrefix: cryptoDatabasePrefix(creds.userId, creds.deviceId) })
bindCrypto(c)
} catch (e) {
console.error('crypto: ', e)
cryptoFailure = e instanceof Error ? e.message : String(e)
}
await c.startClient({
initialSyncLimit: 30,
lazyLoadMembers: true,
// Your own messages that are still sending (or failed) are kept apart
// from the timeline, so a failed one doesn't sit between others' replies.
pendingEventOrdering: PendingEventOrdering.Detached,
threadSupport: false,
})
return c
}
export function stopClient() {
if (!current) return
bindCrypto(null)
current.stopClient()
current.removeAllListeners()
current = null
}
// Crypto stores of sessions that are over but not deleted yet. Deleting
// an IndexedDB database waits until every connection to it is closed, and
// the crypto stack may hold one until the page goes away; whatever is left
// here is deleted when the next page starts.
const forgetKey = 'warp.web.crypto.forget'
function pendingForget(): string[] {
try {
const v = JSON.parse(localStorage.getItem(forgetKey) ?? '[]')
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []
} catch {
return []
}
}
function setPendingForget(list: string[]) {
try {
if (list.length) localStorage.setItem(forgetKey, JSON.stringify(list))
else localStorage.removeItem(forgetKey)
} catch {
// Not kept: the store stays until it is deleted by hand.
}
}
/** Deletes a crypto store; false when it is still open after a few seconds. */
async function deleteStore(c: MatrixClient, prefix: string): Promise<boolean> {
const timeout = new Promise<false>((resolve) => window.setTimeout(() => resolve(false), 3000))
const done = c
.clearStores({ cryptoDatabasePrefix: prefix })
.then(() => true as const)
.catch((e) => {
console.error('clearStores: ', e)
return false as const
})
const ok = await Promise.race([done, timeout])
if (ok) setPendingForget(pendingForget().filter((p) => p !== prefix))
return ok
}
/**
* Stops the client and deletes what it kept in this browser, the
* session's encryption keys included. For when the session is over: kept,
* they could only ever decrypt for a device the homeserver has forgotten.
*/
export async function destroyClient(session?: { userId: string; deviceId: string }) {
// The session may have no running client (it failed to start), and its
// keys still have to go.
const c = current ?? anonymousClient()
stopClient()
const userId = session?.userId ?? c.getUserId()
const deviceId = session?.deviceId ?? c.getDeviceId()
if (!userId || !deviceId) return
const prefix = cryptoDatabasePrefix(userId, deviceId)
setPendingForget([...new Set([...pendingForget(), prefix])])
await deleteStore(c, prefix)
}
/** Finishes deleting the crypto stores of sessions that ended. Runs before any client starts. */
export async function forgetEndedSessions() {
const list = pendingForget()
if (!list.length) return
const c = anonymousClient()
for (const prefix of list) await deleteStore(c, prefix)
}
+254
View File
@@ -0,0 +1,254 @@
import { MatrixError, type AuthDict, type MatrixClient } from 'matrix-js-sdk'
import {
decodeRecoveryKey,
deriveRecoveryKeyFromPassphrase,
ImportRoomKeyStage,
type CryptoApi,
type CryptoCallbacks,
type KeyBackupRestoreResult,
type VerificationRequest,
} from 'matrix-js-sdk/lib/crypto-api'
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage'
// End-to-end encryption, on top of the SDK's Rust crypto stack: whether
// this session is verified, the recovery key that unlocks secret storage
// (cross-signing keys and the key backup's key), setting it all up, and
// verification requests. The stores and dialogs call these; nothing else
// touches the crypto API.
let current: MatrixClient | null = null
/** Called by client.ts once the crypto stack is up, and with null when it stops. */
export function bindCrypto(c: MatrixClient | null) {
current = c
cachedKey = null
}
function crypto(): CryptoApi {
const api = current?.getCrypto()
if (!api) throw new Error('End-to-end encryption isn’t running in this session.')
return api
}
/** The IndexedDB name of a session's crypto store. */
export function cryptoDatabasePrefix(userId: string, deviceId: string): string {
return `warp-web-crypto:${userId}:${deviceId}`
}
// The secret storage key. The SDK asks for it, often several times in a
// row, while it reads or writes secret storage; it is kept in memory only
// for as long as this page runs.
let cachedKey: { keyId: string; key: Uint8Array<ArrayBuffer> } | null = null
export class WrongRecoveryKeyError extends Error {
constructor() {
super('That isn’t the recovery key for this account. Check it and try again.')
}
}
export const cryptoCallbacks: CryptoCallbacks = {
async getSecretStorageKey({ keys }) {
// Only a key the person entered (or one just created) is ever returned:
// unlockWithRecoveryKey checks it before it is cached.
if (cachedKey && keys[cachedKey.keyId]) return [cachedKey.keyId, cachedKey.key]
return null
},
cacheSecretStorageKey(keyId, _info, key) {
cachedKey = { keyId, key }
},
}
export interface EncryptionStatus {
/** The account has cross-signing keys: sessions can be verified. */
crossSigning: boolean
/** This session is signed by the account's own identity. */
sessionVerified: boolean
/** Secret storage holds the cross-signing keys: a recovery key unlocks them. */
secretStorage: boolean
/** The key backup on the homeserver, if there is one. */
backupVersion: string | null
/** This session backs its keys up to it. */
backupActive: boolean
/** This session holds the backup's key, so it can restore from it. */
backupKey: boolean
}
export async function encryptionStatus(): Promise<EncryptionStatus> {
const api = crypto()
const c = current!
const userId = c.getUserId()!
const deviceId = c.getDeviceId()!
const [crossSigning, device, secretStorage, backupInfo, backupActive, backupKey] = await Promise.all([
api.userHasCrossSigningKeys(userId, true),
api.getDeviceVerificationStatus(userId, deviceId),
api.isSecretStorageReady(),
api.getKeyBackupInfo().catch(() => null),
api.getActiveSessionBackupVersion(),
api.getSessionBackupPrivateKey(),
])
return {
crossSigning,
sessionVerified: Boolean(device?.crossSigningVerified),
secretStorage,
backupVersion: backupInfo?.version ?? null,
backupActive: backupActive !== null,
backupKey: backupKey !== null,
}
}
/** Raised when the homeserver wants the account password to go on. */
export class PasswordNeededError extends Error {
constructor() {
super('Enter your account password to continue.')
}
}
function uiaSession(e: unknown): string | undefined | null {
if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) return (e.data.session as string) ?? undefined
return null
}
/**
* Sets up encryption for the account: cross-signing keys (this session
* signs them, so it is verified), secret storage under a new recovery key,
* and a new key backup. Returns the recovery key to show the person once.
*
* With `reset`, replaces cross-signing keys that already exist: anyone who
* verified this account has to verify it again.
*/
export async function setUpEncryption(opts: { password?: string; reset?: boolean }): Promise<string> {
const api = crypto()
const c = current!
await api.bootstrapCrossSigning({
setupNewCrossSigning: opts.reset ?? false,
authUploadDeviceSigningKeys: async (makeRequest) => {
try {
await makeRequest(null)
} catch (e) {
const session = uiaSession(e)
if (session === null) throw e
if (!opts.password) throw new PasswordNeededError()
const auth: AuthDict = {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: c.getUserId()! },
password: opts.password,
session,
}
try {
await makeRequest(auth)
} catch (e2) {
if (uiaSession(e2) !== null || (e2 instanceof MatrixError && e2.errcode === 'M_FORBIDDEN')) {
throw new Error('That password isn’t right.')
}
throw e2
}
}
},
})
let recoveryKey = ''
await api.bootstrapSecretStorage({
setupNewSecretStorage: true,
setupNewKeyBackup: true,
createSecretStorageKey: async () => {
const key = await api.createRecoveryKeyFromPassphrase()
recoveryKey = key.encodedPrivateKey ?? ''
return key
},
})
return recoveryKey
}
async function keyFromInput(input: string, info: SecretStorageKeyDescription): Promise<Uint8Array<ArrayBuffer>> {
const text = input.trim()
try {
return decodeRecoveryKey(text)
} catch {
// Not a recovery key: it may be the passphrase the key was made from.
}
const pp = info.passphrase
if (!pp?.salt || !pp.iterations) throw new WrongRecoveryKeyError()
return deriveRecoveryKeyFromPassphrase(text, pp.salt, pp.iterations, pp.bits)
}
/**
* Unlocks secret storage with the recovery key (or its passphrase): this
* session gets the cross-signing keys and signs itself, which verifies
* it, and takes the key backup's key so older messages can be restored.
*/
export async function unlockWithRecoveryKey(input: string): Promise<void> {
const api = crypto()
const c = current!
const keyId = await c.secretStorage.getDefaultKeyId()
const tuple = keyId ? await c.secretStorage.getKey(keyId) : null
if (!tuple) throw new Error('This account has no secret storage. Set up encryption instead.')
const key = await keyFromInput(input, tuple[1])
if (!(await c.secretStorage.checkKey(key, tuple[1]))) throw new WrongRecoveryKeyError()
cachedKey = { keyId: tuple[0], key }
await api.bootstrapCrossSigning({})
await api.loadSessionBackupPrivateKeyFromSecretStorage().catch(() => undefined)
await api.checkKeyBackupAndEnable()
}
/** Fetches and imports every room key in the backup this session can read. */
export async function restoreKeyBackup(onProgress?: (done: number, total: number) => void): Promise<KeyBackupRestoreResult> {
const api = crypto()
await api.checkKeyBackupAndEnable()
return api.restoreKeyBackup({
progressCallback: (p) => {
if (p.stage === ImportRoomKeyStage.LoadKeys) onProgress?.(p.successes + p.failures, p.total)
},
})
}
/** After this session was verified by another, it may have been sent the backup key. */
export async function enableBackupIfPossible() {
const api = current?.getCrypto()
if (!api) return
await api.checkKeyBackupAndEnable().catch(() => undefined)
}
export function verifyOwnSession(): Promise<VerificationRequest> {
return crypto().requestOwnUserVerification()
}
export function verifyDevice(userId: string, deviceId: string): Promise<VerificationRequest> {
return crypto().requestDeviceVerification(userId, deviceId)
}
export interface SessionInfo {
deviceId: string
name?: string
verified: boolean
/** This browser's own session. */
own: boolean
}
/** Someone's sessions as their keys say, with whether this session trusts each. */
export async function sessionsOf(userId: string): Promise<SessionInfo[]> {
const api = crypto()
const c = current!
const map = await api.getUserDeviceInfo([userId], true)
const devices = [...(map.get(userId)?.values() ?? [])].filter((d) => !d.dehydrated)
const out = await Promise.all(
devices.map(async (d) => {
const status = await api.getDeviceVerificationStatus(userId, d.deviceId)
return {
deviceId: d.deviceId,
name: d.displayName,
verified: Boolean(status?.isVerified()),
own: userId === c.getUserId() && d.deviceId === c.getDeviceId(),
}
}),
)
return out.sort((a, b) => Number(b.own) - Number(a.own) || (a.name ?? a.deviceId).localeCompare(b.name ?? b.deviceId))
}
/** The recovery key was entered (or created) in this page, so secret storage is open. */
export function recoveryKeyKnown(): boolean {
return cachedKey !== null
}
/** Starts a new key backup, its key kept in secret storage. Needs the recovery key first. */
export async function createKeyBackup(): Promise<void> {
await crypto().bootstrapSecretStorage({ setupNewKeyBackup: true })
}
+114
View File
@@ -0,0 +1,114 @@
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { decryptAttachment, safeMimetype, type EncryptedFile } from './attachments'
import { client } from './client'
// Authenticated media (Matrix 1.11) needs the access token in a header, so
// an <img src> can't load it directly. Files are fetched here and shown from
// blob: URLs, cached for the session and released on sign-out. A file
// from an encrypted room is fetched whole and decrypted here.
/** Where media comes from: an mxc:// URI, or an encrypted file and its type. */
export type MediaSource = string | { file: EncryptedFile; mimetype?: string }
function sourceKey(src: MediaSource | undefined | null): string | undefined {
if (!src) return undefined
return typeof src === 'string' ? src : src.file?.url
}
const cache = new Map<string, Promise<string>>()
function key(mxc: string, width?: number, height?: number) {
return width ? `${mxc}#${width}x${height}` : mxc
}
export function httpUrl(mxc: string, width?: number, height?: number): string | null {
return client().mxcUrlToHttp(mxc, width, height, width ? 'scale' : undefined, false, true, true)
}
async function fetchBlob(url: string): Promise<Blob> {
const token = client().getAccessToken()
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
credentials: 'omit',
})
if (!res.ok) throw new Error(`media: ${res.status}`)
return res.blob()
}
async function fetchDecrypted(file: EncryptedFile, mimetype?: string): Promise<Blob> {
const url = httpUrl(file.url)
if (!url) throw new Error('media: not an mxc:// URL')
const data = await (await fetchBlob(url)).arrayBuffer()
return new Blob([await decryptAttachment(data, file)], { type: safeMimetype(mimetype) })
}
/**
* A blob: URL for the media, or its thumbnail when a size is given. An
* encrypted file has no server-side thumbnail: it is always the whole file.
*/
export function mediaUrl(src: MediaSource, width?: number, height?: number): Promise<string> {
if (typeof src !== 'string') {
width = height = undefined
}
const mxc = sourceKey(src)
if (!mxc) return Promise.reject(new Error('media: no file'))
const k = key(mxc, width, height)
let p = cache.get(k)
if (!p) {
const url = httpUrl(mxc, width, height)
if (!url) return Promise.reject(new Error('media: not an mxc:// URL'))
const blob = typeof src === 'string' ? fetchBlob(url) : fetchDecrypted(src.file, src.mimetype)
p = blob.then((b) => URL.createObjectURL(b))
// A failure is not cached, so the next view tries again.
p.catch(() => cache.delete(k))
cache.set(k, p)
}
return p
}
/** Saves the file under its own name. */
export async function downloadMedia(src: MediaSource, filename: string) {
const url = await mediaUrl(src)
const a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.append(a)
a.click()
a.remove()
}
export function clearMediaCache() {
for (const p of cache.values()) p.then((u) => URL.revokeObjectURL(u)).catch(() => undefined)
cache.clear()
}
/** Reactive blob URL for a media source; undefined while loading or on error. */
export function useMedia(
source: () => MediaSource | undefined | null,
size?: { width: number; height: number },
): { url: Ref<string | undefined>; failed: Ref<boolean> } {
const url = ref<string>()
const failed = ref(false)
let alive = true
// Watched by URI: the timeline rebuilds its objects on every change.
watch(
() => sourceKey(source()),
(mxc) => {
url.value = undefined
failed.value = false
const src = source()
if (!src || !mxc || !mxc.startsWith('mxc://')) return
mediaUrl(src, size?.width, size?.height)
.then((u) => {
if (alive && sourceKey(source()) === mxc) url.value = u
})
.catch(() => {
if (alive) failed.value = true
})
},
{ immediate: true },
)
onBeforeUnmount(() => (alive = false))
return { url, failed }
}
+78
View File
@@ -0,0 +1,78 @@
import { MsgType, type MatrixClient, type Room } from 'matrix-js-sdk'
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
import { encryptAttachment } from './attachments'
// Sending: text, and files uploaded to the media repository first. Your own
// message appears at once as a local echo with status "sending"; the SDK
// moves it into the timeline when the server has it, or marks it not sent.
// In an end-to-end encrypted room the SDK encrypts the message itself, and
// a file is encrypted here before it is uploaded.
export function sendText(c: MatrixClient, roomId: string, text: string) {
const body = text.replace(/\s+$/, '')
const emote = body.match(/^\/me\s+([\s\S]+)$/)
const content: RoomMessageEventContent = emote
? { msgtype: MsgType.Emote, body: emote[1]! }
: { msgtype: MsgType.Text, body: body.replace(/^\/\//, '/') }
return c.sendMessage(roomId, content)
}
function imageSize(file: File): Promise<{ w: number; h: number } | undefined> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
resolve({ w: img.naturalWidth, h: img.naturalHeight })
URL.revokeObjectURL(url)
}
img.onerror = () => {
resolve(undefined)
URL.revokeObjectURL(url)
}
img.src = url
})
}
function msgtypeFor(file: File): MsgType {
if (file.type.startsWith('image/')) return MsgType.Image
if (file.type.startsWith('video/')) return MsgType.Video
if (file.type.startsWith('audio/')) return MsgType.Audio
return MsgType.File
}
export interface UploadProgress {
name: string
loaded: number
total: number
}
export async function sendFile(
c: MatrixClient,
room: Room,
file: File,
onProgress?: (p: UploadProgress) => void,
) {
const msgtype = msgtypeFor(file)
const info: Record<string, unknown> = { mimetype: file.type || 'application/octet-stream', size: file.size }
if (msgtype === MsgType.Image) Object.assign(info, (await imageSize(file)) ?? {})
const progressHandler = (p: { loaded: number; total: number }) => onProgress?.({ name: file.name, loaded: p.loaded, total: p.total })
let source: { url: string } | { file: object }
if (room.hasEncryptionStateEvent()) {
const { data, file: keys } = await encryptAttachment(await file.arrayBuffer())
// The name and type stay inside the encrypted message.
const upload = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false, progressHandler })
source = { file: { ...keys, url: upload.content_uri } }
} else {
const upload = await c.uploadContent(file, { name: file.name, type: file.type || 'application/octet-stream', progressHandler })
source = { url: upload.content_uri }
}
const content = {
msgtype,
body: file.name,
filename: file.name,
...source,
info,
} as RoomMessageEventContent
return c.sendMessage(room.roomId, content)
}
+347
View File
@@ -0,0 +1,347 @@
import {
Direction,
EventStatus,
EventType,
MsgType,
type MatrixClient,
type MatrixEvent,
type Room,
} from 'matrix-js-sdk'
import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
import type { MessageStatus } from '@warp/ui'
import { sameDay } from '@/lib/format'
import type { EncryptedFile } from './attachments'
import type { MediaSource } from './media'
// Turns a room's live timeline into what the timeline view draws: messages
// grouped by sender, day separators and one-line notices for state changes.
// The SDK keeps the events; this is a pure projection of them.
export interface MediaInfo {
mxc?: string
/** The thumbnail the sender uploaded, when there is one. */
thumbMxc?: string
/** Set in an encrypted room: the file is ciphertext, this decrypts it. */
file?: EncryptedFile
thumbFile?: EncryptedFile
thumbMimetype?: string
mimetype?: string
size?: number
w?: number
h?: number
filename: string
}
export interface MessageItem {
kind: 'message'
key: string
eventId?: string
sender: string
senderName: string
avatarMxc?: string
own: boolean
ts: number
msgtype: string
body: string
media?: MediaInfo
edited: boolean
redacted: boolean
/** Still ciphertext: being decrypted, or it can't be (see `utd`). */
encrypted: boolean
/** Why this session can't decrypt it. */
utd?: string
/** Sent end-to-end encrypted. */
e2ee: boolean
status?: MessageStatus
/** First of a run of messages from one sender: shows the name. */
first: boolean
/** Last of a run: shows the time and status. */
last: boolean
event: MatrixEvent
}
export interface NoticeItem {
kind: 'notice'
key: string
ts: number
text: string
}
export interface DayItem {
kind: 'day'
key: string
ts: number
}
export type TimelineItem = MessageItem | NoticeItem | DayItem
// Messages from one sender this close together form one group.
const groupGapMs = 5 * 60_000
function nameOf(room: Room, userId: string): string {
return room.getMember(userId)?.name ?? userId
}
function statusOf(ev: MatrixEvent): MessageStatus | undefined {
switch (ev.status) {
case EventStatus.NOT_SENT:
return 'failed'
case EventStatus.SENDING:
case EventStatus.QUEUED:
case EventStatus.ENCRYPTING:
return 'sending'
case EventStatus.CANCELLED:
return undefined
default:
return 'sent'
}
}
/** A one-line description of a state change, or null to leave it out. */
function describeState(ev: MatrixEvent, room: Room): string | null {
const sender = nameOf(room, ev.getSender() ?? '')
const content = ev.getContent()
const prev = ev.getPrevContent()
switch (ev.getType()) {
case EventType.RoomCreate:
return `${sender} created the room`
case EventType.RoomName:
return content.name ? `${sender} named the room ${content.name}` : `${sender} removed the room name`
case EventType.RoomTopic:
return content.topic ? `${sender} changed the topic to “${content.topic}”` : `${sender} removed the topic`
case EventType.RoomAvatar:
return `${sender} changed the room avatar`
case EventType.RoomEncryption:
return `${sender} turned on end-to-end encryption`
case EventType.RoomTombstone:
return `${sender} upgraded this room`
case EventType.RoomMember: {
const target = ev.getStateKey() ?? ''
const targetName = content.displayname ?? prev.displayname ?? target
const reason = content.reason ? `: ${content.reason}` : ''
switch (content.membership) {
case 'join':
if (prev.membership === 'join') {
if (content.displayname !== prev.displayname) {
if (!content.displayname) return `${prev.displayname ?? target} removed their display name`
if (!prev.displayname) return `${target} set their display name to ${content.displayname}`
return `${prev.displayname} changed their name to ${content.displayname}`
}
if (content.avatar_url !== prev.avatar_url) return `${targetName} changed their avatar`
return null
}
return `${targetName} joined`
case 'invite':
return `${sender} invited ${targetName}`
case 'leave':
if (target !== ev.getSender()) {
if (prev.membership === 'ban') return `${sender} unbanned ${targetName}`
if (prev.membership === 'invite') return `${sender} withdrew ${targetName}’s invitation`
return `${sender} removed ${targetName}${reason}`
}
return prev.membership === 'invite' ? `${targetName} declined the invitation` : `${targetName} left`
case 'ban':
return `${sender} banned ${targetName}${reason}`
case 'knock':
return `${targetName} asked to join`
}
return null
}
}
return null
}
/** What a message this session can't decrypt says about why, honestly. */
export function utdText(ev: MatrixEvent, cryptoRunning: boolean): string | undefined {
if (!ev.isDecryptionFailure()) return cryptoRunning ? undefined : 'End-to-end encryption didn’t start in this browser, so it can’t read this message.'
switch (ev.decryptionFailureReason) {
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
return 'Sent before this session signed in. Verify this session or restore your key backup to read it.'
case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP:
return 'Sent before this session signed in, and its key isn’t in your key backup.'
case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED:
return 'Sent before you joined this room, so you can’t read it.'
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
return 'The sender only shares keys with verified sessions. Verify this session to read new messages.'
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
return 'The sender’s session didn’t share the key to this message with you.'
case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
return 'This session only has the key to later messages from this sender.'
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
return 'Hidden: the sender’s identity changed since you verified them.'
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
return 'Hidden: sent from a session its owner hasn’t verified.'
case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
return 'Waiting for the key to this message. It comes from the sender’s session, which may be offline.'
default:
return 'Can’t decrypt this message.'
}
}
/** Where to load a message's file or thumbnail from. */
export function mediaSource(m: MediaInfo, thumb = false): MediaSource | undefined {
if (thumb && m.thumbFile) return { file: m.thumbFile, mimetype: m.thumbMimetype ?? 'image/jpeg' }
if (thumb && m.thumbMxc) return m.thumbMxc
if (m.file) return { file: m.file, mimetype: m.mimetype }
return m.mxc
}
function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined {
const msgtype = content.msgtype as string
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video && msgtype !== MsgType.Audio && msgtype !== MsgType.File) {
return undefined
}
const c = content as RoomMessageEventContent & {
url?: string
file?: EncryptedFile
filename?: string
info?: {
mimetype?: string
size?: number
w?: number
h?: number
thumbnail_url?: string
thumbnail_file?: EncryptedFile
thumbnail_info?: { mimetype?: string }
}
}
return {
mxc: c.url ?? c.file?.url,
thumbMxc: c.info?.thumbnail_url ?? c.info?.thumbnail_file?.url,
file: c.file,
thumbFile: c.info?.thumbnail_file,
thumbMimetype: c.info?.thumbnail_info?.mimetype,
mimetype: c.info?.mimetype,
size: c.info?.size,
w: c.info?.w,
h: c.info?.h,
filename: c.filename || c.body || 'file',
}
}
/** Every event the view shows, oldest first: the live timeline then your pending ones. */
function displayEvents(room: Room): MatrixEvent[] {
return [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()]
}
export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
const me = c.getUserId() ?? ''
const cryptoRunning = Boolean(c.getCrypto())
const events = displayEvents(room)
// An event is read when someone else's receipt is on it or on anything
// after it, or someone else has written after it.
const readByOthers = new Set<string>()
let seen = false
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]!
const id = ev.getId()
if (!id || ev.status) continue
if (!seen) {
const sender = ev.getSender()
if (sender && sender !== me) seen = true
else if (room.getUsersReadUpTo(ev).some((u) => u !== me)) seen = true
}
if (seen) readByOthers.add(id)
}
const items: TimelineItem[] = []
let lastTs = 0
let prev: MessageItem | null = null
for (const ev of events) {
const type = ev.getType()
if (ev.isRelation('m.replace') || type === EventType.Reaction || type === EventType.RoomRedaction) continue
if (ev.status === EventStatus.CANCELLED) continue
const ts = ev.getTs()
if (!lastTs || !sameDay(lastTs, ts)) {
items.push({ kind: 'day', key: `day-${ts}`, ts })
prev = null
}
lastTs = ts
const key = ev.getTxnId() ?? ev.getId() ?? `${ts}-${items.length}`
const isMessage = type === EventType.RoomMessage || type === EventType.Sticker || type === EventType.RoomMessageEncrypted
if (!isMessage) {
if (!ev.isState()) continue
const text = describeState(ev, room)
if (text) {
items.push({ kind: 'notice', key, ts, text })
prev = null
}
continue
}
const sender = ev.getSender() ?? ''
const own = sender === me
const content = ev.getContent<RoomMessageEventContent>()
// Still ciphertext, or failed: the SDK then makes it an m.room.message
// with its own "Unable to decrypt" text, which is never shown.
const encrypted = type === EventType.RoomMessageEncrypted || ev.isDecryptionFailure()
const redacted = ev.isRedacted()
const status = own ? statusOf(ev) : undefined
const item: MessageItem = {
kind: 'message',
key,
eventId: ev.getId(),
sender,
senderName: nameOf(room, sender),
avatarMxc: room.getMember(sender)?.getMxcAvatarUrl() ?? undefined,
own,
ts,
msgtype: type === EventType.Sticker ? 'm.sticker' : String(content.msgtype ?? ''),
body: redacted ? '' : String(content.body ?? ''),
media: redacted || encrypted ? undefined : type === EventType.Sticker ? stickerMedia(ev) : mediaOf(content),
edited: Boolean(ev.replacingEventId()),
redacted,
encrypted,
utd: encrypted ? utdText(ev, cryptoRunning) : undefined,
e2ee: ev.isEncrypted(),
status: status === 'sent' && ev.getId() && readByOthers.has(ev.getId()!) ? 'read' : status,
first: true,
last: true,
event: ev,
}
if (prev && prev.sender === sender && ts - prev.ts < groupGapMs) {
item.first = false
prev.last = false
}
items.push(item)
prev = item
}
// Only the newest message of a run says "Sent" or "Read"; one that is
// still sending or failed always says so.
for (const it of items) {
if (it.kind === 'message' && it.own && !it.last && (it.status === 'sent' || it.status === 'read')) {
it.status = undefined
}
}
return items
}
function stickerMedia(ev: MatrixEvent): MediaInfo {
const c = ev.getContent() as { url?: string; body?: string; info?: { w?: number; h?: number; mimetype?: string } }
return { mxc: c.url, w: c.info?.w, h: c.info?.h, mimetype: c.info?.mimetype, filename: c.body ?? 'sticker' }
}
/** Whether there is older history to page back to. */
export function canPaginateBack(room: Room): boolean {
const tl = room.getLiveTimeline()
if (tl.getEvents().some((e) => e.getType() === EventType.RoomCreate)) return false
return tl.getPaginationToken(Direction.Backward) !== null
}
/** The newest event that came from the server, for read receipts. */
export function lastSyncedEvent(room: Room): MatrixEvent | undefined {
const events = room.getLiveTimeline().getEvents()
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]!
if (ev.getId() && !ev.status && !ev.getId()!.startsWith('~')) return ev
}
return undefined
}
+36
View File
@@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useSession } from './stores/session'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true, title: 'Sign in' } },
{
path: '/register',
name: 'register',
component: () => import('./views/RegisterView.vue'),
meta: { public: true, title: 'Create account' },
},
{
path: '/',
component: () => import('./layouts/ClientShell.vue'),
children: [
{ path: '', name: 'home', component: () => import('./views/HomeView.vue') },
{ path: 'room/:roomId', name: 'room', component: () => import('./views/RoomView.vue'), props: true },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
const session = useSession()
if (!to.meta.public && !session.signedIn) {
return { name: 'login', query: to.fullPath !== '/' ? { next: to.fullPath } : undefined }
}
if (to.meta.public && session.signedIn) return { name: 'home' }
})
router.afterEach((to) => {
if (to.meta.title) document.title = `${to.meta.title} · Warp`
})
+373
View File
@@ -0,0 +1,373 @@
import {
ClientEvent,
EventType,
KnownMembership,
MatrixEventEvent,
MsgType,
NotificationCountType,
Preset,
RoomEvent,
RoomMemberEvent,
RoomStateEvent,
SyncState,
UserEvent,
Visibility,
type MatrixClient,
type MatrixEvent,
type Room,
} from 'matrix-js-sdk'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { client } from '@/matrix/client'
// The room list and the client's connection state, as plain reactive data
// derived from the SDK. The SDK's objects change in place and emit events;
// every event that can change what is on screen bumps `version` (at most
// once a frame), and the computed views below re-derive from the SDK.
export interface RoomSummary {
roomId: string
name: string
avatarMxc?: string
preview: string
ts: number
unread: number
highlight: number
encrypted: boolean
isDirect: boolean
/** The other person in a direct message. */
dmUserId?: string
presence?: 'online' | 'away' | 'offline'
}
export interface InviteSummary {
roomId: string
name: string
avatarMxc?: string
inviter?: string
inviterName?: string
isDirect: boolean
ts: number
}
/** The text a room list shows for an event, or null when it shows nothing. */
export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSender: boolean): string | null {
if (ev.isRedacted()) return 'Message deleted'
const type = ev.getType()
// A message that failed to decrypt reads as m.room.message with the SDK's
// own placeholder text: never show that.
if (ev.isDecryptionFailure()) return withSender ? `${senderName(ev, me, room)}: Can’t decrypt this message` : 'Can’t decrypt this message'
if (type === EventType.RoomMessageEncrypted) return 'Encrypted message'
if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
if (ev.isRelation('m.replace')) return null
const content = ev.getContent()
let text: string
switch (content.msgtype) {
case MsgType.Image:
text = 'Image'
break
case MsgType.Video:
text = 'Video'
break
case MsgType.Audio:
text = 'Audio'
break
case MsgType.File:
text = `File: ${content.body ?? ''}`
break
default:
text = type === EventType.Sticker ? 'Sticker' : String(content.body ?? '')
}
if (content.msgtype === MsgType.Emote) {
return `* ${room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender()} ${text}`
}
if (!withSender) return text
return `${senderName(ev, me, room)}: ${text}`
}
function senderName(ev: MatrixEvent, me: string, room: Room): string {
return ev.getSender() === me ? 'You' : (room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender() ?? '')
}
function directRooms(c: MatrixClient): Map<string, string> {
// m.direct maps a user to their DM rooms; flip it to room → user.
const out = new Map<string, string>()
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
for (const [user, rooms] of Object.entries(content)) {
if (!Array.isArray(rooms)) continue
for (const r of rooms) out.set(r, user)
}
return out
}
function presenceOf(c: MatrixClient, userId: string): RoomSummary['presence'] {
const p = c.getUser(userId)?.presence
if (p === 'online') return 'online'
if (p === 'unavailable') return 'away'
if (p === 'offline') return 'offline'
return undefined
}
function summarize(c: MatrixClient, room: Room, direct: Map<string, string>): RoomSummary {
const me = c.getUserId() ?? ''
const dmUserId = direct.get(room.roomId)
const isDirect = dmUserId !== undefined
const events = room.getLiveTimeline().getEvents()
let preview = ''
for (let i = events.length - 1; i >= 0; i--) {
const text = describeEvent(events[i]!, me, room, !isDirect)
if (text !== null) {
preview = text
break
}
}
let avatarMxc = room.getMxcAvatarUrl() ?? undefined
if (!avatarMxc && isDirect) avatarMxc = room.getMember(dmUserId)?.getMxcAvatarUrl() ?? undefined
return {
roomId: room.roomId,
name: room.name,
avatarMxc,
preview,
ts: room.getLastActiveTimestamp(),
unread: room.getUnreadNotificationCount(NotificationCountType.Total),
highlight: room.getUnreadNotificationCount(NotificationCountType.Highlight),
encrypted: room.hasEncryptionStateEvent(),
isDirect,
dmUserId,
presence: isDirect && dmUserId ? presenceOf(c, dmUserId) : undefined,
}
}
function summarizeInvite(c: MatrixClient, room: Room): InviteSummary {
const me = c.getUserId() ?? ''
const own = room.getMember(me)
const memberEvent = own?.events.member
const inviter = memberEvent?.getSender() ?? room.getDMInviter()
return {
roomId: room.roomId,
name: room.name,
avatarMxc: room.getMxcAvatarUrl() ?? undefined,
inviter,
inviterName: inviter ? (room.getMember(inviter)?.name ?? inviter) : undefined,
isDirect: Boolean(memberEvent?.getContent().is_direct),
ts: memberEvent?.getTs() ?? 0,
}
}
const encryptionState = {
type: EventType.RoomEncryption,
state_key: '',
content: { algorithm: 'm.megolm.v1.aes-sha2' },
}
export const useChat = defineStore('chat', () => {
const version = ref(0)
const syncState = shallowRef<SyncState | null>(null)
const ready = ref(false)
let scheduled = false
let attached: MatrixClient | null = null
function bump() {
if (scheduled) return
scheduled = true
const fire = () => {
if (!scheduled) return
scheduled = false
window.clearTimeout(timer)
version.value++
}
// A background tab gets no animation frames: the timer keeps the
// unread count in the tab title (and decryption) moving there.
requestAnimationFrame(fire)
const timer = window.setTimeout(fire, 250)
}
function onSync(state: SyncState) {
syncState.value = state
if (state === SyncState.Prepared || state === SyncState.Syncing) ready.value = true
bump()
}
/** Starts listening to the running client. */
function attach() {
const c = client()
if (attached === c) return
attached = c
ready.value = c.isInitialSyncComplete()
syncState.value = c.getSyncState()
c.on(ClientEvent.Sync, onSync)
for (const e of [
ClientEvent.Room,
ClientEvent.DeleteRoom,
ClientEvent.AccountData,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
RoomEvent.Name,
RoomEvent.MyMembership,
RoomEvent.Receipt,
RoomEvent.Redaction,
RoomEvent.LocalEchoUpdated,
RoomEvent.UnreadNotifications,
RoomStateEvent.Events,
RoomMemberEvent.Typing,
RoomMemberEvent.Name,
UserEvent.Presence,
// An encrypted event changes in place once it is decrypted.
MatrixEventEvent.Decrypted,
] as const) {
c.on(e as ClientEvent.Room, bump)
}
bump()
}
function detach() {
attached = null
ready.value = false
syncState.value = null
}
const rooms = computed<RoomSummary[]>(() => {
void version.value
if (!attached) return []
const c = attached
const direct = directRooms(c)
return c
.getRooms()
.filter((r) => r.getMyMembership() === KnownMembership.Join && !isReplaced(r))
.map((r) => summarize(c, r, direct))
.sort((a, b) => b.ts - a.ts)
})
const invites = computed<InviteSummary[]>(() => {
void version.value
if (!attached) return []
const c = attached
return c
.getRooms()
.filter((r) => r.getMyMembership() === KnownMembership.Invite)
.map((r) => summarizeInvite(c, r))
.sort((a, b) => b.ts - a.ts)
})
/** A room that was upgraded to a newer one the person is in. */
function isReplaced(room: Room): boolean {
const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, '')
const next = tombstone?.getContent().replacement_room
return Boolean(next && attached?.getRoom(next)?.getMyMembership() === KnownMembership.Join)
}
const totalUnread = computed(() => rooms.value.reduce((n, r) => n + (r.unread > 0 ? 1 : 0), 0) + invites.value.length)
// Offline once sync has been failing for a few seconds: one failed
// request that the next retry recovers from is not worth a banner.
const offline = ref(false)
let offlineTimer = 0
watch(syncState, (state) => {
const failing = state === SyncState.Error || state === SyncState.Reconnecting
window.clearTimeout(offlineTimer)
if (!failing) offline.value = false
else if (!offline.value) offlineTimer = window.setTimeout(() => (offline.value = true), 5000)
})
async function createRoom(opts: {
name: string
topic?: string
isPublic: boolean
alias?: string
encrypted: boolean
}): Promise<string> {
const res = await client().createRoom({
name: opts.name.trim(),
topic: opts.topic?.trim() || undefined,
preset: opts.isPublic ? Preset.PublicChat : Preset.PrivateChat,
visibility: opts.isPublic ? Visibility.Public : Visibility.Private,
room_alias_name: opts.isPublic && opts.alias ? opts.alias.trim() : undefined,
initial_state: opts.encrypted ? [encryptionState] : undefined,
})
return res.room_id
}
/** Whether this session can encrypt a new room: the crypto stack is running. */
const canEncrypt = computed(() => {
void version.value
return Boolean(attached?.getCrypto())
})
/** Opens the existing direct message with the person, or starts one. */
async function startDirect(userId: string): Promise<string> {
const c = client()
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
for (const roomId of content[userId] ?? []) {
const room = c.getRoom(roomId)
if (room && room.getMyMembership() === KnownMembership.Join) return roomId
}
// Fails early with a clear error when the person doesn't exist.
await c.getProfileInfo(userId)
// End-to-end encrypted when the other person has a session that can
// read it: encrypting to nobody would only lock them out.
let encrypt = false
const crypto = c.getCrypto()
if (crypto) {
const devices = await crypto.getUserDeviceInfo([userId], true).catch(() => null)
encrypt = (devices?.get(userId)?.size ?? 0) > 0
}
const res = await c.createRoom({
is_direct: true,
invite: [userId],
preset: Preset.TrustedPrivateChat,
initial_state: encrypt ? [encryptionState] : undefined,
})
const next = { ...content, [userId]: [...(content[userId] ?? []), res.room_id] }
await c.setAccountData(EventType.Direct, next)
return res.room_id
}
async function join(idOrAlias: string): Promise<string> {
const room = await client().joinRoom(idOrAlias.trim())
return room.roomId
}
async function acceptInvite(roomId: string) {
const c = client()
const room = c.getRoom(roomId)
const own = room?.getMember(c.getUserId() ?? '')
const memberEvent = own?.events.member
await c.joinRoom(roomId)
// An accepted direct invite goes into m.direct, so it shows as one.
if (memberEvent?.getContent().is_direct) {
const inviter = memberEvent.getSender()
if (inviter) {
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
if (!(content[inviter] ?? []).includes(roomId)) {
await c.setAccountData(EventType.Direct, { ...content, [inviter]: [...(content[inviter] ?? []), roomId] })
}
}
}
}
async function leave(roomId: string) {
const c = client()
await c.leave(roomId)
await c.forget(roomId, true).catch(() => undefined)
bump()
}
return {
version,
ready,
syncState,
offline,
rooms,
invites,
totalUnread,
canEncrypt,
attach,
detach,
bump,
createRoom,
startDirect,
join,
acceptInvite,
leave,
}
})
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { client } from '@/matrix/client'
// Your own display name and avatar, as the homeserver has them.
export const useProfile = defineStore('profile', () => {
const displayName = ref('')
const avatarMxc = ref<string>()
async function load() {
const c = client()
try {
const p = await c.getProfileInfo(c.getUserId() ?? '')
displayName.value = p.displayname ?? ''
avatarMxc.value = p.avatar_url ?? undefined
} catch {
// No profile yet; the localpart stands in.
}
}
async function setDisplayName(name: string) {
await client().setDisplayName(name.trim())
displayName.value = name.trim()
}
async function setAvatar(file: File) {
const c = client()
const res = await c.uploadContent(file, { name: file.name, type: file.type })
await c.setAvatarUrl(res.content_uri)
avatarMxc.value = res.content_uri
}
async function removeAvatar() {
await client().setAvatarUrl('')
avatarMxc.value = undefined
}
return { displayName, avatarMxc, load, setDisplayName, setAvatar, removeAvatar }
})
+124
View File
@@ -0,0 +1,124 @@
import type { MatrixClient } from 'matrix-js-sdk'
import {
CryptoEvent,
VerificationPhase,
VerificationRequestEvent,
type VerificationRequest,
} from 'matrix-js-sdk/lib/crypto-api'
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { client } from '@/matrix/client'
import {
enableBackupIfPossible,
encryptionStatus,
verifyDevice,
verifyOwnSession,
type EncryptionStatus,
} from '@/matrix/crypto'
// End-to-end encryption as the views see it: whether this session is
// verified, the key backup, and the one verification on screen at a time,
// whether this session asked for it or somebody else did.
export const useSecurity = defineStore('security', () => {
/** Null until known, and while the crypto stack isn't running. */
const status = shallowRef<EncryptionStatus | null>(null)
/** The verification the dialog shows. */
const request = shallowRef<VerificationRequest | null>(null)
/** Bumps when the request or anybody's trust changes. */
const version = ref(0)
let attached: MatrixClient | null = null
let refreshing: Promise<void> | null = null
let again = false
async function refresh() {
if (!attached?.getCrypto()) return
// One refresh at a time; changes during one ask for another after it.
if (refreshing) {
again = true
return refreshing
}
refreshing = (async () => {
do {
again = false
status.value = await encryptionStatus().catch(() => status.value)
} while (again)
})().finally(() => (refreshing = null))
return refreshing
}
function changed() {
version.value++
void refresh()
}
function onRequest(req: VerificationRequest) {
// One verification at a time: a second one while one is running is
// turned down rather than stacked.
const busy = request.value && request.value.phase !== VerificationPhase.Done && request.value.phase !== VerificationPhase.Cancelled
if (busy) {
void req.cancel({ reason: 'Already verifying another session', code: 'm.user' }).catch(() => undefined)
return
}
show(req)
}
function onRequestChange() {
version.value++
const req = request.value
if (req?.phase === VerificationPhase.Done) {
// Verified by our own other session: it sends the backup key next.
if (req.isSelfVerification) void enableBackupIfPossible().then(changed)
else changed()
}
}
function show(req: VerificationRequest) {
request.value?.off(VerificationRequestEvent.Change, onRequestChange)
request.value = req
req.on(VerificationRequestEvent.Change, onRequestChange)
version.value++
}
/** Closes the dialog, cancelling the verification if it is still going. */
function dismiss() {
const req = request.value
if (!req) return
req.off(VerificationRequestEvent.Change, onRequestChange)
if (req.phase !== VerificationPhase.Done && req.phase !== VerificationPhase.Cancelled) {
void req.cancel().catch(() => undefined)
}
request.value = null
}
/** Starts listening to the running client. */
function attach() {
const c = client()
if (attached === c) return
attached = c
status.value = null
request.value = null
if (!c.getCrypto()) return
c.on(CryptoEvent.VerificationRequestReceived, onRequest)
for (const e of [
CryptoEvent.KeyBackupStatus,
CryptoEvent.KeyBackupDecryptionKeyCached,
CryptoEvent.KeysChanged,
CryptoEvent.UserTrustStatusChanged,
CryptoEvent.DevicesUpdated,
] as const) {
c.on(e as CryptoEvent.KeysChanged, changed)
}
void refresh()
}
async function verifyThisSession() {
show(await verifyOwnSession())
}
async function verifySession(userId: string, deviceId: string) {
show(await verifyDevice(userId, deviceId))
}
return { status, request, version, attach, refresh, dismiss, verifyThisSession, verifySession }
})
+240
View File
@@ -0,0 +1,240 @@
import { HttpApiEvent, MatrixError, type AuthDict, type LoginResponse, type RegisterResponse } from 'matrix-js-sdk'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { config } from '@/config'
import { anonymousClient, client, destroyClient, startClient, stopClient, type Credentials } from '@/matrix/client'
import { clearMediaCache } from '@/matrix/media'
// The person's Matrix session. It is kept in localStorage while "Stay
// signed in" is on (the default for a chat client), otherwise in
// sessionStorage, which goes with the tab. Signing out logs the session out
// on the homeserver and forgets it here, its encryption keys included.
const storageKey = 'warp.web.session'
const deviceName = 'Warp Web'
function load(): Credentials | null {
for (const store of [localStorage, sessionStorage]) {
try {
const raw = store.getItem(storageKey)
if (raw) return JSON.parse(raw) as Credentials
} catch {
// Unreadable or blocked: treat as signed out.
}
}
return null
}
function save(c: Credentials | null, remember: boolean) {
for (const store of [localStorage, sessionStorage]) {
try {
store.removeItem(storageKey)
} catch {
// Ignore.
}
}
if (!c) return
try {
;(remember ? localStorage : sessionStorage).setItem(storageKey, JSON.stringify(c))
} catch {
// Not kept across reloads; the session still works in this page.
}
}
/** What registration on this homeserver asks for. */
export interface RegistrationInfo {
enabled: boolean
/** Every flow on offer needs a registration token. */
tokenRequired: boolean
/** Some flow takes a token, so offering the field makes sense. */
tokenAccepted: boolean
/** The server's words when it's off. */
reason?: string
}
interface UiaFlow {
stages: string[]
}
interface UiaState {
session?: string
flows?: UiaFlow[]
completed?: string[]
}
function uiaState(e: unknown): UiaState | null {
if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) {
return e.data as UiaState
}
return null
}
const supportedStages = new Set(['m.login.dummy', 'm.login.registration_token'])
export const useSession = defineStore('session', () => {
const saved = load()
const userId = ref(saved?.userId ?? '')
const deviceId = ref(saved?.deviceId ?? '')
const phase = ref<'signedOut' | 'starting' | 'ready'>(saved ? 'starting' : 'signedOut')
/** Set when the session ended on its own, shown on the sign-in page. */
const endedReason = ref('')
const signedIn = computed(() => phase.value !== 'signedOut')
const serverName = computed(() => userId.value.slice(userId.value.indexOf(':') + 1))
function clear(reason = ''): Promise<void> {
// The session is gone for good: its keys in IndexedDB go with it.
const done = userId.value && deviceId.value ? destroyClient({ userId: userId.value, deviceId: deviceId.value }) : Promise.resolve(stopClient())
clearMediaCache()
userId.value = ''
deviceId.value = ''
phase.value = 'signedOut'
endedReason.value = reason
save(null, false)
return done
}
async function begin(creds: Credentials) {
userId.value = creds.userId
deviceId.value = creds.deviceId
phase.value = 'starting'
const c = await startClient(creds)
c.on(HttpApiEvent.SessionLoggedOut, () => clear('Your session ended. Sign in again to continue.'))
phase.value = 'ready'
}
/** Picks the saved session back up on page load. */
async function restore() {
if (!saved || phase.value === 'ready') return
try {
await begin(saved)
} catch (e) {
if (e instanceof MatrixError && e.errcode === 'M_UNKNOWN_TOKEN') {
clear('Your session ended. Sign in again to continue.')
return
}
// Anything else (the homeserver unreachable, say) keeps the session
// and its encryption keys: the shell offers to try again.
stopClient()
throw e
}
}
async function adopt(res: LoginResponse | RegisterResponse, keep: boolean) {
if (!res.access_token || !res.device_id) throw new Error('The homeserver didn’t start a session.')
const creds: Credentials = {
baseUrl: config.homeserverUrl,
userId: res.user_id,
deviceId: res.device_id,
accessToken: res.access_token,
}
save(creds, keep)
await begin(creds)
}
async function signIn(username: string, password: string, keep: boolean) {
endedReason.value = ''
const res = await anonymousClient().loginRequest({
type: 'm.login.password',
identifier: { type: 'm.id.user', user: username.trim() },
password,
initial_device_display_name: deviceName,
})
await adopt(res, keep)
}
async function registrationInfo(): Promise<RegistrationInfo> {
try {
await anonymousClient().registerRequest({})
// A server that registers without any flow at all.
return { enabled: true, tokenRequired: false, tokenAccepted: false }
} catch (e) {
const uia = uiaState(e)
if (uia) {
const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
if (!flows.length) {
return {
enabled: false,
tokenRequired: false,
tokenAccepted: false,
reason: 'This homeserver asks for a sign-up step this client doesn’t support yet.',
}
}
const withToken = flows.filter((f) => f.stages.includes('m.login.registration_token'))
return { enabled: true, tokenRequired: withToken.length === flows.length, tokenAccepted: withToken.length > 0 }
}
if (e instanceof MatrixError && e.httpStatus === 403) {
return { enabled: false, tokenRequired: false, tokenAccepted: false, reason: e.data?.error }
}
throw e
}
}
/** Registers with the user-interactive auth flow: dummy and token stages. */
async function register(username: string, password: string, token: string, keep: boolean) {
endedReason.value = ''
const c = anonymousClient()
const body = {
username: username.trim(),
password,
initial_device_display_name: deviceName,
}
let auth: AuthDict | undefined
for (let step = 0; step < 6; step++) {
try {
const res = await c.registerRequest({ ...body, auth })
await adopt(res, keep)
return
} catch (e) {
const uia = uiaState(e)
if (!uia) throw e
// A stage that failed comes back with its error alongside the flows.
if (auth && e instanceof MatrixError && e.data?.errcode) {
throw new Error(e.data.error ?? 'The homeserver refused that sign-up step.')
}
const done = new Set(uia.completed ?? [])
const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
const flow =
flows.find((f) => (token ? f.stages.includes('m.login.registration_token') : !f.stages.includes('m.login.registration_token'))) ??
flows[0]
if (!flow) throw new Error('This homeserver asks for a sign-up step this client doesn’t support yet.')
const next = flow.stages.find((s) => !done.has(s))
if (!next) throw e
if (next === 'm.login.registration_token') {
if (!token) throw new Error('This homeserver needs a registration token to sign up.')
auth = { type: next, token, session: uia.session }
} else {
auth = { type: next, session: uia.session }
}
}
}
throw new Error('Registration didn’t complete. Try again.')
}
async function signOut() {
try {
await client().logout(true)
} catch {
// The token may already be gone; forget it either way.
}
await clear()
// A fresh page drops the crypto stack's memory and the store it still
// holds open, so the store is deleted now rather than on the next visit.
window.location.replace('/login')
}
return {
userId,
deviceId,
serverName,
phase,
signedIn,
endedReason,
restore,
signIn,
register,
registrationInfo,
signOut,
clear,
}
})
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// Short confirmations after an action ("Password reset for @alice:..."),
// and errors that have no form to sit next to.
export interface Toast {
id: number
tone: 'success' | 'danger' | 'info'
text: string
}
export const useToasts = defineStore('toasts', () => {
const items = ref<Toast[]>([])
let next = 1
function push(tone: Toast['tone'], text: string, ms = 5000) {
const id = next++
items.value.push({ id, tone, text })
window.setTimeout(() => dismiss(id), ms)
}
function dismiss(id: number) {
items.value = items.value.filter((t) => t.id !== id)
}
return {
items,
success: (text: string) => push('success', text),
error: (text: string) => push('danger', text, 8000),
info: (text: string) => push('info', text),
dismiss,
}
})
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@warp/ui/styles.css";
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { WLogo } from '@warp/ui'
import { useChat } from '@/stores/chat'
const chat = useChat()
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
<WLogo variant="symbol" :height="64" />
<h1 class="heading m-0">Pick a room</h1>
<p class="body max-w-sm text-ink-muted">
<template v-if="chat.invites.length">
You have {{ chat.invites.length === 1 ? 'an invite' : `${chat.invites.length} invites` }} waiting in the room list.
</template>
<template v-else>Choose a room from the list, or use + to create one, start a chat or join a room.</template>
</p>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WCheckbox, WTextField } from '@warp/ui'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { errorMessage } from '@/lib/format'
import { useSession } from '@/stores/session'
const session = useSession()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const remember = ref(true)
const busy = ref(false)
const error = ref('')
async function submit() {
if (!username.value || !password.value) return
busy.value = true
error.value = ''
try {
await session.signIn(username.value, password.value, remember.value)
password.value = ''
const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') ? route.query.next : '/'
router.replace(next)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<AuthLayout>
<form class="wp-card flex flex-col gap-4 p-6 shadow-raised" @submit.prevent="submit">
<div>
<h1 class="heading m-0">Sign in</h1>
<p class="body mt-1 text-ink-muted">With your account on this homeserver.</p>
</div>
<WBanner v-if="session.endedReason && !error" tone="info">{{ session.endedReason }}</WBanner>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<WTextField
v-model="username"
wide
mono
label="Username"
hint="Your username, like maja, or your full Matrix ID"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WTextField v-model="password" wide label="Password" type="password" autocomplete="current-password" required />
<WCheckbox v-model="remember" label="Stay signed in on this browser" />
<WButton type="submit" variant="primary" :loading="busy" :disabled="!username || !password">Sign in</WButton>
</form>
<p class="body mt-4 text-center text-ink-muted">
New here?
<RouterLink to="/register" class="font-semibold">Create an account</RouterLink>
</p>
</AuthLayout>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WCheckbox, WSpinner, WTextField } from '@warp/ui'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { errorMessage } from '@/lib/format'
import { useSession, type RegistrationInfo } from '@/stores/session'
const session = useSession()
const router = useRouter()
const info = ref<RegistrationInfo>()
const loadError = ref('')
const username = ref('')
const password = ref('')
const confirm = ref('')
const token = ref('')
const remember = ref(true)
const busy = ref(false)
const error = ref('')
onMounted(async () => {
try {
info.value = await session.registrationInfo()
} catch (e) {
loadError.value = errorMessage(e)
}
})
const usernameError = computed(() =>
username.value && !/^[a-z0-9._=\-/]+$/.test(username.value)
? 'Use lowercase letters, digits and . _ = - / only'
: undefined,
)
const confirmError = computed(() =>
confirm.value && confirm.value !== password.value ? 'The passwords don’t match' : undefined,
)
const ready = computed(
() =>
username.value &&
password.value.length >= 8 &&
confirm.value === password.value &&
!usernameError.value &&
(!info.value?.tokenRequired || token.value.trim()),
)
async function submit() {
if (!ready.value) return
busy.value = true
error.value = ''
try {
await session.register(username.value, password.value, token.value.trim(), remember.value)
router.replace('/')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<AuthLayout>
<div v-if="!info && !loadError" class="flex justify-center py-12">
<WSpinner :size="28" label="Checking registration" />
</div>
<div v-else-if="loadError || (info && !info.enabled)" class="wp-card flex flex-col gap-4 p-6 shadow-raised">
<h1 class="heading m-0">Create an account</h1>
<WBanner tone="warning" :title="loadError ? 'Can’t check registration' : 'Registration is off'">
{{ loadError || info?.reason || 'This homeserver isn’t taking new accounts. Ask its admin for one.' }}
</WBanner>
<RouterLink to="/login" class="font-semibold">Back to sign in</RouterLink>
</div>
<template v-else>
<form class="wp-card flex flex-col gap-4 p-6 shadow-raised" @submit.prevent="submit">
<div>
<h1 class="heading m-0">Create an account</h1>
<p class="body mt-1 text-ink-muted">
Your username becomes your Matrix ID, <span class="mxid">@{{ username || 'name' }}</span> on this homeserver. It
can’t be changed later.
</p>
</div>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<WTextField
v-model="username"
wide
mono
label="Username"
:error="usernameError"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WTextField
v-model="password"
wide
label="Password"
type="password"
hint="At least 8 characters"
autocomplete="new-password"
required
/>
<WTextField
v-model="confirm"
wide
label="Confirm password"
type="password"
:error="confirmError"
autocomplete="new-password"
required
/>
<WTextField
v-if="info?.tokenAccepted"
v-model="token"
wide
mono
:label="info.tokenRequired ? 'Registration token' : 'Registration token (optional)'"
hint="From the homeserver’s admin"
autocomplete="off"
spellcheck="false"
:required="info.tokenRequired"
/>
<WCheckbox v-model="remember" label="Stay signed in on this browser" />
<WButton type="submit" variant="primary" :loading="busy" :disabled="!ready">Create account</WButton>
</form>
<p class="body mt-4 text-center text-ink-muted">
Have an account?
<RouterLink to="/login" class="font-semibold">Sign in</RouterLink>
</p>
</template>
</AuthLayout>
</template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { KnownMembership } from 'matrix-js-sdk'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { WBanner, WButton, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import RoomInfoPane from '@/components/RoomInfoPane.vue'
import RoomTimeline from '@/components/RoomTimeline.vue'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { sendFile, sendText } from '@/matrix/send'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
const props = defineProps<{ roomId: string }>()
const chat = useChat()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
const room = computed(() => {
void chat.version
return client().getRoom(props.roomId)
})
const membership = computed(() => {
void chat.version
return room.value?.getMyMembership()
})
const summary = computed(() => chat.rooms.find((r) => r.roomId === props.roomId))
const invite = computed(() => chat.invites.find((r) => r.roomId === props.roomId))
const topic = computed(() => {
void chat.version
return String(room.value?.currentState.getStateEvents('m.room.topic', '')?.getContent().topic ?? '')
})
const encrypted = computed(() => summary.value?.encrypted ?? false)
// An encrypted room can't be written to without the crypto stack.
const cryptoMissing = computed(() => encrypted.value && !chat.canEncrypt)
const memberCount = computed(() => {
void chat.version
return room.value?.getJoinedMemberCount() ?? 0
})
const canSend = computed(() => {
void chat.version
const r = room.value
return r ? r.currentState.maySendMessage(me) : false
})
const infoOpen = ref(false)
const text = ref(drafts.get(props.roomId) ?? '')
watch(text, (v) => (v ? drafts.set(props.roomId, v) : drafts.delete(props.roomId)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
const typingNames = computed(() => {
void chat.version
return (room.value?.getMembers() ?? []).filter((m) => m.typing && m.userId !== me).map((m) => m.name)
})
const typingText = computed(() => {
const n = typingNames.value
if (!n.length) return ''
if (n.length === 1) return `${n[0]} is typing…`
if (n.length === 2) return `${n[0]} and ${n[1]} are typing…`
return `${n.length} people are typing…`
})
function send(body: string) {
setTyping(false)
sendText(client(), props.roomId, body).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
const r = room.value
if (!r) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
for (const file of Array.from(files)) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(client(), r, file, (p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
})
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
const dragging = ref(false)
function onDrop(e: DragEvent) {
dragging.value = false
const files = e.dataTransfer?.files
if (files && files.length && canSend.value && !cryptoMissing.value) sendFiles(files)
}
// Invites and rooms you're not in.
const joining = ref(false)
const joinError = ref('')
async function accept() {
joining.value = true
joinError.value = ''
try {
await chat.acceptInvite(props.roomId)
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
async function decline() {
joining.value = true
try {
await chat.leave(props.roomId)
router.replace('/')
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
async function joinHere() {
joining.value = true
joinError.value = ''
try {
await chat.join(props.roomId)
} catch (e) {
joinError.value = errorMessage(e)
} finally {
joining.value = false
}
}
const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'}`)
</script>
<template>
<!-- Invited -->
<div v-if="membership === KnownMembership.Invite && invite" class="flex flex-1 items-center justify-center px-4">
<div class="wp-card flex w-full max-w-[420px] flex-col items-center gap-4 p-6 text-center shadow-raised">
<MxcAvatar :name="invite.name" :mxc="invite.avatarMxc" :size="64" />
<div>
<h1 class="heading m-0">{{ invite.name }}</h1>
<p class="body mt-1 text-ink-muted">
<template v-if="invite.inviter">
<span class="body-strong text-ink">{{ invite.inviterName }}</span>
<span v-if="invite.inviterName !== invite.inviter" class="mxid"> ({{ invite.inviter }})</span>
invited you {{ invite.isDirect ? 'to chat' : 'to this room' }}.
</template>
<template v-else>You’re invited to this room.</template>
</p>
</div>
<WBanner v-if="joinError" tone="danger" class="w-full text-left">{{ joinError }}</WBanner>
<div class="flex gap-2">
<WButton :disabled="joining" @click="decline">Decline</WButton>
<WButton variant="primary" :loading="joining" @click="accept">Accept</WButton>
</div>
</div>
</div>
<!-- Not in the room -->
<div v-else-if="!room || membership !== KnownMembership.Join" class="flex flex-1 items-center justify-center px-4">
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 text-center">
<WIcon name="room" :size="40" class="text-ink-muted" />
<h1 class="heading m-0">You’re not in this room</h1>
<p class="mxid text-ink-muted">{{ roomId }}</p>
<WBanner v-if="joinError" tone="danger" class="w-full text-left">{{ joinError }}</WBanner>
<div class="flex gap-2">
<WButton variant="ghost" @click="router.replace('/')">Back</WButton>
<WButton variant="primary" :loading="joining" @click="joinHere">Join room</WButton>
</div>
</div>
</div>
<div v-else class="flex min-h-0 flex-1">
<div
class="relative flex min-w-0 flex-1 flex-col"
@dragover.prevent="dragging = canSend && !cryptoMissing"
@dragleave.self="dragging = false"
@drop.prevent="onDrop"
>
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-2 md:px-4">
<WIconButton class="md:hidden" icon="chevron-left" label="Back to rooms" @click="router.push('/')" />
<MxcAvatar :name="summary?.name ?? room.name" :mxc="summary?.avatarMxc" :size="36" :presence="summary?.presence" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" @click="infoOpen = !infoOpen">
<span class="flex items-center gap-2">
<h1 class="heading m-0 truncate">{{ summary?.name ?? room.name }}</h1>
<WIcon v-if="encrypted" name="lock" :size="16" class="shrink-0 text-teal-text" label="End-to-end encrypted" />
</span>
<span class="small block truncate text-ink-muted">
<template v-if="topic">{{ topic }}</template>
<template v-else>{{ memberCount }} {{ memberCount === 1 ? 'member' : 'members' }}</template>
</span>
</button>
<span v-if="!encrypted" class="caption hidden items-center gap-1 text-ink-muted sm:flex">
<WIcon name="shield-alert" :size="14" /> Not encrypted
</span>
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
</header>
<RoomTimeline :room="room" :is-direct="summary?.isDirect ?? false" />
<div class="shrink-0 bg-surface-sunken px-4 pt-2 pb-4">
<div class="mx-auto flex max-w-4xl flex-col gap-2">
<p class="caption m-0 h-4 text-ink-muted" aria-live="polite">{{ typingText }}</p>
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else @paste="onPaste">
<WComposer v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
</div>
</div>
</div>
<div
v-if="dragging"
class="pointer-events-none absolute inset-2 z-20 flex items-center justify-center rounded-lg border-2 border-dashed border-teal-strong bg-surface/80"
>
<p class="heading flex items-center gap-2 text-teal-text"><WIcon name="attach" /> Drop to send</p>
</div>
</div>
<RoomInfoPane v-if="infoOpen" :room="room" :is-direct="summary?.isDirect ?? false" @close="infoOpen = false" />
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "preserve",
"types": ["vite/client", "node"],
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// In development the client talks to a homeserver through Vite's proxy, the
// same paths warp-web proxies in production, so the code never needs to
// know where the homeserver is. WARP_WEB_HOMESERVER points it elsewhere.
const homeserver = process.env.WARP_WEB_HOMESERVER ?? 'http://localhost:8008'
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
// Embedded into the warp-web binary by internal/client.
outDir: 'internal/client/build/dist',
emptyOutDir: true,
assetsInlineLimit: 0,
// matrix-js-sdk is most of the bundle; it is one chunk on purpose.
chunkSizeWarningLimit: 1500,
},
server: {
port: 5175,
proxy: {
'/_matrix/client': { target: homeserver, changeOrigin: true },
'/_matrix/media': { target: homeserver, changeOrigin: true },
},
},
})