From 5f5ffdaddb07f24b60a235fa46d1ec3e6fb1ee32 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:11:12 +0200 Subject: [PATCH] feat: mute notifications for single folders or whole accounts #78 --- App.tsx | 38 +++++++ locales/rn/en.json | 2 + scripts/generate-icons.mjs | 1 + src/components/SidebarDrawer.tsx | 79 ++++++++++++- src/components/icons/index.ts | 2 + .../settings/NotificationSettings.tsx | 27 +++++ src/lib/__tests__/notification-mutes.test.ts | 53 +++++++++ .../__tests__/push-config-mailboxes.test.ts | 70 ++++++++++++ src/lib/notification-mutes.ts | 104 ++++++++++++++++++ src/lib/push-notifications.ts | 59 ++++++++-- src/stores/settings-store.ts | 7 ++ 11 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 src/lib/__tests__/notification-mutes.test.ts create mode 100644 src/lib/notification-mutes.ts diff --git a/App.tsx b/App.tsx index 2cb212d9..b1ab1f7a 100644 --- a/App.tsx +++ b/App.tsx @@ -39,6 +39,9 @@ import { type NotificationTapPayload, } from './src/lib/push-notifications'; import { addUnifiedPushEndpointListener } from './src/lib/unified-push'; + +// Pause between a mute change and the push-filter update it sends (#78). +const MUTES_RESYNC_DELAY_MS = 1_000; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; import ComposeScreen from './src/screens/ComposeScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen'; @@ -684,6 +687,41 @@ export default function App() { }; }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); + // Muting a folder or account changes the server-side push filter (#78), + // which the setup above would only send at the next launch. Send it now, + // after a short pause so a few taps in a row make one update. Compared by + // content, since hydration hands back an equal but new object; the value + // seen first after hydration is the baseline, not a change. + const notificationMutes = useSettingsStore((s) => s.notificationMutes); + const settingsHydrated = useSettingsStore((s) => s.hydrated); + const mutesKey = JSON.stringify(notificationMutes); + const lastMutesKey = React.useRef(null); + React.useEffect(() => { + if (!settingsHydrated) return; + if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) { + lastMutesKey.current = mutesKey; + return; + } + lastMutesKey.current = mutesKey; + if (!isAuthenticated || !client || !emailNotificationsEnabled) return; + let cancelled = false; + const timer = setTimeout(async () => { + const relayBaseUrl = await getStoredRelayBaseUrl(); + if (!relayBaseUrl || cancelled) return; + try { + await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined }); + } catch (error) { + console.warn('[push] mute update failed:', error instanceof Error ? error.message : error); + } + }, MUTES_RESYNC_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + // Only a change of the mutes should send an update; the rest is read live. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mutesKey, settingsHydrated]); + // Live updates (SSE with polling fallback), re-armed on every account // switch and every re-established session — the singleton `client` object // never changes identity, so it cannot be the dependency on its own. diff --git a/locales/rn/en.json b/locales/rn/en.json index 0eb98652..aad32c27 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -996,6 +996,8 @@ "email": { "app_icon_badge": "Unread count on app icon", "app_icon_badge_desc": "Show the inbox unread count as a badge on the app icon.", + "mute_account": "Mute this account", + "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.", "sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings." }, "push": { diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 8be3b870..fe32f5a6 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -37,6 +37,7 @@ const MAP = { ArrowUpNarrowWide: 'sort-ascending', Ban: 'ban', Bell: 'bell', + BellOff: 'bell-off', BellRing: 'bell-ringing', Bold: 'bold', Book: 'book-2', diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index d7342572..f0e929e7 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -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} + {muted && ( + + + + )} ); @@ -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, 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 */} - toggleSection('foldersExpanded', setFoldersExpanded)}> + 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 ? ( ) : ( @@ -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, diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index fba15194..c949964f 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -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']); diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 93c26dd0..aba61598 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -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() { > update('emailNotificationsEnabled', v)} /> + {canMuteAccount && ( + + { + if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v)); + }} + /> + + )} {canBadgeAppIcon() && ( { + it('lets a folder follow its account until it has its own setting', () => { + const accountMuted = setAccountMuted(EMPTY_NOTIFICATION_MUTES, 'a', true); + expect(isMailboxMuted(accountMuted, 'a', 'reports')).toBe(true); + expect(isMailboxMuted(accountMuted, 'b', 'reports')).toBe(false); + + const unmutedFolder = setMailboxMuted(accountMuted, 'a', 'inbox', false); + expect(isMailboxMuted(unmutedFolder, 'a', 'inbox')).toBe(false); + expect(isMailboxMuted(unmutedFolder, 'a', 'reports')).toBe(true); + + // The folder's own setting wins over the account in both directions. + const accountBack = setAccountMuted(unmutedFolder, 'a', false); + const mutedFolder = setMailboxMuted(accountBack, 'a', 'renovate', true); + expect(isMailboxMuted(mutedFolder, 'a', 'renovate')).toBe(true); + expect(isMailboxMuted(mutedFolder, 'a', 'reports')).toBe(false); + }); + + it('drops the folder setting when it is set back to what the account says', () => { + const muted = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'renovate', true); + const unmuted = setMailboxMuted(muted, 'a', 'renovate', false); + expect(unmuted.mailboxes).toEqual({}); + + // So muting the account later reaches this folder too. + expect(isMailboxMuted(setAccountMuted(unmuted, 'a', true), 'a', 'renovate')).toBe(true); + }); + + it('resets the folder settings of one account only', () => { + let mutes = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'x', true); + mutes = setMailboxMuted(mutes, 'ab', 'y', true); + mutes = resetMailboxMutes(mutes, 'a'); + expect(mailboxOverridesForAccount(mutes, 'a')).toEqual({ muted: [], unmuted: [] }); + // An account id that merely starts with "a" keeps its settings. + expect(mailboxOverridesForAccount(mutes, 'ab')).toEqual({ muted: ['y'], unmuted: [] }); + }); + + it('repairs malformed stored values', () => { + expect(normalizeNotificationMutes(null)).toEqual(EMPTY_NOTIFICATION_MUTES); + expect(normalizeNotificationMutes(['a'])).toEqual(EMPTY_NOTIFICATION_MUTES); + expect(normalizeNotificationMutes({ accounts: ['a', 3, ''], mailboxes: { 'a/x': true, 'a/y': 'yes' } })) + .toEqual({ accounts: ['a'], mailboxes: { 'a/x': true } }); + }); +}); diff --git a/src/lib/__tests__/push-config-mailboxes.test.ts b/src/lib/__tests__/push-config-mailboxes.test.ts index 93082ce6..51cfc20c 100644 --- a/src/lib/__tests__/push-config-mailboxes.test.ts +++ b/src/lib/__tests__/push-config-mailboxes.test.ts @@ -19,6 +19,7 @@ vi.mock('../../api/email', () => ({ })); import { getMailboxes, getSharedMailboxes } from '../../api/email'; +import { EMPTY_NOTIFICATION_MUTES, type NotificationMutes } from '../notification-mutes'; import { buildEmailPushConfig } from '../push-notifications'; import { provideLoadedMailboxes } from '../mailbox-source'; import { generateAccountId } from '../account-utils'; @@ -65,3 +66,72 @@ describe('push filter folders', () => { }); }); }); + +// Muted accounts and folders (#78) are left out by the server-side filter. +describe('push filter with muted folders and accounts (#78)', () => { + const folders = [ + { id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }, + { id: 'junk', role: 'junk', accountId: 'jmap-primary' }, + { id: 'renovate', accountId: 'jmap-primary' }, + { id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true }, + { id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true }, + ]; + const mutes = (accounts: string[], mailboxes: Record = {}): NotificationMutes => ({ accounts, mailboxes }); + const junkOnly = (ids: string[]) => ({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ids }] }); + + beforeEach(() => { + provideLoadedMailboxes(async () => folders as never); + }); + + it('changes nothing without mutes', async () => { + const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES); + expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk'])); + expect(config.team.filter).toEqual(junkOnly(['j'])); + }); + + it('adds a muted folder to the excluded mailboxes next to Junk', async () => { + const config = await buildEmailPushConfig(mutes([], { 'jmap-primary/renovate': true, 'jmap-primary/gone': true })); + expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk', 'renovate'])); + // Another account's folders are untouched. + expect(config.team.filter).toEqual(junkOnly(['j'])); + }); + + it('silences a muted account entirely', async () => { + const config = await buildEmailPushConfig(mutes(['team'])); + expect(config.team.filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], + }); + expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk'])); + }); + + it('lets a muted account notify for the folders unmuted again', async () => { + const one = await buildEmailPushConfig(mutes(['jmap-primary'], { 'jmap-primary/renovate': false })); + expect(one['jmap-primary'].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailbox: 'renovate' }], + }); + + const two = await buildEmailPushConfig(mutes(['jmap-primary'], { + 'jmap-primary/renovate': false, + 'jmap-primary/inbox': false, + // Gone from the server: must not end up in the filter. + 'jmap-primary/deleted': false, + // Unmuting Junk does not bring spam back. + 'jmap-primary/junk': false, + })); + expect(two['jmap-primary'].filter).toEqual({ + operator: 'AND', + conditions: [ + { notKeyword: '$junk' }, + { operator: 'OR', conditions: [{ inMailbox: 'inbox' }, { inMailbox: 'renovate' }] }, + ], + }); + }); + + it('fails instead of dropping the mutes when the folders cannot be read', async () => { + provideLoadedMailboxes(async () => null); + vi.mocked(getMailboxes).mockRejectedValueOnce(new Error('JMAP down')); + await expect(buildEmailPushConfig(mutes(['jmap-primary']))).rejects.toThrow(/JMAP down/); + }); +}); diff --git a/src/lib/notification-mutes.ts b/src/lib/notification-mutes.ts new file mode 100644 index 00000000..90580b56 --- /dev/null +++ b/src/lib/notification-mutes.ts @@ -0,0 +1,104 @@ +/** + * Which accounts and folders stay silent when new mail arrives (#78, + * webmail #1169). Port of the webmail's lib/notification-mutes.ts - keep in sync. + * + * An account is either muted or not. A folder can follow its account or carry + * its own setting, and its own setting always wins: mute one folder in a + * normal account, or mute a whole account and unmute the folders that still + * matter. Ids are the server's JMAP account and mailbox ids, which is what the + * push filter needs; shared-account mailboxes must pass their originalId. + */ + +export interface NotificationMutes { + /** JMAP account ids muted as a whole. */ + accounts: string[]; + /** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */ + mailboxes: Record; +} + +export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} }; + +function mailboxKey(accountId: string, mailboxId: string): string { + return `${accountId}/${mailboxId}`; +} + +/** Repairs a value from storage or another build into a usable mutes object. */ +export function normalizeNotificationMutes(value: unknown): NotificationMutes { + if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES; + const raw = value as { accounts?: unknown; mailboxes?: unknown }; + const accounts = Array.isArray(raw.accounts) + ? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '') + : []; + const mailboxes: Record = {}; + if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) { + for (const [key, muted] of Object.entries(raw.mailboxes as Record)) { + if (typeof muted === 'boolean') mailboxes[key] = muted; + } + } + return { accounts, mailboxes }; +} + +export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean { + return mutes.accounts.includes(accountId); +} + +/** The folder's own setting, or undefined when it follows its account. */ +export function mailboxMuteOverride( + mutes: NotificationMutes, + accountId: string, + mailboxId: string, +): boolean | undefined { + return mutes.mailboxes[mailboxKey(accountId, mailboxId)]; +} + +export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean { + return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId); +} + +/** Mailbox ids of one account with their own setting, split by that setting. */ +export function mailboxOverridesForAccount( + mutes: NotificationMutes, + accountId: string, +): { muted: string[]; unmuted: string[] } { + const prefix = mailboxKey(accountId, ''); + const muted: string[] = []; + const unmuted: string[] = []; + for (const [key, value] of Object.entries(mutes.mailboxes)) { + if (!key.startsWith(prefix)) continue; + (value ? muted : unmuted).push(key.slice(prefix.length)); + } + return { muted, unmuted }; +} + +export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes { + const accounts = mutes.accounts.filter((id) => id !== accountId); + if (muted) accounts.push(accountId); + return { ...mutes, accounts }; +} + +/** + * Mutes or unmutes one folder. Setting it back to what the account says drops + * the folder's own setting, so a folder muted and unmuted again follows the + * account later instead of staying pinned to "unmuted". + */ +export function setMailboxMuted( + mutes: NotificationMutes, + accountId: string, + mailboxId: string, + muted: boolean, +): NotificationMutes { + const mailboxes = { ...mutes.mailboxes }; + const key = mailboxKey(accountId, mailboxId); + if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key]; + else mailboxes[key] = muted; + return { ...mutes, mailboxes }; +} + +/** Drops every folder setting of one account so all its folders follow the account again. */ +export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes { + const prefix = mailboxKey(accountId, ''); + const mailboxes = Object.fromEntries( + Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)), + ); + return { ...mutes, mailboxes }; +} diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 5ea47bd0..ddc58c04 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -14,6 +14,13 @@ import { JMAPMethodError, requireMethodResult } from '../api/jmap-result'; import { CAPABILITIES } from '../api/types'; import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox } from '../api/types'; import { generateAccountId } from './account-utils'; +import { + EMPTY_NOTIFICATION_MUTES, + isAccountMuted, + mailboxOverridesForAccount, + type NotificationMutes, +} from './notification-mutes'; +import { useSettingsStore } from '../stores/settings-store'; import { t } from '../stores/locale-store'; import { authorityOfType, @@ -296,32 +303,62 @@ export function serverSupportsEmailPush(): boolean { * a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are * ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades * to keyword-only filtering rather than letting everything through. + * + * Muted accounts and folders (#78) are cut out of the same filter: a muted + * folder joins Junk in the "not only in" list; a muted account only lets + * through mail in the folders the user unmuted again, or nothing at all. */ -export async function buildEmailPushConfig(): Promise> { +export async function buildEmailPushConfig( + mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES, +): Promise> { const primary = jmapClient.accountId; const junkByAccount = new Map([[primary, []]]); + const mailboxIdsByAccount = new Map>(); // The mail store loads the same folders at the same moment (sign-in, // start): reuse its list rather than a second Mailbox/get of our own. const username = jmapClient.username; const serverUrl = jmapClient.serverUrl; const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null; - const all = loaded ?? [ - ...(await getMailboxes().catch(() => [] as Mailbox[])), - ...(await getSharedMailboxes().catch(() => [] as Mailbox[])), - ]; + // With mutes a swallowed fetch failure would look like "no such folder" + // and unmute everything, so let the real error surface; otherwise the + // filter can still degrade to keyword-only on a transient miss. + const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0; + const all = loaded ?? (hasMutes + ? [...(await getMailboxes()), ...(await getSharedMailboxes())] + : [ + ...(await getMailboxes().catch(() => [] as Mailbox[])), + ...(await getSharedMailboxes().catch(() => [] as Mailbox[])), + ]); for (const m of all) { const accountId = m.accountId || primary; - const junk = junkByAccount.get(accountId) ?? []; // Shared-account mailboxes carry a client-side ":" id; // the server only knows the original. - if (m.role === 'junk') junk.push(m.originalId ?? m.id); + const serverId = m.originalId ?? m.id; + const ids = mailboxIdsByAccount.get(accountId) ?? new Set(); + ids.add(serverId); + mailboxIdsByAccount.set(accountId, ids); + const junk = junkByAccount.get(accountId) ?? []; + if (m.role === 'junk') junk.push(serverId); junkByAccount.set(accountId, junk); } const config: Record = {}; for (const [accountId, junkIds] of junkByAccount) { + // Settings for folders that no longer exist are left out. + const existing = mailboxIdsByAccount.get(accountId) ?? new Set(); + const overrides = mailboxOverridesForAccount(mutes, accountId); const conditions: Record[] = [{ notKeyword: '$junk' }]; - if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() }); + if (isAccountMuted(mutes, accountId)) { + const allowed = overrides.unmuted + .filter((id) => existing.has(id) && !junkIds.includes(id)) + .sort(); + if (allowed.length === 0) conditions.push(NEVER_MATCH); + else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] }); + else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) }); + } else { + const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]); + if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() }); + } config[accountId] = { // Always the operator form: that's how the server echoes it back, so a // stored config compares equal to a freshly built one. @@ -333,6 +370,10 @@ export async function buildEmailPushConfig(): Promise { if (Array.isArray(v)) return v.map(sortKeys); @@ -980,7 +1021,7 @@ async function setupPushNotificationsInner( if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId)); const refusedBefore = await readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush() - ? withoutAccounts(await buildEmailPushConfig(), refusedBefore) + ? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore) : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 1a4e5793..1ed4f379 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -4,6 +4,7 @@ import type { Identity } from '../api/types'; import { getIdentities as fetchIdentities } from '../api/identity'; import { jmapClient } from '../api/jmap-client'; import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order'; +import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes'; export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -260,6 +261,9 @@ interface PersistedSettings { // Notifications. Sound/vibration live in the Android notification channel // (the OS owns them after channel creation), so there are no sound keys. emailNotificationsEnabled: boolean; + // Accounts and folders whose new mail does not notify (#78), keyed by the + // server's JMAP ids; see lib/notification-mutes. + notificationMutes: NotificationMutes; calendarNotificationsEnabled: boolean; calendarInvitationParsingEnabled: boolean; // Inbox unread count on the app icon (iOS, see lib/app-badge). Same @@ -413,6 +417,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { filesShowHiddenFiles: false, emailNotificationsEnabled: true, + notificationMutes: EMPTY_NOTIFICATION_MUTES, calendarNotificationsEnabled: true, calendarInvitationParsingEnabled: true, appIconUnreadBadge: true, @@ -605,6 +610,8 @@ export function mergeWithDefaults(parsed: Partial): Persisted if (validator && !validator(v)) continue; if (k === 'bottomQuickActions') { out[k] = normalizeBottomQuickActions(v); + } else if (k === 'notificationMutes') { + out[k] = normalizeNotificationMutes(v); } else if (Array.isArray(def)) { if (Array.isArray(v)) out[k] = v; } else if (typeof def === 'object') {