diff --git a/public/locales/en.json b/public/locales/en.json index e7d37247..d0d4cde3 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -163,10 +163,6 @@ "block_messages": "Block Messages", "block_messages_moved": "This option has been moved to \"Account > Block Users\" section.", "system": "System", - "desktop_notifications": "Desktop Notifications", - "notif_permission_blocked": "Notification permission is blocked. Please allow notification permission from browser address bar.", - "notif_not_supported": "Notifications are not supported by the system.", - "notif_show_desktop": "Show desktop notifications when a message arrives.", "enable": "Enable", "notification_sound": "Notification Sound", "notification_sound_desc": "Play sound when a new message arrives.", diff --git a/public/locales/ru.json b/public/locales/ru.json index a8dd9c1a..b6e08d25 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -163,10 +163,6 @@ "block_messages": "Блокировка сообщений", "block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».", "system": "Система", - "desktop_notifications": "Уведомления на рабочем столе", - "notif_permission_blocked": "Разрешение на уведомления заблокировано. Разрешите уведомления в адресной строке браузера.", - "notif_not_supported": "Уведомления не поддерживаются системой.", - "notif_show_desktop": "Показывать уведомления на рабочем столе при получении сообщений.", "enable": "Включить", "notification_sound": "Звук уведомлений", "notification_sound_desc": "Воспроизводить звук при получении нового сообщения.", diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index d28611dd..6f3dd788 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -7,7 +7,6 @@ import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; -import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -176,54 +175,14 @@ function PushNotification() { export function SystemNotification() { const { t } = useTranslation(); - const notifPermission = usePermissionState('notifications', getNotificationState()); - const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( settingsAtom, 'isNotificationSounds' ); - const requestNotificationPermission = () => { - window.Notification.requestPermission(); - }; - return ( {t('Settings.system')} - - - {'Notification' in window - ? t('Settings.notif_permission_blocked') - : t('Settings.notif_not_supported')} - - ) : ( - {t('Settings.notif_show_desktop')} - ) - } - after={ - notifPermission === 'prompt' ? ( - - ) : ( - - ) - } - /> - undefined; @@ -190,6 +192,7 @@ export function useRegisterPushNotifications(): () => Promise { savePusherIds(ids); setPushEnabled(true); + await ensureRtcRingPushRule(mx); return; } @@ -206,6 +209,7 @@ export function useRegisterPushNotifications(): () => Promise { savePusherIds(ids); setPushEnabled(true); + await ensureRtcRingPushRule(mx); }, [mx, clientConfig]); } @@ -246,6 +250,12 @@ export function useDisablePushNotifications(): () => Promise { } } + // 3. Drop the account-scoped RTC ring push rule. It's added symmetrically in + // register flows; leaving it behind would mean other logged-in clients + // (Element, a second Vojo session) still apply ring tweaks after the + // user explicitly turned push off on this device. + await removeRtcRingPushRule(mx); + clearPusherIds(); setPushEnabled(false); }, [mx]); @@ -269,9 +279,24 @@ export function usePushNotificationsLifecycle(): void { register().catch(noop); }, [register]); + // Push rule for RTC ring is account-scoped and survives between sessions, but + // existing pushers that predate this feature won't have it — re-assert on + // every client startup (idempotent) so users with already-enabled push don't + // need to toggle push off/on to start receiving call pushes. + useEffect(() => { + if (!isPushEnabled()) return; + ensureRtcRingPushRule(mx).catch(noop); + }, [mx]); + useEffect(() => { const onNavigate = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { roomId?: string } | undefined; + const detail = (ev as CustomEvent).detail as + | { roomId?: string; isInvite?: boolean } + | undefined; + if (detail?.isInvite) { + navigate(getInboxInvitesPath()); + return; + } if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId)); }; const onSubChange = () => { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6b8b38ea..07bcc58f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,31 +1,23 @@ import { useAtomValue } from 'jotai'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import LogoSVG from '../../../../public/res/svg/vojo.svg'; import LogoUnreadSVG from '../../../../public/res/svg/vojo-unread.svg'; import LogoHighlightSVG from '../../../../public/res/svg/vojo-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; -import InviteSound from '../../../../public/sound/invite.ogg'; -import { notificationPermission, setFavicon } from '../../utils/dom'; +import { setFavicon } from '../../utils/dom'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { allInvitesAtom } from '../../state/room-list/inviteList'; -import { usePreviousValue } from '../../hooks/usePreviousValue'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils'; import { - getMemberDisplayName, getNotificationType, getUnreadInfo, isNotificationEvent, } from '../../utils/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications'; import { PushPermissionPrompt } from '../../components/push-permission-prompt'; import { useAndroidBackButton } from '../../hooks/useAndroidBackButton'; @@ -79,102 +71,20 @@ function FaviconUpdater() { return null; } -function InviteNotifications() { - const audioRef = useRef(null); - const invites = useAtomValue(allInvitesAtom); - const perviousInviteLen = usePreviousValue(invites.length, 0); - const mx = useMatrixClient(); - - const navigate = useNavigate(); - const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - - const notify = useCallback( - (count: number) => { - const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, - body: `You have ${count} new invitation request.`, - silent: true, - }); - - noti.onclick = () => { - if (!window.closed) navigate(getInboxInvitesPath()); - noti.close(); - }; - }, - [navigate] - ); - - const playSound = useCallback(() => { - const audioElement = audioRef.current; - audioElement?.play(); - }, []); - - useEffect(() => { - if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { - if (showNotifications && notificationPermission('granted')) { - notify(invites.length - perviousInviteLen); - } - - if (notificationSound) { - playSound(); - } - } - }, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]); - - return ( - // eslint-disable-next-line jsx-a11y/media-has-caption - - ); -} - +// OS notifications are exclusively owned by the Service Worker push pipeline +// (see src/sw.ts). This component only owns the in-tab sound + dedup cache — +// the timeline listener fires on any notifiable event and plays a sound when +// the tab is focused / the event isn't for the currently-open room. Push and +// in-app are one channel now; a push-less build simply gets no OS banner. function MessageNotifications() { const audioRef = useRef(null); - const notifRef = useRef(); const unreadCacheRef = useRef>(new Map()); const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const navigate = useNavigate(); const notificationSelected = useInboxNotificationsSelected(); const selectedRoomId = useSelectedRoom(); - const notify = useCallback( - ({ - roomName, - roomAvatar, - username, - }: { - roomName: string; - roomAvatar?: string; - username: string; - roomId: string; - eventId: string; - }) => { - const noti = new window.Notification(roomName, { - icon: roomAvatar, - badge: roomAvatar, - body: `New inbox notification from ${username}`, - silent: true, - }); - - noti.onclick = () => { - if (!window.closed) navigate(getInboxNotificationsPath()); - noti.close(); - notifRef.current = undefined; - }; - - notifRef.current?.close(); - notifRef.current = noti; - }, - [navigate] - ); - const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); @@ -215,20 +125,6 @@ function MessageNotifications() { return; } - if (showNotifications && notificationPermission('granted')) { - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - notify({ - roomName: room.name ?? 'Unknown', - roomAvatar: avatarMxc - ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined, - username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, - roomId: room.roomId, - eventId, - }); - } - if (notificationSound) { playSound(); } @@ -237,16 +133,7 @@ function MessageNotifications() { return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [ - mx, - notificationSound, - notificationSelected, - showNotifications, - playSound, - notify, - selectedRoomId, - useAuthentication, - ]); + }, [mx, notificationSound, notificationSelected, playSound, selectedRoomId]); return ( // eslint-disable-next-line jsx-a11y/media-has-caption @@ -276,7 +163,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index f677e5c4..3503762e 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -40,7 +40,6 @@ export interface Settings { showHiddenEvents: boolean; legacyUsernameColor: boolean; - showNotifications: boolean; isNotificationSounds: boolean; hour24Clock: boolean; @@ -74,7 +73,6 @@ const defaultSettings: Settings = { showHiddenEvents: false, legacyUsernameColor: false, - showNotifications: true, isNotificationSounds: true, hour24Clock: false, diff --git a/src/app/utils/push.ts b/src/app/utils/push.ts index f66bfbab..e715fc82 100644 --- a/src/app/utils/push.ts +++ b/src/app/utils/push.ts @@ -1,4 +1,11 @@ -import { MatrixClient, IPusherRequest } from 'matrix-js-sdk'; +import { + MatrixClient, + IPusherRequest, + PushRuleKind, + ConditionKind, + PushRuleActionName, + TweakName, +} from 'matrix-js-sdk'; import { isNativePlatform } from './capacitor'; export type PushPlatform = 'web' | 'fcm'; @@ -66,6 +73,59 @@ export type PusherIds = { appId: string; }; +export const RTC_RING_PUSH_RULE_ID = 'chat.vojo.rtc.ring'; + +// Matrix default push rules don't cover `org.matrix.msc4075.rtc.notification`, +// so without an explicit Override rule the homeserver never dispatches RTC ring +// events to Sygnal — background/killed devices silently miss incoming DM calls. +// +// Idempotent: Synapse returns 200 OK on PUT with an identical body, so calling +// this on every pusher (re)registration and on lifecycle startup is cheap. +// Failures are logged but not thrown — a transient /pushrules 5xx shouldn't +// break pusher setup (foreground path via useIncomingRtcNotifications keeps +// working regardless of whether this rule made it to the server). +export async function ensureRtcRingPushRule(mx: MatrixClient): Promise { + try { + await mx.addPushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID, { + conditions: [ + { + kind: ConditionKind.EventMatch, + key: 'type', + pattern: 'org.matrix.msc4075.rtc.notification', + }, + { + kind: ConditionKind.EventMatch, + key: 'content.notification_type', + pattern: 'ring', + }, + ], + actions: [ + PushRuleActionName.Notify, + { set_tweak: TweakName.Sound, value: 'ring' }, + { set_tweak: TweakName.Highlight, value: true }, + ], + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[push] ensureRtcRingPushRule failed:', err); + } +} + +// Symmetric cleanup for `useDisablePushNotifications`. The rule lives in +// account data so it would otherwise outlive the pusher — and other logged-in +// clients (Element, a second Vojo session) would keep applying the ring +// tweaks after the user explicitly turned push off here. 404 on delete of an +// absent rule lands in the same warn path — that's the "already gone" case, +// not a failure worth surfacing. +export async function removeRtcRingPushRule(mx: MatrixClient): Promise { + try { + await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[push] removeRtcRingPushRule failed:', err); + } +} + export async function registerWebPusher( mx: MatrixClient, subscription: PushSubscription, diff --git a/src/index.tsx b/src/index.tsx index 16b946c7..cf8defe8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,8 +46,10 @@ if ('serviceWorker' in navigator) { } if (type === 'notificationClick') { - const { roomId } = ev.data ?? {}; - window.dispatchEvent(new CustomEvent('vojo:pushNavigate', { detail: { roomId } })); + const { roomId, isInvite } = ev.data ?? {}; + window.dispatchEvent( + new CustomEvent('vojo:pushNavigate', { detail: { roomId, isInvite } }) + ); return; } diff --git a/src/sw.ts b/src/sw.ts index 467c30d0..6aebade5 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -159,7 +159,16 @@ self.addEventListener('fetch', (event: FetchEvent) => { // --- Push Notifications --- +// Sygnal's WebPush pushkin ships the Matrix push object flat at the top level +// (`{room_id, event_id, unread, prio}`) — no `notification` wrapper. The 4KB +// WebPush payload limit pushed Sygnal to drop the extra nesting. Other push +// gateways (or older Sygnal builds) still wrap — we accept both shapes so the +// SW doesn't silently drop pushes after a gateway swap. type PushPayload = { + event_id?: string; + room_id?: string; + unread?: number; + prio?: 'high' | 'low'; notification?: { event_id?: string; room_id?: string; @@ -173,9 +182,17 @@ type PushPayload = { }; }; +// `WindowClient.focused` requires OS-level window focus on top of the tab being +// the active one — so Vojo visually open on monitor A while the user types in +// another window on monitor B reads as `focused: false`, and we'd double-notify +// a user who is looking right at the app. Relaxing to visibility-only treats +// "tab is on-screen" as "don't spam me", which matches the intuitive UX. +// Trade-off: a tab that's visually visible but the user isn't actually looking +// at (side-by-side layouts, background-visible panes) will also suppress OS +// notifications — acceptable for MVP; revisit if users report missed pings. async function hasVisibleClient(): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - return clients.some((c) => c.visibilityState === 'visible' && c.focused); + return clients.some((c) => c.visibilityState === 'visible'); } async function anySession(): Promise { @@ -191,10 +208,36 @@ async function anySession(): Promise { // Fallback strings for when we can't fetch event content (offline / encrypted / // no session). The main app runs i18next, but the SW is a separate context — // we read navigator.language here and ship a small map for the locales we support. -type PushFallback = { brand: string; newMessage: string; encrypted: string }; +type PushFallback = { + brand: string; + newMessage: string; + encrypted: string; + incomingCall: string; + openToAnswer: string; + invitation: string; + invitedYou: (roomName?: string) => string; +}; const PUSH_FALLBACKS: Record = { - en: { brand: 'Vojo', newMessage: 'New message', encrypted: 'New encrypted message' }, - ru: { brand: 'Vojo', newMessage: 'Новое сообщение', encrypted: 'Новое зашифрованное сообщение' }, + en: { + brand: 'Vojo', + newMessage: 'New message', + encrypted: 'New encrypted message', + incomingCall: 'Incoming call', + openToAnswer: 'Open Vojo to answer', + invitation: 'Invitation', + invitedYou: (roomName) => + roomName ? `Invited you to ${roomName}` : 'Invited you to a room', + }, + ru: { + brand: 'Vojo', + newMessage: 'Новое сообщение', + encrypted: 'Новое зашифрованное сообщение', + incomingCall: 'Входящий звонок', + openToAnswer: 'Откройте Vojo чтобы ответить', + invitation: 'Приглашение', + invitedYou: (roomName) => + roomName ? `Приглашает вас в ${roomName}` : 'Приглашает вас в комнату', + }, }; function pushFallback(): PushFallback { @@ -208,7 +251,7 @@ async function fetchEventDetails( session: SessionInfo, roomId: string, eventId: string -): Promise<{ title: string; body: string }> { +): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> { const headers = { Authorization: `Bearer ${session.accessToken}` }; const [evRes, nameRes] = await Promise.all([ fetch( @@ -224,22 +267,87 @@ async function fetchEventDetails( const fb = pushFallback(); let title = fb.brand; let body = fb.newMessage; + let isCall = false; + let isInvite = false; + let roomName: string | undefined; + let inviterDisplay: string | undefined; + let inviterMxid: string | undefined; + + if (nameRes?.ok) { + const json = await nameRes.json(); + if (typeof json?.name === 'string') roomName = json.name; + } if (evRes.ok) { const event = await evRes.json(); - if (event?.type === 'm.room.encrypted') { + // RTC ring: `format: 'event_id_only'` strips `type` from the push payload, + // so we can only identify a call after fetching the full event here. Match + // the same pair as useIncomingRtcNotifications — any other rtc.notification + // flavour (group "notification") falls through to the message path. + if ( + event?.type === 'org.matrix.msc4075.rtc.notification' && + event?.content?.notification_type === 'ring' + ) { + isCall = true; + title = fb.incomingCall; + body = fb.openToAnswer; + } else if ( + event?.type === 'm.room.member' && + event?.content?.membership === 'invite' + ) { + // Invite state events surface the invitee in state_key; the inviter + // (event.sender) is the person we want to show. `content.displayname` + // is the invitee's, so we need a second round-trip against the inviter's + // member state to get a nice human name. + isInvite = true; + if (typeof event?.sender === 'string') inviterMxid = event.sender; + } else if (event?.type === 'm.room.encrypted') { body = fb.encrypted; } else if (typeof event?.content?.body === 'string') { body = event.content.body.slice(0, 200); } } - if (nameRes?.ok) { - const json = await nameRes.json(); - if (typeof json?.name === 'string') title = json.name; + if (isInvite && inviterMxid) { + // Separate fetch (not batched with the initial Promise.all) because we + // only know the sender MXID after the event request returns. Failure is + // non-fatal — we still have the MXID local-part as a last resort. + try { + const memberRes = await fetch( + `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent( + roomId + )}/state/m.room.member/${encodeURIComponent(inviterMxid)}`, + { headers } + ); + if (memberRes.ok) { + const member = await memberRes.json(); + if (typeof member?.displayname === 'string' && member.displayname.trim()) { + inviterDisplay = member.displayname; + } + } + } catch { + /* keep inviterDisplay undefined; local-part fallback below */ + } + if (!inviterDisplay) { + const local = inviterMxid.startsWith('@') + ? inviterMxid.slice(1).split(':')[0] + : inviterMxid; + inviterDisplay = local; + } } - return { title, body }; + if (isInvite) { + title = inviterDisplay ?? fb.invitation; + body = fb.invitedYou(roomName); + } else if (isCall) { + // For DM calls room_name is typically the peer's display name, so + // surface it in body (title stays "Incoming call" as the primary signal). + if (roomName) body = roomName; + } else if (roomName) { + title = roomName; + } + + return { title, body, isCall, isInvite }; } self.addEventListener('push', (event: PushEvent) => { @@ -247,9 +355,6 @@ self.addEventListener('push', (event: PushEvent) => { event.waitUntil( (async () => { - // Foreground dedup: in-app MessageNotifications handles visible clients - if (await hasVisibleClient()) return; - let payload: PushPayload = {}; try { if (event.data) payload = event.data.json(); @@ -258,8 +363,8 @@ self.addEventListener('push', (event: PushEvent) => { } const notif = payload.notification; - const roomId = notif?.room_id; - const eventId = notif?.event_id; + const roomId = notif?.room_id ?? payload.room_id; + const eventId = notif?.event_id ?? payload.event_id; // Defensive: if Sygnal sends a notification without event_id (e.g. unread-count-only // push, which `events_only: true` on the pusher should suppress but can slip through @@ -270,18 +375,72 @@ self.addEventListener('push', (event: PushEvent) => { const fb = pushFallback(); let title = notif?.room_name ?? fb.brand; let body = notif?.content?.body ?? fb.newMessage; + let isCall = false; + let isInvite = false; + // Fetch event details BEFORE the visible-client gate, because an + // incoming-call ring must surface even when Vojo is already open in + // another pane/monitor — we can only distinguish a ring from a regular + // message by the event type, which is stripped from the push payload + // (`format: 'event_id_only'`). const session = await anySession(); if (session) { try { const details = await fetchEventDetails(session, roomId, eventId); title = details.title; body = details.body; + isCall = details.isCall; + isInvite = details.isInvite; } catch { - // fall back to defaults + // fall back to defaults; isCall/isInvite stay false and we show a + // generic message notification. Cold-start (no live session → no + // access token) lands here too — see techdebt 5.24. } } + // Foreground dedup: a visible Vojo window already surfaces sound + + // favicon in-app, so we skip the OS banner for messages and invites. + // Calls are an explicit exception — a missed ring is a much higher-cost + // failure than a duplicated banner. + if (!isCall && (await hasVisibleClient())) return; + + if (isCall) { + // Distinct tag so a call notification doesn't collide with a prior + // message notification for the same room (message path uses `roomId`). + // `requireInteraction` keeps it on-screen until the user acts — a ring + // shouldn't auto-dismiss after a few seconds like a chat message. + // `renotify: true` forces re-alert (sound + re-surface) when a second + // ring arrives for the same room while the first notification is still + // in the tray — without it Chrome silently replaces the old one. + await self.registration.showNotification(title, { + body, + icon: '/res/android/android-chrome-192x192.png', + badge: '/res/android/android-chrome-96x96.png', + tag: `call_${roomId}`, + data: { roomId, eventId, isCall: true }, + requireInteraction: true, + renotify: true, + } as NotificationOptions & { renotify?: boolean }); + return; + } + + if (isInvite) { + // Invite notifications route to /inbox/invites, so we carry `isInvite` + // in data for the click handler. Distinct tag namespace keeps invite + // banners from clobbering a message notification for the same room + // (rare, but possible if the invite event and a decrypted preview + // arrive close together). + await self.registration.showNotification(title, { + body, + icon: '/res/android/android-chrome-192x192.png', + badge: '/res/android/android-chrome-96x96.png', + tag: `invite_${roomId}`, + data: { roomId, eventId, isInvite: true }, + renotify: true, + } as NotificationOptions & { renotify?: boolean }); + return; + } + await self.registration.showNotification(title, { body, icon: '/res/android/android-chrome-192x192.png', @@ -296,7 +455,12 @@ self.addEventListener('push', (event: PushEvent) => { self.addEventListener('notificationclick', (event) => { event.notification.close(); - const { roomId } = (event.notification.data as { roomId?: string }) ?? {}; + const { roomId, isCall, isInvite } = + (event.notification.data as { + roomId?: string; + isCall?: boolean; + isInvite?: boolean; + }) ?? {}; event.waitUntil( (async () => { @@ -304,10 +468,20 @@ self.addEventListener('notificationclick', (event) => { if (windows.length > 0) { const target = windows[0]; await target.focus(); - target.postMessage({ type: 'notificationClick', roomId }); + // `isCall` is forwarded but unused by the client today — once live + // session lands on the room, useIncomingRtcNotifications picks the + // ring up from the timeline (or backfill). The 2.5.3b bridge will + // consume this flag to auto-invoke the accept flow. + target.postMessage({ type: 'notificationClick', roomId, isCall, isInvite }); return; } - const path = roomId ? `/home/${encodeURIComponent(roomId)}/` : '/'; + // Cold-start path: no live window to hand off to, so pick the + // destination up-front. Invites land on the inbox list (we don't want + // to drop the user into a room they haven't joined); everything else + // opens the room directly. + let path = '/'; + if (isInvite) path = '/inbox/invites'; + else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`; await self.clients.openWindow(path); })() );