refactor(notifications): drop bundled Cinny message chime + DM-call ring audio; alerts now system-owned (native ringtone, web push)

This commit is contained in:
heaven 2026-06-29 19:57:28 +03:00
parent 628618aafe
commit 80c64e0760
12 changed files with 50 additions and 176 deletions

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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>

View file

@ -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')}

View file

@ -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}
/>
);
})}
</>
);
}

View file

@ -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 />

View file

@ -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;
};

View file

@ -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) {