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:
parent
6ced8246e6
commit
6a690d4ecc
9 changed files with 292 additions and 196 deletions
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "Воспроизводить звук при получении нового сообщения.",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
212
src/sw.ts
|
|
@ -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);
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue