mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): follow instance branding in tab title and favicon (#3288)
This commit is contained in:
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]);
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user