feat: custom favicon with unread badge, fix stale mailbox counts

Replace the default Next.js favicon.ico with an SVG mail icon from
Lucide and add useFaviconBadge, a canvas-based hook that draws a red
unread-count badge on the tab favicon. Shows "+" when count > 9. On
Safari 15 and below (no Canvas roundRect), falls back to fillRect.

Also fixes a real JMAP push bug in the email store: Mailbox state
changes on push weren't always paired with Email changes, so unread
counts in the sidebar could go stale when a new message arrived. Now
Email changes refresh mailbox counts too, and the Mailbox branch
de-dups when both fire in the same push.

Credits #63 (@jabiinfante).
This commit is contained in:
Jabi Infante
2026-04-16 23:06:26 +02:00
committed by Matthieu MALVACHE
parent 76cebb7905
commit 393fd12e8f
5 changed files with 134 additions and 3 deletions
+4
View File
@@ -34,6 +34,7 @@ import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
export default function Home() {
const router = useRouter();
@@ -98,6 +99,9 @@ export default function Home() {
const contactStore = useContactStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
useFaviconBadge(inboxUnread);
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+5
View File
@@ -0,0 +1,5 @@
<!-- Mail icon from Lucide (https://lucide.dev) - MIT License -->
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef } from "react";
const SIZE = 32;
export function useFaviconBadge(count: number) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const originalHref = useRef<string | null>(null);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (!link?.href) return;
originalHref.current = link.href;
const canvas = document.createElement("canvas");
canvas.width = SIZE;
canvas.height = SIZE;
canvasRef.current = canvas;
fetch(link.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 */
});
}, []);
useEffect(() => {
if (count <= 0) {
if (originalHref.current) {
setFavicon(originalHref.current, "image/svg+xml");
}
return;
}
if (canvasRef.current) {
applyBadge(canvasRef.current, imageRef.current, count);
}
}, [count]);
}
function applyBadge(
canvas: HTMLCanvasElement,
image: HTMLImageElement | null,
count: number,
) {
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.clearRect(0, 0, SIZE, SIZE);
if (image) {
ctx.drawImage(image, 0, 0, SIZE, SIZE);
}
const label = count > 9 ? "+" : String(count);
const fontSize = SIZE * 0.55;
const padding = 2;
ctx.font = `bold ${fontSize}px arial,sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const textWidth = ctx.measureText(label).width;
const badgeW = textWidth + padding * 2;
const badgeH = fontSize + padding;
const x = SIZE - badgeW - 1;
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();
ctx.fillStyle = "#ffffff";
ctx.fillText(label, x + padding, y + padding / 2);
setFavicon(canvas.toDataURL("image/png"));
}
function removeCurrentFavicons() {
const links = document.querySelectorAll<HTMLLinkElement>("link[rel~='icon']");
links.forEach((link) => link.remove());
}
export function setFavicon(href: string, type: string = "image/png") {
removeCurrentFavicons();
const link = document.createElement("link");
link.rel = "icon";
link.type = type;
link.href = href;
document.head.appendChild(link);
}
+5 -3
View File
@@ -1027,14 +1027,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
// Handle Email state changes - refresh current mailbox
// Handle Email state changes - refresh current mailbox and mailbox counts
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
get().fetchTagCounts(client);
get().fetchMailboxes(client);
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
// Handle Mailbox state changes - refresh mailbox list (skip if already
// triggered by an Email change in the same push, to avoid double fetch)
if (accountChanges.Mailbox && !accountChanges.Email) {
await get().fetchMailboxes(client);
}