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>
275 lines
11 KiB
Vue
275 lines
11 KiB
Vue
<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>
|