add notifications support for android (mostly)

This commit is contained in:
v.lagerev 2026-04-17 22:54:44 +03:00
parent 05dfc9facb
commit 52de41cf04
21 changed files with 1048 additions and 3 deletions

View file

@ -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"

View file

@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-browser')
implementation project(':capacitor-push-notifications')
}

View file

@ -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"
}

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
@ -33,9 +34,25 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<!-- Replace Capacitor's default FCM service. VojoFirebaseMessagingService
extends MessagingService so super.onMessageReceived() still forwards
to the JS bridge; we add cold-start notification display on top. -->
<service
android:name="com.capacitorjs.plugins.pushnotifications.MessagingService"
tools:node="remove" />
<service
android:name=".VojoFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View file

@ -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;
}
}

View file

@ -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<String, String> 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<String, String> 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 "";
}
}

View file

@ -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')

View file

@ -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;

View file

@ -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"
}
}

12
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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.",

View file

@ -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": "Отправлять уведомления на вашу почту.",

View file

@ -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<boolean>(() => 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 = <span>{t('Settings.push_description')}</span>;
// 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 = (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{t('Settings.push_permission_blocked')}
</Text>
);
} else if (enableState.status === AsyncStatus.Error) {
description = (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{t('Settings.push_error')}
</Text>
);
}
let after: React.ReactNode = (
<Switch
disabled={status === 'denied'}
value={enabled}
onChange={(val) => (val ? enable() : doDisable())}
/>
);
if (busy) {
after = <Spinner variant="Secondary" />;
} else if (status === 'prompt' && !enabled) {
after = (
<Button size="300" radii="300" onClick={() => enable()}>
<Text size="B300">{t('Settings.enable')}</Text>
</Button>
);
}
return (
<SettingTile
title={t('Settings.push_notifications')}
description={description}
after={after}
/>
);
}
export function SystemNotification() {
const { t } = useTranslation();
const notifPermission = usePermissionState('notifications', getNotificationState());
@ -136,6 +218,14 @@ export function SystemNotification() {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<PushNotification />
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"

View file

@ -5,6 +5,13 @@ export type HashRouterConfig = {
basename?: string;
};
export type PushConfig = {
vapidPublicKey: string;
gatewayUrl: string;
webAppId?: string;
fcmAppId?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -18,6 +25,8 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
push?: PushConfig;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -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<PushStatus>(() =>
isNativePlatform() ? 'prompt' : webStatus()
);
useEffect(() => {
let cancelled = false;
const refresh = async (): Promise<void> => {
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<PushSubscription> {
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<void> {
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<void> } | undefined;
let errHandle: { remove: () => Promise<void> } | 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<void> {
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';

View file

@ -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) {
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
<PushNotificationsFeature />
{children}
</>
);

169
src/app/utils/push.ts Normal file
View file

@ -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<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
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<PusherIds> {
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<PusherIds> {
// 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<void> {
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);
};

View file

@ -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();

View file

@ -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'));
}
});
}

165
src/sw.ts
View file

@ -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<boolean> {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
return clients.some((c) => c.visibilityState === 'visible' && c.focused);
}
async function anySession(): Promise<SessionInfo | undefined> {
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<string, PushFallback> = {
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);