Version the brand images' URLs, so a new mark reaches returning visitors
The logo, favicons and app icons are served from public/img under fixed names with a browser cache of hours, and the service worker fetches them through that cache. After the mark changed on 2026-09-27, returning visitors kept the old cat until their copies expired, and the favicon and an installed app's icon hold on longer still. Every URL that names one now carries ?v=BRAND_V (src/lib/brand.ts, brandImage()): the header, sign-in, About, the mail empty state, the notification icons, index.html's favicon links, the manifest's icons and the service worker's shell and notification icons. Date-stamped, never a counter, for the sites' ASSET_V reason; the three static files carry the value written out, and the comment says to keep them in step.
This commit is contained in:
1 parent
2a30a32c79
commit
0adc402629
9 files changed
+49
-25
No files matched your search
+3
-3
@@ -19,9 +19,9 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico?v=2026-09-27a" sizes="any" />
|
||||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png?v=2026-09-27a" />
|
||||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png?v=2026-09-27a" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>inbuxa</title>
|
<title>inbuxa</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -51,17 +51,17 @@
|
|||||||
"theme_color": "#0f766e",
|
"theme_color": "#0f766e",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "img/icon-192.png",
|
"src": "img/icon-192.png?v=2026-09-27a",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "img/icon-512.png",
|
"src": "img/icon-512.png?v=2026-09-27a",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "img/icon-maskable.png",
|
"src": "img/icon-maskable.png?v=2026-09-27a",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
|||||||
+8
-6
@@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2";
|
|||||||
* eventually would.
|
* eventually would.
|
||||||
*/
|
*/
|
||||||
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
||||||
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
// The brand images' version; the same value as BRAND_V in src/lib/brand.ts.
|
||||||
|
const BRAND_V = "2026-09-27a";
|
||||||
|
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png?v=${BRAND_V}`, `${BASE}/img/icon-192.png?v=${BRAND_V}`, `${BASE}/favicon.ico?v=${BRAND_V}`];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Only the app page may be kept as the app page.
|
* Only the app page may be kept as the app page.
|
||||||
@@ -482,7 +484,7 @@ self.addEventListener("push", (event) => {
|
|||||||
// or a payload too large to carry the message. Say something true
|
// or a payload too large to carry the message. Say something true
|
||||||
// rather than inventing a sender.
|
// rather than inventing a sender.
|
||||||
await self.registration.showNotification(strings.newMail, {
|
await self.registration.showNotification(strings.newMail, {
|
||||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -492,8 +494,8 @@ self.addEventListener("push", (event) => {
|
|||||||
const { title, body, preview } = textOf(email, strings);
|
const { title, body, preview } = textOf(email, strings);
|
||||||
await self.registration.showNotification(title, {
|
await self.registration.showNotification(title, {
|
||||||
body: preview ? `${body}\n${preview}` : body,
|
body: preview ? `${body}\n${preview}` : body,
|
||||||
icon: `${BASE}/img/icon-192.png`,
|
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
|
||||||
badge: `${BASE}/img/favicon-64.png`,
|
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
|
||||||
tag: `ihasmail-${email.id || body}`,
|
tag: `ihasmail-${email.id || body}`,
|
||||||
// Only where there is a message to act on: a payload without an id can
|
// Only where there is a message to act on: a payload without an id can
|
||||||
// be shown but not archived, and a button that cannot work should not
|
// be shown but not archived, and a button that cannot work should not
|
||||||
@@ -537,8 +539,8 @@ async function runAction(action, data) {
|
|||||||
} catch {
|
} catch {
|
||||||
await self.registration.showNotification(data.title || "ihasmail", {
|
await self.registration.showNotification(data.title || "ihasmail", {
|
||||||
body: data.failed || "Could not do that — open ihasmail and try again",
|
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||||
icon: `${BASE}/img/icon-192.png`,
|
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
|
||||||
badge: `${BASE}/img/favicon-64.png`,
|
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
|
||||||
tag: `ihasmail-failed-${id}`,
|
tag: `ihasmail-failed-${id}`,
|
||||||
data: { url: data.url },
|
data: { url: data.url },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
|
import { withBase } from "@/lib/basePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this instance calls itself, when nothing has said otherwise yet.
|
* What this instance calls itself, when nothing has said otherwise yet.
|
||||||
@@ -36,3 +37,25 @@ export function useAppName(): string {
|
|||||||
export function currentAppName(): string {
|
export function currentAppName(): string {
|
||||||
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
|
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brand images' version, carried as `?v=` on every URL that names one.
|
||||||
|
*
|
||||||
|
* The images live in `public/img` under fixed names and are served with a
|
||||||
|
* browser cache of hours, so replacing one (the mark changed on 2026-09-27)
|
||||||
|
* left returning visitors on the old picture until their copy expired -- and
|
||||||
|
* the favicon and an installed app's icon hold on longer still. A new value
|
||||||
|
* here is a new URL everywhere at once.
|
||||||
|
*
|
||||||
|
* Date-stamped with a letter for a second change the same day, never a
|
||||||
|
* counter, for the reason the sites give for ASSET_V: a value that could have
|
||||||
|
* been requested before may already be cached, with old bytes behind it.
|
||||||
|
* `index.html`, `public/manifest.webmanifest` and `public/sw.js` can't import
|
||||||
|
* this, so they carry the same value written out; keep the four in step.
|
||||||
|
*/
|
||||||
|
export const BRAND_V = "2026-09-27a";
|
||||||
|
|
||||||
|
/** A brand image's URL under the mount, versioned: `brandImage("/img/logo.png")`. */
|
||||||
|
export function brandImage(path: string): string {
|
||||||
|
return withBase(`${path}?v=${BRAND_V}`);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { withBase } from "../basePath";
|
import { brandImage } from "@/lib/brand";
|
||||||
|
|
||||||
let baseTitle = "inbuxa";
|
let baseTitle = "inbuxa";
|
||||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||||
@@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void {
|
|||||||
if (!link) return;
|
if (!link) return;
|
||||||
if (!baseFavicon) {
|
if (!baseFavicon) {
|
||||||
baseFavicon = new Image();
|
baseFavicon = new Image();
|
||||||
baseFavicon.src = withBase("/img/favicon-64.png");
|
baseFavicon.src = brandImage("/img/favicon-64.png");
|
||||||
baseFavicon.onload = () => setUnreadBadge(count);
|
baseFavicon.onload = () => setUnreadBadge(count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!baseFavicon.complete) return;
|
if (!baseFavicon.complete) return;
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
link.href = withBase("/img/favicon-64.png");
|
link.href = brandImage("/img/favicon-64.png");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
faviconCanvas ??= document.createElement("canvas");
|
faviconCanvas ??= document.createElement("canvas");
|
||||||
@@ -95,7 +95,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
|
|||||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||||
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
||||||
const { onClick, ...options } = opts;
|
const { onClick, ...options } = opts;
|
||||||
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
|
const full = { icon: brandImage("/img/icon-192.png"), badge: brandImage("/img/favicon-64.png"), ...options };
|
||||||
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
|
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
|
||||||
if (viaWorker) {
|
if (viaWorker) {
|
||||||
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
|
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "rea
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
|
||||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||||
import { toggleTarget } from "@/lib/palette";
|
import { toggleTarget } from "@/lib/palette";
|
||||||
@@ -148,7 +147,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<MenuIcon size={22} />
|
<MenuIcon size={22} />
|
||||||
</button>
|
</button>
|
||||||
<Link href="/mail" className="brand">
|
<Link href="/mail" className="brand">
|
||||||
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||||
{/* A product name, not a word: translated it is a different product.
|
{/* A product name, not a word: translated it is a different product.
|
||||||
Read from the session rather than written here, so a deployment
|
Read from the session rather than written here, so a deployment
|
||||||
that set APP_NAME is called what it calls itself -- the document
|
that set APP_NAME is called what it calls itself -- the document
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ApiError } from "@/jmap/client";
|
|||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ export function LoginPage() {
|
|||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<form className="login-card" onSubmit={submit}>
|
<form className="login-card" onSubmit={submit}>
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" width={120} height={126} />
|
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" width={120} height={126} />
|
||||||
{/* A product name, not a word: not translated, and not guessed at
|
{/* A product name, not a word: not translated, and not guessed at
|
||||||
from the page it is on. INBUXA's is its wordmark. */}
|
from the page it is on. INBUXA's is its wordmark. */}
|
||||||
<h1 className="notranslate" translate="no">
|
<h1 className="notranslate" translate="no">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
|||||||
import { appliesTo, comparatorsFor } from "@/lib/listSort";
|
import { appliesTo, comparatorsFor } from "@/lib/listSort";
|
||||||
import type { Comparator } from "@/jmap/types";
|
import type { Comparator } from "@/jmap/types";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { withBase } from "@/lib/basePath";
|
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
||||||
import { keyboard } from "@/lib/input/keyboard";
|
import { keyboard } from "@/lib/input/keyboard";
|
||||||
@@ -21,6 +20,7 @@ import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute";
|
|||||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
||||||
import { plural, t as translate, tNode } from "@/lib/i18n";
|
import { plural, t as translate, tNode } from "@/lib/i18n";
|
||||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||||
|
import { brandImage } from "@/lib/brand";
|
||||||
|
|
||||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -468,7 +468,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
<ThreadView key={`${threadId}:${openMessageId ?? ""}`} threadId={threadId} messageId={openMessageId} 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, settings.conversationMode ? null : next!); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
|
<ThreadView key={`${threadId}:${openMessageId ?? ""}`} threadId={threadId} messageId={openMessageId} 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, settings.conversationMode ? null : next!); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
|
||||||
) : (
|
) : (
|
||||||
<div className="no-thread">
|
<div className="no-thread">
|
||||||
<img src={withBase("/img/logo.png")} alt="" />
|
<img src={brandImage("/img/logo.png")} alt="" />
|
||||||
<div>
|
<div>
|
||||||
{list?.total
|
{list?.total
|
||||||
? settings.conversationMode
|
? settings.conversationMode
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useAppName } from "@/lib/brand";
|
|||||||
import { apiFetch, client } from "@/jmap/client";
|
import { apiFetch, client } from "@/jmap/client";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { withBase } from "@/lib/basePath";
|
|
||||||
import { t, tNode } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
|
import { brandImage } from "@/lib/brand";
|
||||||
|
|
||||||
/** ihasmail-inbuxa: which webmail node answered and which mail node it talks to (server/src/nodes.ts). */
|
/** ihasmail-inbuxa: which webmail node answered and which mail node it talks to (server/src/nodes.ts). */
|
||||||
interface Nodes {
|
interface Nodes {
|
||||||
@@ -47,7 +47,7 @@ export function AboutSettings() {
|
|||||||
inbuxa's own, not APP_NAME. */}
|
inbuxa's own, not APP_NAME. */}
|
||||||
<p className="lead">{tNode("{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.", { inbuxa: <strong className="brand-inbuxa notranslate" translate="no">inbuxa</strong> })}</p>
|
<p className="lead">{tNode("{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.", { inbuxa: <strong className="brand-inbuxa notranslate" translate="no">inbuxa</strong> })}</p>
|
||||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||||
<img src={withBase("/img/inbuxa-mark.png")} alt="" width={80} />
|
<img src={brandImage("/img/inbuxa-mark.png")} alt="" width={80} />
|
||||||
<div>
|
<div>
|
||||||
<InbuxaWordmark height={26} />
|
<InbuxaWordmark height={26} />
|
||||||
{/* A product name and a version string: neither is a word to translate. */}
|
{/* A product name and a version string: neither is a word to translate. */}
|
||||||
|
|||||||
Reference in new issue
Block a user