feat: show unread count badge on favicon

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.
This commit is contained in:
honzup
2026-07-16 18:00:41 +02:00
committed by Linus Rath
parent 578339c400
commit 334fdbfb86
32 changed files with 1598 additions and 1 deletions
+2
View File
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { FaviconBadge } from "@/components/favicon-badge";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
@@ -132,6 +133,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
<FaviconBadge />
{children}
</body>
</html>
@@ -0,0 +1,98 @@
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);
});
});
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
/**
* Badges the browser-tab favicon 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.
*
* 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.
*
* Renders nothing.
*/
export function FaviconBadge() {
// The store's canonical inbox selector. `role === 'inbox'` alone is not
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
// a delegated setup the first match can be somebody else's inbox.
const inboxUnread = useEmailStore(
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
useFaviconBadge(inboxUnread, enabled);
return null;
}
+8 -1
View File
@@ -118,7 +118,7 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
@@ -231,6 +231,13 @@ export function LayoutSettings() {
/>
</SettingItem>
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
<ToggleSwitch
checked={faviconUnreadBadge}
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
+559
View File
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { StrictMode } from 'react';
import { render, renderHook, waitFor } from '@testing-library/react';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
vi.mock('@/lib/favicon-badge', () => ({
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
),
}));
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
/** The link the hook owns: the only one it may ever touch. */
function badgeLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
}
/** The base icon link the page (or React) rendered: must survive untouched. */
function baseLinks(): HTMLLinkElement[] {
return Array.from(
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
);
}
/** Every icon link in <head>, in document order. The browser honours the last. */
function iconLinks(): HTMLLinkElement[] {
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
}
function lastIconLink(): HTMLLinkElement | null {
return iconLinks().at(-1) ?? null;
}
/**
* What Next/React does on a client-side navigation: it re-hoists its metadata
* icon link into <head>, appending a *fresh* node after everything already
* there — including our badge link.
*/
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
const link = document.createElement('link');
link.rel = 'icon';
link.href = href;
document.head.appendChild(link);
return link;
}
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
}
function Badger({ count }: { count: number }) {
useFaviconBadge(count);
return null;
}
beforeEach(() => {
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// document.head outlives every test, so a spy on it that a failing assertion
// never got to restore would leak into the next test's counts.
vi.restoreAllMocks();
});
describe('useFaviconBadge', () => {
it('appends its own badged icon link when the count is positive', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
// Last-declared icon wins, so ours must be last in <head>.
expect(document.head.lastElementChild).toBe(badgeLink());
});
it('never removes or mutates an icon link it did not create', async () => {
const before = baseLinks()[0];
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(before.isConnected).toBe(true);
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
expect(baseLinks()).toHaveLength(1);
});
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const survivors = baseLinks();
expect(survivors).toHaveLength(2);
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
expect(survivors[1].getAttribute('href')).toBe('/a.png');
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
});
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
// that DOM node. Removing it out from under React makes the commit phase throw
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
// later deleted. The hook must therefore never touch a node it did not create.
document.head.innerHTML = '';
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
// The React-owned node is still there, untouched.
const reactOwned = baseLinks();
expect(reactOwned).toHaveLength(1);
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
expect(() => unmount()).not.toThrow();
expect(badgeLink()).toBeNull();
});
it('replaces its own link rather than mutating its href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const first = badgeLink();
rerender({ n: 4 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
});
// Firefox ignores an in-place href change on the favicon link.
expect(badgeLink()).not.toBe(first);
expect(first!.isConnected).toBe(false);
});
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
// The field bug: Firefox does not re-evaluate the favicon when an icon link
// is *removed* — a removal is not an insertion, so it keeps painting the
// last icon it was handed and the stale "99+" badge sticks until a hard
// reload. Clearing must therefore be an insertion: our own link is replaced
// by a brand-new node carrying the original base href.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('inserts the base-href link exactly once while the count stays at zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () =>
appendSpy.mock.calls.filter(
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
).length;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(ownAppends()).toBe(1);
// Neither further renders at the same count nor unrelated <head> churn (an
// observer tick) may re-insert it: no remove/append thrash on every tick.
rerender({ n: 0 });
rerender({ n: 0 });
document.head.appendChild(document.createElement('meta'));
await settle();
expect(ownAppends()).toBe(1);
expect(badgeLink()).toBe(restored);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('keeps its own link last even while it is only carrying the base href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
it('badges again with a fresh insertion when the count leaves zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ n: 7 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('removes only its own link on unmount', async () => {
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does not fetch, and adds no link, while the count is zero', async () => {
renderHook(() => useFaviconBadge(0));
await Promise.resolve();
await Promise.resolve();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
});
it('leaves the icon alone when the base is not SVG', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
);
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
// Assert on the observable end state, not merely on the href being
// unchanged: the href is also unchanged *before* the catch block runs,
// so a href-only assertion would pass even if the code went on to swap
// the icon a tick later. The renderer must never be reached.
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('leaves the icon alone when the base fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does nothing when there is no icon link to read', async () => {
document.head.innerHTML = '';
renderHook(() => useFaviconBadge(3));
await Promise.resolve(); // let any deferred async work start
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
});
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
// StrictMode double-invokes effects, and a count change while the fetch is in
// flight re-runs the effect: neither may issue a second request.
let resolveFetch: (response: Response) => void = () => {};
const inFlight = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
vi.stubGlobal(
'fetch',
vi.fn(() => inFlight),
);
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 1 },
wrapper: StrictMode,
});
rerender({ n: 2 });
rerender({ n: 5 });
expect(fetch).toHaveBeenCalledTimes(1);
resolveFetch(svgResponse());
await waitFor(() => {
// The badge lands on the latest count, not the one in flight at fetch time.
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
});
rerender({ n: 6 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not re-render the badge when the count is unchanged', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 3 },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const settled = badgeLink();
renderBadgedFaviconMock.mockClear();
rerender({ n: 3 });
// The element identity alone is not evidence: `count` is the effect's only
// dependency, so React would skip the effect regardless. Assert the
// renderer was not invoked again — that is the behaviour under test.
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBe(settled);
});
describe('when the setting is off', () => {
it('adds no link and does not fetch, however high the count', async () => {
renderHook(() => useFaviconBadge(3, false));
await settle();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
// Same guarantee as the count-back-to-zero clear, and for the same reason:
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
// Turning the setting off by *removing* our link would leave the stale
// badge painted on the tab until a hard reload.
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ on: true });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('keeps its base-href link last when React re-hoists its icon', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
});
describe('when React re-hoists its icon link on a client-side navigation', () => {
it('moves its own link back to the end so the badge keeps winning', async () => {
// The field bug: Inbox badges the tab, a hop to /calendar makes React
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
// wins again and the badge vanishes.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(lastIconLink()).toBe(badgeLink());
const own = badgeLink()!;
const rehoisted = rehoistBaseIcon();
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(document.head.lastElementChild).toBe(own);
expect(badgeLink()).toBe(own); // moved, not recreated
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
});
it('restores the badge without the count changing', async () => {
// The "never comes back" half of the bug. Navigating back to the inbox
// does not change the unread count, so nothing re-runs the count effect:
// the observer alone must put the badge back on top.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
renderBadgedFaviconMock.mockClear();
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
rerender({ n: 3 }); // same count: no effect re-run to lean on
await settle();
expect(lastIconLink()).toBe(badgeLink());
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('re-applies the badge if its own link is removed entirely', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
badgeLink()!.remove(); // React blows away part of <head>
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
});
it('settles: re-appending its own link does not feed the observer a loop', async () => {
// Moving our link fires the observer again. If the move is not guarded by
// "am I already last?", that second run moves it again, for ever. Count
// the appends of *our* node: exactly one, and it must stop growing.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const own = badgeLink()!;
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(ownAppends()).toBe(1);
await settle();
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
expect(lastIconLink()).toBe(own);
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
expect(iconLinks()).toHaveLength(3);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('still never removes or mutates a link it did not create', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
const rehoisted = rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
const survivors = baseLinks();
expect(survivors).toHaveLength(4);
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
'/a.svg',
'/a.png',
ORIGINAL_HREF,
ORIGINAL_HREF,
]);
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
expect(rehoisted.isConnected).toBe(true);
// The React-owned node is still React's to delete.
expect(() => unmount()).not.toThrow();
});
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(disconnect).toHaveBeenCalled();
expect(badgeLink()).toBeNull();
// A post-unmount re-hoist must not resurrect the badge.
rehoistBaseIcon();
await settle();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(2);
disconnect.mockRestore();
});
});
});
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useRef } from 'react';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
import { debug } from '@/lib/debug';
// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
// layout.tsx) renders <link rel="icon"> through React, which hoists it into
// <head> and keeps a fiber pointing at that DOM node. Removing it out from
// under React leaves the fiber holding a detached node, and the next commit
// that deletes that fiber throws "Cannot read properties of null (reading
// 'removeChild')". So we never remove or mutate a node we did not create:
// instead we append an *extra* icon link, marked as ours. The last-declared
// icon wins in browsers, so ours overrides the base without deleting it.
//
// Ours is never removed to clear the badge, though — only on unmount. Firefox
// re-evaluates the favicon on an *insertion* and on nothing else: a removal
// leaves it painting the last icon it was handed, which is how a read inbox
// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
// the original base href in place of the badge (see `apply`).
const MARKER = 'data-favicon-badge';
const OWN_SELECTOR = `link[${MARKER}]`;
const ICON_SELECTOR = 'link[rel~="icon"]';
const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
// Both the badged icon and the untouched base we fall back to are SVG: the hook
// disables itself unless the fetched base is served as image/svg+xml, so by the
// time either link exists that content type is a proven fact, not a guess.
const ICON_TYPE = 'image/svg+xml';
function removeOwnLink(): void {
document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
}
function ownLink(): HTMLLinkElement | null {
return document.head.querySelector<HTMLLinkElement>(OWN_SELECTOR);
}
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
function isLastIconLink(link: HTMLLinkElement): boolean {
const icons = document.head.querySelectorAll<HTMLLinkElement>(ICON_SELECTOR);
return icons[icons.length - 1] === link;
}
/**
* Appends a fresh icon link of ours, replacing any previous one of ours.
*
* Always a remove-then-append of a *new* node, never an href mutation: Firefox
* only re-evaluates the favicon when an icon link is inserted. It ignores an
* in-place href change, and — the count-back-to-zero bug — it equally ignores a
* removal, happily painting the last icon it was handed. So even *clearing* the
* badge is done by inserting: see `apply`, which re-inserts our link carrying
* the original base href rather than deleting it.
*/
function setOwnLink(href: string): void {
removeOwnLink();
const link = document.createElement('link');
link.rel = 'icon';
link.type = ICON_TYPE;
link.href = href;
link.setAttribute(MARKER, '');
document.head.appendChild(link);
}
/**
* Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
* false (the `faviconUnreadBadge` setting).
*
* The base icon is read from the rendered <link rel="icon">, so admin and
* per-domain branding overrides (configManager `faviconUrl`) are respected
* without plumbing config to the client.
*
* Every failure — no icon link, a fetch error, a non-SVG base, unparseable
* source — leaves the existing favicon untouched.
*/
export function useFaviconBadge(count: number, enabled = true): void {
// Disabled is just "nothing to show", i.e. exactly a count of zero, so it
// rides the same paths: no fetch while we have never badged, and — the part
// that matters — clearing an *existing* badge by inserting a fresh link
// carrying the base href rather than removing ours, which Firefox would
// ignore (see `apply`). Switching the setting off therefore restores the
// plain icon immediately, with no reload.
const effectiveCount = enabled ? count : 0;
const baseSource = useRef<string | null>(null);
const baseHref = useRef<string | null>(null);
// The href our own link currently carries, and the hook's whole state machine:
// null -> nothing of ours is in <head> (we have never badged)
// baseHref -> ours is in <head>, showing the unbadged base icon
// a data: URL -> ours is in <head>, showing the badge
const appliedHref = useRef<string | null>(null);
const disabled = useRef(false);
const fetchStarted = useRef(false);
const unmounted = useRef(false);
const latestCount = useRef(effectiveCount);
latestCount.current = effectiveCount;
// Declared before the badge effect so that on a StrictMode remount it runs
// first and clears `unmounted` before the badge effect reads it.
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
// Restore the server-rendered favicon by removing our override. Nothing
// else in <head> is ours to touch.
removeOwnLink();
appliedHref.current = null;
};
}, []);
// Reads the refs rather than a closure over `count`, so that the reply to an
// in-flight fetch — and the MutationObserver below, which outlives any single
// render — lands on the newest count, not the one that started it.
const apply = useCallback(() => {
if (unmounted.current || disabled.current) return;
const current = latestCount.current;
if (current <= 0) {
// Clearing the badge is an *insertion*, not a removal.
//
// The field bug: with 133 unread the tab showed "99+", the user read
// everything, the store went to 0 — and Firefox kept painting "99+" until
// a hard reload. Removing our link is not an insertion, and Firefox only
// re-evaluates the favicon on an insertion; a removal leaves it painting
// the last icon it was handed. So instead of deleting our link we replace
// it with a fresh one carrying the *original* base href: same pixels as
// the untouched base link below it, but handed to the browser as a new
// icon, which it does repaint.
//
// Never badged (`appliedHref` still null)? Then nothing of ours is in
// <head> and nothing should be: a fully-read inbox adds no link at all.
const base = baseHref.current;
if (appliedHref.current === null || base === null) return;
if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
setOwnLink(base);
appliedHref.current = base;
return;
}
const source = baseSource.current;
if (source === null) return; // still fetching; the fetch will call back
const next = renderBadgedFavicon(source, current);
if (!next) return;
if (next === appliedHref.current && ownLink()) return;
setOwnLink(next);
appliedHref.current = next;
}, []);
useEffect(() => {
if (disabled.current) return;
// Nothing to show and nothing applied: do not even fetch. A fully-read
// inbox — or the setting switched off before we ever badged — should cost
// no request.
if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
// The base is fetched at most once, ever. Without this guard a StrictMode
// double-invoke issues two requests, and any count change while the fetch
// is in flight issues another.
if (baseSource.current !== null || fetchStarted.current) {
apply();
return;
}
// The one and only read of the base link. Its href is both what we fetch the
// source from and what we hand back to the browser when the badge clears.
const link = document.querySelector<HTMLLinkElement>(BASE_SELECTOR);
const href = link?.getAttribute('href');
if (!href) {
disabled.current = true;
return;
}
baseHref.current = href;
fetchStarted.current = true;
void (async () => {
try {
const response = await fetch(href);
if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('image/svg+xml')) {
throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
}
baseSource.current = await response.text();
apply();
} catch (error) {
disabled.current = true;
debug.log('[favicon-badge] disabled:', error);
}
})();
}, [effectiveCount, apply]);
// Keep ours the last icon link in <head>.
//
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
// <link rel="icon"> from app/(main)/layout.tsx into <head>. The re-inserted
// node lands *after* our badge link, the last-declared icon wins, and the
// badge vanishes. Coming back to the inbox did not bring it back either: the
// count is unchanged, so the effect above never re-ran and our link just sat
// there outranked. Watching <head> fixes both halves at once.
//
// Termination: moving our own link is itself a <head> mutation, so it feeds
// the observer a fresh record. The guard is `isLastIconLink` — on that second
// run ours *is* last, so we do nothing and the cascade stops. One move per
// foreign insertion, never two.
const keepOwnLinkLast = useCallback(() => {
if (unmounted.current || disabled.current) return;
// Ours must stay last in *both* states — badged, and showing the base href
// after a clear (`appliedHref` is only null when we have never badged, and
// then nothing of ours is in <head> to keep last). Gating this on the count
// instead would strand our base-href link behind a re-hoisted React icon,
// and the next badge would have to fight its way back on top.
if (appliedHref.current === null) return;
const own = ownLink();
if (!own) {
// React blew our link away with the rest of the head: re-apply from scratch.
apply();
return;
}
if (isLastIconLink(own)) return;
// Re-appending *our own* element is the only mutation we ever make; a node
// we did not create is never removed, moved or touched (see above).
document.head.appendChild(own);
}, [apply]);
useEffect(() => {
const observer = new MutationObserver(keepOwnLinkLast);
observer.observe(document.head, { childList: true });
return () => observer.disconnect();
}, [keepOwnLinkLast]);
}
+342
View File
@@ -0,0 +1,342 @@
import { describe, it, expect } from 'vitest';
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
function decode(dataUrl: string): string {
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
}
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
const match =
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
svg,
);
expect(match).not.toBeNull();
const [, x, y, w, h, rx] = match!.map(Number);
return { x, y, w, h, rx };
}
function fontSize(svg: string): number {
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
}
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
const [minX, minY, width, height] = /viewBox="([^"]+)"/
.exec(svg)![1]
.trim()
.split(/[\s,]+/)
.map(Number);
return { minX, minY, width, height };
}
describe('formatBadgeCount', () => {
it('returns an empty string for zero and below', () => {
expect(formatBadgeCount(0)).toBe('');
expect(formatBadgeCount(-3)).toBe('');
});
it('returns the count verbatim from 1 to 99', () => {
expect(formatBadgeCount(1)).toBe('1');
expect(formatBadgeCount(9)).toBe('9');
expect(formatBadgeCount(47)).toBe('47');
expect(formatBadgeCount(99)).toBe('99');
});
it('caps at 99+ above 99', () => {
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
// typical inbox needs three glyphs almost always, and three glyphs do not
// fit at the full font size — so "99+" rendered permanently smaller than a
// real two-digit count would have.
expect(formatBadgeCount(100)).toBe('99+');
expect(formatBadgeCount(133)).toBe('99+');
expect(formatBadgeCount(1000)).toBe('99+');
});
it('returns an empty string for non-finite input', () => {
expect(formatBadgeCount(Number.NaN)).toBe('');
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
});
});
describe('renderBadgedFavicon', () => {
it('returns null when the count is zero', () => {
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
});
it('returns null when the source is not SVG', () => {
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
});
it('returns null when the root element is not <svg>', () => {
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
});
it('returns null when the root has no viewBox', () => {
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
});
it('returns a percent-encoded svg data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3);
expect(url).not.toBeNull();
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
});
it('draws a badge band and the count text', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('<rect');
expect(svg).toContain('>3<');
});
it('renders 99+ for large counts', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(svg).toContain('>99+<');
});
it('preserves the base artwork and its clipPath id', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('id="_clip1"');
expect(svg).toContain('#123456');
});
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('width="16"');
expect(svg).toContain('height="16"');
expect(svg).toContain('viewBox="0 0 1000 1000"');
expect(svg).not.toContain('1000pt');
});
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
});
it('shrinks the font as the label grows so three glyphs still fit', () => {
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(three)).toBeLessThan(fontSize(one));
});
it('returns null rather than throwing when the source contains a lone surrogate', () => {
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
expect(renderBadgedFavicon(bad, 3)).toBeNull();
});
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
// The box is only as wide as its digits need — "5" must not squat on as much
// white as "99+". It is never wider than the icon, and three glyphs, whose
// font is budgeted against the full span, grow to exactly fill it.
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
expect(w(7)).toBeLessThan(w(47));
expect(w(47)).toBeLessThan(w(250));
expect(w(250)).toBeCloseTo(1000, 5);
});
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
// Gmail's own single-digit badge sits hard right in a box about a third of
// the icon wide, so the box grows leftwards from the corner.
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
const expected: Record<string, { x: number; w: number; font: number }> = {
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
};
for (const [count, want] of Object.entries(expected)) {
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
const { x, y, w, h, rx } = band(svg);
expect(x).toBeCloseTo(want.x, 5);
expect(w).toBeCloseTo(want.w, 5);
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
expect(y).toBeCloseTo(375, 5);
expect(h).toBeCloseTo(625, 5);
expect(rx).toBeCloseTo(62.5, 5);
}
});
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
// corner, so its right edge sits on minX + span whatever the label. Centring
// was rejected — at a single digit it lands under the middle of the mark.
const cases: [string, number, number][] = [
// [base svg, minX, span]
[BASE_SVG, 0, 1000],
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
];
for (const [svgSource, minX, span] of cases) {
for (const count of [7, 47, 250]) {
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
expect(x + w).toBeCloseTo(minX + span, 5);
expect(x).toBeGreaterThanOrEqual(minX);
}
}
});
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
// The font is budgeted against the full icon span, not against the fitted
// box, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their box then grows to fill the icon.
const FONT_MAX = 0.61 * 1000;
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(three)).toBeLessThan(FONT_MAX);
});
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
// a visible ~1px round at 16px. Guard against a silent revert to either.
for (const count of [7, 47, 250]) {
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.1 * h, 5);
expect(rx).toBeGreaterThan(0);
expect(rx).toBeLessThan(h / 2);
}
});
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
// 700 read visibly heavier than Gmail's equivalent badge.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
});
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The band is flush to the bottom and, at three glyphs, to the left and
// right edges too — but it must never overflow any of them.
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x + w).toBeLessThanOrEqual(1000);
expect(y + h).toBeLessThanOrEqual(1000);
}
});
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
// negative number: dropping `minX +` from the anchoring passed it. Anchor
// against a viewBox whose origin is negative, where the band's own x is
// legitimately negative, so the offset is genuinely pinned.
it('anchors the band to the viewBox origin, including a negative origin', () => {
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(-40);
expect(y).toBeGreaterThanOrEqual(-40);
expect(x + w).toBeLessThanOrEqual(200);
expect(y + h).toBeLessThanOrEqual(200);
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
// band's bottom edge and its right edge both sit well past the midpoint.
expect(x + w).toBeGreaterThan(80);
expect(y + h).toBeGreaterThan(80);
}
});
it('fits the label inside the band, with padding, for every label length', () => {
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
// padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted box, so the padding is the
// same at every label length.
const GLYPH_ADV = 0.6;
const PAD_FACTOR = 0.04;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { w } = band(svg);
const label = count > 99 ? '99+' : String(count);
const textW = label.length * GLYPH_ADV * fontSize(svg);
const pad = PAD_FACTOR * 1000;
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
}
});
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3)!;
// encodeURI leaves "#" bare, which the browser reads as a fragment
// delimiter: everything after the first colour would be silently dropped.
expect(url).toContain('%23');
expect(url).not.toContain('#');
});
it('returns an empty label, and no badge, for a fractional count below one', () => {
expect(formatBadgeCount(0.5)).toBe('');
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
});
it('returns null when the root svg has no SVG namespace', () => {
// Non-null but unrenderable: a data URL built from this would show nothing.
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
});
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
// Presentation attributes lose to any CSS rule in the document. A branded
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
// and the digits red — exactly what the white band exists to prevent.
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
});
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
// The base may be an admin-uploaded file, which upstream serves under a
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
// same-origin data: URL would un-fence it, so sanitise before serialising.
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
const url = renderBadgedFavicon(HOSTILE, 3)!;
expect(url).not.toBeNull();
const svg = decode(url);
expect(svg).not.toContain('<script');
expect(svg).not.toContain('foreignObject');
expect(svg.toLowerCase()).not.toContain('onload');
expect(svg.toLowerCase()).not.toContain('onclick');
expect(svg.toLowerCase()).not.toContain('onmouseover');
expect(svg).not.toContain('alert');
// The legitimate artwork survives.
expect(svg).toContain('#123456');
});
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
// the rendered box instead.
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
const vb = viewBoxOf(svg);
expect(vb.width).toBe(100);
expect(vb.height).toBe(100);
expect(vb.minX).toBe(0);
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
const { x, y, w, h } = band(svg);
// Sized against the square side (100), not the 20-unit short axis.
expect(h).toBeCloseTo(0.625 * 100, 5);
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
// anchored to the right of the squared span.
expect(w).toBeCloseTo(81.2, 5);
expect(x + w).toBeCloseTo(100, 5);
// Still in bounds of the normalised viewBox.
expect(x).toBeGreaterThanOrEqual(vb.minX);
expect(y).toBeGreaterThanOrEqual(vb.minY);
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
});
it('leaves a square viewBox untouched', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('viewBox="0 0 1000 1000"');
});
});
+206
View File
@@ -0,0 +1,206 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
// A neutral white band with black digits, rather than the conventional red
// badge. The band guarantees contrast for the count whatever the base icon
// looks like, which matters because `faviconUrl` is admin-overridable and may
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
// icon is rgb(219,45,84), so a red badge sat red-on-red.
const BADGE_FILL = '#ffffff';
const BADGE_TEXT_FILL = '#000000';
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
// The badge is a Gmail-style band across the bottom of the icon, sized as a
// fraction of the icon's own coordinate space so it lands correctly whatever
// viewBox the base declares.
//
// The fractions below are not invented: they are measured, pixel-by-pixel, off
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
// band height. Not square, and emphatically not h/2.
//
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
// on as much white as "99+" does.
//
// What keeps a three-glyph label legible is not the width — it is the small
// corner radius, plus budgeting the font against the FULL span rather than
// against the fitted box. The rounded-end pill that preceded this failed for the
// first reason: round ends (rx = h/2) squander their horizontal extent on the
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
// illegible smudge — and at one digit the same pill read as a plain circle. Do
// not reinstate rx = h/2. Because the font is budgeted against the full span,
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
// hugs them.
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
// Counts above this render as "99+". Gmail caps at 20, and matching it was
// tried and reverted: the cap decides how often the label needs three glyphs,
// and three glyphs do not fit at the full font size. Capping at 20 meant a
// typical inbox showed "20+" at 84% of the cap size essentially always, where
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
// number you can act on beat parity with Gmail's ceiling.
const BADGE_MAX = 99;
/**
* Formats an unread count for display in the badge.
* Returns an empty string when there is nothing to show.
*/
export function formatBadgeCount(count: number): string {
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
// 0 and draw a "0" badge, since String(0) is truthy.
if (!Number.isFinite(count) || count < 1) return '';
const whole = Math.floor(count);
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
}
/**
* Strips anything active from the base SVG.
*
* The base may be an admin-uploaded file, which the branding route deliberately
* serves under a sandboxing CSP because SVG can carry script (see
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
* same-origin `data:` URL inside our own document would un-fence exactly what
* that CSP fences, so remove script, foreignObject and every on* handler first.
*/
function sanitiseSvg(doc: Document): void {
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) {
el.removeAttributeNS(attr.namespaceURI, attr.localName);
}
}
});
}
/**
* Composes an unread badge over an SVG favicon and returns it as a data URL.
*
* Returns null — meaning "leave the favicon alone" — when the count is zero,
* or when the source is not usable SVG. Never throws.
*/
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
const label = formatBadgeCount(count);
if (!label) return null;
try {
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
if (doc.querySelector('parsererror')) return null;
const root = doc.documentElement;
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
// but renders as nothing, so it would yield a non-null, blank data URL.
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
const viewBox = root.getAttribute('viewBox');
if (!viewBox) return null;
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
if (
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
rawWidth <= 0 ||
rawHeight <= 0
) {
return null;
}
sanitiseSvg(doc);
// The base declares "1000pt"; point units in a favicon are unreliable.
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
// at any size it asks for.
root.setAttribute('width', '16');
root.setAttribute('height', '16');
// Normalise the viewBox to a square, centred on the original, before doing
// any badge maths. Sizing the badge off min(width, height) double-penalised
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
// 16px icon. Squaring first sizes the badge against the box the icon is
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
// preserveAspectRatio="none" will now letterbox rather than stretch — an
// acceptable, arguably better, trade for a favicon, which is always square.
const side = Math.max(rawWidth, rawHeight);
const minX = rawMinX - (side - rawWidth) / 2;
const minY = rawMinY - (side - rawHeight) / 2;
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
const span = side;
const h = BAND_HEIGHT * span;
const fontMax = FONT_MAX * span;
const pad = PAD_FACTOR * span;
// The font first, budgeted against the FULL span: the largest size that
// would still leave the padding intact if the box ran edge to edge. That is
// the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
// The box then hugs the label — never wider than the icon, anchored to the
// bottom-right corner. A three-glyph label, whose font was budgeted against
// the whole span, fills that span exactly; shorter labels get a narrower
// box, leaving the left of the base mark uncovered so the artwork stays
// recognisable. Gmail's own badge does the same: measured off its 16px
// favicon, a single digit sits hard right in a box about a third of the
// icon wide. Centring was tried and rejected — at one digit the box lands
// under the middle of the mark and bites a hole out of it.
const textW = label.length * GLYPH_ADV * font;
const w = Math.min(span, textW + 2 * pad);
const x = minX + span - w;
const y = minY + span - h;
const rx = CORNER_FACTOR * h;
const bandRect = doc.createElementNS(SVG_NS, 'rect');
bandRect.setAttribute('x', String(x));
bandRect.setAttribute('y', String(y));
bandRect.setAttribute('width', String(w));
bandRect.setAttribute('height', String(h));
bandRect.setAttribute('rx', String(rx));
bandRect.setAttribute('ry', String(rx));
// Presentation attributes lose to any CSS rule in the same document, and a
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
// would paint the badge red-on-red, the exact failure the white band exists
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
// attribute as the guarantee, the presentation attribute as the fallback.
bandRect.setAttribute('fill', BADGE_FILL);
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
const text = doc.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x + w / 2));
text.setAttribute('y', String(y + h / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', BADGE_FONT);
// 500, not 700: at true 16px a bold count read visibly heavier than the
// equivalent badge in Gmail's tab, which is the thing users compare it to.
text.setAttribute('font-weight', '500');
text.setAttribute('font-size', String(font));
text.setAttribute('fill', BADGE_TEXT_FILL);
text.setAttribute(
'style',
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
);
text.textContent = label;
root.appendChild(bandRect);
root.appendChild(text);
const serialised = new XMLSerializer().serializeToString(doc);
// Percent-encoding rather than base64: btoa throws on any character outside
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
// "#" in a colour truncates the data URL at the first fill.
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
} catch {
return null;
}
}
+4
View File
@@ -949,6 +949,10 @@
"label": "Zobrazit celkový počet zpráv",
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
},
"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."
},
"pro_interface": {
"label": "Pro rozhraní (experimentální)",
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Vis samlet antal beskeder",
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
},
"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."
},
"pro_interface": {
"label": "Pro-grænseflade (eksperimentel)",
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Gesamtzahl der Nachrichten anzeigen",
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
},
"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."
},
"pro_interface": {
"label": "Pro-Oberfläche (experimentell)",
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Show Total Message Count",
"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."
},
"pro_interface": {
"label": "Pro Interface (Experimental)",
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar el número total de mensajes",
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
},
"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."
},
"pro_interface": {
"label": "Interfaz Pro (experimental)",
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "نمایش تعداد کل پیام‌ها",
"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."
},
"pro_interface": {
"label": "رابط حرفه‌ای (آزمایشی)",
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
+4
View File
@@ -949,6 +949,10 @@
"label": "Afficher le nombre total de messages",
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
},
"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."
},
"pro_interface": {
"label": "Interface Pro (expérimental)",
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
+4
View File
@@ -914,6 +914,10 @@
"label": "הצגת מספר ההודעות הכולל",
"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."
},
"pro_interface": {
"label": "ממשק Pro (ניסיוני)",
"description": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Összes üzenet számának megjelenítése",
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
},
"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."
},
"pro_interface": {
"label": "Pro felület (Kísérleti)",
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostra il numero totale di messaggi",
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
},
"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."
},
"pro_interface": {
"label": "Interfaccia Pro (sperimentale)",
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
+4
View File
@@ -949,6 +949,10 @@
"label": "メッセージの総数を表示",
"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."
},
"pro_interface": {
"label": "Pro インターフェイス(実験的)",
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
+4
View File
@@ -949,6 +949,10 @@
"label": "전체 메시지 수 표시",
"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."
},
"pro_interface": {
"label": "Pro 인터페이스 (실험적)",
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Rādīt kopējo ziņojumu skaitu",
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
},
"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."
},
"pro_interface": {
"label": "Pro saskarne (eksperimentāla)",
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Totaal aantal berichten tonen",
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
},
"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."
},
"pro_interface": {
"label": "Pro-interface (experimenteel)",
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Pokaż łączną liczbę wiadomości",
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
},
"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."
},
"pro_interface": {
"label": "Interfejs Pro (eksperymentalny)",
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar contagem total de mensagens",
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
},
"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."
},
"pro_interface": {
"label": "Interface Pro (experimental)",
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Afișează numărul total de mesaje",
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
},
"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."
},
"pro_interface": {
"label": "Interfață Pro (experimentală)",
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показывать общее количество сообщений",
"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."
},
"pro_interface": {
"label": "Pro-интерфейс (экспериментальный)",
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Zobraziť celkový počet správ",
"description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných."
},
"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."
},
"pro_interface": {
"label": "Pro rozhranie (experimentálne)",
"description": "Rozloženie pre pokročilých používateľov iba pre stolné počítače.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Toplam mesaj sayısını göster",
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
},
"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."
},
"pro_interface": {
"label": "Pro Arayüz (Deneysel)",
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показувати загальну кількість повідомлень",
"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."
},
"pro_interface": {
"label": "Pro-інтерфейс (експериментальний)",
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
+4
View File
@@ -949,6 +949,10 @@
"label": "显示邮件总数",
"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."
},
"pro_interface": {
"label": "Pro 界面(实验性)",
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store favicon unread badge', () => {
beforeEach(() => {
useSettingsStore.getState().resetToDefaults();
});
it('defaults to on', () => {
expect(useSettingsStore.getState().faviconUnreadBadge).toBe(true);
});
it('includes the favicon unread badge in exported settings', () => {
useSettingsStore.getState().updateSetting('faviconUnreadBadge', false);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
faviconUnreadBadge?: boolean;
};
expect(exported.faviconUnreadBadge).toBe(false);
});
});
+3
View File
@@ -262,6 +262,7 @@ interface SettingsState {
senderFavicons: boolean;
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
@@ -459,6 +460,7 @@ const DEFAULT_SETTINGS = {
senderFavicons: true,
showAvatarsInJunk: false,
faviconUnreadBadge: true,
// Sidebar
colorfulSidebarIcons: true,
@@ -639,6 +641,7 @@ export const useSettingsStore = create<SettingsState>()(
enableCrossAllView: state.enableCrossAllView,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
faviconUnreadBadge: state.faviconUnreadBadge,
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
showFolderTotalCount: state.showFolderTotalCount,