Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/fix-notifications-settings.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 10 additions & 3 deletions src/app/components/RoomNotificationSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,16 @@ export function RoomNotificationModeSwitcher({
/>
}
>
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
<Box direction="Column" gap="100">
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
{mode === RoomNotificationMode.Unset && (
<Text size="T200" priority="300">
Follows your global notification rules
</Text>
)}
</Box>
</MenuItem>
))}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/Room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
28 changes: 0 additions & 28 deletions src/app/features/settings/cosmetics/Themes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box direction="Column" gap="700">
Expand All @@ -410,31 +407,6 @@ export function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Unread Counts"
description="Display the number of unread messages on room and sidebar badges."
after={
<Switch variant="Primary" value={showUnreadCounts} onChange={setShowUnreadCounts} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Badge Counts for DMs Only"
description="Only show unread counts on Direct Message badges. Non-DM rooms and spaces show a plain dot instead."
after={
<Switch variant="Primary" value={badgeCountDMsOnly} onChange={setBadgeCountDMsOnly} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Unread Ping Counts"
description="When enabled overrides Show Unread Counts to still display counts."
after={<Switch variant="Primary" value={showPingCounts} onChange={setShowPingCounts} />}
/>
</SequenceCard>
</Box>
</Box>
);
Expand Down
7 changes: 6 additions & 1 deletion src/app/features/settings/notifications/AllMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,13 +90,17 @@ export function AllMessagesNotifications() {
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">All Messages</Text>
<Box gap="100">
<Box gap="100" alignItems="Center">
<NotificationLevelsHint />
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Secondary" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<Text size="T300" priority="300">
Default notification level for all messages in rooms where no per-room override is set.
</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { NotificationLevelsHint } from './NotificationLevelsHint';

const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
Expand Down Expand Up @@ -163,13 +164,17 @@ export function KeywordMessagesNotifications() {
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Keyword Messages</Text>
<Box gap="100">
<Box gap="100" alignItems="Center">
<NotificationLevelsHint />
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<Text size="T300" priority="300">
Custom keywords that trigger notifications when matched in a message body.
</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { MouseEventHandler, useState } from 'react';
import { Box, config, Header, Icon, IconButton, Icons, Menu, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '$utils/keyboard';

export function NotificationLevelsHint() {
const [anchor, setAnchor] = useState<RectCords>();

const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};

const itemPadding = { padding: config.space.S200, paddingTop: 0 };

return (
<PopOut
anchor={anchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: '280px' }}>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Notification Levels</Text>
</Header>
<Box direction="Column" style={itemPadding} gap="300" tabIndex={0}>
<Box direction="Column" gap="100">
<Text size="L400">Disable</Text>
<Text size="T300" priority="300">
The rule is muted. No notifications of any kind.
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Notify Silent</Text>
<Text size="T300" priority="300">
Visual notification (badge and banner) when a relevant message arrives. No sound.
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Notify Loud</Text>
<Text size="T300" priority="300">
Visual highlight plus sound. Respects the Notification Sound setting above, and
triggers sound and vibration on mobile push.
</Text>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpen}
type="button"
variant="Secondary"
size="300"
radii="300"
aria-pressed={!!anchor}
>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
</IconButton>
</PopOut>
);
}
44 changes: 21 additions & 23 deletions src/app/features/settings/notifications/SpecialMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,18 +121,23 @@ export function SpecialMessagesNotifications() {
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
[pushRulesEvt]
);
const intentionalMentions = mx.supportsIntentionalMentions();

return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Special Messages</Text>
<Box gap="100">
<Box gap="100" alignItems="Center">
<NotificationLevelsHint />
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<Text size="T300" priority="300">
Overrides the All Messages level for messages that mention you or match a keyword.
</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
Expand Down Expand Up @@ -191,29 +197,21 @@ export function SpecialMessagesNotifications() {
>
<SettingTile
title="Mention @room"
description="Only triggers if the sender has permission to notify the whole room."
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.IsRoomMention}
defaultPushRuleData={DefaultIsRoomMention}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Contains @room"
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.AtRoomNotification}
defaultPushRuleData={DefaultAtRoomNotification}
/>
intentionalMentions ? (
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.IsRoomMention}
defaultPushRuleData={DefaultIsRoomMention}
/>
) : (
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.AtRoomNotification}
defaultPushRuleData={DefaultAtRoomNotification}
/>
)
}
/>
</SequenceCard>
Expand Down
Loading
Loading