diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c33e2e7ff..6cc23dacc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6741,7 +6741,7 @@ dependencies = [ [[package]] name = "tauri-plugin-notifications" version = "0.5.0-rc.11" -source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=0175b5ec58a31ab15397e981a67f47fa32254620#0175b5ec58a31ab15397e981a67f47fa32254620" +source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=057d323862d815a0c7459a1f1acd5a3c08750a51#057d323862d815a0c7459a1f1acd5a3c08750a51" dependencies = [ "log", "notify-rust", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee6cfdbb3..f06327d19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -91,12 +91,12 @@ windows = { version = "0.61", features = [ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } [target.'cfg(any(windows, target_os = "linux"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0175b5ec58a31ab15397e981a67f47fa32254620" } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "057d323862d815a0c7459a1f1acd5a3c08750a51" } # default-features = false drops notify-rust so macOS uses the native # UNUserNotificationCenter backend (needs a signed .app to deliver). [target.'cfg(target_os = "macos")'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0175b5ec58a31ab15397e981a67f47fa32254620", default-features = false } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "057d323862d815a0c7459a1f1acd5a3c08750a51", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = { version = "2", optional = true } @@ -119,7 +119,7 @@ cef = { version = "=150.2.1", optional = true } libloading = "0.8" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "0175b5ec58a31ab15397e981a67f47fa32254620", features = [ +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "057d323862d815a0c7459a1f1acd5a3c08750a51", features = [ "push-notifications", ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } diff --git a/src/app/features/settings/notifications/TauriNotificationsApiClient.ts b/src/app/features/settings/notifications/TauriNotificationsApiClient.ts index 71716c82d..d5d7face9 100644 --- a/src/app/features/settings/notifications/TauriNotificationsApiClient.ts +++ b/src/app/features/settings/notifications/TauriNotificationsApiClient.ts @@ -20,6 +20,7 @@ export type TauriNotificationsApi = { importance?: number; vibration?: boolean; }) => Promise; + removeChannel: (id: string) => Promise; isPermissionGranted: () => Promise; requestPermission: () => Promise; sendNotification: (payload: Record) => Promise; diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts index 1a624b27e..353f77ef9 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -14,8 +14,10 @@ const notificationsApi = vi.hoisted(() => ({ sendNotification: vi.fn<(notification: Record) => Promise>(), removeActive: vi.fn<(notifications: Array<{ id: number }>) => Promise>(), createChannel: vi.fn<() => void>(), + removeChannel: vi.fn<() => Promise>(), Importance: { Default: 3, + High: 4, }, })); @@ -101,6 +103,7 @@ describe('UnifiedPushNotifications', () => { notificationsApi.sendNotification.mockResolvedValue(undefined); notificationsApi.removeActive.mockResolvedValue(undefined); notificationsApi.createChannel.mockResolvedValue(undefined); + notificationsApi.removeChannel.mockResolvedValue(undefined); getTauriNotificationsApi.mockResolvedValue(notificationsApi); unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ status: 'registered', @@ -180,6 +183,55 @@ describe('UnifiedPushNotifications', () => { await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledTimes(2)); }); + it('posts message notifications as a conversation on the high-importance channel', async () => { + matrixClient.getRoom.mockReturnValue(makeRoom()); + + await listenAndPush({ + type: 'm.room.message', + room_id: '!room:example.com', + room_name: 'Room', + event_id: '$plain:example.com', + sender: '@alice:example.com', + sender_display_name: 'Alice', + content: { body: 'hello' }, + }); + + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); + expect(notificationsApi.sendNotification.mock.calls[0]?.[0]).toMatchObject({ + channelId: 'messages.v2', + groupConversation: false, + messages: [{ body: 'hello', senderName: 'Alice', senderKey: '@alice:example.com' }], + }); + }); + + it('posts invitations on their own channel', async () => { + await listenAndPush({ + type: 'm.room.member', + room_id: '!room:example.com', + room_name: 'Room', + sender_display_name: 'Alice', + content: { membership: 'invite' }, + }); + + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); + expect(notificationsApi.sendNotification.mock.calls[0]?.[0]).toMatchObject({ + channelId: 'invites', + title: 'New Invitation', + }); + }); + + it('creates the messages channel at high importance and drops the legacy one', async () => { + await tryEnableUnifiedPush(matrixClient as never); + + expect(notificationsApi.createChannel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'messages.v2', importance: 4 }) + ); + expect(notificationsApi.createChannel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'invites' }) + ); + expect(notificationsApi.removeChannel).toHaveBeenCalledWith('messages'); + }); + it('silently updates the same notification after encrypted decryption succeeds', async () => { matrixClient.getRoom.mockReturnValue(makeRoom()); matrixClient.getCrypto.mockReturnValue({ @@ -232,7 +284,7 @@ describe('UnifiedPushNotifications', () => { expect(decryptEvent).not.toHaveBeenCalled(); }); - it('discards encrypted enrichment that resolves after the observation deadline', async () => { + it('applies encrypted enrichment that only decrypts long after the baseline post', async () => { vi.useFakeTimers(); try { matrixClient.getRoom.mockReturnValue(makeRoom()); @@ -244,18 +296,50 @@ describe('UnifiedPushNotifications', () => { decryptEvent: vi.fn<() => Promise>>().mockReturnValue(decryption), }); - await listenAndPush(encryptedPush('$deadline:example.com')); + await listenAndPush(encryptedPush('$late:example.com')); await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); - vi.advanceTimersByTime(2_500); + vi.advanceTimersByTime(30_000); resolveDecryption({ clearEvent: { type: 'm.room.message', content: { body: 'late plaintext' }, }, }); - await Promise.resolve(); - await Promise.resolve(); + + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledTimes(2)); + expect(notificationsApi.sendNotification.mock.calls[1]?.[0]).toMatchObject({ + body: 'You: late plaintext', + silent: true, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('stops waiting for the room key once the retry window elapses', async () => { + vi.useFakeTimers(); + try { + matrixClient.getRoom.mockReturnValue(makeRoom()); + let resolveDecryption!: (content: Record) => void; + const decryption = new Promise>((resolve) => { + resolveDecryption = resolve; + }); + matrixClient.getCrypto.mockReturnValue({ + decryptEvent: vi.fn<() => Promise>>().mockReturnValue(decryption), + }); + + await listenAndPush(encryptedPush('$expired:example.com')); + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + resolveDecryption({ + clearEvent: { + type: 'm.room.message', + content: { body: 'far too late' }, + }, + }); + await vi.advanceTimersByTimeAsync(0); expect(notificationsApi.sendNotification).toHaveBeenCalledOnce(); } finally { @@ -386,20 +470,8 @@ describe('UnifiedPushNotifications', () => { ); }); - it('serializes cross-room summary writes and refreshes from current caches', async () => { + it('posts one notification per room and no account-level summary', async () => { matrixClient.getRoom.mockReturnValue(makeRoom()); - const summaryPosts: Record[] = []; - let releaseFirstSummary!: () => void; - const firstSummary = new Promise((resolve) => { - releaseFirstSummary = resolve; - }); - notificationsApi.sendNotification.mockImplementation(async (notification) => { - if (notification.groupSummary) { - summaryPosts.push(notification); - if (summaryPosts.length === 1) await firstSummary; - } - }); - await listenForUnifiedPushMessages(() => makeSettings() as never); const roomPayload = (roomId: string, eventId: string, roomName: string) => ({ ...encryptedPush(eventId), @@ -407,108 +479,38 @@ describe('UnifiedPushNotifications', () => { room_name: roomName, }); pushHandler({ message: JSON.stringify(roomPayload('!room-a:example.com', '$a', 'Room A')) }); - await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); pushHandler({ message: JSON.stringify(roomPayload('!room-b:example.com', '$b', 'Room B')) }); - await vi.waitFor(() => expect(summaryPosts).toHaveLength(1)); - - pushHandler({ message: JSON.stringify(roomPayload('!room-c:example.com', '$c', 'Room C')) }); - releaseFirstSummary(); - await vi.waitFor(() => expect(summaryPosts).toHaveLength(2)); - expect(summaryPosts[0]).toMatchObject({ title: '2 messages in 2 chats' }); - expect(summaryPosts[1]).toMatchObject({ title: '3 messages in 3 chats' }); + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledTimes(2)); + const posted = notificationsApi.sendNotification.mock.calls.map( + ([notification]) => notification + ); + expect(posted.some((notification) => notification.groupSummary)).toBe(false); + expect(new Set(posted.map((notification) => notification.title))).toEqual( + new Set(['Room A', 'Room B']) + ); + expect(new Set(posted.map((notification) => notification.id)).size).toBe(2); }); - it('compensates a summary that observed a baseline pending for another room', async () => { - matrixClient.getRoom.mockReturnValue(makeRoom()); - let rejectFailedPost!: (error: Error) => void; - let failedPostStarted!: () => void; - const failedPost = new Promise((_, reject) => { - rejectFailedPost = reject; - }); - const failedPostStartedPromise = new Promise((resolve) => { - failedPostStarted = resolve; - }); - const summaryPosts: Record[] = []; - notificationsApi.sendNotification.mockImplementation(async (notification) => { - const eventId = (notification.extra as Record | undefined)?.event_id; - if (eventId === '$a-failed') { - failedPostStarted(); - await failedPost; - return; - } - if (notification.groupSummary) summaryPosts.push(notification); - }); - + it('dismisses a group summary left behind by an older version', async () => { await listenForUnifiedPushMessages(() => makeSettings() as never); - const roomPayload = ( - roomId: string, - eventId: string, - roomName: string, - sender_display_name: string - ) => ({ - ...encryptedPush(eventId), - room_id: roomId, - room_name: roomName, - sender_display_name, - }); - pushHandler({ - message: JSON.stringify( - roomPayload('!room-a:example.com', '$a-old', 'Room A', 'Old message') - ), - }); - await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); - - pushHandler({ - message: JSON.stringify( - roomPayload('!room-a:example.com', '$a-failed', 'Room A', 'Failed message') - ), - }); - await failedPostStartedPromise; - pushHandler({ - message: JSON.stringify(roomPayload('!room-b:example.com', '$b', 'Room B', 'B message')), - }); - await vi.waitFor(() => expect(summaryPosts).toHaveLength(1)); - - rejectFailedPost(new Error('pending baseline failed')); - await vi.waitFor(() => expect(summaryPosts).toHaveLength(2)); - expect(summaryPosts[0]?.inboxLines).toEqual( - expect.arrayContaining(['Room A: Failed message: Encrypted message']) - ); - expect(summaryPosts[1]?.inboxLines).toEqual( - expect.arrayContaining(['Room A: Old message: Encrypted message']) - ); - expect(summaryPosts[1]?.inboxLines).not.toEqual( - expect.arrayContaining(['Room A: Failed message: Encrypted message']) - ); + await vi.waitFor(() => expect(notificationsApi.removeActive).toHaveBeenCalledOnce()); + expect(notificationsApi.removeActive.mock.calls[0]?.[0]).toEqual([{ id: expect.any(Number) }]); }); - it('still cleans up the account summary when child removal fails', async () => { + it('clears a room even when dismissing its notification fails', async () => { matrixClient.getRoom.mockReturnValue(makeRoom()); await listenForUnifiedPushMessages(() => makeSettings() as never); - const roomPayload = (roomId: string, eventId: string) => ({ - ...encryptedPush(eventId), - room_id: roomId, - }); - pushHandler({ message: JSON.stringify(roomPayload('!room-a:example.com', '$a')) }); - pushHandler({ message: JSON.stringify(roomPayload('!room-b:example.com', '$b')) }); - await vi.waitFor(() => - expect( - notificationsApi.sendNotification.mock.calls.some( - ([notification]) => notification.groupSummary - ) - ).toBe(true) - ); - - let removeAttempts = 0; - notificationsApi.removeActive.mockImplementation(async () => { - removeAttempts += 1; - if (removeAttempts === 1) throw new Error('child removal failed'); + pushHandler({ + message: JSON.stringify({ ...encryptedPush('$a'), room_id: '!room-a:example.com' }), }); - await clearRoomNotification('@user:example.com', '!room-a:example.com'); + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledOnce()); - expect(removeAttempts).toBe(2); + notificationsApi.removeActive.mockRejectedValueOnce(new Error('child removal failed')); + await expect( + clearRoomNotification('@user:example.com', '!room-a:example.com') + ).resolves.toBeUndefined(); }); it('checks preview policy again after a minimal event resolves', async () => { diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 3375271e6..b6506caf4 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -3,6 +3,7 @@ import { type IPusherRequest, type MatrixClient, MatrixEvent, + MatrixEventEvent, } from '$types/matrix-sdk'; import { EventType } from 'matrix-js-sdk/lib/@types/event'; import { @@ -11,7 +12,6 @@ import { } from '$utils/notificationStyle'; import { getMxIdLocalPart } from '$utils/matrix'; import { getStateEvent } from '$utils/room/hierarchy'; -import { getMemberAvatarMxc } from '$utils/room/display'; import { createDebugLogger } from '$utils/debugLogger'; import { registerUnifiedPushTransport, @@ -56,6 +56,34 @@ type UnifiedPushPayload = { const UP_REGISTER_TIMEOUT_MS = 30_000; +// Android freezes a channel's importance at creation, so raising `messages` from +// Default to High needs a new id. Mirrored in the plugin's UnifiedPushNotifier. +const MESSAGES_CHANNEL_ID = 'messages.v2'; +const INVITES_CHANNEL_ID = 'invites'; +const LEGACY_MESSAGES_CHANNEL_ID = 'messages'; + +async function ensureNotificationChannels( + notificationsApi: Awaited> +): Promise { + await notificationsApi.createChannel({ + id: MESSAGES_CHANNEL_ID, + name: 'Messages', + description: 'Matrix message notifications', + importance: notificationsApi.Importance.High, + vibration: true, + }); + await notificationsApi.createChannel({ + id: INVITES_CHANNEL_ID, + name: 'Invitations', + description: 'Room and space invitations', + importance: notificationsApi.Importance.Default, + vibration: true, + }); + await notificationsApi.removeChannel(LEGACY_MESSAGES_CHANNEL_ID).catch(() => { + // Never created on this device, or already gone. + }); +} + export type UnifiedPushTransportConfigInput = Pick< PushTransportConfig, 'unifiedPushGatewayUrl' | 'unifiedPushAppID' @@ -117,14 +145,7 @@ export async function tryEnableUnifiedPush( config?: UnifiedPushTransportConfigInput ): Promise { const notificationsApi = await getTauriNotificationsApi(); - - await notificationsApi.createChannel({ - id: 'messages', - name: 'Messages', - description: 'Matrix message and invite notifications', - importance: notificationsApi.Importance.Default, - vibration: true, - }); + await ensureNotificationChannels(notificationsApi); const registration = await registerUnifiedPushWithTimeout(config?.vapidPublicKey); @@ -305,12 +326,11 @@ type NotificationSettings = { const NOTIF_GROUP_KEY = 'matrix_messages'; const MAX_MESSAGES = 10; const MAX_SEEN_EVENT_IDS = 200; -const ENCRYPTED_PREVIEW_OBSERVATION_DEADLINE_MS = 2_500; +const ENCRYPTED_PREVIEW_RETRY_WINDOW_MS = 5 * 60_000; type NotifPerson = { name: string; key?: string; - iconUrl?: string; }; type NotifMessage = { @@ -351,19 +371,16 @@ async function resolvePreviewEvent( } /** - * Decrypts the ciphertext from a rich push payload locally — no homeserver - * fetch needed. The Megolm session keys are in the crypto store. This is - * what makes encrypted notifications as fast as Element. + * Rebuilds the encrypted event from a rich push payload so it can be decrypted + * locally — no homeserver fetch needed, the Megolm keys are in the crypto store. */ -async function decryptPreviewFromPayload( - mx: MatrixClient, +function buildEncryptedPreviewEvent( roomId: string, eventId: string, pushData: UnifiedPushPayload -): Promise { - const crypto = mx.getCrypto(); - if (!crypto || !pushData.content) return undefined; - const mEvent = new MatrixEvent({ +): MatrixEvent | undefined { + if (!pushData.content) return undefined; + return new MatrixEvent({ type: 'm.room.encrypted', content: pushData.content, room_id: roomId, @@ -371,9 +388,38 @@ async function decryptPreviewFromPayload( sender: pushData.sender, origin_server_ts: Date.now(), }); - await mEvent.attemptDecryption(crypto as CryptoBackend); - if (mEvent.isDecryptionFailure()) throw new Error('Encrypted preview decryption failed'); - return mEvent; +} + +function holdsPlaintext(event: MatrixEvent): boolean { + return ( + !event.isDecryptionFailure() && event.getType() !== EventType.RoomMessageEncrypted.toString() + ); +} + +/** + * Runs `apply` as soon as `event` holds plaintext: right away when it is already + * decrypted, or later once the Megolm key arrives — a backgrounded app routinely + * receives the push before the to-device key, and the SDK retries decryption on + * its own when the key lands. + */ +function whenDecrypted(event: MatrixEvent, apply: () => Promise): void { + if (holdsPlaintext(event)) { + void apply(); + return; + } + + const onDecrypted = () => { + if (!holdsPlaintext(event)) return; + event.off(MatrixEventEvent.Decrypted, onDecrypted); + clearTimeout(retryWindowTimer); + void apply(); + }; + + event.on(MatrixEventEvent.Decrypted, onDecrypted); + const retryWindowTimer = setTimeout(() => { + event.off(MatrixEventEvent.Decrypted, onDecrypted); + unifiedPushLog.warn('notification', 'Encrypted preview never decrypted within retry window'); + }, ENCRYPTED_PREVIEW_RETRY_WINDOW_MS); } const roomNotifId = (userId: string, roomId: string) => hashCode(`${userId}\u0000${roomId}`); @@ -392,7 +438,6 @@ type RoomNotifCache = { const roomNotifCaches = new Map(); const roomNotifGenerations = new Map(); const roomNotifQueues = new Map>(); -const accountSummaryQueues = new Map>(); function enqueueRoomOperation(key: string, operation: () => Promise): Promise { const previous = roomNotifQueues.get(key) ?? Promise.resolve(); @@ -408,31 +453,6 @@ function enqueueRoomOperation(key: string, operation: () => Promise): Prom return task; } -function enqueueAccountSummaryOperation( - userId: string, - operation: () => Promise -): Promise { - const previous = accountSummaryQueues.get(userId) ?? Promise.resolve(); - const task = previous.then(operation, operation); - const completion = task.then( - () => undefined, - () => undefined - ); - accountSummaryQueues.set(userId, completion); - void completion.then(() => { - if (accountSummaryQueues.get(userId) === completion) accountSummaryQueues.delete(userId); - }); - return task; -} - -function resolveAvatarUrl(mx: MatrixClient, roomId: string, userId: string): string | undefined { - const room = mx.getRoom(roomId); - if (!room) return undefined; - const mxcUrl = getMemberAvatarMxc(room, userId); - if (!mxcUrl) return undefined; - return mx.mxcUrlToHttp(mxcUrl, 96, 96, 'crop', false, true, true) ?? undefined; -} - function getOrCreateRoomCache(userId: string, roomId: string, roomName: string): RoomNotifCache { const key = `${userId}\u0000${roomId}`; let cache = roomNotifCaches.get(key); @@ -463,71 +483,18 @@ export function resetUnifiedPushNotificationStateForTests(): void { roomNotifCaches.clear(); roomNotifGenerations.clear(); roomNotifQueues.clear(); - accountSummaryQueues.clear(); } -async function refreshAccountSummary( - userId: string, - removeWhenEmpty: boolean, - isCurrent?: () => boolean -): Promise { - await enqueueAccountSummaryOperation(userId, async () => { - let notificationsApi: Awaited>; - try { - notificationsApi = await getTauriNotificationsApi(); - } catch { - return; - } - if (isCurrent && !isCurrent()) return; - - const accountCaches = Array.from(roomNotifCaches.entries()).filter(([key]) => - key.startsWith(`${userId}\u0000`) - ); - const roomCount = accountCaches.length; - if (roomCount <= 1) { - if (removeWhenEmpty) { - try { - await notificationsApi.removeActive([{ id: summaryNotifId(userId) }]); - } catch { - // already dismissed - } - } - return; - } - - const totalMessages = accountCaches - .map(([, accountCache]) => accountCache) - .reduce((sum, accountCache) => sum + accountCache.messages.length, 0); - const summaryText = `${totalMessages} messages in ${roomCount} chats`; - const summaryLines: string[] = []; - accountCaches.forEach(([, accountCache]) => { - const latest = accountCache.messages[accountCache.messages.length - 1]; - if (latest) { - summaryLines.push( - `${accountCache.roomName}: ${latest.sender?.name ?? 'You'}: ${latest.text}` - ); - } - }); - - if (isCurrent && !isCurrent()) return; - try { - await notificationsApi.sendNotification({ - id: summaryNotifId(userId), - title: summaryText, - body: '', - summary: summaryText, - inboxLines: summaryLines.slice(-5), - channelId: 'messages', - group: NOTIF_GROUP_KEY, - groupSummary: true, - icon: 'notification_icon', - silent: true, - autoCancel: true, - }); - } catch { - // The room notification was already posted; a summary is best effort. - } - }); +// Older versions posted an account-level summary that collapsed every room into +// one entry; Android bundles the per-room notifications on its own. +async function dismissLegacyGroupSummary(userId: string): Promise { + if (!userId) return; + try { + const notificationsApi = await getTauriNotificationsApi(); + await notificationsApi.removeActive([{ id: summaryNotifId(userId) }]); + } catch { + // Nothing to dismiss, or the plugin is unavailable. + } } /** Clears accumulated messages for a room and dismisses its notification. */ @@ -542,7 +509,6 @@ export async function clearRoomNotification(userId: string, roomId: string) { } catch { // already dismissed } - await refreshAccountSummary(userId, true); }); } @@ -566,17 +532,24 @@ async function postRoomNotification( id: roomNotifId(userId, roomId), title: roomName, body: latestBody, - channelId: 'messages', + channelId: MESSAGES_CHANNEL_ID, group: NOTIF_GROUP_KEY, icon: 'notification_icon', silent: isSilent, autoCancel: true, extra, ...(isMobileTauri() ? { actionTypeId: 'sable-message' } : {}), + // Android renders these as a conversation; inbox/big-text covers the rest. + messages: messages.map((m) => ({ + body: m.text, + timestamp: m.timestamp, + senderName: m.sender?.name, + senderKey: m.sender?.key, + })), + groupConversation: cache.isGroupConversation, inboxLines: inboxLines.length > 1 ? inboxLines : undefined, largeBody: inboxLines.length > 1 ? undefined : latestBody, }); - await refreshAccountSummary(userId, false, isCurrent); return true; } @@ -615,7 +588,7 @@ async function handleRichPushPayload( await notificationsApi.sendNotification({ title: roomName, body: senderName ? `${senderName}: ${previewText}` : previewText, - channelId: 'messages', + channelId: MESSAGES_CHANNEL_ID, icon: 'notification_icon', silent: isSilent, autoCancel: true, @@ -655,11 +628,7 @@ async function handleRichPushPayload( } const sender: NotifPerson | undefined = senderName - ? { - name: senderName, - key: senderId, - iconUrl: senderId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined, - } + ? { name: senderName, key: senderId } : undefined; const message: NotifMessage = { @@ -698,7 +667,6 @@ async function handleRichPushPayload( cache.messages = previousMessages; if (eventId) cache.pendingEventIds.delete(eventId); if (cache.messages.length === 0) roomNotifCaches.delete(cache.key); - await refreshAccountSummary(userId, true); unifiedPushLog.warn('notification', 'UnifiedPush baseline notification failed'); return false; } @@ -749,7 +717,7 @@ async function handleRichPushPayload( title: 'New Invitation', body, largeBody: body, - channelId: 'messages', + channelId: INVITES_CHANNEL_ID, group: NOTIF_GROUP_KEY, icon: 'notification_icon', autoCancel: true, @@ -779,102 +747,80 @@ function scheduleEncryptedPreviewEnrichment( const initialSettings = getSettings(); if (!initialSettings.showMessageContent || !initialSettings.showEncryptedMessageContent) return; - let settled = false; - let deadlineExpired = false; - const deadlineTimer = setTimeout(() => { - if (!settled) { - deadlineExpired = true; - unifiedPushLog.warn( - 'notification', - 'Encrypted preview decryption exceeded observation deadline' - ); - } - }, ENCRYPTED_PREVIEW_OBSERVATION_DEADLINE_MS); - - const enrichment = decryptPreviewFromPayload(initialSettings.mx, roomId, eventId, pushData); - void enrichment - .then(async (decrypted) => { - if (deadlineExpired || !decrypted) return; - - await enqueueRoomOperation(cache.key, async () => { - const liveSettings = getSettings(); - const isAllowed = - liveSettings.showMessageContent && - liveSettings.showEncryptedMessageContent && - !(document.visibilityState === 'visible' && liveSettings.useInAppNotifications); - if (!isAllowed || !isCurrentRoomCache(cache) || !cache.messages.includes(message)) { - return; - } + const crypto = initialSettings.mx.getCrypto(); + const decrypted = buildEncryptedPreviewEvent(roomId, eventId, pushData); + if (!crypto || !decrypted) return; + + const applyDecryptedPreview = async (): Promise => { + await enqueueRoomOperation(cache.key, async () => { + const liveSettings = getSettings(); + const isAllowed = + liveSettings.showMessageContent && + liveSettings.showEncryptedMessageContent && + !(document.visibilityState === 'visible' && liveSettings.useInAppNotifications); + if (!isAllowed || !isCurrentRoomCache(cache) || !cache.messages.includes(message)) { + return; + } - const enrichedPreview = resolveNotificationPreviewText({ - content: decrypted.getContent(), - eventType: decrypted.getType(), - isEncryptedRoom: true, - showMessageContent: liveSettings.showMessageContent, - showEncryptedMessageContent: liveSettings.showEncryptedMessageContent, - }); - if (!enrichedPreview || enrichedPreview === ENCRYPTED_MESSAGE_PREVIEW) return; - - const liveRoom = liveSettings.mx.getRoom(roomId); - const decryptedSender = decrypted.getSender(); - const senderName = decryptedSender - ? (liveRoom?.getMember(decryptedSender)?.name ?? - getMxIdLocalPart(decryptedSender) ?? - decryptedSender) - : undefined; - const previousText = message.text; - const previousSender = message.sender; - message.text = enrichedPreview; - message.sender = senderName - ? { - name: senderName, - key: decryptedSender, - iconUrl: decryptedSender - ? resolveAvatarUrl(liveSettings.mx, roomId, decryptedSender) - : undefined, - } - : message.sender; - - const current = () => { - const currentSettings = getSettings(); - return ( - isCurrentRoomCache(cache) && - cache.messages.includes(message) && - currentSettings.showMessageContent && - currentSettings.showEncryptedMessageContent && - !(document.visibilityState === 'visible' && currentSettings.useInAppNotifications) - ); - }; - try { - const posted = await postRoomNotification( - userId, - roomId, - cache, - true, - { - room_id: roomId, - event_id: eventId, - user_id: pushData?.user_id, - }, - current - ); - if (!posted) { - message.text = previousText; - message.sender = previousSender; - } - } catch { + const enrichedPreview = resolveNotificationPreviewText({ + content: decrypted.getContent(), + eventType: decrypted.getType(), + isEncryptedRoom: true, + showMessageContent: liveSettings.showMessageContent, + showEncryptedMessageContent: liveSettings.showEncryptedMessageContent, + }); + if (!enrichedPreview || enrichedPreview === ENCRYPTED_MESSAGE_PREVIEW) return; + + const liveRoom = liveSettings.mx.getRoom(roomId); + const decryptedSender = decrypted.getSender(); + const senderName = decryptedSender + ? (liveRoom?.getMember(decryptedSender)?.name ?? + getMxIdLocalPart(decryptedSender) ?? + decryptedSender) + : undefined; + const previousText = message.text; + const previousSender = message.sender; + message.text = enrichedPreview; + message.sender = senderName ? { name: senderName, key: decryptedSender } : message.sender; + + const current = () => { + const currentSettings = getSettings(); + return ( + isCurrentRoomCache(cache) && + cache.messages.includes(message) && + currentSettings.showMessageContent && + currentSettings.showEncryptedMessageContent && + !(document.visibilityState === 'visible' && currentSettings.useInAppNotifications) + ); + }; + try { + const posted = await postRoomNotification( + userId, + roomId, + cache, + true, + { + room_id: roomId, + event_id: eventId, + user_id: pushData?.user_id, + }, + current + ); + if (!posted) { message.text = previousText; message.sender = previousSender; } - }); - }) - .catch(() => { - unifiedPushLog.warn('notification', 'Encrypted preview decryption failed'); - }) - .finally(() => { - settled = true; - clearTimeout(deadlineTimer); + } catch { + message.text = previousText; + message.sender = previousSender; + } }); + }; + + whenDecrypted(decrypted, applyDecryptedPreview); + void decrypted.attemptDecryption(crypto as CryptoBackend).catch(() => { + unifiedPushLog.warn('notification', 'Encrypted preview decryption failed'); + }); } /** @@ -934,11 +880,7 @@ async function handleMinimalPushPayload( } const sender: NotifPerson | undefined = senderName - ? { - name: senderName, - key: senderId, - iconUrl: senderId && roomId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined, - } + ? { name: senderName, key: senderId } : undefined; const message: NotifMessage = { @@ -974,7 +916,6 @@ async function handleMinimalPushPayload( cache.messages = previousMessages; if (eventId) cache.pendingEventIds.delete(eventId); if (cache.messages.length === 0) roomNotifCaches.delete(cache.key); - await refreshAccountSummary(userId, true); throw error; } @@ -999,81 +940,76 @@ async function handleMinimalPushPayload( (!isEncryptedRoom || settings.showEncryptedMessageContent) ) { resolvePreviewEvent(settings.mx, roomId, eventId) - .then(async (fetched) => { + .then((fetched) => { if (!fetched) return; - await enqueueRoomOperation(cache.key, async () => { - const liveSettings = getSettings(); - const eventEncrypted = isEncryptedRoom || fetched.isEncrypted(); - const isAllowed = - liveSettings.showMessageContent && - (!eventEncrypted || liveSettings.showEncryptedMessageContent) && - !(document.visibilityState === 'visible' && liveSettings.useInAppNotifications); - if (!isAllowed || !isCurrentRoomCache(cache) || !cache.messages.includes(message)) { - return; - } + const applyFetchedPreview = () => + enqueueRoomOperation(cache.key, async () => { + const liveSettings = getSettings(); + const eventEncrypted = isEncryptedRoom || fetched.isEncrypted(); + const isAllowed = + liveSettings.showMessageContent && + (!eventEncrypted || liveSettings.showEncryptedMessageContent) && + !(document.visibilityState === 'visible' && liveSettings.useInAppNotifications); + if (!isAllowed || !isCurrentRoomCache(cache) || !cache.messages.includes(message)) { + return; + } - const enrichedPreview = resolveNotificationPreviewText({ - content: fetched.getContent(), - eventType: fetched.getType(), - isEncryptedRoom: eventEncrypted, - showMessageContent: liveSettings.showMessageContent, - showEncryptedMessageContent: liveSettings.showEncryptedMessageContent, - }); - if (!enrichedPreview) return; - - const fetchedSender = fetched.getSender(); - const liveRoom = liveSettings.mx.getRoom(roomId); - const nextSenderName = fetchedSender - ? (liveRoom?.getMember(fetchedSender)?.name ?? - getMxIdLocalPart(fetchedSender) ?? - fetchedSender) - : undefined; - const previousText = message.text; - const previousSender = message.sender; - message.text = enrichedPreview; - message.sender = nextSenderName - ? { - name: nextSenderName, - key: fetchedSender, - iconUrl: fetchedSender - ? resolveAvatarUrl(liveSettings.mx, roomId, fetchedSender) - : undefined, + const enrichedPreview = resolveNotificationPreviewText({ + content: fetched.getContent(), + eventType: fetched.getType(), + isEncryptedRoom: eventEncrypted, + showMessageContent: liveSettings.showMessageContent, + showEncryptedMessageContent: liveSettings.showEncryptedMessageContent, + }); + if (!enrichedPreview) return; + + const fetchedSender = fetched.getSender(); + const liveRoom = liveSettings.mx.getRoom(roomId); + const nextSenderName = fetchedSender + ? (liveRoom?.getMember(fetchedSender)?.name ?? + getMxIdLocalPart(fetchedSender) ?? + fetchedSender) + : undefined; + const previousText = message.text; + const previousSender = message.sender; + message.text = enrichedPreview; + message.sender = nextSenderName ? { name: nextSenderName, key: fetchedSender } : sender; + + const current = () => { + const currentSettings = getSettings(); + return ( + isCurrentRoomCache(cache) && + cache.messages.includes(message) && + currentSettings.showMessageContent && + (!eventEncrypted || currentSettings.showEncryptedMessageContent) && + !(document.visibilityState === 'visible' && currentSettings.useInAppNotifications) + ); + }; + try { + const sent = await postRoomNotification( + userId, + roomId, + cache, + true, + { + room_id: roomId, + event_id: eventId, + user_id: pushData?.user_id, + }, + current + ); + if (!sent) { + message.text = previousText; + message.sender = previousSender; } - : sender; - - const current = () => { - const currentSettings = getSettings(); - return ( - isCurrentRoomCache(cache) && - cache.messages.includes(message) && - currentSettings.showMessageContent && - (!eventEncrypted || currentSettings.showEncryptedMessageContent) && - !(document.visibilityState === 'visible' && currentSettings.useInAppNotifications) - ); - }; - try { - const sent = await postRoomNotification( - userId, - roomId, - cache, - true, - { - room_id: roomId, - event_id: eventId, - user_id: pushData?.user_id, - }, - current - ); - if (!sent) { + } catch { message.text = previousText; message.sender = previousSender; } - } catch { - message.text = previousText; - message.sender = previousSender; - } - }); + }); + + whenDecrypted(fetched, applyFetchedPreview); }) .catch((error) => { unifiedPushLog.warn( @@ -1133,6 +1069,8 @@ export async function listenForUnifiedPushMessages(getSettings: () => Notificati throw error; } + void dismissLegacyGroupSummary(getSettings().mx.getUserId() ?? ''); + return { unregister: async () => { try { diff --git a/src/app/pages/client/client-non-ui/notifications.tsx b/src/app/pages/client/client-non-ui/notifications.tsx index 88419c64d..e20bf9ebb 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -34,6 +34,7 @@ import { settingsAtom } from '$state/settings'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; +import { markAsRead } from '$utils/notifications'; import { usePreviousValue } from '$hooks/usePreviousValue'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getStateEvent } from '$utils/room/hierarchy'; @@ -740,6 +741,7 @@ const SABLE_REPLY_ACTION = 'sable-reply'; export function NativeNotificationActionRouting() { const mx = useMatrixClient(); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); const queue = useAtomValue(nativeNotificationRepliesAtom); const sessions = useAtomValue(sessionsAtom); const sessionsRef = useRef(sessions); @@ -811,6 +813,9 @@ export function NativeNotificationActionRouting() { setInFlight((previous: Set) => new Set(previous).add(item.key)); void mx .sendMessage(item.roomId, null, { msgtype: MsgType.Text, body: item.text }) + // Replying is reading; otherwise the room stays unread and its + // notification lingers while later pushes stack onto it. + .then(() => markAsRead(mx, item.roomId, hideReads)) .catch(() => { showToast('Reply was not sent. Open the room to retry.'); }) @@ -823,7 +828,7 @@ export function NativeNotificationActionRouting() { remove(item.key); }); return clearExpiryTimer; - }, [mx, queue, remove, setActiveSessionId, inFlight, setInFlight, replyWakeup]); + }, [mx, queue, remove, setActiveSessionId, inFlight, setInFlight, replyWakeup, hideReads]); return null; } diff --git a/src/types/tauri-plugin-notifications-api.d.ts b/src/types/tauri-plugin-notifications-api.d.ts index dbcb7c3a5..8e4549b95 100644 --- a/src/types/tauri-plugin-notifications-api.d.ts +++ b/src/types/tauri-plugin-notifications-api.d.ts @@ -9,6 +9,13 @@ declare module '@choochmeque/tauri-plugin-notifications-api' { readonly High: 4; }; + export type NotificationMessage = { + body: string; + timestamp: number; + senderName?: string; + senderKey?: string; + }; + export type NotificationOptions = { id?: number; channelId?: string; @@ -22,6 +29,8 @@ declare module '@choochmeque/tauri-plugin-notifications-api' { groupSummary?: boolean; sound?: string; inboxLines?: string[]; + messages?: NotificationMessage[]; + groupConversation?: boolean; icon?: string; largeIcon?: string; iconColor?: string;