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
6a9096881a
commit
bdd7e05c9a
9 changed files with 292 additions and 196 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "Воспроизводить звук при получении нового сообщения.",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box direction="Column" gap="100">
|
||||
<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
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -8,16 +8,18 @@ import {
|
|||
PUSH_ENABLED_KEY,
|
||||
PUSH_STATE_CHANGE_EVENT,
|
||||
clearPusherIds,
|
||||
ensureRtcRingPushRule,
|
||||
isPushEnabled,
|
||||
loadPusherIds,
|
||||
registerFcmPusher,
|
||||
registerWebPusher,
|
||||
removeRtcRingPushRule,
|
||||
savePusherIds,
|
||||
setPushEnabled,
|
||||
unregisterPusher,
|
||||
urlBase64ToUint8Array,
|
||||
} from '../utils/push';
|
||||
import { getHomeRoomPath } from '../pages/pathUtils';
|
||||
import { getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
|
|
@ -190,6 +192,7 @@ export function useRegisterPushNotifications(): () => Promise<void> {
|
|||
|
||||
savePusherIds(ids);
|
||||
setPushEnabled(true);
|
||||
await ensureRtcRingPushRule(mx);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +209,7 @@ export function useRegisterPushNotifications(): () => Promise<void> {
|
|||
|
||||
savePusherIds(ids);
|
||||
setPushEnabled(true);
|
||||
await ensureRtcRingPushRule(mx);
|
||||
}, [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();
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -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<HTMLAudioElement>(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
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<source src={InviteSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<HTMLAudioElement>(null);
|
||||
const notifRef = useRef<Notification>();
|
||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(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) {
|
|||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<PushNotificationsFeature />
|
||||
<PushPermissionPrompt />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
mx: MatrixClient,
|
||||
subscription: PushSubscription,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
212
src/sw.ts
212
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<boolean> {
|
||||
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> {
|
||||
|
|
@ -191,10 +208,36 @@ async function anySession(): Promise<SessionInfo | undefined> {
|
|||
// Fallback strings for when we can't fetch event content (offline / encrypted /
|
||||
// no session). The main app runs i18next, but the SW is a separate context —
|
||||
// we read navigator.language here and ship a small map for the locales we support.
|
||||
type PushFallback = { brand: string; newMessage: string; encrypted: string };
|
||||
type PushFallback = {
|
||||
brand: string;
|
||||
newMessage: string;
|
||||
encrypted: string;
|
||||
incomingCall: string;
|
||||
openToAnswer: string;
|
||||
invitation: string;
|
||||
invitedYou: (roomName?: string) => string;
|
||||
};
|
||||
const PUSH_FALLBACKS: Record<string, PushFallback> = {
|
||||
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);
|
||||
})()
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue