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
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<HTMLLinkElement>(`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<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 {
return document.head.querySelector<HTMLMetaElement>(`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) {
@@ -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<TitlePart>;
@@ -19,7 +17,8 @@ interface BadgeState {
hasUnread: boolean;
}
let currentBaseTitle = TITLE_PREFIX;
let titleProductName = PRODUCT_NAME;
let currentTitleParts: ReadonlyArray<string> = [];
let currentBadgeState: BadgeState = {mentionCount: 0, hasUnread: false};
const normalizeTitleParts = (value?: TitleInput): Array<string> => {
@@ -29,11 +28,11 @@ const normalizeTitleParts = (value?: TitleInput): Array<string> => {
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>): string => {
const buildDocumentTitle = (parts: ReadonlyArray<string>): 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<string | undefined>(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<string>;
updateDocumentTitle();
return () => {
if (!options?.preserveTitleOnUnmount && prevTitleRef.current) {
currentBaseTitle = prevTitleRef.current;
if (!preserveTitleOnUnmount) {
currentTitleParts = previousParts;
updateDocumentTitle();
}
};
}, [fullTitle, options?.preserveTitleOnUnmount]);
}, [partsKey, preserveTitleOnUnmount]);
};