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,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>
|
||||
Reference in new issue
Block a user