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
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:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IconName } from '@warp/ui'
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
icon: IconName
|
||||
danger?: boolean
|
||||
action: () => void
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
public?: boolean
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
// Unsent composer text per room. It survives switching rooms, not reloads.
|
||||
export const drafts = new Map<string, string>()
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@warp/ui/styles.css";
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user