From 25c088383f6980432dbd2522c117566cdd5aa0cd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:18:52 +0200 Subject: [PATCH] feat: show the inbox unread count on the app icon Ports the webmail's app-icon badge (`useAppBadge`): the icon shows the unread count of the active account's own inbox, and a new "Unread count on app icon" toggle (on by default, exported as the webmail's `faviconUnreadBadge`) turns it off. Signing out of the last account clears it. This is iOS only. On Android expo-notifications badges through ShortcutBadger, which few launchers honour, and implements a count of 0 as `NotificationManager.cancelAll()`, so clearing the badge would also dismiss other accounts' mail notifications and calendar reminders. Android launchers already badge the icon from the notifications on display. For the same reason the Android-only background push task does not update the badge. --- App.tsx | 2 + locales/rn/en.json | 2 + src/components/AppIconBadge.tsx | 22 ++++++ .../settings/NotificationSettings.tsx | 13 ++++ src/lib/__tests__/app-badge.test.ts | 67 +++++++++++++++++++ src/lib/app-badge.ts | 43 ++++++++++++ src/stores/settings-store.ts | 5 ++ 7 files changed, 154 insertions(+) create mode 100644 src/components/AppIconBadge.tsx create mode 100644 src/lib/__tests__/app-badge.test.ts create mode 100644 src/lib/app-badge.ts diff --git a/App.tsx b/App.tsx index fe627cc5..d7ec84ef 100644 --- a/App.tsx +++ b/App.tsx @@ -56,6 +56,7 @@ import { useUpdatesStore } from './src/stores/updates-store'; import { UpdateBanner } from './src/components/UpdateBanner'; import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt'; import { ToastHost } from './src/components/ToastHost'; +import { AppIconBadge } from './src/components/AppIconBadge'; import { getEmails } from './src/api/email'; import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; @@ -146,6 +147,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps + s.appIconUnreadBadge); + const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled)); + + useEffect(() => { + void setAppBadge(count); + }, [count]); + + useEffect(() => () => { void setAppBadge(0); }, []); + + return null; +} diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 07225873..5ed92924 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -41,6 +41,7 @@ import { isUnifiedPushSupported, saveUnifiedPushDistributor, } from '../../lib/unified-push'; +import { canBadgeAppIcon } from '../../lib/app-badge'; type PushStatus = | { kind: 'idle' } @@ -79,6 +80,7 @@ export function NotificationSettings() { const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge); const supported = isPushSupported(); const upSupported = isUnifiedPushSupported(); @@ -465,6 +467,17 @@ export function NotificationSettings() { > update('emailNotificationsEnabled', v)} /> + {canBadgeAppIcon() && ( + + update('appIconUnreadBadge', v)} /> + + )} {Platform.OS === 'android' && ( {t( diff --git a/src/lib/__tests__/app-badge.test.ts b/src/lib/__tests__/app-badge.test.ts new file mode 100644 index 00000000..49a9d8ba --- /dev/null +++ b/src/lib/__tests__/app-badge.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('expo-notifications', () => ({ + setBadgeCountAsync: vi.fn(async () => true), +})); + +import { Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { appBadgeCount, setAppBadge } from '../app-badge'; +import type { Mailbox } from '../../api/types'; + +const setBadge = Notifications.setBadgeCountAsync as unknown as ReturnType; +const platform = Platform as { OS: string }; + +function mailbox(id: string, extra: Partial): Mailbox { + return { + id, + name: id, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + ...extra, + } as Mailbox; +} + +afterEach(() => { + platform.OS = 'android'; + setBadge.mockClear(); +}); + +describe('appBadgeCount', () => { + it("counts the own inbox's unread, not a shared inbox listed first", () => { + const mailboxes = [ + mailbox('team:inbox', { role: 'inbox', isShared: true, unreadEmails: 40 }), + mailbox('inbox', { role: 'inbox', isShared: false, unreadEmails: 3 }), + mailbox('lists', { unreadEmails: 12 }), + ]; + expect(appBadgeCount(mailboxes, true)).toBe(3); + }); + + it('is 0 when the setting is off or there is no inbox yet', () => { + expect(appBadgeCount([mailbox('inbox', { role: 'inbox', unreadEmails: 3 })], false)).toBe(0); + expect(appBadgeCount([], true)).toBe(0); + }); +}); + +describe('setAppBadge', () => { + it('sets and clears the badge on iOS', async () => { + platform.OS = 'ios'; + await setAppBadge(5); + await setAppBadge(0); + expect(setBadge.mock.calls).toEqual([[5], [0]]); + }); + + it('never touches the badge on Android, where a 0 cancels every notification', async () => { + await setAppBadge(5); + await setAppBadge(0); + expect(setBadge).not.toHaveBeenCalled(); + }); + + it('swallows a failing native call', async () => { + platform.OS = 'ios'; + setBadge.mockRejectedValueOnce(new Error('no permission')); + await expect(setAppBadge(2)).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/app-badge.ts b/src/lib/app-badge.ts new file mode 100644 index 00000000..fc9facf6 --- /dev/null +++ b/src/lib/app-badge.ts @@ -0,0 +1,43 @@ +import { Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import type { Mailbox } from '../api/types'; + +/** + * The number on the app icon: the unread count of the active account's own + * inbox, like the webmail's tab/app-icon badge (`FaviconBadge`). `role === + * 'inbox'` alone is not enough: shared and group inboxes sit in the same list, + * so the first match can be somebody else's. 0 (no badge) when the setting is + * off. + */ +export function appBadgeCount(mailboxes: readonly Mailbox[], enabled: boolean): number { + if (!enabled) return 0; + const inbox = mailboxes.find((m) => m.role === 'inbox' && !m.isShared); + return Math.max(0, inbox?.unreadEmails ?? 0); +} + +/** + * Whether this platform can badge the app icon without side effects. + * + * iOS only. On Android expo-notifications badges through ShortcutBadger, + * which only a few launchers honour (Android 8+ launchers derive their badge + * from the notifications on display instead), and it implements a count of 0 + * as `NotificationManager.cancelAll()`: clearing the badge would also dismiss + * every notification the app shows, including other accounts' new mail and + * calendar reminders. + */ +export function canBadgeAppIcon(): boolean { + return Platform.OS === 'ios'; +} + +/** + * Show `count` on the app icon (0 clears it). A silent no-op where the + * platform can't badge or the user denied badge permission. + */ +export async function setAppBadge(count: number): Promise { + if (!canBadgeAppIcon()) return; + try { + await Notifications.setBadgeCountAsync(Math.max(0, Math.floor(count))); + } catch { + // Badging is cosmetic; never let it surface as an error. + } +} diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index ea464bb8..ce9bd25e 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -255,6 +255,9 @@ interface PersistedSettings { emailNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean; calendarInvitationParsingEnabled: boolean; + // Inbox unread count on the app icon (iOS, see lib/app-badge). Same + // preference as the webmail's `faviconUnreadBadge` (tab and app icon). + appIconUnreadBadge: boolean; // Sidebar apps sidebarApps: SidebarApp[]; @@ -403,6 +406,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { emailNotificationsEnabled: true, calendarNotificationsEnabled: true, calendarInvitationParsingEnabled: true, + appIconUnreadBadge: true, sidebarApps: [], keepAppsLoaded: false, @@ -610,6 +614,7 @@ export const SETTINGS_KEY_MAP: Partial> exportLowercase: 'filenameLowercase', exportStripDiacritics: 'filenameStripDiacritics', filtersExpandedView: 'expandedFilterView', + appIconUnreadBadge: 'faviconUnreadBadge', }; // Keys that describe this device rather than the user's preferences.