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

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