mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +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 { UpdateBanner } from './src/components/UpdateBanner';
|
||||||
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||||
import { ToastHost } from './src/components/ToastHost';
|
import { ToastHost } from './src/components/ToastHost';
|
||||||
|
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||||
import { getEmails } from './src/api/email';
|
import { getEmails } from './src/api/email';
|
||||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||||
@@ -146,6 +147,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
<OfflineCacheBanner />
|
<OfflineCacheBanner />
|
||||||
<PushOnboardingPrompt />
|
<PushOnboardingPrompt />
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
<AppIconBadge />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
|||||||
@@ -562,6 +562,8 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email": {
|
"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."
|
"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": {
|
"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,
|
isUnifiedPushSupported,
|
||||||
saveUnifiedPushDistributor,
|
saveUnifiedPushDistributor,
|
||||||
} from '../../lib/unified-push';
|
} from '../../lib/unified-push';
|
||||||
|
import { canBadgeAppIcon } from '../../lib/app-badge';
|
||||||
|
|
||||||
type PushStatus =
|
type PushStatus =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
@@ -79,6 +80,7 @@ export function NotificationSettings() {
|
|||||||
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||||
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||||
|
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
|
||||||
|
|
||||||
const supported = isPushSupported();
|
const supported = isPushSupported();
|
||||||
const upSupported = isUnifiedPushSupported();
|
const upSupported = isUnifiedPushSupported();
|
||||||
@@ -465,6 +467,17 @@ export function NotificationSettings() {
|
|||||||
>
|
>
|
||||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||||
</SettingItem>
|
</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' && (
|
{Platform.OS === 'android' && (
|
||||||
<Text style={styles.fieldDescription}>
|
<Text style={styles.fieldDescription}>
|
||||||
{t(
|
{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;
|
emailNotificationsEnabled: boolean;
|
||||||
calendarNotificationsEnabled: boolean;
|
calendarNotificationsEnabled: boolean;
|
||||||
calendarInvitationParsingEnabled: 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
|
// Sidebar apps
|
||||||
sidebarApps: SidebarApp[];
|
sidebarApps: SidebarApp[];
|
||||||
@@ -403,6 +406,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
emailNotificationsEnabled: true,
|
emailNotificationsEnabled: true,
|
||||||
calendarNotificationsEnabled: true,
|
calendarNotificationsEnabled: true,
|
||||||
calendarInvitationParsingEnabled: true,
|
calendarInvitationParsingEnabled: true,
|
||||||
|
appIconUnreadBadge: true,
|
||||||
|
|
||||||
sidebarApps: [],
|
sidebarApps: [],
|
||||||
keepAppsLoaded: false,
|
keepAppsLoaded: false,
|
||||||
@@ -610,6 +614,7 @@ export const SETTINGS_KEY_MAP: Partial<Record<keyof PersistedSettings, string>>
|
|||||||
exportLowercase: 'filenameLowercase',
|
exportLowercase: 'filenameLowercase',
|
||||||
exportStripDiacritics: 'filenameStripDiacritics',
|
exportStripDiacritics: 'filenameStripDiacritics',
|
||||||
filtersExpandedView: 'expandedFilterView',
|
filtersExpandedView: 'expandedFilterView',
|
||||||
|
appIconUnreadBadge: 'faviconUnreadBadge',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keys that describe this device rather than the user's preferences.
|
// Keys that describe this device rather than the user's preferences.
|
||||||
|
|||||||
Reference in New Issue
Block a user