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

+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>