feat: show unread count badge on favicon#608
Merged
Merged
Conversation
Closes bulwarkmail#560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #560.
Draws the active inbox's unread count onto the browser-tab favicon, 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.
On by default, switchable off in Settings → Layout, alongside the other appearance toggles — a badged tab icon is not to everyone's taste.
What it does
The badge is composed over the base favicon as SVG and served as a percent-encoded
data:URL. The base icon is read from the rendered<link rel="icon">rather than from config, so admin and per-domainfaviconUrlbranding overrides are inherited for free — the count is drawn on whatever logo the deployment actually serves, including the per-domain override added in #585. Staying in SVG rather than rasterising to a canvas also means the browser renders it at whatever size it asks for, so a HiDPI tab is not handed a 16px bitmap.The count is the active account's inbox, read with the store's canonical selector (
role === 'inbox' && !isShared), so a delegated setup cannot badge someone else's inbox.Three things that are deliberate, and non-obvious
We append our own icon link, and never touch one we did not create. Next's metadata
iconsare rendered by React, which keeps a fiber pointing at that DOM node. Removing it — the usual recipe for this feature — leaves React holding a detached node, and the next commit that deletes that fiber throwsCannot read properties of null (reading 'removeChild')in the commit phase. So the badge is an additional<link rel="icon">, marked as ours and appended last (last-declared wins). Non-SVG fallback links keep theirtype/sizes, and nothing of React's is disturbed.Every state change is an insertion, never a mutation or a removal. That is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place
hrefchange — 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 fresh link of ours carrying the original base href. Switching the setting off rides the same path, which is why the plain icon returns immediately.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 — the badge vanished on the way to Calendar and never came back. AMutationObserveron<head>moves our own link back to the end when a foreign icon link appears, and no-ops once it is last, so a move cannot feed itself.The badge design
A white box with black digits, in the bottom-right corner, sized to the label and growing leftwards. The geometry is measured from Gmail's own 16px favicon rather than invented: box height 0.625 of the icon, digit cap height 0.44, flush to the bottom and right edges, corners rounded by about a pixel, weight 500. A single digit occupies roughly a third of the icon's width, so the left of the base mark stays uncovered and the artwork remains recognisable; a three-glyph label fills the width, and its font shrinks modestly to fit.
The corner radius is the load-bearing part. A shape with fully round ends — a disc, or a rounded-end pill — squanders its width on those ends, which is exactly the space three glyphs need: at 16px,
99+in a pill was an illegible smudge, and a single digit read as a plain circle. Squaring the corners off is what makes a compact, label-sized box work.Neutral white rather than the conventional red, because
faviconUrlis admin-overridable and Bulwark's own icon isrgb(219,45,84)— a red badge sat red-on-red.Counts run 1–99, then
99+.Security
The base SVG may be admin-uploaded, and
app/api/admin/branding/[filename]deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it verbatim as a same-origindata:URL would un-fence exactly that, soscript,foreignObjectand everyon*handler are stripped before serialising. The badge's own colours are set via astyleattribute as well as presentation attributes, so a branded base carrying<style>rect{fill:…}</style>cannot recolour it.Mounted app-wide
In the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page cleared it on every hop to settings, calendar or contacts.
A question for you: should deployments be able to enforce this?
The setting added here is a per-user preference, which is the right shape for "I don't want a badged icon".
It is not a branding control, and I have not pretended otherwise. If you would like an organisation that has set its own
faviconUrlto be able to say "do not stamp anything across our logo", that needs an admin-level key —branded<boolean>in/api/configbesidefaviconUrl, with a toggle in the Branding tab. That is about four files and no i18n, and I am happy to add it. I left it out to keep the first cut small, and because it is a product decision that is yours rather than mine.Tests
lib/__tests__/favicon-badge.test.ts,hooks/__tests__/use-favicon-badge.test.tsx,components/__tests__/favicon-badge.test.tsxandstores/__tests__/settings-store-favicon-badge.test.ts— covering the geometry (right-anchoring, label-fitted width, negative-origin and non-square viewBoxes, the corner radius being neither square norh/2), the percent-encoding, the SVG sanitisation, the React-owned-link case, multi-link heads, clear-by-insertion, the observer's termination, the shared-inbox selector, and the setting (default on; off adds no link; toggling off restores the base icon by insertion).npm run typecheck,npm run lintand the 22-locale key-parity test are clean. The setting's strings are present in all 22 locale files with the English text as a placeholder, matching how #561 landed.A note on verification
The three browser behaviours above — React's metadata re-hoist, Firefox's refusal to repaint on a removal, and the commit-phase crash — are not things jsdom can see. Each was found by running the branch against a live Stalwart instance, and each fix was confirmed there in Firefox before being written up. The screenshots are from that deployment.