mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-22 16:01:12 +00:00
fix: favicon badge — remove favicon.ico, fix Firefox compat
- Delete app/favicon.ico so Next.js renders a single <link rel="icon"> for icon.svg. With two icon links in the DOM, browsers disagreed on which to use: Chrome and Firefox on Linux both picked the *first* one, making the dynamically-appended badge link invisible. - Mutate href on the existing <link> instead of injecting a second one. Firefox only updates the tab icon when the href of the element it already tracks changes — adding new <link> nodes dynamically is ignored. - Simplify image loading: replace fetch → blob → FileReader → Image chain with new Image() + crossOrigin="anonymous". - Fix badge border in Safari 15 fallback: use strokeRect instead of stroke() after fillRect (fillRect does not add to the canvas path).
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+49
-74
@@ -3,81 +3,81 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const SIZE = 32;
|
||||
const DYNAMIC_ATTR = "data-dynamic-favicon";
|
||||
|
||||
export function useFaviconBadge(count: number) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const baseLinkRef = useRef<HTMLLinkElement | null>(null);
|
||||
const originalHrefRef = useRef<string>("");
|
||||
const imageLoaded = useRef(false);
|
||||
const countRef = useRef(count);
|
||||
countRef.current = count;
|
||||
|
||||
useEffect(() => {
|
||||
// Grab the base favicon href from whatever the document currently has
|
||||
// (Next.js renders one from app/icon.svg). We NEVER remove or edit
|
||||
// that link — Next.js's metadata reconciler owns it and pulling it
|
||||
// out from under React crashes reconciliation with
|
||||
// "parentNode is null". Our badge lives on its own link tagged with
|
||||
// data-dynamic-favicon.
|
||||
const baseLink = document.querySelector<HTMLLinkElement>("link[rel~='icon']:not([data-dynamic-favicon])");
|
||||
// We mutate href on the existing Next.js-rendered <link> directly.
|
||||
// Firefox only updates the tab favicon when the href of the element it
|
||||
// already tracks changes — it ignores new <link> nodes added dynamically.
|
||||
// Changing href is safe: the React reconciler crash ("parentNode is null")
|
||||
// only happens when a tracked node is *removed* from the DOM, not when
|
||||
// its attributes change. On cleanup we restore the original href.
|
||||
const baseLink = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!baseLink?.href) return;
|
||||
|
||||
baseLinkRef.current = baseLink;
|
||||
originalHrefRef.current = baseLink.href;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = SIZE;
|
||||
canvas.height = SIZE;
|
||||
canvasRef.current = canvas;
|
||||
|
||||
fetch(baseLink.href)
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
if (countRef.current > 0) {
|
||||
applyBadge(canvas, img, countRef.current);
|
||||
}
|
||||
};
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(() => {
|
||||
/* favicon not loadable — badge will draw without base icon */
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
imageLoaded.current = true;
|
||||
imageRef.current = img;
|
||||
if (countRef.current > 0) {
|
||||
drawBadge(canvas, img, countRef.current, baseLink);
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
imageLoaded.current = true;
|
||||
};
|
||||
img.src = originalHrefRef.current;
|
||||
|
||||
return () => {
|
||||
clearDynamicFavicon();
|
||||
if (baseLinkRef.current) {
|
||||
baseLinkRef.current.href = originalHrefRef.current;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const baseLink = baseLinkRef.current;
|
||||
if (!baseLink || !imageLoaded.current) return;
|
||||
|
||||
if (count <= 0) {
|
||||
clearDynamicFavicon();
|
||||
baseLink.href = originalHrefRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasRef.current) {
|
||||
applyBadge(canvasRef.current, imageRef.current, count);
|
||||
drawBadge(canvasRef.current, imageRef.current, count, baseLink);
|
||||
}
|
||||
}, [count]);
|
||||
}
|
||||
|
||||
function applyBadge(
|
||||
function drawBadge(
|
||||
canvas: HTMLCanvasElement,
|
||||
image: HTMLImageElement | null,
|
||||
count: number,
|
||||
link: HTMLLinkElement,
|
||||
) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
|
||||
if (image) {
|
||||
ctx.drawImage(image, 0, 0, SIZE, SIZE);
|
||||
}
|
||||
if (image) ctx.drawImage(image, 0, 0, SIZE, SIZE);
|
||||
|
||||
const label = count > 9 ? "+" : String(count);
|
||||
const fontSize = SIZE * 0.55;
|
||||
@@ -92,46 +92,21 @@ function applyBadge(
|
||||
const y = SIZE - badgeH;
|
||||
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.beginPath();
|
||||
// roundRect is Baseline 2023; fall back to fillRect on Safari 15 and below.
|
||||
if (typeof ctx.roundRect === "function") {
|
||||
ctx.roundRect(x, y, badgeW, badgeH, 3);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(x, y, badgeW, badgeH);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#b91c1c";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
// roundRect is Baseline 2023; fall back to fillRect on Safari 15 and below.
|
||||
if (typeof ctx.roundRect === "function") {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, badgeW, badgeH, 3);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillRect(x, y, badgeW, badgeH);
|
||||
ctx.strokeRect(x, y, badgeW, badgeH);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(label, x + padding, y + padding / 2);
|
||||
|
||||
setDynamicFavicon(canvas.toDataURL("image/png"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a dynamic favicon link marked with data-dynamic-favicon. The
|
||||
* app/icon.svg managed by Next.js is left alone. Browsers pick the
|
||||
* last matching link for rel="icon", so the dynamic link takes
|
||||
* precedence on Chromium; Firefox/Safari fall back to the static icon,
|
||||
* which is fine — the count-based badge remains a Chromium nicety.
|
||||
*/
|
||||
function setDynamicFavicon(href: string) {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[${DYNAMIC_ATTR}]`);
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.setAttribute(DYNAMIC_ATTR, "");
|
||||
link.rel = "icon";
|
||||
link.type = "image/png";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.href !== href) {
|
||||
link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDynamicFavicon() {
|
||||
document.querySelector(`link[${DYNAMIC_ATTR}]`)?.remove();
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user