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.
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
"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]);
|
|
}
|