From d7bbe933fa362601d4a8a8cef5f49a4123840ddf Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 14 Oct 2026 16:02:18 +0200 Subject: [PATCH] fix(app): unify guild acronyms and shrink them to fit (#3462) --- .../dialogs/shared/GuildComboboxRenderers.tsx | 10 +-- .../components/layout/GuildsLayout.module.css | 32 ------- .../sidebar_nav/GuildFolderItem.module.css | 8 -- .../layout/sidebar_nav/GuildFolderItem.tsx | 11 +-- .../sidebar_nav/GuildListItemPresentation.tsx | 36 ++------ .../pages/OAuthAuthorizePage.module.css | 4 +- .../modals/ChannelFollowModal.module.css | 3 +- .../guild/components/GuildAcronym.module.css | 27 ++++++ .../guild/components/GuildAcronym.tsx | 36 ++++++++ .../modals/AddGuildModal.module.css | 28 ------ .../add_guild_modal/GuildCreateShared.tsx | 37 ++------ .../components/popouts/GuildIcon.module.css | 40 --------- .../guild/components/popouts/GuildIcon.tsx | 22 +---- .../guild/utils/GuildAcronymUtils.test.ts | 73 ++++++++++++++++ .../features/guild/utils/GuildAcronymUtils.ts | 86 +++++++++++++++++++ .../guild/utils/GuildInitialsUtils.ts | 76 ---------------- .../markdown/renderers/LinkRenderer.tsx | 14 ++- .../renderers/MessageJumpLink.module.css | 15 ---- .../EntranceSoundSection.module.css | 3 +- .../tabs/components/EntranceSoundSection.tsx | 7 +- .../ProfileTypeSelector.module.css | 3 +- .../my_profile_tab/ProfileTypeSelector.tsx | 7 +- 22 files changed, 267 insertions(+), 311 deletions(-) create mode 100644 fluxer_app/src/features/guild/components/GuildAcronym.module.css create mode 100644 fluxer_app/src/features/guild/components/GuildAcronym.tsx create mode 100644 fluxer_app/src/features/guild/utils/GuildAcronymUtils.test.ts create mode 100644 fluxer_app/src/features/guild/utils/GuildAcronymUtils.ts delete mode 100644 fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts diff --git a/fluxer_app/src/features/app/components/dialogs/shared/GuildComboboxRenderers.tsx b/fluxer_app/src/features/app/components/dialogs/shared/GuildComboboxRenderers.tsx index 5e619f111..ada9d7f53 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/GuildComboboxRenderers.tsx +++ b/fluxer_app/src/features/app/components/dialogs/shared/GuildComboboxRenderers.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox'; import {clsx} from 'clsx'; import type React from 'react'; @@ -26,8 +26,6 @@ export interface GuildComboboxRenderersConfig { getNotice?: (option: T, disabled: boolean) => React.ReactNode; } -const ROW_INITIALS_MAX_LENGTH = 1; - const renderRow = ( option: T, disabled: boolean, @@ -37,7 +35,6 @@ const renderRow = ( ) => { const isGlobal = !option.value; const iconUrl = option.iconUrl ?? null; - const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase(); const notice = getNotice?.(option, disabled); return (
( aria-hidden data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder" > - {initial} +
))} diff --git a/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css b/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css index 9b5e65254..2447ddc59 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css @@ -185,19 +185,12 @@ background-color: transparent; background-size: cover; background-position: center; - font-weight: 600; - font-size: 1.25rem; color: var(--text-primary); overflow: hidden; transition: border-radius 70ms ease-out, background-color 70ms ease-out, color 70ms ease-out; - container-type: size; - --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); - --initials-fit-inline-size: 92cqi; - --initials-letter-spacing: 0px; - --initials-scale: 1; } .guildIconSelected:not(.guildIconNoImage) { @@ -242,28 +235,6 @@ } } -.guildIconInitials { - white-space: nowrap; - display: inline-block; - text-align: center; - line-height: 1; - color: inherit; - font-weight: 600; - font-size: min( - var(--initials-font-size), - calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) - ); - letter-spacing: var(--initials-letter-spacing); - transform: scale(var(--initials-scale)); - transform-origin: center; -} - -.guildIcon[data-initials-length='medium'] .guildIconInitials { - --initials-font-size: clamp(0.85rem, 38cqi, 1.11rem); - --initials-letter-spacing: 0px; - --initials-scale: 0.92; -} - .guildIndicator { position: absolute; left: var(--guild-list-indicator-inset); @@ -765,10 +736,7 @@ align-items: center; justify-content: center; overflow: hidden; - font-size: 0.5rem; - font-weight: 600; color: var(--text-primary); - line-height: 1; } .combinePreviewIconInitials { diff --git a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css index 958cee0c4..e56bbded2 100644 --- a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css +++ b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css @@ -124,15 +124,7 @@ .miniGuildIconWithInitials { background-color: var(--folder-mini-surface); -} - -.miniGuildInitials { - font-size: 0.5rem; - font-weight: 600; color: var(--text-primary); - line-height: 1; - text-align: center; - white-space: nowrap; } .folderBadge { diff --git a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx index e5a02d01d..8efd2fdd8 100644 --- a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx +++ b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx @@ -23,10 +23,10 @@ import { import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState'; import {useHover} from '@app/features/app/hooks/useHover'; import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import type {Guild} from '@app/features/guild/models/Guild'; import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded'; import GuildReadState from '@app/features/guild/state/GuildReadState'; -import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils'; import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery'; @@ -760,8 +760,6 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => { ); }); -const MINI_GUILD_INITIALS_MAX_LENGTH = 2; - interface MiniGuildIconProps { readonly guild: Guild; } @@ -782,12 +780,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => { className={flxElementClassName(styles.miniGuildIcon, styles.miniGuildIconWithInitials)} data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon--2" > - - {truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)} - + ); }); diff --git a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx index 6a4d9100e..30b757020 100644 --- a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx +++ b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx @@ -9,16 +9,9 @@ import type {GuildListItemDragAndDrop} from '@app/features/app/components/layout import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge'; import {DropPlacement} from '@app/features/app/components/layout/types/DndTypes'; import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import type {Guild} from '@app/features/guild/models/Guild'; import Guilds from '@app/features/guild/state/Guilds'; -import { - type GuildInitialsLength, - getGuildIconDisplayInitials, - getGuildInitialsFitStyle, - getInitialsFromName, - getInitialsLength, - truncateInitials, -} from '@app/features/guild/utils/GuildInitialsUtils'; import {ariaCurrentPage} from '@app/features/platform/ARIAProps'; import {Edge} from '@app/features/ui/AxisOrientation'; import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge'; @@ -58,13 +51,6 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot return STANDARD_GUILD_LIST_ITEM_MOTION; } -function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null { - if (displayInitials.length === 0) { - return null; - } - return getInitialsLength(displayInitials); -} - function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null { if (dragAndDrop == null) { return null; @@ -164,7 +150,6 @@ function GuildListItemBadges({ function CombinePreviewIcon({guild}: {readonly guild: Guild}) { const url = AvatarSourceUtils.getGuildIconURL(guild, false); - const initials = truncateInitials(getInitialsFromName(guild.name), 2); const backgroundStyle: React.CSSProperties = {}; if (url.length > 0) { backgroundStyle.backgroundImage = `url(${url})`; @@ -176,7 +161,10 @@ function CombinePreviewIcon({guild}: {readonly guild: Guild}) { data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.combine-preview-icon" > {url.length === 0 && ( - {initials} + )} ); @@ -304,8 +292,6 @@ export const GuildListItemPresentation = forwardRef< forwardedRef, ) { const rootRef = useMergeRefs([surfaceRef, forwardedRef]); - const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name)); - const initialsLength = resolveInitialsLength(displayInitials); const dropIndicator = resolveDropIndicator(dragAndDrop); const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion); const draggingCursor = resolveDraggingCursor(dragAndDrop); @@ -372,18 +358,14 @@ export const GuildListItemPresentation = forwardRef< to={guildIconTarget} from={false} tween={{duration: motion.iconTweenDuration, ease: 'easeOut'}} - data-initials-length={initialsLength} style={guildIconStyle} data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon" > {!hasPaintedIcon && ( - - {displayInitials} - + )} getGuildAcronym(name) || GUILD_ACRONYM_FALLBACK, [name]); + const style = useMemo( + () => ({'--guild-acronym-font-scale': getFittedGuildAcronymFontScale(acronym).toFixed(4)}), + [acronym], + ); + return ( + + + {acronym} + + + ); +} diff --git a/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css b/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css index 492f2033d..33d0c0d93 100644 --- a/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css +++ b/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css @@ -131,35 +131,7 @@ border-radius: 50%; border: 0.0625rem solid var(--background-modifier-accent); background: var(--background-tertiary); - container-type: size; - --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); - --initials-fit-inline-size: 92cqi; - --initials-letter-spacing: 0px; - --initials-scale: 1; -} - -.iconInitials { - user-select: none; - -webkit-user-select: none; - font-weight: 600; color: var(--text-primary); - line-height: 1; - white-space: nowrap; - display: inline-block; - text-align: center; - font-size: min( - var(--initials-font-size), - calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) - ); - letter-spacing: var(--initials-letter-spacing); - transform: scale(var(--initials-scale)); - transform-origin: center; -} - -.iconPlaceholder[data-initials-length='medium'] .iconInitials { - --initials-font-size: clamp(0.85rem, 38cqi, 1.11rem); - --initials-letter-spacing: 0px; - --initials-scale: 0.92; } .iconActions { diff --git a/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateShared.tsx b/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateShared.tsx index 60b7dbb59..392257801 100644 --- a/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateShared.tsx +++ b/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateShared.tsx @@ -13,18 +13,13 @@ import {openAssetSourceModal} from '@app/features/expressions/components/modals/ import {isAnimatedFile} from '@app/features/expressions/utils/AnimatedImageUtils'; import {formatImageUploadRecommendedHint} from '@app/features/expressions/utils/AssetUploadHintCopy'; import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions/utils/ImageUploadFileUtils'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import styles from '@app/features/guild/components/modals/AddGuildModal.module.css'; import { ANIMATED_ICONS_ARE_NOT_SUPPORTED_WHEN_CREATING_A_DESCRIPTOR, COMMUNITY_NAME_DESCRIPTOR, ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, } from '@app/features/guild/components/modals/add_guild_modal/shared'; -import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils'; -import { - getGuildIconDisplayInitials, - getGuildInitialsFitStyle, - getInitialsLength, -} from '@app/features/guild/utils/GuildInitialsUtils'; import { FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR, INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR, @@ -45,7 +40,7 @@ import {ph} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {EnvelopeSimpleIcon} from '@phosphor-icons/react'; import type React from 'react'; -import {useCallback, useMemo, useState} from 'react'; +import {useCallback, useState} from 'react'; import type {UseFormReturn} from 'react-hook-form'; interface GuildCreateFormValues { @@ -72,12 +67,7 @@ export function useGuildCreateIconField({ }: GuildCreateIconFieldOptions) { const {i18n} = useLingui(); const [previewIconUrl, setPreviewIconUrl] = useState(null); - const initials = useMemo(() => { - const raw = (nameValue || '').trim(); - if (!raw) return ''; - return getGuildIconDisplayInitials(StringUtils.getInitialsFromName(raw)); - }, [nameValue]); - const initialsLength = initials ? getInitialsLength(initials) : null; + const iconName = (nameValue || '').trim(); const showIconUploadErrorModal = useCallback( (message: string) => { showGenericErrorModal({ @@ -156,7 +146,7 @@ export function useGuildCreateIconField({ showGifOption: false, }); }, [handleIconUpload, i18n, messages]); - return {previewIconUrl, initials, initialsLength, handleOpenIconUpload, handleClearIcon}; + return {previewIconUrl, iconName, handleOpenIconUpload, handleClearIcon}; } export function renderGuildCreateAccountGate(i18n: I18n, dataFlx: string): React.ReactNode { @@ -224,8 +214,7 @@ export function renderGuildCreateFields({ nameInputDataFlx, showIconError, previewIconUrl, - initials, - initialsLength, + iconName, handleOpenIconUpload, handleClearIcon, }: GuildCreateFieldsOptions): React.ReactNode { @@ -244,20 +233,8 @@ export function renderGuildCreateFields({ data-flx={`${dataFlx}.icon-image`} /> ) : ( -
- {initials ? ( - - {initials} - - ) : null} +
+ {iconName ? : null}
)}
diff --git a/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css b/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css index 5ecd9d6cb..659ff6e76 100644 --- a/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css +++ b/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css @@ -11,54 +11,14 @@ justify-content: center; overflow: hidden; border-radius: 624.9375rem; - font-weight: 600; color: var(--text-tertiary); background-color: var(--background-tertiary); background-image: var(--guild-icon-image, none); background-position: center; background-size: cover; - container-type: inline-size; - --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); - --initials-fit-inline-size: 92cqi; - --initials-letter-spacing: 0px; - --initials-scale: 1; - --initials-scale-cap: 1; } .containerNoIcon { background-image: none; background-color: var(--background-tertiary); } - -.initials { - align-self: center; - line-height: 1; - white-space: nowrap; - display: inline-block; - text-align: center; - font-size: min( - var(--initials-font-size), - calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) - ); - letter-spacing: var(--initials-letter-spacing); - transform: scale(calc(var(--initials-scale) * var(--initials-scale-cap))); - transform-origin: center; -} - -.container[data-initials-length='medium'] .initials { - --initials-font-size: clamp(0.85rem, 38cqi, 1.11rem); - --initials-scale: 0.92; - --initials-letter-spacing: 0px; -} - -@container (max-width: 32px) { - .initials { - --initials-scale-cap: 0.88; - } -} - -@container (max-width: 26px) { - .initials { - --initials-scale-cap: 0.78; - } -} diff --git a/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx b/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx index e80df22bb..0ebf2a84c 100644 --- a/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx +++ b/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx @@ -2,13 +2,8 @@ import {useHover} from '@app/features/app/hooks/useHover'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import styles from '@app/features/guild/components/popouts/GuildIcon.module.css'; -import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils'; -import { - getGuildIconDisplayInitials, - getGuildInitialsFitStyle, - getInitialsLength, -} from '@app/features/guild/utils/GuildInitialsUtils'; import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery'; import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; @@ -16,7 +11,7 @@ import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; interface GuildIconProps { id: string; @@ -43,8 +38,6 @@ export const GuildIcon = observer(function GuildIcon({ sizePx, containerProps, }: GuildIconProps) { - const initials = useMemo(() => getGuildIconDisplayInitials(StringUtils.getInitialsFromName(name)), [name]); - const initialsLength = getInitialsLength(initials); const [hoverRef, isHovering] = useHover(); const iconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}) : null; const hoverIconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}, true) : null; @@ -72,18 +65,9 @@ export const GuildIcon = observer(function GuildIcon({ className={clsx(styles.container, className, paintedUrl == null && styles.containerNoIcon)} data-flx="guild.guild-icon.container" {...containerProps} - data-initials-length={initialsLength} style={styleVars} > - {paintedUrl == null && ( - - {initials} - - )} + {paintedUrl == null && }
); }); diff --git a/fluxer_app/src/features/guild/utils/GuildAcronymUtils.test.ts b/fluxer_app/src/features/guild/utils/GuildAcronymUtils.test.ts new file mode 100644 index 000000000..ae3099c6c --- /dev/null +++ b/fluxer_app/src/features/guild/utils/GuildAcronymUtils.test.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + getGuildAcronym, + getGuildAcronymFontScale, + getGuildAcronymLength, +} from '@app/features/guild/utils/GuildAcronymUtils'; +import {describe, expect, test} from 'vitest'; + +const ACRONYM_VECTORS: ReadonlyArray = [ + ['Movies? We Like Movies', 'M?WLM', 5], + ['Movies Movies', 'MM', 2], + ['(Internal) Fluxer DevOps', '(I)FD', 5], + ['secret server', 'ss', 2], + ['Hampus server', 'Hs', 2], + ['Fluxer', 'F', 1], + [' Fluxer ', 'F', 1], + ['a b\tc\nd', 'abcd', 4], + ["Bob's Burgers", 'BB', 2], + ['Bob’s Burgers', 'BB', 2], + ["Bob's", "B's", 3], + ["Don't Panic", "D'tP", 4], + ['Area 51', 'A5', 2], + ['2024 Reunion', '2R', 2], + ['Rock-n-Roll', 'R-n-R', 5], + ['Spider-Man Fans', 'S-MF', 4], + ['snake_case_name', 's', 1], + ['C++ & Rust', 'C++&R', 5], + ['@everyone', '@e', 2], + ['!!!', '!!!', 3], + ['\u{1F3AE}\u{1F3B2}', '\u{1F3AE}\u{1F3B2}', 2], + ['\u{1F496}tes tes', '\u{1F496}tt', 3], + ['❤️love', '❤️l', 2], + ['\u{1F468}‍\u{1F469}‍\u{1F467} Family', '\u{1F468}‍\u{1F469}‍\u{1F467}F', 2], + ['\u{1F1F8}\u{1F1EA} Sweden', '\u{1F1F8}\u{1F1EA}S', 2], + ['1️⃣ One', '1️⃣O', 2], + ['日本語 サーバー', '日サ', 2], + ['原神交流群', '原', 1], + ['한국 서버', '한서', 2], + ['Привет Мир', 'ПМ', 2], + ['مرحبا بالعالم', 'مب', 2], + ['שלום עולם', 'שע', 2], + ['می‌خواهم بازی', 'مب', 2], + ['École Normale', 'ÉN', 2], + ['नमस्ते दुनिया', 'नदु', 2], + ['one two three four five six seven eight nine ten', 'ottffssent', 10], + ['', '', 0], + [' \t  ', '', 0], +]; + +const FONT_SCALE_VECTORS: ReadonlyArray = [ + [0, 18], + [1, 18], + [2, 18], + [3, 16], + [4, 14], + [5, 12], + [6, 10], + [10, 10], +]; + +describe('getGuildAcronym', () => { + test.each(ACRONYM_VECTORS)('%j -> %j', (name, acronym, length) => { + expect(getGuildAcronym(name)).toBe(acronym); + expect(getGuildAcronymLength(acronym)).toBe(length); + }); +}); + +describe('getGuildAcronymFontScale', () => { + test.each(FONT_SCALE_VECTORS)('%i graphemes -> %ipx on a 48px icon', (length, fontSizePx) => { + expect(getGuildAcronymFontScale(length) * 48).toBeCloseTo(fontSizePx); + }); +}); diff --git a/fluxer_app/src/features/guild/utils/GuildAcronymUtils.ts b/fluxer_app/src/features/guild/utils/GuildAcronymUtils.ts new file mode 100644 index 000000000..abbd543f2 --- /dev/null +++ b/fluxer_app/src/features/guild/utils/GuildAcronymUtils.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const POSSESSIVE_SUFFIX = /['’]s(?=\s)/gu; +const WORD_RUN = /([\p{L}\p{N}_]\p{M}*)[\p{L}\p{N}\p{M}_‌‍]*/gu; +const WHITESPACE = /\s+/gu; +const GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX = 48; +const GUILD_ACRONYM_FONT_SIZES_PX = [18, 18, 16, 14, 12, 10]; +const GUILD_ACRONYM_FIT_PADDING_PX = 4; +const GUILD_ACRONYM_FIT_WIDTH_RATIO = + (GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX - GUILD_ACRONYM_FIT_PADDING_PX * 2) / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX; +const GUILD_ACRONYM_MEASURE_FONT_SIZE_PX = 100; +const GUILD_ACRONYM_WIDTH_CACHE_CAPACITY = 512; + +const acronymWidthCache = new Map(); +let measureContext: CanvasRenderingContext2D | null | undefined; + +export const GUILD_ACRONYM_FALLBACK = '?'; + +const graphemeSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, {granularity: 'grapheme'}) + : null; + +function firstGrapheme(wordRun: string, leadingCharacterWithMarks: string): string { + if (graphemeSegmenter == null) return leadingCharacterWithMarks; + for (const {segment} of graphemeSegmenter.segment(wordRun)) { + return segment; + } + return leadingCharacterWithMarks; +} + +export function getGuildAcronym(name: string): string { + return name.replace(POSSESSIVE_SUFFIX, '').replace(WORD_RUN, firstGrapheme).replace(WHITESPACE, ''); +} + +export function getGuildAcronymLength(acronym: string): number { + if (graphemeSegmenter == null) return Array.from(acronym).length; + let length = 0; + for (const _segment of graphemeSegmenter.segment(acronym)) { + length += 1; + } + return length; +} + +export function getGuildAcronymFontScale(acronymLength: number): number { + const index = Math.min(Math.max(acronymLength, 1), GUILD_ACRONYM_FONT_SIZES_PX.length) - 1; + return GUILD_ACRONYM_FONT_SIZES_PX[index] / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX; +} + +function getMeasureContext(): CanvasRenderingContext2D | null { + if (measureContext !== undefined) return measureContext; + if (typeof document === 'undefined' || document.body == null) return null; + const context = document.createElement('canvas').getContext('2d'); + if (context != null) { + context.font = `500 ${GUILD_ACRONYM_MEASURE_FONT_SIZE_PX}px ${getComputedStyle(document.body).fontFamily}`; + } + measureContext = context; + return context; +} + +function getGuildAcronymWidthPerEm(acronym: string): number | null { + const cached = acronymWidthCache.get(acronym); + if (cached !== undefined) return cached; + const context = getMeasureContext(); + if (context == null) return null; + const widthPerEm = context.measureText(acronym).width / GUILD_ACRONYM_MEASURE_FONT_SIZE_PX; + if (acronymWidthCache.size >= GUILD_ACRONYM_WIDTH_CACHE_CAPACITY) acronymWidthCache.clear(); + acronymWidthCache.set(acronym, widthPerEm); + return widthPerEm; +} + +export function fitGuildAcronymFontScale(acronymLength: number, widthPerEm: number | null): number { + const scale = getGuildAcronymFontScale(acronymLength); + if (widthPerEm == null || widthPerEm <= 0) return scale; + const maxScale = GUILD_ACRONYM_FIT_WIDTH_RATIO / widthPerEm; + if (scale <= maxScale) return scale; + for (const fontSize of GUILD_ACRONYM_FONT_SIZES_PX) { + const candidate = fontSize / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX; + if (candidate < scale && candidate <= maxScale) return candidate; + } + return maxScale; +} + +export function getFittedGuildAcronymFontScale(acronym: string): number { + return fitGuildAcronymFontScale(getGuildAcronymLength(acronym), getGuildAcronymWidthPerEm(acronym)); +} diff --git a/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts b/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts deleted file mode 100644 index 6e25e20c3..000000000 --- a/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type React from 'react'; - -const GUILD_ICON_INITIALS_MAX_LENGTH = 4; -const MEASURE_FONT_SIZES_PX = [10, 12, 14, 16, 20, 24]; -const MEASURE_FONT_WEIGHT = 600; - -export const GuildInitialsLength = Object.freeze({ - SHORT: 'short', - MEDIUM: 'medium', -} as const); - -export type GuildInitialsLength = (typeof GuildInitialsLength)[keyof typeof GuildInitialsLength]; - -export type GuildInitialsFitStyle = React.CSSProperties & { - '--initials-width-em'?: string; -}; - -const measuredWidthsEm = new Map(); -let measureContext: OffscreenCanvasRenderingContext2D | null | undefined; - -export function getInitialsFromName(name: string): string { - const words = name.split(/\s+/u).filter(Boolean); - return words - .map((word) => Array.from(word)[0]) - .filter(Boolean) - .join(''); -} - -export function truncateInitials(initials: string, maxLength: number): string { - if (maxLength <= 0) return ''; - return Array.from(initials).slice(0, maxLength).join(''); -} - -export function getGuildIconDisplayInitials(initials: string): string { - return truncateInitials(initials, GUILD_ICON_INITIALS_MAX_LENGTH); -} - -export function getInitialsLength(displayInitials: string): GuildInitialsLength { - return Array.from(displayInitials).length <= 2 ? GuildInitialsLength.SHORT : GuildInitialsLength.MEDIUM; -} - -function getMeasureContext(): OffscreenCanvasRenderingContext2D | null { - if (measureContext !== undefined) return measureContext; - if (typeof document === 'undefined' || typeof OffscreenCanvas === 'undefined') { - measureContext = null; - return measureContext; - } - measureContext = new OffscreenCanvas(1, 1).getContext('2d'); - document.fonts?.addEventListener('loadingdone', () => measuredWidthsEm.clear()); - return measureContext; -} - -function measureInitialsWidthEm(initials: string): number | null { - const context = getMeasureContext(); - if (context == null || document.body == null) return null; - const fontFamily = getComputedStyle(document.body).fontFamily; - const key = `${fontFamily}\u0000${initials}`; - const cached = measuredWidthsEm.get(key); - if (cached !== undefined) return cached; - let widthEm = 0; - for (const fontSize of MEASURE_FONT_SIZES_PX) { - context.font = `${MEASURE_FONT_WEIGHT} ${fontSize}px ${fontFamily}`; - widthEm = Math.max(widthEm, context.measureText(initials).width / fontSize); - } - measuredWidthsEm.set(key, widthEm); - return widthEm; -} - -export function getGuildInitialsFitStyle(displayInitials: string): GuildInitialsFitStyle | undefined { - if (!displayInitials) return undefined; - const widthEm = measureInitialsWidthEm(displayInitials); - if (widthEm == null || widthEm <= 0) return undefined; - return {'--initials-width-em': widthEm.toFixed(3)}; -} diff --git a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx index 0d8b7c02c..83f1c423b 100644 --- a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx +++ b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx @@ -14,10 +14,9 @@ import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/Ch import DeveloperMode from '@app/features/devtools/state/DeveloperMode'; import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands'; import DiscoveryChannelPreviews from '@app/features/discovery/state/DiscoveryChannelPreviews'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import type {Guild} from '@app/features/guild/models/Guild'; import Guilds from '@app/features/guild/state/Guilds'; -import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils'; -import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils'; import { COPY_LINK_DESCRIPTOR, OKAY_DESCRIPTOR, @@ -228,7 +227,6 @@ function CompactGuildMentionIcon({guild}: {guild: Pick {!iconUrl && ( - - {initials} - + )} ); diff --git a/fluxer_app/src/features/messaging/components/markdown/renderers/MessageJumpLink.module.css b/fluxer_app/src/features/messaging/components/markdown/renderers/MessageJumpLink.module.css index 1b2846fa8..24c97d6e0 100644 --- a/fluxer_app/src/features/messaging/components/markdown/renderers/MessageJumpLink.module.css +++ b/fluxer_app/src/features/messaging/components/markdown/renderers/MessageJumpLink.module.css @@ -59,21 +59,6 @@ background-position: center; background-size: cover; color: var(--text-primary); - font-weight: 600; - line-height: 1; -} - -.guildInitials { - display: grid; - place-items: center; - width: 100%; - height: 100%; - font-size: 0.56em; - line-height: 1; - max-width: 100%; - overflow: hidden; - text-align: center; - white-space: nowrap; } .divider { diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.module.css b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.module.css index 2ca4d23d9..685384222 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.module.css +++ b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.module.css @@ -203,8 +203,7 @@ border: 0.0625rem solid var(--entrance-sound-border); background: color-mix(in srgb, #0f1014 80%, var(--background-header-secondary) 20%); color: white; - font-size: 0.75rem; - font-weight: 700; + overflow: hidden; } .scopeTileText { diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx index 88f39b700..a9e247d17 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx @@ -3,6 +3,7 @@ import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import Guilds from '@app/features/guild/state/Guilds'; import {DIRECT_MESSAGES_DESCRIPTOR, GET_PREMIUM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache'; @@ -186,7 +187,6 @@ interface ScopeArtworkProps { function ScopeArtwork({option, selected, compact}: ScopeArtworkProps): React.ReactElement { if (option.guildId) { const iconUrl = option.guildIcon ? AvatarUtils.getGuildIconURL({id: option.guildId, icon: option.guildIcon}) : null; - const initial = option.label.trim().charAt(0).toUpperCase() || '?'; return iconUrl ? ( - {initial} + ); } diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css index 4bbbe99ee..ad11bdb80 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css @@ -48,8 +48,7 @@ align-items: center; justify-content: center; color: white; - font-weight: 700; - font-size: 0.85rem; + overflow: hidden; border: 0.0625rem solid var(--background-modifier-accent); } diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.tsx index 995d95910..bd6e8a447 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {GuildComboboxOption} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers'; +import {GuildAcronym} from '@app/features/guild/components/GuildAcronym'; import Guilds from '@app/features/guild/state/Guilds'; import {Combobox} from '@app/features/ui/components/form/FormCombobox'; import styles from '@app/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css'; @@ -31,7 +32,6 @@ export const ProfileTypeSelector = observer(({selectedGuildId, onChange, disable const renderGuildRow = useCallback((option: GuildComboboxOption) => { const isGlobal = !option.value; const iconUrl = option.icon ? AvatarUtils.getGuildIconURL({id: option.value, icon: option.icon}) : null; - const initial = option.label.charAt(0).toUpperCase(); return (
- {initial} +
))}