add notifications support for android (mostly)
This commit is contained in:
parent
05dfc9facb
commit
52de41cf04
21 changed files with 1048 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ android {
|
|||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
29
android/app/google-services.json
Executable file
29
android/app/google-services.json
Executable 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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "Отправлять уведомления на вашу почту.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
340
src/app/hooks/usePushNotifications.ts
Normal file
340
src/app/hooks/usePushNotifications.ts
Normal 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';
|
||||
|
|
@ -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
169
src/app/utils/push.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
165
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<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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue