Merge pull request #243 from Coffey-Labs/fix/login-app-name

Call the instance what it calls itself, on the page that matters most
This commit is contained in:
Coffey Labs
2026-09-02 15:27:20 -07:00
committed by GitHub
5 changed files with 84 additions and 6 deletions
+2 -1
View File
@@ -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);
+40
View File
@@ -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);
});
});
+13
View File
@@ -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";
+7 -3
View File
@@ -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 }) {
</button>
<Link href="/mail" className="brand">
<img src={withBase("/img/logo.png")} alt="" />
{/* 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. */}
<span className="brand-name notranslate" translate="no">
ihasmail
{appName}
</span>
</Link>
<SearchBar />
+22 -2
View File
@@ -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() {
<form className="login-card" onSubmit={submit}>
<div className="logo">
<img src={withBase("/img/logo.png")} alt="" width={120} height={143} />
<h1 className="notranslate" translate="no">ihasmail</h1>
{/* A product name, not a word: not translated, and not guessed at
from the page it is on. */}
<h1 className="notranslate" translate="no">{appName}</h1>
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
</div>
{error && (