Files
Warp/apps/web/src/views/RoomView.vue
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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
Web client (warp-web) with E2EE, and two sync fixes
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>
2026-10-18 16:22:11 +02:00

275 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>