From 52de41cf042d122ccd88f0c56d174553625b2534 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Fri, 17 Apr 2026 22:54:44 +0300 Subject: [PATCH] add notifications support for android (mostly) --- android/app/build.gradle | 4 + android/app/capacitor.build.gradle | 1 + android/app/google-services.json | 29 ++ android/app/src/main/AndroidManifest.xml | 19 +- .../main/java/chat/vojo/app/MainActivity.java | 14 + .../app/VojoFirebaseMessagingService.java | 116 ++++++ android/capacitor.settings.gradle | 3 + capacitor.config.ts | 5 + config.json | 7 + package-lock.json | 12 +- package.json | 1 + public/locales/en.json | 4 + public/locales/ru.json | 4 + .../notifications/SystemNotification.tsx | 92 ++++- src/app/hooks/useClientConfig.ts | 9 + src/app/hooks/usePushNotifications.ts | 340 ++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 7 + src/app/utils/push.ts | 169 +++++++++ src/client/initMatrix.ts | 39 ++ src/index.tsx | 11 + src/sw.ts | 165 +++++++++ 21 files changed, 1048 insertions(+), 3 deletions(-) create mode 100755 android/app/google-services.json create mode 100644 android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java create mode 100644 src/app/hooks/usePushNotifications.ts create mode 100644 src/app/utils/push.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index 36d359bd..80808050 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,6 +41,10 @@ dependencies { implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') + // Needed for VojoFirebaseMessagingService. @capacitor/push-notifications + // already depends on firebase-messaging but declares it `implementation` + // so classes aren't exposed at app-module compile time. + implementation "com.google.firebase:firebase-messaging:25.0.1" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 3960cbae..71b0ca40 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,6 +10,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-browser') + implementation project(':capacitor-push-notifications') } diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100755 index 00000000..426b6845 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "51806967595", + "project_id": "chat-vojo-app", + "storage_bucket": "chat-vojo-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:51806967595:android:93921bf62aa9713a79576e", + "android_client_info": { + "package_name": "chat.vojo.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBroeOOHxg-tEyU-O-zjSWF7mEejedRWsM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b06ddbfd..bcbfb4df 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + + + + + + + + + diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index f1a59ec1..21d14028 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -5,9 +5,23 @@ import androidx.activity.EdgeToEdge; import com.getcapacitor.BridgeActivity; public class MainActivity extends BridgeActivity { + public static volatile boolean isInForeground = false; + @Override protected void onCreate(Bundle savedInstanceState) { EdgeToEdge.enable(this); super.onCreate(savedInstanceState); } + + @Override + public void onResume() { + super.onResume(); + isInForeground = true; + } + + @Override + public void onPause() { + super.onPause(); + isInForeground = false; + } } diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java new file mode 100644 index 00000000..6f728c09 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -0,0 +1,116 @@ +package chat.vojo.app; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +import com.capacitorjs.plugins.pushnotifications.MessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import java.util.Map; + +/** + * Sygnal delivers Matrix pushes as data-only FCM messages (no `notification` + * block), so the FCM SDK never auto-displays anything. The Capacitor plugin's + * base MessagingService just forwards to JS via `pushNotificationReceived`, + * but JS listeners detach when the WebView is paused/backgrounded. + * + * This service builds a system notification whenever the activity is NOT in + * the foreground — covering both the "backgrounded" and "killed" cases. + * When the activity IS visible, in-app MessageNotifications handles display. + * + * Each message gets its own notification (unique id per event_id). Android + * auto-groups them under the "vojo_messages" group when there are 4+. + */ +public class VojoFirebaseMessagingService extends MessagingService { + + private static final String CHANNEL_ID = "vojo_messages"; + private static final String GROUP_KEY = "vojo_messages"; + // Reserved id for the group summary. Chosen as MIN_VALUE so it can't collide + // with String.hashCode() of any event/room key (which notoriously returns 0 + // for the empty string and a handful of other inputs). + private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE; + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + if (!MainActivity.isInForeground) { + showSystemNotification(remoteMessage); + } + } + + private void showSystemNotification(RemoteMessage message) { + Map data = message.getData(); + String roomId = data.get("room_id"); + String eventId = data.get("event_id"); + + // Sygnal flattens nested notification fields with `_` separator: + // sender_display_name, content_body, content_msgtype, etc. + String title = firstNonEmpty( + data.get("room_name"), + data.get("sender_display_name"), + data.get("sender"), + "Vojo" + ); + String body = firstNonEmpty(data.get("content_body"), "New message"); + + // Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires + // `pushNotificationActionPerformed` and the existing JS listener navigates. + Intent launchIntent = new Intent(this, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + String messageId = message.getMessageId(); + launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); + for (Map.Entry e : data.entrySet()) { + launchIntent.putExtra(e.getKey(), e.getValue()); + } + + // Unique requestCode per event so each notification's PendingIntent is distinct + String uniqueKey = eventId != null ? eventId : (roomId != null ? roomId : "vojo"); + int requestCode = uniqueKey.hashCode(); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pendingIntent = PendingIntent.getActivity(this, requestCode, launchIntent, flags); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setGroup(GROUP_KEY) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE); + + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + + // Unique notification id per event — each message shows separately in the shade. + // Guard against the (rare) hashCode collision with the reserved summary id. + int notifId = uniqueKey.hashCode(); + if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; + nm.notify(notifId, builder.build()); + + // Summary notification for the group (Android shows this when 4+ notifications stack) + NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Vojo") + .setContentText("New messages") + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setAutoCancel(true); + nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); + } + + private static String firstNonEmpty(String... values) { + for (String v : values) { + if (v != null && !v.isEmpty()) return v; + } + return ""; + } +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9a11f94f..821d963d 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index cc7786f6..45e73227 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -8,6 +8,11 @@ const config: CapacitorConfig = { // Keep default: resolveServiceWorkerRequests = true // SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+) }, + plugins: { + PushNotifications: { + presentationOptions: ['badge', 'sound', 'alert'], + }, + }, }; export default config; diff --git a/config.json b/config.json index 8d6379df..adc16608 100644 --- a/config.json +++ b/config.json @@ -13,5 +13,12 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "push": { + "vapidPublicKey": "BHmGRaixeMlWHyxMuRIYDA72dqQIV6mSdap4smklDixZsWS4ZhL01cv9YRHEW6NO0iumXeQ-T0_yirtcHNB5tZw", + "gatewayUrl": "http://sygnal:5000/_matrix/push/v1/notify", + "webAppId": "chat.vojo.app.web", + "fcmAppId": "chat.vojo.app.fcm" } } diff --git a/package-lock.json b/package-lock.json index c5667009..eee54f39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@capacitor/browser": "8.0.3", "@capacitor/cli": "8.3.0", "@capacitor/core": "8.3.0", + "@capacitor/push-notifications": "8.0.3", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", @@ -109,7 +110,7 @@ "vite-plugin-top-level-await": "1.4.4" }, "engines": { - "node": ">=16.0.0" + "node": ">=22.0.0" } }, "node_modules/@actions/core": { @@ -1905,6 +1906,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.3.tgz", + "integrity": "sha512-jmBBoJvOzmzem8YoO9dQJBPFiM1bksTNAoV7yksCBN10BybAOtmBF19vFPt/jr2KGAirnPZz0ne2X0OH/rRGtg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 933e5d3a..5a502398 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@capacitor/browser": "8.0.3", "@capacitor/cli": "8.3.0", "@capacitor/core": "8.3.0", + "@capacitor/push-notifications": "8.0.3", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", diff --git a/public/locales/en.json b/public/locales/en.json index dde23801..b715ec79 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -167,6 +167,10 @@ "enable": "Enable", "notification_sound": "Notification Sound", "notification_sound_desc": "Play sound when a new message arrives.", + "push_notifications": "Background Notifications", + "push_description": "Receive notifications even when Vojo is closed or minimized.", + "push_permission_blocked": "Push notification permission was denied. Please enable it in your device settings.", + "push_error": "Failed to enable background notifications.", "email_notification": "Email Notification", "email_no_email": "Your account does not have any email attached.", "email_send_notif": "Send notification to your email.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 93a656ac..5eece7bc 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -167,6 +167,10 @@ "enable": "Включить", "notification_sound": "Звук уведомлений", "notification_sound_desc": "Воспроизводить звук при получении нового сообщения.", + "push_notifications": "Фоновые уведомления", + "push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.", + "push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.", + "push_error": "Не удалось включить фоновые уведомления.", "email_notification": "Уведомления по почте", "email_no_email": "К вашему аккаунту не привязана электронная почта.", "email_send_notif": "Отправлять уведомления на вашу почту.", diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 129f282c..c1433403 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { IPusherRequest } from 'matrix-js-sdk'; @@ -11,6 +11,12 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + useRegisterPushNotifications, + useDisablePushNotifications, + usePushNotificationStatus, + isPushEnabled, +} from '../../../hooks/usePushNotifications'; function EmailNotification() { const { t } = useTranslation(); @@ -86,6 +92,82 @@ function EmailNotification() { ); } +function PushNotification() { + const { t } = useTranslation(); + const status = usePushNotificationStatus(); + const register = useRegisterPushNotifications(); + const disable = useDisablePushNotifications(); + + const [enabled, setEnabled] = useState(() => isPushEnabled()); + + const [enableState, enable] = useAsyncCallback( + useCallback(async () => { + await register(); + setEnabled(true); + }, [register]) + ); + + const [disableState, doDisable] = useAsyncCallback( + useCallback(async () => { + await disable(); + setEnabled(false); + }, [disable]) + ); + + if (status === 'unavailable') return null; + + const busy = + enableState.status === AsyncStatus.Loading || disableState.status === AsyncStatus.Loading; + + let description: React.ReactNode = {t('Settings.push_description')}; + // A permission denial surfaces as both status='denied' AND enableState.error, but + // the status change is async on native (checkPermissions round-trip) while the + // error lands synchronously. Check the error reason first to avoid a flash of + // the generic push_error before the permission-specific message. + const permissionDenied = + status === 'denied' || + (enableState.status === AsyncStatus.Error && + (enableState.error as Error | undefined)?.message === 'permission_denied'); + if (permissionDenied) { + description = ( + + {t('Settings.push_permission_blocked')} + + ); + } else if (enableState.status === AsyncStatus.Error) { + description = ( + + {t('Settings.push_error')} + + ); + } + + let after: React.ReactNode = ( + (val ? enable() : doDisable())} + /> + ); + if (busy) { + after = ; + } else if (status === 'prompt' && !enabled) { + after = ( + + ); + } + + return ( + + ); +} + export function SystemNotification() { const { t } = useTranslation(); const notifPermission = usePermissionState('notifications', getNotificationState()); @@ -136,6 +218,14 @@ export function SystemNotification() { } /> + + + (null); diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts new file mode 100644 index 00000000..af1593f2 --- /dev/null +++ b/src/app/hooks/usePushNotifications.ts @@ -0,0 +1,340 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMatrixClient } from './useMatrixClient'; +import { useClientConfig } from './useClientConfig'; +import { isNativePlatform } from '../utils/capacitor'; +import { + PUSH_APP_IDS, + clearPusherIds, + isPushEnabled, + loadPusherIds, + registerFcmPusher, + registerWebPusher, + savePusherIds, + setPushEnabled, + unregisterPusher, + urlBase64ToUint8Array, +} from '../utils/push'; +import { getHomeRoomPath } from '../pages/pathUtils'; + +const noop = (): void => undefined; + +export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; + +const webStatus = (): PushStatus => { + if (typeof window === 'undefined') return 'unavailable'; + if (!('PushManager' in window) || !('serviceWorker' in navigator)) return 'unavailable'; + if (!('Notification' in window)) return 'unavailable'; + if (Notification.permission === 'granted') return 'granted'; + if (Notification.permission === 'denied') return 'denied'; + return 'prompt'; +}; + +export function usePushNotificationStatus(): PushStatus { + const [status, setStatus] = useState(() => + isNativePlatform() ? 'prompt' : webStatus() + ); + + useEffect(() => { + let cancelled = false; + + const refresh = async (): Promise => { + if (!isNativePlatform()) { + if (!cancelled) setStatus(webStatus()); + return; + } + try { + const { PushNotifications } = await import('@capacitor/push-notifications'); + const result = await PushNotifications.checkPermissions(); + if (cancelled) return; + if (result.receive === 'granted') setStatus('granted'); + else if (result.receive === 'denied') setStatus('denied'); + else setStatus('prompt'); + } catch { + if (!cancelled) setStatus('unavailable'); + } + }; + + refresh(); + + // Re-check when: + // - user returns from OS settings (visibilitychange / focus) + // - register/disable flow explicitly signals a permission change + // - web Permissions API reports a change (best-effort, no native equivalent) + const onVisibility = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh(); + }; + const onFocus = () => refresh(); + const onPermChange = () => refresh(); + + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('focus', onFocus); + window.addEventListener('vojo:pushPermissionChange', onPermChange); + + let permStatus: PermissionStatus | undefined; + if (!isNativePlatform() && 'permissions' in navigator) { + navigator.permissions + // notifications is not in the canonical PermissionName union in some TS libs + .query({ name: 'notifications' as PermissionName }) + .then((p) => { + if (cancelled) return; + permStatus = p; + p.addEventListener('change', onPermChange); + }) + .catch(() => undefined); + } + + return () => { + cancelled = true; + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('focus', onFocus); + window.removeEventListener('vojo:pushPermissionChange', onPermChange); + permStatus?.removeEventListener('change', onPermChange); + }; + }, []); + + return status; +} + +async function getWebSubscription(vapidKey: string): Promise { + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + if (existing) return existing; + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidKey), + }); +} + +export function useRegisterPushNotifications(): () => Promise { + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + + return useCallback(async () => { + const cfg = clientConfig.push; + if (!cfg?.gatewayUrl) { + throw new Error('Push gateway is not configured'); + } + + if (isNativePlatform()) { + const { PushNotifications } = await import('@capacitor/push-notifications'); + const perm = await PushNotifications.requestPermissions(); + // Notify status listeners of the new permission state regardless of grant/deny + // so the UI swaps from 'prompt' → 'granted'/'denied' without waiting for a remount. + window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange')); + if (perm.receive !== 'granted') throw new Error('permission_denied'); + + const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm; + const ids = await new Promise<{ pushkey: string; appId: string }>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('fcm_token_timeout')), 15000); + + let regHandle: { remove: () => Promise } | undefined; + let errHandle: { remove: () => Promise } | undefined; + const cleanup = () => { + clearTimeout(timeout); + regHandle?.remove(); + errHandle?.remove(); + }; + + PushNotifications.addListener('registration', async (token) => { + try { + const result = await registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId); + cleanup(); + resolve(result); + } catch (err) { + cleanup(); + reject(err); + } + }).then((h) => { + regHandle = h; + }); + + PushNotifications.addListener('registrationError', (e) => { + cleanup(); + reject(new Error(e.error || 'fcm_registration_error')); + }).then((h) => { + errHandle = h; + }); + + PushNotifications.register().catch((err) => { + cleanup(); + reject(err); + }); + }); + + savePusherIds(ids); + setPushEnabled(true); + return; + } + + if (!cfg.vapidPublicKey) throw new Error('VAPID key is not configured'); + if (webStatus() === 'unavailable') throw new Error('Push is not supported'); + + const permission = await Notification.requestPermission(); + window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange')); + if (permission !== 'granted') throw new Error('permission_denied'); + + const subscription = await getWebSubscription(cfg.vapidPublicKey); + const appId = cfg.webAppId ?? PUSH_APP_IDS.web; + const ids = await registerWebPusher(mx, subscription, cfg.gatewayUrl, appId); + + savePusherIds(ids); + setPushEnabled(true); + }, [mx, clientConfig]); +} + +export function useDisablePushNotifications(): () => Promise { + const mx = useMatrixClient(); + + return useCallback(async () => { + const ids = loadPusherIds(); + // 1. Deactivate on server first — avoids dead pushers + token leakage + if (ids) { + try { + await unregisterPusher(mx, ids.pushkey, ids.appId); + } catch { + /* ignore */ + } + } + + // 2. Invalidate the device-side token/subscription — a local safety net + // so pushes stop even if the server-side setPusher(kind: null) failed. + // Do NOT remove Capacitor JS listeners — they're owned by + // usePushNotificationsLifecycle and guarded by isPushEnabled() checks, + // so they become no-ops when disabled. Removing them would break + // re-enable in the same session until full app restart. + if (isNativePlatform()) { + try { + const { PushNotifications } = await import('@capacitor/push-notifications'); + await PushNotifications.unregister(); + } catch { + /* ignore */ + } + } else if ('serviceWorker' in navigator) { + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) await sub.unsubscribe(); + } catch { + /* ignore */ + } + } + + clearPusherIds(); + setPushEnabled(false); + }, [mx]); +} + +/** + * Runs the app-level side effects for push notifications: + * - web: auto re-register on startup if previously enabled (subscription may rotate) + * - native: handle FCM token delivery + rotation without recursing into register() + * - navigate to the tapped notification's room (web + native) + */ +export function usePushNotificationsLifecycle(): void { + const register = useRegisterPushNotifications(); + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const navigate = useNavigate(); + + useEffect(() => { + if (isNativePlatform()) return; + if (!isPushEnabled()) return; + register().catch(noop); + }, [register]); + + useEffect(() => { + const onNavigate = (ev: Event) => { + const detail = (ev as CustomEvent).detail as { roomId?: string } | undefined; + if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId)); + }; + const onSubChange = () => { + if (isPushEnabled()) register().catch(noop); + }; + + window.addEventListener('vojo:pushNavigate', onNavigate); + window.addEventListener('vojo:pushSubscriptionChange', onSubChange); + return () => { + window.removeEventListener('vojo:pushNavigate', onNavigate); + window.removeEventListener('vojo:pushSubscriptionChange', onSubChange); + }; + }, [navigate, register]); + + useEffect(() => { + if (!isNativePlatform()) return undefined; + + let cleanup: (() => void) | undefined; + (async () => { + const { PushNotifications } = await import('@capacitor/push-notifications'); + const cfg = clientConfig.push; + + // Android 8+ requires a notification channel before any system notification can appear, + // and apps with no channels aren't listed in Settings → Notifications. Sygnal sends + // data-only FCM (event_id_only format), so the plugin's auto-channel-on-notification-payload + // path never triggers — we must create the channel explicitly. + try { + await PushNotifications.createChannel({ + id: 'vojo_messages', + name: 'Messages', + description: 'New chat messages and invites', + importance: 5, + visibility: 1, + sound: 'default', + vibration: true, + lights: true, + }); + } catch { + /* channel may already exist */ + } + + // Persistent listener: update pusher on the server with the (possibly rotated) token. + // MUST NOT call the full register() flow here — that would call + // PushNotifications.register() again and re-fire this listener → infinite loop. + const regHandle = await PushNotifications.addListener('registration', (token) => { + if (!isPushEnabled()) return; + if (!cfg?.gatewayUrl) return; + const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm; + registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId) + .then((ids) => savePusherIds(ids)) + .catch(noop); + }); + + const actionHandle = await PushNotifications.addListener( + 'pushNotificationActionPerformed', + (action) => { + const roomId = (action.notification.data as { room_id?: string })?.room_id; + if (roomId) navigate(getHomeRoomPath(roomId)); + } + ); + + // Display is handled entirely by the native VojoFirebaseMessagingService + // (which fires for both backgrounded and killed process states via FCM's + // direct delivery). JS-side pushNotificationReceived would double-fire + // alongside native when the bridge is alive but backgrounded, so we don't + // subscribe to it here — foreground dedup stays the in-app responsibility + // (MessageNotifications cards), everything else goes through the native path. + + // Kick off token delivery on startup if push was previously enabled and + // permission is still granted. The listener above picks up the token. + if (isPushEnabled()) { + try { + const perm = await PushNotifications.checkPermissions(); + if (perm.receive === 'granted') { + await PushNotifications.register(); + } + } catch { + /* ignore */ + } + } + + cleanup = () => { + regHandle.remove(); + actionHandle.remove(); + }; + })().catch(noop); + + return () => cleanup?.(); + }, [navigate, mx, clientConfig]); +} + +export { isPushEnabled, getPushPlatform } from '../utils/push'; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index aabe8f88..ddb01504 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -257,6 +258,11 @@ type ClientNonUIFeaturesProps = { children: ReactNode; }; +function PushNotificationsFeature() { + usePushNotificationsLifecycle(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> @@ -265,6 +271,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/app/utils/push.ts b/src/app/utils/push.ts new file mode 100644 index 00000000..f5d68e37 --- /dev/null +++ b/src/app/utils/push.ts @@ -0,0 +1,169 @@ +import { MatrixClient, IPusherRequest } from 'matrix-js-sdk'; +import { isNativePlatform } from './capacitor'; + +export type PushPlatform = 'web' | 'fcm'; + +export const getPushPlatform = (): PushPlatform => (isNativePlatform() ? 'fcm' : 'web'); + +export const PUSH_APP_IDS = { + web: 'chat.vojo.app.web', + fcm: 'chat.vojo.app.fcm', +} as const; + +export const PUSH_ENABLED_KEY = 'vojo_push_enabled'; + +export const isPushEnabled = (): boolean => localStorage.getItem(PUSH_ENABLED_KEY) === 'true'; + +export const setPushEnabled = (enabled: boolean): void => { + if (enabled) localStorage.setItem(PUSH_ENABLED_KEY, 'true'); + else localStorage.removeItem(PUSH_ENABLED_KEY); +}; + +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const output = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i += 1) output[i] = rawData.charCodeAt(i); + return output; +} + +const arrayBufferToBase64Url = (buf: ArrayBuffer): string => { + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i += 1) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +/* eslint-disable no-await-in-loop */ +async function withRetry(fn: () => Promise, attempts = 3): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i < attempts - 1) { + await new Promise((r) => { + setTimeout(r, 2 ** i * 500); + }); + } + } + } + throw lastErr; +} +/* eslint-enable no-await-in-loop */ + +export type PusherIds = { + pushkey: string; + appId: string; +}; + +export async function registerWebPusher( + mx: MatrixClient, + subscription: PushSubscription, + gatewayUrl: string, + appId: string +): Promise { + const p256dh = subscription.getKey('p256dh'); + const auth = subscription.getKey('auth'); + if (!p256dh || !auth) throw new Error('PushSubscription is missing required keys'); + + // Sygnal webpush spec (https://github.com/matrix-org/sygnal/blob/main/docs/applications.md): + // pushkey = p256dh (base64url), endpoint/auth go inside data. + // `format: 'event_id_only'` belongs at top-level of `data` per Matrix pusher spec — + // Sygnal reads it there to strip content before shipping the push. The SW then pulls + // content via authed Matrix API, so nothing sensitive flows through the push gateway. + // `events_only: true` tells Sygnal to skip unread-count-only pushes (no event_id → + // nothing useful for the SW to display, would produce generic "New message" spam). + // append: true so we don't clobber pushers from other accounts that share the same + // browser-level push subscription (one SW → one p256dh across accounts). + const pushkey = arrayBufferToBase64Url(p256dh); + + await withRetry(() => + mx.setPusher({ + kind: 'http', + app_id: appId, + pushkey, + app_display_name: 'Vojo Web', + device_display_name: navigator.userAgent.slice(0, 120), + lang: navigator.language || 'en', + data: { + url: gatewayUrl, + endpoint: subscription.endpoint, + auth: arrayBufferToBase64Url(auth), + format: 'event_id_only', + events_only: true, + }, + append: true, + }) + ); + + return { pushkey, appId }; +} + +export async function registerFcmPusher( + mx: MatrixClient, + fcmToken: string, + gatewayUrl: string, + appId: string, + deviceName = 'Android' +): Promise { + // Intentionally NOT sending `format: 'event_id_only'` — we need the full payload + // fields (room_name, sender_display_name, content_body, …) in FCM data. When the + // app is killed, VojoFirebaseMessagingService (native Java) builds the system + // notification straight from the data map; it can't easily re-auth against the + // Matrix API to fetch event content the way the web Service Worker does. + // Sygnal ships Matrix pushes as data-only FCM regardless of `format` — the field + // controls which keys are present, not FCM priority/delivery. + await withRetry(() => + mx.setPusher({ + kind: 'http', + app_id: appId, + pushkey: fcmToken, + app_display_name: 'Vojo Android', + device_display_name: deviceName, + lang: navigator.language || 'en', + data: { + url: gatewayUrl, + }, + append: false, + }) + ); + + return { pushkey: fcmToken, appId }; +} + +export async function unregisterPusher( + mx: MatrixClient, + pushkey: string, + appId: string +): Promise { + await mx.setPusher({ + kind: null, + app_id: appId, + pushkey, + } as unknown as IPusherRequest); +} + +const PUSHER_IDS_KEY = 'vojo_push_pusher_ids'; + +export const savePusherIds = (ids: PusherIds): void => { + localStorage.setItem(PUSHER_IDS_KEY, JSON.stringify(ids)); +}; + +export const loadPusherIds = (): PusherIds | undefined => { + const raw = localStorage.getItem(PUSHER_IDS_KEY); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + if (typeof parsed?.pushkey === 'string' && typeof parsed?.appId === 'string') return parsed; + } catch { + /* noop */ + } + return undefined; +}; + +export const clearPusherIds = (): void => { + localStorage.removeItem(PUSHER_IDS_KEY); +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 498d4f75..757451a0 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,6 +3,13 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; +import { + clearPusherIds, + loadPusherIds, + setPushEnabled, + unregisterPusher, +} from '../app/utils/push'; +import { isNativePlatform } from '../app/utils/capacitor'; type Session = { baseUrl: string; @@ -54,7 +61,39 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { }; export const logoutClient = async (mx: MatrixClient) => { + // 1. Deactivate pusher on the homeserver while we still have a valid token. + // Run before pushSessionToSW() so that if a push arrives mid-logout, the + // SW still has a working session to resolve event details with. + const pusherIds = loadPusherIds(); + if (pusherIds) { + try { + await unregisterPusher(mx, pusherIds.pushkey, pusherIds.appId); + } catch { + // ignore — logout must proceed + } + } + pushSessionToSW(); + + // 2. Unsubscribe locally so the browser/FCM stops delivering to this device. + // Critical: if step 1 failed (bad network, server 5xx), this is the only + // thing that actually stops pushes from arriving at this installation. + try { + if (isNativePlatform()) { + const { PushNotifications } = await import('@capacitor/push-notifications'); + await PushNotifications.unregister(); + } else if ('serviceWorker' in navigator) { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) await sub.unsubscribe(); + } + } catch { + // ignore + } + + clearPusherIds(); + setPushEnabled(false); + mx.stopClient(); try { await mx.logout(); diff --git a/src/index.tsx b/src/index.tsx index 5a368746..16b946c7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,6 +42,17 @@ if ('serviceWorker' in navigator) { if (type === 'requestSession') { sendSessionToSW(); + return; + } + + if (type === 'notificationClick') { + const { roomId } = ev.data ?? {}; + window.dispatchEvent(new CustomEvent('vojo:pushNavigate', { detail: { roomId } })); + return; + } + + if (type === 'pushSubscriptionChange') { + window.dispatchEvent(new CustomEvent('vojo:pushSubscriptionChange')); } }); } diff --git a/src/sw.ts b/src/sw.ts index 69293b1d..467c30d0 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -156,3 +156,168 @@ self.addEventListener('fetch', (event: FetchEvent) => { }) ); }); + +// --- Push Notifications --- + +type PushPayload = { + notification?: { + event_id?: string; + room_id?: string; + sender?: string; + sender_display_name?: string; + room_name?: string; + type?: string; + content?: { body?: string; msgtype?: string }; + counts?: { unread?: number; missed_calls?: number }; + prio?: 'high' | 'low'; + }; +}; + +async function hasVisibleClient(): Promise { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + return clients.some((c) => c.visibilityState === 'visible' && c.focused); +} + +async function anySession(): Promise { + const clients = await self.clients.matchAll({ type: 'window' }); + const existing = clients.map((c) => sessions.get(c.id)).find((s): s is SessionInfo => !!s); + if (existing) return existing; + if (clients.length > 0) { + return requestSessionWithTimeout(clients[0].id); + } + return undefined; +} + +// Fallback strings for when we can't fetch event content (offline / encrypted / +// no session). The main app runs i18next, but the SW is a separate context — +// we read navigator.language here and ship a small map for the locales we support. +type PushFallback = { brand: string; newMessage: string; encrypted: string }; +const PUSH_FALLBACKS: Record = { + en: { brand: 'Vojo', newMessage: 'New message', encrypted: 'New encrypted message' }, + ru: { brand: 'Vojo', newMessage: 'Новое сообщение', encrypted: 'Новое зашифрованное сообщение' }, +}; + +function pushFallback(): PushFallback { + const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') + .slice(0, 2) + .toLowerCase(); + return PUSH_FALLBACKS[lang] ?? PUSH_FALLBACKS.en; +} + +async function fetchEventDetails( + session: SessionInfo, + roomId: string, + eventId: string +): Promise<{ title: string; body: string }> { + const headers = { Authorization: `Bearer ${session.accessToken}` }; + const [evRes, nameRes] = await Promise.all([ + fetch( + `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, + { headers } + ), + fetch( + `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name/`, + { headers } + ).catch(() => undefined), + ]); + + const fb = pushFallback(); + let title = fb.brand; + let body = fb.newMessage; + + if (evRes.ok) { + const event = await evRes.json(); + if (event?.type === 'm.room.encrypted') { + body = fb.encrypted; + } else if (typeof event?.content?.body === 'string') { + body = event.content.body.slice(0, 200); + } + } + + if (nameRes?.ok) { + const json = await nameRes.json(); + if (typeof json?.name === 'string') title = json.name; + } + + return { title, body }; +} + +self.addEventListener('push', (event: PushEvent) => { + if (!event.data) return; + + event.waitUntil( + (async () => { + // Foreground dedup: in-app MessageNotifications handles visible clients + if (await hasVisibleClient()) return; + + let payload: PushPayload = {}; + try { + if (event.data) payload = event.data.json(); + } catch { + payload = {}; + } + + const notif = payload.notification; + const roomId = notif?.room_id; + const eventId = notif?.event_id; + + // Defensive: if Sygnal sends a notification without event_id (e.g. unread-count-only + // push, which `events_only: true` on the pusher should suppress but can slip through + // if config drifts), skip it. We'd otherwise show a generic "New message" tied to no + // room/event — clicking it goes nowhere useful. + if (!eventId || !roomId) return; + + const fb = pushFallback(); + let title = notif?.room_name ?? fb.brand; + let body = notif?.content?.body ?? fb.newMessage; + + const session = await anySession(); + if (session) { + try { + const details = await fetchEventDetails(session, roomId, eventId); + title = details.title; + body = details.body; + } catch { + // fall back to defaults + } + } + + await self.registration.showNotification(title, { + body, + icon: '/res/android/android-chrome-192x192.png', + badge: '/res/android/android-chrome-96x96.png', + tag: roomId, + data: { roomId, eventId }, + renotify: true, + } as NotificationOptions & { renotify?: boolean }); + })() + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const { roomId } = (event.notification.data as { roomId?: string }) ?? {}; + + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (windows.length > 0) { + const target = windows[0]; + await target.focus(); + target.postMessage({ type: 'notificationClick', roomId }); + return; + } + const path = roomId ? `/home/${encodeURIComponent(roomId)}/` : '/'; + await self.clients.openWindow(path); + })() + ); +}); + +self.addEventListener('pushsubscriptionchange', ((event: ExtendableEvent) => { + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + windows.forEach((c) => c.postMessage({ type: 'pushSubscriptionChange' })); + })() + ); +}) as EventListener);