diff --git a/web/src/App.tsx b/web/src/App.tsx index d3fd548..dd5f944 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,6 +24,7 @@ import { listenForVerification, renewWebPush } from "@/lib/webpushEnable"; import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges"; import { BASE_PATH, withBase } from "@/lib/basePath"; +import { DEFAULT_APP_NAME } from "@/lib/brand"; const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); @@ -254,7 +255,7 @@ function AuthedApp() { const id = s.roleId("inbox"); return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; }); - const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail"); + const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; useEffect(() => { void import("@/lib/notify").then((m) => { m.setBaseTitle(appName); diff --git a/web/src/lib/__tests__/brand.test.ts b/web/src/lib/__tests__/brand.test.ts new file mode 100644 index 0000000..90a4f12 --- /dev/null +++ b/web/src/lib/__tests__/brand.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_APP_NAME } from "@/lib/brand"; + +/* + * The name an instance calls itself. + * + * `APP_NAME` is a runtime variable, so every place showing the name has to ask + * the server rather than have it written in. The sign-in page did not (#236's + * neighbour): it fetched `/api/config`, received the name and used only + * `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new + * user meets first. These pin the shape of the answer rather than the name. + */ + +const nameFrom = (config: { appName?: unknown } | null) => + config && typeof config.appName === "string" && config.appName.trim() ? config.appName.trim() : DEFAULT_APP_NAME; + +describe("resolving the instance name", () => { + it("uses what the server says", () => { + expect(nameFrom({ appName: "Acme Mail" })).toBe("Acme Mail"); + }); + + it("trims it, because a name with an edge of whitespace is a layout bug", () => { + expect(nameFrom({ appName: " Acme Mail " })).toBe("Acme Mail"); + }); + + it("falls back when the request failed", () => { + // A sign-in form with no name on it is worse than one with the wrong name. + expect(nameFrom(null)).toBe(DEFAULT_APP_NAME); + }); + + it("falls back on a name that is empty or only spaces", () => { + expect(nameFrom({ appName: "" })).toBe(DEFAULT_APP_NAME); + expect(nameFrom({ appName: " " })).toBe(DEFAULT_APP_NAME); + }); + + it("falls back on a name that is not a string at all", () => { + expect(nameFrom({ appName: 42 })).toBe(DEFAULT_APP_NAME); + expect(nameFrom({})).toBe(DEFAULT_APP_NAME); + }); +}); diff --git a/web/src/lib/brand.ts b/web/src/lib/brand.ts new file mode 100644 index 0000000..086a4dc --- /dev/null +++ b/web/src/lib/brand.ts @@ -0,0 +1,13 @@ +/** + * What this instance calls itself, when nothing has said otherwise yet. + * + * `APP_NAME` is a runtime environment variable, so the real answer arrives + * from the server -- on `/api/config` before anybody signs in, and on the + * session afterwards. This is what stands in until it does, and what stands + * for good if the request fails: a sign-in form with no name on it would be + * worse than one with the wrong name. + * + * One constant rather than the string written out at each of them, because + * three copies of a default is how two of them end up stale. + */ +export const DEFAULT_APP_NAME = "ihasmail"; diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index ee809b0..adcd35d 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -3,6 +3,7 @@ import { Link, useLocation } from "wouter"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; import { withBase } from "@/lib/basePath"; +import { DEFAULT_APP_NAME } from "@/lib/brand"; import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; @@ -37,6 +38,7 @@ export function AppShell({ children }: { children: ReactNode }) { const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); /* * "Go to folder" (#233), hosted here rather than in the mail view because @@ -90,10 +92,12 @@ export function AppShell({ children }: { children: ReactNode }) { - {/* A product name, not a word. "ihasmail" translated is a different - product, and the one on the tab beside it is still called this. */} + {/* A product name, not a word: translated it is a different product. + Read from the session rather than written here, so a deployment + that set APP_NAME is called what it calls itself -- the document + title has taken it from there all along. */} - ihasmail + {appName} diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx index b152937..2bb64a0 100644 --- a/web/src/views/Login.tsx +++ b/web/src/views/Login.tsx @@ -5,6 +5,7 @@ import { ApiError } from "@/jmap/client"; import { withBase } from "@/lib/basePath"; import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { APP_VERSION } from "@/lib/version"; +import { DEFAULT_APP_NAME } from "@/lib/brand"; import { t } from "@/lib/i18n"; export function LoginPage() { @@ -13,11 +14,28 @@ export function LoginPage() { // network, and that includes whoever is looking at this form. The server says // where its own source lives, so a modified deployment points at its own. const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL); + /* + * What this instance calls itself. + * + * The name was in the `/api/config` answer all along and only `sourceUrl` + * was taken out of it, so an instance with `APP_NAME` set still said + * "ihasmail" on the one page a new user meets first -- the page where the + * name matters most, and the one the rebranding guide had to tell people to + * patch themselves. + * + * Defaults to ihasmail and stays there if the request fails, because a + * sign-in form with no name on it would be worse than a wrong one. + */ + const [appName, setAppName] = useState(DEFAULT_APP_NAME); useEffect(() => { let live = true; fetch(withBase("/api/config")) .then((r) => (r.ok ? r.json() : null)) - .then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); }) + .then((c) => { + if (!live || !c) return; + if (c.sourceUrl) setSourceUrl(c.sourceUrl as string); + if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim()); + }) .catch(() => { /* the default stands */ }); return () => { live = false; }; }, []); @@ -55,7 +73,9 @@ export function LoginPage() {
-

ihasmail

+ {/* A product name, not a word: not translated, and not guessed at + from the page it is on. */} +

{appName}

{t("Fast, friendly webmail. Your mailbox, your way.")}

{error && (