diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 4b8fbca..5170b77 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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: () => { diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..0da09e0 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/hooks/use-favicon-badge.ts b/hooks/use-favicon-badge.ts new file mode 100644 index 0000000..a8a4c79 --- /dev/null +++ b/hooks/use-favicon-badge.ts @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +const SIZE = 32; + +export function useFaviconBadge(count: number) { + const canvasRef = useRef(null); + const imageRef = useRef(null); + const originalHref = useRef(null); + const countRef = useRef(count); + countRef.current = count; + + useEffect(() => { + const link = document.querySelector("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("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); +} diff --git a/stores/email-store.ts b/stores/email-store.ts index 33f67dd..9169c84 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1027,14 +1027,16 @@ export const useEmailStore = create((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); }