From 67fb5f513eedbdb7c786dc134f19d87aaba8c6b2 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Fri, 24 Jul 2026 15:31:21 -0700 Subject: [PATCH] browser tab show it --- src/app/layout.tsx | 2 + src/app/smoke-tests/SmokeHarness.tsx | 87 ++++++++++++++++++- src/app/smoke-tests/page.tsx | 1 + src/components/BrowserTabIndicator.tsx | 56 ++++++++++++ src/lib/__tests__/browserTabIndicator.test.ts | 55 ++++++++++++ src/lib/browserTabIndicator.ts | 42 +++++++++ tests/smoke/mail.spec.ts | 25 ++++++ 7 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/components/BrowserTabIndicator.tsx create mode 100644 src/lib/__tests__/browserTabIndicator.test.ts create mode 100644 src/lib/browserTabIndicator.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 147c73f..97e0f70 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import Script from "next/script"; import { Suspense } from "react"; import { auth, signOut } from "@/auth"; import AppearanceProvider from "@/components/AppearanceProvider"; +import BrowserTabIndicator from "@/components/BrowserTabIndicator"; import DesktopNav from "@/components/DesktopNav"; import MobileNav from "@/components/MobileNav"; import NavigationGuardProvider from "@/components/NavigationGuardProvider"; @@ -63,6 +64,7 @@ export default async function RootLayout({ + {session && ( diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index b2c7395..530a75c 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -5,7 +5,14 @@ import { useCallback, useState } from "react"; import AttachmentList from "@/components/AttachmentList"; import Composer from "@/components/Composer"; import EmailListPanel from "@/components/EmailListPanel"; +import { useUnreadCount } from "@/components/UnreadCountProvider"; +import { + DEFAULT_FAVICON_HREF, + getBrowserTabTitle, + getUnreadFaviconDataUrl, +} from "@/lib/browserTabIndicator"; import { prepareHtml } from "@/lib/emailHtml"; +import { dispatchUnreadCountEvent } from "@/lib/unreadCount"; import type { Email, EmailBodyPart } from "@/lib/types"; export type SmokePanel = @@ -14,7 +21,8 @@ export type SmokePanel = | "attachments" | "target" | "auto-sync" - | "dark-rendering"; + | "dark-rendering" + | "tab-indicator"; const fixtureEmails: Email[] = [ { @@ -114,6 +122,81 @@ const navItems: Array<{ panel: SmokePanel; label: string }> = [ { panel: "attachments", label: "Attachments" }, ]; +function TabIndicatorSmokePanel() { + const unreadCount = useUnreadCount(); + + function setUnreadCount(targetCount: number) { + const difference = targetCount - unreadCount; + if (difference === 0) return; + + dispatchUnreadCountEvent( + difference > 0 ? "unread" : "read", + Array.from( + { length: Math.abs(difference) }, + (_, index) => `tab-indicator-${targetCount}-${index}`, + ), + ); + } + + return ( +
+
+
+
0 + ? getUnreadFaviconDataUrl(unreadCount) + : DEFAULT_FAVICON_HREF + }")`, + }} + /> +
+

+ {getBrowserTabTitle(unreadCount)} +

+

+ {unreadCount === 0 + ? "No unread email" + : `${unreadCount} unread ${unreadCount === 1 ? "email" : "emails"}`} +

+
+
+
+ + + +
+
+
+ ); +} + export default function SmokeHarness({ panel }: { panel: SmokePanel }) { const [autoSyncEmails, setAutoSyncEmails] = useState(fixtureEmails); const runAutoSyncCheck = useCallback(async () => { @@ -232,6 +315,8 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { )} + {panel === "tab-indicator" && } + {panel === "target" && (

diff --git a/src/app/smoke-tests/page.tsx b/src/app/smoke-tests/page.tsx index 78c9378..1c24284 100644 --- a/src/app/smoke-tests/page.tsx +++ b/src/app/smoke-tests/page.tsx @@ -12,6 +12,7 @@ const smokePanels = new Set([ "target", "auto-sync", "dark-rendering", + "tab-indicator", ]); export default async function SmokePage({ searchParams }: Props) { diff --git a/src/components/BrowserTabIndicator.tsx b/src/components/BrowserTabIndicator.tsx new file mode 100644 index 0000000..b8e372e --- /dev/null +++ b/src/components/BrowserTabIndicator.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useUnreadCount } from "@/components/UnreadCountProvider"; +import { + DEFAULT_FAVICON_HREF, + formatBrowserTabUnreadCount, + getBrowserTabTitle, + getUnreadFaviconDataUrl, +} from "@/lib/browserTabIndicator"; + +const MANAGED_FAVICON_SELECTOR = 'link[data-mail-unread-favicon="true"]'; + +export default function BrowserTabIndicator() { + const unreadCount = useUnreadCount(); + const faviconRef = useRef(null); + + useEffect(() => { + let favicon = document.head.querySelector( + MANAGED_FAVICON_SELECTOR, + ); + const createdFavicon = !favicon; + + if (!favicon) { + favicon = document.createElement("link"); + favicon.rel = "icon"; + favicon.type = "image/svg+xml"; + favicon.sizes = "any"; + favicon.dataset.mailUnreadFavicon = "true"; + document.head.append(favicon); + } + + faviconRef.current = favicon; + + return () => { + faviconRef.current = null; + if (createdFavicon) favicon.remove(); + }; + }, []); + + useEffect(() => { + const unreadLabel = formatBrowserTabUnreadCount(unreadCount); + const favicon = faviconRef.current; + + document.title = getBrowserTabTitle(unreadCount); + + if (favicon) { + favicon.href = unreadLabel + ? getUnreadFaviconDataUrl(unreadCount) + : DEFAULT_FAVICON_HREF; + favicon.dataset.unreadCount = unreadLabel; + } + }, [unreadCount]); + + return null; +} diff --git a/src/lib/__tests__/browserTabIndicator.test.ts b/src/lib/__tests__/browserTabIndicator.test.ts new file mode 100644 index 0000000..386ebd9 --- /dev/null +++ b/src/lib/__tests__/browserTabIndicator.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + formatBrowserTabUnreadCount, + getBrowserTabTitle, + getUnreadFaviconDataUrl, + getUnreadFaviconSvg, +} from "../browserTabIndicator"; + +describe("formatBrowserTabUnreadCount", () => { + it("shows exact unread totals below 99", () => { + assert.equal(formatBrowserTabUnreadCount(1), "1"); + assert.equal(formatBrowserTabUnreadCount(98), "98"); + }); + + it("caps unread totals at 99+", () => { + assert.equal(formatBrowserTabUnreadCount(99), "99+"); + assert.equal(formatBrowserTabUnreadCount(412), "99+"); + }); + + it("treats invalid, negative, and fractional counts safely", () => { + assert.equal(formatBrowserTabUnreadCount(0), ""); + assert.equal(formatBrowserTabUnreadCount(-2), ""); + assert.equal(formatBrowserTabUnreadCount(Number.NaN), ""); + assert.equal(formatBrowserTabUnreadCount(8.9), "8"); + }); +}); + +describe("getBrowserTabTitle", () => { + it("puts the unread count first so it remains visible in a narrow tab", () => { + assert.equal(getBrowserTabTitle(7), "(7) Mail"); + assert.equal(getBrowserTabTitle(99), "(99+) Mail"); + }); + + it("restores the normal title when the inbox is clear", () => { + assert.equal(getBrowserTabTitle(0), "Mail"); + }); +}); + +describe("getUnreadFaviconSvg", () => { + it("uses the themed mail mark with a readable capped count", () => { + const svg = getUnreadFaviconSvg(120); + + assert.match(svg, /fill="#0f172a"/); + assert.match(svg, /fill="#2563eb"/); + assert.match(svg, />99\+<\/text>/); + }); + + it("encodes the generated SVG as a favicon data URL", () => { + const dataUrl = getUnreadFaviconDataUrl(12); + + assert.match(dataUrl, /^data:image\/svg\+xml,/); + assert.match(decodeURIComponent(dataUrl), />12<\/text>/); + }); +}); diff --git a/src/lib/browserTabIndicator.ts b/src/lib/browserTabIndicator.ts new file mode 100644 index 0000000..92c3130 --- /dev/null +++ b/src/lib/browserTabIndicator.ts @@ -0,0 +1,42 @@ +export const DEFAULT_BROWSER_TAB_TITLE = "Mail"; +export const DEFAULT_FAVICON_HREF = "/icon.svg"; + +function normalizeUnreadCount(count: number): number { + if (!Number.isFinite(count)) return 0; + return Math.max(0, Math.floor(count)); +} + +export function formatBrowserTabUnreadCount(count: number): string { + const normalizedCount = normalizeUnreadCount(count); + if (normalizedCount === 0) return ""; + return normalizedCount >= 99 ? "99+" : String(normalizedCount); +} + +export function getBrowserTabTitle( + count: number, + baseTitle = DEFAULT_BROWSER_TAB_TITLE, +): string { + const unreadLabel = formatBrowserTabUnreadCount(count); + return unreadLabel ? `(${unreadLabel}) ${baseTitle}` : baseTitle; +} + +export function getUnreadFaviconSvg(count: number): string { + const unreadLabel = formatBrowserTabUnreadCount(count); + const label = unreadLabel || "1"; + const badgeWidth = label.length === 1 ? 28 : label.length === 2 ? 35 : 43; + const badgeX = 62 - badgeWidth; + const fontSize = label.length === 1 ? 19 : label.length === 2 ? 16 : 12.5; + + return ` + ${label} unread ${label === "1" ? "email" : "emails"} + + + + + ${label} +`; +} + +export function getUnreadFaviconDataUrl(count: number): string { + return `data:image/svg+xml,${encodeURIComponent(getUnreadFaviconSvg(count))}`; +} diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index 17be6d7..3c550f6 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -18,6 +18,31 @@ test("serves the themed favicon without an auth redirect", async ({ expect(response.headers()["content-type"]).toContain("image/svg+xml"); }); +test("keeps the browser tab in sync with live unread mail", async ({ + page, +}) => { + await page.goto("/smoke-tests?panel=tab-indicator"); + + const favicon = page.locator('link[data-mail-unread-favicon="true"]'); + await expect(page).toHaveTitle("Mail"); + await expect(favicon).toHaveAttribute("href", /\/icon\.svg$/); + await expect(favicon).toHaveAttribute("data-unread-count", ""); + + await page.getByRole("button", { name: "Set 1 unread" }).click(); + await expect(page).toHaveTitle("(1) Mail"); + await expect(favicon).toHaveAttribute("href", /^data:image\/svg\+xml,/); + await expect(favicon).toHaveAttribute("data-unread-count", "1"); + + await page.getByRole("button", { name: "Set 99 unread" }).click(); + await expect(page).toHaveTitle("(99+) Mail"); + await expect(favicon).toHaveAttribute("data-unread-count", "99+"); + + await page.getByRole("button", { name: "Clear unread" }).click(); + await expect(page).toHaveTitle("Mail"); + await expect(favicon).toHaveAttribute("href", /\/icon\.svg$/); + await expect(favicon).toHaveAttribute("data-unread-count", ""); +}); + test("loads sender artwork and falls back cleanly when it is unavailable", async ({ page, }) => { -- 2.51.2