Files
SRCmail/lib/favicon-badge.ts
T
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
Closes #560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
2026-07-16 18:00:41 +02:00

207 lines
9.8 KiB
TypeScript

const SVG_NS = 'http://www.w3.org/2000/svg';
// A neutral white band with black digits, rather than the conventional red
// badge. The band guarantees contrast for the count whatever the base icon
// looks like, which matters because `faviconUrl` is admin-overridable and may
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
// icon is rgb(219,45,84), so a red badge sat red-on-red.
const BADGE_FILL = '#ffffff';
const BADGE_TEXT_FILL = '#000000';
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
// The badge is a Gmail-style band across the bottom of the icon, sized as a
// fraction of the icon's own coordinate space so it lands correctly whatever
// viewBox the base declares.
//
// The fractions below are not invented: they are measured, pixel-by-pixel, off
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
// band height. Not square, and emphatically not h/2.
//
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
// on as much white as "99+" does.
//
// What keeps a three-glyph label legible is not the width — it is the small
// corner radius, plus budgeting the font against the FULL span rather than
// against the fitted box. The rounded-end pill that preceded this failed for the
// first reason: round ends (rx = h/2) squander their horizontal extent on the
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
// illegible smudge — and at one digit the same pill read as a plain circle. Do
// not reinstate rx = h/2. Because the font is budgeted against the full span,
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
// hugs them.
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
// Counts above this render as "99+". Gmail caps at 20, and matching it was
// tried and reverted: the cap decides how often the label needs three glyphs,
// and three glyphs do not fit at the full font size. Capping at 20 meant a
// typical inbox showed "20+" at 84% of the cap size essentially always, where
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
// number you can act on beat parity with Gmail's ceiling.
const BADGE_MAX = 99;
/**
* Formats an unread count for display in the badge.
* Returns an empty string when there is nothing to show.
*/
export function formatBadgeCount(count: number): string {
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
// 0 and draw a "0" badge, since String(0) is truthy.
if (!Number.isFinite(count) || count < 1) return '';
const whole = Math.floor(count);
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
}
/**
* Strips anything active from the base SVG.
*
* The base may be an admin-uploaded file, which the branding route deliberately
* serves under a sandboxing CSP because SVG can carry script (see
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
* same-origin `data:` URL inside our own document would un-fence exactly what
* that CSP fences, so remove script, foreignObject and every on* handler first.
*/
function sanitiseSvg(doc: Document): void {
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) {
el.removeAttributeNS(attr.namespaceURI, attr.localName);
}
}
});
}
/**
* Composes an unread badge over an SVG favicon and returns it as a data URL.
*
* Returns null — meaning "leave the favicon alone" — when the count is zero,
* or when the source is not usable SVG. Never throws.
*/
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
const label = formatBadgeCount(count);
if (!label) return null;
try {
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
if (doc.querySelector('parsererror')) return null;
const root = doc.documentElement;
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
// but renders as nothing, so it would yield a non-null, blank data URL.
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
const viewBox = root.getAttribute('viewBox');
if (!viewBox) return null;
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
if (
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
rawWidth <= 0 ||
rawHeight <= 0
) {
return null;
}
sanitiseSvg(doc);
// The base declares "1000pt"; point units in a favicon are unreliable.
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
// at any size it asks for.
root.setAttribute('width', '16');
root.setAttribute('height', '16');
// Normalise the viewBox to a square, centred on the original, before doing
// any badge maths. Sizing the badge off min(width, height) double-penalised
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
// 16px icon. Squaring first sizes the badge against the box the icon is
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
// preserveAspectRatio="none" will now letterbox rather than stretch — an
// acceptable, arguably better, trade for a favicon, which is always square.
const side = Math.max(rawWidth, rawHeight);
const minX = rawMinX - (side - rawWidth) / 2;
const minY = rawMinY - (side - rawHeight) / 2;
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
const span = side;
const h = BAND_HEIGHT * span;
const fontMax = FONT_MAX * span;
const pad = PAD_FACTOR * span;
// The font first, budgeted against the FULL span: the largest size that
// would still leave the padding intact if the box ran edge to edge. That is
// the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
// The box then hugs the label — never wider than the icon, anchored to the
// bottom-right corner. A three-glyph label, whose font was budgeted against
// the whole span, fills that span exactly; shorter labels get a narrower
// box, leaving the left of the base mark uncovered so the artwork stays
// recognisable. Gmail's own badge does the same: measured off its 16px
// favicon, a single digit sits hard right in a box about a third of the
// icon wide. Centring was tried and rejected — at one digit the box lands
// under the middle of the mark and bites a hole out of it.
const textW = label.length * GLYPH_ADV * font;
const w = Math.min(span, textW + 2 * pad);
const x = minX + span - w;
const y = minY + span - h;
const rx = CORNER_FACTOR * h;
const bandRect = doc.createElementNS(SVG_NS, 'rect');
bandRect.setAttribute('x', String(x));
bandRect.setAttribute('y', String(y));
bandRect.setAttribute('width', String(w));
bandRect.setAttribute('height', String(h));
bandRect.setAttribute('rx', String(rx));
bandRect.setAttribute('ry', String(rx));
// Presentation attributes lose to any CSS rule in the same document, and a
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
// would paint the badge red-on-red, the exact failure the white band exists
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
// attribute as the guarantee, the presentation attribute as the fallback.
bandRect.setAttribute('fill', BADGE_FILL);
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
const text = doc.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x + w / 2));
text.setAttribute('y', String(y + h / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', BADGE_FONT);
// 500, not 700: at true 16px a bold count read visibly heavier than the
// equivalent badge in Gmail's tab, which is the thing users compare it to.
text.setAttribute('font-weight', '500');
text.setAttribute('font-size', String(font));
text.setAttribute('fill', BADGE_TEXT_FILL);
text.setAttribute(
'style',
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
);
text.textContent = label;
root.appendChild(bandRect);
root.appendChild(text);
const serialised = new XMLSerializer().serializeToString(doc);
// Percent-encoding rather than base64: btoa throws on any character outside
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
// "#" in a colour truncates the data URL at the first fill.
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
} catch {
return null;
}
}