diff --git a/.changeset/fix-notifications-settings.md b/.changeset/fix-notifications-settings.md
new file mode 100644
index 0000000000..ba9c1a878f
--- /dev/null
+++ b/.changeset/fix-notifications-settings.md
@@ -0,0 +1,27 @@
+---
+sable: minor
+---
+
+Overhaul notification settings UX and fix several notification bugs.
+
+**Bug fixes**
+
+- Fix push notifications always being silent: `resolveSilent` in the service worker now always returns `false`, leaving sound/vibration decisions entirely to the OS and Sygnal push gateway. The in-app sound setting no longer affects push sound behaviour.
+- Fix in-app notification banner not appearing on desktop. The banner was erroneously gated behind a `mobileOrTablet()` check; it now fires on all platforms when the user has In-App Notifications enabled.
+- Fix in-app banner rendered as `position: fixed` in `ClientLayout` so it spans the full viewport and doesn't displace page content.
+- Fix in-app banner showing "sent an encrypted message" for encrypted rooms. Events reaching the banner are already decrypted by the SDK; `isEncryptedRoom: false` is now passed so the actual message body is always shown when message content preview is enabled.
+- Fix desktop OS notifications not firing when the browser window is minimised or the tab is hidden. The OS notification block now runs before the visibility guard, which only gates the in-app banner and audio.
+- Fix iOS lock screen media player appearing after an in-app notification sound plays. `mediaSession.playbackState` is cleared after a short delay following `play()`. If in-app media (e.g. a video in a room) has since registered its own metadata the media session is left untouched.
+
+**Settings page improvements**
+
+- Move badge display settings (Show Message Counts, Direct Messages Only, Always Count Mentions) from Appearance into the Notifications page, where they belong.
+- Rename "Mobile In-App Notifications" to "In-App Notifications" and show the toggle on all platforms, not just mobile.
+- Rename "Notification Sound" setting to "In-App Notification Sound" to clarify it only controls the in-page audio, not push sound.
+- Fix "System Notifications" description removing the incorrect claim that mobile uses the in-app banner instead.
+- Add a notification levels info button (ⓘ) to the All Messages, Special Messages, and Keyword Messages section headings explaining Disable / Notify Silent / Notify Loud.
+- Add descriptive text under each notification section heading.
+- Collapse the two `@room` push rules into a single "Mention @room" control. On servers with MSC3952 support (`m.intentional_mentions`) it targets `IsRoomMention`; on older servers it falls back to `AtRoomNotification`. Synapse mirrors the two rules so displaying both produced an apparent sync loop where setting one immediately reset the other.
+- Add a "Follows your global notification rules" subtitle to the "Default" option in the per-room notification switcher.
+- Default "In-App Notifications" to off for all platforms. Users can opt in per-device in Notifications settings.
+- Default "Show Message Counts" (badge numbers) to off — badges show a number only for direct mentions by default, matching Discord-style behaviour.
diff --git a/src/app/components/RoomNotificationSwitcher.tsx b/src/app/components/RoomNotificationSwitcher.tsx
index f62c41a4a6..2687585000 100644
--- a/src/app/components/RoomNotificationSwitcher.tsx
+++ b/src/app/components/RoomNotificationSwitcher.tsx
@@ -104,9 +104,16 @@ export function RoomNotificationModeSwitcher({
/>
}
>
-
- {mode === value ? {modeToStr[mode]} : modeToStr[mode]}
-
+
+
+ {mode === value ? {modeToStr[mode]} : modeToStr[mode]}
+
+ {mode === RoomNotificationMode.Unset && (
+
+ Follows your global notification rules
+
+ )}
+
))}
diff --git a/src/app/components/notification-banner/NotificationBanner.css.ts b/src/app/components/notification-banner/NotificationBanner.css.ts
index 1ddfacda3b..4a29d24d78 100644
--- a/src/app/components/notification-banner/NotificationBanner.css.ts
+++ b/src/app/components/notification-banner/NotificationBanner.css.ts
@@ -23,12 +23,11 @@ const slideOut = keyframes({
},
});
-// Floats at the top of the chat area, starting after the icon sidebar.
+// Floats at the top of the viewport, spanning full width on all platforms.
export const BannerContainer = style({
position: 'fixed',
top: 0,
- // 66px = sidebar icon strip width — overridden to 0 on mobile below
- left: toRem(66),
+ left: 0,
right: 0,
zIndex: 9999,
display: 'flex',
@@ -37,13 +36,6 @@ export const BannerContainer = style({
padding: config.space.S400,
pointerEvents: 'none',
alignItems: 'stretch',
-
- '@media': {
- // On narrow screens the sidebar collapses, so span the full width.
- '(max-width: 768px)': {
- left: 0,
- },
- },
});
export const Banner = style({
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index c960d431bc..b7aef9107b 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
+import { useAtomValue } from 'jotai';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
@@ -13,7 +14,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRoomMembers } from '$hooks/useRoomMembers';
import { CallView } from '$features/call/CallView';
import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer';
-import { useAtomValue } from 'jotai';
import { callChatAtom } from '$state/callEmbed';
import { RoomViewHeader } from './RoomViewHeader';
import { MembersDrawer } from './MembersDrawer';
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index baf737628e..6693197ab2 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -388,9 +388,6 @@ function PageZoomInput() {
}
export function Appearance() {
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
- const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts');
- const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly');
- const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts');
return (
@@ -410,31 +407,6 @@ export function Appearance() {
} />
-
-
- }
- />
-
-
-
- }
- />
-
-
- }
- />
-
);
diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx
index e750cc7d05..61ace27c45 100644
--- a/src/app/features/settings/notifications/AllMessages.tsx
+++ b/src/app/features/settings/notifications/AllMessages.tsx
@@ -20,6 +20,7 @@ import {
import { useMatrixClient } from '$hooks/useMatrixClient';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
+import { NotificationLevelsHint } from './NotificationLevelsHint';
const getAllMessageDefaultRule = (
ruleId: RuleId,
@@ -89,13 +90,17 @@ export function AllMessagesNotifications() {
All Messages
-
+
+
Badge:
1
+
+ Default notification level for all messages in rooms where no per-room override is set.
+
Keyword Messages
-
+
+
Badge:
1
+
+ Custom keywords that trigger notifications when matched in a message body.
+
();
+
+ const handleOpen: MouseEventHandler = (evt) => {
+ setAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const itemPadding = { padding: config.space.S200, paddingTop: 0 };
+
+ return (
+ setAnchor(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx
index 92d559d3dd..ac8d736f3d 100644
--- a/src/app/features/settings/notifications/SpecialMessages.tsx
+++ b/src/app/features/settings/notifications/SpecialMessages.tsx
@@ -17,6 +17,7 @@ import {
} from '$hooks/useNotificationMode';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
+import { NotificationLevelsHint } from './NotificationLevelsHint';
const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
@@ -120,18 +121,23 @@ export function SpecialMessagesNotifications() {
() => pushRulesEvt?.getContent() ?? { global: {} },
[pushRulesEvt]
);
+ const intentionalMentions = mx.supportsIntentionalMentions();
return (
Special Messages
-
+
+
Badge:
1
+
+ Overrides the All Messages level for messages that mention you or match a keyword.
+
- }
- />
-
-
-
+ intentionalMentions ? (
+
+ ) : (
+
+ )
}
/>
diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx
index 6394f423c4..659d3945da 100644
--- a/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/src/app/features/settings/notifications/SystemNotification.tsx
@@ -186,24 +186,39 @@ export function SystemNotification() {
settingsAtom,
'clearNotificationsOnRead'
);
+ const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts');
+ const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly');
+ const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts');
+
+ // Describe what the current badge combo actually does so users aren't left guessing.
+ const badgeBehaviourSummary = (): string => {
+ if (!showUnreadCounts && !showPingCounts) {
+ return 'Badges show a plain dot for any unread activity — no numbers displayed.';
+ }
+ if (!showUnreadCounts && showPingCounts) {
+ return 'Badges show a number only when you are directly mentioned; all other unread activity shows a plain dot.';
+ }
+ if (showUnreadCounts && badgeCountDMsOnly) {
+ return 'Only Direct Message badges show a number count. Rooms and spaces show a plain dot instead.';
+ }
+ return 'All rooms and DMs show a number count for every unread message.';
+ };
return (
System & Notifications
- {mobileOrTablet() && (
-
- }
- />
-
- )}
+
+ }
+ />
+
{mobileOrTablet() && (
}
/>
)}
+
+ }
+ />
+
}
/>
+
+
+
+
+
+
+
+
+ Badges
+
+ {badgeBehaviourSummary()}
+
}
+ title="Show Message Counts"
+ description="Show a number on room, space, and DM badges for every unread message."
+ after={
+
+ }
/>
-
+
+ }
+ />
-
-
+ }
+ />
);
diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx
index 4bbd4068fc..9cb4a3f416 100644
--- a/src/app/pages/client/ClientLayout.tsx
+++ b/src/app/pages/client/ClientLayout.tsx
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { Box } from 'folds';
+import { NotificationBanner } from '$components/notification-banner';
type ClientLayoutProps = {
nav: ReactNode;
@@ -8,6 +9,7 @@ type ClientLayoutProps = {
export function ClientLayout({ nav, children }: ClientLayoutProps) {
return (
+
{nav}
{children}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 3116c4c566..9823eaebd5 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -7,7 +7,7 @@ import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-ht
import { sanitizeCustomHtml } from '$utils/sanitize';
import { roomToUnreadAtom } from '$state/room/roomToUnread';
import LogoSVG from '$public/res/svg/cinny.svg';
-import { NotificationBanner } from '$components/notification-banner';
+
import LogoUnreadSVG from '$public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '$public/res/svg/cinny-highlight.svg';
import NotificationSound from '$public/sound/notification.ogg';
@@ -41,6 +41,19 @@ import { mobileOrTablet } from '$utils/user-agent';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
+function clearMediaSessionQuickly(): void {
+ if (!('mediaSession' in navigator)) return;
+ // iOS registers the lock screen media player as a side-effect of
+ // HTMLAudioElement.play(). We delay slightly so iOS has finished updating
+ // the media session before we clear it — clearing too early is a no-op.
+ // We only clear if no real in-app media (video/audio in a room) has since
+ // registered meaningful metadata; if it has, leave it alone.
+ setTimeout(() => {
+ if (navigator.mediaSession.metadata !== null) return;
+ navigator.mediaSession.playbackState = 'none';
+ }, 500);
+}
+
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@@ -158,6 +171,7 @@ function InviteNotifications() {
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
+ clearMediaSessionQuickly();
}, []);
useEffect(() => {
@@ -230,6 +244,7 @@ function MessageNotifications() {
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
+ clearMediaSessionQuickly();
}, []);
useEffect(() => {
@@ -292,9 +307,6 @@ function MessageNotifications() {
// If neither a loud nor a highlight rule matches, and it's not a DM, nothing to show.
if (!isHighlightByRule && !loudByRule && !isDM) return;
- // Page hidden: SW (push) handles the OS notification. Nothing to do in-app.
- if (document.visibilityState !== 'visible') return;
-
// Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
notifiedEventsRef.current.add(eventId);
if (notifiedEventsRef.current.size > 200) {
@@ -302,11 +314,45 @@ function MessageNotifications() {
if (first) notifiedEventsRef.current.delete(first);
}
- // Page is visible — show the themed in-app notification banner for any
- // highlighted message (mention / keyword) or loud push rule.
- // okay fast patch because that showNotifications setting atom is not getting set correctly or something
- if (mobileOrTablet() && showNotifications && (isHighlightByRule || loudByRule || isDM)) {
+ // On desktop: fire an OS notification so the user is alerted even when the
+ // browser window is minimised or the tab is not active.
+ if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
+ const avatarMxc =
+ room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
+ const osPayload = buildRoomMessageNotification({
+ roomName: room.name ?? 'Unknown',
+ roomAvatar: avatarMxc
+ ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
+ : undefined,
+ username:
+ getMemberDisplayName(room, sender, nicknamesRef.current) ??
+ getMxIdLocalPart(sender) ??
+ sender,
+ previewText: resolveNotificationPreviewText({
+ content: mEvent.getContent(),
+ eventType: mEvent.getType(),
+ isEncryptedRoom,
+ showMessageContent,
+ showEncryptedMessageContent,
+ }),
+ silent: !notificationSound || !loudByRule,
+ eventId,
+ });
+ const noti = new window.Notification(osPayload.title, osPayload.options);
+ const { roomId } = room;
+ noti.onclick = () => {
+ window.focus();
+ setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined });
+ noti.close();
+ };
+ }
+
+ // Everything below requires the page to be visible (in-app UI + audio).
+ if (document.visibilityState !== 'visible') return;
+
+ // Page is visible — show the themed in-app notification banner.
+ if (showNotifications && (isHighlightByRule || loudByRule || isDM)) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const roomAvatar = avatarMxc
@@ -317,21 +363,22 @@ function MessageNotifications() {
getMxIdLocalPart(sender) ??
sender;
const content = mEvent.getContent();
+ // Events reaching here are already decrypted (m.room.encrypted is skipped
+ // above). Pass isEncryptedRoom:false so the preview always shows the actual
+ // message body when showMessageContent is enabled.
const previewText = resolveNotificationPreviewText({
content: mEvent.getContent(),
eventType: mEvent.getType(),
- isEncryptedRoom,
+ isEncryptedRoom: false,
showMessageContent,
showEncryptedMessageContent,
});
// Build a rich ReactNode body using the same HTML parser as the room
- // timeline — this gives full mxc image transforms, mention pills,
- // linkify, spoilers, code blocks, etc.
+ // timeline — mxc images, mention pills, linkify, spoilers, code blocks.
let bodyNode: ReactNode;
if (
showMessageContent &&
- (!isEncryptedRoom || showEncryptedMessageContent) &&
content.format === 'org.matrix.custom.html' &&
content.formatted_body
) {
@@ -348,7 +395,7 @@ function MessageNotifications() {
roomAvatar,
username: resolvedSenderName,
previewText,
- silent: !notificationSound,
+ silent: !notificationSound || !loudByRule,
eventId,
});
const { roomId } = room;
@@ -372,45 +419,8 @@ function MessageNotifications() {
});
}
- // On desktop: also fire an OS notification so the user is alerted even
- // if the browser window is minimised (respects System Notifications toggle).
- if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
- const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
- const avatarMxc =
- room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
- const osPayload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
- roomAvatar: avatarMxc
- ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
- : undefined,
- username:
- getMemberDisplayName(room, sender, nicknamesRef.current) ??
- getMxIdLocalPart(sender) ??
- sender,
- previewText: resolveNotificationPreviewText({
- content: mEvent.getContent(),
- eventType: mEvent.getType(),
- isEncryptedRoom,
- showMessageContent,
- showEncryptedMessageContent,
- }),
- // Play sound only if the push rule requests it and the user has sounds enabled.
- silent: !notificationSound || !loudByRule,
- eventId,
- });
- const noti = new window.Notification(osPayload.title, osPayload.options);
- const { roomId } = room;
- noti.onclick = () => {
- window.focus();
- setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined });
- noti.close();
- };
- }
-
// In-app audio: play whenever notification sounds are enabled.
- // Not gated on loudByRule — that only controls the OS-level silent flag.
- // Audio API requires a visible, focused document; skip when hidden.
- if (document.visibilityState === 'visible' && notificationSound) {
+ if (notificationSound) {
playSound();
}
};
@@ -498,8 +508,6 @@ export function HandleNotificationClick() {
}
function SyncNotificationSettingsWithServiceWorker() {
- const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
const [showEncryptedMessageContent] = useSetting(
settingsAtom,
@@ -525,9 +533,11 @@ function SyncNotificationSettingsWithServiceWorker() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
+ // notificationSoundEnabled is intentionally excluded: push notification sound
+ // is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
+ // The in-app sound setting only controls the in-page