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
@@ -91,7 +91,7 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende
|
||||
text = 'Video'
|
||||
break
|
||||
case MsgType.Audio:
|
||||
text = 'Audio'
|
||||
text = content['org.matrix.msc3245.voice'] ? 'Voice message' : 'Audio'
|
||||
break
|
||||
case MsgType.File:
|
||||
text = `File: ${content.body ?? ''}`
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { EventType, MatrixEventEvent, RoomEvent, type MatrixClient, type MatrixEvent, type Room } from 'matrix-js-sdk'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { router } from '@/router'
|
||||
import { useCall } from './call'
|
||||
import { describeEvent } from './chat'
|
||||
|
||||
// Notifications for new messages and incoming calls, while the app isn't
|
||||
// in front of you. Which messages notify is the account's push rules
|
||||
// (the same ones that decide what your phone is told), evaluated here on
|
||||
// each live event once it is decrypted.
|
||||
//
|
||||
// In the desktop app they are the system's own notifications, through the
|
||||
// shell's bridge (window.warpDesktop, see apps/desktop); in a browser they
|
||||
// are the Notification API's, once you allow them.
|
||||
|
||||
export interface DesktopNotification {
|
||||
/** Replaces an earlier notification with the same ID: one per room. */
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
roomId: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Added by the desktop app's shell. */
|
||||
warpDesktop?: { notify(n: DesktopNotification): void }
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop app asks the client to open a room (a notification was clicked). */
|
||||
const openRoomEvent = 'warp-desktop:open-room'
|
||||
|
||||
export type NotificationPermissionState = NotificationPermission | 'unsupported' | 'desktop'
|
||||
|
||||
const offKey = 'warp.web.notifications'
|
||||
|
||||
function savedEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(offKey) !== 'off'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function currentPermission(): NotificationPermissionState {
|
||||
if (window.warpDesktop) return 'desktop'
|
||||
if (typeof Notification === 'undefined') return 'unsupported'
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
// A message this much older than now is history catching up (after
|
||||
// sleep, or a room arriving late), not news.
|
||||
const staleAfterMs = 5 * 60_000
|
||||
|
||||
export const useNotifications = defineStore('notifications', () => {
|
||||
const enabled = ref(savedEnabled())
|
||||
const permission = ref<NotificationPermissionState>(currentPermission())
|
||||
watch(enabled, (on) => {
|
||||
try {
|
||||
if (on) localStorage.removeItem(offKey)
|
||||
else localStorage.setItem(offKey, 'off')
|
||||
} catch {
|
||||
// Not kept: on again after a reload.
|
||||
}
|
||||
})
|
||||
|
||||
/** Whether notifications will actually show. */
|
||||
const active = computed(() => enabled.value && (permission.value === 'granted' || permission.value === 'desktop'))
|
||||
|
||||
/** Asks the browser for permission; must run from a click. */
|
||||
async function enable() {
|
||||
enabled.value = true
|
||||
if (permission.value === 'default') {
|
||||
permission.value = await Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
const shown = new Map<string, Notification>()
|
||||
const seen = new Set<string>()
|
||||
let attached: MatrixClient | null = null
|
||||
let since = 0
|
||||
|
||||
function inFront(roomId: string): boolean {
|
||||
const route = router.currentRoute.value
|
||||
return document.visibilityState === 'visible' && document.hasFocus() && route.name === 'room' && route.params.roomId === roomId
|
||||
}
|
||||
|
||||
function show(n: DesktopNotification) {
|
||||
if (!active.value) return
|
||||
if (window.warpDesktop) {
|
||||
window.warpDesktop.notify(n)
|
||||
return
|
||||
}
|
||||
shown.get(n.id)?.close()
|
||||
const note = new Notification(n.title, { body: n.body, tag: n.id, icon: '/favicon.svg' })
|
||||
note.onclick = () => {
|
||||
window.focus()
|
||||
openRoom(n.roomId)
|
||||
note.close()
|
||||
}
|
||||
note.onclose = () => {
|
||||
if (shown.get(n.id) === note) shown.delete(n.id)
|
||||
}
|
||||
shown.set(n.id, note)
|
||||
}
|
||||
|
||||
function openRoom(roomId: string) {
|
||||
void router.push({ name: 'room', params: { roomId } })
|
||||
}
|
||||
|
||||
function consider(c: MatrixClient, ev: MatrixEvent, room: Room) {
|
||||
const id = ev.getId()
|
||||
if (!id || seen.has(id)) return
|
||||
if (ev.getSender() === c.getUserId()) return
|
||||
if (ev.getTs() < since || Date.now() - ev.getTs() > staleAfterMs) return
|
||||
// Calls ring through the call store, below.
|
||||
const type = ev.getType()
|
||||
if (type === EventType.CallInvite || type === EventType.RTCNotification) return
|
||||
// Not decrypted yet: decide once it is.
|
||||
if (type === EventType.RoomMessageEncrypted && !ev.isDecryptionFailure()) {
|
||||
ev.once(MatrixEventEvent.Decrypted, () => consider(c, ev, room))
|
||||
return
|
||||
}
|
||||
if (ev.isDecryptionFailure() || ev.isRedacted()) return
|
||||
seen.add(id)
|
||||
if (seen.size > 500) seen.delete(seen.values().next().value!)
|
||||
if (!c.getPushActionsForEvent(ev, true)?.notify) return
|
||||
if (inFront(room.roomId)) return
|
||||
const me = c.getUserId() ?? ''
|
||||
const dms = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
|
||||
const direct = Object.values(dms).some((rooms) => Array.isArray(rooms) && rooms.includes(room.roomId))
|
||||
const text = describeEvent(ev, me, room, !direct)
|
||||
if (text === null) return
|
||||
show({ id: room.roomId, title: room.name, body: text, roomId: room.roomId })
|
||||
}
|
||||
|
||||
function onTimeline(ev: MatrixEvent, room: Room | undefined, toStart: boolean | undefined, removed: boolean, data: { liveEvent?: boolean }) {
|
||||
if (!attached || !room || toStart || removed || !data?.liveEvent) return
|
||||
if (!attached.isInitialSyncComplete()) return
|
||||
consider(attached, ev, room)
|
||||
}
|
||||
|
||||
function onOpenRoom(e: Event) {
|
||||
const roomId = (e as CustomEvent<string>).detail
|
||||
if (typeof roomId === 'string' && roomId) openRoom(roomId)
|
||||
}
|
||||
|
||||
/** Starts watching the running client for things to notify about. */
|
||||
function attach(c: MatrixClient) {
|
||||
if (attached === c) return
|
||||
attached = c
|
||||
since = Date.now() - 30_000
|
||||
permission.value = currentPermission()
|
||||
// Thread replies are in their thread's timeline; the room re-emits those.
|
||||
c.on(RoomEvent.Timeline, onTimeline)
|
||||
window.addEventListener(openRoomEvent, onOpenRoom)
|
||||
}
|
||||
|
||||
function detach() {
|
||||
attached?.off(RoomEvent.Timeline, onTimeline)
|
||||
attached = null
|
||||
window.removeEventListener(openRoomEvent, onOpenRoom)
|
||||
for (const n of shown.values()) n.close()
|
||||
shown.clear()
|
||||
seen.clear()
|
||||
}
|
||||
|
||||
// An incoming call is news even with the app open in another window.
|
||||
const call = useCall()
|
||||
watch(
|
||||
() => call.incoming,
|
||||
(inc) => {
|
||||
if (!inc || !attached || (document.visibilityState === 'visible' && document.hasFocus())) return
|
||||
show({
|
||||
id: inc.roomId,
|
||||
title: inc.roomName,
|
||||
body: `${inc.callerName}: ${inc.video ? 'Incoming video call' : 'Incoming voice call'}`,
|
||||
roomId: inc.roomId,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return { enabled, permission, active, enable, attach, detach }
|
||||
})
|
||||
Reference in new issue
Block a user