mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-23 08:01:14 +00:00
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:
committed by
Matthieu MALVACHE
parent
76cebb7905
commit
393fd12e8f
@@ -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: () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user