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:
+2
-1
@@ -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);
|
||||
|
||||
@@ -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 { 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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user