Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath
This commit is contained in:
@@ -22,6 +22,7 @@ import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlready
|
||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { BASE_PATH } from "@/lib/basePath";
|
||||
|
||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
@@ -83,6 +84,17 @@ export function App() {
|
||||
* Reload and tab close are covered by `beforeunload` instead.
|
||||
*/
|
||||
<Router
|
||||
/*
|
||||
* The one place the mount prefix enters the router. Every `<Route path>`,
|
||||
* `<Link href>` and `navigate()` in the app stays written root-absolute
|
||||
* -- `/mail/:mailboxId?` -- and wouter strips the base off the address
|
||||
* before matching and puts it back on when it navigates. So a deep link
|
||||
* to `/mail/inbox/abc` under a `/mail` mount is `/mail/mail/inbox/abc`
|
||||
* and nothing in the views has to know it.
|
||||
*
|
||||
* Empty is wouter's own default, so the root case is untouched.
|
||||
*/
|
||||
base={BASE_PATH}
|
||||
aroundNav={(navigate, to, options) => {
|
||||
if (!hasUnsavedChanges()) {
|
||||
navigate(to, options);
|
||||
|
||||
+12
-4
@@ -1,4 +1,5 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export const CAP = {
|
||||
core: "urn:ietf:params:jmap:core",
|
||||
@@ -62,9 +63,16 @@ export type ResultRef = { resultOf: string; name: string; path: string };
|
||||
|
||||
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
|
||||
/**
|
||||
* Generic fetch against our same-origin API with CSRF header + auth handling.
|
||||
*
|
||||
* `path` is written root-absolute at every call site -- `/api/jmap` -- and the
|
||||
* mount prefix is added here rather than there. One place to get it right, and
|
||||
* the `startsWith` below keeps working on the path as written rather than on
|
||||
* whatever the deployment happens to be called.
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
const res = await fetch(withBase(path), {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
|
||||
credentials: "same-origin",
|
||||
@@ -276,12 +284,12 @@ export class JmapClient {
|
||||
}
|
||||
|
||||
uploadUrl(accountId: Id): string {
|
||||
return `/api/upload/${encodeURIComponent(accountId)}`;
|
||||
return withBase(`/api/upload/${encodeURIComponent(accountId)}`);
|
||||
}
|
||||
|
||||
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
||||
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
||||
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
|
||||
const u = withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`);
|
||||
return inline ? `${u}&inline=1` : u;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Id, StateChange } from "./types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export type PushListener = (accountId: Id, type: string, newState: string) => void;
|
||||
|
||||
@@ -70,7 +71,7 @@ class PushManager {
|
||||
private connect(): void {
|
||||
if (this.stopped || this.es) return;
|
||||
if (this.state !== "connected") this.setState("connecting");
|
||||
const url = `/api/events?types=*&closeafter=no&ping=30`;
|
||||
const url = withBase(`/api/events?types=*&closeafter=no&ping=30`);
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
es.onopen = () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { baseUrlOf, normalizeBasePath, stripBasePath } from "../../../../scripts/basePath.mjs";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
|
||||
/**
|
||||
* `BASE_PATH` is typed into a compose file or a `docker run` line by hand, and
|
||||
* the four spellings below are all reasonable things for someone to write.
|
||||
* The one that has to be exactly right is the empty one: every deployment that
|
||||
* exists today is at the root, and this feature must be invisible to them.
|
||||
*
|
||||
* The canonical form is a leading slash and no trailing one, so that the
|
||||
* concatenation `${base}/api/health` is correct with no branch. A trailing
|
||||
* slash would make the empty case produce `//api/health`, which is not a path
|
||||
* on this host but a protocol-relative URL pointing at a host called `api` --
|
||||
* which is why the tests below check the joined result and not just the value.
|
||||
*/
|
||||
describe("normalizing what the operator wrote", () => {
|
||||
it("leaves the canonical form alone", () => {
|
||||
expect(normalizeBasePath("/mail")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a missing leading slash", () => {
|
||||
expect(normalizeBasePath("mail")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a trailing slash", () => {
|
||||
expect(normalizeBasePath("/mail/")).toBe("/mail");
|
||||
expect(normalizeBasePath("mail/")).toBe("/mail");
|
||||
});
|
||||
|
||||
it("accepts a nested mount, however it is punctuated", () => {
|
||||
expect(normalizeBasePath("apps/mail")).toBe("/apps/mail");
|
||||
expect(normalizeBasePath("/apps/mail/")).toBe("/apps/mail");
|
||||
});
|
||||
|
||||
it("tidies away doubled separators and stray whitespace", () => {
|
||||
expect(normalizeBasePath("//mail//")).toBe("/mail");
|
||||
expect(normalizeBasePath(" /mail ")).toBe("/mail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the root, which must behave exactly as it did", () => {
|
||||
it("is the empty string for every way of saying it", () => {
|
||||
expect(normalizeBasePath("")).toBe("");
|
||||
expect(normalizeBasePath("/")).toBe("");
|
||||
expect(normalizeBasePath("///")).toBe("");
|
||||
expect(normalizeBasePath(undefined)).toBe("");
|
||||
expect(normalizeBasePath(null)).toBe("");
|
||||
});
|
||||
|
||||
it("joins onto an app path without doubling the slash", () => {
|
||||
// `//api/health` would be read as a protocol-relative URL and sent to a
|
||||
// host called `api`. This is the assertion the whole canonical form is for.
|
||||
expect(`${normalizeBasePath("/")}/api/health`).toBe("/api/health");
|
||||
expect(`${normalizeBasePath("/mail")}/api/health`).toBe("/mail/api/health");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the directory form Vite and the PWA scope want", () => {
|
||||
it("always ends in a slash", () => {
|
||||
expect(baseUrlOf("")).toBe("/");
|
||||
expect(baseUrlOf("mail")).toBe("/mail/");
|
||||
expect(baseUrlOf("/mail/")).toBe("/mail/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking the prefix off an incoming request", () => {
|
||||
it("passes everything through untouched at the root", () => {
|
||||
expect(stripBasePath("", "/")).toBe("/");
|
||||
expect(stripBasePath("", "/assets/index.js")).toBe("/assets/index.js");
|
||||
expect(stripBasePath("", "/mail/inbox/abc")).toBe("/mail/inbox/abc");
|
||||
});
|
||||
|
||||
it("strips the mount and keeps the rest", () => {
|
||||
expect(stripBasePath("/mail", "/mail/assets/index.js")).toBe("/assets/index.js");
|
||||
expect(stripBasePath("/mail", "/mail/api/health")).toBe("/api/health");
|
||||
});
|
||||
|
||||
it("treats the bare mount as the app's index", () => {
|
||||
// Typing the prefix without the trailing slash is how people reach it.
|
||||
expect(stripBasePath("/mail", "/mail")).toBe("/");
|
||||
expect(stripBasePath("/mail", "/mail/")).toBe("/");
|
||||
});
|
||||
|
||||
it("refuses a path that merely starts with the same letters", () => {
|
||||
// A plain startsWith would hand `/mailbox` the app shell, shadowing
|
||||
// whatever else the proxy serves on this host.
|
||||
expect(stripBasePath("/mail", "/mailbox")).toBe(null);
|
||||
expect(stripBasePath("/mail", "/mailing/list")).toBe(null);
|
||||
});
|
||||
|
||||
it("refuses anything outside the mount", () => {
|
||||
expect(stripBasePath("/mail", "/")).toBe(null);
|
||||
expect(stripBasePath("/mail", "/other-app/thing")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser's view of the mount", () => {
|
||||
/*
|
||||
* Vitest builds with Vite's default base, so this is the root deployment --
|
||||
* which is the case that must not regress, and the reason these assertions
|
||||
* are worth writing down rather than dismissing as trivial.
|
||||
*/
|
||||
it("is empty in a root build", () => {
|
||||
expect(BASE_PATH).toBe("");
|
||||
});
|
||||
|
||||
it("leaves app paths exactly as written", () => {
|
||||
expect(withBase("/api/health")).toBe("/api/health");
|
||||
expect(withBase("/img/logo.png")).toBe("/img/logo.png");
|
||||
expect(withBase("/sw.js")).toBe("/sw.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The subpath this build is mounted at, as the browser sees it.
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
|
||||
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
|
||||
* normaliser -- so this is the same answer the server reached, not a second
|
||||
* guess at it. Reading it here rather than re-deriving it from
|
||||
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
|
||||
* there is nothing in the address that says how much of it is the mount.
|
||||
*
|
||||
* Vite guarantees the value ends in a slash, so the only conversion is
|
||||
* dropping it; `""` for the root, `/mail` otherwise, matching
|
||||
* `scripts/basePath.mjs`.
|
||||
*/
|
||||
export const BASE_PATH: string = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Turn a root-absolute app path into one the server will answer.
|
||||
*
|
||||
* Every `/api/...`, `/img/...` and `/sw.js` in the app goes through here.
|
||||
* Router paths do not: wouter is given `BASE_PATH` as its base and strips and
|
||||
* re-adds the prefix itself, so `<Link href="/mail">` stays written that way.
|
||||
* Mixing the two would double the prefix, which is why this asserts nothing
|
||||
* and simply concatenates -- the discipline is at the call sites, and the
|
||||
* callers that need it are few and all in this repo.
|
||||
*/
|
||||
export function withBase(path: string): string {
|
||||
return BASE_PATH + path;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/** Best display name for a card. */
|
||||
export function contactDisplayName(c: ContactCard): string {
|
||||
@@ -60,7 +61,7 @@ export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||
if (!m) return null;
|
||||
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
|
||||
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
|
||||
if (m.blobId) return withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
@@ -61,7 +62,7 @@ function hardenCss(css: string): string {
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return `/api/image?url=${encodeURIComponent(url)}`;
|
||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { withBase } from "./basePath";
|
||||
|
||||
let baseTitle = "ihasmail";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
let baseFavicon: HTMLImageElement | null = null;
|
||||
@@ -14,13 +16,13 @@ export function setUnreadBadge(count: number): void {
|
||||
if (!link) return;
|
||||
if (!baseFavicon) {
|
||||
baseFavicon = new Image();
|
||||
baseFavicon.src = "/img/favicon-64.png";
|
||||
baseFavicon.src = withBase("/img/favicon-64.png");
|
||||
baseFavicon.onload = () => setUnreadBadge(count);
|
||||
return;
|
||||
}
|
||||
if (!baseFavicon.complete) return;
|
||||
if (count <= 0) {
|
||||
link.href = "/img/favicon-64.png";
|
||||
link.href = withBase("/img/favicon-64.png");
|
||||
return;
|
||||
}
|
||||
faviconCanvas ??= document.createElement("canvas");
|
||||
@@ -60,7 +62,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
||||
try {
|
||||
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
|
||||
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
opts.onClick?.();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { APP_VERSION } from "./version";
|
||||
import { withBase } from "./basePath";
|
||||
import { push, type PushState } from "@/jmap/push";
|
||||
|
||||
/**
|
||||
@@ -71,7 +72,7 @@ export function reloadIfServerRebuilt(): Promise<boolean> {
|
||||
async function check(): Promise<boolean> {
|
||||
let serverVersion: string;
|
||||
try {
|
||||
const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" });
|
||||
const res = await fetch(withBase("/api/health"), { credentials: "same-origin", cache: "no-store" });
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { version?: unknown };
|
||||
if (typeof body.version !== "string" || !body.version) return false;
|
||||
|
||||
+11
-1
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import "./styles/app.css";
|
||||
import { App } from "./App";
|
||||
import { startBuildWatch } from "@/lib/staleBuild";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
|
||||
startBuildWatch();
|
||||
|
||||
@@ -14,7 +15,16 @@ createRoot(document.getElementById("root")!).render(
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
/*
|
||||
* The scope is spelled out rather than left to default to the script's own
|
||||
* directory. Both come to `${BASE_PATH}/` today, but the default is a
|
||||
* property of where the file happens to sit, and this is a statement about
|
||||
* what the worker is allowed to control -- which under a prefix must stop
|
||||
* at the mount. A worker scoped to `/` on a host shared with other
|
||||
* applications would intercept their navigations too, and its offline
|
||||
* fallback would answer them with ihasmail's shell.
|
||||
*/
|
||||
navigator.serviceWorker.register(withBase("/sw.js"), { scope: `${BASE_PATH}/` }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { BASE_PATH } from "@/lib/basePath";
|
||||
import { settings } from "./settings";
|
||||
import { emlFilename } from "@/lib/emlName";
|
||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
@@ -648,6 +649,23 @@ function scheduleAutosave(key: string, get: () => ComposeState) {
|
||||
);
|
||||
}
|
||||
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/*
|
||||
* How an inline image points at a blob while it is being edited, and how to
|
||||
* find one again on the way out.
|
||||
*
|
||||
* `client.downloadUrl` builds these, so under a subpath they carry the mount
|
||||
* prefix -- and the patterns have to as well. Neither would have failed
|
||||
* loudly. A bare `/api/blob/` still appears *inside* `/mail/api/blob/...`, so
|
||||
* the unanchored replacement would have matched only the tail and left `/mail`
|
||||
* standing in front of a `cid:` reference; the anchored match would simply
|
||||
* have missed, and the message would go out linking to the sender's own
|
||||
* webmail where the picture should be.
|
||||
*/
|
||||
const BLOB_URL_PREFIX = `${BASE_PATH}/api/blob/`;
|
||||
const BLOB_URL_RE = escapeRe(BLOB_URL_PREFIX);
|
||||
|
||||
/** Build the JMAP Email creation object from a draft. */
|
||||
export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
|
||||
const mail = useMail.getState();
|
||||
@@ -662,7 +680,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
|
||||
// Inline attachments shown via blob URLs in the editor → back to cid: references.
|
||||
for (const a of d.attachments) {
|
||||
if (a.inline && a.cid && a.blobId && html) {
|
||||
const re = new RegExp(`/api/blob/[^"' )]*${a.blobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"' )]*`, "g");
|
||||
const re = new RegExp(`${BLOB_URL_RE}[^"' )]*${escapeRe(a.blobId)}[^"' )]*`, "g");
|
||||
html = html.replace(re, `cid:${a.cid}`);
|
||||
}
|
||||
}
|
||||
@@ -670,11 +688,11 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
|
||||
const related: EmailBodyPart[] = [];
|
||||
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = [];
|
||||
// Images referencing stored blobs (e.g. signature logos kept in Files) → inline cid parts.
|
||||
if (html && html.includes("/api/blob/")) {
|
||||
if (html && html.includes(BLOB_URL_PREFIX)) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of Array.from(doc.querySelectorAll("img"))) {
|
||||
const src = img.getAttribute("src") ?? "";
|
||||
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src);
|
||||
const m = new RegExp(`^${BLOB_URL_RE}([^/]+)/([^/]+)/([^?]+)(?:\\?([^#]*))?`).exec(src);
|
||||
if (!m) continue;
|
||||
const blobId = decodeURIComponent(m[2]!);
|
||||
const name = decodeURIComponent(m[3]!);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/*
|
||||
* Named explicitly so `shareWith` comes back, which it does not otherwise --
|
||||
@@ -1106,7 +1107,10 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
tag: e.id,
|
||||
onClick: () => {
|
||||
window.location.hash = "";
|
||||
window.history.pushState({}, "", `/mail/${inbox}/${e.threadId}`);
|
||||
// The one navigation that does not go through wouter -- it is
|
||||
// synthesising a popstate so the router picks the address up -- so
|
||||
// it is also the one that has to add the mount prefix itself.
|
||||
window.history.pushState({}, "", withBase(`/mail/${inbox}/${e.threadId}`));
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
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 { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
@@ -80,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
<Link href="/mail" className="brand">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<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. */}
|
||||
<span className="brand-name notranslate" translate="no">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Eye, EyeOff, LogIn } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { ApiError } from "@/jmap/client";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import { t } from "@/lib/i18n";
|
||||
@@ -14,7 +15,7 @@ export function LoginPage() {
|
||||
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
fetch("/api/config")
|
||||
fetch(withBase("/api/config"))
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
|
||||
.catch(() => { /* the default stands */ });
|
||||
@@ -53,7 +54,7 @@ export function LoginPage() {
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="logo">
|
||||
<img src="/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>
|
||||
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
@@ -353,7 +354,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
|
||||
) : (
|
||||
<div className="no-thread">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<img src={withBase("/img/logo.png")} alt="" />
|
||||
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
|
||||
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
|
||||
import { client } from "@/jmap/client";
|
||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
|
||||
export function AboutSettings() {
|
||||
@@ -14,7 +15,7 @@ export function AboutSettings() {
|
||||
<h1>{t("About ihasmail")}</h1>
|
||||
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a> })}</p>
|
||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||
<img src="/img/logo.png" alt={t("ihasmail")} width={96} />
|
||||
<img src={withBase("/img/logo.png")} alt={t("ihasmail")} width={96} />
|
||||
<div>
|
||||
{/* A product name and a version string: neither is a word to translate. */}
|
||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
|
||||
|
||||
Reference in New Issue
Block a user