Call the instance what it calls itself, on the page that matters most
APP_NAME is a runtime variable and two of the three places showing the name ignored it. The sign-in page fetched /api/config, received the name and used only sourceUrl -- so a rebranded deployment still said "ihasmail" on the one page a new user meets first. The top bar had it written in. Only the document title read it, and it had been reading it from the session all along. The rebranding guide documents both as things to patch yourself, one of them with "if you change nothing else on this page, change this". It should not have to. The sign-in page takes the name from the answer it was already getting. The top bar takes it from the session, where the title has taken it from since it was written. Neither is a new request. One shared default rather than the string written out at three call sites, because three copies of a default is how two of them end up stale. It stands if the config request fails, since a sign-in form with no name on it would be worse than one with the wrong name -- and an empty or non-string name falls back too, so a deployment that sets APP_NAME= does not get a nameless page. Confirmed with APP_NAME set to something else: sign-in heading, top bar and tab title all read it.
This commit is contained in:
+2
-1
@@ -24,6 +24,7 @@ import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
|||||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
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 ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||||
@@ -254,7 +255,7 @@ function AuthedApp() {
|
|||||||
const id = s.roleId("inbox");
|
const id = s.roleId("inbox");
|
||||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
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(() => {
|
useEffect(() => {
|
||||||
void import("@/lib/notify").then((m) => {
|
void import("@/lib/notify").then((m) => {
|
||||||
m.setBaseTitle(appName);
|
m.setBaseTitle(appName);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -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 { 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 { useSession } from "@/store/session";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
|
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||||
import { toggleTarget } from "@/lib/palette";
|
import { toggleTarget } from "@/lib/palette";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -37,6 +38,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const pushState = useSession((s) => s.pushState);
|
const pushState = useSession((s) => s.pushState);
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
const logout = useSession((s) => s.logout);
|
const logout = useSession((s) => s.logout);
|
||||||
|
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||||
const acctMenu = useMenu();
|
const acctMenu = useMenu();
|
||||||
/*
|
/*
|
||||||
* "Go to folder" (#233), hosted here rather than in the mail view because
|
* "Go to folder" (#233), hosted here rather than in the mail view because
|
||||||
@@ -90,10 +92,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
<Link href="/mail" className="brand">
|
<Link href="/mail" className="brand">
|
||||||
<img src={withBase("/img/logo.png")} alt="" />
|
<img src={withBase("/img/logo.png")} alt="" />
|
||||||
{/* A product name, not a word. "ihasmail" translated is a different
|
{/* A product name, not a word: translated it is a different product.
|
||||||
product, and the one on the tab beside it is still called this. */}
|
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">
|
<span className="brand-name notranslate" translate="no">
|
||||||
ihasmail
|
{appName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|||||||
+22
-2
@@ -5,6 +5,7 @@ import { ApiError } from "@/jmap/client";
|
|||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -13,11 +14,28 @@ export function LoginPage() {
|
|||||||
// network, and that includes whoever is looking at this form. The server says
|
// 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.
|
// where its own source lives, so a modified deployment points at its own.
|
||||||
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
|
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(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
fetch(withBase("/api/config"))
|
fetch(withBase("/api/config"))
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.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 */ });
|
.catch(() => { /* the default stands */ });
|
||||||
return () => { live = false; };
|
return () => { live = false; };
|
||||||
}, []);
|
}, []);
|
||||||
@@ -55,7 +73,9 @@ export function LoginPage() {
|
|||||||
<form className="login-card" onSubmit={submit}>
|
<form className="login-card" onSubmit={submit}>
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<img src={withBase("/img/logo.png")} alt="" width={120} height={143} />
|
<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>
|
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user