refactor(notifications): drop bundled Cinny message chime + DM-call ring audio; alerts now system-owned (native ringtone, web push)
This commit is contained in:
parent
628618aafe
commit
80c64e0760
12 changed files with 50 additions and 176 deletions
|
|
@ -266,9 +266,11 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
|
|||
|
||||
### settings.ts fields & migrations
|
||||
|
||||
Current `Settings` fields: `themeId? ('light-theme'|'dark-theme')`, `useSystemTheme`, `monochromeMode?`, `isMarkdown`, `editorToolbar`, `twitterEmoji`, `pageZoom`, `hideActivity`, `isPeopleDrawer`, `memberSortFilterIndex`, `enterForNewline`, `hideMembershipEvents`, `hideNickAvatarEvents` (default true), `mediaAutoLoad`, `urlPreview`, `encUrlPreview`, `showHiddenEvents`, `isNotificationSounds`, `inviteSpamFilter`, `developerTools`, `migrationsApplied?`.
|
||||
Current `Settings` fields: `themeId? ('light-theme'|'dark-theme')`, `useSystemTheme`, `monochromeMode?`, `isMarkdown`, `editorToolbar`, `twitterEmoji`, `pageZoom`, `hideActivity`, `isPeopleDrawer`, `memberSortFilterIndex`, `enterForNewline`, `hideMembershipEvents`, `hideNickAvatarEvents` (default true), `mediaAutoLoad`, `urlPreview`, `encUrlPreview`, `showHiddenEvents`, `inviteSpamFilter`, `developerTools`, `migrationsApplied?`.
|
||||
|
||||
`getSettings()` runs three one-shot migrations: `dawn-redesign-v1` (pins **existing** users — stored JSON present — to dark; brand-new users keep `useSystemTheme:true`), `dawn-p3c-cleanup` (drops `messageLayout`/`messageSpacing`/`legacyUsernameColor`), `system-time-format-cleanup` (drops `hour24Clock`/`dateFormatString` — time/date now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts`). **Known platform limitation**: Android's manual "24-hour" toggle is invisible to `Intl`; only a native bridge to `DateFormat.is24HourFormat` would respect it.
|
||||
`getSettings()` runs one-shot, stamped migrations: `dawn-redesign-v1` (pins **existing** users — stored JSON present — to dark; brand-new users keep `useSystemTheme:true`), `dawn-p3c-cleanup` (drops `messageLayout`/`messageSpacing`/`legacyUsernameColor`), `system-time-format-cleanup` (drops `hour24Clock`/`dateFormatString` — time/date now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts`), `dawn-orphan-settings-cleanup` (drops `editorToolbar`/`encUrlPreview`/`showHiddenEvents`), `notification-sound-cleanup` (drops `isNotificationSounds` — the bundled in-app alert sounds were removed; alerts are owned by the system: Android native notification channels + web push). **Known platform limitation**: Android's manual "24-hour" toggle is invisible to `Intl`; only a native bridge to `DateFormat.is24HourFormat` would respect it.
|
||||
|
||||
**No bundled alert sounds.** Vojo ships **no** `<audio>`-played notification/ring files (the Cinny `notification.ogg` message chime and the DM-call `ring.ogg`/`ring.mp3` were removed). Alert sound is owned entirely by the system: Android native channels use `RingtoneManager.getDefaultUri()` + system vibration (`VojoFirebaseMessagingService`), web relies on the push/OS notification. Consequence: on **web/desktop and foreground Android**, an incoming call shows the visual `IncomingCallStrip` but is **silent** (no system ring surface there). Element Call's own in-widget sound effects (reactions, join/leave) are third-party and untouched.
|
||||
|
||||
## Theming
|
||||
|
||||
|
|
|
|||
|
|
@ -218,8 +218,6 @@
|
|||
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
|
||||
"system": "System",
|
||||
"enable": "Enable",
|
||||
"notification_sound": "Notification Sound",
|
||||
"notification_sound_desc": "Play sound when a new message arrives.",
|
||||
"invite_spam_filter": "Spam Invites Filter",
|
||||
"invite_spam_filter_desc": "Hide incoming chat invites that look like spam (mass mailings, banned senders, suspicious words). Disable to see every invite as-is.",
|
||||
"push_notifications": "Background Notifications",
|
||||
|
|
@ -324,7 +322,7 @@
|
|||
"privacy_policy_title": "Privacy Policy",
|
||||
"privacy_policy_desc": "How your data is handled.",
|
||||
"privacy_policy_open": "Open",
|
||||
"about_credits": "Vojo is built on open-source software — including matrix-js-sdk (Apache 2.0), Twemoji (CC-BY 4.0) and Material Design sounds (CC-BY 4.0).",
|
||||
"about_credits": "Vojo is built on open-source software — including matrix-js-sdk (Apache 2.0) and Twemoji (CC-BY 4.0).",
|
||||
"default_for_new_chats": "Default for new chats",
|
||||
"default_for_new_chats_desc": "Notification level for new direct messages and rooms.",
|
||||
"notify_on_mention": "Mentions",
|
||||
|
|
|
|||
|
|
@ -218,8 +218,6 @@
|
|||
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
|
||||
"system": "Система",
|
||||
"enable": "Включить",
|
||||
"notification_sound": "Звук уведомлений",
|
||||
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
||||
"invite_spam_filter": "Фильтр спам-приглашений",
|
||||
"invite_spam_filter_desc": "Скрывать входящие приглашения в чаты, похожие на спам (массовые рассылки, заблокированные отправители, подозрительные слова). Выключите, чтобы видеть все приглашения как есть.",
|
||||
"push_notifications": "Фоновые уведомления",
|
||||
|
|
@ -324,7 +322,7 @@
|
|||
"privacy_policy_title": "Политика конфиденциальности",
|
||||
"privacy_policy_desc": "Как обрабатываются ваши данные.",
|
||||
"privacy_policy_open": "Открыть",
|
||||
"about_credits": "Vojo создан на открытом ПО — включая matrix-js-sdk (Apache 2.0), Twemoji (CC-BY 4.0) и звуки Material Design (CC-BY 4.0).",
|
||||
"about_credits": "Vojo создан на открытом ПО — включая matrix-js-sdk (Apache 2.0) и Twemoji (CC-BY 4.0).",
|
||||
"default_for_new_chats": "По умолчанию для новых чатов",
|
||||
"default_for_new_chats_desc": "Уровень уведомлений для новых личных чатов и комнат.",
|
||||
"notify_on_mention": "Упоминания",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -126,9 +126,9 @@ export function About({ requestClose }: AboutProps) {
|
|||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Compact open-source attribution — Twemoji art & Material sounds are
|
||||
CC-BY 4.0 and matrix-js-sdk is Apache 2.0, all of which require
|
||||
attribution, so one quiet line stays in place of the old credits. */}
|
||||
{/* Compact open-source attribution — Twemoji art is CC-BY 4.0 and
|
||||
matrix-js-sdk is Apache 2.0, both of which require attribution, so one
|
||||
quiet line stays in place of the old credits. */}
|
||||
<Text size="T200" priority="400" align="Center" style={{ opacity: 0.55 }}>
|
||||
{t('Settings.about_credits')}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -93,10 +93,6 @@ function PushNotification() {
|
|||
|
||||
export function SystemNotification() {
|
||||
const { t } = useTranslation();
|
||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||
settingsAtom,
|
||||
'isNotificationSounds'
|
||||
);
|
||||
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
|
||||
// Gate the push row at the call site: PushNotification renders null when push
|
||||
// is unavailable, and SettingsSection's `filter(Boolean)` can only drop
|
||||
|
|
@ -107,11 +103,6 @@ export function SystemNotification() {
|
|||
return (
|
||||
<SettingsSection label={t('Settings.system')}>
|
||||
{pushStatus !== 'unavailable' && <PushNotification />}
|
||||
<SettingTile
|
||||
title={t('Settings.notification_sound')}
|
||||
description={t('Settings.notification_sound_desc')}
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
<SettingTile
|
||||
title={t('Settings.invite_spam_filter')}
|
||||
description={t('Settings.invite_spam_filter_desc')}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,44 @@
|
|||
// Top-level renderer for incoming DM call strips + ringtone audio.
|
||||
// Top-level renderer for incoming DM call strips.
|
||||
//
|
||||
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
||||
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
||||
//
|
||||
// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
|
||||
// ring, we render the in-app strip. On Android the native FCM service decides
|
||||
// independently whether to surface a system CallStyle notification; when the
|
||||
// app is foregrounded it suppresses that banner, so strip render itself does
|
||||
// not need to mirror foreground policy in JS.
|
||||
//
|
||||
// Ring audio mirrors foreground policy on Android — gated on `appActive`
|
||||
// so the native CallStyle ringtone owns UX in background and the JS
|
||||
// <audio> doesn't double-ring during the grace window after backgrounding
|
||||
// while the WebView still processes /sync. On web / iOS there is no
|
||||
// native ring surface, so audio plays regardless of visibility.
|
||||
//
|
||||
// Known gap: if the browser blocks `audio.play()` (cold page load, no user
|
||||
// gesture yet), the ring is silent — strip is still visible but user may
|
||||
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications)
|
||||
// is Phase 3 polish.
|
||||
// Visual-only: if JS knows about an incoming ring we render the in-app strip.
|
||||
// The alert *sound* is owned entirely by the system — on Android the native FCM
|
||||
// service raises a CallStyle notification with the default system ringtone +
|
||||
// vibration (see VojoFirebaseMessagingService / the `vojo_calls_v2` channel).
|
||||
// Web / desktop have no system ring surface, so the strip is silent there by
|
||||
// design — Vojo ships no bundled ringtone.
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAppActive } from '../hooks/useAppActive';
|
||||
import { IncomingCallStrip } from '../features/call-status';
|
||||
import { getIncomingCallKey } from '../utils/rtcNotification';
|
||||
import { isAndroidPlatform } from '../utils/capacitor';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import RingSoundMp3 from '../../../public/sound/ring.mp3';
|
||||
|
||||
export function IncomingCallStripRenderer() {
|
||||
const mx = useMatrixClient();
|
||||
const incoming = useAtomValue(incomingCallsAtom);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
// Foreground/background signal — shared `useAppActive` registers a
|
||||
// single Capacitor App.pause/resume listener (Android+iOS) and
|
||||
// App.appStateChange (web). Used only to gate Android ring audio
|
||||
// below; iOS / web never read the value because they have no native
|
||||
// ring surface.
|
||||
const appActive = useAppActive();
|
||||
|
||||
const hasIncoming = incoming.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
// Platform split on the audio gate:
|
||||
// - Android: gate on appActive. When backgrounded the native CallStyle
|
||||
// ringtone (via vojo_calls_v2 channel) takes over, so JS audio must
|
||||
// stop to avoid double-ring.
|
||||
// - web / iOS: no native fallback exists. Gating on visibility here
|
||||
// silenced the only ring source whenever the user switched tabs —
|
||||
// user-reported regression. Keep audio playing regardless of
|
||||
// visibility on non-Android platforms.
|
||||
const platformGatedActive = isAndroidPlatform() ? appActive : true;
|
||||
if (hasIncoming && platformGatedActive) {
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {
|
||||
// autoplay blocked — strip UI still visible
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [hasIncoming, appActive]);
|
||||
if (!hasIncoming) return null;
|
||||
|
||||
const entries = Array.from(incoming.values());
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio ref={audioRef} loop preload="auto" style={{ display: 'none' }}>
|
||||
<source src={RingSoundOgg} type="audio/ogg" />
|
||||
<source src={RingSoundMp3} type="audio/mpeg" />
|
||||
</audio>
|
||||
{hasIncoming &&
|
||||
entries.map((call) => {
|
||||
const room = mx.getRoom(call.roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<IncomingCallStrip
|
||||
key={getIncomingCallKey(call.callId, call.roomId)}
|
||||
call={call}
|
||||
room={room}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{entries.map((call) => {
|
||||
const room = mx.getRoom(call.roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<IncomingCallStrip
|
||||
key={getIncomingCallKey(call.callId, call.roomId)}
|
||||
call={call}
|
||||
room={room}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { roomToUnreadAtom } 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 { setFavicon } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getNotificationType, getUnreadInfo, isNotificationEvent } from '../../utils/room';
|
||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
||||
|
|
@ -69,77 +63,6 @@ function FaviconUpdater() {
|
|||
return null;
|
||||
}
|
||||
|
||||
// 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 unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||
const mx = useMatrixClient();
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
||||
mEvent,
|
||||
room,
|
||||
toStartOfTimeline,
|
||||
removed,
|
||||
data
|
||||
) => {
|
||||
if (mx.getSyncState() !== 'SYNCING') return;
|
||||
if (document.hasFocus() && selectedRoomId === room?.roomId) return;
|
||||
if (
|
||||
!room ||
|
||||
!data.liveEvent ||
|
||||
room.isSpaceRoom() ||
|
||||
!isNotificationEvent(mEvent) ||
|
||||
getNotificationType(mx, room.roomId) === NotificationType.Mute
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = mEvent.getSender();
|
||||
const eventId = mEvent.getId();
|
||||
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
|
||||
const unreadInfo = getUnreadInfo(room);
|
||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
||||
|
||||
if (unreadInfo.total === 0) return;
|
||||
if (
|
||||
cachedUnreadInfo &&
|
||||
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationSound) {
|
||||
playSound();
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, notificationSound, playSound, selectedRoomId]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<source src={NotificationSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
|
@ -165,7 +88,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
<MessageNotifications />
|
||||
<PushNotificationsFeature />
|
||||
<PushPermissionPrompt />
|
||||
<FullScreenIntentPrompt />
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export interface Settings {
|
|||
mediaAutoLoad: boolean;
|
||||
urlPreview: boolean;
|
||||
|
||||
isNotificationSounds: boolean;
|
||||
inviteSpamFilter: boolean;
|
||||
|
||||
developerTools: boolean;
|
||||
|
|
@ -32,6 +31,7 @@ const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
|
|||
const P3C_CLEANUP_KEY = 'dawn-p3c-cleanup';
|
||||
const SYSTEM_TIME_FORMAT_CLEANUP_KEY = 'system-time-format-cleanup';
|
||||
const ORPHAN_SETTINGS_CLEANUP_KEY = 'dawn-orphan-settings-cleanup';
|
||||
const NOTIFICATION_SOUND_CLEANUP_KEY = 'notification-sound-cleanup';
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: undefined,
|
||||
|
|
@ -49,7 +49,6 @@ const defaultSettings: Settings = {
|
|||
mediaAutoLoad: true,
|
||||
urlPreview: true,
|
||||
|
||||
isNotificationSounds: true,
|
||||
inviteSpamFilter: true,
|
||||
|
||||
developerTools: false,
|
||||
|
|
@ -137,6 +136,20 @@ export const getSettings = (): Settings => {
|
|||
setSettings(merged);
|
||||
}
|
||||
|
||||
// Notification-sound sweep: the bundled in-app alert sounds (the Cinny
|
||||
// `notification.ogg` message chime + the DM-call `ring.*`) are gone — alerts
|
||||
// are owned by the system now (Android native channels, web push). Drop the
|
||||
// orphan `isNotificationSounds` toggle from existing users' persisted JSON.
|
||||
if (!merged.migrationsApplied?.[NOTIFICATION_SOUND_CLEANUP_KEY]) {
|
||||
const orphan = merged as unknown as Record<string, unknown>;
|
||||
delete orphan.isNotificationSounds;
|
||||
merged.migrationsApplied = {
|
||||
...(merged.migrationsApplied ?? {}),
|
||||
[NOTIFICATION_SOUND_CLEANUP_KEY]: true,
|
||||
};
|
||||
setSettings(merged);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -759,10 +759,10 @@ self.addEventListener('push', (event: PushEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Foreground dedup: a visible Vojo window already surfaces the message
|
||||
// in-app (timeline + unread favicon), 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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue