From 6dc698ff5ec8aee44c73c280c3d860cc07b3f694 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 17:33:28 +0200 Subject: [PATCH] fix(app): follow instance branding in tab title and favicon (#3288) --- .../app/state/RuntimeDocumentBranding.ts | 38 ++++++++++--------- .../window/hooks/useFluxerDocumentTitle.ts | 35 +++++++++-------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/fluxer_app/src/features/app/state/RuntimeDocumentBranding.ts b/fluxer_app/src/features/app/state/RuntimeDocumentBranding.ts index 81ef47971..beda074ee 100644 --- a/fluxer_app/src/features/app/state/RuntimeDocumentBranding.ts +++ b/fluxer_app/src/features/app/state/RuntimeDocumentBranding.ts @@ -1,14 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {DEFAULT_APP_SHELL_BRANDING} from '@app/features/app/state/AppShellBranding'; +import {setDocumentTitleProductName} from '@app/features/window/hooks/useFluxerDocumentTitle'; import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types'; -let lastProductName = DEFAULT_APP_SHELL_BRANDING.productName; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); -} - function brandedLink(rel: string): HTMLLinkElement | null { return document.head.querySelector(`link[rel="${rel}"][data-fluxer-branding="true"]`); } @@ -28,6 +23,22 @@ function removeLink(rel: string): void { brandedLink(rel)?.remove(); } +function suspendDefaultLinks(rel: string): void { + for (const link of document.head.querySelectorAll( + `link[rel="${rel}"]:not([data-fluxer-branding])`, + )) { + link.dataset.fluxerDefaultRel = rel; + link.rel = ''; + } +} + +function restoreDefaultLinks(rel: string): void { + for (const link of document.head.querySelectorAll(`link[data-fluxer-default-rel="${rel}"]`)) { + link.rel = rel; + delete link.dataset.fluxerDefaultRel; + } +} + function brandedMeta(name: string): HTMLMetaElement | null { return document.head.querySelector(`meta[name="${name}"][data-fluxer-branding="true"]`); } @@ -47,25 +58,16 @@ function removeMeta(name: string): void { brandedMeta(name)?.remove(); } -function replaceTitleProductName(nextProductName: string): void { - if (lastProductName === nextProductName) return; - const prefix = new RegExp(`^(\\(\\d+\\)\\s+|\\u2022\\s+)?${escapeRegExp(lastProductName)}(?=$| \\| )`, 'u'); - if (prefix.test(document.title)) { - document.title = document.title.replace(prefix, (_match, notificationPrefix: string | undefined) => { - return `${notificationPrefix ?? ''}${nextProductName}`; - }); - } -} - function applyDocumentBranding(productName: string, faviconUrl: string | null, themeColor: string | null): void { if (typeof document === 'undefined') return; - replaceTitleProductName(productName); - lastProductName = productName; + setDocumentTitleProductName(productName); setMeta('application-name', productName); setMeta('apple-mobile-web-app-title', productName); if (faviconUrl === null) { removeLink('icon'); + restoreDefaultLinks('icon'); } else { + suspendDefaultLinks('icon'); setLink('icon', faviconUrl); } if (themeColor === null) { diff --git a/fluxer_app/src/features/window/hooks/useFluxerDocumentTitle.ts b/fluxer_app/src/features/window/hooks/useFluxerDocumentTitle.ts index 5fbb5f324..67370fddc 100644 --- a/fluxer_app/src/features/window/hooks/useFluxerDocumentTitle.ts +++ b/fluxer_app/src/features/window/hooks/useFluxerDocumentTitle.ts @@ -3,9 +3,7 @@ import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils'; import {formatNumber} from '@pkgs/number_utils/src/NumberFormatting'; -import {useEffect, useRef} from 'react'; - -const TITLE_PREFIX = PRODUCT_NAME; +import {useEffect} from 'react'; type TitlePart = string | null | undefined; type TitleInput = TitlePart | Array; @@ -19,7 +17,8 @@ interface BadgeState { hasUnread: boolean; } -let currentBaseTitle = TITLE_PREFIX; +let titleProductName = PRODUCT_NAME; +let currentTitleParts: ReadonlyArray = []; let currentBadgeState: BadgeState = {mentionCount: 0, hasUnread: false}; const normalizeTitleParts = (value?: TitleInput): Array => { @@ -29,11 +28,11 @@ const normalizeTitleParts = (value?: TitleInput): Array => { const parts = Array.isArray(value) ? value : [value]; return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)); }; -const buildDocumentTitle = (parts: Array): string => { +const buildDocumentTitle = (parts: ReadonlyArray): string => { if (!parts.length) { - return TITLE_PREFIX; + return titleProductName; } - return [TITLE_PREFIX, ...parts].join(' | '); + return [titleProductName, ...parts].join(' | '); }; const applyBadgePrefix = (baseTitle: string, badge: BadgeState): string => { if (badge.mentionCount > 0) { @@ -45,25 +44,29 @@ const applyBadgePrefix = (baseTitle: string, badge: BadgeState): string => { return baseTitle; }; const updateDocumentTitle = (): void => { - document.title = applyBadgePrefix(currentBaseTitle, currentBadgeState); + document.title = applyBadgePrefix(buildDocumentTitle(currentTitleParts), currentBadgeState); +}; +export const setDocumentTitleProductName = (productName: string): void => { + if (titleProductName === productName) return; + titleProductName = productName; + updateDocumentTitle(); }; export const updateDocumentTitleBadge = (mentionCount: number, hasUnread: boolean): void => { currentBadgeState = {mentionCount, hasUnread}; updateDocumentTitle(); }; export const useFluxerDocumentTitle = (title?: TitleInput, options?: UseDocumentTitleOptions) => { - const parts = normalizeTitleParts(title); - const fullTitle = buildDocumentTitle(parts); - const prevTitleRef = useRef(undefined); + const partsKey = JSON.stringify(normalizeTitleParts(title)); + const preserveTitleOnUnmount = options?.preserveTitleOnUnmount; useEffect(() => { - prevTitleRef.current = currentBaseTitle; - currentBaseTitle = fullTitle; + const previousParts = currentTitleParts; + currentTitleParts = JSON.parse(partsKey) as Array; updateDocumentTitle(); return () => { - if (!options?.preserveTitleOnUnmount && prevTitleRef.current) { - currentBaseTitle = prevTitleRef.current; + if (!preserveTitleOnUnmount) { + currentTitleParts = previousParts; updateDocumentTitle(); } }; - }, [fullTitle, options?.preserveTitleOnUnmount]); + }, [partsKey, preserveTitleOnUnmount]); };