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 { 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