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.
This commit is contained in:
Linus Rath
2026-09-24 19:36:41 +02:00
parent 9f75e70218
commit 25c088383f
7 changed files with 154 additions and 0 deletions
+2
View File
@@ -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<RootStackParam
<OfflineCacheBanner />
<PushOnboardingPrompt />
<ToastHost />
<AppIconBadge />
<Tab.Navigator
screenOptions={{
headerShown: false,
+2
View File
@@ -562,6 +562,8 @@
},
"notifications": {
"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.",
"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": {
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import { appBadgeCount, setAppBadge } from '../lib/app-badge';
/**
* Keeps the app icon badge on the active account's inbox unread count (see
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
* account signed out, so the badge is cleared then. Renders nothing.
*/
export function AppIconBadge() {
const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
useEffect(() => {
void setAppBadge(count);
}, [count]);
useEffect(() => () => { void setAppBadge(0); }, []);
return null;
}
@@ -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() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}
description={t(
'settings.notifications.email.app_icon_badge_desc',
'Show the inbox unread count as a badge on the app icon.',
)}
>
<ToggleSwitch checked={appIconBadge} onChange={(v) => update('appIconUnreadBadge', v)} />
</SettingItem>
)}
{Platform.OS === 'android' && (
<Text style={styles.fieldDescription}>
{t(
+67
View File
@@ -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<typeof vi.fn>;
const platform = Platform as { OS: string };
function mailbox(id: string, extra: Partial<Mailbox>): 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();
});
});
+43
View File
@@ -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<void> {
if (!canBadgeAppIcon()) return;
try {
await Notifications.setBadgeCountAsync(Math.max(0, Math.floor(count)));
} catch {
// Badging is cosmetic; never let it surface as an error.
}
}
+5
View File
@@ -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<Record<keyof PersistedSettings, string>>
exportLowercase: 'filenameLowercase',
exportStripDiacritics: 'filenameStripDiacritics',
filtersExpandedView: 'expandedFilterView',
appIconUnreadBadge: 'faviconUnreadBadge',
};
// Keys that describe this device rather than the user's preferences.