diff --git a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts index 6cfc33401..0668c453c 100644 --- a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts +++ b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts @@ -8,6 +8,7 @@ import {applyPatchToRow, buildPatchFromData, executeVersionedUpdate} from '@app/ import type {UserRow} from '@app/api/database/types/UserTypes'; import {EMPTY_USER_ROW, USER_COLUMNS} from '@app/api/database/types/UserTypes'; import {emitAccountChangedIfRelevant} from '@app/api/infrastructure/activity/AccountChangeEvents'; +import {getInstanceProductName} from '@app/api/instance/ProductName'; import {User} from '@app/api/models/User'; import {Users} from '@app/api/Tables'; import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift'; @@ -67,7 +68,7 @@ export class UserDataRepository { return new User({ ...EMPTY_USER_ROW, user_id: createUserID(FLUXER_BOT_USER_ID), - username: 'Fluxer', + username: getInstanceProductName(), discriminator: 0, bot: true, system: true, diff --git a/fluxer_app/scripts/GenerateColorSystem.ts b/fluxer_app/scripts/GenerateColorSystem.ts index 9b67f38c7..2ce0716a9 100644 --- a/fluxer_app/scripts/GenerateColorSystem.ts +++ b/fluxer_app/scripts/GenerateColorSystem.ts @@ -31,6 +31,7 @@ interface TokenDef { lightness?: number; alpha?: number; useSaturationFactor?: boolean; + instanceOverride?: string; } interface Config { @@ -457,10 +458,28 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10% {name: '--control-button-active-text', value: 'var(--text-primary)'}, {name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60}, {name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20}, - {name: '--brand-primary', family: 'brand', lightness: 55}, - {name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49}, - {name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84}, - {name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100}, + {name: '--brand-primary', family: 'brand', lightness: 55, instanceOverride: '--instance-brand-primary'}, + { + name: '--brand-secondary', + family: 'brand', + saturation: 60, + lightness: 49, + instanceOverride: '--instance-brand-secondary', + }, + { + name: '--brand-primary-light', + family: 'brand', + saturation: 100, + lightness: 84, + instanceOverride: '--instance-brand-primary-light', + }, + { + name: '--brand-primary-fill', + hue: 0, + saturation: 0, + lightness: 100, + instanceOverride: '--instance-brand-primary-fill', + }, {name: '--status-online', family: 'statusOnline', lightness: 40}, {name: '--status-idle', family: 'statusIdle', lightness: 50}, {name: '--status-dnd', family: 'statusDnd', lightness: 60}, @@ -474,7 +493,13 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10% {name: '--plutonium-icon', family: 'brandIcon', lightness: 50}, {name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'}, {name: '--text-link', family: 'link', lightness: 70}, - {name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98}, + { + name: '--text-on-brand-primary', + hue: 0, + saturation: 0, + lightness: 98, + instanceOverride: '--instance-text-on-brand-primary', + }, ...DARK_CODE_TOKENS, {name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35}, {name: '--markup-mention-text', value: 'var(--text-link)'}, @@ -792,6 +817,7 @@ interface OutputToken { alpha?: number; useSaturationFactor?: boolean; value?: string; + instanceOverride?: string; } function clamp01(value: number): number { @@ -866,6 +892,7 @@ function expandTokens(defs: Array, scales: Record): Arr lightness: def.lightness, alpha: def.alpha, useSaturationFactor: def.useSaturationFactor, + instanceOverride: def.instanceOverride, }); } } @@ -919,7 +946,8 @@ function formatTone(token: OutputToken, families: Record): function formatValue(token: OutputToken, families: Record): string { if (token.type === 'tone') { - return formatTone(token, families); + const tone = formatTone(token, families); + return token.instanceOverride ? `var(${token.instanceOverride}, ${tone})` : tone; } return token.value!.trim(); } diff --git a/fluxer_app/src/app/App.tsx b/fluxer_app/src/app/App.tsx index 952945f1b..65490ce2c 100644 --- a/fluxer_app/src/app/App.tsx +++ b/fluxer_app/src/app/App.tsx @@ -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, diff --git a/fluxer_app/src/features/channel/components/direct_message/DMWelcomeSection.tsx b/fluxer_app/src/features/channel/components/direct_message/DMWelcomeSection.tsx index 40e8332c1..b7f46e603 100644 --- a/fluxer_app/src/features/channel/components/direct_message/DMWelcomeSection.tsx +++ b/fluxer_app/src/features/channel/components/direct_message/DMWelcomeSection.tsx @@ -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 = 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); }; diff --git a/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx index c82382b72..21ae7ddae 100644 --- a/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx +++ b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx @@ -55,6 +55,7 @@ function ThemedApp({ messageGutter: 16, messageGroupSpacing: groupSpacing, hdrDisplayMode: HdrDisplayMode.FULL, + instanceThemeColor: null, }); return ( { 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 () => { diff --git a/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts b/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts new file mode 100644 index 000000000..de514dcca --- /dev/null +++ b/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts @@ -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 | 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%)', + }; +} diff --git a/fluxer_app/src/features/ui/components/Avatar.tsx b/fluxer_app/src/features/ui/components/Avatar.tsx index 4663ee3bb..c998422b8 100644 --- a/fluxer_app/src/features/ui/components/Avatar.tsx +++ b/fluxer_app/src/features/ui/components/Avatar.tsx @@ -64,12 +64,14 @@ const AvatarComponent = React.forwardRef( 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( 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( memberAvatar, memberAvatarUnset, mediaSize, + systemAvatarUrl, userAvatar, userId, ]); diff --git a/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx b/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx index 00cda826c..f27bb2d3e 100644 --- a/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx +++ b/fluxer_app/src/features/user/components/modals/user_profile_modal/ProfileModalContent.tsx @@ -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 = 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( () => @@ -38,10 +46,13 @@ export const ProfileModalContent: React.FC = 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 = 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} diff --git a/fluxer_app/src/features/user/utils/AvatarUtils.ts b/fluxer_app/src/features/user/utils/AvatarUtils.ts index 5fb0ddf2a..e5a46044a 100644 --- a/fluxer_app/src/features/user/utils/AvatarUtils.ts +++ b/fluxer_app/src/features/user/utils/AvatarUtils.ts @@ -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); } diff --git a/fluxer_app/src/features/user/utils/NicknameUtils.ts b/fluxer_app/src/features/user/utils/NicknameUtils.ts index 4ccc60aa8..fdbb50c42 100644 --- a/fluxer_app/src/features/user/utils/NicknameUtils.ts +++ b/fluxer_app/src/features/user/utils/NicknameUtils.ts @@ -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) {