feat: mute notifications for single folders or whole accounts #78

This commit is contained in:
Linus Rath
2026-10-19 13:11:12 +02:00
parent 083ec958a0
commit 5f5ffdaddb
11 changed files with 430 additions and 12 deletions
+76 -3
View File
@@ -9,7 +9,7 @@ import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -40,6 +40,11 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
type NotificationMutes,
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -157,6 +162,8 @@ interface SidebarRowProps {
showTotal: boolean;
hasChildren: boolean;
isExpanded: boolean;
/** New mail here does not notify (#78). */
muted?: boolean;
onPress: () => void;
onLongPress?: () => void;
onToggleExpand: () => void;
@@ -165,7 +172,7 @@ interface SidebarRowProps {
function SidebarRow({
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread,
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -215,6 +222,11 @@ function SidebarRow({
>
{label}
</Text>
{muted && (
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
<BellOff size={12} color={c.textMuted} />
</View>
)}
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
</Pressable>
);
@@ -334,6 +346,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -459,6 +473,41 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
id: mb.originalId ?? mb.id,
accountId: mb.isShared ? mb.accountId : undefined,
});
// Notification mutes (#78). Muting only takes effect through the server's
// emailPush filter, so the actions show only where that exists. The
// settings are keyed by the server's ids: a folder of the own account may
// carry no accountId, so the client's stands in.
let primaryJmapAccountId: string | null = null;
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
const muteActionsForAccount = (accountId: string): SheetAction[] => {
const muted = isAccountMuted(notificationMutes, accountId);
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
const actions: SheetAction[] = [{
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
}];
if (overrides.muted.length + overrides.unmuted.length > 0) {
actions.push({
key: 'reset-mutes',
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
icon: RotateCcw,
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
});
}
return actions;
};
const openOwnAccountSheet = () => {
if (!canMute) return;
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
};
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
setBusy(true);
try {
@@ -499,6 +548,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
if (subtree.length > 1) {
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
}
if (canMute) {
const accountId = muteAccountOf(mb.accountId);
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
actions.push({
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
});
}
if (!mb.isShared) {
actions.push({
key: 'all',
@@ -622,6 +683,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
},
];
if (canMute) actions.push(...muteActionsForAccount(accountId));
if (canCreate) {
actions.push({
key: 'new',
@@ -1036,7 +1098,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
<Pressable
style={styles.sectionHeader}
onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}
// The own account's notification mutes (#78), like the webmail's
// menu on this header.
onLongPress={canMute ? openOwnAccountSheet : undefined}
delayLongPress={350}
>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -1090,6 +1159,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
muted={canMute && (node.isAccountNode
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
@@ -1458,6 +1530,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
fontSize: 13,
lineHeight: 18,
+2
View File
@@ -29,6 +29,7 @@ const G = {
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
@@ -190,6 +191,7 @@ export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
@@ -27,6 +27,7 @@ import {
PushSetupError,
readPushAccountIds,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
setupPushNotifications,
getStoredPushTransport,
@@ -42,6 +43,8 @@ import {
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
import { canBadgeAppIcon } from '../../lib/app-badge';
import { isAccountMuted, setAccountMuted } from '../../lib/notification-mutes';
import { jmapClient } from '../../api/jmap-client';
type PushStatus =
| { kind: 'idle' }
@@ -78,6 +81,14 @@ export function NotificationSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// Muting works through the server's emailPush filter (#78): offered only
// where it exists, for the account this screen shows. Single folders are
// muted from the sidebar.
let ownJmapAccountId: string | null = null;
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -467,6 +478,22 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
description={t(
'settings.notifications.email.mute_account_desc',
'No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.',
)}
>
<ToggleSwitch
checked={accountMuted}
onChange={(v) => {
if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v));
}}
/>
</SettingItem>
)}
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}