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.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation project(':capacitor-android')
|
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"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ android {
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-browser')
|
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"?>
|
<?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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
@ -33,9 +34,25 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,23 @@ import androidx.activity.EdgeToEdge;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
|
public static volatile boolean isInForeground = false;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
EdgeToEdge.enable(this);
|
EdgeToEdge.enable(this);
|
||||||
super.onCreate(savedInstanceState);
|
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'
|
include ':capacitor-browser'
|
||||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
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
|
// Keep default: resolveServiceWorkerRequests = true
|
||||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
PushNotifications: {
|
||||||
|
presentationOptions: ['badge', 'sound', 'alert'],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,12 @@
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"basename": "/"
|
"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/browser": "8.0.3",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
|
"@capacitor/push-notifications": "8.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
|
|
@ -109,7 +110,7 @@
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
|
|
@ -1905,6 +1906,15 @@
|
||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
"@capacitor/browser": "8.0.3",
|
"@capacitor/browser": "8.0.3",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
|
"@capacitor/push-notifications": "8.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,10 @@
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"notification_sound": "Notification Sound",
|
"notification_sound": "Notification Sound",
|
||||||
"notification_sound_desc": "Play sound when a new message arrives.",
|
"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_notification": "Email Notification",
|
||||||
"email_no_email": "Your account does not have any email attached.",
|
"email_no_email": "Your account does not have any email attached.",
|
||||||
"email_send_notif": "Send notification to your email.",
|
"email_send_notif": "Send notification to your email.",
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,10 @@
|
||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"notification_sound": "Звук уведомлений",
|
"notification_sound": "Звук уведомлений",
|
||||||
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
||||||
|
"push_notifications": "Фоновые уведомления",
|
||||||
|
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
|
||||||
|
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
|
||||||
|
"push_error": "Не удалось включить фоновые уведомления.",
|
||||||
"email_notification": "Уведомления по почте",
|
"email_notification": "Уведомления по почте",
|
||||||
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
||||||
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||||
import { IPusherRequest } from 'matrix-js-sdk';
|
import { IPusherRequest } from 'matrix-js-sdk';
|
||||||
|
|
@ -11,6 +11,12 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import {
|
||||||
|
useRegisterPushNotifications,
|
||||||
|
useDisablePushNotifications,
|
||||||
|
usePushNotificationStatus,
|
||||||
|
isPushEnabled,
|
||||||
|
} from '../../../hooks/usePushNotifications';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const { t } = useTranslation();
|
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() {
|
export function SystemNotification() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
|
|
@ -136,6 +218,14 @@ export function SystemNotification() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<PushNotification />
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ export type HashRouterConfig = {
|
||||||
basename?: string;
|
basename?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PushConfig = {
|
||||||
|
vapidPublicKey: string;
|
||||||
|
gatewayUrl: string;
|
||||||
|
webAppId?: string;
|
||||||
|
fcmAppId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
defaultHomeserver?: number;
|
defaultHomeserver?: number;
|
||||||
homeserverList?: string[];
|
homeserverList?: string[];
|
||||||
|
|
@ -18,6 +25,8 @@ export type ClientConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
hashRouter?: HashRouterConfig;
|
hashRouter?: HashRouterConfig;
|
||||||
|
|
||||||
|
push?: PushConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
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 { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||||
|
|
@ -257,6 +258,11 @@ type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function PushNotificationsFeature() {
|
||||||
|
usePushNotificationsLifecycle();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -265,6 +271,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<PushNotificationsFeature />
|
||||||
{children}
|
{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 { cryptoCallbacks } from './secretStorageKeys';
|
||||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||||
import { pushSessionToSW } from '../sw-session';
|
import { pushSessionToSW } from '../sw-session';
|
||||||
|
import {
|
||||||
|
clearPusherIds,
|
||||||
|
loadPusherIds,
|
||||||
|
setPushEnabled,
|
||||||
|
unregisterPusher,
|
||||||
|
} from '../app/utils/push';
|
||||||
|
import { isNativePlatform } from '../app/utils/capacitor';
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -54,7 +61,39 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logoutClient = 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();
|
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();
|
mx.stopClient();
|
||||||
try {
|
try {
|
||||||
await mx.logout();
|
await mx.logout();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,17 @@ if ('serviceWorker' in navigator) {
|
||||||
|
|
||||||
if (type === 'requestSession') {
|
if (type === 'requestSession') {
|
||||||
sendSessionToSW();
|
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