fix(app): apply instance branding to system DM (#3355)

This commit is contained in:
Tarek authored and GitHub committed 2026-10-10 20:36:02 +02:00
1 parent 7197371123
commit 6b6ae13919
11 files changed
+178 -12

No files matched your search

+4
View File
@@ -20,6 +20,7 @@ import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses';
import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBadge';
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
@@ -190,6 +191,9 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
messageGutter: Accessibility.messageGutter,
messageGroupSpacing: Accessibility.getMessageGroupSpacingValue(messageDisplayCompact),
hdrDisplayMode: Accessibility.hdrDisplayMode,
instanceThemeColor: RuntimeConfig.isSelfHosted()
? (RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color ?? null)
: null,
});
useCustomThemeStyle({
enabledThemeCss: ThemeLibrary.activeThemeCss,
@@ -27,6 +27,7 @@ import {UserProfileBadges} from '@app/features/user/components/popouts/UserProfi
import type {Profile} from '@app/features/user/models/Profile';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {msg, ph, plural} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -96,7 +97,9 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
return null;
}
const displayName = NicknameUtils.getNickname(user, null, channel?.id);
const fluxerTag = NicknameUtils.formatTagForStreamerMode(user.tag);
const fluxerTag = NicknameUtils.formatTagForStreamerMode(
user.id === FLUXERBOT_ID ? `${displayName}#${user.discriminator}` : user.tag,
);
const handleSendFriendRequest = () => {
RelationshipActionUtils.sendFriendRequest(i18n, user.id);
};
@@ -55,6 +55,7 @@ function ThemedApp({
messageGutter: 16,
messageGroupSpacing: groupSpacing,
hdrDisplayMode: HdrDisplayMode.FULL,
instanceThemeColor: null,
});
return (
<MeasuringChild
@@ -2,6 +2,7 @@
import {HdrDisplayMode} from '@app/features/accessibility/state/Accessibility';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {getInstanceBrandVariables, INSTANCE_BRAND_VARIABLES} from '@app/features/theme/utils/InstanceBrandColors';
import {useLayoutEffect} from 'react';
interface ThemeCssVariablesOptions {
@@ -14,6 +15,7 @@ interface ThemeCssVariablesOptions {
messageGutter: number;
messageGroupSpacing: number;
hdrDisplayMode: HdrDisplayMode;
instanceThemeColor: string | null;
}
export function useThemeCssVariables({
@@ -26,6 +28,7 @@ export function useThemeCssVariables({
messageGutter,
messageGroupSpacing,
hdrDisplayMode,
instanceThemeColor,
}: ThemeCssVariablesOptions): void {
useLayoutEffect(() => {
const htmlNode = document.documentElement;
@@ -63,6 +66,20 @@ export function useThemeCssVariables({
messageGroupSpacing,
hdrDisplayMode,
]);
useLayoutEffect(() => {
if (instanceThemeColor === null) return;
const variables = getInstanceBrandVariables(instanceThemeColor);
if (variables === null) return;
const htmlNode = document.documentElement;
for (const name of INSTANCE_BRAND_VARIABLES) {
htmlNode.style.setProperty(name, variables[name]);
}
return () => {
for (const name of INSTANCE_BRAND_VARIABLES) {
htmlNode.style.removeProperty(name);
}
};
}, [instanceThemeColor]);
useLayoutEffect(() => {
const htmlNode = document.documentElement;
return () => {
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const INSTANCE_BRAND_VARIABLES = [
'--instance-brand-primary',
'--instance-brand-secondary',
'--instance-brand-primary-light',
'--instance-brand-primary-fill',
'--instance-text-on-brand-primary',
] as const;
type InstanceBrandVariable = (typeof INSTANCE_BRAND_VARIABLES)[number];
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function parseHexColor(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!HEX_COLOR_PATTERN.test(trimmed)) return null;
const digits =
trimmed.length === 4
? trimmed
.slice(1)
.split('')
.map((digit) => digit + digit)
.join('')
: trimmed.slice(1);
return [
Number.parseInt(digits.slice(0, 2), 16),
Number.parseInt(digits.slice(2, 4), 16),
Number.parseInt(digits.slice(4, 6), 16),
];
}
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
const r = red / 255;
const g = green / 255;
const b = blue / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) return [0, 0, lightness * 100];
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
let hue: number;
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
return [(hue * 60 + 360) % 360, saturation * 100, lightness * 100];
}
function relativeLuminance(red: number, green: number, blue: number): number {
const linear = (channel: number) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue);
}
function formatNumber(value: number): string {
return String(Math.round(value * 100) / 100);
}
function hsl(hue: number, saturation: number, lightness: number): string {
return `hsl(${formatNumber(hue)}, calc(${formatNumber(saturation)}% * var(--saturation-factor)), ${formatNumber(lightness)}%)`;
}
export function getInstanceBrandVariables(themeColor: string): Record<InstanceBrandVariable, string> | null {
const rgb = parseHexColor(themeColor);
if (rgb === null) return null;
const [hue, saturation, lightness] = rgbToHsl(...rgb);
const luminance = relativeLuminance(...rgb);
const prefersDarkText = (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05);
return {
'--instance-brand-primary': hsl(hue, saturation, lightness),
'--instance-brand-secondary': hsl(hue, saturation * (6 / 7), lightness * 0.89),
'--instance-brand-primary-light': hsl(hue, saturation, lightness + (100 - lightness) * 0.65),
'--instance-brand-primary-fill': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 100%)',
'--instance-text-on-brand-primary': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 98%)',
};
}
@@ -64,12 +64,14 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
const {i18n} = useLingui();
const userId = user.id;
const userAvatar = user.avatar;
const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(userId);
const guildMember = GuildMembers.getMember(guildId || '', userId);
const hasGuildMemberAvatarSource = Boolean(guildId) && guildMember != null;
const memberAvatar = guildMember?.avatar ?? null;
const memberAvatarUnset = guildMember?.isAvatarUnset() ?? false;
const avatarUrl = useMemo(() => {
if (customAvatarUrl !== undefined) return customAvatarUrl;
if (systemAvatarUrl !== null) return systemAvatarUrl;
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
@@ -88,11 +90,13 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
memberAvatar,
memberAvatarUnset,
mediaSize,
systemAvatarUrl,
userAvatar,
userId,
]);
const hoverAvatarUrl = useMemo(() => {
if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl;
if (systemAvatarUrl !== null) return systemAvatarUrl;
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
@@ -111,6 +115,7 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
memberAvatar,
memberAvatarUnset,
mediaSize,
systemAvatarUrl,
userAvatar,
userId,
]);
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils';
import {ProfileBody} from '@app/features/user/components/modals/user_profile_modal/ProfileBody';
import {ProfileMediaHeader} from '@app/features/user/components/modals/user_profile_modal/ProfileMediaHeader';
import type {ProfileModalContentProps} from '@app/features/user/components/modals/user_profile_modal/UserProfileModalShared';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
import {resolveProfileGuildMembership, toProfileDisplayContext} from '@app/features/user/utils/ProfileGuildMembership';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {
MEDIA_PROXY_AVATAR_SIZE_PROFILE,
MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
@@ -26,7 +29,12 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
showProfileDataWarning,
}) => {
const effectiveProfile = profile?.getEffectiveProfile() ?? null;
const bannerColor = getUserAccentColor(user, effectiveProfile?.accent_color);
const isSystemUser = user.id === FLUXERBOT_ID;
const systemThemeColor =
isSystemUser && RuntimeConfig.isSelfHosted()
? RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color
: null;
const bannerColor = systemThemeColor ?? getUserAccentColor(user, effectiveProfile?.accent_color);
const membership = resolveProfileGuildMembership(profile);
const profileContext = useMemo<ProfileDisplayUtils.ProfileDisplayContext>(
() =>
@@ -38,10 +46,13 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
}),
[user, profile, membership],
);
const {avatarUrl, hoverAvatarUrl} = useMemo(
const {avatarUrl: profileAvatarUrl, hoverAvatarUrl: profileHoverAvatarUrl} = useMemo(
() => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides, MEDIA_PROXY_AVATAR_SIZE_PROFILE),
[profileContext, previewOverrides],
);
const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(user.id);
const avatarUrl = systemAvatarUrl ?? profileAvatarUrl;
const hoverAvatarUrl = systemAvatarUrl ?? profileHoverAvatarUrl;
const {bannerUrl, hoverBannerUrl} = useMemo(
() =>
ProfileDisplayUtils.getProfileBannerUrls(
@@ -59,8 +70,8 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
profileContext={profileContext}
previewOverrides={previewOverrides}
bannerColor={bannerColor}
bannerUrl={bannerUrl}
hoverBannerUrl={hoverBannerUrl}
bannerUrl={isSystemUser ? null : bannerUrl}
hoverBannerUrl={isSystemUser ? null : hoverBannerUrl}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
renderActionButtons={renderActionButtons}
@@ -18,6 +18,7 @@ import {
parseAvatarHash,
} from '@app/features/user/utils/AvatarMediaUtils';
import {getDefaultAvatarAssetURL} from '@app/features/user/utils/DefaultAvatars';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {
MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
MEDIA_PROXY_ICON_SIZE_DEFAULT,
@@ -75,6 +76,12 @@ const getViewportSplashSize = (): MediaProxyImageSize => {
export const getDefaultAvatarURLForIndex = (index: number): string => getDefaultAvatarAssetURL(index);
export function getSystemUserBrandingAvatarURL(userId: string): string | null {
if (userId !== FLUXERBOT_ID || !RuntimeConfig.isSelfHosted()) return null;
const branding = RuntimeConfig.getSnapshotOrNull()?.appPublic.branding;
return branding?.icon_url ?? branding?.logo_url ?? null;
}
export function getDefaultAvatarPrimaryColor(id: string) {
return getSharedDefaultAvatarPrimaryColor(id);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Channels from '@app/features/channel/state/Channels';
import GuildMembers from '@app/features/member/state/GuildMembers';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
@@ -8,6 +9,7 @@ import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import {noteText} from '@app/features/theme/fonts/ScriptFontLoader';
import type {User} from '@app/features/user/models/User';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
export interface UserDisplayNameLike {
username: string;
@@ -51,6 +53,10 @@ export function getDisplayName(user: UserDisplayNameLike): string {
}
export function getNickname(user: User, guildId?: string | null, channelId?: string): string {
if (user.id === FLUXERBOT_ID) {
const productName = RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.product_name;
if (productName) return formatNicknameForStreamerMode(productName);
}
let name = user.displayName || user.globalName || user.username || user.id || '';
const relationship = Relationships.getRelationship(user.id);
if (relationship?.nickname) {