dm calls mvp: phase 2.5.2: SW push is the only OS notification channel; in-app banners removed, calls bypass the visible-tab gate

This commit is contained in:
heaven 2026-04-19 21:32:41 +03:00
parent 6ced8246e6
commit 6a690d4ecc
9 changed files with 292 additions and 196 deletions

View file

@ -163,10 +163,6 @@
"block_messages": "Block Messages", "block_messages": "Block Messages",
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.", "block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
"system": "System", "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", "enable": "Enable",
"notification_sound": "Notification Sound", "notification_sound": "Notification Sound",
"notification_sound_desc": "Play sound when a new message arrives.", "notification_sound_desc": "Play sound when a new message arrives.",

View file

@ -163,10 +163,6 @@
"block_messages": "Блокировка сообщений", "block_messages": "Блокировка сообщений",
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».", "block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
"system": "Система", "system": "Система",
"desktop_notifications": "Уведомления на рабочем столе",
"notif_permission_blocked": "Разрешение на уведомления заблокировано. Разрешите уведомления в адресной строке браузера.",
"notif_not_supported": "Уведомления не поддерживаются системой.",
"notif_show_desktop": "Показывать уведомления на рабочем столе при получении сообщений.",
"enable": "Включить", "enable": "Включить",
"notification_sound": "Звук уведомлений", "notification_sound": "Звук уведомлений",
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.", "notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",

View file

