mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user