Desktop: notifications, tray, auto-update, installer; web: sliding sync, voice messages, notifications
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
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
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>
This commit is contained in:
1 parent
8ff2730676
commit
829b236efe
33 files changed
+2067
-51
No files matched your search
@@ -5,6 +5,7 @@ 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'
|
||||
import VoicePlayer from './VoicePlayer.vue'
|
||||
|
||||
// What goes inside a bubble: text with links, an image, a player or a file
|
||||
// card. Message text is never rendered as HTML.
|
||||
@@ -92,6 +93,14 @@ async function download() {
|
||||
<span v-else class="flex h-full items-center justify-center"><WSpinner /></span>
|
||||
</button>
|
||||
|
||||
<!-- Voice message -->
|
||||
<VoicePlayer
|
||||
v-else-if="item.msgtype === 'm.audio' && media?.mxc && media.voice"
|
||||
:source="mediaSource(media)!"
|
||||
:duration-ms="media.duration"
|
||||
:waveform="media.voice.waveform"
|
||||
/>
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
@@ -5,7 +5,8 @@ import { WBanner, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
|
||||
import { drafts } from '@/lib/drafts'
|
||||
import { errorMessage, formatBytes } from '@/lib/format'
|
||||
import { client, cryptoError } from '@/matrix/client'
|
||||
import { sendFile, sendText } from '@/matrix/send'
|
||||
import { sendFile, sendText, sendVoice } from '@/matrix/send'
|
||||
import { canRecord, formatDuration, VoiceRecorder } from '@/matrix/voice'
|
||||
import type { MessageItem } from '@/matrix/timeline'
|
||||
import { useChat } from '@/stores/chat'
|
||||
import { useToasts } from '@/stores/toasts'
|
||||
@@ -111,6 +112,58 @@ async function sendFiles(list: FileList | File[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice messages: the record button replaces typing until it is sent or
|
||||
// thrown away.
|
||||
const recordable = canRecord()
|
||||
const recording = ref(false)
|
||||
const elapsed = ref(0)
|
||||
const levels = ref<number[]>([])
|
||||
let recorder: VoiceRecorder | null = null
|
||||
|
||||
async function startRecording() {
|
||||
if (recording.value || !canWrite.value) return
|
||||
const rec = new VoiceRecorder()
|
||||
rec.onTick = (ms, level) => {
|
||||
elapsed.value = ms
|
||||
levels.value = [...levels.value.slice(-47), level]
|
||||
}
|
||||
rec.onLimit = () => void stopRecording(true)
|
||||
try {
|
||||
await rec.start()
|
||||
} catch (e) {
|
||||
rec.cancel()
|
||||
toasts.error(e instanceof DOMException && e.name === 'NotAllowedError' ? 'Allow the microphone to record a voice message.' : `Can’t record: ${errorMessage(e)}`)
|
||||
return
|
||||
}
|
||||
recorder = rec
|
||||
elapsed.value = 0
|
||||
levels.value = []
|
||||
recording.value = true
|
||||
}
|
||||
|
||||
async function stopRecording(send: boolean) {
|
||||
const rec = recorder
|
||||
recorder = null
|
||||
recording.value = false
|
||||
if (!rec) return
|
||||
if (!send) {
|
||||
rec.cancel()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const voice = await rec.stop()
|
||||
if (voice.durationMs < 700) return
|
||||
const to = target()
|
||||
replyTo.value = null
|
||||
const id = ++uploadId
|
||||
uploads.value.push({ id, name: 'voice message', loaded: 0, total: 0 })
|
||||
await sendVoice(client(), props.room, voice, to).finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
|
||||
} catch (e) {
|
||||
toasts.error(`Can’t send the voice message: ${errorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => recorder?.cancel())
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const files = e.clipboardData?.files
|
||||
if (files && files.length) {
|
||||
@@ -120,6 +173,11 @@ function onPaste(e: ClipboardEvent) {
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && recording.value) {
|
||||
e.stopPropagation()
|
||||
void stopRecording(false)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape' && replyTo.value) {
|
||||
e.stopPropagation()
|
||||
replyTo.value = null
|
||||
@@ -150,7 +208,22 @@ defineExpose({ sendFiles, canWrite, focus: () => composer.value?.focus() })
|
||||
</p>
|
||||
<WIconButton icon="close" label="Cancel reply" @click="replyTo = null" />
|
||||
</div>
|
||||
<WComposer ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
|
||||
<div v-if="recording" class="wp-composer items-center" role="group" aria-label="Recording a voice message">
|
||||
<WIconButton icon="trash" label="Discard the voice message" @click="stopRecording(false)" />
|
||||
<span class="size-2.5 shrink-0 animate-pulse rounded-full bg-danger" aria-hidden="true" />
|
||||
<span class="body tabular-nums">{{ formatDuration(elapsed) }}</span>
|
||||
<div class="flex h-6 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden" aria-hidden="true">
|
||||
<span v-for="(l, i) in levels" :key="i" class="w-[3px] shrink-0 rounded-full bg-ink-muted" :style="{ height: `${Math.max(12, Math.round(l * 100))}%` }" />
|
||||
</div>
|
||||
<button type="button" class="wp-composer-send" aria-label="Send the voice message" @click="stopRecording(true)">
|
||||
<WIcon name="send" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<WComposer v-else ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles">
|
||||
<template v-if="recordable && !text.trim()" #actions>
|
||||
<WIconButton icon="mic" label="Record a voice message" @click="startRecording" />
|
||||
</template>
|
||||
</WComposer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,9 @@ 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 { 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'
|
||||
@@ -21,6 +23,15 @@ 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)
|
||||
@@ -137,6 +148,25 @@ const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
|
||||
</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">
|
||||
@@ -183,6 +213,12 @@ const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { WIcon, WSpinner } from '@warp/ui'
|
||||
import { mediaUrl, type MediaSource } from '@/matrix/media'
|
||||
import { formatDuration } from '@/matrix/voice'
|
||||
|
||||
// A voice message: play/pause, its waveform filling in as it plays (click
|
||||
// to seek), and the time. The file loads on the first play.
|
||||
|
||||
const props = defineProps<{ source: MediaSource; durationMs?: number; waveform: number[] }>()
|
||||
|
||||
const bars = 40
|
||||
const heights = computed(() => {
|
||||
const w = props.waveform
|
||||
if (!w.length) return Array.from({ length: bars }, () => 0.3)
|
||||
return Array.from({ length: bars }, (_, i) => {
|
||||
const v = w[Math.floor((i * w.length) / bars)] ?? 0
|
||||
return Math.max(0.12, Math.min(1, v / 1024))
|
||||
})
|
||||
})
|
||||
|
||||
const audio = ref<HTMLAudioElement>()
|
||||
const src = ref<string>()
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const playing = ref(false)
|
||||
const position = ref(0)
|
||||
const length = ref(props.durationMs ? props.durationMs / 1000 : 0)
|
||||
|
||||
const progress = computed(() => (length.value ? Math.min(1, position.value / length.value) : 0))
|
||||
const shownTime = computed(() => formatDuration((playing.value || position.value ? position.value : length.value) * 1000))
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
if (src.value) return true
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
src.value = await mediaUrl(props.source)
|
||||
// Let the <audio> pick up the new src before it is played.
|
||||
await nextTick()
|
||||
return true
|
||||
} catch {
|
||||
failed.value = true
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (playing.value) {
|
||||
audio.value?.pause()
|
||||
return
|
||||
}
|
||||
if (!(await load()) || !audio.value) return
|
||||
await audio.value.play().catch(() => (failed.value = true))
|
||||
}
|
||||
|
||||
async function seek(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const frac = Math.min(1, Math.max(0, (e.clientX - el.getBoundingClientRect().left) / el.clientWidth))
|
||||
if (!(await load()) || !audio.value) return
|
||||
const total = Number.isFinite(audio.value.duration) ? audio.value.duration : length.value
|
||||
audio.value.currentTime = frac * total
|
||||
position.value = audio.value.currentTime
|
||||
}
|
||||
|
||||
function onMeta() {
|
||||
const d = audio.value?.duration
|
||||
// WebM recordings often report Infinity until played through.
|
||||
if (d && Number.isFinite(d)) length.value = d
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
playing.value = false
|
||||
position.value = 0
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => audio.value?.pause())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-[220px] items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-full border border-current/40 hover:bg-current/10"
|
||||
:aria-label="playing ? 'Pause voice message' : 'Play voice message'"
|
||||
@click="toggle"
|
||||
>
|
||||
<WSpinner v-if="loading" :size="16" />
|
||||
<WIcon v-else :name="playing ? 'pause' : 'play'" :size="18" />
|
||||
</button>
|
||||
<div class="flex h-8 flex-1 cursor-pointer items-center gap-[2px]" role="presentation" @click="seek">
|
||||
<span
|
||||
v-for="(h, i) in heights"
|
||||
:key="i"
|
||||
:class="['w-[3px] shrink-0 rounded-full bg-current', i / bars < progress ? 'opacity-100' : 'opacity-35']"
|
||||
:style="{ height: `${Math.round(h * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="caption w-10 shrink-0 text-right opacity-75 tabular-nums">{{ failed ? '—' : shownTime }}</span>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="src"
|
||||
preload="auto"
|
||||
class="hidden"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@ended="onEnded"
|
||||
@timeupdate="position = audio?.currentTime ?? 0"
|
||||
@loadedmetadata="onMeta"
|
||||
@durationchange="onMeta"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user