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

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