mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat(pwa): badge the installed app icon with unread count (Badging API)
Adds useAppBadge alongside the existing useFaviconBadge, mounted from the same FaviconBadge component and gated by the same faviconUnreadBadge setting. Targets the separate icon Chrome/Edge/WebKit show for an installed PWA (taskbar/dock/Home Screen/pinned-app-shortcut), which does not react to favicon changes at all - a merely pinned browser tab is unaffected, only an actually installed app. No unmount cleanup: the badge is scoped to the installed app/origin, not to this component or window, so clearing on every unmount would blank another still-open window's accurate badge. A count of zero or the setting being off already clears it through the main effect.
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { FaviconBadge } from '@/components/favicon-badge';
|
||||
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||
import { useAppBadge } from '@/hooks/use-app-badge';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
@@ -9,8 +10,12 @@ import type { Mailbox } from '@/lib/jmap/types';
|
||||
vi.mock('@/hooks/use-favicon-badge', () => ({
|
||||
useFaviconBadge: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/hooks/use-app-badge', () => ({
|
||||
useAppBadge: vi.fn(),
|
||||
}));
|
||||
|
||||
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
||||
const useAppBadgeMock = vi.mocked(useAppBadge);
|
||||
|
||||
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
||||
return {
|
||||
@@ -58,6 +63,7 @@ describe('FaviconBadge', () => {
|
||||
const { container } = render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
||||
expect(useAppBadgeMock).toHaveBeenCalledWith(7, true);
|
||||
expect(container.firstChild).toBeNull(); // renders no markup
|
||||
});
|
||||
|
||||
@@ -70,6 +76,7 @@ describe('FaviconBadge', () => {
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
||||
expect(useAppBadgeMock).toHaveBeenCalledWith(7, false);
|
||||
});
|
||||
|
||||
it('ignores a shared inbox, even when it sorts first', () => {
|
||||
@@ -86,6 +93,7 @@ describe('FaviconBadge', () => {
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
||||
expect(useAppBadgeMock).toHaveBeenCalledWith(4, true);
|
||||
});
|
||||
|
||||
it('badges zero when there is no inbox yet', () => {
|
||||
@@ -94,5 +102,6 @@ describe('FaviconBadge', () => {
|
||||
render(<FaviconBadge />);
|
||||
|
||||
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
||||
expect(useAppBadgeMock).toHaveBeenCalledWith(0, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
|
||||
import { useAppBadge } from "@/hooks/use-app-badge";
|
||||
|
||||
/**
|
||||
* Badges the browser-tab favicon with the inbox unread count, so new mail is
|
||||
* visible without focusing the tab. See issue #560.
|
||||
* Badges the browser-tab favicon, and — where supported — the installed PWA's
|
||||
* app icon, with the inbox unread count, so new mail is visible without
|
||||
* focusing the tab. See issue #560.
|
||||
*
|
||||
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
|
||||
* default.
|
||||
* Two independent mechanisms share the one unread count: `useFaviconBadge`
|
||||
* repaints the in-tab favicon (works everywhere); `useAppBadge` calls the
|
||||
* Badging API for the separate icon Chrome/Edge pin next to the address bar
|
||||
* once the app is installed — Chromium supports it there, WebKit supports it
|
||||
* for an iOS/iPadOS Home Screen web app; a silent no-op anywhere else.
|
||||
*
|
||||
* Both opt out via the single `faviconUnreadBadge` setting (Settings ->
|
||||
* Appearance); on by default.
|
||||
*
|
||||
* Mounted in the root layout rather than on the mail route: the badge belongs
|
||||
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
|
||||
* cleared the badge, and flickered the icon — on every hop to /settings,
|
||||
* /calendar or /contacts.
|
||||
* to the tab/app, not to a page. Mounting it on the mail page unmounted it —
|
||||
* and so cleared the badge, and flickered the icon — on every hop to
|
||||
* /settings, /calendar or /contacts.
|
||||
*
|
||||
* Renders nothing.
|
||||
*/
|
||||
@@ -28,6 +36,7 @@ export function FaviconBadge() {
|
||||
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
|
||||
|
||||
useFaviconBadge(inboxUnread, enabled);
|
||||
useAppBadge(inboxUnread, enabled);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useAppBadge } from '@/hooks/use-app-badge';
|
||||
|
||||
function stubBadgingApi() {
|
||||
const setAppBadge = vi.fn(async (_count?: number) => {});
|
||||
const clearAppBadge = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'setAppBadge', {
|
||||
value: setAppBadge,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'clearAppBadge', {
|
||||
value: clearAppBadge,
|
||||
configurable: true,
|
||||
});
|
||||
return { setAppBadge, clearAppBadge };
|
||||
}
|
||||
|
||||
function removeBadgingApi() {
|
||||
// @ts-expect-error - deleting a test-only stub; the properties are typed
|
||||
// as required on Navigator (lib.dom.d.ts), so a real browser lacking them
|
||||
// is exactly what this simulates.
|
||||
delete navigator.setAppBadge;
|
||||
// @ts-expect-error - see above
|
||||
delete navigator.clearAppBadge;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
removeBadgingApi();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useAppBadge', () => {
|
||||
it('calls setAppBadge with the count when positive', async () => {
|
||||
const { setAppBadge, clearAppBadge } = stubBadgingApi();
|
||||
renderHook(() => useAppBadge(5));
|
||||
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenCalledWith(5));
|
||||
expect(clearAppBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls clearAppBadge when the count is zero', async () => {
|
||||
const { setAppBadge, clearAppBadge } = stubBadgingApi();
|
||||
renderHook(() => useAppBadge(0));
|
||||
|
||||
await waitFor(() => expect(clearAppBadge).toHaveBeenCalled());
|
||||
expect(setAppBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats disabled as a count of zero, even with unread mail', async () => {
|
||||
const { setAppBadge, clearAppBadge } = stubBadgingApi();
|
||||
renderHook(() => useAppBadge(12, false));
|
||||
|
||||
await waitFor(() => expect(clearAppBadge).toHaveBeenCalled());
|
||||
expect(setAppBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the badge when the count changes', async () => {
|
||||
const { setAppBadge } = stubBadgingApi();
|
||||
const { rerender } = renderHook(({ count }) => useAppBadge(count), {
|
||||
initialProps: { count: 1 },
|
||||
});
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenLastCalledWith(1));
|
||||
|
||||
rerender({ count: 7 });
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenLastCalledWith(7));
|
||||
});
|
||||
|
||||
it('clears an already-set badge when count becomes zero or the setting is disabled', async () => {
|
||||
const { setAppBadge, clearAppBadge } = stubBadgingApi();
|
||||
const { rerender } = renderHook(
|
||||
({ count, enabled }: { count: number; enabled: boolean }) => useAppBadge(count, enabled),
|
||||
{ initialProps: { count: 5, enabled: true } },
|
||||
);
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenCalledWith(5));
|
||||
|
||||
rerender({ count: 0, enabled: true });
|
||||
await waitFor(() => expect(clearAppBadge).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ count: 5, enabled: true });
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender({ count: 5, enabled: false });
|
||||
await waitFor(() => expect(clearAppBadge).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('does not clear an unrelated window/instance on unmount', async () => {
|
||||
// The badge is scoped to the installed app, not this component — an
|
||||
// unmount must not fire a clear that would blank another open window
|
||||
// still showing a nonzero count. See the hook's doc comment.
|
||||
const { setAppBadge, clearAppBadge } = stubBadgingApi();
|
||||
const { unmount } = renderHook(() => useAppBadge(3));
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenCalledWith(3));
|
||||
|
||||
unmount();
|
||||
expect(clearAppBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a silent no-op when the Badging API is unsupported', async () => {
|
||||
removeBadgingApi();
|
||||
expect(() => renderHook(() => useAppBadge(5))).not.toThrow();
|
||||
// Nothing to await: there is no API to have called. The assertion is
|
||||
// simply that mounting/unmounting never throws without the API present.
|
||||
});
|
||||
|
||||
it('swallows a rejected setAppBadge call without throwing', async () => {
|
||||
const setAppBadge = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true });
|
||||
Object.defineProperty(navigator, 'clearAppBadge', {
|
||||
value: vi.fn(async () => {}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
expect(() => renderHook(() => useAppBadge(2))).not.toThrow();
|
||||
await waitFor(() => expect(setAppBadge).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it('swallows a rejected clearAppBadge call without throwing', async () => {
|
||||
const clearAppBadge = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
Object.defineProperty(navigator, 'setAppBadge', {
|
||||
value: vi.fn(async () => {}),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'clearAppBadge', { value: clearAppBadge, configurable: true });
|
||||
|
||||
expect(() => renderHook(() => useAppBadge(0))).not.toThrow();
|
||||
await waitFor(() => expect(clearAppBadge).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
/**
|
||||
* Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to
|
||||
* the address bar, or the OS taskbar/dock/Home Screen icon once launched
|
||||
* standalone — with the inbox unread count, via the Badging API
|
||||
* (`navigator.setAppBadge`/`clearAppBadge`).
|
||||
*
|
||||
* This is a different surface from `useFaviconBadge`: that one repaints the
|
||||
* favicon *inside* the browser tab strip and works in any tab, installed or
|
||||
* not. This one calls a spec API that targets the installed app icon —
|
||||
* Chromium supports it there; WebKit supports it for iOS/iPadOS Home Screen
|
||||
* web apps (not plain Safari tabs). Either way it's a no-op wherever
|
||||
* unsupported: both methods are feature-detected per call, so a browser that
|
||||
* has one but not the other (or neither) never throws.
|
||||
*
|
||||
* No unmount cleanup: the badge is scoped to the installed app/origin, not
|
||||
* to this component or even this window — with several windows of the same
|
||||
* app open, whichever last called set/clear wins for all of them (that's the
|
||||
* API's own model, not a bug here). Clearing on every unmount would fight
|
||||
* that: window A closing would blank window B's still-accurate badge. A
|
||||
* `count` of zero (fully-read inbox) or `enabled: false` already clears it
|
||||
* through the effect below; unmounting without either of those (e.g. a
|
||||
* logout that doesn't first zero the store) can leave a stale number until
|
||||
* the app's next unread-count change — accepted as inherent to a
|
||||
* window/tab-agnostic API without adding cross-window coordination
|
||||
* (service worker, BroadcastChannel) for it.
|
||||
*/
|
||||
export function useAppBadge(count: number, enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
|
||||
const effectiveCount = enabled ? count : 0;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (effectiveCount > 0) {
|
||||
if (typeof navigator.setAppBadge !== 'function') return;
|
||||
await navigator.setAppBadge(effectiveCount);
|
||||
} else {
|
||||
if (typeof navigator.clearAppBadge !== 'function') return;
|
||||
await navigator.clearAppBadge();
|
||||
}
|
||||
} catch (error) {
|
||||
debug.log('[app-badge] failed:', error);
|
||||
}
|
||||
})();
|
||||
}, [count, enabled]);
|
||||
}
|
||||
@@ -1060,8 +1060,8 @@
|
||||
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
|
||||
},
|
||||
"favicon_unread_badge": {
|
||||
"label": "Unread Count on Tab Icon",
|
||||
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||
"label": "Unread Count on Tab and App Icon",
|
||||
"description": "Show the inbox unread count as a badge on the browser tab icon, and on the installed app icon where supported, so new mail is visible when the tab is not focused. Disable to keep both icons unbadged."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro Interface (Experimental)",
|
||||
|
||||
@@ -1057,8 +1057,8 @@
|
||||
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
|
||||
},
|
||||
"favicon_unread_badge": {
|
||||
"label": "Unread Count on Tab Icon",
|
||||
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||
"label": "Счётчик непрочитанных на иконке вкладки и приложения",
|
||||
"description": "Показывает количество непрочитанных писем значком на иконке вкладки браузера и, если поддерживается, на иконке установленного приложения — чтобы новая почта была видна без переключения на вкладку. Отключите, чтобы обе иконки оставались без значка."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-интерфейс (экспериментальный)",
|
||||
|
||||
Reference in New Issue
Block a user