Closes #560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
|
import { getLocaleDirection } from "@/i18n/direction";
|
|
import { Geist, Geist_Mono } from "next/font/google";
|
|
import { headers } from "next/headers";
|
|
import { getLocale, getTranslations } from "next-intl/server";
|
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
|
import { FaviconBadge } from "@/components/favicon-badge";
|
|
import { configManager } from "@/lib/admin/config-manager";
|
|
import {
|
|
matchDomainBranding,
|
|
parseDomainBranding,
|
|
pickRequestHost,
|
|
} from "@/lib/admin/domain-branding";
|
|
import { withBasePath } from "@/lib/browser-navigation";
|
|
import { locales } from "@/i18n/routing";
|
|
import "../globals.css";
|
|
|
|
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
|
// next-intl's getLocale() returns the default locale here - emitting
|
|
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
|
// "translate this page". Recover the active locale from the request pathname
|
|
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
|
// Accept-Language) when the path carries no locale segment.
|
|
async function resolveRequestLocale(): Promise<string> {
|
|
const pathname = (await headers()).get("x-pathname") || "";
|
|
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
|
return seg ?? (await getLocale());
|
|
}
|
|
|
|
const geistSans = Geist({
|
|
variable: "--font-geist-sans",
|
|
subsets: ["latin"],
|
|
});
|
|
|
|
const geistMono = Geist_Mono({
|
|
variable: "--font-geist-mono",
|
|
subsets: ["latin"],
|
|
});
|
|
|
|
export const viewport: Viewport = {
|
|
width: "device-width",
|
|
initialScale: 1,
|
|
viewportFit: "cover",
|
|
};
|
|
|
|
export async function generateMetadata(): Promise<Metadata> {
|
|
await configManager.ensureLoaded();
|
|
// The <head> favicon must honor per-domain branding, exactly like
|
|
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
|
// request host and prefer its override; fall back to the global
|
|
// admin/env/default value when the host has no favicon override (#585).
|
|
const host = pickRequestHost(await headers());
|
|
const domainOverride = matchDomainBranding(
|
|
host,
|
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
|
).faviconUrl;
|
|
const faviconUrl =
|
|
domainOverride && domainOverride.length > 0
|
|
? domainOverride
|
|
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
|
// Localize the <head> description to match the UI language; a hardcoded
|
|
// English description is another signal that makes Chrome offer to
|
|
// "translate this page". Resolve the locale from the request path, since this
|
|
// layout is above the [locale] segment (see resolveRequestLocale).
|
|
const locale = await resolveRequestLocale();
|
|
const t = await getTranslations({ locale });
|
|
|
|
return {
|
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
|
description: t("meta_description"),
|
|
// A private webmail should not be indexed by search engines. This is opt-in
|
|
// via Settings -> General; the default (false) emits noindex/nofollow.
|
|
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
|
? { index: true, follow: true }
|
|
: { index: false, follow: false },
|
|
appleWebApp: {
|
|
capable: true,
|
|
statusBarStyle: "black-translucent",
|
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
|
},
|
|
formatDetection: {
|
|
telephone: false,
|
|
},
|
|
icons: { icon: withBasePath(faviconUrl) },
|
|
};
|
|
}
|
|
|
|
export default async function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const locale = await resolveRequestLocale();
|
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
|
|
|
return (
|
|
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
|
<head>
|
|
<meta name="theme-color" content="#ffffff" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta
|
|
name="apple-mobile-web-app-title"
|
|
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
|
/>
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
{parentOrigin && (
|
|
<meta name="parent-origin" content={parentOrigin} />
|
|
)}
|
|
<script
|
|
nonce={nonce}
|
|
suppressHydrationWarning
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
(function() {
|
|
try {
|
|
const stored = localStorage.getItem('theme-storage');
|
|
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
const resolved = theme === 'system' ? systemTheme : theme;
|
|
document.documentElement.classList.remove('light', 'dark');
|
|
document.documentElement.classList.add(resolved);
|
|
} catch (e) {
|
|
document.documentElement.classList.add('light');
|
|
}
|
|
})();
|
|
`,
|
|
}}
|
|
/>
|
|
</head>
|
|
<body
|
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
>
|
|
<ServiceWorkerRegistration />
|
|
<FaviconBadge />
|
|
{children}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|