From 334fdbfb86443a75d1adc2abcc758a3960d2df9e Mon Sep 17 00:00:00 2001 From: honzup <5564623+honzup@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:06:14 +0200 Subject: [PATCH] feat: show unread count badge on favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 , landing after ours, and the base icon silently wins again. A MutationObserver on 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. --- app/(main)/layout.tsx | 2 + components/__tests__/favicon-badge.test.tsx | 98 +++ components/favicon-badge.tsx | 33 ++ components/settings/layout-settings.tsx | 9 +- hooks/__tests__/use-favicon-badge.test.tsx | 559 ++++++++++++++++++ hooks/use-favicon-badge.ts | 237 ++++++++ lib/__tests__/favicon-badge.test.ts | 342 +++++++++++ lib/favicon-badge.ts | 206 +++++++ locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh/common.json | 4 + .../settings-store-favicon-badge.test.ts | 22 + stores/settings-store.ts | 3 + 32 files changed, 1598 insertions(+), 1 deletion(-) create mode 100644 components/__tests__/favicon-badge.test.tsx create mode 100644 components/favicon-badge.tsx create mode 100644 hooks/__tests__/use-favicon-badge.test.tsx create mode 100644 hooks/use-favicon-badge.ts create mode 100644 lib/__tests__/favicon-badge.test.ts create mode 100644 lib/favicon-badge.ts create mode 100644 stores/__tests__/settings-store-favicon-badge.test.ts diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index a8af157f..34d9f94a 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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`} > + {children} diff --git a/components/__tests__/favicon-badge.test.tsx b/components/__tests__/favicon-badge.test.tsx new file mode 100644 index 00000000..5524c01c --- /dev/null +++ b/components/__tests__/favicon-badge.test.tsx @@ -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 & { 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(); + + 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(); + + 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(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true); + }); + + it('badges zero when there is no inbox yet', () => { + useEmailStore.setState({ mailboxes: [] }); + + render(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true); + }); +}); diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx new file mode 100644 index 00000000..0e3a4295 --- /dev/null +++ b/components/favicon-badge.tsx @@ -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; +} diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index bbab221f..8e8e15a8 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -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() { /> + + updateSetting('faviconUnreadBadge', checked)} + /> + + {(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && ( ({ + 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('link[data-favicon-badge]'); +} + +/** The base icon link the page (or React) rendered: must survive untouched. */ +function baseLinks(): HTMLLinkElement[] { + return Array.from( + document.querySelectorAll('link[rel~="icon"]:not([data-favicon-badge])'), + ); +} + +/** Every icon link in , in document order. The browser honours the last. */ +function iconLinks(): HTMLLinkElement[] { + return Array.from(document.head.querySelectorAll('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 , 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 { + for (let i = 0; i < 20; i++) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function svgResponse(body = '') { + 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 = ``; + 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 . + 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 = + `` + + ``; + + 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 into 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( + <> + + + , + ); + + 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 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((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 *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 + + 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 = + `` + + ``; + + const { unmount } = render( + <> + + + , + ); + 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(); + }); + }); +}); diff --git a/hooks/use-favicon-badge.ts b/hooks/use-favicon-badge.ts new file mode 100644 index 00000000..1053f0e8 --- /dev/null +++ b/hooks/use-favicon-badge.ts @@ -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 through React, which hoists it into +// 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(OWN_SELECTOR); +} + +/** True when ours is the last icon link in , i.e. the one the browser uses. */ +function isLastIconLink(link: HTMLLinkElement): boolean { + const icons = document.head.querySelectorAll(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 , 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(null); + const baseHref = useRef(null); + // The href our own link currently carries, and the hook's whole state machine: + // null -> nothing of ours is in (we have never badged) + // baseHref -> ours is in , showing the unbadged base icon + // a data: URL -> ours is in , showing the badge + const appliedHref = useRef(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 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 + // 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(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 . + // + // On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata + // from app/(main)/layout.tsx into . 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 fixes both halves at once. + // + // Termination: moving our own link is itself a 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 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]); +} diff --git a/lib/__tests__/favicon-badge.test.ts b/lib/__tests__/favicon-badge.test.ts new file mode 100644 index 00000000..699f4b36 --- /dev/null +++ b/lib/__tests__/favicon-badge.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect } from 'vitest'; +import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge'; + +const BASE_SVG = ``; + +function decode(dataUrl: string): string { + return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', '')); +} + +/** The badge band: the last the renderer appends, identified by its fill. */ +function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } { + const match = + /]*\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(/]*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 ', () => { + expect(renderBadgedFavicon('', 3)).toBeNull(); + }); + + it('returns null when the root has no viewBox', () => { + const noViewBox = ``; + 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('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(/]*fill="#ffffff"/); + expect(svg).toMatch(/]*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 = `abc\uD800def`; + 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 = { + '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], + [``, -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(/]*font-weight="500"/); + expect(svg).toMatch(/]*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 = ``; + 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 = ``; + 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 = ``; + const svg = decode(renderBadgedFavicon(STYLED, 3)!); + expect(svg).toMatch(/]*style="[^"]*fill:\s*#ffffff/); + expect(svg).toMatch(/]*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 = `hi`; + const url = renderBadgedFavicon(HOSTILE, 3)!; + expect(url).not.toBeNull(); + const svg = decode(url); + expect(svg).not.toContain(' { + // 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 = ``; + 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"'); + }); +}); diff --git a/lib/favicon-badge.ts b/lib/favicon-badge.ts new file mode 100644 index 00000000..d717fd0e --- /dev/null +++ b/lib/favicon-badge.ts @@ -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 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 `` — 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; + } +} diff --git a/locales/cs/common.json b/locales/cs/common.json index 577cc390..ef6e7451 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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.", diff --git a/locales/da/common.json b/locales/da/common.json index 334b635c..916a2f73 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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.", diff --git a/locales/de/common.json b/locales/de/common.json index 02b42581..fe8c93fa 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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.", diff --git a/locales/en/common.json b/locales/en/common.json index 7de12348..793fa699 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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.", diff --git a/locales/es/common.json b/locales/es/common.json index 7110b9be..f9869b37 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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.", diff --git a/locales/fa/common.json b/locales/fa/common.json index cfd9e953..8e36a247 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "چیدمان قدرت-کاربری فقط دسکتاپ", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8d0c2ac7..6708307e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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.", diff --git a/locales/he/common.json b/locales/he/common.json index bcbe1a4b..38690937 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 24a18433..d64fa4c1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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.", diff --git a/locales/it/common.json b/locales/it/common.json index 1721864c..e38cdc38 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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.", diff --git a/locales/ja/common.json b/locales/ja/common.json index d93f3b27..4d786e0a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6a80e438..76a3c908 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index f9fc75b7..08ef766b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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ļ.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 52624f8b..ced27bcb 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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.", diff --git a/locales/pl/common.json b/locales/pl/common.json index d365e95a..fad1317e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 62fd321c..6202f6eb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a6eb79c6..a8e9b251 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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.", diff --git a/locales/ru/common.json b/locales/ru/common.json index a0962e5a..24de9a4f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.", diff --git a/locales/sk/common.json b/locales/sk/common.json index a53aad49..1defe95e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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.", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0fc1ebba..3dcb2851 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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.", diff --git a/locales/uk/common.json b/locales/uk/common.json index a916fb12..a5c69da6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5e151add..38fc1ad1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。", diff --git a/stores/__tests__/settings-store-favicon-badge.test.ts b/stores/__tests__/settings-store-favicon-badge.test.ts new file mode 100644 index 00000000..7301ffe0 --- /dev/null +++ b/stores/__tests__/settings-store-favicon-badge.test.ts @@ -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); + }); +}); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 21e68627..28e3cd8d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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()( enableCrossAllView: state.enableCrossAllView, senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, + faviconUnreadBadge: state.faviconUnreadBadge, colorfulSidebarIcons: state.colorfulSidebarIcons, tintListRowsByTag: state.tintListRowsByTag, showFolderTotalCount: state.showFolderTotalCount,