From 0adc4026292f39d4bb43c291c2335ab165f4350b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 26 Sep 2026 22:50:22 -0700 Subject: [PATCH] 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. --- web/index.html | 6 +++--- web/public/manifest.webmanifest | 6 +++--- web/public/sw.js | 14 ++++++++------ web/src/lib/brand.ts | 23 +++++++++++++++++++++++ web/src/lib/notify/notify.ts | 8 ++++---- web/src/views/AppShell.tsx | 5 ++--- web/src/views/Login.tsx | 4 ++-- web/src/views/mail/MailView.tsx | 4 ++-- web/src/views/settings/AboutSettings.tsx | 4 ++-- 9 files changed, 49 insertions(+), 25 deletions(-) diff --git a/web/index.html b/web/index.html index af68c58..6c89972 100644 --- a/web/index.html +++ b/web/index.html @@ -19,9 +19,9 @@ - - - + + + inbuxa diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index 7175155..f70b5a7 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -51,17 +51,17 @@ "theme_color": "#0f766e", "icons": [ { - "src": "img/icon-192.png", + "src": "img/icon-192.png?v=2026-09-27a", "sizes": "192x192", "type": "image/png" }, { - "src": "img/icon-512.png", + "src": "img/icon-512.png?v=2026-09-27a", "sizes": "512x512", "type": "image/png" }, { - "src": "img/icon-maskable.png", + "src": "img/icon-maskable.png?v=2026-09-27a", "sizes": "192x192", "type": "image/png", "purpose": "maskable" diff --git a/web/public/sw.js b/web/public/sw.js index 843c97a..53aab1f 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2"; * eventually would. */ 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. @@ -482,7 +484,7 @@ self.addEventListener("push", (event) => { // or a payload too large to carry the message. Say something true // rather than inventing a sender. 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; } @@ -492,8 +494,8 @@ self.addEventListener("push", (event) => { const { title, body, preview } = textOf(email, strings); await self.registration.showNotification(title, { body: preview ? `${body}\n${preview}` : body, - icon: `${BASE}/img/icon-192.png`, - badge: `${BASE}/img/favicon-64.png`, + icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, + badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: `ihasmail-${email.id || body}`, // 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 @@ -537,8 +539,8 @@ async function runAction(action, data) { } catch { await self.registration.showNotification(data.title || "ihasmail", { body: data.failed || "Could not do that — open ihasmail and try again", - icon: `${BASE}/img/icon-192.png`, - badge: `${BASE}/img/favicon-64.png`, + icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, + badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: `ihasmail-failed-${id}`, data: { url: data.url }, }); diff --git a/web/src/lib/brand.ts b/web/src/lib/brand.ts index db9006d..83c674f 100644 --- a/web/src/lib/brand.ts +++ b/web/src/lib/brand.ts @@ -1,4 +1,5 @@ import { useSession } from "@/store/session"; +import { withBase } from "@/lib/basePath"; /** * What this instance calls itself, when nothing has said otherwise yet. @@ -36,3 +37,25 @@ export function useAppName(): string { export function currentAppName(): string { 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}`); +} diff --git a/web/src/lib/notify/notify.ts b/web/src/lib/notify/notify.ts index 3cb013e..4c01cd4 100644 --- a/web/src/lib/notify/notify.ts +++ b/web/src/lib/notify/notify.ts @@ -1,4 +1,4 @@ -import { withBase } from "../basePath"; +import { brandImage } from "@/lib/brand"; let baseTitle = "inbuxa"; let faviconCanvas: HTMLCanvasElement | null = null; @@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void { if (!link) return; if (!baseFavicon) { baseFavicon = new Image(); - baseFavicon.src = withBase("/img/favicon-64.png"); + baseFavicon.src = brandImage("/img/favicon-64.png"); baseFavicon.onload = () => setUnreadBadge(count); return; } if (!baseFavicon.complete) return; if (count <= 0) { - link.href = withBase("/img/favicon-64.png"); + link.href = brandImage("/img/favicon-64.png"); return; } 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 (document.visibilityState === "visible" && document.hasFocus()) return; 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; if (viaWorker) { void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined); diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 3a47ac7..eff482d 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -2,8 +2,7 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "rea 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 { useSession } from "@/store/session"; -import { withBase } from "@/lib/basePath"; -import { DEFAULT_APP_NAME } from "@/lib/brand"; +import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; @@ -148,7 +147,7 @@ export function AppShell({ children }: { children: ReactNode }) { - + {/* 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 diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx index ad8b1e3..68288f2 100644 --- a/web/src/views/Login.tsx +++ b/web/src/views/Login.tsx @@ -5,7 +5,7 @@ import { ApiError } from "@/jmap/client"; import { withBase } from "@/lib/basePath"; import { APP_VERSION } from "@/lib/version"; 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 { InbuxaWordmark } from "@/ui/InbuxaWordmark"; @@ -92,7 +92,7 @@ export function LoginPage() {
- + {/* A product name, not a word: not translated, and not guessed at from the page it is on. INBUXA's is its wordmark. */}

diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 6d5ef47..4f67106 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -4,7 +4,6 @@ import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail"; import { appliesTo, comparatorsFor } from "@/lib/listSort"; import type { Comparator } from "@/jmap/types"; 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/input/keyboard"; @@ -21,6 +20,7 @@ import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; import { plural, t as translate, tNode } from "@/lib/i18n"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; +import { brandImage } from "@/lib/brand"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -468,7 +468,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; 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} /> ) : (
- +
{list?.total ? settings.conversationMode diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 745b108..4c1a400 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -4,9 +4,9 @@ import { useAppName } from "@/lib/brand"; import { apiFetch, client } from "@/jmap/client"; import { APP_VERSION } from "@/lib/version"; import { DEFAULT_SOURCE_URL } from "@/lib/source"; -import { withBase } from "@/lib/basePath"; import { t, tNode } from "@/lib/i18n"; 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). */ interface Nodes { @@ -47,7 +47,7 @@ export function AboutSettings() { inbuxa's own, not APP_NAME. */}

{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: inbuxa })}

- +
{/* A product name and a version string: neither is a word to translate. */}