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 fd5d34206..5b81453ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -148,7 +148,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -159,7 +159,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -957,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -1481,7 +1481,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1783,7 +1783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2723,7 +2723,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3548,7 +3548,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3672,7 +3672,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4068,7 +4068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.48.0", ] [[package]] @@ -4712,7 +4712,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -4749,7 +4749,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5155,7 +5155,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5213,7 +5213,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5795,7 +5795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6487,7 +6487,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=e7a879690069d6f66ce3b536ad2495736c9beca3#e7a879690069d6f66ce3b536ad2495736c9beca3" dependencies = [ "log", "notify-rust", @@ -6842,7 +6842,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7468,7 +7468,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7542,7 +7542,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8212,7 +8212,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aedc31083..e37d449ab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,12 +73,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 = "e7a879690069d6f66ce3b536ad2495736c9beca3" } # 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 = "e7a879690069d6f66ce3b536ad2495736c9beca3", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = { version = "2", optional = true } @@ -102,7 +102,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 = "e7a879690069d6f66ce3b536ad2495736c9beca3", 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/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..d5d0d641e 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1002,6 +1002,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 +1130,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 + ); } return notificationsApiPromise; @@ -70,6 +137,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 +152,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 +163,9 @@ export async function sendNativeTauriNotification({ body, silent, extra, + actionTypeId, + group, + icon, }: NativeTauriNotification): Promise { if (!(await ensureTauriNotificationPermission())) return; const api = await getTauriNotificationsApi(); @@ -102,5 +175,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.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 4f369b855..6ac5cceb3 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, }; } @@ -167,7 +188,7 @@ export async function tryEnableUnifiedPush( app_display_name: 'Sable (UnifiedPush)', device_display_name: deviceDisplayName, lang: navigator.language || 'en', - data: pusherData, + data: withPushPayloadFormat({ url: gatewayUrl }, config?.useRichPushPayloads), append: false, } as unknown as IPusherRequest); @@ -314,6 +335,28 @@ function hashCode(str: string): number { return Math.abs(hash); } +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; + } +} + const roomNotifId = (roomId: string) => hashCode(roomId); const SUMMARY_NOTIF_ID = hashCode('sable-group-summary'); @@ -387,7 +430,9 @@ 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; @@ -430,7 +475,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 +484,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 +503,41 @@ 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 + ) { + 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 { + const fetched = await resolvePreviewEvent(settings.mx, roomId, eventId); + if (fetched) { + previewText = resolveNotificationPreviewText({ + content: fetched.getContent(), + eventType: fetched.getType(), + isEncryptedRoom: true, + showMessageContent: settings.showMessageContent, + showEncryptedMessageContent: settings.showEncryptedMessageContent, + }); + } + } + } + const sender: NotifPerson | undefined = senderName ? { name: senderName, @@ -473,7 +554,6 @@ async function handleRichPushPayload(pushData: UnifiedPushPayload, settings: Not const cache = getOrCreateRoomCache(roomId, roomName); - const eventId: string | undefined = pushData?.event_id; if (eventId && cache.seenEventIds.has(eventId)) break; if (eventId) cache.seenEventIds.add(eventId); @@ -547,7 +627,7 @@ async function handleMinimalPushPayload( } 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 +654,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'; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b39ec1512..269f63925 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, PushProcessor, RoomEvent, SetPresence, @@ -26,6 +27,7 @@ import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { getTauriNotificationsApi, + isAndroidTauri, isDesktopTauri, isIosTauri, isNativeNotificationTauri, @@ -53,13 +55,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'; @@ -513,6 +529,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 +1019,112 @@ 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 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 || + !sessions.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, sessions]); + + 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 +1138,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,