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.
34 lines
1.2 KiB
TypeScript
34 lines
1.2 KiB
TypeScript
"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;
|
|
}
|