mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Settings get a page of their own for accessibility, with presets on top (screen reader, low vision, dyslexia, focus, keyboard and motor, motion and seizure safe, deaf and hard of hearing) that change several options at once and can be undone, then the options by topic, and a reset. Text size used to be a fixed 14/16/18px on the root, which threw away the size people set in their browser. It is now a percentage of the browser's size, from 87.5% to 200%. Motion, contrast and transparency follow the system by default and can be overridden either way; the old Animations switch becomes Reduce motion. The visual options are applied before the first paint, so the page no longer flashes at the wrong size. - `lib/accessibility-settings.ts`: the new settings, their allowed values, and the code that writes them onto `<html>` - `app/accessibility.css`: bundled fonts (Atkinson Hyperlegible Next, Lexend, OpenDyslexic), text spacing, a focus outline that survives Windows contrast themes, more-contrast colours on any theme, reduced transparency, a large pointer - admins can preset every new option but lock only shortcuts, send confirmation and the notification duration; a lock on text size or another reading aid works as a default - always-light mail and toolbar labels now sync, and imports drop values the page doesn't know - font size and animations move off Appearance, the plain text font off Reading; everyday settings such as conversation view and undo send show on both their own page and Accessibility
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
|
import { getLocaleDirection } from "@/i18n/direction";
|
|
import { Geist, Geist_Mono, Hanken_Grotesk } 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 { ThemeColorSync } from "@/components/theme-color-sync";
|
|
import { IosViewportHeal } from "@/components/ios-viewport-heal";
|
|
import { configManager } from "@/lib/admin/config-manager";
|
|
import {
|
|
matchDomainBranding,
|
|
parseDomainBranding,
|
|
pickRequestHost,
|
|
type BrandingOverrideKey,
|
|
} from "@/lib/admin/domain-branding";
|
|
import { withBasePath } from "@/lib/browser-navigation";
|
|
import { locales, defaultLocale } from "@/i18n/routing";
|
|
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
|
|
import { accessibilityBootScript } from "@/lib/accessibility-settings";
|
|
import "../globals.css";
|
|
import "../print.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"],
|
|
});
|
|
|
|
// The "Flat fields" theme's family. Not preloaded and no metric-adjusted
|
|
// fallback: the file is only fetched once that theme renders text in it, and
|
|
// scripts Hanken does not cover fall through to the theme's system stack.
|
|
const hankenGrotesk = Hanken_Grotesk({
|
|
variable: "--font-hanken",
|
|
subsets: ["latin", "latin-ext"],
|
|
display: "swap",
|
|
preload: false,
|
|
adjustFontFallback: false,
|
|
});
|
|
|
|
// Resolve a branding value for the requesting host: per-domain override first,
|
|
// then the global admin/env/default value (same precedence as app/manifest.ts).
|
|
async function brandedValue(key: BrandingOverrideKey, fallback: string): Promise<string> {
|
|
const host = pickRequestHost(await headers());
|
|
const override = matchDomainBranding(
|
|
host,
|
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
|
)[key];
|
|
if (typeof override === "string" && override.length > 0) return override;
|
|
return configManager.get<string>(key, fallback);
|
|
}
|
|
|
|
/**
|
|
* Whether an admin explicitly set a PWA theme colour (per-domain override, or
|
|
* an admin/env value rather than the built-in default). When they have, it wins
|
|
* outright; when they haven't, <ThemeColorSync /> tracks the active theme.
|
|
*/
|
|
async function hasExplicitThemeColor(): Promise<boolean> {
|
|
const host = pickRequestHost(await headers());
|
|
const override = matchDomainBranding(
|
|
host,
|
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
|
).pwaThemeColor;
|
|
if (typeof override === "string" && override.length > 0) return true;
|
|
return configManager.getAllWithSources().pwaThemeColor?.source !== "default";
|
|
}
|
|
|
|
/**
|
|
* Absolute origin this request came in on, used as `metadataBase` so Next.js
|
|
* can turn the relative OG image path into the absolute URL that link-preview
|
|
* crawlers require. Returns null when the host header is unusable, in which
|
|
* case we omit metadataBase rather than emit a localhost URL.
|
|
*/
|
|
async function requestOrigin(): Promise<URL | null> {
|
|
const h = await headers();
|
|
const first = (name: string) => h.get(name)?.split(",")[0]?.trim() ?? "";
|
|
// pickRequestHost() strips the port, which the origin needs to keep, so read
|
|
// the raw headers here with the same X-Forwarded-Host-first precedence.
|
|
let authority = first("x-forwarded-host") || first("host");
|
|
if (!authority) return null;
|
|
|
|
const forwardedProto = first("x-forwarded-proto").toLowerCase();
|
|
// Assume TLS when the proxy doesn't say: a webmail served over plain HTTP is
|
|
// the exception, and an https:// preview URL is the safer guess.
|
|
const proto = forwardedProto === "http" || forwardedProto === "https" ? forwardedProto : "https";
|
|
|
|
// A proxy (and Next's own dev server) may carry the port separately, leaving
|
|
// X-Forwarded-Host portless. Re-attach it unless it's the default for the scheme.
|
|
if (!authority.includes(":")) {
|
|
const port = first("x-forwarded-port");
|
|
const isDefaultPort = (proto === "https" && port === "443") || (proto === "http" && port === "80");
|
|
if (/^\d+$/.test(port) && !isDefaultPort) authority += `:${port}`;
|
|
}
|
|
|
|
try {
|
|
return new URL(`${proto}://${authority}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** App name, resolved exactly like app/manifest.ts so <head> and manifest agree. */
|
|
async function brandedAppName(): Promise<string> {
|
|
if (IS_LITE) return process.env.NEXT_PUBLIC_APP_NAME || "Webmail";
|
|
return (
|
|
(await brandedValue("appName", "")) ||
|
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
|
"Webmail"
|
|
);
|
|
}
|
|
|
|
export async function generateViewport(): Promise<Viewport> {
|
|
if (IS_LITE) {
|
|
// No admin config in the static build; <ThemeColorSync /> tracks the theme.
|
|
return { width: "device-width", initialScale: 1, viewportFit: "cover", themeColor: "#ffffff" };
|
|
}
|
|
await configManager.ensureLoaded();
|
|
// Chromium uses <meta name="theme-color"> in preference to the manifest's
|
|
// theme_color when coloring an installed desktop PWA's title bar, so a
|
|
// hardcoded value here silently overrode the admin setting (#671). Emit the
|
|
// configured colour instead, honoring per-domain branding like the manifest.
|
|
return {
|
|
width: "device-width",
|
|
initialScale: 1,
|
|
viewportFit: "cover",
|
|
themeColor: (await brandedValue("pwaThemeColor", "")) || "#ffffff",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Static-build metadata: no request to read a host or locale from, so the
|
|
* <head> carries the build-time app name, the default locale's description
|
|
* and the manifest postbuild writes next to index.html. The inline bootstrap
|
|
* script below corrects <html lang/dir> per page in the browser.
|
|
*/
|
|
async function liteMetadata(): Promise<Metadata> {
|
|
const appName = await brandedAppName();
|
|
const t = await getTranslations({ locale: defaultLocale });
|
|
return {
|
|
title: appName,
|
|
description: t("meta_description"),
|
|
applicationName: appName,
|
|
robots: { index: false, follow: false },
|
|
manifest: withBasePath("/manifest.webmanifest"),
|
|
appleWebApp: {
|
|
capable: true,
|
|
statusBarStyle: "black-translucent",
|
|
title: appName,
|
|
},
|
|
formatDetection: {
|
|
telephone: false,
|
|
},
|
|
icons: { icon: withBasePath("/branding/Bulwark_Favicon.svg") },
|
|
};
|
|
}
|
|
|
|
export async function generateMetadata(): Promise<Metadata> {
|
|
if (IS_LITE) return liteMetadata();
|
|
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 faviconUrl = await brandedValue("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 });
|
|
|
|
const appName = await brandedAppName();
|
|
// An admin-configured description is branding and outranks the built-in
|
|
// string; without one, use the localized default so the <head> matches the
|
|
// UI language (same precedence as app/manifest.ts).
|
|
const description = (await brandedValue("appDescription", "")) || t("meta_description");
|
|
const origin = await requestOrigin();
|
|
|
|
return {
|
|
title: appName,
|
|
description,
|
|
applicationName: appName,
|
|
// 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 },
|
|
// Link previews are worth emitting even under noindex: pasting the webmail
|
|
// URL into a chat should unfurl as the deployment's own brand, not as a
|
|
// bare link. /api/og-image renders the branded 1200x630 card.
|
|
...(origin ? { metadataBase: origin } : {}),
|
|
openGraph: {
|
|
type: "website",
|
|
siteName: appName,
|
|
title: appName,
|
|
description,
|
|
locale,
|
|
url: withBasePath("/"),
|
|
images: [
|
|
{
|
|
url: withBasePath("/api/og-image"),
|
|
width: 1200,
|
|
height: 630,
|
|
alt: appName,
|
|
type: "image/png",
|
|
},
|
|
],
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: appName,
|
|
description,
|
|
images: [withBasePath("/api/og-image")],
|
|
},
|
|
appleWebApp: {
|
|
capable: true,
|
|
statusBarStyle: "black-translucent",
|
|
title: appName,
|
|
},
|
|
formatDetection: {
|
|
telephone: false,
|
|
},
|
|
icons: { icon: withBasePath(faviconUrl) },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lite only: the exported HTML is one document per locale, but this layout
|
|
* sits above the [locale] segment and cannot know which one it is rendering.
|
|
* Fix <html lang/dir> from the URL before first paint, in the same inline
|
|
* script that applies the theme.
|
|
*/
|
|
function liteLocaleBootstrap(): string {
|
|
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
|
const rtl = locales.filter((l) => getLocaleDirection(l) === "rtl");
|
|
return `
|
|
try {
|
|
var locales = ${JSON.stringify([...locales])};
|
|
var rtl = ${JSON.stringify(rtl)};
|
|
var base = ${JSON.stringify(basePath)} || (typeof window.${LITE_MOUNT_GLOBAL} === 'string' ? window.${LITE_MOUNT_GLOBAL} : '');
|
|
var path = location.pathname;
|
|
if (base && path.indexOf(base) === 0) path = path.slice(base.length);
|
|
var seg = path.split('/').filter(Boolean)[0];
|
|
if (seg && locales.indexOf(seg) !== -1) {
|
|
document.documentElement.lang = seg;
|
|
document.documentElement.dir = rtl.indexOf(seg) !== -1 ? 'rtl' : 'ltr';
|
|
}
|
|
} catch (e) {}`;
|
|
}
|
|
|
|
export default async function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
|
|
|
let locale: string;
|
|
let nonce: string | undefined;
|
|
let themeColorConfigured: boolean;
|
|
if (IS_LITE) {
|
|
// Static export: no request, no nonce, no admin config.
|
|
locale = defaultLocale;
|
|
nonce = undefined;
|
|
themeColorConfigured = false;
|
|
} else {
|
|
await configManager.ensureLoaded();
|
|
locale = await resolveRequestLocale();
|
|
nonce = (await headers()).get("x-nonce") ?? "";
|
|
themeColorConfigured = await hasExplicitThemeColor();
|
|
}
|
|
const appName = await brandedAppName();
|
|
|
|
return (
|
|
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
|
<head>
|
|
<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={appName} />
|
|
<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() {${IS_LITE ? liteLocaleBootstrap() : ""}
|
|
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');
|
|
}
|
|
${accessibilityBootScript()}
|
|
})();
|
|
`,
|
|
}}
|
|
/>
|
|
</head>
|
|
<body
|
|
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
|
|
>
|
|
<ServiceWorkerRegistration />
|
|
{!themeColorConfigured && <ThemeColorSync />}
|
|
<FaviconBadge />
|
|
<IosViewportHeal />
|
|
{children}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|