Files
Warp/apps/web/src/components/SettingsDialog.vue
T
tajniak81andClaude Opus 5.5 829b236efe
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 / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
Desktop: notifications, tray, auto-update, installer; web: sliding sync, voice messages, notifications
Web client (also what the desktop app runs):

- Simplified Sliding Sync (MSC4186) when the homeserver advertises
  org.matrix.simplified_msc3575. src/matrix/sliding.ts answers the SDK's
  GET /sync with a sliding sync request and rewrites the response into
  the /sync shape, as the Android app does; the SDK's own sliding sync
  mode doesn't sort thread replies into threads. 20 rooms first, then
  every room (up to 1000); to_device, e2ee, account data, receipts and
  typing extensions; M_UNKNOWN_POS starts a new connection. The SDK's
  /members?at=<sync token> drops the token, which here is ours. Settings
  can switch back to /sync.
- Notifications for new messages and incoming calls while Warp isn't in
  front, by the account's push rules, evaluated after decryption. The
  browser's Notification API once turned on in Settings, or the desktop
  app's bridge (window.warpDesktop). One per room; a click opens it.
- Voice messages: record from the composer, sent as m.audio with
  MSC3245's voice marker and MSC1767's duration and waveform (encrypted
  in encrypted rooms); a waveform player in the timeline.
- @warp/ui: a slot for extra composer buttons; bell-off, play and pause
  icons.

Desktop app:

- System notifications through Wails' notifications service, fed by
  /_desktop/bridge.js (/_desktop/notify, JSON only); clicking one shows
  the window and opens the room.
- Tray icon with Open and Quit; closing the window hides it (a one-time
  notification says so), File > Quit ends it. The page title goes to
  the window and the tray tooltip, the unread count to the taskbar or
  Dock badge.
- Auto-update from a Wails update manifest at DESKTOP_UPDATE_URL: a quiet
  check a minute after start and every six hours, a notification when a
  newer version exists, Help > Check for updates opens the update window.
  Optional signature key (DESKTOP_UPDATE_KEY). Linux package installs
  leave updates to the package manager. build/manifest writes the
  manifest.
- Per-user NSIS installer (make desktop-windows-installer), the macOS
  bundle zipped for the updater, a desktop CI job, and a Desktop release
  workflow that builds all three platforms and the manifest.

Tested on Windows against a local homeserver: sliding sync with E2EE,
threads and a server restart; notifications (toast history), close to
tray, relaunch restoring the window; voice messages recorded, sent and
played, encrypted too; the update check finding a newer version. Not
tested: installing an update, the NSIS installer, CI, macOS and Linux.
Code signing needs certificates and isn't done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:01:02 +02:00

232 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, usingSlidingSync } from '@/matrix/client'
import { setSlidingEnabled, slidingEnabled } from '@/matrix/sliding'
import { useNotifications } from '@/stores/notifications'
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 notifications = useNotifications()
// Sliding sync takes effect on the next start of the client.
const sliding = ref(slidingEnabled())
function toggleSliding() {
sliding.value = !sliding.value
setSlidingEnabled(sliding.value)
window.location.reload()
}
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-2 border-t border-line pt-4">
<p class="body-strong m-0">Notifications</p>
<div class="flex items-start gap-3">
<WIcon :name="notifications.active ? 'bell' : 'bell-off'" :class="['mt-[2px] shrink-0', notifications.active ? 'text-teal-text' : 'text-ink-muted']" />
<div class="min-w-0 flex-1">
<p class="body m-0">{{ notifications.active ? 'Notifications are on' : 'Notifications are off' }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="notifications.permission === 'unsupported'">This browser can’t show notifications.</template>
<template v-else-if="notifications.permission === 'denied'">The browser blocks notifications from this site. Allow them in its site settings.</template>
<template v-else>New messages and calls, when Warp isn’t in front. Your push rules decide which messages.</template>
</p>
</div>
<template v-if="notifications.permission !== 'unsupported' && notifications.permission !== 'denied'">
<WButton v-if="!notifications.active" size="sm" variant="primary" icon="bell" @click="notifications.enable()">Turn on</WButton>
<WButton v-else size="sm" variant="ghost" @click="notifications.enabled = false">Turn off</WButton>
</template>
</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>
<p class="small m-0 text-ink-muted">
Sync: {{ usingSlidingSync() ? 'Simplified Sliding Sync' : '/sync' }}
<button type="button" class="ml-1 font-semibold text-teal-text hover:underline" @click="toggleSliding">
{{ sliding ? 'Use /sync instead' : 'Use sliding sync when the server has it' }}
</button>
</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>