fix(app): follow instance branding in tab title and favicon (#3288)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:33:28 +02:00
1 parent 39e48458da
commit 6dc698ff5e
2 files changed
+39 -34

No files matched your search

@@ -1,14 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFAULT_APP_SHELL_BRANDING} from '@app/features/app/state/AppShellBranding'; 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'; 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 { function brandedLink(rel: string): HTMLLinkElement | null {
return document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"][data-fluxer-branding="true"]`); return document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"][data-fluxer-branding="true"]`);
} }
@@ -28,6 +23,22 @@ function removeLink(rel: string): void {
brandedLink(rel)?.remove(); brandedLink(rel)?.remove();
} }
function suspendDefaultLinks(rel: string): void {
for (const link of document.head.querySelectorAll<HTMLLinkElement>(
`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<HTMLLinkElement>(`link[data-fluxer-default-rel="${rel}"]`)) {
link.rel = rel;
delete link.dataset.fluxerDefaultRel;
}
}
function brandedMeta(name: string): HTMLMetaElement | null { function brandedMeta(name: string): HTMLMetaElement | null {
return document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"][data-fluxer-branding="true"]`); return document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"][data-fluxer-branding="true"]`);
} }
@@ -47,25 +58,16 @@ function removeMeta(name: string): void {
brandedMeta(name)?.remove(); 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 { function applyDocumentBranding(productName: string, faviconUrl: string | null, themeColor: string | null): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
replaceTitleProductName(productName); setDocumentTitleProductName(productName);
lastProductName = productName;
setMeta('application-name', productName); setMeta('application-name', productName);
setMeta('apple-mobile-web-app-title', productName); setMeta('apple-mobile-web-app-title', productName);
if (faviconUrl === null) { if (faviconUrl === null) {
removeLink('icon'); removeLink('icon');
restoreDefaultLinks('icon');
} else { } else {
suspendDefaultLinks('icon');
setLink('icon', faviconUrl); setLink('icon', faviconUrl);
} }
if (themeColor === null) { if (themeColor === null) {
@@ -3,9 +3,7 @@
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils'; import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {formatNumber} from '@pkgs/number_utils/src/NumberFormatting'; import {formatNumber} from '@pkgs/number_utils/src/NumberFormatting';
import {useEffect, useRef} from 'react'; import {useEffect} from 'react';
const TITLE_PREFIX = PRODUCT_NAME;
type TitlePart = string | null | undefined; type TitlePart = string | null | undefined;
type TitleInput = TitlePart | Array<TitlePart>; type TitleInput = TitlePart | Array<TitlePart>;
@@ -19,7 +17,8 @@ interface BadgeState {
hasUnread: boolean; hasUnread: boolean;
} }
let currentBaseTitle = TITLE_PREFIX; let titleProductName = PRODUCT_NAME;
let currentTitleParts: ReadonlyArray<string> = [];
let currentBadgeState: BadgeState = {mentionCount: 0, hasUnread: false}; let currentBadgeState: BadgeState = {mentionCount: 0, hasUnread: false};
const normalizeTitleParts = (value?: TitleInput): Array<string> => { const normalizeTitleParts = (value?: TitleInput): Array<string> => {
@@ -29,11 +28,11 @@ const normalizeTitleParts = (value?: TitleInput): Array<string> => {
const parts = Array.isArray(value) ? value : [value]; const parts = Array.isArray(value) ? value : [value];
return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)); return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part));
}; };
const buildDocumentTitle = (parts: Array<string>): string => { const buildDocumentTitle = (parts: ReadonlyArray<string>): string => {
if (!parts.length) { if (!parts.length) {
return TITLE_PREFIX; return titleProductName;
} }
return [TITLE_PREFIX, ...parts].join(' | '); return [titleProductName, ...parts].join(' | ');
}; };
const applyBadgePrefix = (baseTitle: string, badge: BadgeState): string => { const applyBadgePrefix = (baseTitle: string, badge: BadgeState): string => {
if (badge.mentionCount > 0) { if (badge.mentionCount > 0) {
@@ -45,25 +44,29 @@ const applyBadgePrefix = (baseTitle: string, badge: BadgeState): string => {
return baseTitle; return baseTitle;
}; };
const updateDocumentTitle = (): void => { 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 => { export const updateDocumentTitleBadge = (mentionCount: number, hasUnread: boolean): void => {
currentBadgeState = {mentionCount, hasUnread}; currentBadgeState = {mentionCount, hasUnread};
updateDocumentTitle(); updateDocumentTitle();
}; };
export const useFluxerDocumentTitle = (title?: TitleInput, options?: UseDocumentTitleOptions) => { export const useFluxerDocumentTitle = (title?: TitleInput, options?: UseDocumentTitleOptions) => {
const parts = normalizeTitleParts(title); const partsKey = JSON.stringify(normalizeTitleParts(title));
const fullTitle = buildDocumentTitle(parts); const preserveTitleOnUnmount = options?.preserveTitleOnUnmount;
const prevTitleRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
prevTitleRef.current = currentBaseTitle; const previousParts = currentTitleParts;
currentBaseTitle = fullTitle; currentTitleParts = JSON.parse(partsKey) as Array<string>;
updateDocumentTitle(); updateDocumentTitle();
return () => { return () => {
if (!options?.preserveTitleOnUnmount && prevTitleRef.current) { if (!preserveTitleOnUnmount) {
currentBaseTitle = prevTitleRef.current; currentTitleParts = previousParts;
updateDocumentTitle(); updateDocumentTitle();
} }
}; };
}, [fullTitle, options?.preserveTitleOnUnmount]); }, [partsKey, preserveTitleOnUnmount]);
}; };