@ -7,7 +7,6 @@ import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -176,54 +175,14 @@ function PushNotification() {
export function SystemNotification() { export function SystemNotification() {
const { t } = useTranslation(); const { t } = useTranslation();
const notifPermission = usePermissionState('notifications', getNotificationState());
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting( const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom, settingsAtom,
'isNotificationSounds' 'isNotificationSounds'
); );
const requestNotificationPermission = () => {
window.Notification.requestPermission();
};
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.system')}</Text> <Text size="L400">{t('Settings.system')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.desktop_notifications')}
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{'Notification' in window
? t('Settings.notif_permission_blocked')
: t('Settings.notif_not_supported')}
</Text>
) : (
<span>{t('Settings.notif_show_desktop')}</span>
)
}
after={
notifPermission === 'prompt' ? (
<Button size="300" radii="300" onClick={requestNotificationPermission}>
<Text size="B300">{t('Settings.enable')}</Text>
</Button>
) : (
<Switch
disabled={notifPermission !== 'granted'}
value={showNotifications}
onChange={setShowNotifications}
/>
)
}
/>
</SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -8,16 +8,18 @@ import {
PUSH_ENABLED_KEY, PUSH_ENABLED_KEY,
PUSH_STATE_CHANGE_EVENT, PUSH_STATE_CHANGE_EVENT,
clearPusherIds, clearPusherIds,
ensureRtcRingPushRule,
isPushEnabled, isPushEnabled,
loadPusherIds, loadPusherIds,
registerFcmPusher, registerFcmPusher,
registerWebPusher, registerWebPusher,
removeRtcRingPushRule,
savePusherIds, savePusherIds,
setPushEnabled, setPushEnabled,
unregisterPusher, unregisterPusher,
urlBase64ToUint8Array, urlBase64ToUint8Array,
} from '../utils/push'; } from '../utils/push';
import { getHomeRoomPath } from '../pages/pathUtils'; import { getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
const noop = (): void => undefined; const noop = (): void => undefined;
@ -190,6 +192,7 @@ export function useRegisterPushNotifications(): () => Promise<void> {
savePusherIds(ids); savePusherIds(ids);
setPushEnabled(true); setPushEnabled(true);
await ensureRtcRingPushRule(mx);
return; return;
} }
@ -206,6 +209,7 @@ export function useRegisterPushNotifications(): () => Promise<void> {
savePusherIds(ids); savePusherIds(ids);
setPushEnabled(true); setPushEnabled(true);
await ensureRtcRingPushRule(mx);
}, [mx, clientConfig]); }, [mx, clientConfig]);
} }
@ -246,6 +250,12 @@ export function useDisablePushNotifications(): () => Promise<void> {
} }
} }
// 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(); clearPusherIds();
setPushEnabled(false); setPushEnabled(false);
}, [mx]); }, [mx]);
@ -269,9 +279,24 @@ export function usePushNotificationsLifecycle(): void {
register().catch(noop); register().catch(noop);
}, [register]); }, [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(() => { useEffect(() => {
const onNavigate = (ev: Event) => { 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)); if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId));
}; };
const onSubChange = () => { const onSubChange = () => {

View file

@ -1,31 +1,23 @@
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/vojo.svg'; import LogoSVG from '../../../../public/res/svg/vojo.svg';
import LogoUnreadSVG from '../../../../public/res/svg/vojo-unread.svg'; import LogoUnreadSVG from '../../../../public/res/svg/vojo-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/vojo-highlight.svg'; import LogoHighlightSVG from '../../../../public/res/svg/vojo-highlight.svg';
import NotificationSound from '../../../../public/sound/notification.ogg'; import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg'; import { setFavicon } from '../../utils/dom';
import { notificationPermission, setFavicon } from '../../utils/dom';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils';
import { import {
getMemberDisplayName,
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications'; import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
import { PushPermissionPrompt } from '../../components/push-permission-prompt'; import { PushPermissionPrompt } from '../../components/push-permission-prompt';
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton'; import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
@ -79,102 +71,20 @@ function FaviconUpdater() {
return null; return null;
} }
function InviteNotifications() { // OS notifications are exclusively owned by the Service Worker push pipeline
const audioRef = useRef<HTMLAudioElement>(null); // (see src/sw.ts). This component only owns the in-tab sound + dedup cache —
const invites = useAtomValue(allInvitesAtom); // the timeline listener fires on any notifiable event and plays a sound when
const perviousInviteLen = usePreviousValue(invites.length, 0); // the tab is focused / the event isn't for the currently-open room. Push and
const mx = useMatrixClient(); // in-app are one channel now; a push-less build simply gets no OS banner.
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
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={InviteSound} type="audio/ogg" />
</audio>
);
}
function MessageNotifications() { function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const notifRef = useRef<Notification>();
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map()); const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected(); const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom(); 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 playSound = useCallback(() => {
const audioElement = audioRef.current; const audioElement = audioRef.current;
audioElement?.play(); audioElement?.play();
@ -215,20 +125,6 @@ function MessageNotifications() {
return; 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) { if (notificationSound) {
playSound(); playSound();
} }
@ -237,16 +133,7 @@ function MessageNotifications() {
return () => { return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
}; };
}, [ }, [mx, notificationSound, notificationSelected, playSound, selectedRoomId]);
mx,
notificationSound,
notificationSelected,
showNotifications,
playSound,
notify,
selectedRoomId,
useAuthentication,
]);
return ( return (
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
@ -276,7 +163,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<SystemEmojiFeature /> <SystemEmojiFeature />
<PageZoomFeature /> <PageZoomFeature />
<FaviconUpdater /> <FaviconUpdater />
<InviteNotifications />
<MessageNotifications /> <MessageNotifications />
<PushNotificationsFeature /> <PushNotificationsFeature />
<PushPermissionPrompt /> <PushPermissionPrompt />

View file

@ -40,7 +40,6 @@ export interface Settings {
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
hour24Clock: boolean; hour24Clock: boolean;
@ -74,7 +73,6 @@ const defaultSettings: Settings = {
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false, legacyUsernameColor: false,
showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,
hour24Clock: false, hour24Clock: false,

View file

@ -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'; import { isNativePlatform } from './capacitor';
export type PushPlatform = 'web' | 'fcm'; export type PushPlatform = 'web' | 'fcm';
@ -66,6 +73,59 @@ export type PusherIds = {
appId: string; 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<void> {
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<void> {
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( export async function registerWebPusher(
mx: MatrixClient, mx: MatrixClient,
subscription: PushSubscription, subscription: PushSubscription,

View file

@ -46,8 +46,10 @@ if ('serviceWorker' in navigator) {
} }
if (type === 'notificationClick') { if (type === 'notificationClick') {
const { roomId } = ev.data ?? {}; const { roomId, isInvite } = ev.data ?? {};
window.dispatchEvent(new CustomEvent('vojo:pushNavigate', { detail: { roomId } })); window.dispatchEvent(
new CustomEvent('vojo:pushNavigate', { detail: { roomId, isInvite } })
);
return; return;
} }

212
src/sw.ts
View file

@ -159,7 +159,16 @@ self.addEventListener('fetch', (event: FetchEvent) => {
// --- Push Notifications --- // --- 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 = { type PushPayload = {
event_id?: string;
room_id?: string;
unread?: number;
prio?: 'high' | 'low';
notification?: { notification?: {
event_id?: string; event_id?: string;
room_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<boolean> { async function hasVisibleClient(): Promise<boolean> {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 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<SessionInfo | undefined> { async function anySession(): Promise<SessionInfo | undefined> {
@ -191,10 +208,36 @@ async function anySession(): Promise<SessionInfo | undefined> {
// Fallback strings for when we can't fetch event content (offline / encrypted / // 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 — // 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. // 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<string, PushFallback> = { const PUSH_FALLBACKS: Record<string, PushFallback> = {
en: { brand: 'Vojo', newMessage: 'New message', encrypted: 'New encrypted message' }, en: {
ru: { brand: 'Vojo', newMessage: 'Новое сообщение', encrypted: 'Новое зашифрованное сообщение' }, 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 { function pushFallback(): PushFallback {
@ -208,7 +251,7 @@ async function fetchEventDetails(
session: SessionInfo, session: SessionInfo,
roomId: string, roomId: string,
eventId: string eventId: string
): Promise<{ title: string; body: string }> { ): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> {
const headers = { Authorization: `Bearer ${session.accessToken}` }; const headers = { Authorization: `Bearer ${session.accessToken}` };
const [evRes, nameRes] = await Promise.all([ const [evRes, nameRes] = await Promise.all([
fetch( fetch(
@ -224,22 +267,87 @@ async function fetchEventDetails(
const fb = pushFallback(); const fb = pushFallback();
let title = fb.brand; let title = fb.brand;
let body = fb.newMessage; 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) { if (evRes.ok) {
const event = await evRes.json(); 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; body = fb.encrypted;
} else if (typeof event?.content?.body === 'string') { } else if (typeof event?.content?.body === 'string') {
body = event.content.body.slice(0, 200); body = event.content.body.slice(0, 200);
} }
} }
if (nameRes?.ok) { if (isInvite && inviterMxid) {
const json = await nameRes.json(); // Separate fetch (not batched with the initial Promise.all) because we
if (typeof json?.name === 'string') title = json.name; // 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) => { self.addEventListener('push', (event: PushEvent) => {
@ -247,9 +355,6 @@ self.addEventListener('push', (event: PushEvent) => {
event.waitUntil( event.waitUntil(
(async () => { (async () => {
// Foreground dedup: in-app MessageNotifications handles visible clients
if (await hasVisibleClient()) return;
let payload: PushPayload = {}; let payload: PushPayload = {};
try { try {
if (event.data) payload = event.data.json(); if (event.data) payload = event.data.json();
@ -258,8 +363,8 @@ self.addEventListener('push', (event: PushEvent) => {
} }
const notif = payload.notification; const notif = payload.notification;
const roomId = notif?.room_id; const roomId = notif?.room_id ?? payload.room_id;
const eventId = notif?.event_id; const eventId = notif?.event_id ?? payload.event_id;
// Defensive: if Sygnal sends a notification without event_id (e.g. unread-count-only // 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 // 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(); const fb = pushFallback();
let title = notif?.room_name ?? fb.brand; let title = notif?.room_name ?? fb.brand;
let body = notif?.content?.body ?? fb.newMessage; 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(); const session = await anySession();
if (session) { if (session) {
try { try {
const details = await fetchEventDetails(session, roomId, eventId); const details = await fetchEventDetails(session, roomId, eventId);
title = details.title; title = details.title;
body = details.body; body = details.body;
isCall = details.isCall;
isInvite = details.isInvite;
} catch { } 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, { await self.registration.showNotification(title, {
body, body,
icon: '/res/android/android-chrome-192x192.png', icon: '/res/android/android-chrome-192x192.png',
@ -296,7 +455,12 @@ self.addEventListener('push', (event: PushEvent) => {
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
event.notification.close(); 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( event.waitUntil(
(async () => { (async () => {
@ -304,10 +468,20 @@ self.addEventListener('notificationclick', (event) => {
if (windows.length > 0) { if (windows.length > 0) {
const target = windows[0]; const target = windows[0];
await target.focus(); 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; 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); await self.clients.openWindow(path);
})() })()
); );