diff --git a/.changeset/native-push-notifications.md b/.changeset/native-push-notifications.md new file mode 100644 index 000000000..ce391d7a6 --- /dev/null +++ b/.changeset/native-push-notifications.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add native push notifications with reply actions, content preview, and configurable gateway diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 817ad39de..2a43a5a18 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "tauri-plugin-notifications" version = "0.5.0-rc.11" -source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=0ca647a8d762cbba2ecfbefed7ff13565ec46229#0ca647a8d762cbba2ecfbefed7ff13565ec46229" +source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=cc3a468263db75e40a8a4751f3dae9bce5bc4234#cc3a468263db75e40a8a4751f3dae9bce5bc4234" dependencies = [ "log", "notify-rust", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aeaa3c8b2..ef00b504c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -75,12 +75,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 = "0ca647a8d762cbba2ecfbefed7ff13565ec46229" } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234" } # 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 = "0ca647a8d762cbba2ecfbefed7ff13565ec46229", default-features = false } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = { version = "2", optional = true } @@ -104,7 +104,7 @@ cef = { version = "=148.0.0", 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 = "0ca647a8d762cbba2ecfbefed7ff13565ec46229", features = [ +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "cc3a468263db75e40a8a4751f3dae9bce5bc4234", 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2b601c854..1069b85ab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -79,6 +79,22 @@ "appLink": false } ] + }, + "notifications": { + "actionTypes": [ + { + "id": "sable-message", + "actions": [ + { + "id": "sable-reply", + "title": "Reply", + "input": true, + "inputButtonTitle": "Send", + "inputPlaceholder": "Type a reply" + } + ] + } + ] } } } diff --git a/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx b/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx index 17181352b..32ce3c78c 100644 --- a/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx +++ b/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx @@ -58,6 +58,8 @@ export function NotificationTransportRuntimeFeature() { settingsAtom, 'showMessageContentInEncryptedNotifications' ); + const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads'); + const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride'); const runtimeRef = useRef(); if (!runtimeRef.current) runtimeRef.current = new NotificationTransportRuntime(); @@ -99,6 +101,8 @@ export function NotificationTransportRuntimeFeature() { vapidPublicKey?: string; webPushAppID?: string; pushNotifyUrl?: string; + useRichPushPayloads?: boolean; + pushNotifyUrlOverride?: string; }>({}); upConfigRef.current = { unifiedPushAppID: @@ -110,6 +114,8 @@ export function NotificationTransportRuntimeFeature() { vapidPublicKey: clientConfig.pushNotificationDetails?.vapidPublicKey, webPushAppID: clientConfig.pushNotificationDetails?.webPushAppID, pushNotifyUrl: clientConfig.pushNotificationDetails?.pushNotifyUrl, + useRichPushPayloads, + pushNotifyUrlOverride, }; // Keep the pusher current: establish it when UnifiedPush becomes the active @@ -149,7 +155,14 @@ export function NotificationTransportRuntimeFeature() { })(); return () => {}; - }, [provider, mx, setBackgroundPushEnabled, setBackgroundPushProvider]); + }, [ + provider, + mx, + useRichPushPayloads, + pushNotifyUrlOverride, + setBackgroundPushEnabled, + setBackgroundPushProvider, + ]); useEffect( () => () => { diff --git a/src/app/features/settings/notifications/PushPusherConfig.ts b/src/app/features/settings/notifications/PushPusherConfig.ts new file mode 100644 index 000000000..93729ae48 --- /dev/null +++ b/src/app/features/settings/notifications/PushPusherConfig.ts @@ -0,0 +1,37 @@ +export type PushPusherSettings = { + useRichPushPayloads?: boolean; + pushNotifyUrlOverride?: string; +}; + +export function resolvePushNotifyUrl( + configuredUrl: string | undefined, + override: string | undefined +): string { + const candidate = override?.trim() || configuredUrl?.trim(); + if (!candidate) + throw new Error('Push requires pushNotificationDetails.pushNotifyUrl in config.json.'); + + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new Error('Push gateway URL must be a full HTTPS URL ending in /notify.'); + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.hash || + url.pathname !== '/_matrix/push/v1/notify' + ) { + throw new Error('Push gateway URL must be an HTTPS Matrix /_matrix/push/v1/notify endpoint.'); + } + return url.toString(); +} + +export function withPushPayloadFormat>( + data: T, + useRichPushPayloads = false +): T | (T & { format: 'event_id_only' }) { + return useRichPushPayloads ? data : { ...data, format: 'event_id_only' }; +} diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 15cad7915..8ee60c4c0 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -458,6 +458,8 @@ function BackgroundPushNotificationSetting() { settingsAtom, 'pushTransportOverride' ); + const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads'); + const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride'); const [legacyPushNotifications, setLegacyPushNotifications] = useSetting( settingsAtom, 'usePushNotifications' @@ -591,6 +593,8 @@ function BackgroundPushNotificationSetting() { vapidPublicKey: clientConfig.pushNotificationDetails?.vapidPublicKey, webPushAppID: clientConfig.pushNotificationDetails?.webPushAppID, pushNotifyUrl: clientConfig.pushNotificationDetails?.pushNotifyUrl, + useRichPushPayloads, + pushNotifyUrlOverride, }); const buildRegisteredUnifiedPushState = ( @@ -1002,6 +1006,10 @@ export function SystemNotification() { settingsAtom, 'clearNotificationsOnRead' ); + const [useRichPushPayloads, setUseRichPushPayloads] = useSetting( + settingsAtom, + 'useRichPushPayloads' + ); const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts'); @@ -1126,6 +1134,19 @@ export function SystemNotification() { } /> + + } + /> + }) => void ) => Promise; + registerActionTypes: (types: NotificationActionType[]) => Promise; + onAction: ( + listener: (event: NotificationActionEvent) => void + ) => Promise; +}; + +export type NotificationActionType = { + id: string; + actions: Array<{ id: string; title: string; input?: boolean }>; +}; + +export type NotificationActionNotification = Record & { + actionTypeId: string; + extra?: Record; +}; + +export type NotificationActionEvent = { + actionId: string; + inputValue?: string | null; + notification: NotificationActionNotification; +}; + +type ActionListenerDependencies = { addListener: typeof addPluginListener; invoke: typeof invoke }; +let actionListenerCount = 0; +let actionListenerTransition: Promise = Promise.resolve(); + +const transitionActionListener = (invokeFn: typeof invoke, active: boolean): Promise => { + actionListenerTransition = actionListenerTransition + .catch(() => {}) + .then(async () => { + await invokeFn('plugin:notifications|set_action_listener_active', { active }); + }); + return actionListenerTransition; }; +export async function subscribeToNativeNotificationActions( + listener: (event: NotificationActionEvent) => void, + dependencies: ActionListenerDependencies = { addListener: addPluginListener, invoke } +): Promise { + const pluginListener = await dependencies.addListener( + 'notifications', + 'actionPerformed', + listener + ); + try { + actionListenerCount += 1; + if (actionListenerCount === 1) await transitionActionListener(dependencies.invoke, true); + } catch (error) { + actionListenerCount = Math.max(0, actionListenerCount - 1); + await pluginListener.unregister(); + throw error; + } + return { + unregister: async () => { + try { + actionListenerCount = Math.max(0, actionListenerCount - 1); + if (actionListenerCount === 0) await transitionActionListener(dependencies.invoke, false); + } finally { + await pluginListener.unregister(); + } + }, + }; +} + let notificationsApiPromise: Promise | null = null; export async function getTauriNotificationsApi(): Promise { if (!notificationsApiPromise) { - notificationsApiPromise = - import('@choochmeque/tauri-plugin-notifications-api') as unknown as Promise; + notificationsApiPromise = import('@choochmeque/tauri-plugin-notifications-api').then( + (api) => + ({ + ...api, + onAction: subscribeToNativeNotificationActions, + }) as unknown as TauriNotificationsApi + ); + notificationsApiPromise.catch(() => { + notificationsApiPromise = null; + }); } return notificationsApiPromise; @@ -70,6 +140,7 @@ export async function ensureTauriNotificationPermission(): Promise { const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); export const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); export const isIosTauri = (): boolean => isTauri() && osType() === 'ios'; +export const isAndroidTauri = (): boolean => isTauri() && osType() === 'android'; // Platforms where OS notifications go through the native plugin instead of web APIs. export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isIosTauri(); @@ -84,8 +155,10 @@ export type NativeTauriNotification = { title: string; body?: string; silent?: boolean; - /** Attached to the notification and handed back by onNotificationClicked. */ extra?: Record; + actionTypeId?: string; + group?: string; + icon?: string; }; export async function sendNativeTauriNotification({ @@ -93,6 +166,9 @@ export async function sendNativeTauriNotification({ body, silent, extra, + actionTypeId, + group, + icon, }: NativeTauriNotification): Promise { if (!(await ensureTauriNotificationPermission())) return; const api = await getTauriNotificationsApi(); @@ -102,5 +178,8 @@ export async function sendNativeTauriNotification({ body, silent: silent ?? false, extra, + ...(actionTypeId ? { actionTypeId } : {}), + ...(group ? { group } : {}), + ...(icon ? { icon } : {}), }); } diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts index f28ee751a..a3caac936 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -35,6 +35,7 @@ const matrixClient = vi.hoisted(() => ({ getPushers: vi .fn<() => Promise<{ pushers: Array }>>() .mockResolvedValue({ pushers: [] }), + getSafeUserId: vi.fn<() => string>(() => '@user:example.com'), })); vi.mock('./UnifiedPushTransport', () => unifiedPushTransport); diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 4f369b855..c1425eee3 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -1,6 +1,14 @@ -import type { IPusherRequest, MatrixClient } from '$types/matrix-sdk'; +import { + type CryptoBackend, + type IPusherRequest, + type MatrixClient, + MatrixEvent, +} from '$types/matrix-sdk'; import { EventType } from 'matrix-js-sdk/lib/@types/event'; -import { resolveNotificationPreviewText } from '$utils/notificationStyle'; +import { + resolveNotificationPreviewText, + ENCRYPTED_MESSAGE_PREVIEW, +} from '$utils/notificationStyle'; import { getMxIdLocalPart } from '$utils/matrix'; import { getStateEvent, getMemberAvatarMxc } from '$utils/room'; import { createDebugLogger } from '$utils/debugLogger'; @@ -18,7 +26,16 @@ import { } from './UnifiedPushMessageListener'; import { addPluginListener } from '@tauri-apps/api/core'; import type { PushTransportConfig } from './NotificationTransport'; -import { getTauriNotificationsApi } from './TauriNotificationsApiClient'; +import { + getTauriNotificationsApi, + isAndroidTauri, + isIosTauri, +} from './TauriNotificationsApiClient'; +import { + resolvePushNotifyUrl, + withPushPayloadFormat, + type PushPusherSettings, +} from './PushPusherConfig'; export { getUnifiedPushDistributors, getUnifiedPushDistributor, saveUnifiedPushDistributor }; @@ -54,7 +71,7 @@ export type UnifiedPushTransportConfigInput = Pick< vapidPublicKey?: string; webPushAppID?: string; pushNotifyUrl?: string; -}; +} & PushPusherSettings; type UnifiedPushPusherConfig = { appId: string; @@ -128,6 +145,7 @@ export async function tryEnableUnifiedPush( (await mx.getDevice(mx.getDeviceId() ?? ''))?.display_name ?? 'Android Device'; if (registration.p256dh && registration.auth && config?.webPushAppID && config?.pushNotifyUrl) { + const pushNotifyUrl = resolvePushNotifyUrl(config.pushNotifyUrl, config?.pushNotifyUrlOverride); await mx.setPusher({ kind: 'http', app_id: config.webPushAppID, @@ -135,20 +153,23 @@ export async function tryEnableUnifiedPush( app_display_name: 'Sable (UnifiedPush)', device_display_name: deviceDisplayName, lang: navigator.language || 'en', - data: { - url: config.pushNotifyUrl, - format: 'event_id_only', - endpoint, - p256dh: registration.p256dh, - auth: registration.auth, - }, + data: withPushPayloadFormat( + { + url: pushNotifyUrl, + endpoint, + p256dh: registration.p256dh, + auth: registration.auth, + default_payload: { user_id: mx.getSafeUserId() }, + }, + config?.useRichPushPayloads + ), append: false, } as unknown as IPusherRequest); return { status: 'registered', endpoint, - gatewayUrl: config.pushNotifyUrl, + gatewayUrl: pushNotifyUrl, distributor: registration.distributor, }; } @@ -156,10 +177,6 @@ export async function tryEnableUnifiedPush( const resolvedConfig = resolveUnifiedPushPusherConfig(config); const gatewayUrl = resolvedConfig.gatewayUrl ?? UP_PUBLIC_GATEWAY; - const pusherData: Record = { - url: gatewayUrl, - }; - await mx.setPusher({ kind: 'http', app_id: resolvedConfig.appId, @@ -167,7 +184,10 @@ export async function tryEnableUnifiedPush( app_display_name: 'Sable (UnifiedPush)', device_display_name: deviceDisplayName, lang: navigator.language || 'en', - data: pusherData, + data: withPushPayloadFormat( + { url: gatewayUrl, default_payload: { user_id: mx.getSafeUserId() } }, + config?.useRichPushPayloads + ), append: false, } as unknown as IPusherRequest); @@ -314,8 +334,59 @@ function hashCode(str: string): number { return Math.abs(hash); } -const roomNotifId = (roomId: string) => hashCode(roomId); -const SUMMARY_NOTIF_ID = hashCode('sable-group-summary'); +async function resolvePreviewEvent( + mx: MatrixClient, + roomId: string, + eventId: string +): Promise { + try { + const evt = await mx.fetchRoomEvent(roomId, eventId); + const mEvent = new MatrixEvent(evt); + if (mEvent.isEncrypted() && mx.getCrypto()) { + await mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend); + } + return mEvent; + } catch (error) { + unifiedPushLog.warn( + 'notification', + 'Failed to fetch/decrypt event for push preview', + error instanceof Error ? error : new Error(String(error)) + ); + return undefined; + } +} + +/** + * 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. + */ +async function decryptPreviewFromPayload( + mx: MatrixClient, + roomId: string, + eventId: string, + pushData: UnifiedPushPayload +): Promise { + const crypto = mx.getCrypto(); + if (!crypto || !pushData.content) return undefined; + try { + const mEvent = new MatrixEvent({ + type: 'm.room.encrypted', + content: pushData.content, + room_id: roomId, + event_id: eventId, + sender: pushData.sender, + origin_server_ts: Date.now(), + }); + await mEvent.attemptDecryption(crypto as CryptoBackend); + return mEvent; + } catch { + return undefined; + } +} + +const roomNotifId = (userId: string, roomId: string) => hashCode(`${userId}\u0000${roomId}`); +const summaryNotifId = (userId: string) => hashCode(`sable-group-summary\u0000${userId}`); type RoomNotifCache = { roomName: string; @@ -335,29 +406,33 @@ function resolveAvatarUrl(mx: MatrixClient, roomId: string, userId: string): str return mx.mxcUrlToHttp(mxcUrl, 96, 96, 'crop', false, true, true) ?? undefined; } -function getOrCreateRoomCache(roomId: string, roomName: string): RoomNotifCache { - let cache = roomNotifCaches.get(roomId); +function getOrCreateRoomCache(userId: string, roomId: string, roomName: string): RoomNotifCache { + const key = `${userId}\u0000${roomId}`; + let cache = roomNotifCaches.get(key); if (!cache) { cache = { roomName, messages: [], seenEventIds: new Set(), isGroupConversation: false }; - roomNotifCaches.set(roomId, cache); + roomNotifCaches.set(key, cache); } cache.roomName = roomName; return cache; } /** Clears accumulated messages for a room and dismisses its notification. */ -export async function clearRoomNotification(roomId: string) { - roomNotifCaches.delete(roomId); +export async function clearRoomNotification(userId: string, roomId: string) { + roomNotifCaches.delete(`${userId}\u0000${roomId}`); try { const notificationsApi = await getTauriNotificationsApi(); - await notificationsApi.removeActive([{ id: roomNotifId(roomId) }]); + await notificationsApi.removeActive([{ id: roomNotifId(userId, roomId) }]); } catch { // already dismissed } - if (roomNotifCaches.size <= 1) { + const accountRoomCount = Array.from(roomNotifCaches.keys()).filter((key) => + key.startsWith(`${userId}\u0000`) + ).length; + if (accountRoomCount <= 1) { try { const notificationsApi = await getTauriNotificationsApi(); - await notificationsApi.removeActive([{ id: SUMMARY_NOTIF_ID }]); + await notificationsApi.removeActive([{ id: summaryNotifId(userId) }]); } catch { // ignore } @@ -365,6 +440,7 @@ export async function clearRoomNotification(roomId: string) { } async function postRoomNotification( + userId: string, roomId: string, cache: RoomNotifCache, isSilent: boolean, @@ -378,7 +454,7 @@ async function postRoomNotification( const inboxLines = messages.slice(-5).map((m) => `${m.sender?.name ?? 'You'}: ${m.text}`); await notificationsApi.sendNotification({ - id: roomNotifId(roomId), + id: roomNotifId(userId, roomId), title: roomName, body: latestBody, channelId: 'messages', @@ -387,25 +463,31 @@ async function postRoomNotification( silent: isSilent, autoCancel: true, extra, + ...(isAndroidTauri() || isIosTauri() ? { actionTypeId: 'sable-message' } : {}), inboxLines: inboxLines.length > 1 ? inboxLines : undefined, + largeBody: inboxLines.length > 1 ? undefined : latestBody, }); - const roomCount = roomNotifCaches.size; + const accountCaches = Array.from(roomNotifCaches.entries()).filter(([key]) => + key.startsWith(`${userId}\u0000`) + ); + const roomCount = accountCaches.length; if (roomCount > 1) { - const totalMessages = Array.from(roomNotifCaches.values()).reduce( - (sum, c) => sum + c.messages.length, - 0 - ); + const totalMessages = accountCaches + .map(([, accountCache]) => accountCache) + .reduce((sum, c) => sum + c.messages.length, 0); const summaryText = `${totalMessages} messages in ${roomCount} chats`; const summaryLines: string[] = []; - Array.from(roomNotifCaches.values()).forEach((c) => { - const latest = c.messages[c.messages.length - 1]; - if (latest) { - summaryLines.push(`${c.roomName}: ${latest.sender?.name ?? 'You'}: ${latest.text}`); - } - }); + accountCaches + .map(([, accountCache]) => accountCache) + .forEach((c) => { + const latest = c.messages[c.messages.length - 1]; + if (latest) { + summaryLines.push(`${c.roomName}: ${latest.sender?.name ?? 'You'}: ${latest.text}`); + } + }); await notificationsApi.sendNotification({ - id: SUMMARY_NOTIF_ID, + id: summaryNotifId(userId), title: summaryText, body: '', summary: summaryText, @@ -421,7 +503,11 @@ async function postRoomNotification( } /** Handles a rich push payload containing full event details (type, room_name, content, etc.). */ -async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: NotificationSettings) { +async function handleRichPushPayload( + pushData: UnifiedPushPayload, + settings: NotificationSettings, + userId: string +) { const eventType = pushData.type as EventType; switch (eventType) { @@ -430,7 +516,7 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not case EventType.RoomMessageEncrypted: { const isEncrypted = eventType === EventType.RoomMessageEncrypted; - const previewText = resolveNotificationPreviewText({ + let previewText = resolveNotificationPreviewText({ content: pushData?.content, eventType: pushData?.type, isEncryptedRoom: isEncrypted, @@ -439,7 +525,8 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not }); const roomId: string | undefined = pushData?.room_id; - const roomName: string = pushData?.room_name ?? 'Unknown Room'; + const roomName: string = + pushData?.room_name ?? pushData?.sender_display_name ?? 'Unknown Room'; const senderName: string | undefined = pushData?.sender_display_name; const senderId: string | undefined = pushData?.sender; const isSilent = !settings.notificationSoundEnabled; @@ -457,6 +544,44 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not break; } + const eventId: string | undefined = pushData?.event_id; + + if ( + previewText === ENCRYPTED_MESSAGE_PREVIEW && + eventId && + settings.showMessageContent && + settings.showEncryptedMessageContent + ) { + // Try local timeline first (decryption already done by SDK). + const room = settings.mx.getRoom(roomId); + const mEvent = room + ?.getLiveTimeline() + .getEvents() + .find((e) => e.getId() === eventId); + if (mEvent) { + previewText = resolveNotificationPreviewText({ + content: mEvent.getContent(), + eventType: mEvent.getType(), + isEncryptedRoom: true, + showMessageContent: settings.showMessageContent, + showEncryptedMessageContent: settings.showEncryptedMessageContent, + }); + } else { + // Decrypt the ciphertext from the push payload locally — no + // homeserver fetch needed. The Megolm keys are in the crypto store. + const decrypted = await decryptPreviewFromPayload(settings.mx, roomId, eventId, pushData); + if (decrypted) { + previewText = resolveNotificationPreviewText({ + content: decrypted.getContent(), + eventType: decrypted.getType(), + isEncryptedRoom: true, + showMessageContent: settings.showMessageContent, + showEncryptedMessageContent: settings.showEncryptedMessageContent, + }); + } + } + } + const sender: NotifPerson | undefined = senderName ? { name: senderName, @@ -471,9 +596,8 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not sender, }; - const cache = getOrCreateRoomCache(roomId, roomName); + const cache = getOrCreateRoomCache(userId, roomId, roomName); - const eventId: string | undefined = pushData?.event_id; if (eventId && cache.seenEventIds.has(eventId)) break; if (eventId) cache.seenEventIds.add(eventId); @@ -488,7 +612,7 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not cache.isGroupConversation = (room.getJoinedMemberCount() ?? 0) > 2; } - await postRoomNotification(roomId, cache, isSilent, { + await postRoomNotification(userId, roomId, cache, isSilent, { room_id: roomId, event_id: pushData?.event_id, user_id: pushData?.user_id, @@ -508,11 +632,13 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not await notificationsApi.sendNotification({ title: 'New Invitation', body, + largeBody: body, channelId: 'messages', group: NOTIF_GROUP_KEY, icon: 'notification_icon', autoCancel: true, extra: { + type: 'invite', room_id: pushData?.room_id, event_id: pushData?.event_id, user_id: pushData?.user_id, @@ -531,7 +657,8 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not */ async function handleMinimalPushPayload( pushData: UnifiedPushPayload, - settings: NotificationSettings + settings: NotificationSettings, + userId: string ) { const roomId: string | undefined = pushData?.room_id; const eventId: string | undefined = pushData?.event_id; @@ -542,12 +669,12 @@ async function handleMinimalPushPayload( // Unread count of zero means the room was read — dismiss the notification. if (unread === 0) { - await clearRoomNotification(roomId); + await clearRoomNotification(userId, roomId); return; } const room = settings.mx.getRoom(roomId); - const roomName = room?.name ?? 'Unknown Room'; + const roomName = room?.name ?? pushData?.sender_display_name ?? 'Unknown Room'; const isEncryptedRoom = room ? !!getStateEvent(room, EventType.RoomEncryption) : false; let senderName: string | undefined; @@ -574,6 +701,29 @@ async function handleMinimalPushPayload( } } + if ( + !previewText && + eventId && + settings.showMessageContent && + (!isEncryptedRoom || settings.showEncryptedMessageContent) + ) { + const fetched = await resolvePreviewEvent(settings.mx, roomId, eventId); + if (fetched) { + const sender = fetched.getSender(); + if (sender) { + senderName = room?.getMember(sender)?.name ?? getMxIdLocalPart(sender) ?? sender; + senderId = sender; + } + previewText = resolveNotificationPreviewText({ + content: fetched.getContent(), + eventType: fetched.getType(), + isEncryptedRoom, + showMessageContent: settings.showMessageContent, + showEncryptedMessageContent: settings.showEncryptedMessageContent, + }); + } + } + if (!previewText) { previewText = isEncryptedRoom ? 'Encrypted message' : 'New message'; } @@ -592,7 +742,7 @@ async function handleMinimalPushPayload( sender, }; - const cache = getOrCreateRoomCache(roomId, roomName); + const cache = getOrCreateRoomCache(userId, roomId, roomName); if (eventId && cache.seenEventIds.has(eventId)) return; if (eventId) cache.seenEventIds.add(eventId); @@ -607,9 +757,10 @@ async function handleMinimalPushPayload( cache.isGroupConversation = (room.getJoinedMemberCount() ?? 0) > 2; } - await postRoomNotification(roomId, cache, !settings.notificationSoundEnabled, { + await postRoomNotification(userId, roomId, cache, !settings.notificationSoundEnabled, { room_id: roomId, event_id: eventId, + user_id: pushData?.user_id, }); } @@ -626,11 +777,12 @@ async function handleUnifiedPushPayload( const pushData = (raw.extra ?? raw) as UnifiedPushPayload; const eventType = pushData?.type as EventType | undefined; + const userId = pushData?.user_id ?? settings.mx.getUserId() ?? ''; if (eventType) { - await handleRichPushPayload(pushData, settings); + await handleRichPushPayload(pushData, settings, userId); } else { - await handleMinimalPushPayload(pushData, settings); + await handleMinimalPushPayload(pushData, settings, userId); } } diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 4624bf95c..66c339208 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-22T13:55:36.079802+00:00 + * Generated at: 2026-07-23T16:27:46.863860175+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index f5b20c841..ec487f0b0 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-22T13:55:36.081813+00:00 + * Generated at: 2026-07-23T16:27:46.864190375+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index dde8c8fe8..36258a670 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-22T13:55:36.082561+00:00 + * Generated at: 2026-07-23T16:27:46.864230864+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 9a6b9d86c..2d7af475e 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-22T13:55:36.075263+00:00 + * Generated at: 2026-07-23T16:27:46.863303966+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 40c291d10..469963d21 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,9 +1,9 @@ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { type as osType } from '@tauri-apps/plugin-os'; import { setTrayBadge } from '$generated/tauri/commands'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { resolveSection } from '$pages/pathUtils'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; @@ -11,6 +11,7 @@ import { getPresenceSyncManager } from '$client/initMatrix'; import { MatrixEvent, MatrixEventEvent, + MsgType, RoomEvent, SetPresence, SyncState, @@ -25,6 +26,7 @@ import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { getTauriNotificationsApi, + isAndroidTauri, isDesktopTauri, isIosTauri, isNativeNotificationTauri, @@ -52,13 +54,27 @@ import { useInboxNotificationsSelected } from '$hooks/router/useInbox'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { ShareTargetFeature } from '$features/share-target/ShareTargetFeature'; import { registrationAtom } from '$state/serviceWorkerRegistration'; -import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; +import { + pendingNotificationAtom, + inAppBannerAtom, + activeSessionIdAtom, + sessionsAtom, +} from '$state/sessions'; import { buildRoomMessageNotification, resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { mobileOrTablet } from '$utils/user-agent'; import { createDebugLogger } from '$utils/debugLogger'; +import { showToast } from '$state/toast'; +import { + nativeNotificationRepliesAtom, + nativeNotificationReplyInFlightAtom, + enqueueNativeNotificationReplyAtom, + removeNativeNotificationReplyAtom, + parseNativeNotificationReply, + NATIVE_REPLY_EXPIRY_MS, +} from '$state/nativeNotificationReplies'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; @@ -234,6 +250,7 @@ function InviteNotifications() { title: 'Invitation', body, silent: true, + group: 'matrix_messages', extra: { type: 'invite' }, }).catch(() => {}); return; @@ -513,6 +530,8 @@ function MessageNotifications() { body: osPayload.options.body, silent: osPayload.options.silent ?? false, extra, + actionTypeId: 'sable-message', + group: 'matrix_messages', }).catch(() => {}); } else { const noti = new window.Notification(osPayload.title, osPayload.options); @@ -1001,6 +1020,114 @@ function NativeNotificationClickRouting() { return null; } +const SABLE_MESSAGE_ACTION_TYPE = 'sable-message'; +const SABLE_REPLY_ACTION = 'sable-reply'; + +function NativeNotificationActionRouting() { + const mx = useMatrixClient(); + const queue = useAtomValue(nativeNotificationRepliesAtom); + const sessions = useAtomValue(sessionsAtom); + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + const enqueue = useSetAtom(enqueueNativeNotificationReplyAtom); + const remove = useSetAtom(removeNativeNotificationReplyAtom); + const [inFlight, setInFlight] = useAtom(nativeNotificationReplyInFlightAtom); + const [replyWakeup, setReplyWakeup] = useState(0); + const setActiveSessionId = useSetAtom(activeSessionIdAtom); + + useEffect(() => { + if (!isAndroidTauri() && !isIosTauri() && !isDesktopTauri()) return undefined; + let disposed = false; + let unregister: (() => Promise | void) | undefined; + + const route = (event: unknown) => { + const reply = parseNativeNotificationReply(event); + if (!reply) return; + if ( + !sessionsRef.current.some((session) => session.userId === reply.userId) || + !enqueue(reply) + ) { + showToast('Reply was not sent. Open the room to retry.'); + } + }; + + getTauriNotificationsApi() + .then(async (api) => { + await api.registerActionTypes([ + { + id: SABLE_MESSAGE_ACTION_TYPE, + actions: [{ id: SABLE_REPLY_ACTION, title: 'Reply', input: true }], + }, + ]); + return api.onAction(route); + }) + .then((listener) => { + if (disposed) listener.unregister(); + else unregister = listener.unregister; + }) + .catch(() => {}); + + return () => { + disposed = true; + unregister?.(); + }; + }, [enqueue]); + + useEffect(() => { + const item = queue[0]; + if (!item) return undefined; + const expiresIn = NATIVE_REPLY_EXPIRY_MS - (Date.now() - item.createdAt); + if (expiresIn <= 0) { + remove(item.key); + showToast('Reply was not sent. Open the room to retry.'); + return undefined; + } + const expiryTimer = setTimeout(() => { + remove(item.key); + showToast('Reply was not sent. Open the room to retry.'); + }, expiresIn); + const clearExpiryTimer = () => clearTimeout(expiryTimer); + if (mx.getUserId() !== item.userId) { + setActiveSessionId(item.userId); + return clearExpiryTimer; + } + const room = mx.getRoom(item.roomId); + const ready = [SyncState.Prepared, SyncState.Syncing, SyncState.Catchup].includes( + mx.getSyncState() as SyncState + ); + if (!ready || !room) { + const readinessTimer = setTimeout(() => setReplyWakeup((value) => value + 1), 750); + return () => { + clearExpiryTimer(); + clearTimeout(readinessTimer); + }; + } + if (room.getMyMembership() !== 'join') { + remove(item.key); + showToast('Reply was not sent. Open the room to retry.'); + return clearExpiryTimer; + } + if (inFlight.has(item.key)) return clearExpiryTimer; + setInFlight((previous: Set) => new Set(previous).add(item.key)); + void mx + .sendMessage(item.roomId, null, { msgtype: MsgType.Text, body: item.text }) + .catch(() => { + showToast('Reply was not sent. Open the room to retry.'); + }) + .finally(() => { + setInFlight((previous: Set) => { + const next = new Set(previous); + next.delete(item.key); + return next; + }); + remove(item.key); + }); + return clearExpiryTimer; + }, [mx, queue, remove, setActiveSessionId, inFlight, setInFlight, replyWakeup]); + + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useIncomingCallSignaling(); return ( @@ -1014,6 +1141,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/state/nativeNotificationReplies.ts b/src/app/state/nativeNotificationReplies.ts new file mode 100644 index 000000000..a8c063790 --- /dev/null +++ b/src/app/state/nativeNotificationReplies.ts @@ -0,0 +1,74 @@ +import { atom } from 'jotai'; + +export const NATIVE_REPLY_MAX = 8; +export const NATIVE_REPLY_EXPIRY_MS = 120_000; +export type NativeNotificationReply = { + key: string; + userId: string; + roomId: string; + eventId: string; + text: string; + createdAt: number; +}; +export type NativeActionPayload = { + actionId: string; + inputValue?: string | null; + notification: { + actionTypeId: string; + extra: { user_id: string; room_id: string; event_id: string }; + }; +}; + +export function parseNativeNotificationReply( + value: unknown +): Omit | undefined { + if (!value || typeof value !== 'object') return undefined; + const event = value as Partial; + const notification = event.notification; + const extra = notification?.extra; + const text = typeof event.inputValue === 'string' ? event.inputValue.trim() : ''; + if ( + event.actionId !== 'sable-reply' || + notification?.actionTypeId !== 'sable-message' || + !text || + !extra || + typeof extra.user_id !== 'string' || + !extra.user_id || + typeof extra.room_id !== 'string' || + !extra.room_id || + typeof extra.event_id !== 'string' || + !extra.event_id + ) + return undefined; + return { + key: `${extra.user_id}\u0000${extra.room_id}\u0000${extra.event_id}\u0000${text}`, + userId: extra.user_id, + roomId: extra.room_id, + eventId: extra.event_id, + text, + }; +} + +export const nativeNotificationRepliesAtom = atom([]); +export const nativeNotificationReplyInFlightAtom = atom>(new Set()); +export const enqueueNativeNotificationReplyAtom = atom( + null, + (get, set, reply: Omit) => { + const now = Date.now(); + const queue = get(nativeNotificationRepliesAtom).filter( + (item) => now - item.createdAt < NATIVE_REPLY_EXPIRY_MS + ); + if (queue.some((item) => item.key === reply.key) || queue.length >= NATIVE_REPLY_MAX) { + set(nativeNotificationRepliesAtom, queue); + return false; + } + set(nativeNotificationRepliesAtom, [...queue, { ...reply, createdAt: now }]); + return true; + } +); +export const removeNativeNotificationReplyAtom = atom(null, (get, set, key: string) => + set( + nativeNotificationRepliesAtom, + get(nativeNotificationRepliesAtom).filter((item) => item.key !== key) + ) +); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 3e7c20c68..15da4a7bd 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -151,6 +151,8 @@ export interface Settings { backgroundNotificationSounds: boolean; showMessageContentInNotifications: boolean; showMessageContentInEncryptedNotifications: boolean; + useRichPushPayloads: boolean; + pushNotifyUrlOverride?: string; clearNotificationsOnRead: boolean; backgroundPushEnabled: boolean; backgroundPushProvider: NotificationTransportProvider | null; @@ -327,6 +329,8 @@ export const defaultSettings: Settings = { backgroundNotificationSounds: true, showMessageContentInNotifications: false, showMessageContentInEncryptedNotifications: false, + useRichPushPayloads: true, + pushNotifyUrlOverride: undefined, clearNotificationsOnRead: false, backgroundPushEnabled: mobileOrTablet(), backgroundPushProvider: null, diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index c3b9032f0..788a68ad6 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -46,7 +46,7 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip try { const { clearRoomNotification } = await import('$features/settings/notifications/UnifiedPushNotifications'); - await clearRoomNotification(roomId); + await clearRoomNotification(mx.getUserId() ?? '', roomId); } catch { // Notification plugin not available (desktop, web) — ignore. }