From b45290d16f516faf65b05596a7f613d9f3c4e38e Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 18:55:06 -0500 Subject: [PATCH 01/64] Add a public key for VAPID, push ID, and push endpoint (I'm willing to publicly host it unless it becomes irrational in cost which should never happen) --- config.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config.json b/config.json index de6015a1..dc03d487 100644 --- a/config.json +++ b/config.json @@ -10,6 +10,12 @@ ], "allowCustomHomeservers": true, + "pushNotificationDetails": { + "pushNotifyUrl": "https://cinny.cc/_matrix/push/v1/notify", + "vapidPublicKey": "BHLwykXs79AbKNiblEtZZRAgnt7o5_ieImhVJD8QZ01MVwAHnXwZzNgQEJJEU3E5CVsihoKtb7yaNe5x3vmkWkI", + "webPushAppID": "cc.cinny.web" + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ From 5f52e932aede15afe51812126904b7f9b8a0cdc2 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 18:57:59 -0500 Subject: [PATCH 02/64] add workbox precaching (for SW caching) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b1b14e55..f278255a 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "vite": "5.4.15", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "workbox-precaching": "7.3.0" } } From 4cc1179a4f6b253fd8e32fa9b9bf66b3b71197c3 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 19:00:39 -0500 Subject: [PATCH 03/64] Add claim and install events --- src/sw.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sw.ts b/src/sw.ts index 2179dfcb..d2b03b3d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -28,7 +28,15 @@ function fetchConfig(token?: string): RequestInit | undefined { } self.addEventListener('activate', (event: ExtendableEvent) => { - event.waitUntil(clients.claim()); + event.waitUntil( + (async () => { + await self.clients.claim(); + })() + ); +}); + +self.addEventListener('install', (event: ExtendableEvent) => { + event.waitUntil(self.skipWaiting()); }); self.addEventListener('fetch', (event: FetchEvent) => { From 14085c7ccf74f819b68755391b0a160651dd4d3d Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 19:02:03 -0500 Subject: [PATCH 04/64] Convert to a more generic implementation for communicating with the client --- src/sw.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index d2b03b3d..419be534 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -3,19 +3,33 @@ export type {}; declare const self: ServiceWorkerGlobalScope; -async function askForAccessToken(client: Client): Promise { - return new Promise((resolve) => { - const responseKey = Math.random().toString(36); - const listener = (event: ExtendableMessageEvent) => { - if (event.data.responseKey !== responseKey) return; - resolve(event.data.token); - self.removeEventListener('message', listener); - }; - self.addEventListener('message', listener); - client.postMessage({ responseKey, type: 'token' }); +const DEFAULT_NOTIFICATION_ICON = '/icons/icon-192x192.png'; // Replace with your actual default icon path +const DEFAULT_NOTIFICATION_BADGE = '/icons/badge-72x72.png'; // Replace with your actual default badge icon path (for notification UI) + +const pendingReplies = new Map(); +let messageIdCounter = 0; +function sendAndWaitForReply(client: WindowClient, type: string, payload: object) { + messageIdCounter += 1; + const id = messageIdCounter; + const promise = new Promise((resolve) => { + pendingReplies.set(id, resolve); }); + client.postMessage({ type, id, payload }); + return promise; } +self.addEventListener('message', (event) => { + const { replyTo } = event.data; + if (replyTo) { + const resolve = pendingReplies.get(replyTo); + if (resolve) { + pendingReplies.delete(replyTo); + resolve(event.data.payload); + } + } +}); + + function fetchConfig(token?: string): RequestInit | undefined { if (!token) return undefined; From 5029ea0a8ae8f854b8b93b15cb36ea0d594de5e6 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:04:22 -0500 Subject: [PATCH 05/64] add precaching from workbox import --- src/sw.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sw.ts b/src/sw.ts index 419be534..ffc5c0e1 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,4 +1,5 @@ /// +import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'; export type {}; declare const self: ServiceWorkerGlobalScope; From 4968e23dffee810e5e99a599f340415f0da150ee Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:04:38 -0500 Subject: [PATCH 06/64] actually set the path (should fix android blank icon issue) --- src/sw.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index ffc5c0e1..4a664c1f 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -4,8 +4,8 @@ import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'; export type {}; declare const self: ServiceWorkerGlobalScope; -const DEFAULT_NOTIFICATION_ICON = '/icons/icon-192x192.png'; // Replace with your actual default icon path -const DEFAULT_NOTIFICATION_BADGE = '/icons/badge-72x72.png'; // Replace with your actual default badge icon path (for notification UI) +const DEFAULT_NOTIFICATION_ICON = '/public/res/apple/apple-touch-icon-180x180.png'; +const DEFAULT_NOTIFICATION_BADGE = '/public/res/apple-touch-icon-72x72.png'; const pendingReplies = new Map(); let messageIdCounter = 0; From 3b62beabf1dee0510d6c66d96ffe3d50c77c6e2a Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:04:55 -0500 Subject: [PATCH 07/64] add param type --- src/sw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sw.ts b/src/sw.ts index 4a664c1f..62db9e95 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -19,7 +19,7 @@ function sendAndWaitForReply(client: WindowClient, type: string, payload: object return promise; } -self.addEventListener('message', (event) => { +self.addEventListener('message', (event: ExtendableMessageEvent) => { const { replyTo } = event.data; if (replyTo) { const resolve = pendingReplies.get(replyTo); From 1d4864592fc60f57e5124ccde1b167950ae33bf4 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:05:28 -0500 Subject: [PATCH 08/64] Clean up fetch code --- src/sw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 62db9e95..16ee5de5 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -65,11 +65,11 @@ self.addEventListener('fetch', (event: FetchEvent) => { } event.respondWith( (async (): Promise => { + console.log('Unironic race condition mitigation it seems.'); const client = await self.clients.get(event.clientId); - let token: string | undefined; - if (client) token = await askForAccessToken(client); - - return fetch(url, fetchConfig(token)); + const token: string = await sendAndWaitForReply(client, 'token', {}); + const response = await fetch(url, fetchConfig(token)); + return response; })() ); }); From c77ba1b5342535c22df2789939c0239596769a91 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:07:24 -0500 Subject: [PATCH 09/64] Add push notif handling (clicks and push events) --- src/sw.ts | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/sw.ts b/src/sw.ts index 16ee5de5..ac86f622 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -73,3 +73,80 @@ self.addEventListener('fetch', (event: FetchEvent) => { })() ); }); + +const onPushNotification = async (event: PushEvent) => { + let title = 'New Notification'; + const options: NotificationOptions = { + body: 'You have a new message!', + icon: DEFAULT_NOTIFICATION_ICON, + badge: DEFAULT_NOTIFICATION_BADGE, + data: { + url: self.registration.scope, + timestamp: Date.now(), + }, + // tag: 'cinny-notification-tag', // Optional: Replaces existing notification with same tag + // renotify: true, // Optional: If using tag, renotify will alert user even if tag matches + // silent: false, // Optional: Set to true for no sound/vibration. User can also set this. + }; + + if (event.data) { + try { + const pushData = event.data.json(); + title = pushData.title || title; + options.body = options.body ?? pushData.data.toString(); + options.icon = pushData.icon || options.icon; + options.badge = pushData.badge || options.badge; + + if (pushData.image) options.image = pushData.image; + if (pushData.vibrate) options.vibrate = pushData.vibrate; + if (pushData.actions) options.actions = pushData.actions; + options.tag = 'Cinny'; + if (typeof pushData.renotify === 'boolean') options.renotify = pushData.renotify; + if (typeof pushData.silent === 'boolean') options.silent = pushData.silent; + + if (pushData.data) { + options.data = { ...options.data, ...pushData.data }; + } + if (typeof pushData.unread === 'number') { + await self.navigator.setAppBadge(pushData.unread); + } else { + await navigator.clearAppBadge(); + } + } catch (e) { + const pushText = event.data.text(); + options.body = pushText || options.body; + } + } + + return self.registration.showNotification(title, options); +}; + +self.addEventListener('push', (event: PushEvent) => event.waitUntil(onPushNotification(event))); + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close(); + + /** + * We should likely add a postMessage back to navigate to the room the event is from + */ + const targetUrl = event.notification.data?.url || self.registration.scope; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url === targetUrl && 'focus' in client) { + return (client as WindowClient).focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + return Promise.resolve(); + }) + ); +}); + +if (self.__WB_MANIFEST) { + precacheAndRoute(self.__WB_MANIFEST); +} +cleanupOutdatedCaches(); From ca026bf9472332433df084f56a133d1b89c67b9d Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:08:44 -0500 Subject: [PATCH 10/64] Add SW update prompt (useful for rolling out new changes and informing the user of them) --- src/index.tsx | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 402a4c1b..da60808a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,7 +25,43 @@ if ('serviceWorker' in navigator) { ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` : `/dev-sw.js?dev-sw`; - navigator.serviceWorker.register(swUrl); + const swRegisterOptions: RegistrationOptions = {}; + if (!isProduction) { + swRegisterOptions.type = 'module'; + } + + const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { + const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; + const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY); + + if (userPreference === 'true') { + return; + } + + if (window.confirm('A new version of the app is available. Refresh to update?')) { + if (registration.waiting) { + registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' }); + } else { + window.location.reload(); + } + } + }; + + navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker) { + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + showUpdateAvailablePrompt(registration); + } + } + }; + } + }; + }); + navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'token' && event.data?.responseKey) { // Get the token for SW. From f6acd72ab519d78fe5399b17f6158d1d224a101c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:10:05 -0500 Subject: [PATCH 11/64] Add isProduction to determine the behavior to use for the SW --- src/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index da60808a..a65466e2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,10 +18,9 @@ import './app/i18n'; document.body.classList.add(configClass, varsClass); -// Register Service Worker if ('serviceWorker' in navigator) { - const swUrl = - import.meta.env.MODE === 'production' + const isProduction = import.meta.env.MODE === 'production'; + const swUrl = isProduction ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` : `/dev-sw.js?dev-sw`; From 1db4685189c8efc18a4578a22544a01461744cee Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:13:10 -0500 Subject: [PATCH 12/64] Embed a very simple message handler between the SW and client for now (for token auth atm) --- src/index.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index a65466e2..fd3ec6be 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -62,13 +62,23 @@ if ('serviceWorker' in navigator) { }); navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data?.type === 'token' && event.data?.responseKey) { - // Get the token for SW. + if (!event.data || !event.source) { + return; + } + + if (event.data.type === 'token' && event.data.id) { const token = localStorage.getItem('cinny_access_token') ?? undefined; - event.source!.postMessage({ - responseKey: event.data.responseKey, - token, + event.source.postMessage({ + replyTo: event.data.id, + payload: token, }); + } else if (event.data.type === 'openRoom' && event.data.id) { + /* Example: + event.source.postMessage({ + replyTo: event.data.id, + payload: success?, + }); + */ } }); } From 36227ef2579ad63aaa8f1adf045a022e2638cc9a Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:13:38 -0500 Subject: [PATCH 13/64] Move MapSet position --- src/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index fd3ec6be..ef9baa7f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,8 @@ import App from './app/pages/App'; // import i18n (needs to be bundled ;)) import './app/i18n'; +enableMapSet(); + document.body.classList.add(configClass, varsClass); if ('serviceWorker' in navigator) { From 4af54629f792d7a2b089409bb492e78bd30b597a Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:13:59 -0500 Subject: [PATCH 14/64] clean up imports --- src/index.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index ef9baa7f..c79fa6ad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,15 +5,9 @@ import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; - -enableMapSet(); - import './index.scss'; - import { trimTrailingSlash } from './app/utils/common'; import App from './app/pages/App'; - -// import i18n (needs to be bundled ;)) import './app/i18n'; enableMapSet(); From 8a3eee367d7b3aebff653d85b549a136beefecb6 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:14:15 -0500 Subject: [PATCH 15/64] remove disable import --- src/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index c79fa6ad..f5118555 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable import/first */ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; @@ -17,8 +16,8 @@ document.body.classList.add(configClass, varsClass); if ('serviceWorker' in navigator) { const isProduction = import.meta.env.MODE === 'production'; const swUrl = isProduction - ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` - : `/dev-sw.js?dev-sw`; + ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` + : `/dev-sw.js?dev-sw`; const swRegisterOptions: RegistrationOptions = {}; if (!isProduction) { From a8839633146846b1bc77b22e2c3679cc93ff1189 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:15:45 -0500 Subject: [PATCH 16/64] Adds the client state listener for toggling whether to use push notifs or app notifs --- src/app/pages/client/ClientRoot.tsx | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 846d8ff3..8eac63bd 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; +import { togglePusher } from '../../features/settings/notifications/PushNotifications'; function ClientRootLoading() { return ( @@ -124,6 +125,18 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { ); } +const pushNotificationListener = (mx: MatrixClient) => { + navigator.serviceWorker.ready.then((registration) => { + registration.pushManager.getSubscription().then((subscription) => { + document.addEventListener('visibilitychange', () => { + console.log('Check check baby'); + togglePusher(mx, subscription, document.visibilityState === 'visible'); + }); + togglePusher(mx, subscription, true); + }); + }); +}; + const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { @@ -171,13 +184,16 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncState( mx, - useCallback((state) => { - if (state === 'PREPARED') { - setLoading(false); - } - }, []) + useCallback( + (state) => { + if (state === 'PREPARED') { + setLoading(false); + pushNotificationListener(mx); + } + }, + [mx] + ) ); - return ( {mx && } From ac5925f293f8a8e44238afc50baff554995bc966 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:16:44 -0500 Subject: [PATCH 17/64] Add a check for our custom tweak in the in-app notif handling to determine which should be used --- src/app/pages/client/ClientNonUIFeatures.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..0e2d5d67 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -187,12 +187,15 @@ function MessageNotifications() { ) => { if (mx.getSyncState() !== 'SYNCING') return; if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; + let pushActions = mx.getPushActionsForEvent(mEvent); + const hasInAppTweak = pushActions?.tweaks?.sound === 'cinny_show_banner'; + if ( !room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent) || - getNotificationType(mx, room.roomId) === NotificationType.Mute + (getNotificationType(mx, room.roomId) === NotificationType.Mute && !hasInAppTweak) ) { return; } From e2fbf9e738f0cde5e1627bcfb346205d6fb3a3b7 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:44:55 -0500 Subject: [PATCH 18/64] Add WebPush separate toggle --- .../notifications/SystemNotification.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0df06df..a5470dd7 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -84,6 +84,110 @@ function EmailNotification() { ); } +function WebPushNotificationSetting() { + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const [userWantsWebPush, setUserWantsWebPush] = useSetting(settingsAtom, 'enableWebPush'); + + const browserPermission = usePermissionState('notifications', getNotificationState()); + const [isPushSubscribed, setIsPushSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(true); // Start loading to check status + + const checkSubscriptionStatus = useCallback(async () => { + if ( + browserPermission === 'granted' && + 'serviceWorker' in navigator && + 'PushManager' in window + ) { + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + setIsPushSubscribed(!!subscription); + } catch (err) { + setIsPushSubscribed(false); + } finally { + setIsLoading(false); + } + } else { + setIsPushSubscribed(false); + setIsLoading(false); + } + }, [browserPermission]); + + useEffect(() => { + checkSubscriptionStatus(); + }, [checkSubscriptionStatus]); + + const handleRequestPermissionAndEnable = async () => { + setIsLoading(true); + try { + const permissionResult = await requestBrowserNotificationPermission(); + if (permissionResult === 'granted') { + setUserWantsWebPush(true); + await enablePushNotifications(mx, clientConfig); + } else { + setUserWantsWebPush(false); + } + } catch (error: any) { + setUserWantsWebPush(false); + } finally { + await checkSubscriptionStatus(); + setIsLoading(false); + } + }; + + const handlePushSwitchChange = async (wantsPush: boolean) => { + setIsLoading(true); + setUserWantsWebPush(wantsPush); + + try { + if (wantsPush) { + await enablePushNotifications(mx, clientConfig); + } else { + await disablePushNotifications(mx, clientConfig); + } + } catch (error: any) { + setUserWantsWebPush(!wantsPush); + } finally { + await checkSubscriptionStatus(); + setIsLoading(false); + } + }; + + let descriptionText = 'Receive notifications when the app is closed or in the background.'; + if (browserPermission === 'granted' && isPushSubscribed) { + descriptionText = + 'You are subscribed to receive notifications when the app is in the background.'; + } + + return ( + + Notification permission is blocked by your browser. Please allow it from site settings. + + ) : ( + {descriptionText} + ) + } + after={ + isLoading ? ( + + ) : browserPermission === 'prompt' ? ( + + ) : browserPermission === 'granted' ? ( + + ) : null + } + /> + ); +} + export function SystemNotification() { const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); From a271a5647e95e400ca7c05c7547140646b535faa Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:45:43 -0500 Subject: [PATCH 19/64] Add render changes --- .../notifications/SystemNotification.tsx | 47 ++++++------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index a5470dd7..deb775a0 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -189,20 +189,23 @@ function WebPushNotificationSetting() { } export function SystemNotification() { - const notifPermission = usePermissionState('notifications', getNotificationState()); - const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [showInAppNotifs, setShowInAppNotifs] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( settingsAtom, 'isNotificationSounds' ); - const requestNotificationPermission = () => { - window.Notification.requestPermission(); - }; - return ( - System + System & Notifications + + + - {'Notification' in window - ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' - : 'Notifications are not supported by the system.'} - - ) : ( - Show desktop notifications when message arrive. - ) - } - after={ - notifPermission === 'prompt' ? ( - - ) : ( - - ) - } + title="In-App Notifications" + description="Show a notification when a message arrives while the app is open (but not focused on the room)." + after={} /> } /> From b801f076a773924ac9c9bbb45f0c42aadc4fb277 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:45:57 -0500 Subject: [PATCH 20/64] update imports --- .../settings/notifications/SystemNotification.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index deb775a0..6e292dd8 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,6 +1,5 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; -import { IPusherRequest } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -10,6 +9,12 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + requestBrowserNotificationPermission, + enablePushNotifications, + disablePushNotifications, +} from './PushNotifications'; +import { useClientConfig } from '../../../hooks/useClientConfig'; function EmailNotification() { const mx = useMatrixClient(); From e38eeb30bda871219fd33e6a1b98ff5754e4284c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:46:19 -0500 Subject: [PATCH 21/64] Add push notif implementation --- .../notifications/PushNotifications.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/app/features/settings/notifications/PushNotifications.tsx diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx new file mode 100644 index 00000000..75b830ef --- /dev/null +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -0,0 +1,211 @@ +import { MatrixClient, PushRuleActionName, PushRuleKind, TweakName } from 'matrix-js-sdk'; +import { ClientConfig } from '../../../hooks/useClientConfig'; + +export async function requestBrowserNotificationPermission(): Promise { + if (!('Notification' in window)) { + return 'denied'; + } + try { + const permission: NotificationPermission = await Notification.requestPermission(); + return permission; + } catch (error) { + return 'denied'; + } +} + +export async function enablePushNotifications( + mx: MatrixClient, + clientConfig: ClientConfig +): Promise { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + throw new Error('Push messaging is not supported in this browser.'); + } + if (!mx || !mx.getHomeserverUrl() || !mx.getAccessToken()) { + throw new Error('Matrix client is not properly initialized or authenticated.'); + } + + if ( + !clientConfig.pushNotificationDetails?.vapidPublicKey || + !clientConfig.pushNotificationDetails?.webPushAppID || + !clientConfig.pushNotificationDetails?.pushNotifyUrl + ) { + throw new Error('One or more push configuration constants are missing.'); + } + + const registration = await navigator.serviceWorker.ready; + + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + try { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, + }); + } catch (subscribeError: any) { + if (Notification.permission === 'denied') { + throw new Error('Notification permission denied. Please enable in browser settings.'); + } + throw new Error(`Failed to subscribe: ${subscribeError.message || String(subscribeError)}`); + } + } + + const pwaAppIdForPlatform = clientConfig.pushNotificationDetails?.webPushAppID; + if (!pwaAppIdForPlatform) { + await subscription.unsubscribe(); + throw new Error('Could not determine PWA App ID for push endpoint.'); + } + + const subJson = subscription.toJSON(); + const p256dhKey = subJson.keys?.p256dh; + const authKey = subJson.keys?.auth; + + if (!p256dhKey || !authKey) { + await subscription.unsubscribe(); + throw new Error('Push subscription keys (p256dh, auth) are missing.'); + } + + const pusherData = { + kind: 'http' as const, + app_id: pwaAppIdForPlatform, + pushkey: p256dhKey, + app_display_name: 'Cinny', + device_display_name: + (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown device', + lang: navigator.language || 'en', + data: { + url: clientConfig.pushNotificationDetails?.pushNotifyUrl, + format: 'event_id_only' as const, + endpoint: subscription.endpoint, + p256dh: p256dhKey, + auth: authKey, + }, + enabled: false, + 'org.matrix.msc3881.enabled': false, + 'org.matrix.msc3881.device_id': mx.getDeviceId(), + append: false, + }; + + try { + await mx.setPusher(pusherData); + } catch (pusherError: any) { + await subscription.unsubscribe(); + throw new Error( + `Failed to set up push with Matrix server: ${pusherError.message || String(pusherError)}` + ); + } +} + +export async function disablePushNotifications( + mx: MatrixClient, + clientConfig: ClientConfig +): Promise { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + return; + } + + const pwaAppIdForPlatform = clientConfig.pushNotificationDetails?.webPushAppID; + + await subscription.unsubscribe(); + + const subJson = subscription.toJSON(); + const p256dhKey = subJson.keys?.p256dh; + const authKey = subJson.keys?.auth; + + if (mx && mx.getAccessToken() && pwaAppIdForPlatform) { + const pusherToRemove = { + kind: null, + app_id: pwaAppIdForPlatform, + pushkey: p256dhKey, + }; + await mx.setPusher(pusherToRemove as any); + } +} + +export async function deRegisterAllPushers(mx: MatrixClient): Promise { + const response = await mx.getPushers(); + const pushers = response.pushers || []; + + if (pushers.length === 0) { + return; + } + + const deletionPromises = pushers.map((pusher) => { + const pusherToDelete: Partial & { kind: null; app_id: string; pushkey: string } = { + kind: null, + app_id: pusher.app_id, + pushkey: pusher.pushkey, + ...(pusher.data && { data: pusher.data }), + ...(pusher.profile_tag && { profile_tag: pusher.profile_tag }), + }; + + return mx + .setPusher(pusherToDelete as any) + .then(() => ({ status: 'fulfilled', app_id: pusher.app_id })) + .catch((err) => ({ status: 'rejected', app_id: pusher.app_id, error: err })); + }); + + await Promise.allSettled(deletionPromises); +} + +export async function togglePusher( + mx: MatrixClient, + subscription: PushSubscription, + visible: boolean +): Promise { + const MUTE_RULE_ID = 'cc.cinny.mute_push'; + const p256dhKey = subscription?.toJSON().keys?.p256dh; + const { pushers } = await mx.getPushers(); + const existingPusher = pushers.find((p) => p.pushkey === p256dhKey); + + if (existingPusher && existingPusher.kind === 'http') { + if (visible) { + /* + Need to clean up the old push rules I made + The push rules should be removed upon roomId change + and a new one added for the NEW current room + + On visibility change push rule should be added for the given room + so in background pushrule removed and in foreground pushrule added + + In some ways it is simply easier to just de-register the push notificaitons + as this gives perfect behavior. Then on visibility change re-enable them. + We can check the stored setting for background push notifs and if it exists + enable the push notifs based on that settings value. + Can look to the SettingsNotifications for how the other settings are stored. + + I might also want to mention that the reason I list the above + is explicitly BECAUSE otherwise we use both push notifs and the normal notifs + + Tuwunnel fails to deserialize custom tweaks as a result of: + https://github.com/ruma/ruma/issues/368 <- related deserialization issue + https://github.com/serde-rs/serde/issues/1183 <- upstream for ruma for deserializing + + Instead we'll do a hackier bypass, but only Cinny will acknowledge this as the client is responsible + for handling the sounds themselves. This is more or less a custom tweak still. + */ + mx.addPushRule('global', PushRuleKind.Override, `${MUTE_RULE_ID}`, { + conditions: [], + actions: [ + PushRuleActionName.DontNotify, + { + set_tweak: TweakName.Sound, + value: 'cinny_show_banner', + }, + { + set_tweak: TweakName.Highlight, + value: true, + }, + ], + }); + } else { + await mx.deletePushRule('global', PushRuleKind.Override, `${MUTE_RULE_ID}`); + } + } +} From af705f6785394af81314bda8d55d9f1064f63847 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:46:31 -0500 Subject: [PATCH 22/64] add push notif details to client config --- src/app/hooks/useClientConfig.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..4e9fb749 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -10,6 +10,12 @@ export type ClientConfig = { homeserverList?: string[]; allowCustomHomeservers?: boolean; + pushNotificationDetails?: { + pushNotifyUrl?: string; + vapidPublicKey?: string; + webPushAppID?: string; + }; + featuredCommunities?: { openAsDefault?: boolean; spaces?: string[]; From 758740302370d9b7af4cb5fb64fac50fd6c388e8 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:48:43 -0500 Subject: [PATCH 23/64] Update gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1af58a97..d0883737 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea + +.env \ No newline at end of file From 99891765b7ad2f79f483566a9b197ffb9786cd9f Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 8 Jun 2025 20:49:15 -0500 Subject: [PATCH 24/64] update package-lock --- package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 6d82c219..9b5601c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,8 @@ "vite": "5.4.15", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "workbox-precaching": "7.3.0" }, "engines": { "node": ">=16.0.0" @@ -12212,6 +12213,7 @@ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz", "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==", "dev": true, + "license": "MIT", "dependencies": { "workbox-core": "7.3.0", "workbox-routing": "7.3.0", From 39fa3c49b1f9415e5ef42b9fc2dcbee68f729c9b Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:32:30 -0500 Subject: [PATCH 25/64] Undo these --- src/app/pages/client/ClientNonUIFeatures.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0e2d5d67..170bc03b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -187,15 +187,13 @@ function MessageNotifications() { ) => { if (mx.getSyncState() !== 'SYNCING') return; if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; - let pushActions = mx.getPushActionsForEvent(mEvent); - const hasInAppTweak = pushActions?.tweaks?.sound === 'cinny_show_banner'; if ( !room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent) || - (getNotificationType(mx, room.roomId) === NotificationType.Mute && !hasInAppTweak) + getNotificationType(mx, room.roomId) === NotificationType.Mute ) { return; } From 91d6a5d2e91cb2fa8dceabdc8486702693cc9bd2 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:35:58 -0500 Subject: [PATCH 26/64] Pass client config for togglePusher --- src/app/pages/client/ClientRoot.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 8eac63bd..9f9341a4 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -38,6 +38,7 @@ import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { togglePusher } from '../../features/settings/notifications/PushNotifications'; +import { ClientConfig, useClientConfig } from '../../hooks/useClientConfig'; function ClientRootLoading() { return ( @@ -125,12 +126,11 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { ); } -const pushNotificationListener = (mx: MatrixClient) => { +const pushNotificationListener = (mx: MatrixClient, clientConfig: ClientConfig) => { navigator.serviceWorker.ready.then((registration) => { registration.pushManager.getSubscription().then((subscription) => { document.addEventListener('visibilitychange', () => { - console.log('Check check baby'); - togglePusher(mx, subscription, document.visibilityState === 'visible'); + togglePusher(mx, subscription, clientConfig, document.visibilityState === 'visible'); }); togglePusher(mx, subscription, true); }); @@ -159,6 +159,7 @@ type ClientRootProps = { export function ClientRoot({ children }: ClientRootProps) { const [loading, setLoading] = useState(true); const { baseUrl } = getSecret(); + const clientConfig = useClientConfig(); const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => initClient(getSecret() as any), []) @@ -188,10 +189,10 @@ export function ClientRoot({ children }: ClientRootProps) { (state) => { if (state === 'PREPARED') { setLoading(false); - pushNotificationListener(mx); + pushNotificationListener(mx, clientConfig); } }, - [mx] + [clientConfig, mx] ) ); return ( From f100279e650a4e504701575d083294a44ce12613 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:37:54 -0500 Subject: [PATCH 27/64] Move message listener and add a togglePush listener (should ensure registers/deregisters always go off this way) --- src/sw.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index ac86f622..c62d560a 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -19,18 +19,6 @@ function sendAndWaitForReply(client: WindowClient, type: string, payload: object return promise; } -self.addEventListener('message', (event: ExtendableMessageEvent) => { - const { replyTo } = event.data; - if (replyTo) { - const resolve = pendingReplies.get(replyTo); - if (resolve) { - pendingReplies.delete(replyTo); - resolve(event.data.payload); - } - } -}); - - function fetchConfig(token?: string): RequestInit | undefined { if (!token) return undefined; @@ -42,6 +30,29 @@ function fetchConfig(token?: string): RequestInit | undefined { }; } +self.addEventListener('message', (event: ExtendableMessageEvent) => { + if (event.data.type === 'togglePush') { + const token = event.data?.token; + const fetchOptions = fetchConfig(token); + event.waitUntil( + fetch(`${event.data.url}/_matrix/client/v3/pushers/set`, { + method: 'POST', + ...fetchOptions, + body: JSON.stringify(event.data.pusherData), + }) + ); + return; + } + const { replyTo } = event.data; + if (replyTo) { + const resolve = pendingReplies.get(replyTo); + if (resolve) { + pendingReplies.delete(replyTo); + resolve(event.data.payload); + } + } +}); + self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil( (async () => { From 07e15e2421fd076dddf08a7266763a5f88755aae Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:39:45 -0500 Subject: [PATCH 28/64] Revise our fetch to be more in line with W3C spec --- src/sw.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index c62d560a..3fd25134 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -76,13 +76,21 @@ self.addEventListener('fetch', (event: FetchEvent) => { } event.respondWith( (async (): Promise => { - console.log('Unironic race condition mitigation it seems.'); + if (!event.clientId) throw new Error('Missing clientId'); const client = await self.clients.get(event.clientId); - const token: string = await sendAndWaitForReply(client, 'token', {}); + if (!client) throw new Error('Client not found'); + const token = await sendAndWaitForReply(client, 'token', {}); + if (!token) throw new Error('Failed to retrieve token'); const response = await fetch(url, fetchConfig(token)); + if (!response.ok) throw new Error(`Fetch failed: ${response.statusText}`); return response; })() ); + event.waitUntil( + (async function () { + console.log('Ensuring fetch processing completes before worker termination.'); + })() + ); }); const onPushNotification = async (event: PushEvent) => { From 0c67cb40d728a6496f36346081275aedd9ffdcec Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:40:56 -0500 Subject: [PATCH 29/64] Swap to sending to service worker for completion --- .../notifications/PushNotifications.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 75b830ef..adcb39eb 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -86,7 +86,13 @@ export async function enablePushNotifications( }; try { - await mx.setPusher(pusherData); + + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } catch (pusherError: any) { await subscription.unsubscribe(); throw new Error( @@ -119,12 +125,18 @@ export async function disablePushNotifications( const authKey = subJson.keys?.auth; if (mx && mx.getAccessToken() && pwaAppIdForPlatform) { - const pusherToRemove = { + const pusherData = { kind: null, app_id: pwaAppIdForPlatform, pushkey: p256dhKey, }; - await mx.setPusher(pusherToRemove as any); + + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } } From 129599572de43c1dc2c79b4bfb6a13523d74df37 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:42:11 -0500 Subject: [PATCH 30/64] add client config to parameters --- src/app/features/settings/notifications/PushNotifications.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index adcb39eb..f3f6205c 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -169,6 +169,7 @@ export async function deRegisterAllPushers(mx: MatrixClient): Promise { export async function togglePusher( mx: MatrixClient, subscription: PushSubscription, + clientConfig: ClientConfig, visible: boolean ): Promise { const MUTE_RULE_ID = 'cc.cinny.mute_push'; From a02a10eda117b9a4d4353cf70dba221b1c591107 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:43:18 -0500 Subject: [PATCH 31/64] adjust to reflect using the service worker to actually toggle --- .../notifications/PushNotifications.tsx | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index f3f6205c..6b02e7c0 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -177,9 +177,8 @@ export async function togglePusher( const { pushers } = await mx.getPushers(); const existingPusher = pushers.find((p) => p.pushkey === p256dhKey); - if (existingPusher && existingPusher.kind === 'http') { - if (visible) { - /* + if (visible) { + /* Need to clean up the old push rules I made The push rules should be removed upon roomId change and a new one added for the NEW current room @@ -203,22 +202,8 @@ export async function togglePusher( Instead we'll do a hackier bypass, but only Cinny will acknowledge this as the client is responsible for handling the sounds themselves. This is more or less a custom tweak still. */ - mx.addPushRule('global', PushRuleKind.Override, `${MUTE_RULE_ID}`, { - conditions: [], - actions: [ - PushRuleActionName.DontNotify, - { - set_tweak: TweakName.Sound, - value: 'cinny_show_banner', - }, - { - set_tweak: TweakName.Highlight, - value: true, - }, - ], - }); - } else { - await mx.deletePushRule('global', PushRuleKind.Override, `${MUTE_RULE_ID}`); - } + disablePushNotifications(mx, clientConfig); + } else { + enablePushNotifications(mx, clientConfig); } } From 13d38e852551cd7b320cac84a04e2f1902ed4522 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:43:27 -0500 Subject: [PATCH 32/64] Fix imports --- src/app/features/settings/notifications/PushNotifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 6b02e7c0..aeb9e809 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -1,4 +1,4 @@ -import { MatrixClient, PushRuleActionName, PushRuleKind, TweakName } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import { ClientConfig } from '../../../hooks/useClientConfig'; export async function requestBrowserNotificationPermission(): Promise { From c57509ac7b4ce99d34f758f21980d1e8cbaa433c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:45:23 -0500 Subject: [PATCH 33/64] Remove large comment block, no longer needed --- .../notifications/PushNotifications.tsx | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index aeb9e809..1aa8d2e2 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -86,7 +86,6 @@ export async function enablePushNotifications( }; try { - navigator.serviceWorker.controller?.postMessage({ url: mx.baseUrl, type: 'togglePush', @@ -168,40 +167,10 @@ export async function deRegisterAllPushers(mx: MatrixClient): Promise { export async function togglePusher( mx: MatrixClient, - subscription: PushSubscription, clientConfig: ClientConfig, visible: boolean ): Promise { - const MUTE_RULE_ID = 'cc.cinny.mute_push'; - const p256dhKey = subscription?.toJSON().keys?.p256dh; - const { pushers } = await mx.getPushers(); - const existingPusher = pushers.find((p) => p.pushkey === p256dhKey); - if (visible) { - /* - Need to clean up the old push rules I made - The push rules should be removed upon roomId change - and a new one added for the NEW current room - - On visibility change push rule should be added for the given room - so in background pushrule removed and in foreground pushrule added - - In some ways it is simply easier to just de-register the push notificaitons - as this gives perfect behavior. Then on visibility change re-enable them. - We can check the stored setting for background push notifs and if it exists - enable the push notifs based on that settings value. - Can look to the SettingsNotifications for how the other settings are stored. - - I might also want to mention that the reason I list the above - is explicitly BECAUSE otherwise we use both push notifs and the normal notifs - - Tuwunnel fails to deserialize custom tweaks as a result of: - https://github.com/ruma/ruma/issues/368 <- related deserialization issue - https://github.com/serde-rs/serde/issues/1183 <- upstream for ruma for deserializing - - Instead we'll do a hackier bypass, but only Cinny will acknowledge this as the client is responsible - for handling the sounds themselves. This is more or less a custom tweak still. - */ disablePushNotifications(mx, clientConfig); } else { enablePushNotifications(mx, clientConfig); From b148cc3d1b8007206f8ffc8d3fd10b9efbff87ef Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 17:46:27 -0500 Subject: [PATCH 34/64] Remove subscription / service worker check requirement --- src/app/pages/client/ClientRoot.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9f9341a4..3d9c2b50 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -127,13 +127,8 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { } const pushNotificationListener = (mx: MatrixClient, clientConfig: ClientConfig) => { - navigator.serviceWorker.ready.then((registration) => { - registration.pushManager.getSubscription().then((subscription) => { - document.addEventListener('visibilitychange', () => { - togglePusher(mx, subscription, clientConfig, document.visibilityState === 'visible'); - }); - togglePusher(mx, subscription, true); - }); + document.addEventListener('visibilitychange', () => { + togglePusher(mx, clientConfig, document.visibilityState === 'visible'); }); }; From 654f32bd31f6e578ea120620307a9308d2934786 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 19:23:51 -0500 Subject: [PATCH 35/64] Add store to push notif setting to local storage (so we can manage the pusher state cleanly) --- .../notifications/SystemNotification.tsx | 66 +++++-------------- 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 6e292dd8..e0da07c0 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; +import { IPusherRequest } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -92,59 +93,30 @@ function EmailNotification() { function WebPushNotificationSetting() { const mx = useMatrixClient(); const clientConfig = useClientConfig(); - const [userWantsWebPush, setUserWantsWebPush] = useSetting(settingsAtom, 'enableWebPush'); - + const [userPushPreference, setUserPushPreference] = useState(false); + const [isLoading, setIsLoading] = useState(true); const browserPermission = usePermissionState('notifications', getNotificationState()); - const [isPushSubscribed, setIsPushSubscribed] = useState(false); - const [isLoading, setIsLoading] = useState(true); // Start loading to check status - - const checkSubscriptionStatus = useCallback(async () => { - if ( - browserPermission === 'granted' && - 'serviceWorker' in navigator && - 'PushManager' in window - ) { - setIsLoading(true); - try { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - setIsPushSubscribed(!!subscription); - } catch (err) { - setIsPushSubscribed(false); - } finally { - setIsLoading(false); - } - } else { - setIsPushSubscribed(false); - setIsLoading(false); - } - }, [browserPermission]); - useEffect(() => { - checkSubscriptionStatus(); - }, [checkSubscriptionStatus]); - + const storedPreference = localStorage.getItem(PUSH_PREFERENCE_KEY); + setUserPushPreference(storedPreference === 'true'); + setIsLoading(false); + }, []); const handleRequestPermissionAndEnable = async () => { setIsLoading(true); try { const permissionResult = await requestBrowserNotificationPermission(); if (permissionResult === 'granted') { - setUserWantsWebPush(true); await enablePushNotifications(mx, clientConfig); - } else { - setUserWantsWebPush(false); + localStorage.setItem('cinny_web_push_enabled', 'true'); + setUserPushPreference(true); } - } catch (error: any) { - setUserWantsWebPush(false); } finally { - await checkSubscriptionStatus(); setIsLoading(false); } }; const handlePushSwitchChange = async (wantsPush: boolean) => { setIsLoading(true); - setUserWantsWebPush(wantsPush); try { if (wantsPush) { @@ -152,30 +124,23 @@ function WebPushNotificationSetting() { } else { await disablePushNotifications(mx, clientConfig); } - } catch (error: any) { - setUserWantsWebPush(!wantsPush); + localStorage.setItem('cinny_web_push_enabled', String(wantsPush)); + setUserPushPreference(wantsPush); } finally { - await checkSubscriptionStatus(); setIsLoading(false); } }; - let descriptionText = 'Receive notifications when the app is closed or in the background.'; - if (browserPermission === 'granted' && isPushSubscribed) { - descriptionText = - 'You are subscribed to receive notifications when the app is in the background.'; - } - return ( - Notification permission is blocked by your browser. Please allow it from site settings. + Permission blocked. Please allow notifications in your browser settings. ) : ( - {descriptionText} + 'Receive notifications when the app is closed or in the background.' ) } after={ @@ -186,7 +151,10 @@ function WebPushNotificationSetting() { Enable ) : browserPermission === 'granted' ? ( - + ) : null } /> From b8eb1b999e00c510ed8b0e02cd21447b2e2bcfc0 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:14:17 +0530 Subject: [PATCH 36/64] Fix space navigation & view space timeline dev-option (#2358) * fix inaccessible space on alias change * fix new room in space open in home * allow opening space timeline * hide event timeline feature behind dev tool * add navToActivePath to clear cache function --- src/app/hooks/useRoomNavigate.ts | 16 ++++++-- src/app/pages/client/sidebar/SpaceTabs.tsx | 5 ++- src/app/pages/client/space/RoomProvider.tsx | 45 ++++++++++++++++----- src/app/pages/client/space/Space.tsx | 20 +++++++++ src/app/state/navToActivePath.ts | 8 +++- src/client/initMatrix.ts | 2 + 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 0f9f365c..e626c06b 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -13,6 +13,8 @@ import { getOrphanParents } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; import { useSelectedSpace } from './router/useSelectedSpace'; +import { settingsAtom } from '../state/settings'; +import { useSetting } from '../state/hooks/settings'; export const useRoomNavigate = () => { const navigate = useNavigate(); @@ -20,6 +22,7 @@ export const useRoomNavigate = () => { const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const spaceSelectedId = useSelectedSpace(); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const navigateSpace = useCallback( (roomId: string) => { @@ -32,15 +35,22 @@ export const useRoomNavigate = () => { const navigateRoom = useCallback( (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = getOrphanParents(roomToParents, roomId); + const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId( mx, spaceSelectedId && orphanParents.includes(spaceSelectedId) ? spaceSelectedId - : orphanParents[0] + : orphanParents[0] // TODO: better orphan parent selection. ); + + if (openSpaceTimeline) { + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomId, eventId), opts); + return; + } + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } @@ -52,7 +62,7 @@ export const useRoomNavigate = () => { navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); }, - [mx, navigate, spaceSelectedId, roomToParents, mDirects] + [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); return { diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 5b47cb52..011741ee 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -744,13 +744,14 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { const targetSpaceId = target.getAttribute('data-id'); if (!targetSpaceId) return; + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); if (screenSize === ScreenSize.Mobile) { - navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + navigate(spacePath); return; } const activePath = navToActivePath.get(targetSpaceId); - if (activePath) { + if (activePath && activePath.pathname.startsWith(spacePath)) { navigate(joinPathComponent(activePath)); return; } diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index a9632137..0fd52ab6 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -1,21 +1,24 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useSpace } from '../../../hooks/useSpace'; -import { getAllParents } from '../../../utils/room'; +import { getAllParents, getSpaceChildren } from '../../../utils/room'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { mDirectAtom } from '../../../state/mDirectList'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); const space = useSpace(); - const roomToParents = useAtomValue(roomToParentsAtom); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); const allRooms = useAtomValue(allRoomsAtom); @@ -24,12 +27,36 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); - if ( - !room || - room.isSpaceRoom() || - !allRooms.includes(room.roomId) || - !getAllParents(roomToParents, room.roomId).has(space.roomId) - ) { + if (!room || !allRooms.includes(room.roomId)) { + // room is not joined + return ( + + ); + } + + if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) { + // allow to view space timeline + return ( + + {children} + + ); + } + + if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { + if (getSpaceChildren(space).includes(room.roomId)) { + // fill missing roomToParent mapping + setRoomToParents({ + type: 'PUT', + parent: space.roomId, + children: [room.roomId], + }); + } + return ( (({ room, requestClose }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const openSpaceSettings = useOpenSpaceSettings(); + const { navigateRoom } = useRoomNavigate(); const allChild = useSpaceChildren( allRoomsAtom, @@ -118,6 +121,11 @@ const SpaceMenu = forwardRef(({ room, requestClo requestClose(); }; + const handleOpenTimeline = () => { + navigateRoom(room.roomId); + requestClose(); + }; + return ( @@ -168,6 +176,18 @@ const SpaceMenu = forwardRef(({ room, requestClo Space Settings + {developerTools && ( + } + radii="300" + > + + Event Timeline + + + )} diff --git a/src/app/state/navToActivePath.ts b/src/app/state/navToActivePath.ts index 80869146..af90c914 100644 --- a/src/app/state/navToActivePath.ts +++ b/src/app/state/navToActivePath.ts @@ -9,6 +9,8 @@ import { const NAV_TO_ACTIVE_PATH = 'navToActivePath'; +const getStoreKey = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`; + type NavToActivePath = Map; type NavToActivePathAction = @@ -25,7 +27,7 @@ type NavToActivePathAction = export type NavToActivePathAtom = WritableAtom; export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => { - const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`; + const storeKey = getStoreKey(userId); const baseNavToActivePathAtom = atomWithLocalStorage( storeKey, @@ -64,3 +66,7 @@ export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => return navToActivePathAtom; }; + +export const clearNavToActivePathStore = (userId: string) => { + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b513e27c..b80a080f 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,6 +1,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { cryptoCallbacks } from './state/secretStorageKeys'; +import { clearNavToActivePathStore } from '../app/state/navToActivePath'; type Session = { baseUrl: string; @@ -46,6 +47,7 @@ export const startClient = async (mx: MatrixClient) => { export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); + clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); window.location.reload(); }; From fb0a0f83aa83c36c227559b8c17ca9bafc299e6f Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:17:46 +0530 Subject: [PATCH 37/64] Add allow from currently selected space if no m.space.parent found (#2359) --- .../common-settings/general/RoomJoinRules.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index ebd4cad5..c0d62a6a 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { color, Text } from 'folds'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; +import { useAtomValue } from 'jotai'; import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { ExtendedJoinRules, @@ -20,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { getStateEvents } from '../../../utils/room'; +import { + useRecursiveChildSpaceScopeFactory, + useSpaceChildren, +} from '../../../state/hooks/roomList'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; type RestrictedRoomAllowContent = { room_id: string; @@ -36,7 +43,11 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const allowKnockRestricted = roomVersion >= 10; const allowRestricted = roomVersion >= 8; const allowKnock = roomVersion >= 7; + + const roomIdToParents = useAtomValue(roomToParentsAtom); const space = useSpaceOptionally(); + const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents); + const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope); const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); const canEdit = powerLevelAPI.canSendStateEvent( @@ -74,9 +85,22 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { async (joinRule: ExtendedJoinRules) => { const allow: RestrictedRoomAllowContent[] = []; if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') { - const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) => - event.getStateKey() - ); + const roomParents = roomIdToParents.get(room.roomId); + + const parents = getStateEvents(room, StateEvent.SpaceParent) + .map((event) => event.getStateKey()) + .filter((parentId) => typeof parentId === 'string') + .filter((parentId) => roomParents?.has(parentId)); + + if (parents.length === 0 && space && roomParents) { + // if no m.space.parent found + // find parent in current space + const selectedParents = subspaces.filter((rId) => roomParents.has(rId)); + if (roomParents.has(space.roomId)) { + selectedParents.push(space.roomId); + } + selectedParents.forEach((pId) => parents.push(pId)); + } parents.forEach((parentRoomId) => { if (!parentRoomId) return; allow.push({ @@ -92,7 +116,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { if (allow.length > 0) c.allow = allow; await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); }, - [mx, room] + [mx, room, space, subspaces, roomIdToParents] ) ); From c416785f76ece27ab5a664fd2a70afa541213cac Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:18:55 +0530 Subject: [PATCH 38/64] Release v4.8.1 (#2360) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- src/client/state/cons.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b5601c5..4083fd1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index f278255a..4e9782d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.8.0", + "version": "4.8.1", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 30f4b3ca..ff2fdb9b 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.8.0 + v4.8.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 88d38981..645753ff 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.8.0 + v4.8.1 } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 3e9306b2..1cb8b102 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.8.0', + version: '4.8.1', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', From 350f3ac2f789c19a78f753b8afb3e45dad2eff27 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Tue, 10 Jun 2025 20:24:43 -0500 Subject: [PATCH 39/64] forgot to remove removed const ref --- src/app/features/settings/notifications/SystemNotification.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0da07c0..791caa2e 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -97,7 +97,7 @@ function WebPushNotificationSetting() { const [isLoading, setIsLoading] = useState(true); const browserPermission = usePermissionState('notifications', getNotificationState()); useEffect(() => { - const storedPreference = localStorage.getItem(PUSH_PREFERENCE_KEY); + const storedPreference = localStorage.getItem('cinny_web_push_enabled'); setUserPushPreference(storedPreference === 'true'); setIsLoading(false); }, []); From 815a0ac7f49571f5b100ad859ff325a6498d73c7 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Wed, 11 Jun 2025 01:41:49 -0500 Subject: [PATCH 40/64] set app badge --- src/app/hooks/useRoomEventReaders.ts | 7 +++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useRoomEventReaders.ts b/src/app/hooks/useRoomEventReaders.ts index 6222bf92..6c315f50 100644 --- a/src/app/hooks/useRoomEventReaders.ts +++ b/src/app/hooks/useRoomEventReaders.ts @@ -1,5 +1,6 @@ import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { useEffect, useState } from 'react'; +import { getUnreadInfo } from '../utils/room'; const getEventReaders = (room: Room, evtId?: string) => { if (!evtId) return []; @@ -21,7 +22,7 @@ const getEventReaders = (room: Room, evtId?: string) => { export const useRoomEventReaders = (room: Room, eventId?: string): string[] => { const [readers, setReaders] = useState(() => getEventReaders(room, eventId)); - + const unreadInfo = getUnreadInfo(room); useEffect(() => { setReaders(getEventReaders(room, eventId)); @@ -46,11 +47,13 @@ export const useRoomEventReaders = (room: Room, eventId?: string): string[] => { room.on(RoomEvent.Receipt, handleReceipt); room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); + + navigator.setAppBadge(unreadInfo.total); return () => { room.removeListener(RoomEvent.Receipt, handleReceipt); room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); }; - }, [room, eventId]); + }, [room, eventId, unreadInfo.total]); return readers; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 170bc03b..09dda9dd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -212,7 +212,7 @@ function MessageNotifications() { ) { return; } - + navigator.setAppBadge(unreadInfo.total); if (showNotifications && notificationPermission('granted')) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); From f24c6cce76832ad9eb23765c406c8e15c16d8393 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 12 Jun 2025 17:34:29 -0500 Subject: [PATCH 41/64] Prevent firefox from crashing because of no badging API --- src/app/hooks/useRoomEventReaders.ts | 6 +++++- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++++- src/sw.ts | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useRoomEventReaders.ts b/src/app/hooks/useRoomEventReaders.ts index 6c315f50..ffbb5918 100644 --- a/src/app/hooks/useRoomEventReaders.ts +++ b/src/app/hooks/useRoomEventReaders.ts @@ -48,7 +48,11 @@ export const useRoomEventReaders = (room: Room, eventId?: string): string[] => { room.on(RoomEvent.Receipt, handleReceipt); room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); - navigator.setAppBadge(unreadInfo.total); + try { + navigator.setAppBadge(unreadInfo.total); + } catch (e) { + // Likely Firefox/Gecko-based and doesn't support badging API + } return () => { room.removeListener(RoomEvent.Receipt, handleReceipt); room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 09dda9dd..6b35961a 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -212,7 +212,12 @@ function MessageNotifications() { ) { return; } - navigator.setAppBadge(unreadInfo.total); + try { + navigator.setAppBadge(unreadInfo.total); + } catch (e) { + // Likely Firefox/Gecko-based and doesn't support badging API + } + if (showNotifications && notificationPermission('granted')) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); diff --git a/src/sw.ts b/src/sw.ts index 3fd25134..96e26c11 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -127,7 +127,11 @@ const onPushNotification = async (event: PushEvent) => { options.data = { ...options.data, ...pushData.data }; } if (typeof pushData.unread === 'number') { - await self.navigator.setAppBadge(pushData.unread); + try { + self.navigator.setAppBadge(pushData.unread); + } catch (e) { + // Likely Firefox/Gecko-based and doesn't support badging API + } } else { await navigator.clearAppBadge(); } From 488bb724c592d90ec637e8e6787c16b1ae1988c4 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 19 Jun 2025 00:25:56 -0500 Subject: [PATCH 42/64] add docs for Sygnal set-up --- docs/Caddyfile | 10 ++ docs/Dockerfile | 9 ++ docs/sample.env | 1 + docs/sygnal-setup.md | 308 +++++++++++++++++++++++++++++++++++++++++++ docs/sygnal.yaml | 9 ++ 5 files changed, 337 insertions(+) create mode 100644 docs/Caddyfile create mode 100644 docs/Dockerfile create mode 100644 docs/sample.env create mode 100644 docs/sygnal-setup.md create mode 100644 docs/sygnal.yaml diff --git a/docs/Caddyfile b/docs/Caddyfile new file mode 100644 index 00000000..3d03c2f4 --- /dev/null +++ b/docs/Caddyfile @@ -0,0 +1,10 @@ +(tls_cloudflare) { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } +} + + { + import tls_cloudflare + reverse_proxy sygnal:5000 +} diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 00000000..6900bc67 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,9 @@ +FROM caddy:builder AS builder + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare +FROM caddy:latest + +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY .env /etc/caddy/.env \ No newline at end of file diff --git a/docs/sample.env b/docs/sample.env new file mode 100644 index 00000000..dc3b650c --- /dev/null +++ b/docs/sample.env @@ -0,0 +1 @@ +CLOUDFLARE_API_TOKEN= \ No newline at end of file diff --git a/docs/sygnal-setup.md b/docs/sygnal-setup.md new file mode 100644 index 00000000..0a13fa43 --- /dev/null +++ b/docs/sygnal-setup.md @@ -0,0 +1,308 @@ +## Sygnal with Caddy & Cloudflare on Vultr + +This document walks you through setting up a [Sygnal](https://github.com/matrix-org/sygnal) push gateway for Matrix, running in a Docker container. We will use [Caddy](https://caddyserver.com/) as a reverse proxy, also in Docker, to handle HTTPS automatically using DNS challenges with [Cloudflare](https://www.cloudflare.com/). + +Now Cloudflare and Vultr have a deal in place where traffic from Cloudflare to Vultr and vice versa does not incur bandwidth usage. So you can pass endless amounts through without any extra billing. This is why the docs utilize Vultr, but you're free to use whatever cloud provider you want and not use Cloudflare if you so choose. + +### Prerequisites + +1. **Vultr Server**: A running server instance. This guide assumes a fresh server running a common Linux distribution like Debian, Ubuntu, or Alpine. +2. **Domain Name**: A domain name managed through Cloudflare. +3. **Cloudflare Account**: Your domain must be using Cloudflare's DNS. +4. **Docker & Docker Compose**: Docker and `docker-compose` must be installed on your Vultr server. +5. **A Matrix Client**: A client like [Cinny](https://github.com/cinnyapp/cinny) that you want to point to your new push gateway. + +--- + +### Step 1: Cloudflare Configuration + +Before touching the server, we need to configure Cloudflare. + +#### 1.1. DNS Record + +In your Cloudflare dashboard, create an **A** (for IPv4) or **AAAA** (for IPv6) record for the subdomain you'll use for Sygnal. Point it to your Vultr server's IP address. + +- **Type**: `A` or `AAAA` +- **Name**: `sygnal.your-domain.com` (or your chosen subdomain) +- **Content**: Your Vultr server's IP address +- **Proxy status**: **Proxied** (Orange Cloud). This is important for Caddy's setup. + +#### 1.2. API Token + +Caddy needs an API token to prove to Cloudflare that you own the domain so it can create the necessary DNS records for issuing an SSL certificate. + +1. Go to **My Profile** \> **API Tokens** in Cloudflare. +2. Click **Create Token**. +3. Use the **Edit zone DNS** template. +4. Under **Permissions**, ensure `Zone:DNS:Edit` is selected. +5. Under **Zone Resources**, select the specific zone for `your-domain.com`. +6. Continue to summary and create the token. +7. **Copy the generated token immediately.** You will not be able to see it again. We will use this as your `CLOUDFLARE_API_TOKEN`. + +--- + +### Step 2: Server Preparation + +#### 2.1. Connect to your Server (SSH) + +If your Vultr instance uses an IPv6 address, connecting via SSH can sometimes be tricky. You can create an alias in your local `~/.ssh/config` file to make it easier. + +Open or create `~/.ssh/config` on your local machine and add: + +``` +Host vultr-sygnal + # Replace with your server's IPv6 or IPv4 address + Hostname 2001:19f0:5400:1532:5400:05ff:fe78:fb25 + User root + # For IPv6, uncomment the line below + # AddressFamily inet6 +``` + +Now you can connect simply by typing `ssh vultr-sygnal`. + +#### 2.2. Install Docker and Docker Compose + +Follow the official Docker documentation to install the Docker Engine and Docker Compose for your server's operating system. + +#### 2.3. Configure Firewall + +We need to allow HTTP and HTTPS traffic so Caddy can obtain certificates and serve requests. If you are using `ufw`: + +```sh +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +sudo ufw status +``` + +--- + +### Step 3: Project Structure and Configuration + +On your Vultr server, let's create a directory to hold all our configuration files. + +```sh +mkdir -p /opt/matrix-sygnal +cd /opt/matrix-sygnal +``` + +We will create all subsequent files inside this `/opt/matrix-sygnal` directory. + +#### 3.1. Sygnal VAPID Keys + +WebPush requires a VAPID key pair. The private key stays on your server, and the public key is given to clients. + +1. **Generate the Private Key**: + Use `openssl` to generate an EC private key. + + ```sh + # This command needs to be run in the /opt/matrix-sygnal directory + openssl ecparam -name prime256v1 -genkey -noout -out sygnal_private_key.pem + ``` + +2. **Extract the Public Key**: + Extract the corresponding public key from the private key. You will need this for your client configuration later. + + ```sh + # This command extracts the public key in the correct format + openssl ec -in sygnal_private_key.pem -pubout -outform DER | tail -c 65 | base64 | tr '/+' '_-' | tr -d '=' + ``` + + **Save the output of this command.** This is your `vapidPublicKey`. It should look similar to the one from the `cinny.cc` example. + +#### 3.2. Sygnal Configuration (`sygnal.yaml`) + +Create a file named `sygnal.yaml`. This file tells Sygnal how to run. + +```yaml +# /opt/matrix-sygnal/sygnal.yaml +http: + bind_addresses: ['0.0.0.0'] + port: 5000 + +# This is where we configure our push gateway app +apps: + # This app_id must match the one used in your client's configuration + cc.cinny.web: + type: webpush + # This path is *inside the container*. We will map our generated key to it. + vapid_private_key: /data/private_key.pem + # An email for VAPID contact details + vapid_contact_email: mailto:your-email@your-domain.com +``` + +#### 3.3. Caddy Configuration (`Caddyfile`) + +Create a file named `Caddyfile`. This tells Caddy how to proxy requests. + +**Replace `sygnal.your-domain.com`** with the domain you configured in Step 1. + +```caddyfile +# /opt/matrix-sygnal/Caddyfile + +# Reusable snippet for Cloudflare TLS +(tls_cloudflare) { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } +} + +# Your public-facing URL +sygnal.your-domain.com { + # Get an SSL certificate from Let's Encrypt using the Cloudflare DNS challenge + import tls_cloudflare + + # Log requests to standard output + log + + # Reverse proxy requests to the sygnal container on port 5000 + # 'sygnal' is the service name we will define in docker-compose.yml + reverse_proxy sygnal:5000 +} +``` + +#### 3.4. Caddy Dockerfile + +While you can use the standard `caddy:latest` image, you need one with the Cloudflare DNS provider plugin. Create a file named `Dockerfile` for Caddy. + +```dockerfile +# /opt/matrix-sygnal/Dockerfile +FROM caddy:builder AS builder + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare + +FROM caddy:latest + +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +``` + +#### 3.5. Environment File (`.env`) + +Create a file named `.env` to securely store your Cloudflare API Token. + +```.env +# /opt/matrix-sygnal/.env +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-from-step-1 +``` + +--- + +### Step 4: Docker Compose + +Using `docker-compose` simplifies managing our multi-container application. Create a `docker-compose.yml` file. + +```yaml +# /opt/matrix-sygnal/docker-compose.yml +version: '3.7' + +services: + caddy: + # Build the Caddy image from our Dockerfile in the current directory + build: . + container_name: caddy + hostname: caddy + restart: unless-stopped + networks: + - matrix + ports: + # Expose standard web ports to the host + - '80:80' + - '443:443' + volumes: + # Mount the Caddyfile into the container + - ./Caddyfile:/etc/caddy/Caddyfile + # Create a volume for Caddy's data (certs, etc.) + - caddy_data:/data + # Load the Cloudflare token from the .env file + env_file: + - ./.env + + sygnal: + # Use the official Sygnal image + image: matrixdotorg/sygnal:latest + container_name: sygnal + hostname: sygnal + restart: unless-stopped + networks: + - matrix + volumes: + # Mount the Sygnal config file + - ./sygnal.yaml:/sygnal.yaml + # Mount the generated private key to the path specified in sygnal.yaml + - ./sygnal_private_key.pem:/data/private_key.pem + # Create a volume for any other data Sygnal might store + - sygnal_data:/data + command: ['--config-path=/sygnal.yaml'] + +volumes: + caddy_data: + sygnal_data: + +networks: + matrix: + driver: bridge +``` + +--- + +### Step 5: Launch the Services + +Your directory `/opt/matrix-sygnal` should now look like this: + +``` +/opt/matrix-sygnal/ +├── Caddyfile +├── docker-compose.yml +├── Dockerfile +├── .env +├── sygnal.yaml +└── sygnal_private_key.pem +``` + +Now, you can build and run everything with a single command: + +```sh +cd /opt/matrix-sygnal +sudo docker-compose up --build -d +``` + +- `--build` tells Docker Compose to build the Caddy image from your `Dockerfile`. +- `-d` runs the containers in detached mode (in the background). + +To check the status and logs: + +```sh +# See if containers are running +sudo docker-compose ps + +# View the live logs for both services +sudo docker-compose logs -f + +# View logs for a specific service (e.g., caddy) +sudo docker-compose logs -f caddy +``` + +Caddy will automatically start, obtain an SSL certificate for `sygnal.your-domain.com`, and begin proxying requests to the Sygnal container. + +--- + +### Step 6: Client Configuration + +The final step is to configure your Matrix client to use your new push gateway. In Cinny, for example, you would modify its `config.json` or use a homeserver that advertises these settings. + +Update the `pushNotificationDetails` section with the information from your server: + +```json +"pushNotificationDetails": { + "pushNotifyUrl": "https://sygnal.your-domain.com/_matrix/push/v1/notify", + "vapidPublicKey": "YOUR_VAPID_PUBLIC_KEY_FROM_STEP_3.1", + "webPushAppID": "cc.cinny.web" +} +``` + +- **`pushNotifyUrl`**: The public URL of your new Sygnal instance. +- **`vapidPublicKey`**: The public key you generated in step 3.1. +- **`webPushAppID`**: The application ID you defined in your `sygnal.yaml`. This must match exactly. + +After configuring your client, it will register for push notifications with your Sygnal instance, which will then handle delivering them. diff --git a/docs/sygnal.yaml b/docs/sygnal.yaml new file mode 100644 index 00000000..3f5613fc --- /dev/null +++ b/docs/sygnal.yaml @@ -0,0 +1,9 @@ +http: + bind_addresses: ['0.0.0.0'] + port: 5000 + +apps: + cc.cinny.web: + type: webpush + vapid_private_key: /data/private_key.pem + vapid_contact_email: help@cinny.cc From 8e7c6ff0339d3f82c4902b9fc14ffb3062171caa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:29:55 +1000 Subject: [PATCH 43/64] Bump actions/setup-node from 4.3.0 to 4.4.0 (#2307) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4.3.0...v4.4.0) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 4.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 441da0de..450e4e29 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index 34308c21..66cd5ad5 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 44205ff2..b11da5be 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Setup node - uses: actions/setup-node@v4.3.0 + uses: actions/setup-node@v4.4.0 with: node-version: 20.12.2 cache: 'npm' From 42e552f6f8c5225c11bd934d893ca3228b553e69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:52:03 +1000 Subject: [PATCH 44/64] Bump docker/build-push-action from 6.15.0 to 6.18.0 (#2351) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.15.0 to 6.18.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.15.0...v6.18.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 6.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-pr.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 4e88c78d..398785ab 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Build Docker image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v6.18.0 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index b11da5be..0a758c51 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -90,7 +90,7 @@ jobs: ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v6.18.0 with: context: . platforms: linux/amd64,linux/arm64 From 72aa3862382e19417f09eb3ced9d7e0b019f1a2c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 19 Jun 2025 14:22:45 -0500 Subject: [PATCH 45/64] Remove incorrect nav badge handling placement --- src/app/hooks/useRoomEventReaders.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/app/hooks/useRoomEventReaders.ts b/src/app/hooks/useRoomEventReaders.ts index ffbb5918..f634e586 100644 --- a/src/app/hooks/useRoomEventReaders.ts +++ b/src/app/hooks/useRoomEventReaders.ts @@ -1,6 +1,5 @@ import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { useEffect, useState } from 'react'; -import { getUnreadInfo } from '../utils/room'; const getEventReaders = (room: Room, evtId?: string) => { if (!evtId) return []; @@ -22,7 +21,6 @@ const getEventReaders = (room: Room, evtId?: string) => { export const useRoomEventReaders = (room: Room, eventId?: string): string[] => { const [readers, setReaders] = useState(() => getEventReaders(room, eventId)); - const unreadInfo = getUnreadInfo(room); useEffect(() => { setReaders(getEventReaders(room, eventId)); @@ -48,16 +46,11 @@ export const useRoomEventReaders = (room: Room, eventId?: string): string[] => { room.on(RoomEvent.Receipt, handleReceipt); room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); - try { - navigator.setAppBadge(unreadInfo.total); - } catch (e) { - // Likely Firefox/Gecko-based and doesn't support badging API - } return () => { room.removeListener(RoomEvent.Receipt, handleReceipt); room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); }; - }, [room, eventId, unreadInfo.total]); + }, [room, eventId]); return readers; }; From 8cbc9ce6018380dbc308f57c4b6b4f87413c6fa4 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 19 Jun 2025 14:22:58 -0500 Subject: [PATCH 46/64] Remove incorrect nav badge handling placement --- src/app/pages/client/ClientNonUIFeatures.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6b35961a..170bc03b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -212,11 +212,6 @@ function MessageNotifications() { ) { return; } - try { - navigator.setAppBadge(unreadInfo.total); - } catch (e) { - // Likely Firefox/Gecko-based and doesn't support badging API - } if (showNotifications && notificationPermission('granted')) { const avatarMxc = From 60af716dbc554720243db5652fc4267cc2f7d61f Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 19 Jun 2025 14:23:19 -0500 Subject: [PATCH 47/64] move nav badge handling to favicon and sum total there for it --- src/app/pages/client/ClientNonUIFeatures.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 170bc03b..a7c8b306 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -57,7 +57,11 @@ function FaviconUpdater() { useEffect(() => { let notification = false; let highlight = false; + let total = 0; roomToUnread.forEach((unread) => { + if (unread.from === null) { + total += unread.total; + } if (unread.total > 0) { notification = true; } @@ -71,6 +75,11 @@ function FaviconUpdater() { } else { setFavicon(LogoSVG); } + try { + navigator.setAppBadge(total); + } catch (e) { + // Likely Firefox/Gecko-based and doesn't support badging API + } }, [roomToUnread]); return null; From 77bbb94bfafdec854e450595b9bce23b0e0abeac Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Thu, 19 Jun 2025 15:07:56 -0500 Subject: [PATCH 48/64] swap fetch to use retry logic (shouldn't occur very often, but when it does we don't want to immediately fail) --- src/sw.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 96e26c11..65700465 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -19,6 +19,45 @@ function sendAndWaitForReply(client: WindowClient, type: string, payload: object return promise; } +async function fetchWithRetry( + url: string, + token: string, + retries = 3, + delay = 250 +): Promise { + let lastError: Error | undefined; + + /* eslint-disable no-await-in-loop */ + for (let attempt = 1; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < retries) { + console.warn( + `Fetch attempt ${attempt} failed: ${lastError.message}. Retrying in ${delay}ms...` + ); + await new Promise((res) => { + setTimeout(res, delay); + }); + } + } + } + /* eslint-enable no-await-in-loop */ + throw new Error(`Fetch failed after ${retries} retries. Last error: ${lastError?.message}`); +} + function fetchConfig(token?: string): RequestInit | undefined { if (!token) return undefined; @@ -81,8 +120,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { if (!client) throw new Error('Client not found'); const token = await sendAndWaitForReply(client, 'token', {}); if (!token) throw new Error('Failed to retrieve token'); - const response = await fetch(url, fetchConfig(token)); - if (!response.ok) throw new Error(`Fetch failed: ${response.statusText}`); + const response = await fetchWithRetry(url, token); return response; })() ); From 2861ccdc46690cb9f735c39109ff9b75b31d7b38 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sat, 21 Jun 2025 05:11:04 -0500 Subject: [PATCH 49/64] Add global app events --- src/app/utils/appEvents.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/app/utils/appEvents.ts diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts new file mode 100644 index 00000000..12f3f22b --- /dev/null +++ b/src/app/utils/appEvents.ts @@ -0,0 +1,6 @@ +export const appEvents = { + + onVisibilityHidden: null as (() => void) | null, + + onVisibilityChange: null as ((isVisible: boolean) => void) | null, +}; From ff7c40ec856bd59a88b8578b2c860f817b314691 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sat, 21 Jun 2025 05:11:31 -0500 Subject: [PATCH 50/64] Swap our visibility handler and notification implementation to use the global app events --- src/app/pages/client/ClientRoot.tsx | 46 +++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 3d9c2b50..ac7a3bae 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -39,6 +39,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { togglePusher } from '../../features/settings/notifications/PushNotifications'; import { ClientConfig, useClientConfig } from '../../hooks/useClientConfig'; +import { appEvents } from '../../utils/appEvents'; function ClientRootLoading() { return ( @@ -178,17 +179,44 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [mx, startMatrix]); + useEffect(() => { + const handleVisibilityChange = () => { + const isVisible = document.visibilityState === 'visible'; + appEvents.onVisibilityChange?.(isVisible); + if (!isVisible) { + appEvents.onVisibilityHidden?.(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + + useEffect(() => { + if (!mx) return; + + const handleVisibilityForNotifications = (isVisible: boolean) => { + togglePusher(mx, clientConfig, isVisible); + }; + + appEvents.onVisibilityChange = handleVisibilityForNotifications; + + return () => { + appEvents.onVisibilityChange = null; + }; + }, [mx, clientConfig]); + useSyncState( mx, - useCallback( - (state) => { - if (state === 'PREPARED') { - setLoading(false); - pushNotificationListener(mx, clientConfig); - } - }, - [clientConfig, mx] - ) + useCallback((state) => { + if (state === 'PREPARED') { + setLoading(false); + } + }, []) ); return ( From b972f308f4c27fff30a2aef373636e5975fabcd2 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:23:04 -0500 Subject: [PATCH 51/64] Add visibility hook to remove from the ClientRoot --- src/app/hooks/useAppVisibility.ts | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/hooks/useAppVisibility.ts diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts new file mode 100644 index 00000000..f53974c1 --- /dev/null +++ b/src/app/hooks/useAppVisibility.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useAtom } from 'jotai'; +import { togglePusher } from '../features/settings/notifications/PushNotifications'; +import { appEvents } from '../utils/appEvents'; +import { useClientConfig } from './useClientConfig'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; +import { pushSubscriptionAtom } from '../state/pushSubscription'; + +export function useAppVisibility(mx: MatrixClient | undefined) { + const clientConfig = useClientConfig(); + const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const pushSubAtom = useAtom(pushSubscriptionAtom); + + useEffect(() => { + const handleVisibilityChange = () => { + const isVisible = document.visibilityState === 'visible'; + appEvents.onVisibilityChange?.(isVisible); + if (!isVisible) { + appEvents.onVisibilityHidden?.(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + useEffect(() => { + if (!mx) return; + + const handleVisibilityForNotifications = (isVisible: boolean) => { + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom); + }; + + appEvents.onVisibilityChange = handleVisibilityForNotifications; + // eslint-disable-next-line consistent-return + return () => { + appEvents.onVisibilityChange = null; + }; + }, [mx, clientConfig, usePushNotifications, pushSubAtom]); +} From b68908959997ceabe8f3b3115ea025d11e6e36f0 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:27:48 -0500 Subject: [PATCH 52/64] Place our storage handling into a state module instead and reference it --- src/app/state/pushSubscription.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/app/state/pushSubscription.ts diff --git a/src/app/state/pushSubscription.ts b/src/app/state/pushSubscription.ts new file mode 100644 index 00000000..1f5f10e3 --- /dev/null +++ b/src/app/state/pushSubscription.ts @@ -0,0 +1,32 @@ +import { atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +const PUSH_SUBSCRIPTION_KEY = 'webPushSubscription'; + +const basePushSubscriptionAtom = atomWithLocalStorage( + PUSH_SUBSCRIPTION_KEY, + (key) => getLocalStorageItem(key, null), + (key, value) => { + setLocalStorageItem(key, value); + } +); + +export const pushSubscriptionAtom = atom< + PushSubscriptionJSON | null, + [PushSubscription | null], + void +>( + (get) => get(basePushSubscriptionAtom), + (get, set, subscription: PushSubscription | null) => { + if (subscription) { + const subscriptionJSON = subscription.toJSON(); + set(basePushSubscriptionAtom, subscriptionJSON); + } else { + set(basePushSubscriptionAtom, null); + } + } +); From cfea393f2de8e77b287b397310a8d1b2e2bdcd7c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:27:55 -0500 Subject: [PATCH 53/64] Revise to use atom values and simplify; modify to check for valid subscription via self-heal; modify togglePusher to defer to the users push notif setting before visibility changes --- .../notifications/PushNotifications.tsx | 193 ++++++++---------- 1 file changed, 85 insertions(+), 108 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 1aa8d2e2..3b2382fa 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -9,157 +9,130 @@ export async function requestBrowserNotificationPermission(): Promise ): Promise { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error('Push messaging is not supported in this browser.'); } - if (!mx || !mx.getHomeserverUrl() || !mx.getAccessToken()) { - throw new Error('Matrix client is not properly initialized or authenticated.'); - } - - if ( - !clientConfig.pushNotificationDetails?.vapidPublicKey || - !clientConfig.pushNotificationDetails?.webPushAppID || - !clientConfig.pushNotificationDetails?.pushNotifyUrl - ) { - throw new Error('One or more push configuration constants are missing.'); - } - + const [pushSubAtom, setPushSubscription] = pushSubscriptionAtom; const registration = await navigator.serviceWorker.ready; + const currentBrowserSub = await registration.pushManager.getSubscription(); - let subscription = await registration.pushManager.getSubscription(); - if (!subscription) { - try { - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, - }); - } catch (subscribeError: any) { - if (Notification.permission === 'denied') { - throw new Error('Notification permission denied. Please enable in browser settings.'); - } - throw new Error(`Failed to subscribe: ${subscribeError.message || String(subscribeError)}`); - } + /* Self-Healing Check. Effectively checks if the browser has invalidated our subscription and recreates it + only when necessary. This prevents us from needing an external call to get back the web push info. + */ + if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) { + console.error('Valid saved subscription found. Ensuring pusher is enabled on homeserver...'); + const pusherData = { + kind: 'http' as const, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: pushSubAtom.keys!.p256dh!, + app_display_name: 'Cinny', + device_display_name: 'This Browser', + lang: navigator.language || 'en', + data: { + url: clientConfig.pushNotificationDetails?.pushNotifyUrl, + format: 'event_id_only' as const, + endpoint: pushSubAtom.endpoint, + p256dh: pushSubAtom.keys!.p256dh!, + auth: pushSubAtom.keys!.auth!, + }, + append: false, + }; + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); + return; } - const pwaAppIdForPlatform = clientConfig.pushNotificationDetails?.webPushAppID; - if (!pwaAppIdForPlatform) { - await subscription.unsubscribe(); - throw new Error('Could not determine PWA App ID for push endpoint.'); + console.error('No valid saved subscription. Performing full, new subscription...'); + + if (currentBrowserSub) { + await currentBrowserSub.unsubscribe(); } - const subJson = subscription.toJSON(); - const p256dhKey = subJson.keys?.p256dh; - const authKey = subJson.keys?.auth; + const newSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, + }); - if (!p256dhKey || !authKey) { - await subscription.unsubscribe(); - throw new Error('Push subscription keys (p256dh, auth) are missing.'); - } + setPushSubscription(newSubscription); + const subJson = newSubscription.toJSON(); const pusherData = { kind: 'http' as const, - app_id: pwaAppIdForPlatform, - pushkey: p256dhKey, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: subJson.keys!.p256dh!, app_display_name: 'Cinny', device_display_name: - (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown device', + (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device', lang: navigator.language || 'en', data: { url: clientConfig.pushNotificationDetails?.pushNotifyUrl, format: 'event_id_only' as const, - endpoint: subscription.endpoint, - p256dh: p256dhKey, - auth: authKey, + endpoint: newSubscription.endpoint, + p256dh: subJson.keys!.p256dh!, + auth: subJson.keys!.auth!, }, - enabled: false, - 'org.matrix.msc3881.enabled': false, - 'org.matrix.msc3881.device_id': mx.getDeviceId(), append: false, }; - try { - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); - } catch (pusherError: any) { - await subscription.unsubscribe(); - throw new Error( - `Failed to set up push with Matrix server: ${pusherError.message || String(pusherError)}` - ); - } + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } +/** + * Disables push notifications by telling the homeserver to delete the pusher, + * but keeps the browser subscription locally for a fast re-enable. + */ export async function disablePushNotifications( mx: MatrixClient, - clientConfig: ClientConfig + clientConfig: ClientConfig, + pushSubscriptionAtom: Atom ): Promise { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - return; - } + const [pushSubAtom] = pushSubscriptionAtom; - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); + const pusherData = { + kind: null, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: pushSubAtom?.keys?.p256dh, + }; - if (!subscription) { - return; - } - - const pwaAppIdForPlatform = clientConfig.pushNotificationDetails?.webPushAppID; - - await subscription.unsubscribe(); - - const subJson = subscription.toJSON(); - const p256dhKey = subJson.keys?.p256dh; - const authKey = subJson.keys?.auth; - - if (mx && mx.getAccessToken() && pwaAppIdForPlatform) { - const pusherData = { - kind: null, - app_id: pwaAppIdForPlatform, - pushkey: p256dhKey, - }; - - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); - } + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } export async function deRegisterAllPushers(mx: MatrixClient): Promise { const response = await mx.getPushers(); const pushers = response.pushers || []; - - if (pushers.length === 0) { - return; - } + if (pushers.length === 0) return; const deletionPromises = pushers.map((pusher) => { - const pusherToDelete: Partial & { kind: null; app_id: string; pushkey: string } = { + const pusherToDelete = { kind: null, app_id: pusher.app_id, pushkey: pusher.pushkey, - ...(pusher.data && { data: pusher.data }), - ...(pusher.profile_tag && { profile_tag: pusher.profile_tag }), }; - - return mx - .setPusher(pusherToDelete as any) - .then(() => ({ status: 'fulfilled', app_id: pusher.app_id })) - .catch((err) => ({ status: 'rejected', app_id: pusher.app_id, error: err })); + return mx.setPusher(pusherToDelete as any); }); await Promise.allSettled(deletionPromises); @@ -168,11 +141,15 @@ export async function deRegisterAllPushers(mx: MatrixClient): Promise { export async function togglePusher( mx: MatrixClient, clientConfig: ClientConfig, - visible: boolean + visible: boolean, + usePushNotifications: boolean, + pushSubscriptionAtom: Atom ): Promise { - if (visible) { - disablePushNotifications(mx, clientConfig); - } else { - enablePushNotifications(mx, clientConfig); + if (usePushNotifications) { + if (visible) { + await disablePushNotifications(mx, clientConfig, pushSubscriptionAtom); + } else { + await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom); + } } } From 4123299af5a79bb46c186479b2d57d5b1d080e74 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:28:30 -0500 Subject: [PATCH 54/64] remove unused method for push notif visibility changes --- src/app/pages/client/ClientRoot.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index ac7a3bae..4cccd641 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -127,12 +127,6 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { ); } -const pushNotificationListener = (mx: MatrixClient, clientConfig: ClientConfig) => { - document.addEventListener('visibilitychange', () => { - togglePusher(mx, clientConfig, document.visibilityState === 'visible'); - }); -}; - const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { From 296a2463da4139468ba354cdcbcbb81a35186fce Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:29:03 -0500 Subject: [PATCH 55/64] useAppVisibility change hook instead of baking into client root for cleanliness --- src/app/pages/client/ClientRoot.tsx | 33 +---------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 4cccd641..c1c602c5 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -149,7 +149,6 @@ type ClientRootProps = { export function ClientRoot({ children }: ClientRootProps) { const [loading, setLoading] = useState(true); const { baseUrl } = getSecret(); - const clientConfig = useClientConfig(); const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => initClient(getSecret() as any), []) @@ -160,6 +159,7 @@ export function ClientRoot({ children }: ClientRootProps) { ); useLogoutListener(mx); + useAppVisibility(mx); useEffect(() => { if (loadState.status === AsyncStatus.Idle) { @@ -173,37 +173,6 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [mx, startMatrix]); - useEffect(() => { - const handleVisibilityChange = () => { - const isVisible = document.visibilityState === 'visible'; - appEvents.onVisibilityChange?.(isVisible); - if (!isVisible) { - appEvents.onVisibilityHidden?.(); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, []); - - - useEffect(() => { - if (!mx) return; - - const handleVisibilityForNotifications = (isVisible: boolean) => { - togglePusher(mx, clientConfig, isVisible); - }; - - appEvents.onVisibilityChange = handleVisibilityForNotifications; - - return () => { - appEvents.onVisibilityChange = null; - }; - }, [mx, clientConfig]); - useSyncState( mx, useCallback((state) => { From d3e3ecb64bc230f61525a9f067f533072908a553 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:29:10 -0500 Subject: [PATCH 56/64] update imports --- src/app/pages/client/ClientRoot.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index c1c602c5..82b2b294 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -37,9 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; -import { togglePusher } from '../../features/settings/notifications/PushNotifications'; -import { ClientConfig, useClientConfig } from '../../hooks/useClientConfig'; -import { appEvents } from '../../utils/appEvents'; +import { useAppVisibility } from '../../hooks/useAppVisibility'; function ClientRootLoading() { return ( From e3a450a67969ba6befba12aed64f64cded3e242c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:33:39 -0500 Subject: [PATCH 57/64] Add deregister pushers component --- .../DeregisterPushNotifications.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/app/features/settings/notifications/DeregisterPushNotifications.tsx diff --git a/src/app/features/settings/notifications/DeregisterPushNotifications.tsx b/src/app/features/settings/notifications/DeregisterPushNotifications.tsx new file mode 100644 index 00000000..bc6b65d4 --- /dev/null +++ b/src/app/features/settings/notifications/DeregisterPushNotifications.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + color, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import { useAtom } from 'jotai'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { pushSubscriptionAtom } from '../../../state/pushSubscription'; +import { deRegisterAllPushers } from './PushNotifications'; +import { SettingTile } from '../../../components/setting-tile'; + +type ConfirmDeregisterDialogProps = { + onClose: () => void; + onConfirm: () => void; + isLoading: boolean; +}; + +function ConfirmDeregisterDialog({ onClose, onConfirm, isLoading }: ConfirmDeregisterDialogProps) { + return ( + }> + + + +
+ + Reset All Push Notifications + + + + +
+ + + This will remove push notifications from all your sessions and devices. This action + cannot be undone. Are you sure you want to continue? + + + + + + +
+
+
+
+ ); +} + +export function DeregisterAllPushersSetting() { + const mx = useMatrixClient(); + const [deregisterState] = useAsyncCallback(deRegisterAllPushers); + const [isConfirming, setIsConfirming] = useState(false); + const [usePushNotifications, setPushNotifications] = useSetting( + settingsAtom, + 'usePushNotifications' + ); + + const [pushSubscription, setPushSubscription] = useAtom(pushSubscriptionAtom); + + const handleOpenConfirmDialog = () => { + setIsConfirming(true); + }; + + const handleCloseConfirmDialog = () => { + if (deregisterState.status === AsyncStatus.Loading) return; + setIsConfirming(false); + }; + + const handleConfirmDeregister = async () => { + await deRegisterAllPushers(mx); + setPushNotifications(false); + setPushSubscription(null); + setIsConfirming(false); + }; + + return ( + <> + {isConfirming && ( + + )} + + + + This will remove push notifications from all your sessions/devices. You will need to + re-enable them on each device individually. + + {deregisterState.status === AsyncStatus.Error && ( + +
+ Failed to deregister devices. Please try again. +
+ )} + {deregisterState.status === AsyncStatus.Success && ( + +
+ Successfully deregistered all devices. +
+ )} + + } + after={ + + } + /> + + ); +} From 35e5d90dccb286c1af255914f7c5c6cd1c6b064c Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:33:52 -0500 Subject: [PATCH 58/64] use deregister pushers component --- .../settings/notifications/SystemNotification.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 791caa2e..e641a039 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -211,6 +211,15 @@ export function SystemNotification() { > + + + +
); } From c909629150c1469c9bf1f7281720e9d67b95745d Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:34:05 -0500 Subject: [PATCH 59/64] adjust name to reflect new values --- src/app/features/settings/notifications/SystemNotification.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e641a039..059d8402 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -162,7 +162,7 @@ function WebPushNotificationSetting() { } export function SystemNotification() { - const [showInAppNotifs, setShowInAppNotifs] = useSetting(settingsAtom, 'showNotifications'); + const [showInAppNotifs, setShowInAppNotifs] = useSetting(settingsAtom, 'useInAppNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( settingsAtom, 'isNotificationSounds' From f31087e685b6d3ac6c62bb7f0163a0b690ab63c8 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:34:18 -0500 Subject: [PATCH 60/64] update name --- .../features/settings/notifications/SystemNotification.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 059d8402..2cf4868a 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -151,10 +151,7 @@ function WebPushNotificationSetting() { Enable ) : browserPermission === 'granted' ? ( - + ) : null } /> From 72bb04f190551d397bb1c5f004f31e866b00d10a Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:35:07 -0500 Subject: [PATCH 61/64] Swap to using the atom values instead of raw setting them in the component. Passing the values forward as needed. --- .../notifications/SystemNotification.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 2cf4868a..9209a8ac 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -93,12 +93,15 @@ function EmailNotification() { function WebPushNotificationSetting() { const mx = useMatrixClient(); const clientConfig = useClientConfig(); - const [userPushPreference, setUserPushPreference] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [usePushNotifications, setPushNotifications] = useSetting( + settingsAtom, + 'usePushNotifications' + ); + const pushSubAtom = useAtom(pushSubscriptionAtom); + const browserPermission = usePermissionState('notifications', getNotificationState()); useEffect(() => { - const storedPreference = localStorage.getItem('cinny_web_push_enabled'); - setUserPushPreference(storedPreference === 'true'); setIsLoading(false); }, []); const handleRequestPermissionAndEnable = async () => { @@ -106,9 +109,8 @@ function WebPushNotificationSetting() { try { const permissionResult = await requestBrowserNotificationPermission(); if (permissionResult === 'granted') { - await enablePushNotifications(mx, clientConfig); - localStorage.setItem('cinny_web_push_enabled', 'true'); - setUserPushPreference(true); + await enablePushNotifications(mx, clientConfig, pushSubAtom); + setPushNotifications(true); } } finally { setIsLoading(false); @@ -120,12 +122,11 @@ function WebPushNotificationSetting() { try { if (wantsPush) { - await enablePushNotifications(mx, clientConfig); + await enablePushNotifications(mx, clientConfig, pushSubAtom); } else { - await disablePushNotifications(mx, clientConfig); + await disablePushNotifications(mx, clientConfig, pushSubAtom); } - localStorage.setItem('cinny_web_push_enabled', String(wantsPush)); - setUserPushPreference(wantsPush); + setPushNotifications(wantsPush); } finally { setIsLoading(false); } From 5831325c6d6b6142ee4971dce5e5d7c28de2e197 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:35:20 -0500 Subject: [PATCH 62/64] Update imports --- .../features/settings/notifications/SystemNotification.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 9209a8ac..276b6421 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,6 +1,8 @@ +/* eslint-disable no-nested-ternary */ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { IPusherRequest } from 'matrix-js-sdk'; +import { useAtom } from 'jotai'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -16,6 +18,8 @@ import { disablePushNotifications, } from './PushNotifications'; import { useClientConfig } from '../../../hooks/useClientConfig'; +import { pushSubscriptionAtom } from '../../../state/pushSubscription'; +import { DeregisterAllPushersSetting } from './DeregisterPushNotifications'; function EmailNotification() { const mx = useMatrixClient(); From 6359b8547d2205abb69241955ccf4451d8501fef Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:35:53 -0500 Subject: [PATCH 63/64] Update names to reflect real behavior --- src/app/pages/client/ClientNonUIFeatures.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index a7c8b306..2a4b54ba 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -92,7 +92,7 @@ function InviteNotifications() { const mx = useMatrixClient(); const navigate = useNavigate(); - const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const notify = useCallback( @@ -143,7 +143,7 @@ function MessageNotifications() { const unreadCacheRef = useRef>(new Map()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const navigate = useNavigate(); From 3d9f7f4e264dec5638eccf2c0bce3ec9d30cba87 Mon Sep 17 00:00:00 2001 From: Gigiaj Date: Sun, 29 Jun 2025 23:36:18 -0500 Subject: [PATCH 64/64] adjust name of showNotifications to useInAppNotifications and add usePushNotifications --- src/app/state/settings.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 799747ac..394f9c61 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -32,7 +32,8 @@ export interface Settings { showHiddenEvents: boolean; legacyUsernameColor: boolean; - showNotifications: boolean; + usePushNotifications: boolean; + useInAppNotifications: boolean; isNotificationSounds: boolean; developerTools: boolean; @@ -62,7 +63,8 @@ const defaultSettings: Settings = { showHiddenEvents: false, legacyUsernameColor: false, - showNotifications: true, + usePushNotifications: false, + useInAppNotifications: true, isNotificationSounds: true, developerTools: false,