Closes #560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
99 lines
2.9 KiB
TypeScript
99 lines
2.9 KiB
TypeScript
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 { useEmailStore } from '@/stores/email-store';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import type { Mailbox } from '@/lib/jmap/types';
|
|
|
|
vi.mock('@/hooks/use-favicon-badge', () => ({
|
|
useFaviconBadge: vi.fn(),
|
|
}));
|
|
|
|
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
|
|
|
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
|
return {
|
|
name: patch.id,
|
|
sortOrder: 0,
|
|
totalEmails: 0,
|
|
unreadEmails: 0,
|
|
totalThreads: 0,
|
|
unreadThreads: 0,
|
|
isSubscribed: true,
|
|
myRights: {
|
|
mayReadItems: true,
|
|
mayAddItems: true,
|
|
mayRemoveItems: true,
|
|
maySetSeen: true,
|
|
maySetKeywords: true,
|
|
mayCreateChild: true,
|
|
mayRename: true,
|
|
mayDelete: true,
|
|
maySubmit: true,
|
|
},
|
|
...patch,
|
|
} as Mailbox;
|
|
}
|
|
|
|
const initialMailboxes = useEmailStore.getState().mailboxes;
|
|
|
|
beforeEach(() => {
|
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('FaviconBadge', () => {
|
|
it('badges the unread count of the primary inbox', () => {
|
|
useEmailStore.setState({
|
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
|
});
|
|
|
|
const { container } = render(<FaviconBadge />);
|
|
|
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
|
expect(container.firstChild).toBeNull(); // renders no markup
|
|
});
|
|
|
|
it('disables the badge when the setting is off', () => {
|
|
useSettingsStore.setState({ faviconUnreadBadge: false });
|
|
useEmailStore.setState({
|
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
|
});
|
|
|
|
render(<FaviconBadge />);
|
|
|
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
|
});
|
|
|
|
it('ignores a shared inbox, even when it sorts first', () => {
|
|
// Shared and group inboxes ship in the same `mailboxes` array. A plain
|
|
// `role === 'inbox'` lookup would badge somebody else's inbox on a
|
|
// delegated setup, so the store's canonical `!isShared` filter is required.
|
|
useEmailStore.setState({
|
|
mailboxes: [
|
|
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
|
|
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
|
|
],
|
|
});
|
|
|
|
render(<FaviconBadge />);
|
|
|
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
|
});
|
|
|
|
it('badges zero when there is no inbox yet', () => {
|
|
useEmailStore.setState({ mailboxes: [] });
|
|
|
|
render(<FaviconBadge />);
|
|
|
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
|
});
|
|
});
|