diff --git a/.gitignore b/.gitignore index c070bcb4a..ce8f53db2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ **/*.css.d.ts **/*.tsbuildinfo **/.cache/ +**/.swc/ **/__pycache__/ **/_build/ **/coverage/ diff --git a/fluxer_app/index.html b/fluxer_app/index.html index a3220ee09..00beb4605 100644 --- a/fluxer_app/index.html +++ b/fluxer_app/index.html @@ -23,6 +23,7 @@ +
diff --git a/fluxer_app/package.json b/fluxer_app/package.json index 659155f49..6975390ad 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -200,6 +200,7 @@ "@radix-ui/react-switch": "catalog:", "@simplewebauthn/browser": "catalog:", "@tanstack/react-virtual": "^3.13.23", + "animejs": "4.5.0", "bowser": "catalog:", "clsx": "catalog:", "codemirror": "^6.0.2", diff --git a/fluxer_app/scripts/GenerateThemeVariables.ts b/fluxer_app/scripts/GenerateThemeVariables.ts index d7ffed5df..1f632885b 100644 --- a/fluxer_app/scripts/GenerateThemeVariables.ts +++ b/fluxer_app/scripts/GenerateThemeVariables.ts @@ -2,6 +2,7 @@ import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; import {dirname, join, relative, resolve} from 'node:path'; +import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other'; @@ -407,6 +408,11 @@ function renderValueMap(name: string, values: ReadonlyMap): stri return `export const ${name}: Readonly> = {\n${body}\n};`; } +function renderNameUnion(definitions: ReadonlyArray): string { + const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n'); + return `export type ThemeVariableName =\n${body};`; +} + function renderDefinitions(definitions: ReadonlyArray): string { const body = definitions .map( @@ -439,6 +445,8 @@ export interface ThemeVariableDefinition { \tsource: string; } +${renderNameUnion(definitions)} + ${renderDefinitions(definitions)} ${renderStringArray( @@ -456,9 +464,361 @@ ${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)} `; } +interface SkeletonSurfaceInvariant { + file: string; + requires?: ReadonlyArray; + requiresPattern?: ReadonlyArray; + forbids?: ReadonlyArray; + forbidsPattern?: ReadonlyArray; + counts?: Readonly>; + minimums?: Readonly>; +} + +const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)'; + +const SKELETON_SURFACE_INVARIANTS: ReadonlyArray = [ + { + file: 'src/app/globals.css', + counts: { + '--skeleton-chrome-border-color': 2, + '--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1, + '--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1, + '--chat-horizontal-padding-default: 1rem;': 1, + '--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1, + '--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1, + }, + }, + { + file: 'src/features/app/components/skeleton/ChatSkeleton.module.css', + minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1}, + forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], + forbids: ['scopeBadge', 'scope-badge'], + }, + { + file: 'src/features/app/components/skeleton/ChatSkeleton.tsx', + requires: [ + "import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';", + 'composerWrapperStyles.composerRoot', + "'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),", + "'--font-size': remFromPx(messagePresentation.fontSizePx),", + "'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),", + "'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),", + ], + forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'], + }, + { + file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css', + minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2}, + forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], + }, + { + file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css', + minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4}, + forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], + forbids: ['.activeNowPreview'], + }, + { + file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx', + forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'], + requires: ['function resolveFriendsListSections('], + }, + { + file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css', + requires: [ + '.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}', + '.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}', + '.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}', + 'background-color: var(--guilds-layout-item-bg);', + ".item[data-selected='true'] .fluxerIcon", + 'clip-path: inset(0);', + '\ttransform: translateY(0rem);', + ], + forbids: ['--guild-list-indicator-', '::before'], + counts: {'z-index: 0;': 3, 'z-index: 1;': 3}, + }, + { + file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx', + requires: [ + "import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';", + 'guildStyles.guildIndicator', + 'guildStyles.guildIndicatorBar', + 'resolveGuildListIndicatorBarTarget(', + 'const InlineDMPlaceholder = ', + ], + requiresPattern: [']*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'], + forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'], + counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1}, + }, + { + file: 'src/features/ui/components/Scroller.module.css', + counts: {'clip-path: inset(0);': 1}, + }, + { + file: 'src/features/channel/components/MemberListSkeleton.tsx', + counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2}, + }, + { + file: 'src/features/channel/components/textarea/InputWrapper.module.css', + requires: ['.composerRoot:has(.statusTypingSlot)::before'], + }, + { + file: 'src/features/app/components/layout/GuildsLayout.module.css', + requires: [ + '.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}', + '--layout-user-area-overlay-height', + '\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);', + '.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;', + '.guildIndicator {', + '.guildIndicatorBar {', + ], + forbids: ['.fluxerSymbolIcon'], + }, + { + file: 'src/features/app/components/layout/GuildsLayout.tsx', + forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'], + counts: {'scrollNode.scrollTop = ': 2}, + }, + { + file: 'src/features/discovery/discovery/DiscoveryPage.tsx', + counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1}, + }, + { + file: 'src/features/app/components/LongPressable.ts', + forbids: ['useImperativeHandle'], + requires: ['useMergeRefs'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts', + requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'], + forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'], + counts: { + 'function resolveGuildListIndicatorHeight': 1, + 'export function resolveGuildListIndicatorBarTarget': 1, + 'export ': 3, + }, + }, + { + file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx', + requires: [ + 'const shouldShowHoverState = isHovering || contextMenuOpen;', + 'ChatCircleIcon', + 'styles.messageBubbleIcon', + 'styles.contextMenuHover', + 'resolveGuildListIndicatorBarTarget(', + ], + forbids: ['scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx', + requires: ['resolveGuildListIndicatorBarTarget('], + forbids: ['scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx', + requires: [ + 'const shouldShowHoverState = isHovering || contextMenuOpen;', + 'guildStyles.contextMenuHover', + 'resolveGuildListIndicatorBarTarget(', + ], + forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx', + requires: [ + 'const shouldShowHoverState = isHovering || contextMenuOpen;', + 'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});', + "const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});", + "to={{opacity: 1, translateY: '0rem'}}", + 'resolveGuildListIndicatorBarTarget(', + 'guildStyles.guildIndicatorBar', + ], + forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css', + forbids: ['.folderIndicator'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx', + requires: [ + 'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);', + 'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;', + ], + forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx', + forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], + }, + { + file: 'src/features/channel/components/ChannelMembers.tsx', + forbids: ['@tanstack/react-virtual'], + requires: ['styles.virtualRow'], + }, +]; + +const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray = [ + 'src/features/app/components/skeleton', + 'src/features/channel/components', + 'src/features/user/components/profile', +]; +const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css'; +const BORDER_DECLARATION_PATTERN = + /(? { + const files: Array = []; + const visit = (directory: string) => { + for (const entry of readdirSync(directory, {withFileTypes: true})) { + const absolutePath = join(directory, entry.name); + if (entry.isDirectory()) { + visit(absolutePath); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue; + files.push(toPosixPath(relative(appDir, absolutePath))); + } + }; + for (const directory of SKELETON_STYLESHEET_DIRECTORIES) { + visit(join(appDir, directory)); + } + return files.sort(); +} + +interface SkeletonSharedRuleSource { + readonly file: string; + readonly selectors: ReadonlyArray; +} + +const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray = [ + { + file: 'src/features/app/components/layout/GuildsLayout.module.css', + selectors: ['.guildIndicator', '.guildIndicatorBar'], + }, +]; +const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g; + +function assertSkeletonSharedRuleTokens(appDir: string): void { + const violations: Array = []; + const declaredTokens = new Set(SKELETON_SURFACE_TOKENS); + for (const source of SKELETON_SHARED_RULE_SOURCES) { + const contents = readFileSync(join(appDir, source.file), 'utf8'); + for (const selector of source.selectors) { + const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents); + if (rule == null) { + violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`); + continue; + } + for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) { + const token = match[1]; + if (declaredTokens.has(token)) continue; + violations.push( + `${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` + + 'list it in SKELETON_SURFACE_TOKENS.', + ); + } + } + } + if (violations.length > 0) { + throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); + } +} + +const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g; + +function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array): void { + const file = 'src/app/globals.css'; + const contents = readFileSync(join(appDir, file), 'utf8'); + for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) { + const value = match[1].trim(); + if (value.startsWith('var(--background-modifier-accent')) { + violations.push( + `${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` + + 'skeleton chrome must use the softened colour, never the raw accent.', + ); + } + } +} + +function assertSkeletonBorderTokens(appDir: string): void { + const violations: Array = []; + assertSkeletonChromeBorderColourIsSoftened(appDir, violations); + for (const file of collectSkeletonStylesheets(appDir)) { + const contents = readFileSync(join(appDir, file), 'utf8'); + if (contents.includes('--skeleton-chrome-border-color')) { + violations.push( + `${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`, + ); + } + for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) { + const value = match[1].trim(); + if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) { + violations.push( + `${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` + + 'skeleton borders must reference a shared token.', + ); + } + } + } + if (violations.length > 0) { + throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); + } +} + +function assertSkeletonSurfaceInvariants(appDir: string): void { + const violations: Array = []; + for (const invariant of SKELETON_SURFACE_INVARIANTS) { + const path = join(appDir, invariant.file); + if (!existsSync(path)) { + violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`); + continue; + } + const contents = readFileSync(path, 'utf8'); + for (const required of invariant.requires ?? []) { + if (!contents.includes(required)) { + violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`); + } + } + for (const forbidden of invariant.forbids ?? []) { + if (contents.includes(forbidden)) { + violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`); + } + } + for (const source of invariant.requiresPattern ?? []) { + if (!new RegExp(source, 'u').test(contents)) { + violations.push(`${invariant.file} must match /${source}/.`); + } + } + for (const source of invariant.forbidsPattern ?? []) { + if (new RegExp(source, 'u').test(contents)) { + violations.push(`${invariant.file} must not match /${source}/.`); + } + } + for (const [needle, expected] of Object.entries(invariant.counts ?? {})) { + const actual = contents.split(needle).length - 1; + if (actual !== expected) { + violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`); + } + } + for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) { + const actual = contents.split(needle).length - 1; + if (actual < minimum) { + violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`); + } + } + } + if (violations.length > 0) { + throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); + } +} + function main(): void { const scriptDir = import.meta.dirname; const appDir = resolve(scriptDir, '..'); + assertSkeletonSurfaceInvariants(appDir); + assertSkeletonSharedRuleTokens(appDir); + assertSkeletonBorderTokens(appDir); const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts'); const contents = render(appDir); if (process.argv.includes('--check')) { diff --git a/fluxer_app/src/app/App.tsx b/fluxer_app/src/app/App.tsx index 091f2dcf9..4780d216c 100644 --- a/fluxer_app/src/app/App.tsx +++ b/fluxer_app/src/app/App.tsx @@ -19,11 +19,8 @@ import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform'; 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 Initialization from '@app/features/app/state/Initialization'; import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext'; import RuntimeCrash from '@app/features/app/state/RuntimeCrash'; -import Authentication from '@app/features/auth/state/Authentication'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import MemberSidebar from '@app/features/member/state/MemberSidebar'; @@ -127,12 +124,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => { }, []), ); const handleSkipLinkFocus = useTabKeyFocusGuard(); - const isSplashScreenActive = - Authentication.isAuthenticated && - !DeveloperOptions.bypassSplashScreen && - (GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes); useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop); - useInertBackground(overlayScopeRef, isSplashScreenActive); useEffect(() => { showMyselfTypingHelper.start(); return () => showMyselfTypingHelper.stop(); diff --git a/fluxer_app/src/app/globals.css b/fluxer_app/src/app/globals.css index 6fdc2d9a3..4780edc40 100644 --- a/fluxer_app/src/app/globals.css +++ b/fluxer_app/src/app/globals.css @@ -103,6 +103,25 @@ var { --input-wrapper-padding-bottom: 0.5rem; --textarea-top-bar-height: 2.5rem; + --chat-horizontal-padding-default: 1rem; + --message-compact-horizontal-padding: min( + var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)), + 1rem + ); + --textarea-font-size: var(--font-size, 1rem); + --textarea-button-height: 2rem; + --textarea-button-icon-size: 1.375rem; + --textarea-button-compact-height: 2rem; + --textarea-button-compact-icon-size: 1.25rem; + --textarea-container-padding-x: 0px; + --textarea-line-height: calc(var(--textarea-font-size) * 1.375); + --textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2)); + --textarea-upload-gap: var(--message-gutter, 1rem); + --textarea-side-button-padding: max( + 0px, + calc((var(--message-avatar-size, 2.5rem) - var(--textarea-button-height)) / 2) + ); + --footer-box-height: 3.625rem; --footer-box-inset: 0.375rem; --footer-box-inset-inline: var(--footer-box-inset); @@ -111,8 +130,38 @@ var { --outline-frame-border-width: 0.0625rem; --composer-mobile-box-height: 3rem; --composer-action-gap: 0.25rem; + --composer-surface-color: var(--background-secondary-lighter); + --composer-status-line-height: 1.125rem; + --composer-status-safe-gap: 0.5rem; + --composer-status-safe-area: calc( + var(--composer-status-line-height) + + var(--composer-status-safe-gap) + + var(--composer-status-safe-gap) + ); + --guild-list-item-box-size: 3rem; + --guild-list-item-gap: 0.375rem; + --guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap)); + --guild-list-item-target-half-extra: calc((var(--guild-list-item-target-size) - var(--guild-list-item-box-size)) / 2); + --guild-folder-expanded-surface-size: var(--guild-list-item-box-size); + --folder-radius: calc(var(--guild-folder-expanded-surface-size) * 0.3); + --mention-badge-small-size: 1.25rem; + --mention-badge-medium-size: 1.5rem; + --outline-frame-radius-native: clamp(0.5rem, 1.2vw, 0.875rem); + --guild-list-divider-line-size: 0.125rem; + --guild-list-divider-base-gap: var(--guild-list-item-gap); + --guild-members-columns: minmax(15rem, 2.2fr) minmax(7.5rem, 1fr) minmax(7.5rem, 1fr) minmax(9.375rem, 1.15fr) + minmax(10rem, 1.35fr) 4.5rem; + --guild-members-min-width: calc(15rem + 7.5rem + 7.5rem + 9.375rem + 10rem + 4.5rem); + --guild-members-select-column-width: 2.75rem; + --guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns); + --guild-members-min-width-selectable: calc(var(--guild-members-select-column-width) + var(--guild-members-min-width)); --floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent); --floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent); + --skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent); + --skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color); + --skeleton-opacity-strong: 0.55; + --skeleton-opacity-default: 0.45; + --skeleton-opacity-muted: 0.35; --footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2)); --composer-mobile-padding-y: max( @@ -124,10 +173,14 @@ var { --textarea-min-height: var(--footer-box-height); --textarea-padding-y: var(--footer-box-padding-y); --composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2)); - --composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4))); + --messages-bottom-clearance: var(--composer-status-safe-area); + --composer-box-inset-inline: min( + var(--footer-box-inset), + var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) + ); --composer-box-padding-inline: max( 0rem, - calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline)) + calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline)) ); --typing-indicator-height: 1rem; @@ -167,6 +220,7 @@ var { --layout-header-height: 3.5rem; --layout-user-area-height: var(--footer-box-height); --layout-user-area-reserved-height: 0px; + --layout-voice-connection-height: 0px; --layout-mobile-bottom-nav-reserved-height: 0px; --user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width)); --user-area-content-height: var(--textarea-button-height, 2rem); @@ -193,6 +247,90 @@ var { --mobile-bottom-nav-height: 3.75rem; + --message-compact-text-indent-from-username: 2rem; + --guilds-layout-item-bg: var(--guild-list-foreground); + --guild-badge-surface: var(--background-secondary); + --layout-member-list-width: 16.5rem; + --layout-header-height-mobile: 4rem; + + --channel-header-gap: var(--spacing-4); + --channel-header-padding-inline: var(--spacing-4); + --channel-header-action-size: 2rem; + --channel-header-action-size-mobile: 2.5rem; + --channel-header-actions-gap: var(--spacing-2); + --channel-header-back-button-size: 2rem; + --channel-header-back-button-gap: var(--spacing-3); + --channel-header-icon-size: 1.5rem; + --channel-header-back-icon-size: 1.5rem; + --channel-header-caret-size: 1rem; + --channel-header-action-icon-size: 1.5rem; + --channel-header-name-gap: var(--spacing-3); + --channel-header-name-gap-group-dm: var(--spacing-2); + --channel-header-caret-gap: var(--spacing-1); + --channel-header-topic-divider-gap: var(--spacing-2); + + --chat-mobile-horizontal-padding: 0.75rem; + --dm-sidebar-mobile-header-height: 3.5rem; + + --message-search-bar-width: 15.25rem; + --message-search-bar-height: 2.25rem; + --message-search-bar-icon-size: 1rem; + --message-search-bar-icon-gap: 0.5rem; + + --member-list-item-content-height: 2rem; + --member-list-item-padding-block: 0.25rem; + --member-list-item-padding-inline: 0.5rem; + --member-list-item-content-gap: 0.625rem; + --member-list-item-name-line-height: 1.25rem; + --member-list-item-status-line-height: 0.875rem; + --member-list-row-gap: 0.125rem; + --member-list-group-spacer-height: 0.25rem; + --member-list-scroller-padding-block-start: 0.25rem; + --member-list-scroller-padding-block-end: var(--spacing-4); + --member-list-scroller-padding-inline: var(--spacing-2); + + --friend-row-padding-block: 0.75rem; + --friend-row-padding-inline: 1rem; + --friend-row-separator-inset: 0 0.4rem 0 var(--spacing-2); + --friend-row-separator-radius: 0.375rem; + --friend-row-content-gap: 0.75rem; + --friend-row-name-line-height: 1.25rem; + --friend-row-subtext-line-height: 1rem; + --friend-row-subtext-offset: -0.0625rem; + --friend-row-avatar-size: 2.25rem; + --friend-row-action-size: 2.25rem; + --friend-row-action-gap: 0.5rem; + + --avatar-stack-extra-height: 0.375rem; + + --active-now-sidebar-width: 22rem; + --active-now-sidebar-header-padding-inline: 1rem; + --active-now-sidebar-header-padding-block-start: 1rem; + --active-now-sidebar-header-padding-block-end: 0.75rem; + --active-now-sidebar-content-gap: 0.5rem; + --active-now-sidebar-content-padding-inline: 0.75rem; + --active-now-sidebar-content-padding-block-end: 0.75rem; + --active-now-card-gap: 0.75rem; + --active-now-card-padding-block: 0.75rem; + --active-now-card-padding-inline: 0.875rem; + --active-now-card-radius: var(--radius-md); + --active-now-card-shadow: 0 0.375rem 1.125rem rgba(0, 0, 0, 0.08); + --active-now-context-gap: 0.375rem; + --active-now-action-height: 2rem; + --active-now-action-radius: 0.5rem; + --active-now-action-gap: 0.375rem; + --active-now-action-padding-block: 0.3125rem; + --active-now-action-padding-inline: 0.625rem; + + --discovery-card-description-min-height: 5.25rem; + --guild-list-indicator-inset: -0.15rem; + --guild-list-indicator-track-width: 0.5rem; + --guild-list-indicator-bar-width: 0.35rem; + --count-indicator-gap: 0.25rem; + + --guild-members-table-row-min-height: 3rem; + --guild-members-table-row-border-width: 0.0625rem; + --form-surface-background: var(--background-tertiary); --surface-interactive-hover-bg: var(--background-modifier-hover); --surface-interactive-selected-bg: var(--background-modifier-selected); @@ -209,6 +347,7 @@ html.platform-native.platform-macos { } .theme-light { + --guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%); --form-surface-background: var(--background-primary); --surface-interactive-hover-bg: var(--background-modifier-hover); --surface-interactive-selected-bg: var(--background-modifier-selected); diff --git a/fluxer_app/src/app/router/components/RootComponent.tsx b/fluxer_app/src/app/router/components/RootComponent.tsx index b0bfb739f..017a04da4 100644 --- a/fluxer_app/src/app/router/components/RootComponent.tsx +++ b/fluxer_app/src/app/router/components/RootComponent.tsx @@ -4,15 +4,21 @@ import {Routes} from '@app/app/Routes'; import {isAutoRedirectExemptPath} from '@app/app/router/RouterConstants'; import {KeyboardModeListener} from '@app/features/app/components/layout/KeyboardModeListener'; import {MobileBottomNav} from '@app/features/app/components/layout/MobileBottomNav'; -import {SplashScreen} from '@app/features/app/components/layout/SplashScreen'; import {SelfHostedSetupWizardGate} from '@app/features/app/components/setup/SelfHostedSetupWizardGate'; +import {AppSkeleton} from '@app/features/app/components/skeleton/AppSkeleton'; +import {useLoadingSkeletonRetention} from '@app/features/app/hooks/useLoadingSkeletonRetention'; +import {useSkeletonLayoutMemoryCapture} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; +import { + shouldShowLoadingSkeleton as getShouldShowLoadingSkeleton, + shouldShowMobileBottomNav, +} from '@app/features/app/state/AppShellPresentationPolicy'; +import {isClientBooting} from '@app/features/app/state/ClientReadiness'; import Initialization from '@app/features/app/state/Initialization'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow'; import AccountManager from '@app/features/auth/state/AccountManager'; import Authentication from '@app/features/auth/state/Authentication'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils'; @@ -40,6 +46,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({ const requiresSelfHostedSetup = RuntimeConfig.requiresSelfHostedSetup(); const mobileLayoutState = MobileLayout; const [hasRestoredLocation, setHasRestoredLocation] = useState(false); + const [effectiveSkeletonPathname, setEffectiveSkeletonPathname] = useState(null); const currentUser = Users.currentUser; const [hasHandledNotificationNav, setHasHandledNotificationNav] = useState(false); const previousMobileLayoutStateRef = useRef(mobileLayoutState.enabled); @@ -77,13 +84,57 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({ const shouldShowSelfHostedSetup = requiresSelfHostedSetup && !isStandaloneRoute; const shouldBypassGateway = requiresSelfHostedSetup || isStandaloneRoute; const authToken = Authentication.authToken; - const protectedRouteBlocked = !canNavigateToProtectedRoutes && !shouldBypassGateway; - const splashScreenCoversProtectedRoute = + const shouldShowLoadingSkeleton = getShouldShowLoadingSkeleton({ + booting: isClientBooting(), + isAuthenticated, + isAuthSessionInitialized: SessionManager.isInitialized, + shouldBypassGateway, + }); + useSkeletonLayoutMemoryCapture( + isAuthenticated && Initialization.isReady && !shouldBypassGateway && !shouldShowLoadingSkeleton, + ); + const shouldDismissTransientOverlays = !isAuthenticated || shouldShowLoadingSkeleton; + const {isLoadingSkeletonRetained, handleLoadingSkeletonTransitionEnd} = useLoadingSkeletonRetention({ + shouldShowLoadingSkeleton, + }); + let pendingRestorableSkeletonPathname: string | null = null; + if ( isAuthenticated && - !DeveloperOptions.bypassSplashScreen && - !shouldBypassGateway && - (GatewayConnection.isConnectionInterrupted || !canNavigateToProtectedRoutes); - const shouldDismissTransientOverlays = !isAuthenticated || protectedRouteBlocked || splashScreenCoversProtectedRoute; + isLocationStateHydrated && + !AccountManager.isSwitching && + pathname === Routes.ME && + !hasRestoredLocation + ) { + const lastLocation = Location.getLastLocation(); + if (lastLocation != null && lastLocation !== pathname) { + pendingRestorableSkeletonPathname = lastLocation; + } + } + useLayoutEffect(() => { + if ( + !isAuthenticated || + !isLocationStateHydrated || + AccountManager.isSwitching || + pathname !== Routes.ME || + hasRestoredLocation + ) { + return; + } + const lastLocation = Location.getLastLocation(); + if (lastLocation && lastLocation !== pathname) { + setEffectiveSkeletonPathname(lastLocation); + } + }, [hasRestoredLocation, isAuthenticated, isLocationStateHydrated, pathname]); + useLayoutEffect(() => { + if (effectiveSkeletonPathname != null && pathname !== Routes.ME) { + setEffectiveSkeletonPathname(null); + } + }, [effectiveSkeletonPathname, pathname]); + useLayoutEffect(() => { + if (!shouldShowLoadingSkeleton && !isLoadingSkeletonRetained && effectiveSkeletonPathname != null) { + setEffectiveSkeletonPathname(null); + } + }, [effectiveSkeletonPathname, isLoadingSkeletonRetained, shouldShowLoadingSkeleton]); useEffect(() => { if (!isAuthenticated) return; void PushSubscriptionService.cleanupNativeDesktopWebPushSubscriptions('desktop-authenticated'); @@ -298,25 +349,32 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({ navigateWithHistoryStack, normalizeInternalUrl, ]); - const showBottomNav = - mobileLayoutState.enabled && - (location.pathname === Routes.ME || - location.pathname === Routes.FAVORITES || - location.pathname === Routes.NOTIFICATIONS || - location.pathname === Routes.YOU || - (Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3)); + const showBottomNav = shouldShowMobileBottomNav({ + mobileLayoutEnabled: mobileLayoutState.enabled, + pathname, + }); if (shouldShowSelfHostedSetup) { - return ; - } - if (isAuthenticated && !canNavigateToProtectedRoutes && !shouldBypassGateway) { - return ; + return ; } return ( <> - - {children} - {showBottomNav && currentUser && ( - + {!shouldShowLoadingSkeleton && ( + <> + + {children} + {showBottomNav && currentUser && ( + + )} + + )} + {(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && ( + )} ); diff --git a/fluxer_app/src/features/accessibility/state/Accessibility.ts b/fluxer_app/src/features/accessibility/state/Accessibility.ts index 707455b95..5d5501723 100644 --- a/fluxer_app/src/features/accessibility/state/Accessibility.ts +++ b/fluxer_app/src/features/accessibility/state/Accessibility.ts @@ -6,6 +6,7 @@ import { getMessageGroupSpacingForDisplayMode, migrateLegacyMessageGroupSpacing, } from '@app/features/accessibility/state/MessageGroupSpacing'; +import {DEFAULT_MESSAGE_GUTTER_PX} from '@app/features/accessibility/state/MessagePresentationDefaults'; import type {AnimatedMediaKind} from '@app/features/accessibility/state/MotionPreferencesMachine'; import { AuthSessionStorageKey, @@ -667,7 +668,7 @@ class Accessibility { keepStickerAnimationUnderReducedMotion = false; messageGroupSpacing = COMFY_MESSAGE_GROUP_SPACING_DEFAULT; compactMessageGroupSpacing = COMPACT_MESSAGE_GROUP_SPACING_DEFAULT; - messageGutter = 16; + messageGutter = DEFAULT_MESSAGE_GUTTER_PX; fontSize = 16; showUserAvatarsInCompactMode = false; mobileStickerAnimationOverridden = false; diff --git a/fluxer_app/src/features/accessibility/state/MessagePresentationDefaults.ts b/fluxer_app/src/features/accessibility/state/MessagePresentationDefaults.ts new file mode 100644 index 000000000..2848a8478 --- /dev/null +++ b/fluxer_app/src/features/accessibility/state/MessagePresentationDefaults.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const DEFAULT_MESSAGE_GUTTER_PX = 16; diff --git a/fluxer_app/src/features/app/ConnectionRecovery.ts b/fluxer_app/src/features/app/ConnectionRecovery.ts new file mode 100644 index 000000000..871a04356 --- /dev/null +++ b/fluxer_app/src/features/app/ConnectionRecovery.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AccountManager from '@app/features/auth/state/AccountManager'; +import SessionManager from '@app/features/platform/state/AuthSession'; + +class StalledConnectionAccountSwitchUnavailableError extends Error { + constructor() { + super('Cannot switch accounts from the current authentication session state'); + this.name = 'StalledConnectionAccountSwitchUnavailableError'; + } +} + +export function canSwitchAccountFromStalledConnection(): boolean { + return SessionManager.canSwitchAccount() || SessionManager.isConnecting; +} + +export async function switchAccountFromStalledConnection(userId: string): Promise { + if (!canSwitchAccountFromStalledConnection()) { + throw new StalledConnectionAccountSwitchUnavailableError(); + } + SessionManager.requireAccountOnCurrentInstance(userId); + if (SessionManager.isConnecting) { + SessionManager.handleConnectionFailed(); + } + await AccountManager.switchToAccount(userId, null); +} diff --git a/fluxer_app/src/features/app/components/ConnectionIssuesLinks.module.css b/fluxer_app/src/features/app/components/ConnectionIssuesLinks.module.css deleted file mode 100644 index 2d9277bed..000000000 --- a/fluxer_app/src/features/app/components/ConnectionIssuesLinks.module.css +++ /dev/null @@ -1,45 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -.container { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-2); - width: 100%; -} - -.prompt { - margin: 0; - font-size: 0.8125rem; - line-height: 1.4; - color: var(--text-secondary); - text-align: center; -} - -.links { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: var(--spacing-2); - margin: 0; -} - -.link { - color: var(--text-link); - font-size: 0.8125rem; - font-weight: 400; - text-decoration: none; - text-underline-offset: 0.125rem; - transition: color 0.15s ease; -} - -.link:hover { - text-decoration: underline; - text-decoration-thickness: 0.125rem; -} - -.separator { - color: var(--text-tertiary); - user-select: none; -} diff --git a/fluxer_app/src/features/app/components/ConnectionIssuesLinks.tsx b/fluxer_app/src/features/app/components/ConnectionIssuesLinks.tsx deleted file mode 100644 index 0af62174b..000000000 --- a/fluxer_app/src/features/app/components/ConnectionIssuesLinks.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import styles from '@app/features/app/components/ConnectionIssuesLinks.module.css'; -import type {StatusPageIncident} from '@app/features/user/state/StatusPage'; -import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls'; -import {Trans} from '@lingui/react/macro'; - -interface ConnectionIssuesLinksProps { - incident: StatusPageIncident | null; - className?: string; -} - -const STATUS_HISTORY_URL = `${ExternalUrls.SERVICE_STATUS}/history`; - -export function ConnectionIssuesLinks({incident, className}: ConnectionIssuesLinksProps) { - const containerClassName = className != null ? `${styles.container} ${className}` : styles.container; - const incidentUrl = incident?.url ?? STATUS_HISTORY_URL; - return ( -
-

- Connection issues? -

-

- - Status page - - - - {incident ? Read incident : Incident history} - -

-
- ); -} diff --git a/fluxer_app/src/features/app/components/LongPressable.ts b/fluxer_app/src/features/app/components/LongPressable.ts index 1ed15cf0b..82189a64f 100644 --- a/fluxer_app/src/features/app/components/LongPressable.ts +++ b/fluxer_app/src/features/app/components/LongPressable.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; const LONG_PRESS_MOVEMENT_THRESHOLD = 10; const SWIPE_VELOCITY_THRESHOLD = 0.4; @@ -9,6 +10,7 @@ const MIN_VELOCITY_SAMPLES = 2; const MAX_VELOCITY_SAMPLE_AGE = 100; const PRESS_HIGHLIGHT_DELAY_MS = 100; const LONG_PRESS_DURATION_MS = 500; +const LONG_PRESS_CLICK_SUPPRESSION_MS = 750; const HAS_POINTER_EVENTS = typeof window !== 'undefined' && 'PointerEvent' in window; interface VelocitySample { @@ -53,8 +55,12 @@ export const LongPressable = React.forwardRef( const storedEvent = useRef(null); const velocitySamples = useRef>([]); const isPressIntent = useRef(false); + const pressScrollCancelHandler = useRef<(() => void) | null>(null); + const clearTimerRef = useRef<() => void>(() => {}); + const suppressNextClickRef = useRef(false); + const suppressClickTimer = useRef(null); const [isPressed, setIsPressed] = useState(false); - useImperativeHandle(forwardedRef, () => innerRef.current as HTMLElement); + const composedRef = useMergeRefs([forwardedRef, innerRef]); const setPressed = useCallback( (pressed: boolean) => { setIsPressed(pressed); @@ -62,6 +68,21 @@ export const LongPressable = React.forwardRef( }, [onPressStateChange], ); + const clearSuppressedClick = useCallback(() => { + suppressNextClickRef.current = false; + if (suppressClickTimer.current) { + clearTimeout(suppressClickTimer.current); + suppressClickTimer.current = null; + } + }, []); + const suppressNextClick = useCallback(() => { + suppressNextClickRef.current = true; + if (suppressClickTimer.current) clearTimeout(suppressClickTimer.current); + suppressClickTimer.current = setTimeout(() => { + suppressClickTimer.current = null; + suppressNextClickRef.current = false; + }, LONG_PRESS_CLICK_SUPPRESSION_MS); + }, []); const calculateVelocity = useCallback((): number => { const samples = velocitySamples.current; if (samples.length < MIN_VELOCITY_SAMPLES) return 0; @@ -83,7 +104,24 @@ export const LongPressable = React.forwardRef( const distance = Math.sqrt(dx * dx + dy * dy); return distance / dt; }, []); + const detachPressScrollCancel = useCallback(() => { + const handler = pressScrollCancelHandler.current; + if (handler == null) return; + pressScrollCancelHandler.current = null; + window.removeEventListener('scroll', handler, {capture: true}); + }, []); + const attachPressScrollCancel = useCallback(() => { + if (pressScrollCancelHandler.current != null) return; + const handler = () => { + if (isPressIntent.current) { + clearTimerRef.current(); + } + }; + pressScrollCancelHandler.current = handler; + window.addEventListener('scroll', handler, {capture: true, passive: true}); + }, []); const clearTimer = useCallback(() => { + detachPressScrollCancel(); if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; @@ -103,7 +141,8 @@ export const LongPressable = React.forwardRef( velocitySamples.current = []; isPressIntent.current = false; setPressed(false); - }, [setPressed]); + }, [detachPressScrollCancel, setPressed]); + clearTimerRef.current = clearTimer; const { onPointerDown: userOnPointerDown, onPointerMove: userOnPointerMove, @@ -136,6 +175,7 @@ export const LongPressable = React.forwardRef( pointerIdRef.current = pointerId ?? null; velocitySamples.current = [{x, y, timestamp: performance.now()}]; isPressIntent.current = true; + attachPressScrollCancel(); if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) { try { innerRef.current.setPointerCapture(pointerId); @@ -150,13 +190,14 @@ export const LongPressable = React.forwardRef( }, PRESS_HIGHLIGHT_DELAY_MS); longPressTimer.current = setTimeout(() => { if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) { + suppressNextClick(); onLongPress(storedEvent.current); setPressed(false); } clearTimer(); }, delay); }, - [clearTimer, delay, disabled, onLongPress, setPressed], + [attachPressScrollCancel, clearTimer, delay, disabled, onLongPress, setPressed, suppressNextClick], ); const handlePointerDown = useCallback( (event: React.PointerEvent) => { @@ -255,26 +296,35 @@ export const LongPressable = React.forwardRef( }, [clearTimer, userOnTouchCancel], ); - useEffect(() => { - const handleScroll = () => { - if (isPressIntent.current) { - clearTimer(); + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (suppressNextClickRef.current) { + event.preventDefault(); + event.stopPropagation(); + clearSuppressedClick(); + return; } - }; - window.addEventListener('scroll', handleScroll, {capture: true, passive: true}); + userOnClick?.(event); + }, + [clearSuppressedClick, userOnClick], + ); + useEffect(() => { return () => { - window.removeEventListener('scroll', handleScroll, {capture: true}); + detachPressScrollCancel(); if (longPressTimer.current) { clearTimeout(longPressTimer.current); } if (highlightTimer.current) { clearTimeout(highlightTimer.current); } + if (suppressClickTimer.current) { + clearTimeout(suppressClickTimer.current); + } }; - }, [clearTimer]); + }, [detachPressScrollCancel]); const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className; return React.createElement(Component, { - ref: innerRef, + ref: composedRef, className: finalClassName, onPointerDown: HAS_POINTER_EVENTS ? handlePointerDown : undefined, onPointerMove: HAS_POINTER_EVENTS ? handlePointerMove : undefined, @@ -284,7 +334,7 @@ export const LongPressable = React.forwardRef( onTouchMove: !HAS_POINTER_EVENTS ? handleTouchMove : undefined, onTouchEnd: !HAS_POINTER_EVENTS ? handleTouchEnd : undefined, onTouchCancel: !HAS_POINTER_EVENTS ? handleTouchCancel : undefined, - onClick: userOnClick, + onClick: userOnClick ? handleClick : undefined, onKeyDown: handleKeyDown, ...restWithoutPointer, 'data-long-press-owner': !disabled && onLongPress ? 'true' : undefined, diff --git a/fluxer_app/src/features/app/components/NetworkErrorScreen.tsx b/fluxer_app/src/features/app/components/NetworkErrorScreen.tsx deleted file mode 100644 index eda484b41..000000000 --- a/fluxer_app/src/features/app/components/NetworkErrorScreen.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {OutageSplashScreen} from '@app/features/app/components/layout/SplashScreen'; -import {useEffect} from 'react'; - -const AUTO_RELOAD_BASE_MS = 15_000; -const AUTO_RELOAD_JITTER_MS = 5_000; - -export function NetworkErrorScreen() { - useEffect(() => { - const delay = AUTO_RELOAD_BASE_MS + Math.random() * AUTO_RELOAD_JITTER_MS; - const timer = setTimeout(() => { - window.location.reload(); - }, delay); - return () => clearTimeout(timer); - }, []); - return ; -} diff --git a/fluxer_app/src/features/app/components/layout/AppLayout.module.css b/fluxer_app/src/features/app/components/layout/AppLayout.module.css index 4dc89d489..61e9800d1 100644 --- a/fluxer_app/src/features/app/components/layout/AppLayout.module.css +++ b/fluxer_app/src/features/app/components/layout/AppLayout.module.css @@ -15,11 +15,3 @@ height: 100%; min-height: 0; } - -.appLayoutSplashCovered, -.appLayoutSplashCovered * { - user-select: none !important; - -webkit-user-select: none !important; - -moz-user-select: none !important; - -ms-user-select: none !important; -} diff --git a/fluxer_app/src/features/app/components/layout/AppLayout.tsx b/fluxer_app/src/features/app/components/layout/AppLayout.tsx index 3194c87c8..8e7011bc6 100644 --- a/fluxer_app/src/features/app/components/layout/AppLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/AppLayout.tsx @@ -3,12 +3,10 @@ import styles from '@app/features/app/components/layout/AppLayout.module.css'; import {useAppLayoutState} from '@app/features/app/components/layout/app_layout/AppLayoutHooks'; import RequiredActionGate from '@app/features/app/components/layout/RequiredActionGate'; -import {SplashScreen} from '@app/features/app/components/layout/SplashScreen'; import Initialization from '@app/features/app/state/Initialization'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import AccountManager from '@app/features/auth/state/AccountManager'; import Authentication from '@app/features/auth/state/Authentication'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager'; import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager'; @@ -21,10 +19,6 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => { const isAuthenticated = Authentication.isAuthenticated; const socket = GatewayConnection.socket; const appState = useAppLayoutState(); - const isSplashScreenActive = - isAuthenticated && - !DeveloperOptions.bypassSplashScreen && - (GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes); useEffect(() => { if (Initialization.isLoading) { return; @@ -40,16 +34,11 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => { ]); return ( <> - {isAuthenticated && } {isAuthenticated && socket && } {isAuthenticated && } {isAuthenticated && }
{children} diff --git a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx index b972cc6a0..2cdc38e89 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx @@ -5,6 +5,11 @@ import styles from '@app/features/app/components/layout/ChannelItem.module.css'; import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent'; import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon'; import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css'; +import { + DirectSelectionSurface, + markDirectSelection, + peekDirectSelection, +} from '@app/features/app/components/layout/DirectSelectionOrigin'; import { type ChannelReorderTarget, canChannelDropOnTarget, @@ -12,7 +17,7 @@ import { } from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine'; import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem'; import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay'; -import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes'; +import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes'; import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization'; import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState'; import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount'; @@ -221,7 +226,7 @@ export const ChannelItem = observer( defaultHiddenForChannel: channelIsVoice, enabled: !channelIsCategory, }); - const draggingChannel = activeDragItem?.type === DND_TYPES.CHANNEL ? activeDragItem : null; + const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null; const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE; const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null); const unreadCount = ReadStates.getUnreadCount(channel.id); @@ -283,7 +288,7 @@ export const ChannelItem = observer( const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null); const dragItemData = useMemo( () => ({ - type: channelIsCategory ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL, + type: channelIsCategory ? DragItemType.CATEGORY : DragItemType.CHANNEL, id: channel.id, channelType: channel.type, parentId: channel.parentId, @@ -319,7 +324,7 @@ export const ChannelItem = observer( ); const [{isOver}, dropRef] = useDrop( () => ({ - accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY, DND_TYPES.VOICE_PARTICIPANT], + accept: [DragItemType.CHANNEL, DragItemType.CATEGORY, DragItemType.VOICE_PARTICIPANT], canDrop: (item: DragItem) => canChannelDropOnTarget(item, dropTargetData), hover: (item: DragItem, monitor) => { const node = dropTargetRef.current; @@ -335,7 +340,7 @@ export const ChannelItem = observer( setDropIndicator(null); return; } - if (item.type === DND_TYPES.VOICE_PARTICIPANT && channelIsVoice) { + if (item.type === DragItemType.VOICE_PARTICIPANT && channelIsVoice) { const canMove = Permission.can(Permissions.MOVE_MEMBERS, {guildId: guild.id}); if (!canMove || item.currentChannelId === channel.id) { setDropIndicator(null); @@ -405,6 +410,7 @@ export const ChannelItem = observer( CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false); }, [channelIsVoice, channel.id]); const handleSelect = useCallback(() => { + markDirectSelection(DirectSelectionSurface.CHANNEL_LIST); if (channel.type === ChannelTypes.GUILD_CATEGORY) { onToggle?.(); return; @@ -465,7 +471,8 @@ export const ChannelItem = observer( const shouldShowSelectedState = !channelIsCategory && isSelected && (!channelIsVoice || isSelectedByPath); const hasMountedRef = useRef(false); useEffect(() => { - if (shouldShowSelectedState && hasMountedRef.current) { + const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.CHANNEL_LIST); + if (shouldShowSelectedState && hasMountedRef.current && !selectedFromThisRow) { elementRef.current?.scrollIntoView({block: 'nearest'}); } hasMountedRef.current = true; diff --git a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx index a8cf773af..0b22bf33f 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx @@ -14,7 +14,7 @@ import {GenericChannelItem} from '@app/features/app/components/layout/GenericCha import {GuildDetachedBanner} from '@app/features/app/components/layout/GuildDetachedBanner'; import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSpaceDropIndicator'; import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay'; -import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes'; +import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes'; import { shouldShowChannelInCollapsedCategory, shouldShowChannelWhenHidingMutedChannels, @@ -23,7 +23,17 @@ import {createChannelMoveOperation} from '@app/features/app/components/layout/ut import {organizeChannels} from '@app/features/app/components/layout/utils/ChannelOrganization'; import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState'; import {VoiceParticipantsList} from '@app/features/app/components/layout/VoiceParticipantsList'; +import { + type RememberedSkeletonGuildChannelGroup, + type RememberedSkeletonGuildChannelRow, + reportSkeletonGuildChannelList, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList'; +import { + measureSkeletonTextWidthPx, + useSkeletonLayoutReport, +} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; +import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import * as GuildCommands from '@app/features/guild/commands/GuildCommands'; import type {Guild} from '@app/features/guild/models/Guild'; @@ -90,6 +100,66 @@ const MEMBERS_PAGE_PERMISSIONS = Permissions.MODERATE_MEMBERS | Permissions.KICK_MEMBERS; const EMPTY_ARRAY: ReadonlyArray = Object.freeze([]); + +interface ResolvedChannelGroup { + readonly key: string; + readonly category: Channel | undefined; + readonly isCollapsed: boolean; + readonly connectedChannelInGroup: boolean; + readonly filteredVoiceChannels: Array; + readonly visibleTextChannels: ReadonlyArray; + readonly visibleVoiceChannels: ReadonlyArray; + readonly showTextChannels: boolean; + readonly showVoiceChannels: boolean; +} + +function createChannelListProjectionKey( + groups: ReadonlyArray, + membersRowVisible: boolean, +): string { + const groupKeys = groups.map((group) => { + const channelKeys = [...group.visibleTextChannels, ...group.visibleVoiceChannels].map( + (channel) => `${channel.id}~${channel.name}`, + ); + return `${group.category?.id ?? ''}~${group.category?.name ?? ''}~${group.isCollapsed}~${channelKeys.join(',')}`; + }); + return `${membersRowVisible}|${groupKeys.join('|')}`; +} + +function createRememberedChannelGroups( + groups: ReadonlyArray, + container: HTMLElement | null, +): ReadonlyArray { + const categoryNameElements = container?.querySelectorAll(`.${channelItemStyles.categoryName}`); + const channelNameElements = container?.querySelectorAll(`.${channelItemStyles.channelName}`); + let categoryNameIndex = 0; + let channelNameIndex = 0; + const readNextChannelWidth = (): number => { + const width = measureSkeletonTextWidthPx(channelNameElements?.item(channelNameIndex) ?? null); + channelNameIndex += 1; + return width; + }; + return groups.map((group) => { + let categoryNameWidthPx = measureSkeletonTextWidthPx(null); + if (group.category != null) { + categoryNameWidthPx = measureSkeletonTextWidthPx(categoryNameElements?.item(categoryNameIndex) ?? null); + categoryNameIndex += 1; + } + const channels: Array = []; + for (const _textChannel of group.visibleTextChannels) { + channels.push({voice: false, nameWidthPx: readNextChannelWidth()}); + } + for (const _voiceChannel of group.visibleVoiceChannels) { + channels.push({voice: true, nameWidthPx: readNextChannelWidth()}); + } + return { + categoryHeaderVisible: group.category != null, + collapsed: group.isCollapsed, + categoryNameWidthPx, + channels, + }; + }); +} export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue}) => { const {i18n} = useLingui(); const channels = Channels.getGuildChannels(guild.id); @@ -99,11 +169,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr if (!monitor.isDragging()) return false; const itemType = monitor.getItemType(); return ( - itemType === DND_TYPES.CHANNEL || itemType === DND_TYPES.CATEGORY || itemType === DND_TYPES.VOICE_PARTICIPANT + itemType === DragItemType.CHANNEL || + itemType === DragItemType.CATEGORY || + itemType === DragItemType.VOICE_PARTICIPANT ); }); const [activeDragItem, setActiveDragItem] = useState(null); const scrollerRef = useRef(null); + const channelGroupsContainerRef = useRef(null); const stickToBottomRef = useRef(false); const pendingScrollTopRef = useRef(null); const scrollPersistRafRef = useRef(null); @@ -283,6 +356,142 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr }); return unreadState.hasVisibleUnread; }; + const resolvedGroups: Array = []; + for (const group of channelGroups) { + const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false; + const isNullSpace = !group.category; + const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false; + let filteredTextChannels: Array; + let filteredVoiceChannels: Array; + if (hideMutedChannels) { + filteredTextChannels = []; + for (const ch of group.textChannels) { + const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); + if ( + shouldShowChannelWhenHidingMutedChannels({ + isMuted, + isSelected: ch.id === selectedChannelInGuildId, + isConnected: false, + hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id), + }) + ) { + filteredTextChannels.push(ch); + } + } + filteredVoiceChannels = []; + for (const ch of group.voiceChannels) { + const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); + if ( + shouldShowChannelWhenHidingMutedChannels({ + isMuted, + isSelected: ch.id === selectedChannelInGuildId, + isConnected: ch.id === connectedChannelId, + hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id), + }) + ) { + filteredVoiceChannels.push(ch); + } + } + } else { + filteredTextChannels = group.textChannels; + filteredVoiceChannels = group.voiceChannels; + } + let visibleTextChannels: ReadonlyArray = filteredTextChannels; + let visibleVoiceChannels: ReadonlyArray = filteredVoiceChannels; + let connectedChannelInGroup = false; + if (isCollapsed) { + const showTextSelected = selectedChannelInGuildId; + const showSet = new Set(); + for (const ch of filteredTextChannels) { + if ( + shouldShowChannelInCollapsedCategory({ + isCategoryMuted, + isSelected: ch.id === showTextSelected, + hasVisibleUnread: hasVisibleUnreadInChannel(ch.id), + }) + ) { + showSet.add(ch.id); + } + } + if (showSet.size === 0) { + visibleTextChannels = EMPTY_ARRAY; + } else if (showSet.size === filteredTextChannels.length) { + visibleTextChannels = filteredTextChannels; + } else { + const next: Array = []; + for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch); + visibleTextChannels = next; + } + const voiceSet = new Set(); + if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId); + if (connectedChannelId) { + for (const ch of filteredVoiceChannels) { + if (ch.id === connectedChannelId) { + connectedChannelInGroup = true; + voiceSet.add(connectedChannelId); + break; + } + } + } + for (const ch of filteredVoiceChannels) { + if ( + shouldShowChannelInCollapsedCategory({ + isCategoryMuted, + isSelected: ch.id === selectedChannelInGuildId, + hasVisibleUnread: hasVisibleUnreadInChannel(ch.id), + }) + ) { + voiceSet.add(ch.id); + } + } + if (voiceSet.size === 0) { + visibleVoiceChannels = EMPTY_ARRAY; + } else { + const next: Array = []; + let connectedRow: Channel | null = null; + for (const ch of filteredVoiceChannels) { + if (!voiceSet.has(ch.id)) continue; + if (ch.id === connectedChannelId) { + connectedRow = ch; + } else { + next.push(ch); + } + } + visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next; + } + } + if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) { + continue; + } + if ( + hideMutedChannels && + group.category && + filteredTextChannels.length === 0 && + filteredVoiceChannels.length === 0 + ) { + continue; + } + resolvedGroups.push({ + key: group.category?.id || 'null-space', + category: group.category, + isCollapsed, + connectedChannelInGroup, + filteredVoiceChannels, + visibleTextChannels, + visibleVoiceChannels, + showTextChannels: !isCollapsed || visibleTextChannels.length > 0, + showVoiceChannels: !isCollapsed || visibleVoiceChannels.length > 0, + }); + } + useSkeletonLayoutReport( + () => { + reportSkeletonGuildChannelList(guild.id, { + membersRowVisible: canViewMembers, + groups: createRememberedChannelGroups(resolvedGroups, channelGroupsContainerRef.current), + }); + }, + `${guild.id}|${createChannelListProjectionKey(resolvedGroups, canViewMembers)}`, + ); return (
)} -
- {channelGroups.map((group) => { - const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false; - const isNullSpace = !group.category; - const isCategoryMuted = group.category - ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) - : false; - type ChannelRow = (typeof group.textChannels)[number]; - let filteredTextChannels: Array; - let filteredVoiceChannels: Array; - if (hideMutedChannels) { - filteredTextChannels = []; - for (const ch of group.textChannels) { - const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); - if ( - shouldShowChannelWhenHidingMutedChannels({ - isMuted, - isSelected: ch.id === selectedChannelInGuildId, - isConnected: false, - hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id), - }) - ) { - filteredTextChannels.push(ch); - } - } - filteredVoiceChannels = []; - for (const ch of group.voiceChannels) { - const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); - if ( - shouldShowChannelWhenHidingMutedChannels({ - isMuted, - isSelected: ch.id === selectedChannelInGuildId, - isConnected: ch.id === connectedChannelId, - hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id), - }) - ) { - filteredVoiceChannels.push(ch); - } - } - } else { - filteredTextChannels = group.textChannels; - filteredVoiceChannels = group.voiceChannels; - } - let visibleTextChannels: ReadonlyArray = filteredTextChannels; - let visibleVoiceChannels: ReadonlyArray = filteredVoiceChannels; - let connectedChannelInGroup = false; - if (isCollapsed) { - const showTextSelected = selectedChannelInGuildId; - const showSet = new Set(); - for (const ch of filteredTextChannels) { - if ( - shouldShowChannelInCollapsedCategory({ - isCategoryMuted, - isSelected: ch.id === showTextSelected, - hasVisibleUnread: hasVisibleUnreadInChannel(ch.id), - }) - ) { - showSet.add(ch.id); - } - } - if (showSet.size === 0) { - visibleTextChannels = EMPTY_ARRAY; - } else if (showSet.size === filteredTextChannels.length) { - visibleTextChannels = filteredTextChannels; - } else { - const next: Array = []; - for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch); - visibleTextChannels = next; - } - const voiceSet = new Set(); - if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId); - if (connectedChannelId) { - for (const ch of filteredVoiceChannels) { - if (ch.id === connectedChannelId) { - connectedChannelInGroup = true; - voiceSet.add(connectedChannelId); - break; - } - } - } - for (const ch of filteredVoiceChannels) { - if ( - shouldShowChannelInCollapsedCategory({ - isCategoryMuted, - isSelected: ch.id === selectedChannelInGuildId, - hasVisibleUnread: hasVisibleUnreadInChannel(ch.id), - }) - ) { - voiceSet.add(ch.id); - } - } - if (voiceSet.size === 0) { - visibleVoiceChannels = EMPTY_ARRAY; - } else { - const next: Array = []; - let connectedRow: ChannelRow | null = null; - for (const ch of filteredVoiceChannels) { - if (!voiceSet.has(ch.id)) continue; - if (ch.id === connectedChannelId) { - connectedRow = ch; - } else { - next.push(ch); - } - } - visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next; - } - } - if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) { - return null; - } - if ( - hideMutedChannels && - group.category && - filteredTextChannels.length === 0 && - filteredVoiceChannels.length === 0 - ) { - return null; - } - const showTextChannels = !isCollapsed || visibleTextChannels.length > 0; - const showVoiceChannels = !isCollapsed || visibleVoiceChannels.length > 0; - return ( -
- {group.category && ( +
+ {resolvedGroups.map((group) => ( +
+ {group.category && ( + toggleCategory(group.category!.id)} + isDraggingAnything={isDraggingAnything} + activeDragItem={activeDragItem} + onChannelDrop={handleChannelDrop} + onDragStateChange={setActiveDragItem} + isSelectedByPath={selectedChannelInGuildId === group.category.id} + isOnMembersRoute={isMembersSelected} + data-flx="app.channel-list-content.channel-item" + /> + )} + {group.isCollapsed && group.category && !group.connectedChannelInGroup && ( + + )} + {group.showTextChannels && + group.visibleTextChannels.map((ch) => ( toggleCategory(group.category!.id)} + channel={ch} isDraggingAnything={isDraggingAnything} activeDragItem={activeDragItem} onChannelDrop={handleChannelDrop} onDragStateChange={setActiveDragItem} - isSelectedByPath={selectedChannelInGuildId === group.category.id} + isSelectedByPath={selectedChannelInGuildId === ch.id} isOnMembersRoute={isMembersSelected} - data-flx="app.channel-list-content.channel-item" + data-flx="app.channel-list-content.channel-item--2" /> - )} - {isCollapsed && group.category && !connectedChannelInGroup && ( - - )} - {showTextChannels && - visibleTextChannels.map((ch) => ( + ))} + {group.showVoiceChannels && + group.visibleVoiceChannels.map((ch) => { + const channelRow = ( - ))} - {showVoiceChannels && - visibleVoiceChannels.map((ch) => { - const channelRow = ( - - ); - if (isCollapsed && connectedChannelId && ch.id === connectedChannelId) { - return ( - - {channelRow} - - - ); - } + ); + if (group.isCollapsed && connectedChannelId && ch.id === connectedChannelId) { return ( {channelRow} - {!isCollapsed && ( - - )} + ); - })} -
- ); - })} + } + return ( + + {channelRow} + {!group.isCollapsed && ( + + )} + + ); + })} +
+ ))}
{showTrailingDropZone && (
diff --git a/fluxer_app/src/features/app/components/layout/ChannelListSkeleton.tsx b/fluxer_app/src/features/app/components/layout/ChannelListSkeleton.tsx deleted file mode 100644 index d5cab1883..000000000 --- a/fluxer_app/src/features/app/components/layout/ChannelListSkeleton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css'; -import type React from 'react'; - -export const ChannelListSkeleton: React.FC = () => { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -}; diff --git a/fluxer_app/src/features/app/components/layout/DirectSelectionOrigin.ts b/fluxer_app/src/features/app/components/layout/DirectSelectionOrigin.ts new file mode 100644 index 000000000..4c222d3df --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/DirectSelectionOrigin.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const DirectSelectionSurface = Object.freeze({ + GUILD_RAIL: 'guild-rail', + CHANNEL_LIST: 'channel-list', + FAVORITES_LIST: 'favorites-list', +} as const); + +export type DirectSelectionSurface = (typeof DirectSelectionSurface)[keyof typeof DirectSelectionSurface]; + +const markedSurfaces = new Set(); + +export function markDirectSelection(surface: DirectSelectionSurface): void { + markedSurfaces.add(surface); +} + +export function peekDirectSelection(surface: DirectSelectionSurface): boolean { + return markedSurfaces.has(surface); +} + +export function consumeDirectSelection(surface: DirectSelectionSurface): boolean { + const marked = markedSurfaces.has(surface); + markedSurfaces.delete(surface); + return marked; +} diff --git a/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.module.css b/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.module.css index 84724a12c..11fbae13b 100644 --- a/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.module.css +++ b/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.module.css @@ -1,11 +1,13 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ -.headerIconContainer { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.headerIcon { +.headerTitle { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.75rem; color: var(--text-primary); } diff --git a/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.tsx b/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.tsx index b435bf7a4..b30722620 100644 --- a/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.tsx +++ b/fluxer_app/src/features/app/components/layout/DiscoveryGuildHeader.tsx @@ -5,8 +5,6 @@ import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.m import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; -import {CompassIcon} from '@phosphor-icons/react'; -import {clsx} from 'clsx'; const EXPLORE_DESCRIPTOR = msg({ message: 'Explore', @@ -17,7 +15,7 @@ export function DiscoveryGuildHeader() { const {i18n} = useLingui(); return (
@@ -26,16 +24,9 @@ export function DiscoveryGuildHeader() { style={{cursor: 'default'}} data-flx="app.discovery-guild-header.div--2" > -
- - - {i18n._(EXPLORE_DESCRIPTOR)} - -
+

+ {i18n._(EXPLORE_DESCRIPTOR)} +

); diff --git a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx index 9dff04dcd..05ee0ffa9 100644 --- a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx @@ -6,13 +6,19 @@ import channelItemStyles from '@app/features/app/components/layout/ChannelItem.m import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon'; import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css'; import styles from '@app/features/app/components/layout/ChannelListContent.module.css'; -import {ChannelListSkeleton} from '@app/features/app/components/layout/ChannelListSkeleton'; +import { + DirectSelectionSurface, + markDirectSelection, + peekDirectSelection, +} from '@app/features/app/components/layout/DirectSelectionOrigin'; import {computeVerticalDropPosition} from '@app/features/app/components/layout/dnd/DndDropPosition'; import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect'; import favoritesChannelListStyles from '@app/features/app/components/layout/FavoritesChannelListContent.module.css'; import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem'; +import {DragItemType} from '@app/features/app/components/layout/types/DndTypes'; import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState'; import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar'; +import {GuildChannelListSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton'; import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload'; import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands'; @@ -65,24 +71,19 @@ const EMPTY_FAVORITES_DESCRIPTOR = msg({ message: 'Empty favorites', comment: 'Short label in the app layout favorites channel list content.', }); -const DND_TYPES = { - FAVORITES_CHANNEL: 'favorites-channel', - FAVORITES_CATEGORY: 'favorites-category', -} as const; - type ChannelDragItem = { - type: typeof DND_TYPES.FAVORITES_CHANNEL; + type: typeof DragItemType.FAVORITES_CHANNEL; channelId: string; parentId: string | null; }; type CategoryDragItem = { - type: typeof DND_TYPES.FAVORITES_CATEGORY; + type: typeof DragItemType.FAVORITES_CATEGORY; categoryId: string; }; type DragItem = ChannelDragItem | CategoryDragItem; -const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DND_TYPES.FAVORITES_CATEGORY; -const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DND_TYPES.FAVORITES_CHANNEL; +const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DragItemType.FAVORITES_CATEGORY; +const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DragItemType.FAVORITES_CHANNEL; type FavoriteDropIndicator = {position: 'top' | 'bottom'; isValid: boolean}; @@ -120,7 +121,8 @@ const FavoriteChannelResolvedItem = observer( const isSelected = location.pathname === Routes.favoritesChannel(favoriteChannel.channelId); const shouldShowSelectedState = isSelected; useEffect(() => { - if (isSelected) { + const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.FAVORITES_LIST); + if (isSelected && !selectedFromThisRow) { elementRef.current?.scrollIntoView({block: 'nearest'}); } }, [isSelected]); @@ -128,9 +130,9 @@ const FavoriteChannelResolvedItem = observer( const {keyboardModeEnabled} = KeyboardMode; const showKeyboardAffordances = keyboardModeEnabled && isFocused; const [{isDragging}, dragRef, preview] = useDrag({ - type: DND_TYPES.FAVORITES_CHANNEL, + type: DragItemType.FAVORITES_CHANNEL, item: { - type: DND_TYPES.FAVORITES_CHANNEL, + type: DragItemType.FAVORITES_CHANNEL, channelId: favoriteChannel.channelId, parentId: favoriteChannel.parentId, }, @@ -140,7 +142,7 @@ const FavoriteChannelResolvedItem = observer( end: resetFavoriteDropIndicator, }); const [{isOver}, dropRef] = useDrop({ - accept: DND_TYPES.FAVORITES_CHANNEL, + accept: DragItemType.FAVORITES_CHANNEL, hover: (_item, monitor) => { const clientOffset = monitor.getClientOffset(); if (!clientOffset) return; @@ -233,6 +235,7 @@ const FavoriteChannelResolvedItem = observer( if (LinkChannelCommands.openLinkChannel(channel)) { return; } + markDirectSelection(DirectSelectionSurface.FAVORITES_LIST); preloadChannelNow(); NavigationCommands.selectChannel(FAVORITES_GUILD_ID, favoriteChannel.channelId); }; @@ -427,9 +430,9 @@ const FavoriteCategoryItem = observer( const {keyboardModeEnabled} = KeyboardMode; const showKeyboardAffordances = keyboardModeEnabled && isFocused; const [{isDragging}, dragRef, preview] = useDrag({ - type: DND_TYPES.FAVORITES_CATEGORY, + type: DragItemType.FAVORITES_CATEGORY, item: { - type: DND_TYPES.FAVORITES_CATEGORY, + type: DragItemType.FAVORITES_CATEGORY, categoryId: category.id, }, collect: (monitor) => ({ @@ -442,7 +445,7 @@ const FavoriteCategoryItem = observer( unknown, {isOver: boolean; draggedItemType: string | symbol | null} >({ - accept: [DND_TYPES.FAVORITES_CHANNEL, DND_TYPES.FAVORITES_CATEGORY], + accept: [DragItemType.FAVORITES_CHANNEL, DragItemType.FAVORITES_CATEGORY], hover: (item, monitor) => { if (!isCategoryDragItem(item)) { resetCategoryDropIndicator(); @@ -490,8 +493,8 @@ const FavoriteCategoryItem = observer( draggedItemType: monitor.getItemType(), }), }); - const isCategoryDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CATEGORY; - const isChannelDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CHANNEL; + const isCategoryDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CATEGORY; + const isChannelDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CHANNEL; useEffect(() => { if (!isCategoryDragOver) resetCategoryDropIndicator(); }, [isCategoryDragOver, resetCategoryDropIndicator]); @@ -605,7 +608,7 @@ const FavoriteCategoryItem = observer( ); const UncategorizedGroup = ({children}: {children: React.ReactNode}) => { const [{isOver}, dropRef] = useDrop({ - accept: DND_TYPES.FAVORITES_CHANNEL, + accept: DragItemType.FAVORITES_CHANNEL, drop: (item, monitor) => { if (monitor.didDrop()) return; if (item.parentId === null) return; @@ -640,7 +643,8 @@ export const FavoritesChannelListContent = observer(() => { const isDraggingFavorite = useDragLayer((monitor) => { const itemType = monitor.getItemType(); return ( - monitor.isDragging() && (itemType === DND_TYPES.FAVORITES_CHANNEL || itemType === DND_TYPES.FAVORITES_CATEGORY) + monitor.isDragging() && + (itemType === DragItemType.FAVORITES_CHANNEL || itemType === DragItemType.FAVORITES_CATEGORY) ); }); const scrollerRef = useRef(null); @@ -704,7 +708,11 @@ export const FavoritesChannelListContent = observer(() => { aria-label={i18n._(EMPTY_FAVORITES_DESCRIPTOR)} data-flx="app.favorites-channel-list-content.region.context-menu" > - +
diff --git a/fluxer_app/src/features/app/components/layout/FloatingUnreadEdges.ts b/fluxer_app/src/features/app/components/layout/FloatingUnreadEdges.ts new file mode 100644 index 000000000..5f6ed30a1 --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/FloatingUnreadEdges.ts @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine'; + +export interface FloatingUnreadTarget { + id: string; + severity: ScrollIndicatorSeverity; +} + +export interface MeasuredFloatingUnreadTarget { + id: string; + severity: ScrollIndicatorSeverity; + top: number; + bottom: number; +} + +export interface FloatingUnreadEdges { + top: MeasuredFloatingUnreadTarget | null; + bottom: MeasuredFloatingUnreadTarget | null; +} + +export interface FloatingUnreadTargetIndex { + byBottom: ReadonlyArray; + topCandidates: ReadonlyArray; + byTop: ReadonlyArray; + bottomCandidates: ReadonlyArray; +} + +export interface FloatingUnreadTargetBounds { + readonly bottom: number; + readonly top: number; +} + +export interface FloatingUnreadViewport { + scrollTop: number; + viewportHeight: number; + targetIndex: FloatingUnreadTargetIndex; +} + +interface CreateFloatingUnreadTargetIndexFromMeasurementsRequest { + readonly targets: ReadonlyArray; +} + +interface CreateFloatingUnreadTargetIndexFromBoundsRequest { + readonly boundsByTargetId: ReadonlyMap; + readonly targets: ReadonlyArray; +} + +interface CompareFloatingUnreadEdgesQuery { + readonly left: FloatingUnreadEdges; + readonly right: FloatingUnreadEdges; +} + +interface PreserveStableFloatingUnreadEdgesQuery { + readonly current: FloatingUnreadEdges; + readonly next: FloatingUnreadEdges; +} + +const VISIBILITY_EPSILON = 0.5; +const EMPTY_MEASURED_TARGETS: ReadonlyArray = Object.freeze([]); + +function compareTargetIdentifiers(left: string, right: string): number { + if (left === right) return 0; + if (left < right) return -1; + return 1; +} + +class FloatingUnreadEdgeLayoutOwner { + public readonly EMPTY_EDGES: FloatingUnreadEdges = Object.freeze({top: null, bottom: null}); + public readonly EMPTY_TARGET_INDEX: FloatingUnreadTargetIndex = Object.freeze({ + byBottom: EMPTY_MEASURED_TARGETS, + topCandidates: EMPTY_MEASURED_TARGETS, + byTop: EMPTY_MEASURED_TARGETS, + bottomCandidates: EMPTY_MEASURED_TARGETS, + }); + + public createTargetIndexFromMeasurements({ + targets, + }: CreateFloatingUnreadTargetIndexFromMeasurementsRequest): FloatingUnreadTargetIndex { + if (targets.length === 0) return this.EMPTY_TARGET_INDEX; + + const byBottom = targets.slice().sort(this.compareByBottom); + const topCandidates = new Array(byBottom.length); + let topCandidate: MeasuredFloatingUnreadTarget | null = null; + for (let index = 0; index < byBottom.length; index++) { + topCandidate = this.chooseTopCandidate(topCandidate, byBottom[index]); + topCandidates[index] = topCandidate; + } + + const byTop = targets.slice().sort(this.compareByTop); + const bottomCandidates = new Array(byTop.length); + let bottomCandidate: MeasuredFloatingUnreadTarget | null = null; + for (let index = byTop.length - 1; index >= 0; index--) { + bottomCandidate = this.chooseBottomCandidate(byTop[index], bottomCandidate); + bottomCandidates[index] = bottomCandidate; + } + + return {byBottom, topCandidates, byTop, bottomCandidates}; + } + + public createTargetIndexFromBounds({ + targets, + boundsByTargetId, + }: CreateFloatingUnreadTargetIndexFromBoundsRequest): FloatingUnreadTargetIndex { + if (targets.length === 0) return this.EMPTY_TARGET_INDEX; + const measuredTargets: Array = []; + for (const target of targets) { + const bounds = boundsByTargetId.get(target.id); + if (bounds == null) continue; + measuredTargets.push({...target, ...bounds}); + } + return this.createTargetIndexFromMeasurements({targets: measuredTargets}); + } + + public selectEdges({scrollTop, viewportHeight, targetIndex}: FloatingUnreadViewport): FloatingUnreadEdges { + const viewportTop = Math.max(0, scrollTop); + const viewportBottom = viewportTop + Math.max(0, viewportHeight); + const topIndex = this.findLastTargetEndingBefore(targetIndex.byBottom, viewportTop + VISIBILITY_EPSILON); + const bottomIndex = this.findFirstTargetStartingAfter(targetIndex.byTop, viewportBottom - VISIBILITY_EPSILON); + let top: MeasuredFloatingUnreadTarget | null; + if (topIndex >= 0) { + top = targetIndex.topCandidates[topIndex]; + } else { + top = null; + } + let bottom: MeasuredFloatingUnreadTarget | null; + if (bottomIndex < targetIndex.byTop.length) { + bottom = targetIndex.bottomCandidates[bottomIndex]; + } else { + bottom = null; + } + return {top, bottom}; + } + + public areEdgesEqual({left, right}: CompareFloatingUnreadEdgesQuery): boolean { + return this.areEdgesEqualAtPosition(left.top, right.top) && this.areEdgesEqualAtPosition(left.bottom, right.bottom); + } + + public preserveStableEdges({current, next}: PreserveStableFloatingUnreadEdgesQuery): FloatingUnreadEdges { + if (this.areEdgesEqual({left: current, right: next})) { + return current; + } + return next; + } + + private chooseTopCandidate( + current: MeasuredFloatingUnreadTarget | null, + next: MeasuredFloatingUnreadTarget, + ): MeasuredFloatingUnreadTarget { + if (current == null || (current.severity !== 'mention' && next.severity === 'mention')) { + return next; + } + return current; + } + + private chooseBottomCandidate( + current: MeasuredFloatingUnreadTarget, + next: MeasuredFloatingUnreadTarget | null, + ): MeasuredFloatingUnreadTarget { + if (next == null || (next.severity !== 'mention' && current.severity === 'mention')) { + return current; + } + return next; + } + + private compareByBottom(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number { + const bottomDifference = left.bottom - right.bottom; + if (bottomDifference !== 0) return bottomDifference; + const topDifference = left.top - right.top; + if (topDifference !== 0) return topDifference; + return compareTargetIdentifiers(left.id, right.id); + } + + private compareByTop(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number { + const topDifference = left.top - right.top; + if (topDifference !== 0) return topDifference; + const bottomDifference = left.bottom - right.bottom; + if (bottomDifference !== 0) return bottomDifference; + return compareTargetIdentifiers(left.id, right.id); + } + + private findLastTargetEndingBefore( + targets: ReadonlyArray, + viewportTop: number, + ): number { + let low = 0; + let high = targets.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (targets[middle].bottom <= viewportTop) { + low = middle + 1; + } else { + high = middle; + } + } + return low - 1; + } + + private findFirstTargetStartingAfter( + targets: ReadonlyArray, + viewportBottom: number, + ): number { + let low = 0; + let high = targets.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (targets[middle].top < viewportBottom) { + low = middle + 1; + } else { + high = middle; + } + } + return low; + } + + private areEdgesEqualAtPosition( + left: MeasuredFloatingUnreadTarget | null, + right: MeasuredFloatingUnreadTarget | null, + ): boolean { + if (left === right) return true; + if (left == null || right == null) return false; + return left.id === right.id && left.severity === right.severity; + } +} + +export const FloatingUnreadEdgeLayout = Object.freeze(new FloatingUnreadEdgeLayoutOwner()); diff --git a/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.module.css b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.module.css new file mode 100644 index 000000000..fc530344e --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.module.css @@ -0,0 +1,50 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: var(--z-index-elevated-2); +} + +.slot { + position: absolute; + left: 0; + width: 100%; + display: flex; + justify-content: center; + pointer-events: none; +} + +.slotTop { + top: 0.5rem; +} + +.slotBottom { + bottom: var(--floating-unread-indicator-bottom-inset, 0.5rem); +} + +.indicator { + pointer-events: auto; + border: none; + border-radius: 62.4375rem; + padding: 0.25rem 0.75rem; + font-weight: 600; + font-size: 0.6875rem; + line-height: 1rem; + letter-spacing: 0.025em; + text-transform: uppercase; + color: white; + cursor: pointer; + box-shadow: + 0 0.625rem 1.25rem -0.75rem rgba(0, 0, 0, 0.75), + 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.45); +} + +.indicatorBrand { + background-color: hsl(220, 6%, 30%); +} + +.indicatorMention { + background-color: var(--status-danger); +} diff --git a/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx new file mode 100644 index 000000000..352c5e8c9 --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Accessibility from '@app/features/accessibility/state/Accessibility'; +import { + FloatingUnreadEdgeLayout, + type FloatingUnreadEdges, + type FloatingUnreadTarget, + type FloatingUnreadTargetBounds, + type MeasuredFloatingUnreadTarget, +} from '@app/features/app/components/layout/FloatingUnreadEdges'; +import styles from '@app/features/app/components/layout/FloatingUnreadIndicators.module.css'; +import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine'; +import {Edge, type VerticalEdge} from '@app/features/ui/AxisOrientation'; +import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {AnimeButton, AnimePresence} from '@app/features/ui/motion/AnimeElement'; +import {resolveVisibleScrollViewportHeight} from '@app/features/ui/utils/ScrollViewportGeometry'; +import {flxElementClassName} from '@app/lib/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; +import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; + +const SCROLL_TARGET_PADDING_PX = 12; +const SETTLE_REFRESH_DELAY_MS = 280; + +const FloatingUnreadRefreshScope = Object.freeze({ + GEOMETRY: 'geometry', + SCROLL: 'scroll', + TARGETS: 'targets', +} as const); + +type FloatingUnreadRefreshScope = (typeof FloatingUnreadRefreshScope)[keyof typeof FloatingUnreadRefreshScope]; + +function buildFloatingUnreadTargetsSignature(targets: ReadonlyArray): string { + if (targets.length === 0) return ''; + const parts = new Array(targets.length); + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + parts[index] = `${target.id}:${target.severity}`; + } + return parts.join('|'); +} + +export interface FloatingUnreadTargetRegistry { + register: (id: string) => React.RefCallback; + getTargetNode: (id: string) => HTMLElement | null; +} + +export function useFloatingUnreadTargetRegistry(): FloatingUnreadTargetRegistry { + const nodesRef = useRef>(new Map()); + const callbacksRef = useRef>>(new Map()); + const register = useCallback((id: string): React.RefCallback => { + const cached = callbacksRef.current.get(id); + if (cached != null) return cached; + const callback = (node: HTMLElement | null) => { + const registeredNode = nodesRef.current.get(id); + if (registeredNode === node) return; + if (registeredNode == null && node == null) return; + if (node != null) { + nodesRef.current.set(id, node); + callbacksRef.current.set(id, callback); + } else { + nodesRef.current.delete(id); + callbacksRef.current.delete(id); + } + }; + callbacksRef.current.set(id, callback); + return callback; + }, []); + const getTargetNode = useCallback((id: string): HTMLElement | null => { + const node = nodesRef.current.get(id); + if (node == null) { + return null; + } + return node; + }, []); + return useMemo(() => ({register, getTargetNode}), [register, getTargetNode]); +} + +interface FloatingUnreadPillProps { + readonly label: string; + readonly severity: ScrollIndicatorSeverity; + readonly direction: VerticalEdge; + readonly onClick: () => void; +} + +interface VisibleFloatingUnreadEdge { + readonly direction: VerticalEdge; + readonly edge: MeasuredFloatingUnreadTarget; +} + +const FloatingUnreadPill = ({label, severity, direction, onClick}: FloatingUnreadPillProps) => { + const prefersReducedMotion = Accessibility.useReducedMotion; + let directionOffset: 10 | -10; + if (direction === Edge.TOP) { + directionOffset = -10; + } else { + directionOffset = 10; + } + let severityClassName = styles.indicatorBrand; + if (severity === 'mention') { + severityClassName = styles.indicatorMention; + } + if (prefersReducedMotion) { + return ( + + + {label} + + + ); + } + return ( + + + {label} + + + ); +}; + +export interface FloatingUnreadIndicatorsProps { + readonly label: string; + readonly scrollerRef: React.RefObject; + readonly scrollerIdentity: string; + readonly targets: ReadonlyArray; + readonly getTargetNode: (id: string) => HTMLElement | null; + readonly measurementRevision: number; + readonly getTargetBounds: ( + targets: ReadonlyArray, + ) => ReadonlyMap; + readonly scrollToTarget: (id: string, direction: VerticalEdge) => boolean; +} + +function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null { + if (scroller == null) { + return null; + } + return scroller.getScrollerNode(); +} + +function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis { + const defaultView = container.ownerDocument.defaultView; + if (defaultView == null) { + throw new Error('Floating unread indicator container has no owner window'); + } + return defaultView; +} + +function observeScrollContentElements( + resizeObserver: ResizeObserver, + container: HTMLElement, + ownerWindow: Window & typeof globalThis, +): void { + const contentWrapper = container.firstElementChild; + if (!(contentWrapper instanceof ownerWindow.HTMLElement)) return; + const contentChildren = contentWrapper.children; + if (contentChildren.length === 0) { + resizeObserver.observe(contentWrapper); + return; + } + for (let index = 0; index < contentChildren.length; index += 1) { + const child = contentChildren[index]; + if (child instanceof ownerWindow.HTMLElement) resizeObserver.observe(child); + } +} + +interface OwnedAnimationFrame { + readonly id: number; + readonly ownerWindow: Window & typeof globalThis; +} + +interface OwnedSettleTimer { + readonly id: number; + readonly ownerWindow: Window & typeof globalThis; +} + +interface AlignMountedTargetRequest { + readonly animate: boolean; + readonly direction: VerticalEdge; + readonly scroller: ScrollerHandle; + readonly targetNode: HTMLElement; +} + +function cancelOwnedAnimationFrame(frame: OwnedAnimationFrame | null): void { + if (frame == null) return; + frame.ownerWindow.cancelAnimationFrame(frame.id); +} + +function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void { + if (timer == null) return; + timer.ownerWindow.clearTimeout(timer.id); +} + +function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean { + const container = scroller.getScrollerNode(); + if (container == null) return false; + const containerRect = container.getBoundingClientRect(); + const targetRect = targetNode.getBoundingClientRect(); + const targetTop = container.scrollTop + targetRect.top - containerRect.top; + const targetBottom = container.scrollTop + targetRect.bottom - containerRect.top; + let to = targetBottom + SCROLL_TARGET_PADDING_PX - resolveVisibleScrollViewportHeight(container); + if (direction === Edge.TOP) { + to = targetTop - SCROLL_TARGET_PADDING_PX; + } + scroller.scrollTo({to, animate}); + return true; +} + +export const FloatingUnreadIndicators = observer( + ({ + label, + scrollerRef, + scrollerIdentity, + targets, + getTargetNode, + measurementRevision, + getTargetBounds, + scrollToTarget, + }: FloatingUnreadIndicatorsProps) => { + const [edges, setEdges] = useState(FloatingUnreadEdgeLayout.EMPTY_EDGES); + const targetsSignature = useMemo(() => buildFloatingUnreadTargetsSignature(targets), [targets]); + const targetsRef = useRef(targets); + const getTargetNodeRef = useRef(getTargetNode); + const getTargetBoundsRef = useRef(getTargetBounds); + const scrollToTargetRef = useRef(scrollToTarget); + targetsRef.current = targets; + getTargetNodeRef.current = getTargetNode; + getTargetBoundsRef.current = getTargetBounds; + scrollToTargetRef.current = scrollToTarget; + const rafRef = useRef(null); + const targetAlignmentFrameRef = useRef(null); + const settleTimerRef = useRef(null); + const geometrySettleTimerRef = useRef(null); + const geometrySettleArmedRef = useRef(false); + const geometrySettleSuppressedRef = useRef(false); + const refreshNowRef = useRef<() => void>(() => {}); + const geometryInvalidatedRef = useRef(true); + const targetIndexInvalidatedRef = useRef(true); + const visibleViewportHeightRef = useRef(0); + const targetBoundsCacheRef = useRef>(new Map()); + const pendingScrollTopRef = useRef(null); + const targetIndexRef = useRef(FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX); + const refreshTargetIndex = useCallback(() => { + const currentTargets = targetsRef.current; + const boundsCache = targetBoundsCacheRef.current; + const unmeasuredTargets: Array = []; + for (const target of currentTargets) { + if (boundsCache.has(target.id)) continue; + unmeasuredTargets.push(target); + } + if (unmeasuredTargets.length > 0) { + const measuredBounds = getTargetBoundsRef.current(unmeasuredTargets); + for (const [id, bounds] of measuredBounds) { + boundsCache.set(id, bounds); + } + } + if (boundsCache.size > currentTargets.length) { + const retainedBounds = new Map(); + for (const target of currentTargets) { + const bounds = boundsCache.get(target.id); + if (bounds != null) retainedBounds.set(target.id, bounds); + } + targetBoundsCacheRef.current = retainedBounds; + } + targetIndexRef.current = FloatingUnreadEdgeLayout.createTargetIndexFromBounds({ + targets: currentTargets, + boundsByTargetId: targetBoundsCacheRef.current, + }); + }, []); + const refreshNow = useCallback(() => { + const container = resolveScrollerNode(scrollerRef.current); + if (container == null) { + pendingScrollTopRef.current = null; + targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX; + setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES); + return; + } + if (targetsRef.current.length === 0) { + pendingScrollTopRef.current = null; + targetBoundsCacheRef.current.clear(); + targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX; + setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES); + return; + } + if (geometryInvalidatedRef.current) { + geometryInvalidatedRef.current = false; + targetIndexInvalidatedRef.current = true; + targetBoundsCacheRef.current.clear(); + visibleViewportHeightRef.current = resolveVisibleScrollViewportHeight(container); + if (geometrySettleSuppressedRef.current) { + geometrySettleSuppressedRef.current = false; + geometrySettleArmedRef.current = false; + } else if (!geometrySettleArmedRef.current) { + geometrySettleArmedRef.current = true; + const settleOwnerWindow = resolveOwnerWindow(container); + const settleId = settleOwnerWindow.setTimeout(() => { + geometrySettleTimerRef.current = null; + geometrySettleArmedRef.current = false; + geometrySettleSuppressedRef.current = true; + geometryInvalidatedRef.current = true; + targetIndexInvalidatedRef.current = true; + refreshNowRef.current(); + }, SETTLE_REFRESH_DELAY_MS); + geometrySettleTimerRef.current = {id: settleId, ownerWindow: settleOwnerWindow}; + } + } + if (targetIndexInvalidatedRef.current) { + targetIndexInvalidatedRef.current = false; + refreshTargetIndex(); + } + const pendingScrollTop = pendingScrollTopRef.current; + pendingScrollTopRef.current = null; + let scrollTop: number; + if (pendingScrollTop == null) { + scrollTop = container.scrollTop; + } else { + scrollTop = pendingScrollTop; + } + const next = FloatingUnreadEdgeLayout.selectEdges({ + scrollTop, + viewportHeight: visibleViewportHeightRef.current, + targetIndex: targetIndexRef.current, + }); + setEdges((current) => FloatingUnreadEdgeLayout.preserveStableEdges({current, next})); + }, [refreshTargetIndex, scrollerRef]); + refreshNowRef.current = refreshNow; + const scheduleRefresh = useCallback( + (scope: FloatingUnreadRefreshScope) => { + if (scope === FloatingUnreadRefreshScope.GEOMETRY) { + geometryInvalidatedRef.current = true; + targetIndexInvalidatedRef.current = true; + geometrySettleSuppressedRef.current = false; + geometrySettleArmedRef.current = false; + cancelOwnedSettleTimer(geometrySettleTimerRef.current); + geometrySettleTimerRef.current = null; + } else if (scope === FloatingUnreadRefreshScope.TARGETS) { + targetIndexInvalidatedRef.current = true; + if (geometrySettleArmedRef.current) geometryInvalidatedRef.current = true; + } + const container = resolveScrollerNode(scrollerRef.current); + if (container == null) { + refreshNow(); + return; + } + const ownerWindow = resolveOwnerWindow(container); + const pendingFrame = rafRef.current; + if (pendingFrame != null && pendingFrame.ownerWindow === ownerWindow) return; + cancelOwnedAnimationFrame(pendingFrame); + const id = ownerWindow.requestAnimationFrame(() => { + rafRef.current = null; + refreshNow(); + }); + rafRef.current = {id, ownerWindow}; + }, + [refreshNow, scrollerRef], + ); + useLayoutEffect(() => { + const container = resolveScrollerNode(scrollerRef.current); + if (container == null) { + targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX; + setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES); + return; + } + geometryInvalidatedRef.current = true; + targetIndexInvalidatedRef.current = true; + refreshNow(); + const ownerWindow = resolveOwnerWindow(container); + const handleScroll = () => { + pendingScrollTopRef.current = container.scrollTop; + scheduleRefresh(FloatingUnreadRefreshScope.SCROLL); + }; + const handleLayoutChange = () => scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + container.addEventListener('scroll', handleScroll, {passive: true}); + ownerWindow.addEventListener('resize', handleLayoutChange); + let resizeObserver: ResizeObserver | null; + if (ownerWindow.ResizeObserver != null) { + resizeObserver = new ownerWindow.ResizeObserver(handleLayoutChange); + } else { + resizeObserver = null; + } + if (resizeObserver != null) { + resizeObserver.observe(container); + observeScrollContentElements(resizeObserver, container, ownerWindow); + } + return () => { + container.removeEventListener('scroll', handleScroll); + ownerWindow.removeEventListener('resize', handleLayoutChange); + if (resizeObserver != null) { + resizeObserver.disconnect(); + } + cancelOwnedAnimationFrame(rafRef.current); + rafRef.current = null; + cancelOwnedAnimationFrame(targetAlignmentFrameRef.current); + targetAlignmentFrameRef.current = null; + cancelOwnedSettleTimer(settleTimerRef.current); + settleTimerRef.current = null; + cancelOwnedSettleTimer(geometrySettleTimerRef.current); + geometrySettleTimerRef.current = null; + geometrySettleArmedRef.current = false; + geometrySettleSuppressedRef.current = false; + }; + }, [refreshNow, scheduleRefresh, scrollerIdentity, scrollerRef]); + useLayoutEffect(() => { + scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + }, [measurementRevision, scheduleRefresh]); + useLayoutEffect(() => { + scheduleRefresh(FloatingUnreadRefreshScope.TARGETS); + }, [scheduleRefresh, targetsSignature]); + const alignTarget = useCallback( + (id: string, direction: VerticalEdge, animate: boolean): boolean => { + const scroller = scrollerRef.current; + const targetNode = getTargetNodeRef.current(id); + if (scroller == null || targetNode == null) return false; + return alignMountedTarget({animate, direction, scroller, targetNode}); + }, + [scrollerRef], + ); + const scrollToEdge = useCallback( + (edge: MeasuredFloatingUnreadTarget, direction: VerticalEdge) => { + const scroller = scrollerRef.current; + const scrollTarget = scrollToTargetRef.current; + if (scrollTarget(edge.id, direction)) { + scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + if (scroller == null) return; + const scrollNode = scroller.getScrollerNode(); + if (scrollNode == null) return; + const ownerWindow = resolveOwnerWindow(scrollNode); + cancelOwnedAnimationFrame(targetAlignmentFrameRef.current); + const id = ownerWindow.requestAnimationFrame(() => { + targetAlignmentFrameRef.current = null; + alignTarget(edge.id, direction, Accessibility.useSmoothScrolling); + scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + }); + targetAlignmentFrameRef.current = {id, ownerWindow}; + } else if (!alignTarget(edge.id, direction, Accessibility.useSmoothScrolling)) { + scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + return; + } + scheduleRefresh(FloatingUnreadRefreshScope.SCROLL); + if (scroller == null) return; + const scrollNode = scroller.getScrollerNode(); + if (scrollNode == null) return; + const ownerWindow = resolveOwnerWindow(scrollNode); + cancelOwnedSettleTimer(settleTimerRef.current); + const id = ownerWindow.setTimeout(() => { + settleTimerRef.current = null; + alignTarget(edge.id, direction, false); + scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); + }, SETTLE_REFRESH_DELAY_MS); + settleTimerRef.current = {id, ownerWindow}; + }, + [alignTarget, scheduleRefresh, scrollerRef], + ); + const visibleEdges: Array = []; + if (edges.top != null) visibleEdges.push({direction: Edge.TOP, edge: edges.top}); + if (edges.bottom != null) visibleEdges.push({direction: Edge.BOTTOM, edge: edges.bottom}); + function renderVisibleEdge({direction, edge}: VisibleFloatingUnreadEdge): React.ReactNode { + let slotPositionClassName = styles.slotBottom; + if (direction === Edge.TOP) { + slotPositionClassName = styles.slotTop; + } + return ( + + scrollToEdge(edge, direction)} + data-flx="app.floating-unread-indicators.render-visible-edge.floating-unread-pill.scroll-to-edge" + /> + + ); + } + return ( + + + {visibleEdges.map(renderVisibleEdge)} + + + ); + }, +); diff --git a/fluxer_app/src/features/app/components/layout/GuildHeader.tsx b/fluxer_app/src/features/app/components/layout/GuildHeader.tsx index d2ab08e93..f4079c649 100644 --- a/fluxer_app/src/features/app/components/layout/GuildHeader.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildHeader.tsx @@ -3,8 +3,16 @@ import styles from '@app/features/app/components/layout/GuildHeader.module.css'; import {GuildHeaderShell} from '@app/features/app/components/layout/GuildHeaderShell'; import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion'; +import { + reportSkeletonGuildPresentation, + SkeletonGuildBannerPlacement, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl'; import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; +import { + measureSkeletonTextWidthPx, + useSkeletonLayoutReport, +} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry'; import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet'; import {GuildBadge} from '@app/features/guild/components/GuildBadge'; @@ -34,6 +42,25 @@ const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({ const HEADER_MIN_HEIGHT = 56; const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9; const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3; +const GUILD_BADGE_FEATURES: ReadonlyArray = [ + GuildFeatures.VERIFIED, + GuildFeatures.PARTNERED, + GuildFeatures.DISCOVERABLE, +]; + +function resolveGuildBadgeVisible(features: ReadonlySet): boolean { + return GUILD_BADGE_FEATURES.some((feature) => features.has(feature)); +} + +function resolveSkeletonBannerPlacement(hasBanner: boolean, isDetachedBanner: boolean): SkeletonGuildBannerPlacement { + if (!hasBanner) { + return SkeletonGuildBannerPlacement.NONE; + } + if (isDetachedBanner) { + return SkeletonGuildBannerPlacement.DETACHED; + } + return SkeletonGuildBannerPlacement.INTEGRATED; +} export const GuildHeader = observer(({guild}: {guild: Guild}) => { const {i18n} = useLingui(); const {popouts} = Popout; @@ -54,6 +81,10 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => { }); const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER); const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner); + const bannerAspectRatio = + guild.bannerWidth && guild.bannerHeight + ? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO) + : DEFAULT_BANNER_ASPECT_RATIO; const headerContainerRef = useRef(null); const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]); const [containerWidth, setContainerWidth] = useState(() => @@ -87,11 +118,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => { if (!showIntegratedBanner || !bannerURL || !containerWidth) { return {bannerMaxHeight: HEADER_MIN_HEIGHT, centerCrop: false}; } - const aspectRatio = - guild.bannerWidth && guild.bannerHeight - ? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO) - : DEFAULT_BANNER_ASPECT_RATIO; - const idealHeight = containerWidth / aspectRatio; + const idealHeight = containerWidth / bannerAspectRatio; const viewportCap = viewportHeight * MAX_VIEWPORT_HEIGHT_FRACTION; const isCapped = idealHeight > viewportCap; return { @@ -112,6 +139,17 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => { [guild], ); const headerButtonRef = useRef(null); + const guildNameRef = useRef(null); + const badgeVisible = resolveGuildBadgeVisible(guild.features); + const bannerPlacement = resolveSkeletonBannerPlacement(Boolean(bannerURL), isDetachedBanner); + useSkeletonLayoutReport(() => { + reportSkeletonGuildPresentation(guild.id, { + headerNameWidthPx: measureSkeletonTextWidthPx(guildNameRef.current), + badgeVisible, + bannerPlacement, + bannerAspectRatio, + }); + }, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${bannerAspectRatio}:${isMobile}`); return (
{ data-flx="app.guild-header.guild-badge" /> diff --git a/fluxer_app/src/features/app/components/layout/GuildLayout.tsx b/fluxer_app/src/features/app/components/layout/GuildLayout.tsx index 0857dde93..57f932878 100644 --- a/fluxer_app/src/features/app/components/layout/GuildLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildLayout.tsx @@ -5,9 +5,9 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext'; import styles from '@app/features/app/components/layout/GuildLayout.module.css'; import {GuildNavbar} from '@app/features/app/components/layout/GuildNavbar'; -import {GuildNavbarSkeleton} from '@app/features/app/components/layout/GuildNavbarSkeleton'; import {Nagbar} from '@app/features/app/components/layout/Nagbar'; import {NagbarButton} from '@app/features/app/components/layout/NagbarButton'; +import {GuildSidebarSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton'; import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {MatureContentChannelGate} from '@app/features/channel/components/MatureContentChannelGate'; import Channels from '@app/features/channel/state/Channels'; @@ -497,7 +497,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) => return (
- +
{guildUnavailable ? ( > {guildNagbars}
- +
> {guildNagbars}
- +
> {guildNagbars}
- +
{ - const mobileLayout = MobileLayout; - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -}); 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 30652c216..bcc1862c6 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css @@ -22,7 +22,7 @@ --layout-user-area-overlay-height: calc( var(--layout-user-area-height) + var(--user-area-box-inset-block-end) + - var(--layout-voice-connection-height, 0px) + var(--layout-voice-connection-height) ); } @@ -70,8 +70,6 @@ width: 100%; min-width: 0; background-color: var(--background-secondary); - overflow-anchor: none; - --guild-badge-surface: var(--background-secondary); padding-top: var(--spacing-1); padding-bottom: var(--spacing-2); scroll-padding-bottom: calc( @@ -80,15 +78,15 @@ ); } +.guildListScrollContainer.guildListScrollContainer { + overflow-anchor: auto; +} + .guildListContent { --guild-list-item-size: var(--guild-icon-size); - --guild-list-item-box-size: 3rem; - --guild-list-item-gap: 0.375rem; --guild-list-item-inset: calc((var(--guild-list-item-box-size) - var(--guild-list-item-size)) / 2); - --guild-folder-expanded-surface-size: var(--guild-list-item-box-size); - display: flex; - flex-direction: column; - align-items: center; + position: relative; + display: block; width: 100%; min-width: 0; padding-bottom: calc( @@ -97,34 +95,9 @@ ); } -.guildListTopSection, -.guildListGuildsSection { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; -} - -.guildListTopSection { - gap: var(--guild-list-item-gap); -} - -.guildListGuildsSection { - position: relative; - gap: var(--guild-list-item-gap); -} - -.guildListItems { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - gap: 0; -} - .guildListItemSlot { display: flex; + align-items: center; justify-content: center; width: 100%; } @@ -208,7 +181,7 @@ } .guildListReorderTarget { - height: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap)); + height: var(--guild-list-item-target-size); } .guildIcon { @@ -256,7 +229,7 @@ transition-property: background-color, color, border-radius; transition-duration: 70ms; transition-timing-function: ease-out; - background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground)); + background-color: var(--guilds-layout-item-bg); cursor: pointer; } @@ -306,12 +279,12 @@ .guildIndicator { position: absolute; - left: -0.15rem; + left: var(--guild-list-indicator-inset); display: flex; align-items: center; justify-content: center; height: var(--guild-icon-size); - width: 0.5rem; + width: var(--guild-list-indicator-track-width); pointer-events: none; container-type: layout size; z-index: 2; @@ -319,7 +292,7 @@ .guildIndicatorBar { display: block; - width: 0.35rem; + width: var(--guild-list-indicator-bar-width); border-radius: 0 var(--radius-full) var(--radius-full) 0; background-color: var(--text-primary); } @@ -330,20 +303,17 @@ bottom: -0.25rem; pointer-events: none; border-radius: var(--radius-md); + isolation: isolate; } -.guildBadgeActive { - box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary)); -} - -.dmListSection { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: 0; - min-height: 0; - width: 100%; +.guildBadgeActive::before, +.guildBadge:not(:empty)::before { + content: ''; + position: absolute; + inset: -0.1875rem; + z-index: -1; + border-radius: calc(var(--radius-md) + 0.1875rem); + background-color: var(--guild-badge-surface); } .guildVoiceBadge { @@ -363,7 +333,7 @@ flex-shrink: 0; border-radius: var(--radius-full); background-color: var(--status-online); - box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary)); + box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface); pointer-events: none; color: white; } @@ -385,7 +355,7 @@ border-radius: var(--radius-full); background-color: white; color: var(--status-danger); - box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary)); + box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface); } .dmListItem { @@ -431,7 +401,7 @@ .fluxerButtonIcon { composes: guildIcon; - background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground)); + background-color: var(--guilds-layout-item-bg); color: var(--text-primary); transition-property: background-color, color, border-radius; transition-duration: 70ms; @@ -487,12 +457,28 @@ } } +.guildDividerSlot { + --guild-list-divider-previous-extra: 0px; + --guild-list-divider-next-extra: 0px; + display: grid; + grid-template-rows: + calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-next-extra)) + var(--guild-list-divider-line-size) + calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-previous-extra)); + justify-items: center; + width: 100%; + flex-shrink: 0; +} + +.guildDividerSlot[data-next-row-extended='true'] { + --guild-list-divider-next-extra: var(--guild-list-item-target-half-extra); +} + .guildDivider { - height: 0.125rem; + grid-row: 2; + height: var(--guild-list-divider-line-size); width: 2rem; flex-shrink: 0; - margin-top: var(--guild-list-item-gap); - margin-bottom: var(--guild-list-item-gap); border-radius: 0.0625rem; background-color: var(--background-modifier-hover); } @@ -514,9 +500,9 @@ width: 100%; } -.fluxerSymbolIcon { - height: 2.75rem; - width: 2.75rem; +.messageBubbleIcon { + height: 1.75rem; + width: 1.75rem; color: currentColor; } @@ -631,7 +617,7 @@ :global(html.platform-native) .outlineFrame { border-top: 0.0625rem solid var(--user-area-divider-color); - --outline-radius: clamp(0.5rem, 1.2vw, 0.875rem); + --outline-radius: var(--outline-frame-radius-native); } :global(html.platform-native) .outlineFrame.outlineFrameFullscreen { @@ -669,7 +655,6 @@ } .guildTooltipStat { - --guild-tooltip-count-skeleton-width: 2.125rem; display: inline-flex; align-items: center; min-width: 0; @@ -677,14 +662,6 @@ line-height: 1; } -.guildTooltipStatOnline { - --guild-tooltip-count-skeleton-width: 2.125rem; -} - -.guildTooltipStatMembers { - --guild-tooltip-count-skeleton-width: 2.5rem; -} - .guildTooltipStatDot { margin-right: 0.3rem; height: 0.5rem; @@ -713,31 +690,6 @@ white-space: nowrap; } -.guildTooltipStatSkeleton { - height: 0.6875rem; - width: var(--guild-tooltip-count-skeleton-width); - min-width: var(--guild-tooltip-count-skeleton-width); - border-radius: 62.4375rem; - background: color-mix(in srgb, var(--text-primary-muted) 22%, transparent); - animation: guild-tooltip-stat-skeleton-pulse 1.6s ease-in-out infinite; -} - -:global(html.reduced-motion) .guildTooltipStatSkeleton { - animation: none; - opacity: 0.72; -} - -@keyframes guild-tooltip-stat-skeleton-pulse { - 0%, - 100% { - opacity: 0.44; - } - - 50% { - opacity: 0.72; - } -} - .guildVoiceInfo { display: flex; align-items: center; @@ -774,7 +726,7 @@ right: calc(-0.25rem - 0.1875rem); bottom: calc(-0.25rem - 0.1875rem); pointer-events: none; - background-color: var(--guild-badge-surface, var(--background-secondary)); + background-color: var(--guild-badge-surface); border-radius: var(--radius-full); padding: 0.1875rem; } @@ -796,10 +748,6 @@ width: 0.75rem; } -:global(.theme-light) .guildsLayoutContainer { - --guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%); -} - .roundedFull { border-radius: var(--radius-full); } diff --git a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx index a139de73d..ccb80dac4 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx @@ -3,8 +3,23 @@ import {Routes} from '@app/app/Routes'; import Accessibility from '@app/features/accessibility/state/Accessibility'; import {useActiveNagbars, useNagbarConditions} from '@app/features/app/components/layout/app_layout/AppLayoutHooks'; +import {type NagbarState, NagbarType} from '@app/features/app/components/layout/app_layout/AppLayoutTypes'; import {NagbarContainer} from '@app/features/app/components/layout/app_layout/NagbarContainer'; +import {MAINTENANCE_NAGBAR_TONE_KINDS} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar'; import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext'; +import { + consumeDirectSelection, + DirectSelectionSurface, +} from '@app/features/app/components/layout/DirectSelectionOrigin'; +import type { + FloatingUnreadTarget, + FloatingUnreadTargetBounds, +} from '@app/features/app/components/layout/FloatingUnreadEdges'; +import { + FloatingUnreadIndicators, + type FloatingUnreadTargetRegistry, + useFloatingUnreadTargetRegistry, +} from '@app/features/app/components/layout/FloatingUnreadIndicators'; import styles from '@app/features/app/components/layout/GuildsLayout.module.css'; import { DM_LIST_REMOVAL_DELAY_MS, @@ -13,19 +28,52 @@ import { useFrameBatchedDMListRows, } from '@app/features/app/components/layout/guilds_layout/GuildListDMAnimation'; import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame'; -import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay'; +import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine'; import {AddGuildButton} from '@app/features/app/components/layout/sidebar_nav/AddGuildButton'; import {DiscoveryButton} from '@app/features/app/components/layout/sidebar_nav/DiscoveryButton'; import {DownloadButton} from '@app/features/app/components/layout/sidebar_nav/DownloadButton'; import {FavoritesButton} from '@app/features/app/components/layout/sidebar_nav/FavoritesButton'; import {FluxerButton} from '@app/features/app/components/layout/sidebar_nav/FluxerButton'; import {GuildFolderItem} from '@app/features/app/components/layout/sidebar_nav/GuildFolderItem'; +import {resolveDMListItemUnreadState} from '@app/features/app/components/layout/sidebar_nav/GuildListDMItem'; import {GuildListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListItem'; import {HelpButton} from '@app/features/app/components/layout/sidebar_nav/HelpButton'; -import {DND_TYPES, type GuildDragItem, type GuildDropResult} from '@app/features/app/components/layout/types/DndTypes'; +import { + DragItemType, + DropPlacement, + type GuildDragItem, + type GuildDropPosition, + type GuildDropResult, +} from '@app/features/app/components/layout/types/DndTypes'; import {UserArea} from '@app/features/app/components/layout/UserArea'; +import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState'; +import { + getRememberedSkeletonGuildRailScrollTopPx, + type RememberedSkeletonGuildRailItem, + type RememberedSkeletonGuildRailLayout, + type RememberedSkeletonNagbarRow, + registerSkeletonLayoutMemoryPreFlush, + reportSkeletonGuildRailLayout, + reportSkeletonGuildRailScrollTop, + reportSkeletonNagbarLayout, + SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT, + SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + SkeletonGuildRailItemIndicator, + SkeletonGuildRailItemKind, + SkeletonNagbarTone, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; import {WHATS_NEW_ENTRIES} from '@app/features/app/components/whats_new/WhatsNewEntries'; import {openWhatsNewModal} from '@app/features/app/components/whats_new/WhatsNewModal'; +import {ConnectionNoticeTone, resolveConnectionNoticeShape} from '@app/features/app/hooks/useConnectionNotice'; +import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; +import { + NavigationAlignment, + type NavigationRow, + type NavigationRowBounds, + resolveNavigationAlignment, + useNavigationList, +} from '@app/features/app/hooks/useNavigationList'; import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList'; import Initialization from '@app/features/app/state/Initialization'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; @@ -33,8 +81,10 @@ import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimA import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import GuildAvailability from '@app/features/guild/state/GuildAvailability'; +import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded'; import GuildListState, {type OrganizedItem} from '@app/features/guild/state/GuildList'; import GuildReadState from '@app/features/guild/state/GuildReadState'; +import HiddenGuildListButtons from '@app/features/guild/state/HiddenGuildListButtons'; import {PRIMARY_NAVIGATION_LANDMARK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {openMacPermissionsModal} from '@app/features/permissions/system/commands/MacPermissionsModalCommands'; import MacPermissions from '@app/features/permissions/system/state/MacPermissions'; @@ -42,11 +92,13 @@ import {useLocation} from '@app/features/platform/components/router/RouterReact' import {Platform} from '@app/features/platform/types/Platform'; import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus'; import ReadStates from '@app/features/read_state/state/ReadStates'; +import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; +import {AxisOrientation, type VerticalEdge} from '@app/features/ui/AxisOrientation'; import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import {useHoverDeferredOrderedItems} from '@app/features/ui/hooks/UseHoverDeferredOrderedItems'; import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll'; -import {useListScrollAnchor} from '@app/features/ui/hooks/useScrollAnchor'; +import {RelativePosition} from '@app/features/ui/RelativePosition'; import Dimension from '@app/features/ui/state/Dimension'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import MobileLayout from '@app/features/ui/state/MobileLayout'; @@ -56,21 +108,29 @@ import SidebarWidth from '@app/features/ui/state/SidebarWidth'; import WhatsNew from '@app/features/ui/state/WhatsNew'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands'; +import StatusPage from '@app/features/user/state/StatusPage'; +import UserGuildSettings from '@app/features/user/state/UserGuildSettings'; import UserSettings, {type GuildFolder} from '@app/features/user/state/UserSettings'; import Users from '@app/features/user/state/Users'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; import CallState from '@app/features/voice/state/CallState'; import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen'; +import {flxElementClassName} from '@app/lib/react'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; -import {DEFAULT_GUILD_FOLDER_ICON, UNCATEGORIZED_FOLDER_ID} from '@fluxer/constants/src/UserConstants'; +import { + DEFAULT_GUILD_FOLDER_ICON, + GuildFolderFlags, + UNCATEGORIZED_FOLDER_ID, +} from '@fluxer/constants/src/UserConstants'; import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; import {msg, plural} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {ExclamationMarkIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; +import {autorun, untracked} from 'mobx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useDrop} from 'react-dnd'; const NEW_DESCRIPTOR = msg({ @@ -88,6 +148,15 @@ const DOM_DELTA_LINE = 1; const DOM_DELTA_PAGE = 2; const getChannelId = (channel: Channel): string => channel.id; const EMPTY_CHANNELS: ReadonlyArray = Object.freeze([]); +const ESTIMATED_GUILD_ROW_HEIGHT = 54; +const GUILD_BOTTOM_DROP_ZONE_HEIGHT = 24; +const GUILD_LIST_SCROLLER_IDENTITY = 'guild-list-scroller'; +const GUILD_LIST_SCROLL_PERSIST_IDLE_MS = 150; +const GUILD_ROW_STYLE: React.CSSProperties = Object.freeze({width: '100%', paddingBottom: 0}); +const GUILD_ROW_STYLE_WITH_GAP: React.CSSProperties = Object.freeze({ + width: '100%', + paddingBottom: 'var(--guild-list-item-gap)', +}); interface GuildsLayoutSidebarStyle extends React.CSSProperties { '--layout-sidebar-width': string; @@ -108,14 +177,250 @@ function getResizeObserverEntryBlockSize(entry: ResizeObserverEntry): number { return firstBorderBoxSize?.blockSize ?? entry.contentRect.height; } +function resolveGuildFolderId(folderId: number | null): number { + if (folderId != null) return folderId; + return UNCATEGORIZED_FOLDER_ID; +} + +function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null { + if (scroller == null) return null; + return scroller.getScrollerNode(); +} + +type GuildNavigationRow = + | (NavigationRow & {readonly kind: 'fluxer'}) + | (NavigationRow & {readonly kind: 'favorites'}) + | (NavigationRow & {readonly kind: 'dm'; readonly row: DMListRow; readonly isLast: boolean}) + | (NavigationRow & {readonly kind: 'divider'}) + | (NavigationRow & {readonly kind: 'outage'}) + | (NavigationRow & {readonly kind: 'organized-item'; readonly item: OrganizedItem}) + | (NavigationRow & {readonly kind: 'bottom-drop-zone'}) + | (NavigationRow & {readonly kind: 'discovery'}) + | (NavigationRow & {readonly kind: 'add-guild'}) + | (NavigationRow & {readonly kind: 'download'}) + | (NavigationRow & {readonly kind: 'help'}); + +interface GuildNavigationVisibility { + readonly fluxerVisible: boolean; + readonly favoritesVisible: boolean; + readonly discoveryVisible: boolean; + readonly addGuildVisible: boolean; + readonly downloadVisible: boolean; + readonly helpVisible: boolean; +} + +function useGuildNavigationVisibility(): GuildNavigationVisibility { + const communityActionsVisible = !RuntimeConfig.singleCommunityEnabled; + const fluxerVisible = !RuntimeConfig.directMessagesDisabled; + const favoritesVisible = Accessibility.showFavorites; + const downloadVisible = !Platform.isElectron && !Platform.isPWA && !HiddenGuildListButtons.downloadButtonHidden; + const helpVisible = !HiddenGuildListButtons.helpButtonHidden; + return useMemo( + () => + Object.freeze({ + fluxerVisible, + favoritesVisible, + discoveryVisible: communityActionsVisible, + addGuildVisible: communityActionsVisible, + downloadVisible, + helpVisible, + }), + [communityActionsVisible, downloadVisible, favoritesVisible, fluxerVisible, helpVisible], + ); +} + +interface CreateGuildNavigationRowsRequest { + readonly hasUnavailableGuilds: boolean; + readonly isDragging: boolean; + readonly organizedItems: ReadonlyArray; + readonly shouldRenderGuildListItems: boolean; + readonly visibleDMListRows: ReadonlyArray; + readonly visibility: GuildNavigationVisibility; +} + +interface ResolveSelectedGuildNavigationKeyRequest { + readonly organizedItems: ReadonlyArray; + readonly pathname: string; + readonly visibleDMListRows: ReadonlyArray; + readonly visibility: GuildNavigationVisibility; +} + +function createGuildNavigationRows({ + hasUnavailableGuilds, + isDragging, + organizedItems, + shouldRenderGuildListItems, + visibleDMListRows, + visibility, +}: CreateGuildNavigationRowsRequest): Array { + const rows: Array = []; + if (visibility.fluxerVisible) { + rows.push({ + kind: 'fluxer', + key: 'fluxer', + focusable: true, + focusTargetIdentity: 'fluxer', + }); + } + if (visibility.favoritesVisible) { + rows.push({ + kind: 'favorites', + key: 'favorites', + focusable: true, + focusTargetIdentity: 'favorites', + }); + } + for (let index = 0; index < visibleDMListRows.length; index++) { + const row = visibleDMListRows[index]; + rows.push({ + kind: 'dm', + key: `dm:${row.channel.id}`, + focusable: true, + focusTargetIdentity: row, + row, + isLast: index === visibleDMListRows.length - 1, + }); + } + rows.push({ + kind: 'divider', + key: 'divider', + focusable: false, + focusTargetIdentity: 'divider', + }); + if (shouldRenderGuildListItems) { + if (hasUnavailableGuilds) { + rows.push({ + kind: 'outage', + key: 'outage', + focusable: false, + focusTargetIdentity: 'outage', + }); + } + for (const item of organizedItems) { + rows.push({ + kind: 'organized-item', + key: `organized:${getOrganizedItemKey(item)}`, + focusable: true, + focusTargetIdentity: item, + item, + }); + } + if (isDragging && organizedItems.length > 0) { + rows.push({ + kind: 'bottom-drop-zone', + key: 'bottom-drop-zone', + focusable: false, + focusTargetIdentity: 'bottom-drop-zone', + }); + } + } + if (visibility.discoveryVisible) { + rows.push({ + kind: 'discovery', + key: 'discovery', + focusable: true, + focusTargetIdentity: 'discovery', + }); + } + if (visibility.addGuildVisible) { + rows.push({ + kind: 'add-guild', + key: 'add-guild', + focusable: true, + focusTargetIdentity: 'add-guild', + }); + } + if (visibility.downloadVisible) { + rows.push({ + kind: 'download', + key: 'download', + focusable: true, + focusTargetIdentity: 'download', + }); + } + if (visibility.helpVisible) { + rows.push({ + kind: 'help', + key: 'help', + focusable: true, + focusTargetIdentity: 'help', + }); + } + return rows; +} + +function hasGapAfterGuildNavigationRow(row: GuildNavigationRow, nextRow: GuildNavigationRow | null): boolean { + switch (row.kind) { + case 'fluxer': + case 'discovery': + case 'add-guild': + case 'download': + return true; + case 'favorites': + if (nextRow == null) { + return false; + } + return nextRow.kind === 'dm'; + case 'organized-item': + if (nextRow == null) { + return true; + } + return nextRow.kind !== 'organized-item'; + default: + return false; + } +} + +interface OrganizedItemPathQuery { + readonly item: OrganizedItem; + readonly pathname: string; +} + +function guildNavigationItemContainsSelectedGuild({item, pathname}: OrganizedItemPathQuery): boolean { + if (item.type === 'guild') return isSelectedPath(pathname, Routes.guildChannel(item.guild.id)); + return item.guilds.some((guild) => isSelectedPath(pathname, Routes.guildChannel(guild.id))); +} + +function resolveSelectedGuildNavigationKey({ + organizedItems, + pathname, + visibleDMListRows, + visibility, +}: ResolveSelectedGuildNavigationKeyRequest): string | null { + for (const row of visibleDMListRows) { + if (isSelectedPath(pathname, Routes.dmChannel(row.channel.id))) return `dm:${row.channel.id}`; + } + for (const item of organizedItems) { + if (guildNavigationItemContainsSelectedGuild({item, pathname})) { + return `organized:${getOrganizedItemKey(item)}`; + } + } + if (visibility.favoritesVisible && pathname === Routes.FAVORITES) return 'favorites'; + if (visibility.discoveryVisible && Routes.isDiscoverRoute(pathname)) return 'discovery'; + if (visibility.fluxerVisible && pathname === Routes.ME) return 'fluxer'; + return null; +} + const getUnreadDMChannels = (dmChannels: ReadonlyArray): Array => { const out: Array = []; for (let i = 0; i < dmChannels.length; i++) { - if (ReadStates.hasUnread(dmChannels[i].id)) out.push(dmChannels[i]); + if (ReadStates.hasUnreadPrivateChannel(dmChannels[i].id)) out.push(dmChannels[i]); } return out; }; +function resolvePinnedCallChannel(directMessagesDisabled: boolean): Channel | null { + if (directMessagesDisabled) return null; + if (!MediaEngine.connected) return null; + const channelId = MediaEngine.channelId; + if (channelId == null || channelId === '') return null; + const channel = Channels.getChannel(channelId); + if (channel == null) return null; + if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) return null; + if (!CallState.hasActiveCall(channel.id)) return null; + return channel; +} + interface DMChannelVisibilityControllerState { readonly removalDelayMs: number; readonly removalTimers: React.MutableRefObject>; @@ -213,13 +518,296 @@ function getOrganizedItemKey(item: OrganizedItem): string { return item.guild.id; } -function getGuildListAnchorKeyForItem(item: OrganizedItem): string { - if (item.type === 'folder') { - return `folder-${item.folder.id}`; - } - return `guild-${item.guild.id}`; +interface GuildListTargetRow { + readonly rowKey: string; + readonly nestedIndex: number | null; } +interface CreateGuildTargetRowsRequest { + readonly organizedItems: ReadonlyArray; + readonly visibleDMListRows: ReadonlyArray; +} + +interface RegisterFolderTargetRowsRequest { + readonly folder: GuildFolder; + readonly folderGuilds: ReadonlyArray<{id: string}>; + readonly rowKey: string; + readonly targetRows: Map; +} + +interface ResolveGuildTargetBoundsRequest { + readonly getRowBounds: (keys: ReadonlyArray) => ReadonlyMap; + readonly scrollTargetRegistry: FloatingUnreadTargetRegistry; + readonly scrollNode: HTMLElement | null; + readonly targetRows: ReadonlyMap; + readonly targets: ReadonlyArray; +} + +interface ResolveMeasuredGuildTargetBoundsRequest { + readonly containerRect: DOMRect | null; + readonly node: HTMLElement | null; + readonly scrollNode: HTMLElement | null; +} + +interface ResolveEstimatedGuildTargetBoundsRequest { + readonly rowBounds: ReadonlyMap; + readonly targetRow: GuildListTargetRow; +} + +function registerGuildFolderTargetRows({ + folder, + folderGuilds, + rowKey, + targetRows, +}: RegisterFolderTargetRowsRequest): void { + const folderId = resolveGuildFolderId(folder.id); + if (!GuildFolderExpanded.isExpanded(folderId)) { + targetRows.set(`folder-${folder.id}`, {rowKey, nestedIndex: null}); + return; + } + for (let index = 0; index < folderGuilds.length; index++) { + targetRows.set(`guild-${folderGuilds[index].id}`, {rowKey, nestedIndex: index + 1}); + } +} + +function createGuildTargetRows({ + organizedItems, + visibleDMListRows, +}: CreateGuildTargetRowsRequest): ReadonlyMap { + const targetRows = new Map(); + for (const row of visibleDMListRows) { + targetRows.set(`dm-${row.channel.id}`, {rowKey: `dm:${row.channel.id}`, nestedIndex: null}); + } + for (const item of organizedItems) { + const rowKey = `organized:${getOrganizedItemKey(item)}`; + if (item.type === 'folder') { + registerGuildFolderTargetRows({ + folder: item.folder, + folderGuilds: item.guilds, + rowKey, + targetRows, + }); + continue; + } + targetRows.set(`guild-${item.guild.id}`, {rowKey, nestedIndex: null}); + } + return targetRows; +} + +function resolveMeasuredGuildTargetBounds({ + containerRect, + node, + scrollNode, +}: ResolveMeasuredGuildTargetBoundsRequest): NavigationRowBounds | null { + if (scrollNode == null) return null; + if (containerRect == null) return null; + if (node == null) return null; + if (!node.isConnected) return null; + if (!scrollNode.contains(node)) return null; + const rect = node.getBoundingClientRect(); + return { + top: scrollNode.scrollTop + rect.top - containerRect.top, + bottom: scrollNode.scrollTop + rect.bottom - containerRect.top, + }; +} + +function resolveEstimatedGuildTargetBounds({ + rowBounds, + targetRow, +}: ResolveEstimatedGuildTargetBoundsRequest): NavigationRowBounds | null { + const bounds = rowBounds.get(targetRow.rowKey); + if (bounds == null) return null; + if (targetRow.nestedIndex == null) return bounds; + const top = Math.min(bounds.bottom, bounds.top + targetRow.nestedIndex * ESTIMATED_GUILD_ROW_HEIGHT); + return {top, bottom: Math.min(bounds.bottom, top + ESTIMATED_GUILD_ROW_HEIGHT)}; +} + +function resolveGuildTargetBounds({ + getRowBounds, + scrollTargetRegistry, + scrollNode, + targetRows, + targets, +}: ResolveGuildTargetBoundsRequest): ReadonlyMap { + const targetBounds = new Map(); + if (targets.length === 0) return targetBounds; + if (scrollNode == null) return targetBounds; + const containerRect = scrollNode.getBoundingClientRect(); + const unmountedTargetRows: Array = []; + const unmountedTargetIds: Array = []; + for (const target of targets) { + const node = scrollTargetRegistry.getTargetNode(target.id); + const measuredBounds = resolveMeasuredGuildTargetBounds({containerRect, node, scrollNode}); + if (measuredBounds != null) { + targetBounds.set(target.id, measuredBounds); + continue; + } + const targetRow = targetRows.get(target.id); + if (targetRow == null) continue; + unmountedTargetRows.push(targetRow); + unmountedTargetIds.push(target.id); + } + if (unmountedTargetRows.length === 0) return targetBounds; + const rowKeys: Array = []; + for (const targetRow of unmountedTargetRows) { + rowKeys.push(targetRow.rowKey); + } + const rowBounds = getRowBounds(rowKeys); + for (let index = 0; index < unmountedTargetRows.length; index += 1) { + const estimatedBounds = resolveEstimatedGuildTargetBounds({rowBounds, targetRow: unmountedTargetRows[index]}); + if (estimatedBounds != null) targetBounds.set(unmountedTargetIds[index], estimatedBounds); + } + return targetBounds; +} + +function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverity | null { + const mentionCount = ReadStates.getPrivateChannelMentionCount(channelId); + const unreadState = getChannelUnreadState({ + unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId), + mentionCount, + isMuted: UserGuildSettings.isChannelMuted(null, channelId), + showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels, + }); + if (mentionCount > 0) return 'mention'; + if (unreadState.shouldShowUnreadIndicator) return 'unread'; + return null; +} + +function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null { + if (GuildReadState.getMentionCount(guildId) > 0) return 'mention'; + if (GuildReadState.hasUnread(guildId)) return 'unread'; + return null; +} + +function getFolderScrollIndicatorSeverity(guilds: ReadonlyArray<{id: string}>): ScrollIndicatorSeverity | null { + let hasUnread = false; + for (const guild of guilds) { + const severity = getGuildScrollIndicatorSeverity(guild.id); + if (severity === 'mention') return 'mention'; + if (severity === 'unread') hasUnread = true; + } + if (hasUnread) return 'unread'; + return null; +} + +function pushScrollIndicatorTarget( + targets: Array, + id: string, + severity: ScrollIndicatorSeverity | null, +): void { + if (severity == null) return; + targets.push({id, severity}); +} + +interface AppendFolderScrollIndicatorTargetsRequest { + readonly folder: GuildFolder; + readonly folderGuilds: ReadonlyArray<{id: string}>; + readonly targets: Array; +} + +function appendFolderScrollIndicatorTargets({ + folder, + folderGuilds, + targets, +}: AppendFolderScrollIndicatorTargetsRequest): void { + const folderId = resolveGuildFolderId(folder.id); + if (!GuildFolderExpanded.isExpanded(folderId)) { + pushScrollIndicatorTarget(targets, `folder-${folder.id}`, getFolderScrollIndicatorSeverity(folderGuilds)); + return; + } + for (const guild of folderGuilds) { + pushScrollIndicatorTarget(targets, `guild-${guild.id}`, getGuildScrollIndicatorSeverity(guild.id)); + } +} + +interface BuildGuildScrollIndicatorTargetsRequest { + readonly dmListRows: ReadonlyArray; + readonly organizedItems: ReadonlyArray; +} + +function buildGuildScrollIndicatorTargets({ + dmListRows, + organizedItems, +}: BuildGuildScrollIndicatorTargetsRequest): Array { + const targets: Array = []; + for (const row of dmListRows) { + pushScrollIndicatorTarget(targets, `dm-${row.channel.id}`, getDMScrollIndicatorSeverity(row.channel.id)); + } + for (const item of organizedItems) { + if (item.type === 'folder') { + appendFolderScrollIndicatorTargets({folder: item.folder, folderGuilds: item.guilds, targets}); + continue; + } + pushScrollIndicatorTarget(targets, `guild-${item.guild.id}`, getGuildScrollIndicatorSeverity(item.guild.id)); + } + return targets; +} + +function areScrollIndicatorTargetsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (left[index].id !== right[index].id) return false; + if (left[index].severity !== right[index].severity) return false; + } + return true; +} + +function useStableScrollIndicatorTargets( + targets: ReadonlyArray, +): ReadonlyArray { + const stableTargetsRef = useRef(targets); + const stableTargets = stableTargetsRef.current; + if (stableTargets !== targets && areScrollIndicatorTargetsEqual(stableTargets, targets)) { + return stableTargets; + } + stableTargetsRef.current = targets; + return targets; +} + +interface GuildScrollIndicatorsProps { + readonly dmListRows: ReadonlyArray; + readonly getTargetBounds: ( + targets: ReadonlyArray, + ) => ReadonlyMap; + readonly getTargetNode: (id: string) => HTMLElement | null; + readonly label: string; + readonly measurementRevision: number; + readonly organizedItems: ReadonlyArray; + readonly scrollToTarget: (id: string, direction: VerticalEdge) => boolean; + readonly scrollerRef: React.RefObject; +} + +const GuildScrollIndicators = observer( + ({ + dmListRows, + getTargetBounds, + getTargetNode, + label, + measurementRevision, + organizedItems, + scrollToTarget, + scrollerRef, + }: GuildScrollIndicatorsProps) => { + const targets = useStableScrollIndicatorTargets(buildGuildScrollIndicatorTargets({dmListRows, organizedItems})); + return ( + + ); + }, +); + function buildGuildNavigationIndexMap(organizedItems: ReadonlyArray): Map { const guildNavigationIndexes = new Map(); let guildIndex = 0; @@ -238,13 +826,13 @@ function buildGuildNavigationIndexMap(organizedItems: ReadonlyArray, -): number | undefined { +): number | null { for (const [guildId, guildIndex] of guildNavigationIndexes) { if (isSelectedPath(pathname, Routes.guildChannel(guildId))) { return guildIndex; } } - return undefined; + return null; } function getWheelScrollDeltaY(event: WheelEvent | React.WheelEvent, viewportHeight: number): number { @@ -381,24 +969,230 @@ function removeGuildIdsFromGuildFolders( .filter((folder) => folder.guildIds.length > 0); } +function isRelativeGuildDropPosition(position: GuildDropPosition): boolean { + return position === RelativePosition.BEFORE || position === RelativePosition.AFTER; +} + +interface ResolveGuildInsertIndexRequest { + readonly position: GuildDropPosition; + readonly targetIndex: number; +} + +function resolveGuildInsertIndex({position, targetIndex}: ResolveGuildInsertIndexRequest): number { + if (position === RelativePosition.AFTER) return targetIndex + 1; + return targetIndex; +} + +interface MoveGuildIntoFolderRequest { + readonly sourceGuildId: string; + readonly targetKey: string; +} + +function moveGuildIntoFolder({sourceGuildId, targetKey}: MoveGuildIntoFolderRequest): Array { + const targetFolderId = getFolderIdFromKey(targetKey); + return removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])).map((folder) => { + if (folder.id !== targetFolderId) return folder; + return {...folder, guildIds: [...folder.guildIds, sourceGuildId]}; + }); +} + +interface MoveGuildWithinFolderRequest { + readonly position: GuildDropPosition; + readonly sourceGuildId: string; + readonly targetFolderId: number; + readonly targetGuildId: string; +} + +function moveGuildWithinFolder({ + position, + sourceGuildId, + targetFolderId, + targetGuildId, +}: MoveGuildWithinFolderRequest): Array { + return removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])).map((folder) => { + if (folder.id !== targetFolderId) return folder; + const guildIds = [...folder.guildIds]; + const targetIndex = guildIds.indexOf(targetGuildId); + if (targetIndex === -1) { + guildIds.push(sourceGuildId); + return {...folder, guildIds}; + } + guildIds.splice(resolveGuildInsertIndex({position, targetIndex}), 0, sourceGuildId); + return {...folder, guildIds}; + }); +} + +interface CombineGuildsRequest { + readonly sourceGuildId: string; + readonly targetGuildId: string; +} + +function combineGuildsIntoFolder({sourceGuildId, targetGuildId}: CombineGuildsRequest): Array | null { + const cleanedFolders = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); + const topLevelItems = buildTopLevelItems(cleanedFolders); + const targetIndex = topLevelItems.findIndex( + (topLevelItem) => topLevelItem.type === 'guild' && topLevelItem.guildId === targetGuildId, + ); + if (targetIndex === -1) return null; + const folder: GuildFolder = { + id: getNextFolderId(UserSettings.guildFolders), + name: null, + color: null, + flags: 0, + icon: DEFAULT_GUILD_FOLDER_ICON, + guildIds: [targetGuildId, sourceGuildId], + }; + topLevelItems[targetIndex] = {type: 'folder', folder}; + return buildGuildFoldersFromTopLevelItems(topLevelItems); +} + +interface MoveGuildBesideFolderRequest { + readonly position: GuildDropPosition; + readonly sourceGuildId: string; + readonly targetKey: string; +} + +function moveGuildBesideFolder({ + position, + sourceGuildId, + targetKey, +}: MoveGuildBesideFolderRequest): Array | null { + const targetFolderId = getFolderIdFromKey(targetKey); + const originalItems = buildTopLevelItems(UserSettings.guildFolders); + const originalTargetIndex = originalItems.findIndex( + (item) => item.type === 'folder' && item.folder.id === targetFolderId, + ); + if (originalTargetIndex === -1) return null; + const cleanedFolders = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); + const topLevelItems = buildTopLevelItems(cleanedFolders); + let targetIndex = topLevelItems.findIndex((item) => item.type === 'folder' && item.folder.id === targetFolderId); + if (targetIndex === -1) targetIndex = Math.min(originalTargetIndex, topLevelItems.length); + const insertIndex = resolveGuildInsertIndex({position, targetIndex}); + topLevelItems.splice(Math.min(insertIndex, topLevelItems.length), 0, {type: 'guild', guildId: sourceGuildId}); + return buildGuildFoldersFromTopLevelItems(topLevelItems); +} + +interface MoveGuildBesideGuildRequest { + readonly position: GuildDropPosition; + readonly sourceGuildId: string; + readonly targetGuildId: string; +} + +function moveGuildBesideGuild({ + position, + sourceGuildId, + targetGuildId, +}: MoveGuildBesideGuildRequest): Array | null { + const cleanedFolders = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); + const topLevelItems = buildTopLevelItems(cleanedFolders); + const targetIndex = topLevelItems.findIndex((item) => item.type === 'guild' && item.guildId === targetGuildId); + if (targetIndex === -1) return null; + topLevelItems.splice(resolveGuildInsertIndex({position, targetIndex}), 0, {type: 'guild', guildId: sourceGuildId}); + return buildGuildFoldersFromTopLevelItems(topLevelItems); +} + +interface ReorderOrganizedGuildItemRequest { + readonly organizedItems: ReadonlyArray; + readonly position: GuildDropPosition; + readonly sourceKey: string; + readonly targetKey: string; +} + +function reorderOrganizedGuildItem({ + organizedItems, + position, + sourceKey, + targetKey, +}: ReorderOrganizedGuildItemRequest): Array | null { + const oldIndex = organizedItems.findIndex((item) => getOrganizedItemKey(item) === sourceKey); + const targetIndex = organizedItems.findIndex((item) => getOrganizedItemKey(item) === targetKey); + if (oldIndex === -1 || targetIndex === -1) return null; + let targetIndexAfterRemoval = targetIndex; + if (oldIndex < targetIndex) targetIndexAfterRemoval = targetIndex - 1; + const newIndex = resolveGuildInsertIndex({position, targetIndex: targetIndexAfterRemoval}); + const reorderedItems = [...organizedItems]; + const [movedItem] = reorderedItems.splice(oldIndex, 1); + reorderedItems.splice(newIndex, 0, movedItem); + const topLevelItems: Array = reorderedItems.map((item) => { + if (item.type === 'folder') { + return {type: 'folder', folder: cloneGuildFolder(item.folder)}; + } + return {type: 'guild', guildId: item.guild.id}; + }); + return buildGuildFoldersFromTopLevelItems(topLevelItems); +} + +interface ApplyGuildDropRequest { + readonly item: GuildDragItem; + readonly organizedItems: ReadonlyArray; + readonly result: GuildDropResult; +} + +function applyGuildFolderDrop({item, organizedItems, result}: ApplyGuildDropRequest): Array | null { + const sourceKey = item.id; + const targetKey = result.targetId; + if (sourceKey === targetKey) return null; + const {position, targetIsFolder, targetFolderId} = result; + if (position === DropPlacement.INSIDE && targetIsFolder && !item.isFolder) { + return moveGuildIntoFolder({sourceGuildId: item.id, targetKey}); + } + if ( + targetFolderId != null && + targetFolderId !== UNCATEGORIZED_FOLDER_ID && + !item.isFolder && + isRelativeGuildDropPosition(position) + ) { + return moveGuildWithinFolder({ + position, + sourceGuildId: item.id, + targetFolderId, + targetGuildId: targetKey, + }); + } + if (position === DropPlacement.COMBINE && !targetIsFolder && !item.isFolder && targetFolderId == null) { + return combineGuildsIntoFolder({sourceGuildId: item.id, targetGuildId: targetKey}); + } + if ( + !item.isFolder && + item.folderId != null && + item.folderId !== UNCATEGORIZED_FOLDER_ID && + targetIsFolder && + isRelativeGuildDropPosition(position) + ) { + return moveGuildBesideFolder({position, sourceGuildId: item.id, targetKey}); + } + if ( + !item.isFolder && + item.folderId != null && + item.folderId !== UNCATEGORIZED_FOLDER_ID && + !targetIsFolder && + targetFolderId == null && + isRelativeGuildDropPosition(position) + ) { + return moveGuildBesideGuild({position, sourceGuildId: item.id, targetGuildId: targetKey}); + } + return reorderOrganizedGuildItem({organizedItems, position, sourceKey, targetKey}); +} + interface BottomDropZoneProps { - onGuildDrop: (item: GuildDragItem, result: GuildDropResult) => void; - lastItemKey: string; - lastItemIsFolder: boolean; - isDragging: boolean; + readonly onGuildDrop: (item: GuildDragItem, result: GuildDropResult) => void; + readonly lastItemKey: string; + readonly lastItemIsFolder: boolean; + readonly isDragging: boolean; } function BottomDropZone({onGuildDrop, lastItemKey, lastItemIsFolder, isDragging}: BottomDropZoneProps) { const [{isOver, canDrop}, dropRef] = useDrop( () => ({ - accept: [DND_TYPES.GUILD_ITEM, DND_TYPES.GUILD_FOLDER], + accept: [DragItemType.GUILD_ITEM, DragItemType.GUILD_FOLDER], canDrop: (item: GuildDragItem) => item.id !== lastItemKey, drop: (item: GuildDragItem, monitor): GuildDropResult | undefined => { if (!monitor.canDrop()) return; const result: GuildDropResult = { targetId: lastItemKey, - position: 'after', + position: RelativePosition.AFTER, targetIsFolder: lastItemIsFolder, + targetFolderId: null, }; onGuildDrop(item, result); return result; @@ -412,16 +1206,16 @@ function BottomDropZone({onGuildDrop, lastItemKey, lastItemIsFolder, isDragging} ); const isActive = isOver && canDrop; const setRef = useCallback( - (node: HTMLDivElement | null) => { + (node: HTMLElement | null) => { dropRef(node); }, [dropRef], ); if (!isDragging) return null; return ( -
+ ); + if (nextRowExtended) { + return ( + + {divider} + + ); + } + return ( + + {divider} + + ); +} + +interface SkeletonNagbarRowShape { + readonly tone: SkeletonNagbarTone; + readonly hasActions: boolean; +} + +const SKELETON_NAGBAR_ROW_SHAPES: Record = { + [NagbarType.BUILD_ENVIRONMENT]: {tone: SkeletonNagbarTone.DEVELOPMENT, hasActions: false}, + [NagbarType.CONNECTION]: {tone: SkeletonNagbarTone.NEUTRAL, hasActions: false}, + [NagbarType.CORRUPTED_INSTALLATION]: {tone: SkeletonNagbarTone.CRITICAL, hasActions: true}, + [NagbarType.SCHEDULED_MAINTENANCE]: {tone: SkeletonNagbarTone.MAINTENANCE_SCHEDULED, hasActions: true}, + [NagbarType.UNCLAIMED_ACCOUNT]: {tone: SkeletonNagbarTone.ALERT, hasActions: true}, + [NagbarType.EMAIL_VERIFICATION]: {tone: SkeletonNagbarTone.ALERT, hasActions: true}, + [NagbarType.DESKTOP_NOTIFICATION]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true}, + [NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true}, + [NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true}, + [NagbarType.TERMS_ACCEPTANCE]: {tone: SkeletonNagbarTone.LEGAL, hasActions: true}, + [NagbarType.LINUX_INPUT_ACCESS]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true}, + [NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true}, +}; + +const CONNECTION_SKELETON_NAGBAR_TONES: Record = { + [ConnectionNoticeTone.NEUTRAL]: SkeletonNagbarTone.NEUTRAL, + [ConnectionNoticeTone.MAINTENANCE]: SkeletonNagbarTone.MAINTENANCE, +}; + +function createSkeletonNagbarRow(nagbar: NagbarState): RememberedSkeletonNagbarRow { + const shape = SKELETON_NAGBAR_ROW_SHAPES[nagbar.type]; + if (nagbar.type === NagbarType.CONNECTION) { + const notice = resolveConnectionNoticeShape(); + return { + tone: CONNECTION_SKELETON_NAGBAR_TONES[notice.tone], + hasActions: notice.hasActions, + dismissible: nagbar.dismissible, + }; + } + if (nagbar.type === NagbarType.SCHEDULED_MAINTENANCE) { + const scheduledMaintenance = StatusPage.scheduledMaintenance; + if (scheduledMaintenance != null) { + return { + tone: MAINTENANCE_NAGBAR_TONE_KINDS[scheduledMaintenance.status], + hasActions: shape.hasActions, + dismissible: nagbar.dismissible, + }; + } + } + return {tone: shape.tone, hasActions: shape.hasActions, dismissible: nagbar.dismissible}; +} + +const GUILD_RAIL_INDICATOR_TOKENS: Record = { + [SkeletonGuildRailItemIndicator.NONE]: 'n', + [SkeletonGuildRailItemIndicator.UNREAD]: 'u', + [SkeletonGuildRailItemIndicator.MENTION]: 'm', +}; +const GUILD_RAIL_INDICATORS_BY_TOKEN: Record = { + n: SkeletonGuildRailItemIndicator.NONE, + u: SkeletonGuildRailItemIndicator.UNREAD, + m: SkeletonGuildRailItemIndicator.MENTION, +}; + +function resolveGuildRailIndicator(guildIds: ReadonlyArray): SkeletonGuildRailItemIndicator { + let hasUnread = false; + for (const guildId of guildIds) { + if (GuildReadState.getMentionCount(guildId) > 0) { + return SkeletonGuildRailItemIndicator.MENTION; + } + if (GuildReadState.hasUnread(guildId)) { + hasUnread = true; + } + } + if (hasUnread) { + return SkeletonGuildRailItemIndicator.UNREAD; + } + return SkeletonGuildRailItemIndicator.NONE; +} + +function parseGuildRailIndicatorToken(token: string): SkeletonGuildRailItemIndicator { + const indicator = GUILD_RAIL_INDICATORS_BY_TOKEN[token]; + if (indicator == null) { + throw new Error(`Invalid guild rail indicator token: ${token}`); + } + return indicator; +} + +function createGuildRailItemProjection(organizedItems: ReadonlyArray, pathname: string): string { + const expandedFolderIds = new Set(GuildFolderExpanded.expandedFolderIds); + let projection = ''; + let remainingVisualRows = SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT; + for (const item of organizedItems) { + if (remainingVisualRows === 0) { + break; + } + if (item.type !== 'folder') { + projection += `g${GUILD_RAIL_INDICATOR_TOKENS[resolveGuildRailIndicator([item.guild.id])]};`; + remainingVisualRows -= 1; + continue; + } + const folderId = resolveGuildFolderId(item.folder.id); + const isExpanded = expandedFolderIds.has(folderId); + if (!isExpanded) { + const childCount = Math.min(item.guilds.length, SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT); + const showIconWhenCollapsed = + (item.folder.flags & GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED) === GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED; + const indicatorToken = + GUILD_RAIL_INDICATOR_TOKENS[resolveGuildRailIndicator(item.guilds.map((guild) => guild.id))]; + projection += `c${childCount}${showIconWhenCollapsed ? '1' : '0'}${indicatorToken};`; + remainingVisualRows -= 1; + continue; + } + const childCount = Math.min(item.guilds.length, remainingVisualRows - 1); + let childTokens = ''; + for (const guild of item.guilds.slice(0, childCount)) { + const childToken = GUILD_RAIL_INDICATOR_TOKENS[resolveGuildRailIndicator([guild.id])]; + if (isSelectedPath(pathname, Routes.guildChannel(guild.id))) { + childTokens += childToken.toUpperCase(); + } else { + childTokens += childToken; + } + } + projection += `e${childTokens};`; + remainingVisualRows -= childCount + 1; + } + return projection; +} + +interface CreateGuildRailSkeletonLayoutRequest { + readonly guildRailItemProjection: string; + readonly hasUnavailableGuilds: boolean; + readonly inlineDmChannelIds: ReadonlyArray; + readonly pathname: string; + readonly selectedOrganizedItemIndex: number; + readonly visibility: GuildNavigationVisibility; +} + +function createGuildRailSkeletonLayout({ + guildRailItemProjection, + hasUnavailableGuilds, + inlineDmChannelIds, + pathname, + selectedOrganizedItemIndex, + visibility, +}: CreateGuildRailSkeletonLayoutRequest): Omit { + const projectedItems: Array = []; + for (const token of guildRailItemProjection.split(';')) { + if (token === '') { + continue; + } + if (token.startsWith('g')) { + if (token.length !== 2) { + throw new Error(`Invalid guild projection token: ${token}`); + } + projectedItems.push( + Object.freeze({ + kind: SkeletonGuildRailItemKind.GUILD, + indicator: parseGuildRailIndicatorToken(token.slice(1)), + }), + ); + continue; + } + if (token.startsWith('c')) { + const childCount = Number(token.slice(1, 2)); + const showIconWhenCollapsed = token.slice(2, 3) === '1'; + if ( + token.length !== 4 || + !Number.isSafeInteger(childCount) || + childCount < 0 || + childCount > SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT || + (token.slice(2, 3) !== '0' && token.slice(2, 3) !== '1') + ) { + throw new Error(`Invalid collapsed guild folder projection token: ${token}`); + } + projectedItems.push( + Object.freeze({ + kind: SkeletonGuildRailItemKind.COLLAPSED_FOLDER, + indicator: parseGuildRailIndicatorToken(token.slice(3)), + childCount, + showIconWhenCollapsed, + }), + ); + continue; + } + if (!token.startsWith('e')) { + throw new Error(`Invalid guild rail item projection token: ${token}`); + } + const childTokens = token.slice(1); + if (childTokens.length > SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT - 1) { + throw new Error(`Invalid expanded guild folder projection token: ${token}`); + } + const childIndicators: Array = []; + let selectedChildIndex = SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + for (let childIndex = 0; childIndex < childTokens.length; childIndex += 1) { + const childToken = childTokens[childIndex]; + if (childToken !== childToken.toLowerCase()) { + selectedChildIndex = childIndex; + } + childIndicators.push(parseGuildRailIndicatorToken(childToken.toLowerCase())); + } + projectedItems.push( + Object.freeze({ + kind: SkeletonGuildRailItemKind.EXPANDED_FOLDER, + indicator: SkeletonGuildRailItemIndicator.NONE, + childCount: childTokens.length, + childIndicators: Object.freeze(childIndicators), + selectedChildIndex, + }), + ); + } + let selectedItemIndex = SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + if (selectedOrganizedItemIndex >= 0 && selectedOrganizedItemIndex < projectedItems.length) { + selectedItemIndex = selectedOrganizedItemIndex; + } + return Object.freeze({ + inlineDmRowCount: inlineDmChannelIds.length, + inlineDmUnreadFlags: Object.freeze( + inlineDmChannelIds.map((channelId) => resolveDMListItemUnreadState(channelId).shouldShowUnreadIndicator), + ), + selectedInlineDmRowIndex: inlineDmChannelIds.findIndex((channelId) => + isSelectedPath(pathname, Routes.dmChannel(channelId)), + ), + outageVisible: hasUnavailableGuilds, + fluxerVisible: visibility.fluxerVisible, + favoritesVisible: visibility.favoritesVisible, + discoveryVisible: visibility.discoveryVisible, + addGuildVisible: visibility.addGuildVisible, + downloadVisible: visibility.downloadVisible, + helpVisible: visibility.helpVisible, + selectedItemIndex, + organizedItems: Object.freeze(projectedItems), + }); +} + const GuildList = observer(() => { const {i18n} = useLingui(); - const [isDragging, setIsDragging] = useState(false); - const guilds = GuildListState.guilds; + const [activeDragItem, setActiveDragItem] = useState(null); + const isDragging = activeDragItem != null; + const guildNavigationVisibility = useGuildNavigationVisibility(); const organizedItems = GuildListState.getOrganizedGuildList(); const guildNavigationIndexes = useMemo(() => buildGuildNavigationIndexMap(organizedItems), [organizedItems]); - const unavailableGuilds = GuildAvailability.unavailableGuilds; - const unavailableCount = unavailableGuilds.size; - const readVersion = ReadStates.version; + const unavailableCount = GuildAvailability.totalUnavailableGuilds; + const privateReadStateVersion = ReadStates.privateChannelVersion; const dmChannels = Channels.dmChannels; - const unreadDMChannels = useMemo(() => getUnreadDMChannels(dmChannels), [dmChannels, readVersion]); + const unreadDMChannels = useMemo(() => getUnreadDMChannels(dmChannels), [dmChannels, privateReadStateVersion]); const unreadDMChannelIdSet = useMemo(() => { const ids = new Set(); for (let index = 0; index < unreadDMChannels.length; index++) { @@ -452,32 +1515,29 @@ const GuildList = observer(() => { }, [unreadDMChannels]); const unreadDMChannelIdSetRef = useRef>(unreadDMChannelIdSet); unreadDMChannelIdSetRef.current = unreadDMChannelIdSet; - const unreadDMChannelIds = useMemo(() => unreadDMChannels.map(getChannelId).join(','), [unreadDMChannels]); const [hoveredInlineDMChannelId, setHoveredInlineDMChannelId] = useState(null); const orderedUnreadDMChannels = useHoverDeferredOrderedItems({ items: unreadDMChannels, getKey: getChannelId, isHoveringDynamicItem: hoveredInlineDMChannelId != null, - releaseToken: readVersion, + releaseToken: privateReadStateVersion, }); - const scrollRef = useRef(null); + const scrollRef = useRef(null); const pendingScrollTopRef = useRef(null); - const scrollPersistRafRef = useRef(null); + const armedScrollTopRef = useRef(null); + const scrollPersistTimerRef = useRef(null); const location = useLocation(); const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled; const [visibleUnavailableCount, setVisibleUnavailableCount] = useState(unavailableCount); const unavailableIndicatorHideTimer = useRef(null); const hasUnavailableGuilds = visibleUnavailableCount > 0; - const guildReadVersion = GuildReadState.version; - const guildIndicatorDependencies = useMemo( - () => [guilds.length, guildReadVersion, readVersion, unreadDMChannelIds], - [guilds.length, guildReadVersion, readVersion, unreadDMChannelIds], - ); - const getGuildScrollContainer = useCallback(() => scrollRef.current?.getScrollerNode() ?? null, []); + const scrollTargetRegistry = useFloatingUnreadTargetRegistry(); + const scrollToMountedTargetFrameRef = useRef(null); + const getGuildScrollContainer = useCallback(() => resolveGuildListScrollerNode(scrollRef.current), []); useDragAutoScroll({active: isDragging, getScrollElement: getGuildScrollContainer}); const scrollGuildListByWheel = useCallback((event: WheelEvent | React.WheelEvent) => { - const scrollNode = scrollRef.current?.getScrollerNode(); - if (!scrollNode) return false; + const scrollNode = resolveGuildListScrollerNode(scrollRef.current); + if (scrollNode == null) return false; const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); if (maxScrollTop === 0) return false; const deltaY = getWheelScrollDeltaY(event, scrollNode.clientHeight); @@ -490,20 +1550,7 @@ const GuildList = observer(() => { }, []); const [visibleDMChannels, setVisibleDMChannels] = useState>(orderedUnreadDMChannels); const directMessagesDisabled = RuntimeConfig.directMessagesDisabled; - let pinnedCallChannel: Channel | null = null; - if (!directMessagesDisabled && MediaEngine.connected) { - const engineChannelId = MediaEngine.channelId; - if (engineChannelId) { - const candidate = Channels.getChannel(engineChannelId); - if ( - candidate && - (candidate.type === ChannelTypes.DM || candidate.type === ChannelTypes.GROUP_DM) && - CallState.hasActiveCall(candidate.id) - ) { - pinnedCallChannel = candidate; - } - } - } + const pinnedCallChannel = resolvePinnedCallChannel(directMessagesDisabled); const inlineDmsCollapsed = SidebarPreferences.inlineDmsCollapsed; let baseDMChannels: ReadonlyArray; if (inlineDmsCollapsed || directMessagesDisabled) { @@ -534,27 +1581,52 @@ const GuildList = observer(() => { return rows; }, [filteredDMChannels, pinnedCallChannel, unreadDMChannelIdSet]); const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows); - const { - getAnchorRef: getGuildListAnchorRef, - handleResize: handleGuildListAnchorResize, - handleScroll: handleGuildListAnchorScroll, - stabilize: stabilizeGuildListAnchor, - } = useListScrollAnchor({scrollerRef: scrollRef}); - const hasVisibleDMChannels = targetDMListRows.length > 0 || visibleDMListRows.length > 0; - const shouldShowTopDivider = (guilds.length > 0 || hasUnavailableGuilds) && !hasVisibleDMChannels; - const shouldShowEmptyStateDivider = !hasVisibleDMChannels && !hasUnavailableGuilds && guilds.length === 0; const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0; - useLayoutEffect(() => { - stabilizeGuildListAnchor(); - }, [ - hasUnavailableGuilds, - hasVisibleDMChannels, - organizedItems, - shouldRenderGuildListItems, - stabilizeGuildListAnchor, - visibleDMListRows, - visibleUnavailableCount, - ]); + const initialGuildListScrollTop = useMemo(() => { + const liveScrollTop = untracked(() => Dimension.getGuildListDimensions().scrollTop); + if (liveScrollTop > 0) { + return liveScrollTop; + } + return Math.round(getRememberedSkeletonGuildRailScrollTopPx() * getRemScaleForDocument(document)); + }, []); + const skeletonGuildRailChrome = useMemo( + () => ({ + hasUnavailableGuilds, + inlineDmChannelIds: visibleDMListRows.map((row) => row.channel.id), + pathname: location.pathname, + visibility: guildNavigationVisibility, + }), + [hasUnavailableGuilds, visibleDMListRows, location.pathname, guildNavigationVisibility], + ); + useEffect(() => { + let reportedProjection: string | null = null; + const disposeSkeletonProjection = autorun(() => { + const projectedOrganizedItems = GuildListState.getOrganizedGuildList(); + const guildRailItemProjection = createGuildRailItemProjection( + projectedOrganizedItems, + skeletonGuildRailChrome.pathname, + ); + const skeletonGuildRailLayout = createGuildRailSkeletonLayout({ + guildRailItemProjection, + hasUnavailableGuilds: skeletonGuildRailChrome.hasUnavailableGuilds, + inlineDmChannelIds: skeletonGuildRailChrome.inlineDmChannelIds, + pathname: skeletonGuildRailChrome.pathname, + selectedOrganizedItemIndex: projectedOrganizedItems.findIndex((item) => + guildNavigationItemContainsSelectedGuild({item, pathname: skeletonGuildRailChrome.pathname}), + ), + visibility: skeletonGuildRailChrome.visibility, + }); + const projection = [ + guildRailItemProjection, + skeletonGuildRailLayout.inlineDmUnreadFlags.join(''), + skeletonGuildRailLayout.selectedInlineDmRowIndex, + ].join('|'); + if (projection === reportedProjection) return; + reportedProjection = projection; + untracked(() => reportSkeletonGuildRailLayout(skeletonGuildRailLayout)); + }); + return () => disposeSkeletonProjection(); + }, [skeletonGuildRailChrome]); const selectedGuildIndex = useMemo( () => getSelectedGuildNavigationIndex(location.pathname, guildNavigationIndexes), [location.pathname, guildNavigationIndexes], @@ -575,9 +1647,9 @@ const GuildList = observer(() => { const controller = dmVisibilityControllerRef.current; if (controller != null) controller.completeRemoval(channelId); }, []); - const guildListNavigationRef = useRovingFocusList({ + const guildListNavigationRef = useRovingFocusList({ focusableSelector: GUILD_LIST_FOCUSABLE_SELECTOR, - orientation: 'vertical', + orientation: AxisOrientation.VERTICAL, loop: true, enabled: keyboardModeEnabled, restoreFocusOnWindowFocus: false, @@ -628,165 +1700,110 @@ const GuildList = observer(() => { }, []); const handleGuildDrop = useCallback( (item: GuildDragItem, result: GuildDropResult) => { - const sourceKey = item.id; - const targetKey = result.targetId; - if (sourceKey === targetKey) return; - const {position, targetIsFolder, targetFolderId} = result; - if (position === 'inside' && targetIsFolder && !item.isFolder) { - const sourceGuildId = item.id; - const targetFolderId = getFolderIdFromKey(targetKey); - const cleaned = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); - const newGuildFolders = cleaned.map((folder) => { - if (folder.id !== targetFolderId) return folder; - return { - id: folder.id, - name: folder.name, - color: folder.color, - flags: folder.flags, - icon: folder.icon, - guildIds: [...folder.guildIds, sourceGuildId], - }; - }); - UserSettingsCommands.update({guildFolders: newGuildFolders}); - return; - } - if ( - targetFolderId != null && - targetFolderId !== UNCATEGORIZED_FOLDER_ID && - !item.isFolder && - (position === 'before' || position === 'after') - ) { - const sourceGuildId = item.id; - const targetGuildId = targetKey; - const cleaned = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); - const newGuildFolders = cleaned.map((folder) => { - if (folder.id !== targetFolderId) return folder; - const guildIds = [...folder.guildIds]; - const targetIdx = guildIds.indexOf(targetGuildId); - if (targetIdx !== -1) { - const insertIdx = position === 'after' ? targetIdx + 1 : targetIdx; - guildIds.splice(insertIdx, 0, sourceGuildId); - } else { - guildIds.push(sourceGuildId); - } - return { - id: folder.id, - name: folder.name, - color: folder.color, - flags: folder.flags, - icon: folder.icon, - guildIds, - }; - }); - UserSettingsCommands.update({guildFolders: newGuildFolders}); - return; - } - if (position === 'combine' && !targetIsFolder && !item.isFolder && result.targetFolderId == null) { - const sourceGuildId = item.id; - const targetGuildId = targetKey; - const cleaned = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); - const topLevelItems = buildTopLevelItems(cleaned); - const targetIdx = topLevelItems.findIndex((tli) => tli.type === 'guild' && tli.guildId === targetGuildId); - if (targetIdx === -1) return; - const newFolder: GuildFolder = { - id: getNextFolderId(UserSettings.guildFolders), - name: null, - color: null, - flags: 0, - icon: DEFAULT_GUILD_FOLDER_ICON, - guildIds: [targetGuildId, sourceGuildId], - }; - topLevelItems[targetIdx] = {type: 'folder', folder: newFolder}; - const newGuildFolders = buildGuildFoldersFromTopLevelItems(topLevelItems); - UserSettingsCommands.update({guildFolders: newGuildFolders}); - return; - } - if ( - !item.isFolder && - item.folderId != null && - item.folderId !== UNCATEGORIZED_FOLDER_ID && - targetIsFolder && - (position === 'before' || position === 'after') - ) { - const sourceGuildId = item.id; - const parsedTargetFolderId = getFolderIdFromKey(targetKey); - const originalTopLevelItems = buildTopLevelItems(UserSettings.guildFolders); - const originalTargetIdx = originalTopLevelItems.findIndex( - (tli) => tli.type === 'folder' && tli.folder.id === parsedTargetFolderId, - ); - if (originalTargetIdx === -1) return; - const cleaned = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); - const topLevelItems = buildTopLevelItems(cleaned); - let targetIdx = topLevelItems.findIndex( - (tli) => tli.type === 'folder' && tli.folder.id === parsedTargetFolderId, - ); - if (targetIdx === -1) { - targetIdx = Math.min(originalTargetIdx, topLevelItems.length); - } - const newItem: TopLevelGuildItem = {type: 'guild', guildId: sourceGuildId}; - const insertIdx = position === 'after' ? targetIdx + 1 : targetIdx; - topLevelItems.splice(Math.min(insertIdx, topLevelItems.length), 0, newItem); - const newGuildFolders = buildGuildFoldersFromTopLevelItems(topLevelItems); - UserSettingsCommands.update({guildFolders: newGuildFolders}); - return; - } - if ( - !item.isFolder && - item.folderId != null && - item.folderId !== UNCATEGORIZED_FOLDER_ID && - !targetIsFolder && - result.targetFolderId == null && - (position === 'before' || position === 'after') - ) { - const sourceGuildId = item.id; - const targetGuildId = targetKey; - const cleaned = removeGuildIdsFromGuildFolders(UserSettings.guildFolders, new Set([sourceGuildId])); - const topLevelItems = buildTopLevelItems(cleaned); - const targetIdx = topLevelItems.findIndex((tli) => tli.type === 'guild' && tli.guildId === targetGuildId); - if (targetIdx === -1) return; - const newItem: TopLevelGuildItem = {type: 'guild', guildId: sourceGuildId}; - const insertIdx = position === 'after' ? targetIdx + 1 : targetIdx; - topLevelItems.splice(insertIdx, 0, newItem); - const newGuildFolders = buildGuildFoldersFromTopLevelItems(topLevelItems); - UserSettingsCommands.update({guildFolders: newGuildFolders}); - return; - } - const oldIndex = organizedItems.findIndex((i) => getOrganizedItemKey(i) === sourceKey); - const targetIndex = organizedItems.findIndex((i) => getOrganizedItemKey(i) === targetKey); - if (oldIndex === -1 || targetIndex === -1) return; - const targetIndexAfterRemoval = oldIndex < targetIndex ? targetIndex - 1 : targetIndex; - const newIndex = position === 'after' ? targetIndexAfterRemoval + 1 : targetIndexAfterRemoval; - const newOrganizedItems = [...organizedItems]; - const [movedItem] = newOrganizedItems.splice(oldIndex, 1); - newOrganizedItems.splice(newIndex, 0, movedItem); - const topLevelItems: Array = newOrganizedItems.map((oi) => { - if (oi.type === 'folder') { - return {type: 'folder' as const, folder: cloneGuildFolder(oi.folder)}; - } - return {type: 'guild' as const, guildId: oi.guild.id}; - }); - const newGuildFolders = buildGuildFoldersFromTopLevelItems(topLevelItems); - UserSettingsCommands.update({guildFolders: newGuildFolders}); + const guildFolders = applyGuildFolderDrop({item, organizedItems, result}); + if (guildFolders == null) return; + UserSettingsCommands.update({guildFolders}); }, [organizedItems], ); const handleDragStateChange = useCallback((item: GuildDragItem | null) => { - setIsDragging(item !== null); + setActiveDragItem(item); }, []); + const guildNavigationRows = useMemo( + () => + createGuildNavigationRows({ + hasUnavailableGuilds, + isDragging, + organizedItems, + shouldRenderGuildListItems, + visibleDMListRows, + visibility: guildNavigationVisibility, + }), + [ + hasUnavailableGuilds, + isDragging, + organizedItems, + shouldRenderGuildListItems, + visibleDMListRows, + guildNavigationVisibility, + ], + ); + const selectedGuildNavigationKey = useMemo( + () => + resolveSelectedGuildNavigationKey({ + organizedItems, + pathname: location.pathname, + visibleDMListRows, + visibility: guildNavigationVisibility, + }), + [organizedItems, location.pathname, visibleDMListRows, guildNavigationVisibility], + ); + const { + navigationRef: guildNavigationListRef, + onKeyDownCapture: handleGuildNavigationKeyDownCapture, + onFocusCapture: handleGuildNavigationFocusCapture, + onBlurCapture: handleGuildNavigationBlurCapture, + getRowListPosition: getGuildRowListPosition, + getRowBounds: getGuildRowBounds, + layoutRevision: guildLayoutRevision, + scrollToKey: scrollToGuildKey, + } = useNavigationList({ + scrollerRef: scrollRef, + rows: guildNavigationRows, + focusableSelector: GUILD_LIST_FOCUSABLE_SELECTOR, + keyboardNavigationEnabled: keyboardModeEnabled, + preferredActiveKey: selectedGuildNavigationKey, + resolveMissingActiveKey: null, + }); + const mergedGuildNavigationRef = useMergeRefs([guildListNavigationRef, guildNavigationListRef]); + const previousSelectedGuildNavigationKeyRef = useRef(selectedGuildNavigationKey); + const previousGuildNavigationPathnameRef = useRef(location.pathname); + useEffect(() => { + const previousSelectedGuildNavigationKey = previousSelectedGuildNavigationKeyRef.current; + const previousGuildNavigationPathname = previousGuildNavigationPathnameRef.current; + previousSelectedGuildNavigationKeyRef.current = selectedGuildNavigationKey; + previousGuildNavigationPathnameRef.current = location.pathname; + const selectedFromRail = consumeDirectSelection(DirectSelectionSurface.GUILD_RAIL); + if (selectedGuildNavigationKey == null || selectedGuildNavigationKey === previousSelectedGuildNavigationKey) { + return; + } + if (location.pathname === previousGuildNavigationPathname) { + return; + } + if (selectedFromRail) { + return; + } + scrollToGuildKey(selectedGuildNavigationKey, NavigationAlignment.AUTO); + }, [location.pathname, scrollToGuildKey, selectedGuildNavigationKey]); + const persistGuildListScrollTop = useCallback(() => { + const pendingScrollTop = pendingScrollTopRef.current; + if (pendingScrollTop == null) return; + pendingScrollTopRef.current = null; + armedScrollTopRef.current = null; + DimensionCommands.updateGuildListScroll(pendingScrollTop); + reportSkeletonGuildRailScrollTop(pendingScrollTop); + }, []); + const armGuildListScrollPersist = useCallback( + function armGuildListScrollPersistTimer(): void { + if (scrollPersistTimerRef.current != null) return; + armedScrollTopRef.current = pendingScrollTopRef.current; + scrollPersistTimerRef.current = window.setTimeout(() => { + scrollPersistTimerRef.current = null; + if (pendingScrollTopRef.current !== armedScrollTopRef.current) { + armGuildListScrollPersistTimer(); + return; + } + persistGuildListScrollTop(); + }, GUILD_LIST_SCROLL_PERSIST_IDLE_MS); + }, + [persistGuildListScrollTop], + ); const handleScroll = useCallback( (event: React.UIEvent) => { - handleGuildListAnchorScroll(event.currentTarget); - const scrollTop = event.currentTarget.scrollTop; - pendingScrollTopRef.current = scrollTop; - if (scrollPersistRafRef.current != null) return; - scrollPersistRafRef.current = requestAnimationFrame(() => { - scrollPersistRafRef.current = null; - const pendingScrollTop = pendingScrollTopRef.current; - if (pendingScrollTop == null) return; - DimensionCommands.updateGuildListScroll(pendingScrollTop); - }); + pendingScrollTopRef.current = event.currentTarget.scrollTop; + armGuildListScrollPersist(); }, - [handleGuildListAnchorScroll], + [armGuildListScrollPersist], ); const handleWheel = useCallback( (event: React.WheelEvent) => { @@ -817,21 +1834,262 @@ const GuildList = observer(() => { ownerWindow.removeEventListener('wheel', handleWindowWheel, true); }; }, [getGuildScrollContainer, isDragging, scrollGuildListByWheel]); + const expandedGuildFolderKey = GuildFolderExpanded.expandedFolderIds.join(','); + const targetGuildRows = useMemo( + () => createGuildTargetRows({organizedItems, visibleDMListRows}), + [organizedItems, visibleDMListRows, expandedGuildFolderKey], + ); + const getGuildTargetBounds = useCallback( + (targets: ReadonlyArray) => { + const scrollNode = resolveGuildListScrollerNode(scrollRef.current); + return resolveGuildTargetBounds({ + getRowBounds: getGuildRowBounds, + scrollTargetRegistry, + scrollNode, + targetRows: targetGuildRows, + targets, + }); + }, + [getGuildRowBounds, scrollTargetRegistry, targetGuildRows], + ); + const scrollToGuildTarget = useCallback( + (targetId: string, direction: VerticalEdge) => { + const alignment = resolveNavigationAlignment(direction); + const scrollToMountedTarget = () => { + const node = scrollTargetRegistry.getTargetNode(targetId); + const scroller = scrollRef.current; + if (node == null || scroller == null) return false; + scroller.scrollIntoViewNode({ + node, + alignment, + animate: Accessibility.useSmoothScrolling, + }); + return true; + }; + if (scrollToMountedTarget()) return true; + const targetRow = targetGuildRows.get(targetId); + if (targetRow == null || !scrollToGuildKey(targetRow.rowKey, alignment)) return false; + if (scrollToMountedTargetFrameRef.current != null) { + window.cancelAnimationFrame(scrollToMountedTargetFrameRef.current); + } + scrollToMountedTargetFrameRef.current = window.requestAnimationFrame(() => { + scrollToMountedTargetFrameRef.current = null; + scrollToMountedTarget(); + }); + return true; + }, + [scrollTargetRegistry, scrollToGuildKey, targetGuildRows], + ); + useEffect(() => { + return registerSkeletonLayoutMemoryPreFlush(persistGuildListScrollTop); + }, [persistGuildListScrollTop]); useEffect(() => { return () => { - if (scrollPersistRafRef.current != null) { - cancelAnimationFrame(scrollPersistRafRef.current); - scrollPersistRafRef.current = null; + if (scrollToMountedTargetFrameRef.current != null) { + window.cancelAnimationFrame(scrollToMountedTargetFrameRef.current); + scrollToMountedTargetFrameRef.current = null; } + if (scrollPersistTimerRef.current != null) { + window.clearTimeout(scrollPersistTimerRef.current); + scrollPersistTimerRef.current = null; + } + persistGuildListScrollTop(); }; - }, []); + }, [persistGuildListScrollTop]); useEffect(() => { - const scrollTop = Dimension.getGuildListDimensions().scrollTop; - const scrollNode = scrollRef.current?.getScrollerNode(); - if (scrollTop > 0 && scrollNode) { - scrollNode.scrollTop = scrollTop; + const scrollNode = resolveGuildListScrollerNode(scrollRef.current); + if (initialGuildListScrollTop > 0 && scrollNode != null) { + scrollNode.scrollTop = initialGuildListScrollTop; + DimensionCommands.updateGuildListScroll(initialGuildListScrollTop); } - }, []); + }, [initialGuildListScrollTop]); + const renderGuildNavigationRow = (row: GuildNavigationRow): React.ReactNode => { + switch (row.kind) { + case 'fluxer': + return ; + case 'favorites': + return ; + case 'dm': { + const {channel, pendingRemoval, voiceCallActive} = row.row; + return ( + + ); + } + case 'divider': + return ( + + ); + case 'outage': + return ( + + + plural( + {count: visibleUnavailableCount}, + { + one: '# community is temporarily unavailable due to a flux capacitor malfunction.', + other: '# communities are temporarily unavailable due to a flux capacitor malfunction.', + }, + ) + } + data-flx="app.guilds-layout.render-guild-navigation-row.tooltip" + > + + + + + + + + ); + case 'organized-item': { + const {item} = row; + if (item.type === 'folder') { + const isFolderSelected = item.guilds.some((guild) => + isSelectedPath(location.pathname, Routes.guildChannel(guild.id)), + ); + return ( + + + + ); + } + let guildIndex: number | null = null; + const navigationIndex = guildNavigationIndexes.get(item.guild.id); + if (navigationIndex != null) { + guildIndex = navigationIndex; + } + return ( + + + + ); + } + case 'bottom-drop-zone': + return ( + + + + ); + case 'discovery': + return ; + case 'add-guild': + return ; + case 'download': + return ; + case 'help': + return ; + } + }; + const renderGuildNavigationListItem = (row: GuildNavigationRow, index: number): React.ReactNode => { + const indexedNextRow = guildNavigationRows[index + 1]; + let nextRow: GuildNavigationRow | null = null; + if (indexedNextRow != null) { + nextRow = indexedNextRow; + } + let style = GUILD_ROW_STYLE; + if (hasGapAfterGuildNavigationRow(row, nextRow)) { + style = GUILD_ROW_STYLE_WITH_GAP; + } + const listPosition = getGuildRowListPosition(index); + if (listPosition == null) { + return ( +
+ {renderGuildNavigationRow(row)} +
+ ); + } + return ( +
+ {renderGuildNavigationRow(row)} +
+ ); + }; return ( ); @@ -1039,6 +2156,11 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) = (Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3)); const nagbarConditions = useNagbarConditions(); const activeNagbars = useActiveNagbars(nagbarConditions); + const skeletonNagbarRows = useMemo(() => activeNagbars.map(createSkeletonNagbarRow), [activeNagbars]); + const skeletonNagbarRowKey = useMemo( + () => skeletonNagbarRows.map((row) => `${row.tone}:${row.hasActions}:${row.dismissible}`).join(','), + [skeletonNagbarRows], + ); const prevNagbarCount = useRef(activeNagbars.length); const isReady = Initialization.isReady; useEffect(() => { @@ -1047,6 +2169,12 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) = ComponentDispatch.dispatch('LAYOUT_RESIZED'); } }, [activeNagbars.length]); + useEffect(() => { + if (isVoiceCallFullscreenActive) { + return; + } + reportSkeletonNagbarLayout(skeletonNagbarRows); + }, [skeletonNagbarRowKey, skeletonNagbarRows, isVoiceCallFullscreenActive]); useEffect(() => { const layoutElement = layoutRef.current; if (!layoutElement) return; diff --git a/fluxer_app/src/features/app/components/layout/MobileBottomNav.tsx b/fluxer_app/src/features/app/components/layout/MobileBottomNav.tsx index 0964bea0c..f173bedec 100644 --- a/fluxer_app/src/features/app/components/layout/MobileBottomNav.tsx +++ b/fluxer_app/src/features/app/components/layout/MobileBottomNav.tsx @@ -2,6 +2,8 @@ import {Routes} from '@app/app/Routes'; import styles from '@app/features/app/components/layout/MobileBottomNav.module.css'; +import {reportSkeletonVoiceConnected} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import Channels from '@app/features/channel/state/Channels'; import {PRIMARY_NAVIGATION_LANDMARK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; @@ -49,6 +51,9 @@ export const MobileBottomNav = observer(({currentUser}: MobileBottomNavProps) => const isDirectCallChannel = Boolean( voiceChannel && (voiceChannel.type === ChannelTypes.DM || voiceChannel.type === ChannelTypes.GROUP_DM), ); + useSkeletonLayoutReport(() => { + reportSkeletonVoiceConnected(isConnectedToVoice); + }, `${isConnectedToVoice}`); useEffect(() => { if (Routes.isChannelRoute(location.pathname)) { const channel = Navigation.channelId ? Channels.getChannel(Navigation.channelId) : undefined; diff --git a/fluxer_app/src/features/app/components/layout/NagbarButton.tsx b/fluxer_app/src/features/app/components/layout/NagbarButton.tsx index 479fa477c..2005202fb 100644 --- a/fluxer_app/src/features/app/components/layout/NagbarButton.tsx +++ b/fluxer_app/src/features/app/components/layout/NagbarButton.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/app/components/layout/NagbarButton.module.css'; -import {Button} from '@app/features/ui/button/Button'; +import {Button, type ButtonVariant} from '@app/features/ui/button/Button'; import {clsx} from 'clsx'; import type React from 'react'; @@ -12,6 +12,7 @@ interface NagbarButtonProps { className?: string; disabled?: boolean; submitting?: boolean; + variant?: ButtonVariant; } export const NagbarButton = ({ @@ -21,10 +22,11 @@ export const NagbarButton = ({ className, disabled = false, submitting, + variant = 'inverted', }: NagbarButtonProps) => { return (
@@ -532,13 +537,18 @@ export const ThemeStep = observer( const themeLabel = i18n._(THEME_TITLE_DESCRIPTOR); return (
- +
@@ -550,7 +560,11 @@ export const AdminIntroStep = observer(() => { const {i18n} = useLingui(); return (
- +
); }); @@ -583,7 +597,11 @@ export const LoadingStep = observer(() => { return (
- +
); }); @@ -621,7 +639,13 @@ const BrandingAssetRow = observer(({asset, disabled, onUpload, onClear}: Brandin variant="secondary" small disabled={disabled} - leftIcon={} + leftIcon={ + + } onClick={() => onUpload(asset.kind)} data-flx="app.self-hosted-setup-wizard-gate.asset-upload-button" > @@ -632,7 +656,13 @@ const BrandingAssetRow = observer(({asset, disabled, onUpload, onClear}: Brandin variant="secondary" small square - icon={} + icon={ + + } aria-label={i18n._(CLEAR_ASSET_DESCRIPTOR)} disabled={disabled} onClick={() => onClear(asset.kind)} @@ -669,7 +699,11 @@ export const BrandingStep = observer( const {i18n} = useLingui(); return (
- + ))}
@@ -737,7 +772,11 @@ export const RegistrationStep = observer( ]; return (
- + - + - + - + {draft.enabled && ( -
+
onDraftChange({minSizeMb: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change" /> onDraftChange({maxSizeMb: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--2" />
onDraftChange({maxEligibleSizeMb: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--3" /> -
+
onDraftChange({minLifetimeDays: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--4" /> onDraftChange({maxLifetimeDays: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--5" />
-
+
onDraftChange({curve: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--6" /> onDraftChange({renewThresholdDays: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--7" />
onDraftChange({renewWindowDays: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--8" />
@@ -1213,13 +1277,18 @@ export const IntegrationStep = observer( ]; return (
- + {mode === 'configure' && (
@@ -1229,6 +1298,7 @@ export const IntegrationStep = observer( value={draft.klipyApiKey} onChange={(event) => onDraftChange({klipyApiKey: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change" /> )} {kind === 'youtube' && ( @@ -1237,17 +1307,21 @@ export const IntegrationStep = observer( value={draft.youtubeApiKey} onChange={(event) => onDraftChange({youtubeApiKey: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--2" /> )} {kind === 'captcha' && ( <> -
{i18n._(PROVIDER_LABEL_DESCRIPTOR)}
+
+ {i18n._(PROVIDER_LABEL_DESCRIPTOR)} +
onDraftChange({captchaProvider: value})} disabled={disabled} aria-label={i18n._(PROVIDER_LABEL_DESCRIPTOR)} + data-flx="app.setup.setup-wizard-steps.integration-step.radio-group.draft-change" /> )} @@ -1283,25 +1359,32 @@ export const IntegrationStep = observer( value={draft.emailEnabled} onChange={(value) => onDraftChange({emailEnabled: value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change" /> onDraftChange({emailFromEmail: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--4" /> onDraftChange({emailFromName: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--5" /> -
+
onDraftChange({smtpHost: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--6" /> onDraftChange({smtpPort: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--7" />
onDraftChange({smtpUsername: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--8" /> onDraftChange({smtpPassword: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change.password--2" /> onDraftChange({smtpSecure: value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change--2" /> -
+
{smtpTestResult && ( - + {smtpTestResult === 'ok' ? i18n._(SMTP_TEST_OK_DESCRIPTOR) : smtpTestResult} )} @@ -1361,37 +1456,46 @@ export const IntegrationStep = observer( value={draft.blueskyEnabled} onChange={(value) => onDraftChange({blueskyEnabled: value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change--3" /> onDraftChange({blueskyClientName: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--9" /> onDraftChange({blueskyClientUri: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--10" /> onDraftChange({blueskyLogoUri: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--11" /> -
+
onDraftChange({blueskyTosUri: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--12" /> onDraftChange({blueskyPolicyUri: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--13" />
onDraftChange({blueskyKeyId: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--14" /> onDraftChange({blueskyPrivateKey: event.target.value})} disabled={disabled} + data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change.password--3" /> )} @@ -1432,7 +1538,11 @@ export const ServicesStep = observer( const anyAvailable = available.gif || available.youtube || available.bluesky; return (
- + {!anyAvailable && (

{i18n._(SERVICES_NONE_DESCRIPTOR)} @@ -1490,7 +1600,11 @@ export const PremiumStep = observer( ]; return (

- + - +
- - + + + -
{submitError && (

diff --git a/fluxer_app/src/features/app/components/shared/InstanceBadge.module.css b/fluxer_app/src/features/app/components/shared/InstanceBadge.module.css deleted file mode 100644 index 3c209c774..000000000 --- a/fluxer_app/src/features/app/components/shared/InstanceBadge.module.css +++ /dev/null @@ -1,37 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -.badge { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - border-radius: 624.9375rem; - background-color: var(--background-tertiary); - cursor: default; - transition: background-color var(--transition-fast); -} - -.badge:hover { - background-color: var(--background-modifier-hover); -} - -.small { - width: 1rem; - height: 1rem; -} - -.medium { - width: 1.5rem; - height: 1.5rem; -} - -.favicon { - width: 100%; - height: 100%; - border-radius: 624.9375rem; - object-fit: cover; -} - -.globeIcon { - color: var(--text-secondary); -} diff --git a/fluxer_app/src/features/app/components/shared/InstanceBadge.tsx b/fluxer_app/src/features/app/components/shared/InstanceBadge.tsx deleted file mode 100644 index b5effd9d0..000000000 --- a/fluxer_app/src/features/app/components/shared/InstanceBadge.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import styles from '@app/features/app/components/shared/InstanceBadge.module.css'; -import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; -import {GlobeIcon} from '@phosphor-icons/react'; -import {clsx} from 'clsx'; -import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect, useMemo, useState} from 'react'; - -interface InstanceBadgeProps { - instanceDomain: string; - size?: 'small' | 'medium'; - showTooltip?: boolean; - className?: string; -} - -type FaviconLoadState = 'loading' | 'loaded' | 'error'; - -const faviconCache = new Map(); - -function getFaviconUrl(instanceDomain: string): string { - return `https://${instanceDomain}/favicon.ico`; -} - -export const InstanceBadge = observer(function InstanceBadge({ - instanceDomain, - size = 'small', - showTooltip = true, - className, -}: InstanceBadgeProps) { - const [faviconState, setFaviconState] = useState(() => { - const cached = faviconCache.get(instanceDomain); - return cached?.state ?? 'loading'; - }); - const faviconUrl = useMemo(() => getFaviconUrl(instanceDomain), [instanceDomain]); - const handleImageLoad = useCallback(() => { - faviconCache.set(instanceDomain, {state: 'loaded', url: faviconUrl}); - setFaviconState('loaded'); - }, [instanceDomain, faviconUrl]); - const handleImageError = useCallback(() => { - faviconCache.set(instanceDomain, {state: 'error', url: null}); - setFaviconState('error'); - }, [instanceDomain]); - useEffect(() => { - const cached = faviconCache.get(instanceDomain); - if (cached) { - setFaviconState(cached.state); - return; - } - const img = new Image(); - img.onload = handleImageLoad; - img.onerror = handleImageError; - img.src = faviconUrl; - return () => { - img.onload = null; - img.onerror = null; - }; - }, [instanceDomain, faviconUrl, handleImageLoad, handleImageError]); - const iconSize = size === 'small' ? 12 : 18; - const badgeContent = useMemo(() => { - const containerClass = clsx(styles.badge, size === 'small' ? styles.small : styles.medium, className); - if (faviconState === 'loaded') { - return ( - - - - ); - } - return ( - - - ); - }, [faviconState, faviconUrl, size, iconSize, className]); - if (!showTooltip) { - return badgeContent; - } - return ( - - {badgeContent} - - ); -}); diff --git a/fluxer_app/src/features/app/components/skeleton/AppSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/AppSkeleton.module.css new file mode 100644 index 000000000..6e4051e3b --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/AppSkeleton.module.css @@ -0,0 +1,104 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + height: 100%; + min-height: 0; + width: 100%; + min-width: 0; + overflow: hidden; + position: relative; + user-select: none; + -webkit-user-select: none; + opacity: 1; + transition: opacity 120ms cubic-bezier(0.2, 0, 0, 1); +} + +.containerExiting { + position: absolute; + inset: 0; + z-index: calc(var(--z-index-modal) - 1); + opacity: 0; + pointer-events: none; +} + +.containerGuilds { + --layout-user-area-overlay-height: 0px; + grid-template-columns: var(--layout-guild-list-width) minmax(0, 1fr); + background-color: var(--background-secondary); +} + +.containerRailless { + grid-template-columns: minmax(0, 1fr); +} + +.containerReserveUserArea { + --layout-user-area-overlay-height: calc( + var(--layout-user-area-height) + + var(--user-area-box-inset-block-end) + + var(--layout-voice-connection-height) + ); +} + +.containerReserveMobileBottomNav { + --layout-mobile-bottom-nav-reserved-height: var(--mobile-bottom-nav-height); +} + +:global(html.platform-native) .containerGuilds { + padding-top: var(--native-titlebar-height); +} + +.outlineFrame { + --outline-radius: 0px; +} + +:global(html:not(.platform-native)) .outlineFrame { + border-top: none; +} + +:global(html.platform-native) .outlineFrame { + border-top: 0.0625rem solid var(--user-area-divider-color); + --outline-radius: var(--outline-frame-radius-native); +} + +.content { + display: grid; + grid-template-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.main { + display: grid; + grid-template-columns: var(--layout-sidebar-width) minmax(0, 1fr); + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.mainSidebarless { + grid-template-columns: minmax(0, 1fr); +} + +.contentColumn { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background-color: var(--background-secondary); +} + +:global(html.reduced-motion) .container { + transition-duration: 0s; +} + +@media (prefers-reduced-motion: reduce) { + .container { + transition-duration: 0s; + } +} diff --git a/fluxer_app/src/features/app/components/skeleton/AppSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/AppSkeleton.tsx new file mode 100644 index 000000000..fa46b1b01 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/AppSkeleton.tsx @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Routes} from '@app/app/Routes'; +import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar'; +import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame'; +import styles from '@app/features/app/components/skeleton/AppSkeleton.module.css'; +import {ChatSkeleton} from '@app/features/app/components/skeleton/ChatSkeleton'; +import {DiscoveryPageSkeleton, DiscoverySidebarSkeleton} from '@app/features/app/components/skeleton/DiscoverySkeleton'; +import {DMSidebarSkeleton} from '@app/features/app/components/skeleton/DMSidebarSkeleton'; +import {FriendsSkeleton} from '@app/features/app/components/skeleton/FriendsSkeleton'; +import {GuildRailSkeleton} from '@app/features/app/components/skeleton/GuildRailSkeleton'; +import {GuildSidebarSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton'; +import {MobileBottomNavSkeleton} from '@app/features/app/components/skeleton/MobileBottomNavSkeleton'; +import { + type ChatSkeletonPresentation, + resolveChatSkeletonPresentation, +} from '@app/features/app/components/skeleton/ResolveChatSkeleton'; +import { + reservesMobileBottomNavSpace, + resolveSkeletonShell, + type SkeletonContent, + SkeletonSidebarVariant, + showsMobileBottomNav, + showsMobileGuildRail, + showsMobileSidebar, +} from '@app/features/app/components/skeleton/ResolveSkeletonShell'; +import {SimplePageSkeleton} from '@app/features/app/components/skeleton/SimplePageSkeleton'; +import { + getRememberedSkeletonNagbarLayout, + getRememberedSkeletonVoicePresence, + SKELETON_DEFAULT_NAGBAR_LAYOUT, + SKELETON_DEFAULT_VOICE_PRESENCE, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {UserAreaSkeleton} from '@app/features/app/components/skeleton/UserAreaSkeleton'; +import KeybindManager from '@app/features/app/keybindings/KeybindManager'; +import {useCanFitMemberList} from '@app/features/member/hooks/useMemberListVisible'; +import {useLocation} from '@app/features/platform/components/router/RouterReact'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import SidebarWidth from '@app/features/ui/state/SidebarWidth'; +import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen'; +import {flxElementClassName} from '@app/lib/react'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; +import {useLayoutEffect, useRef, useState} from 'react'; + +const SIDEBAR_WIDTH_VARIABLE = '--layout-sidebar-width'; +const VOICE_CONNECTION_HEIGHT_VARIABLE = '--layout-voice-connection-height'; +const GUILD_ID_PATH_INDEX = 2; + +function resolveSkeletonGuildId(pathname: string): string | null { + if (!Routes.isGuildChannelRoute(pathname)) { + return null; + } + return pathname.split('/')[GUILD_ID_PATH_INDEX] ?? null; +} + +function renderSidebar(sidebar: SkeletonSidebarVariant, guildId: string | null) { + switch (sidebar) { + case SkeletonSidebarVariant.DM: + return ; + case SkeletonSidebarVariant.GUILD: + return ( + + ); + case SkeletonSidebarVariant.DISCOVERY: + return ( + + ); + } +} + +function renderContent(content: SkeletonContent, chatPresentation: ChatSkeletonPresentation) { + switch (content.kind) { + case 'chat': + return ( + + ); + case 'friends': + return ; + case 'discovery': + return ; + case 'page': + return ( + + ); + case 'empty': + return null; + } +} + +interface AppSkeletonProps { + readonly isExiting?: boolean; + readonly onTransitionEnd?: React.TransitionEventHandler; + readonly effectivePathname?: string; +} + +interface FrozenChatSkeletonPresentation { + readonly pathname: string; + readonly isMobile: boolean; + readonly canFitMemberList: boolean; + readonly presentation: ChatSkeletonPresentation; +} + +export const AppSkeleton = observer(({isExiting = false, onTransitionEnd, effectivePathname}: AppSkeletonProps) => { + const location = useLocation(); + const pathname = effectivePathname ?? location.pathname; + const isFluxerSelected = pathname.startsWith(Routes.ME) || Routes.isSpecialPage(pathname); + const isFavoritesSelected = pathname.startsWith(Routes.FAVORITES); + const isDiscoverySelected = Routes.isDiscoverRoute(pathname); + const isMobile = MobileLayout.enabled; + const isVoiceCallFullscreenActive = VoiceCallFullscreen.isActive; + const shell = resolveSkeletonShell(pathname); + const guildId = resolveSkeletonGuildId(pathname); + const [rememberedChrome] = useState(() => + Object.freeze({ + nagbar: getRememberedSkeletonNagbarLayout() ?? SKELETON_DEFAULT_NAGBAR_LAYOUT, + voice: getRememberedSkeletonVoicePresence() ?? SKELETON_DEFAULT_VOICE_PRESENCE, + }), + ); + const voicePanelHeightPx = rememberedChrome.voice.panelHeightPx; + const canFitMemberList = useCanFitMemberList(); + const frozenChatPresentationRef = useRef(null); + let frozenChatPresentation = frozenChatPresentationRef.current; + if ( + frozenChatPresentation == null || + frozenChatPresentation.pathname !== pathname || + frozenChatPresentation.isMobile !== isMobile || + frozenChatPresentation.canFitMemberList !== canFitMemberList + ) { + frozenChatPresentation = { + pathname, + isMobile, + canFitMemberList, + presentation: resolveChatSkeletonPresentation(pathname, !isMobile && canFitMemberList), + }; + frozenChatPresentationRef.current = frozenChatPresentation; + } + const chatPresentation = frozenChatPresentation.presentation; + useLayoutEffect(() => { + if (isExiting) return; + KeybindManager.suspend(); + return () => KeybindManager.resume(); + }, [isExiting]); + const showMobileBottomNav = isMobile && showsMobileBottomNav(pathname); + if (shell.chrome === 'bare') { + return ( + + {renderContent(shell.content, chatPresentation)} + {showMobileBottomNav && ( + + )} + + ); + } + const showMobileSidebarInline = isMobile && showsMobileSidebar(pathname); + const showRail = !isMobile || showsMobileGuildRail(pathname); + const reserveMobileBottomNav = isMobile && reservesMobileBottomNavSpace(pathname); + const showUserArea = !isMobile && !isVoiceCallFullscreenActive; + const sidebarWidth = SidebarWidth.cssValue; + const shellStyle: React.CSSProperties = {}; + if (!isMobile && sidebarWidth != null && sidebarWidth !== '') { + Object.assign(shellStyle, {[SIDEBAR_WIDTH_VARIABLE]: sidebarWidth}); + } + if (showUserArea) { + Object.assign(shellStyle, {[VOICE_CONNECTION_HEIGHT_VARIABLE]: `${voicePanelHeightPx}px`}); + } + return ( + + {showRail && ( + + )} + + + } + data-flx="app.skeleton.app-skeleton.outline-frame" + > + + {!isMobile && renderSidebar(shell.sidebar, guildId)} + + {showMobileSidebarInline + ? renderSidebar(shell.sidebar, guildId) + : renderContent(shell.content, chatPresentation)} + + + + + {showUserArea && ( + + )} + {showMobileBottomNav && ( + + )} + + ); +}); diff --git a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css new file mode 100644 index 000000000..cfb15461a --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css @@ -0,0 +1,241 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.grid { + --textarea-font-size: var(--font-size, 1rem); + --textarea-line-height: calc(var(--textarea-font-size) * 1.375); + --textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2)); + --composer-box-inset-inline: min( + var(--footer-box-inset), + var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) + ); + --composer-box-padding-inline: max( + 0rem, + calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline)) + ); + display: grid; + grid-template-rows: var(--layout-header-height-mobile) minmax(0, 1fr); + height: 100%; + min-height: 0; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + background-color: var(--background-secondary-lighter); +} + +@media (min-width: 768px) { + .grid { + grid-template-rows: var(--layout-header-height) minmax(0, 1fr); + } +} + +.header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); + align-items: center; + gap: var(--channel-header-gap); + height: var(--layout-header-height); + min-height: var(--layout-header-height); + padding: 0 var(--channel-header-padding-inline); + border-bottom: 0.0625rem solid var(--user-area-divider-color); + background-color: var(--background-secondary-lighter); +} + +@media (max-width: 767px) { + .header { + height: var(--layout-header-height-mobile); + min-height: var(--layout-header-height-mobile); + } +} + +.identity { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.identityBack { + display: flex; + align-items: center; + justify-content: center; + height: var(--channel-header-back-button-size); + width: var(--channel-header-back-button-size); + flex-shrink: 0; + margin-right: var(--channel-header-back-button-gap); +} + +@media (min-width: 768px) { + .identityBackDesktop { + display: none; + } +} + +.identityName { + margin-left: var(--channel-header-name-gap); +} + +.identityNameGroupDM { + margin-left: var(--channel-header-name-gap-group-dm); +} + +.identityCaret { + margin-left: var(--channel-header-caret-gap); +} + +.identityTopicDivider { + margin-left: var(--channel-header-topic-divider-gap); + margin-right: var(--channel-header-topic-divider-gap); +} + +.headerActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--channel-header-actions-gap); + min-width: 0; + flex-shrink: 0; +} + +.headerAction { + display: flex; + align-items: center; + justify-content: center; + height: var(--channel-header-action-size); + width: var(--channel-header-action-size); + flex-shrink: 0; +} + +.headerSearch { + display: flex; + align-items: center; + box-sizing: border-box; + width: var(--message-search-bar-width); + min-width: var(--message-search-bar-width); + max-width: var(--message-search-bar-width); + height: var(--message-search-bar-height); + min-height: var(--message-search-bar-height); + padding-left: var(--input-container-padding); + padding-right: var(--input-container-padding); + border: var(--skeleton-chrome-border); + border-radius: var(--radius-xl); + background-color: var(--background-textarea); + flex-shrink: 0; +} + +.headerSearchIcon { + margin-right: var(--message-search-bar-icon-gap); + flex-shrink: 0; +} + +.content { + --member-list-width: var(--layout-member-list-width); + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + height: 100%; + min-height: 0; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + contain: layout style; +} + +.memberListDivider { + position: absolute; + top: 0; + bottom: 0; + right: var(--member-list-width); + width: 0.0625rem; + background: var(--user-area-divider-color); + z-index: 5; +} + +.chatArea { + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.chatBody { + display: flex; + flex-direction: column; + font-size: var(--font-size, 1rem); + height: 100%; + min-height: 0; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + background-color: var(--composer-surface-color); +} + +.messages { + display: flex; + flex: 1 1 0%; + flex-direction: column; + justify-content: flex-end; + min-height: 0; + min-width: 0; + overflow: hidden; + padding-left: var(--chat-mobile-horizontal-padding); + padding-right: var(--chat-mobile-horizontal-padding); + padding-bottom: var(--messages-bottom-clearance); +} + +@media (min-width: 768px) { + .messages { + padding-left: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)); + padding-right: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)); + } +} + +.composerInputLine { + display: flex; + align-items: center; + height: var(--textarea-line-height); + min-width: 0; +} + +.composerAction { + display: flex; + align-items: center; + justify-content: center; + height: var(--textarea-button-height); + min-width: var(--textarea-button-height); + flex-shrink: 0; +} + +.memberList { + display: flex; + flex-direction: column; + box-sizing: border-box; + height: 100%; + min-height: 0; + width: calc(var(--member-list-width) + var(--outline-frame-border-width, 0.0625rem)); + min-width: 0; + overflow: hidden; + border-right: var(--outline-frame-border-width, 0.0625rem) solid var(--user-area-divider-color); + border-bottom: var(--outline-frame-border-width, 0.0625rem) solid var(--user-area-divider-color); + border-top-left-radius: var(--outline-radius, 0px); + background-color: var(--background-secondary-lighter); +} + +.memberListGroupDM { + width: calc(var(--member-list-width) + 2 * var(--outline-frame-border-width, 0.0625rem)); + border-left: var(--outline-frame-border-width, 0.0625rem) solid var(--user-area-divider-color); +} + +.memberListScroller { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-top: var(--member-list-scroller-padding-block-start); + padding-left: var(--member-list-scroller-padding-inline); + padding-right: var(--member-list-scroller-padding-inline); + padding-bottom: var(--member-list-scroller-padding-block-end); +} diff --git a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx new file mode 100644 index 000000000..753a0ff12 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/ChatSkeleton.module.css'; +import {usePlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs'; +import { + ChatSkeletonChannelKind, + type ChatSkeletonPresentation, +} from '@app/features/app/components/skeleton/ResolveChatSkeleton'; +import {ScrollFillerSkeleton} from '@app/features/app/components/skeleton/ScrollFillerSkeleton'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonComposerLayout, + getRememberedSkeletonMessagePresentation, + resolveDefaultSkeletonComposerLayout, + resolveDefaultSkeletonMessagePresentation, + SKELETON_UNMEASURED_WIDTH_PX, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import { + type SkeletonInjectedToken, + skeletonSurfaceVar, +} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import {CHANNEL_HEADER_DM_AVATAR_SIZE_PX} from '@app/features/channel/components/ChannelHeaderMetrics'; +import {MemberListSkeleton, MemberListSkeletonVariant} from '@app/features/channel/components/MemberListSkeleton'; +import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css'; +import composerInputStyles from '@app/features/channel/components/textarea/TextareaInput.module.css'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import {flxElementClassName} from '@app/lib/react'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; +import {Fragment, useState} from 'react'; + +const MESSAGE_LIST_SEED_KEY = 'chat-skeleton-message-list'; + +const HEADER_BACK_ICON_SIZE = skeletonSurfaceVar('--channel-header-back-icon-size'); +const HEADER_ICON_SIZE = skeletonSurfaceVar('--channel-header-icon-size'); +const HEADER_DM_AVATAR_SIZE = remFromPx(CHANNEL_HEADER_DM_AVATAR_SIZE_PX); +const HEADER_NAME_FALLBACK_WIDTH = '7.5rem'; +const HEADER_NAME_HEIGHT = '0.875rem'; +const HEADER_CARET_SIZE = skeletonSurfaceVar('--channel-header-caret-size'); +const HEADER_TOPIC_DIVIDER_SIZE = '0.25rem'; +const HEADER_TOPIC_FALLBACK_WIDTH = '18rem'; +const HEADER_TOPIC_HEIGHT = '0.6875rem'; +const HEADER_ACTION_ICON_SIZE = skeletonSurfaceVar('--channel-header-action-icon-size'); +const HEADER_SEARCH_ICON_SIZE = skeletonSurfaceVar('--message-search-bar-icon-size'); +const HEADER_SEARCH_PLACEHOLDER_WIDTH = '7rem'; +const HEADER_SEARCH_PLACEHOLDER_HEIGHT = '0.6875rem'; +const HEADER_MOBILE_ACTION_SIZE = skeletonSurfaceVar('--channel-header-action-size-mobile'); + +const COMPOSER_ICON_SIZE = skeletonSurfaceVar('--textarea-button-icon-size'); +const COMPOSER_PLACEHOLDER_WIDTH = '11rem'; +const COMPOSER_PLACEHOLDER_HEIGHT = '0.875rem'; + +type ChatSkeletonRootToken = Extract< + SkeletonInjectedToken, + '--chat-horizontal-padding' | '--font-size' | '--message-group-spacing' | '--message-compact-timestamp-width' +>; + +type ChatSkeletonRootStyle = React.CSSProperties & Record; + +function resolveMeasuredWidth(widthPx: number, fallback: string): string { + if (widthPx === SKELETON_UNMEASURED_WIDTH_PX) { + return fallback; + } + return remFromPx(widthPx); +} + +function HeaderActionSlot() { + return ( + + + + ); +} + +function HeaderSearchSkeleton() { + return ( + + + + + ); +} + +interface HeaderActionProjection { + readonly desktopLeadingCount: number; + readonly mobileCount: number; + readonly showSearch: boolean; + readonly persistentDesktopActionCount: number; +} + +function resolveDefaultHeaderActionCounts( + channelKind: ChatSkeletonChannelKind, + favoriteCount: number, +): {desktopLeadingCount: number; mobileCount: number; showSearch: boolean} { + switch (channelKind) { + case ChatSkeletonChannelKind.DM: + return {desktopLeadingCount: 4 + favoriteCount, mobileCount: 2 + favoriteCount, showSearch: true}; + case ChatSkeletonChannelKind.GROUP_DM: + return {desktopLeadingCount: 5 + favoriteCount, mobileCount: 2 + favoriteCount, showSearch: true}; + case ChatSkeletonChannelKind.PERSONAL_NOTES: + return {desktopLeadingCount: 0, mobileCount: 0, showSearch: false}; + case ChatSkeletonChannelKind.GUILD: + case ChatSkeletonChannelKind.GUILD_VOICE: + return {desktopLeadingCount: 3 + favoriteCount, mobileCount: 1 + favoriteCount, showSearch: true}; + } +} + +function resolveHeaderActionProjection(presentation: ChatSkeletonPresentation): HeaderActionProjection { + let favoriteCount = 0; + if (presentation.favoritesVisible) { + favoriteCount = 1; + } + const defaults = resolveDefaultHeaderActionCounts(presentation.channelKind, favoriteCount); + let persistentDesktopActionCount = 0; + if (presentation.staffToolsVisible) { + persistentDesktopActionCount += 1; + } + if (presentation.updaterVisible) { + persistentDesktopActionCount += 1; + } + return { + desktopLeadingCount: presentation.headerDesktopLeadingActionCount ?? defaults.desktopLeadingCount, + mobileCount: presentation.headerMobileActionCount ?? defaults.mobileCount, + showSearch: defaults.showSearch, + persistentDesktopActionCount, + }; +} + +interface ChatSkeletonProps { + readonly presentation: ChatSkeletonPresentation; +} + +export const ChatSkeleton = observer(function ChatSkeleton({presentation}: ChatSkeletonProps) { + const isMobile = MobileLayout.enabled; + const {channelKind, showMemberList, showTopic, rememberedMemberGroups} = presentation; + const [rememberedChatState] = useState(() => + Object.freeze({ + composerLayout: + getRememberedSkeletonComposerLayout() ?? resolveDefaultSkeletonComposerLayout(RuntimeConfig.gifEnabled), + messagePresentation: getRememberedSkeletonMessagePresentation() ?? resolveDefaultSkeletonMessagePresentation(), + }), + ); + const {composerLayout, messagePresentation} = rememberedChatState; + let composerActionCount = composerLayout.desktopActionCount; + let composerDividerVisible = composerLayout.sendDividerVisible; + if (isMobile) { + composerActionCount = composerLayout.mobileActionCount; + composerDividerVisible = false; + } + const messageSpecs = usePlaceholderSpecs({ + compact: messagePresentation.compact, + compactAvatarsVisible: messagePresentation.compactAvatarsVisible, + groupSpacing: messagePresentation.groupSpacingPx, + viewportHeightPx: messagePresentation.viewportHeightPx, + seedKey: MESSAGE_LIST_SEED_KEY, + }); + const rootStyle: ChatSkeletonRootStyle = { + '--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx), + '--font-size': remFromPx(messagePresentation.fontSizePx), + '--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx), + '--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx), + }; + const headerActionProjection = resolveHeaderActionProjection(presentation); + const renderHeaderIdentityIcon = (): React.ReactNode => { + if (channelKind === ChatSkeletonChannelKind.DM || channelKind === ChatSkeletonChannelKind.GROUP_DM) { + return ( + + ); + } + return ( + + ); + }; + const renderHeaderIdentityTail = (): React.ReactNode => { + if (isMobile) { + return ( + + ); + } + if (showTopic) { + return ( + <> + + + + ); + } + return null; + }; + const renderHeaderActions = (): React.ReactNode => { + if (isMobile) { + return Array.from(Array(headerActionProjection.mobileCount).keys(), (index) => ( + + )); + } + return ( + <> + {Array.from(Array(headerActionProjection.desktopLeadingCount).keys(), (index) => ( + + ))} + {headerActionProjection.showSearch && ( + + )} + {Array.from(Array(headerActionProjection.persistentDesktopActionCount).keys(), (index) => ( + + ))} + + + ); + }; + const renderComposerActions = (): React.ReactNode => { + const dividerIndex = composerDividerVisible ? composerActionCount - 1 : -1; + return Array.from(Array(composerActionCount).keys(), (index) => ( + + {index === dividerIndex && ( + + )} + + + + + )); + }; + return ( + + + + + + + {renderHeaderIdentityIcon()} + + {renderHeaderIdentityTail()} + + + {renderHeaderActions()} + + + + {showMemberList && ( + + )} + + + + + + + + + + + + + + + + + + + + {renderComposerActions()} + + + + + + + {showMemberList && ( + + + + + + )} + + + ); +}); diff --git a/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.module.css new file mode 100644 index 000000000..a1a1f4997 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.module.css @@ -0,0 +1,165 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: grid; + grid-template-rows: auto 1fr auto; + height: calc(100% - var(--layout-user-area-reserved-height, 0px)); + min-height: 0; + width: var(--layout-sidebar-width); + min-width: 0; + overflow: hidden; + background-color: var(--background-secondary); +} + +.containerMobile { + width: 100%; +} + +:global(html.platform-native) .container { + border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem); + background-clip: padding-box; +} + +.header { + display: flex; + box-sizing: border-box; + align-items: center; + height: var(--layout-header-height); + min-height: var(--layout-header-height); + min-width: 0; + padding: 0 var(--spacing-2); + border-bottom: 0.0625rem solid var(--user-area-divider-color); +} + +.mobileHeader { + display: flex; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + min-height: var(--dm-sidebar-mobile-header-height); + min-width: 0; + padding: 0.5rem 0.5rem 0.5rem 1rem; + border-bottom: var(--theme-border-width) solid var(--background-header-secondary); +} + +.mobileHeaderActions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.scrollArea { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.content { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + padding-left: var(--spacing-2); + padding-right: var(--spacing-2); +} + +.mobileContent { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + padding-left: var(--spacing-2); + padding-right: var(--spacing-2); + padding-bottom: var(--mobile-bottom-nav-height); +} + +.spacer { + height: var(--spacing-2); +} + +.row { + display: flex; + height: 2.625rem; + min-width: 0; + align-items: center; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.mobileRow { + display: flex; + box-sizing: border-box; + min-height: 3.25rem; + min-width: 0; + align-items: center; + padding: 0.375rem 0.5rem; +} + +.rowContent { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 0.75rem; +} + +.channelInfo { + display: flex; + flex: 1 1 0; + flex-direction: column; + justify-content: center; + gap: 0.0625rem; + min-width: 0; +} + +.channelNameRow { + display: flex; + align-items: center; + height: 1.25rem; + min-width: 0; +} + +.channelSubtextRow { + display: flex; + align-items: center; + height: 1rem; + min-width: 0; + margin-top: -0.0625rem; +} + +.separator { + height: 0.0625rem; + margin: var(--spacing-2) calc(-1 * var(--spacing-2)); + background-color: var(--user-area-divider-color); +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.channelList { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.bottomSpacer { + flex: 0 0 auto; + height: calc(var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) + var(--spacing-2)); +} + +.mobileFab { + position: fixed; + right: 1rem; + bottom: calc(var(--mobile-bottom-nav-height) + env(safe-area-inset-bottom, 0px) + 1rem); + z-index: 50; +} diff --git a/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.tsx new file mode 100644 index 000000000..2447e1d5a --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/DMSidebarSkeleton.tsx @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/DMSidebarSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonDMSidebarLayout, + type RememberedSkeletonDMSidebarLayout, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import {flxElementClassName} from '@app/lib/react'; +import {observer} from 'mobx-react-lite'; +import {useMemo, useState} from 'react'; + +const QUICK_SWITCHER_HEIGHT = '1.75rem'; +const FRIENDS_ACTION_LABEL_WIDTH = '4.5rem'; +const PERSONAL_NOTES_ACTION_LABEL_WIDTH = '7.75rem'; +const PREMIUM_ACTION_LABEL_WIDTH = '5.75rem'; +const ACTION_ICON_SIZE = '2rem'; +const ACTION_LABEL_HEIGHT = '0.75rem'; +const SECTION_LABEL_WIDTH = '6rem'; +const SECTION_LABEL_HEIGHT = '0.625rem'; +const SECTION_ACTION_SIZE = '1rem'; +const DEFAULT_CHANNEL_COUNT = 0; +const CHANNEL_AVATAR_SIZE = '2rem'; +const CHANNEL_NAME_HEIGHT = '0.75rem'; +const CHANNEL_SUBTEXT_HEIGHT = '0.5rem'; +const CHANNEL_NAME_WIDTH_MIN = 42; +const CHANNEL_NAME_WIDTH_RANGE = 42; +const CHANNEL_SUBTEXT_WIDTH_MIN = 32; +const CHANNEL_SUBTEXT_WIDTH_RANGE = 38; +const CHANNEL_SUBTEXT_PROBABILITY = 0.45; +const MOBILE_TITLE_WIDTH = '5.5rem'; +const MOBILE_TITLE_HEIGHT = '0.875rem'; +const MOBILE_SEARCH_BUTTON_SIZE = '2rem'; +const MOBILE_ADD_FRIEND_WIDTH = '7.5rem'; +const MOBILE_ADD_FRIEND_HEIGHT = '2.25rem'; +const MOBILE_ACTION_ICON_SIZE = '2.5rem'; +const MOBILE_CHANNEL_AVATAR_SIZE = '2.5rem'; +const MOBILE_TIMESTAMP_WIDTH = '1.5rem'; +const MOBILE_TIMESTAMP_HEIGHT = '0.5rem'; +const MOBILE_FAB_SIZE = '3.5rem'; + +interface DMChannelRowSpec { + nameWidth: string; + subtextWidth: string | undefined; +} + +interface DMActionSkeletonSpec { + readonly key: string; + readonly labelWidth: string; +} + +function createDMActionSkeletonSpecs( + layout: RememberedSkeletonDMSidebarLayout | null, + isMobile: boolean, +): ReadonlyArray { + const specs: Array = []; + if (!isMobile && (layout?.friendsVisible ?? true)) { + specs.push({key: 'friends', labelWidth: FRIENDS_ACTION_LABEL_WIDTH}); + } + if (layout?.personalNotesVisible ?? true) { + specs.push({key: 'personal-notes', labelWidth: PERSONAL_NOTES_ACTION_LABEL_WIDTH}); + } + if (layout?.premiumVisible ?? (!RuntimeConfig.isSelfHosted() && !isMobile)) { + specs.push({key: 'premium', labelWidth: PREMIUM_ACTION_LABEL_WIDTH}); + } + return Object.freeze(specs); +} + +class DMSidebarSkeletonSpecOwner { + private readonly random = createSkeletonRandomFromKey('dm-sidebar-skeleton-channels'); + + public createChannels( + channelCount: number, + subtextFlags: ReadonlyArray | null, + ): ReadonlyArray { + return Array.from({length: channelCount}, (_unused, index) => this.createChannel(subtextFlags, index)); + } + + private createChannel(subtextFlags: ReadonlyArray | null, index: number): DMChannelRowSpec { + const nameWidth = `${CHANNEL_NAME_WIDTH_MIN + this.random() * CHANNEL_NAME_WIDTH_RANGE}%`; + let hasSubtext = this.random() < CHANNEL_SUBTEXT_PROBABILITY; + if (subtextFlags != null) { + hasSubtext = subtextFlags[index] === true; + } + let subtextWidth: string | undefined; + if (hasSubtext) { + subtextWidth = `${CHANNEL_SUBTEXT_WIDTH_MIN + this.random() * CHANNEL_SUBTEXT_WIDTH_RANGE}%`; + } + return {nameWidth, subtextWidth}; + } +} + +interface DMActionSkeletonRowProps { + readonly index: number; + readonly labelWidth: string; + readonly mobile: boolean; +} + +function DMActionSkeletonRow({index, labelWidth, mobile}: DMActionSkeletonRowProps) { + let iconSize = ACTION_ICON_SIZE; + let rowClassName = styles.row; + let keyPrefix = 'dm-action'; + if (mobile) { + iconSize = MOBILE_ACTION_ICON_SIZE; + rowClassName = styles.mobileRow; + keyPrefix = 'dm-mobile-action'; + } + return ( + + + + + + + ); +} + +interface DMChannelSkeletonRowProps { + readonly channel: DMChannelRowSpec; + readonly index: number; + readonly mobile: boolean; +} + +function DMChannelSkeletonRow({channel, index, mobile}: DMChannelSkeletonRowProps) { + let avatarSize = CHANNEL_AVATAR_SIZE; + let rowClassName = styles.row; + let keyPrefix = 'dm-channel'; + if (mobile) { + avatarSize = MOBILE_CHANNEL_AVATAR_SIZE; + rowClassName = styles.mobileRow; + keyPrefix = 'dm-mobile-channel'; + } + return ( + + + + + + + + {channel.subtextWidth != null && ( + + + + )} + + {mobile && ( + + )} + + + ); +} + +export const DMSidebarSkeleton = observer(() => { + const [mountState] = useState(() => { + const isMobile = MobileLayout.enabled; + return Object.freeze({isMobile, rememberedLayout: getRememberedSkeletonDMSidebarLayout()}); + }); + const {isMobile, rememberedLayout} = mountState; + const layout = rememberedLayout?.isMobile === isMobile ? rememberedLayout : null; + const channelCount = layout?.channelRowCount ?? DEFAULT_CHANNEL_COUNT; + const subtextFlags = layout?.channelSubtextFlags ?? null; + const actionSpecs = createDMActionSkeletonSpecs(layout, isMobile); + const channels = useMemo>( + () => new DMSidebarSkeletonSpecOwner().createChannels(channelCount, subtextFlags), + [channelCount, subtextFlags], + ); + if (isMobile) { + return ( + + + + + + + + + + + {actionSpecs.map((action, index) => ( + + ))} + {channels.map((channel, index) => ( + + ))} + + + + + + ); + } + return ( + + + + + + + + {actionSpecs.map((action, index) => ( + + ))} + {(layout?.sectionVisible ?? true) && ( + <> + + + + + + + )} + + {channels.map((channel, index) => ( + + ))} + + + + + + ); +}); diff --git a/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.module.css new file mode 100644 index 000000000..1828409a9 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.module.css @@ -0,0 +1,182 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.navList { + display: flex; + flex-direction: column; + padding: var(--spacing-2); + gap: var(--spacing-1); +} + +.navItem { + display: flex; + align-items: center; + gap: var(--spacing-3); + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 2.75rem; + padding: var(--spacing-2); + border-radius: var(--radius-md); +} + +.navItemActive { + background-color: var(--background-modifier-selected); +} + +.navItemLabel { + flex: 1 1 auto; + min-width: 0; +} + +.container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background-color: var(--background-secondary-lighter); + overflow: hidden; +} + +.navbar { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); + align-items: center; + gap: var(--channel-header-gap); + flex: 0 0 auto; + box-sizing: border-box; + height: var(--layout-header-height); + min-height: var(--layout-header-height); + padding: 0 var(--channel-header-padding-inline); + border-bottom: var(--skeleton-chrome-border); + background-color: var(--background-secondary-lighter); + overflow: hidden; +} + +.categoryTabs { + display: flex; + align-items: center; + gap: var(--spacing-1); + min-width: 0; + overflow: hidden; +} + +.navbarSearch { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + width: var(--message-search-bar-width); + max-width: 100%; + min-width: 0; +} + +.scrollBody { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.content { + box-sizing: border-box; + width: 100%; + padding: var(--spacing-5) var(--channel-header-padding-inline) var(--spacing-8); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.grid { + display: grid; + justify-content: center; + gap: var(--discovery-grid-gap); + padding-bottom: var(--discovery-grid-gap); +} + +.card { + position: relative; + display: flex; + flex-direction: column; + background: var(--background-secondary); + border: var(--skeleton-chrome-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.cardBanner { + display: block; + flex: 0 0 auto; +} + +.cardIcon { + position: absolute; + top: calc(7.5rem - 1.75rem); + left: var(--spacing-4); + display: flex; + border-radius: var(--radius-full); + background-color: var(--background-secondary); + box-shadow: 0 0 0 0.1875rem var(--background-secondary); +} + +.cardBody { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + padding: var(--spacing-10) var(--spacing-4) var(--spacing-4); + flex: 1; +} + +.cardTitle { + display: flex; + align-items: center; + height: 1.25rem; +} + +.cardDescription { + display: flex; + flex-direction: column; + justify-content: space-around; + min-height: var(--discovery-card-description-min-height); + flex: 1; +} + +.cardFooter { + display: flex; + align-items: center; + padding: var(--spacing-3) var(--spacing-4); + border-top: var(--skeleton-chrome-border); +} + +.cardStats { + display: flex; + align-items: center; + gap: var(--spacing-3); + width: 100%; + min-width: 0; +} + +.cardStat { + display: flex; + align-items: center; + gap: var(--count-indicator-gap); + height: 0.9rem; + flex: 0 1 auto; + min-width: 0; +} + +@media (max-width: 640px) { + .navbar { + grid-template-columns: minmax(0, 1fr) minmax(0, 10rem); + height: var(--layout-header-height-mobile); + min-height: var(--layout-header-height-mobile); + } + + .navbarSearch { + width: 10rem; + } + + .content { + padding: var(--spacing-4) var(--spacing-4) var(--spacing-6); + } +} diff --git a/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.tsx new file mode 100644 index 000000000..9e14bcc68 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/DiscoverySkeleton.tsx @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/DiscoverySkeleton.module.css'; +import {SidebarShellSkeleton} from '@app/features/app/components/skeleton/SidebarShellSkeleton'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonDiscoveryLayout, + resolveDefaultSkeletonDiscoveryLayout, + SKELETON_UNMEASURED_WIDTH_PX, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {DISCOVERY_GRID_METRICS_STYLE} from '@app/features/discovery/discovery/DiscoveryGridMetrics'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {flxElementClassName} from '@app/lib/react'; +import {useMemo, useState} from 'react'; + +interface DiscoveryNavItemSpec { + labelWidth: string; + comingSoon: boolean; +} + +const NAV_ITEMS: ReadonlyArray = [ + {labelWidth: '6.5rem', comingSoon: false}, + {labelWidth: '3.5rem', comingSoon: true}, + {labelWidth: '4.5rem', comingSoon: true}, +]; +const NAV_ICON_SIZE = '1.25rem'; +const NAV_LABEL_HEIGHT = '0.875rem'; +const NAV_BADGE_WIDTH = '5.5rem'; +const NAV_BADGE_HEIGHT = '1.25rem'; +const CATEGORY_TAB_FALLBACK_WIDTH = '3rem'; +const CATEGORY_TAB_HEIGHT = '2rem'; +const SEARCH_WIDTH = '100%'; +const SEARCH_HEIGHT = '2.25rem'; +const CARD_BANNER_HEIGHT = '7.5rem'; +const CARD_ICON_SIZE = '3.5rem'; +const CARD_NAME_HEIGHT = '0.875rem'; +const CARD_DESCRIPTION_HEIGHT = '0.75rem'; +const CARD_DESCRIPTION_LINE_COUNT = 4; +const CARD_STAT_HEIGHT = '0.625rem'; +const CARD_STAT_DOT_SIZE = '0.5rem'; +const CARD_NAME_WIDTH_MIN = 40; +const CARD_NAME_WIDTH_RANGE = 45; +const CARD_DESCRIPTION_WIDTH_MIN = 62; +const CARD_DESCRIPTION_WIDTH_RANGE = 38; +const CARD_LAST_LINE_WIDTH_MIN = 30; +const CARD_LAST_LINE_WIDTH_RANGE = 35; +const CARD_STAT_WIDTH_MIN = 3.5; +const CARD_STAT_WIDTH_RANGE = 2; + +interface DiscoveryCardSpec { + nameWidth: string; + descriptionWidths: ReadonlyArray; + statWidths: ReadonlyArray; +} + +class DiscoverySkeletonSpecOwner { + private readonly random = createSkeletonRandomFromKey('discovery-skeleton-cards'); + + public createCards(cardCount: number): ReadonlyArray { + return Array.from({length: cardCount}, () => this.createCard()); + } + + private createCard(): DiscoveryCardSpec { + return { + nameWidth: `${CARD_NAME_WIDTH_MIN + this.random() * CARD_NAME_WIDTH_RANGE}%`, + descriptionWidths: Array.from(Array(CARD_DESCRIPTION_LINE_COUNT).keys(), (lineIndex) => + this.createDescriptionWidth(lineIndex), + ), + statWidths: [ + `${CARD_STAT_WIDTH_MIN + this.random() * CARD_STAT_WIDTH_RANGE}rem`, + `${CARD_STAT_WIDTH_MIN + this.random() * CARD_STAT_WIDTH_RANGE}rem`, + ], + }; + } + + private createDescriptionWidth(lineIndex: number): string { + if (lineIndex === CARD_DESCRIPTION_LINE_COUNT - 1) { + return `${CARD_LAST_LINE_WIDTH_MIN + this.random() * CARD_LAST_LINE_WIDTH_RANGE}%`; + } + return `${CARD_DESCRIPTION_WIDTH_MIN + this.random() * CARD_DESCRIPTION_WIDTH_RANGE}%`; + } +} + +function resolveCategoryTabWidths(widthsPx: ReadonlyArray): ReadonlyArray { + return widthsPx.map((widthPx) => { + if (widthPx === SKELETON_UNMEASURED_WIDTH_PX) { + return CATEGORY_TAB_FALLBACK_WIDTH; + } + return remFromPx(widthPx); + }); +} + +interface DiscoveryNavSkeletonItemProps { + readonly item: DiscoveryNavItemSpec; + readonly active: boolean; +} + +function DiscoveryNavSkeletonItem({item, active}: DiscoveryNavSkeletonItemProps) { + return ( +

+ +
+ +
+ {item.comingSoon && ( + + )} +
+ ); +} + +interface DiscoveryCardStatSkeletonProps { + readonly cardIndex: number; + readonly statIndex: number; + readonly width: string; +} + +function DiscoveryCardStatSkeleton({cardIndex, statIndex, width}: DiscoveryCardStatSkeletonProps) { + return ( +
+ + +
+ ); +} + +interface DiscoveryCardSkeletonProps { + readonly card: DiscoveryCardSpec; + readonly cardIndex: number; +} + +function DiscoveryCardSkeleton({card, cardIndex}: DiscoveryCardSkeletonProps) { + return ( +
+
+ +
+
+ +
+
+
+ +
+
+ {card.descriptionWidths.map((lineWidth, lineIndex) => ( + + ))} +
+
+
+
+ {card.statWidths.map((statWidth, statIndex) => ( + + ))} +
+
+
+ ); +} + +export const DiscoverySidebarSkeleton = () => ( + +
+ {NAV_ITEMS.map((item, itemIndex) => ( + + ))} +
+
+); + +interface DiscoveryResultsSkeletonProps { + readonly columnCount?: number | null; +} + +export const DiscoveryResultsSkeleton = ({columnCount = null}: DiscoveryResultsSkeletonProps = {}) => { + const [layout] = useState(() => getRememberedSkeletonDiscoveryLayout() ?? resolveDefaultSkeletonDiscoveryLayout()); + let resolvedColumnCount: number; + if (columnCount != null && columnCount > 0) { + resolvedColumnCount = columnCount; + } else { + resolvedColumnCount = layout.columnCount; + } + const cardCount = resolvedColumnCount * layout.visibleRowCount; + const cards = useMemo>( + () => new DiscoverySkeletonSpecOwner().createCards(cardCount), + [cardCount], + ); + return ( +
+ {cards.map((card, cardIndex) => ( + + ))} +
+ ); +}; + +export const DiscoveryPageSkeleton = () => { + const [layout] = useState(() => getRememberedSkeletonDiscoveryLayout() ?? resolveDefaultSkeletonDiscoveryLayout()); + const categoryTabWidths = useMemo( + () => resolveCategoryTabWidths(layout.categoryTabWidthsPx), + [layout.categoryTabWidthsPx], + ); + return ( +
+
+
+ {categoryTabWidths.map((tabWidth, tabIndex) => ( + + ))} +
+
+ +
+
+
+
+ +
+
+
+ ); +}; diff --git a/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.module.css new file mode 100644 index 000000000..ad3482d2d --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.module.css @@ -0,0 +1,371 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: flex; + height: 100%; + min-height: 0; + width: 100%; + min-width: 0; + background-color: var(--background-secondary-lighter); +} + +.mainColumn { + display: grid; + grid-template-rows: 3.5rem 1fr; + flex: 1; + min-width: 0; + min-height: 0; +} + +.header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); + align-items: center; + gap: var(--channel-header-gap); + height: var(--layout-header-height); + min-height: var(--layout-header-height); + padding: 0 var(--channel-header-padding-inline); + border-bottom: 0.0625rem solid var(--user-area-divider-color); +} + +@media (max-width: 767px) { + .header { + height: var(--layout-header-height-mobile); + min-height: var(--layout-header-height-mobile); + } +} + +.headerLeftSection { + position: relative; + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.backButton { + margin-right: var(--channel-header-back-button-gap); + flex-shrink: 0; +} + +@media (min-width: 768px) { + .backButton:not(.backButtonMobile) { + display: none; + } +} + +.headerLeft { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + min-width: 0; +} + +.titleSection { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.divider { + height: 1.5rem; + width: 0.0625rem; + background-color: var(--background-modifier-accent); + flex-shrink: 0; +} + +.tabsWrapper { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; +} + +.tabsInner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem; +} + +.headerRight { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--channel-header-actions-gap); + flex-shrink: 0; +} + +.content { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + overflow: hidden; +} + +.searchWrapper { + width: 100%; + padding: 0 1rem; + padding-top: 0.75rem; +} + +.tabBody { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.list { + height: 100%; + min-height: 0; + padding: 1rem; + overflow: hidden; +} + +.emptyState { + display: flex; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + overflow: hidden; +} + +.emptyStateIcon { + margin-bottom: 1rem; +} + +.emptyStateTitle { + display: flex; + align-items: center; + justify-content: center; + height: 1.875rem; + margin-bottom: 0.5rem; + max-width: 100%; +} + +.emptyStateSubtitle { + display: flex; + align-items: center; + justify-content: center; + height: 1.5rem; + width: 100%; + max-width: 28rem; +} + +.sectionTitle { + composes: sectionTitle from '@app/features/channel/components/friends/FriendsListSection.module.css'; + display: flex; + align-items: center; + height: 1rem; +} + +.rows { + composes: sectionContent from '@app/features/channel/components/friends/FriendsListSection.module.css'; +} + +.rowsWithMargin { + composes: sectionContentWithMargin from '@app/features/channel/components/friends/FriendsListSection.module.css'; +} + +.row { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--friend-row-padding-block) var(--friend-row-padding-inline); + max-width: 100%; + overflow: hidden; +} + +.row::before { + content: ''; + position: absolute; + inset: var(--friend-row-separator-inset); + border-radius: var(--friend-row-separator-radius); + border-top: var(--skeleton-chrome-border); + pointer-events: none; +} + +.rowInfo { + display: flex; + align-items: center; + gap: var(--friend-row-content-gap); + flex: 1 1 0; + min-width: 0; + overflow: hidden; +} + +.rowDetails { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.0625rem; + flex: 1 1 0; + min-width: 0; + overflow: hidden; +} + +.rowNameBand { + display: flex; + align-items: center; + height: var(--friend-row-name-line-height); + min-width: 0; + overflow: hidden; +} + +.rowSubtextBand { + display: flex; + align-items: center; + height: var(--friend-row-subtext-line-height); + margin-top: var(--friend-row-subtext-offset); + min-width: 0; + overflow: hidden; +} + +.rowActions { + display: flex; + align-items: center; + gap: var(--friend-row-action-gap); + flex-shrink: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + width: var(--active-now-sidebar-width); + min-width: var(--active-now-sidebar-width); + height: 100%; + border-left: var(--skeleton-chrome-border); + background-color: var(--background-secondary-lighter); +} + +@media (max-width: 1100px) { + .sidebar { + display: none; + } +} + +.sidebarHeader { + display: flex; + align-items: center; + min-height: 3.625rem; + padding: var(--active-now-sidebar-header-padding-block-start) var(--active-now-sidebar-header-padding-inline); + padding-bottom: var(--active-now-sidebar-header-padding-block-end); +} + +.sidebarContent { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--active-now-sidebar-content-gap); + min-height: 0; + padding: 0 var(--active-now-sidebar-content-padding-inline) var(--active-now-sidebar-content-padding-block-end); + overflow: hidden; +} + +.sidebarEmptyState { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 0; + padding: 1.5rem; + overflow: hidden; +} + +.sidebarEmptyTitle { + display: flex; + align-items: center; + justify-content: center; + height: 1.3125rem; +} + +.sidebarEmptyDescription { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + width: 100%; +} + +.sidebarEmptyDescriptionLine { + display: flex; + align-items: center; + justify-content: center; + height: 1.05rem; + width: 100%; +} + +.activeNowCard { + --active-now-participant-extra-height: var(--avatar-stack-extra-height); + display: flex; + flex-direction: column; + gap: var(--active-now-card-gap); + box-sizing: border-box; + width: 100%; + padding: var(--active-now-card-padding-block) var(--active-now-card-padding-inline); + border: var(--skeleton-chrome-border); + border-radius: var(--active-now-card-radius); + background-color: var(--background-secondary); + box-shadow: var(--active-now-card-shadow); +} + +.activeNowContextGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.activeNowStatus { + display: flex; + align-items: center; + gap: var(--active-now-context-gap); + min-height: 1.125rem; +} + +.activeNowContext { + display: flex; + align-items: center; + gap: var(--active-now-context-gap); + min-width: 0; + min-height: 1.21875rem; + overflow: hidden; +} + +.activeNowParticipants { + display: flex; + align-items: center; + width: fit-content; + height: calc( + var(--active-now-participant-size) + + (2 * var(--active-now-participant-outline)) + + var(--active-now-participant-extra-height) + ); + padding-block: var(--active-now-participant-outline) var(--active-now-participant-extra-height); + overflow: visible; +} + +.activeNowAction { + display: flex; + align-items: center; + justify-content: center; + gap: var(--active-now-action-gap); + box-sizing: border-box; + width: 100%; + height: var(--active-now-action-height); + min-height: var(--active-now-action-height); + padding: var(--active-now-action-padding-block) var(--active-now-action-padding-inline); + border: var(--skeleton-chrome-border); + border-radius: var(--active-now-action-radius); + background-color: var(--form-surface-background); +} diff --git a/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.tsx new file mode 100644 index 000000000..131556316 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/FriendsSkeleton.tsx @@ -0,0 +1,641 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/FriendsSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonChannelHeaderLayout, + getRememberedSkeletonFriendsLayout, + type RememberedSkeletonActiveNowCard, + type RememberedSkeletonFriendsLayout, + SKELETON_DEFAULT_CHANNEL_HEADER_LAYOUT, + SKELETON_DEFAULT_FRIENDS_LAYOUT, + SKELETON_UNMEASURED_WIDTH_PX, + SkeletonFriendsTab, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import type {SkeletonInjectedToken} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; +import Initialization from '@app/features/app/state/Initialization'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {resolveAvatarStackGeometry, resolveAvatarStackWidthRem} from '@app/features/ui/avatars/AvatarStackGeometry'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import { + VOICE_ACTIVITY_AVATAR_MAX_VISIBLE, + VOICE_ACTIVITY_AVATAR_SIZE_PX, +} from '@app/features/user/components/profile/VoiceActivityCardMetrics'; +import PrivacyPreferences from '@app/features/user/state/PrivacyPreferences'; +import {flxElementClassName} from '@app/lib/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import {type CSSProperties, useMemo, useState} from 'react'; + +const FALLBACK_TAB_WIDTHS: ReadonlyArray = ['4.5rem', '2.75rem', '5rem', '6.25rem']; +const PENDING_TAB_INDEX = 2; +const PENDING_BADGE_ALLOWANCE = '1.75rem'; +const TAB_HEIGHT = '2rem'; +const BACK_BUTTON_SIZE = '2rem'; +const TITLE_ICON_SIZE = '1.5rem'; +const TITLE_WIDTH = '5.5rem'; +const TITLE_HEIGHT = '0.875rem'; +const HEADER_ACTION_SIZE = '2rem'; +const SEARCH_HEIGHT = '2.75rem'; +const SECTION_TITLE_WIDTH = '7rem'; +const SECTION_TITLE_HEIGHT = '0.625rem'; +const EMPTY_STATE_ICON_SIZE = '4rem'; +const EMPTY_STATE_TITLE_WIDTH = '18rem'; +const EMPTY_STATE_TITLE_HEIGHT = '1.125rem'; +const EMPTY_STATE_SUBTITLE_WIDTH = '22rem'; +const EMPTY_STATE_SUBTITLE_HEIGHT = '0.75rem'; +const ROW_AVATAR_SIZE = '2.25rem'; +const ROW_ACTION_SIZE = '2.25rem'; +const ROW_NAME_HEIGHT = '0.75rem'; +const ROW_SUBTEXT_HEIGHT = '0.625rem'; +const ROW_ACTION_COUNT = 2; +const OUTGOING_ROW_ACTION_COUNT = 1; +const ROW_HEIGHT_PX = 60; +const MAX_FALLBACK_ROW_COUNT = 24; +const ROW_NAME_WIDTH_MIN = 28; +const ROW_NAME_WIDTH_RANGE = 34; +const ROW_SUBTEXT_WIDTH_MIN = 18; +const ROW_SUBTEXT_WIDTH_RANGE = 26; +const SIDEBAR_TITLE_WIDTH = '6.5rem'; +const SIDEBAR_TITLE_HEIGHT = '1.125rem'; +const SIDEBAR_EMPTY_ICON_SIZE = '3rem'; +const SIDEBAR_EMPTY_TITLE_WIDTH = '9rem'; +const SIDEBAR_EMPTY_TITLE_HEIGHT = '0.8125rem'; +const SIDEBAR_EMPTY_DESCRIPTION_HEIGHT = '0.6875rem'; +const SIDEBAR_EMPTY_DESCRIPTION_WIDTHS: ReadonlyArray = ['100%', '68%']; +const ACTIVE_NOW_LABEL_HEIGHT = '0.625rem'; +const ACTIVE_NOW_IN_VOICE_LABEL_WIDTH = '3.75rem'; +const ACTIVE_NOW_STREAMING_LABEL_WIDTH = '4.75rem'; +const ACTIVE_NOW_CONTEXT_HEIGHT = '0.625rem'; +const ACTIVE_NOW_CONTEXT_WIDTH_MIN = 6; +const ACTIVE_NOW_CONTEXT_WIDTH_RANGE = 5; +const ACTIVE_NOW_PARTICIPANT_GEOMETRY = resolveAvatarStackGeometry(VOICE_ACTIVITY_AVATAR_SIZE_PX); +const ACTIVE_NOW_PARTICIPANT_METRICS = { + '--active-now-participant-size': ACTIVE_NOW_PARTICIPANT_GEOMETRY.sizeRem, + '--active-now-participant-overlap': ACTIVE_NOW_PARTICIPANT_GEOMETRY.overlapRem, + '--active-now-participant-outline': ACTIVE_NOW_PARTICIPANT_GEOMETRY.outlineRem, +} satisfies Partial>; +const ACTIVE_NOW_PARTICIPANT_STYLE = ACTIVE_NOW_PARTICIPANT_METRICS as CSSProperties; +const ACTIVE_NOW_ACTION_ICON_SIZE = '1rem'; +const ACTIVE_NOW_ACTION_LABEL_WIDTH = '4.75rem'; +const ACTIVE_NOW_ACTION_LABEL_HEIGHT = '0.625rem'; + +interface FriendRowSpec { + readonly nameWidth: string; + readonly subtextWidth: string; +} + +interface ActiveNowCardSpec { + readonly contextWidth: string; + readonly participantCount: number; + readonly streaming: boolean; +} + +const FALLBACK_ACTIVE_NOW_CARDS: ReadonlyArray = Object.freeze([ + Object.freeze({participantCount: 3, streaming: false}), + Object.freeze({participantCount: 2, streaming: false}), + Object.freeze({participantCount: 4, streaming: false}), +]); + +function resolveViewportRowCount(): number { + if (typeof window === 'undefined') { + return MAX_FALLBACK_ROW_COUNT; + } + return Math.min(MAX_FALLBACK_ROW_COUNT, Math.max(1, Math.ceil(window.innerHeight / ROW_HEIGHT_PX))); +} + +function resolveTabRowCount(layout: RememberedSkeletonFriendsLayout): number { + if (layout.activeTab === SkeletonFriendsTab.ONLINE) { + return layout.onlineRowCount; + } + if (layout.activeTab === SkeletonFriendsTab.ALL) { + return layout.allRowCount; + } + if (layout.activeTab === SkeletonFriendsTab.PENDING) { + return layout.pendingRowCount; + } + return 0; +} + +function resolveTabWidths(layout: RememberedSkeletonFriendsLayout): ReadonlyArray { + return FALLBACK_TAB_WIDTHS.map((fallbackWidth, index) => { + const measuredWidthPx = layout.tabWidthsPx[index] ?? SKELETON_UNMEASURED_WIDTH_PX; + if (measuredWidthPx !== SKELETON_UNMEASURED_WIDTH_PX) { + return remFromPx(measuredWidthPx); + } + if (index === PENDING_TAB_INDEX && layout.pendingBadgeVisible) { + return `calc(${fallbackWidth} + ${PENDING_BADGE_ALLOWANCE})`; + } + return fallbackWidth; + }); +} + +function resolveParticipantsWidth(participantCount: number): string { + return resolveAvatarStackWidthRem(participantCount, VOICE_ACTIVITY_AVATAR_SIZE_PX, VOICE_ACTIVITY_AVATAR_MAX_VISIBLE); +} + +function createActiveNowCardSpecs( + cards: ReadonlyArray, +): ReadonlyArray { + const random = createSkeletonRandomFromKey('friends-skeleton-active-now'); + return cards.map((card) => ({ + contextWidth: `${ACTIVE_NOW_CONTEXT_WIDTH_MIN + random() * ACTIVE_NOW_CONTEXT_WIDTH_RANGE}rem`, + participantCount: card.participantCount, + streaming: card.streaming, + })); +} + +function FriendSkeletonRow({row, actionCount}: {readonly row: FriendRowSpec; readonly actionCount: number}) { + return ( + + + + + + + + + + + + + + {Array.from({length: actionCount}, (_unused, actionIndex) => ( + + ))} + + + ); +} + +interface FriendsListSectionSpec { + readonly rows: ReadonlyArray; + readonly actionCount: number; + readonly withMargin: boolean; +} + +function resolveFriendsListSections( + layout: RememberedSkeletonFriendsLayout, + rows: ReadonlyArray, +): ReadonlyArray { + if (layout.activeTab !== SkeletonFriendsTab.PENDING) { + return [{rows, actionCount: ROW_ACTION_COUNT, withMargin: false}]; + } + const incomingRowCount = Math.min(layout.pendingIncomingRowCount, rows.length); + const incomingRows = rows.slice(0, incomingRowCount); + const outgoingRows = rows.slice(incomingRowCount); + if (incomingRows.length === 0) { + return [{rows: outgoingRows, actionCount: OUTGOING_ROW_ACTION_COUNT, withMargin: false}]; + } + if (outgoingRows.length === 0) { + return [{rows: incomingRows, actionCount: ROW_ACTION_COUNT, withMargin: true}]; + } + return [ + {rows: incomingRows, actionCount: ROW_ACTION_COUNT, withMargin: true}, + {rows: outgoingRows, actionCount: OUTGOING_ROW_ACTION_COUNT, withMargin: false}, + ]; +} + +interface FriendsListSectionSkeletonProps { + readonly rows: ReadonlyArray; + readonly actionCount: number; + readonly withMargin: boolean; +} + +function FriendsListSectionSkeleton({rows, actionCount, withMargin}: FriendsListSectionSkeletonProps) { + return ( + <> + + + + + {rows.map((row, index) => ( + + ))} + + + ); +} + +function FriendsListEmptyStateSkeleton() { + return ( + + + + + + + + + + ); +} + +function ActiveNowEmptyStateSkeleton() { + return ( + + + + + + + {SIDEBAR_EMPTY_DESCRIPTION_WIDTHS.map((lineWidth) => ( + + + + ))} + + + ); +} + +function ActiveNowCardSkeleton({spec}: {readonly spec: ActiveNowCardSpec}) { + let labelWidth = ACTIVE_NOW_IN_VOICE_LABEL_WIDTH; + if (spec.streaming) { + labelWidth = ACTIVE_NOW_STREAMING_LABEL_WIDTH; + } + return ( + + + + + + + + + + {spec.participantCount > 0 && ( + + + + )} + + + + + + ); +} + +export const FriendsSkeleton = observer(function FriendsSkeleton() { + const [{activeNowCards, headerLayout, layout, rowCount, showActiveNow}] = useState(() => { + const rememberedLayout = getRememberedSkeletonFriendsLayout(); + const layout = rememberedLayout ?? SKELETON_DEFAULT_FRIENDS_LAYOUT; + return { + activeNowCards: rememberedLayout != null ? rememberedLayout.activeNowCards : FALLBACK_ACTIVE_NOW_CARDS, + headerLayout: getRememberedSkeletonChannelHeaderLayout() ?? SKELETON_DEFAULT_CHANNEL_HEADER_LAYOUT, + layout, + rowCount: rememberedLayout != null ? resolveTabRowCount(rememberedLayout) : resolveViewportRowCount(), + showActiveNow: Initialization.isReady ? PrivacyPreferences.getShowActiveNow() : layout.activeNowVisible, + }; + }); + const isMobile = MobileLayout.enabled; + const tabWidths = useMemo(() => resolveTabWidths(layout), [layout]); + const showSearchBar = layout.activeTab !== SkeletonFriendsTab.ADD; + const headerActionCount = useMemo(() => { + if (isMobile) { + return 0; + } + let count = 1; + if (headerLayout.updaterVisible) { + count += 1; + } + if (headerLayout.staffToolsVisible) { + count += 1; + } + return count; + }, [headerLayout, isMobile]); + const rows = useMemo>(() => { + const random = createSkeletonRandomFromKey('friends-skeleton-rows'); + return Array.from({length: rowCount}, () => ({ + nameWidth: `${ROW_NAME_WIDTH_MIN + random() * ROW_NAME_WIDTH_RANGE}%`, + subtextWidth: `${ROW_SUBTEXT_WIDTH_MIN + random() * ROW_SUBTEXT_WIDTH_RANGE}%`, + })); + }, [rowCount]); + const listSections = useMemo(() => resolveFriendsListSections(layout, rows), [layout, rows]); + const activeNowCardSpecs = useMemo(() => createActiveNowCardSpecs(activeNowCards), [activeNowCards]); + return ( + + + + + + + {!isMobile && ( + <> + + + + + + + )} + + + {tabWidths.map((tabWidth, tabIndex) => ( + + ))} + + + + + + {Array.from({length: headerActionCount}, (_unused, actionIndex) => ( + + ))} + + + + {showSearchBar && ( + + + + )} + + {rowCount === 0 ? ( + + ) : ( + + {listSections.map((section, sectionIndex) => ( + + ))} + + )} + + + + {showActiveNow && ( + + + + + {activeNowCardSpecs.length === 0 ? ( + + ) : ( + + {activeNowCardSpecs.map((spec, index) => ( + + ))} + + )} + + )} + + ); +}); diff --git a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.module.css new file mode 100644 index 000000000..5219e9e19 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.module.css @@ -0,0 +1,277 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.rail { + grid-column: 1; + grid-row: 1; + position: relative; + z-index: var(--z-index-elevated-1); + height: calc( + 100% - + var(--layout-user-area-reserved-height, 0px) - + var(--layout-mobile-bottom-nav-reserved-height, 0px) + ); + min-height: 0; + width: var(--layout-guild-list-width); + min-width: 0; + background-color: var(--background-secondary); +} + +.scroller { + height: 100%; + width: 100%; + min-width: 0; + overflow: hidden; + clip-path: inset(0); + padding-top: var(--spacing-1); + padding-bottom: var(--spacing-2); +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-width: 0; + padding-bottom: calc( + var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) + + var(--spacing-2) + ); +} + +.section { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: var(--guild-list-item-gap); +} + +.sectionTrailingGap { + padding-bottom: var(--guild-list-item-gap); +} + +.items { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: 0; +} + +.itemSlot { + --guild-rail-skeleton-badge-size: var(--mention-badge-small-size); + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: var(--guild-list-item-target-size); + z-index: 0; +} + +.itemSlot[data-selected='true'] flx-skeleton-circle { + border-radius: 30%; +} + +.folderSlot { + min-height: var(--guild-list-item-target-size); + z-index: 1; +} + +.outageSlot { + min-height: var(--guild-list-item-box-size); +} + +.mentionBadge { + position: absolute; + right: calc(50% - var(--guild-icon-size) / 2 - 0.25rem); + bottom: calc(50% - var(--guild-icon-size) / 2 - 0.25rem); + box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface); +} + +.folderSlot .mentionBadge { + right: -0.25rem; + bottom: -0.25rem; +} + +.itemsTrailingGapCancel { + margin-bottom: calc(-1 * var(--guild-list-item-gap)); +} + +.collapsedFolder { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: var(--guild-list-item-box-size); + height: var(--guild-list-item-box-size); +} + +.collapsedFolderBackground { + position: absolute; + inset: 0; + border-radius: var(--folder-radius); +} + +.collapsedFolderGrid { + position: absolute; + inset: 0.25rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 0.125rem; +} + +.collapsedFolderMini { + border-radius: calc(var(--folder-radius) * 0.38); +} + +.expandedFolderSlot { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.expandedFolder { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.expandedFolderBackground { + position: absolute; + top: 0; + bottom: calc(var(--guild-list-item-gap) / 2); + height: auto; + border-radius: calc(var(--guild-list-item-box-size) * 0.32); +} + +.expandedFolderHeader { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: var(--guild-list-item-target-size); +} + +.expandedFolderHeaderSurface { + border-radius: var(--folder-radius); +} + +.expandedFolderGuilds { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: 0.0625rem; + padding-bottom: calc(var(--guild-list-item-gap) / 2); + transform: translateY(0rem); +} + +.expandedFolderGuild { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: var(--guild-list-item-target-size); + z-index: 0; +} + +.expandedFolderGuild[data-selected='true'] flx-skeleton-circle { + border-radius: 30%; +} + +.item { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: var(--guild-list-item-box-size); + z-index: 0; +} + +.item[data-selected='true'] flx-skeleton-circle { + border-radius: 30%; +} + +.fluxerIcon { + display: flex; + align-items: center; + justify-content: center; + height: var(--guild-icon-size); + width: var(--guild-icon-size); + flex-shrink: 0; + border-radius: var(--radius-full); + background-color: var(--guilds-layout-item-bg); + color: var(--text-primary); +} + +.item[data-selected='true'] .fluxerIcon { + border-radius: 30%; + background-color: var(--brand-primary); + color: white; +} + +.fluxerIconGlyph { + height: 1.75rem; + width: 1.75rem; + color: currentColor; +} + +.dividerSlot { + display: grid; + grid-template-rows: + var(--guild-list-divider-base-gap) + var(--guild-list-divider-line-size) + var(--guild-list-divider-base-gap); + justify-items: center; + width: 100%; + flex-shrink: 0; +} + +.dividerSlotExtended { + grid-template-rows: + calc(var(--guild-list-divider-base-gap) + var(--guild-list-item-target-half-extra)) + var(--guild-list-divider-line-size) + var(--guild-list-divider-base-gap); +} + +.divider { + grid-row: 2; + height: var(--guild-list-divider-line-size); + width: 2rem; + flex-shrink: 0; + border-radius: 0.0625rem; + background-color: var(--background-modifier-hover); +} + +.actionIcon { + composes: createGuildButtonIcon from '@app/features/app/components/layout/GuildsLayout.module.css'; +} + +.item[data-selected='true'] .actionIcon { + border-radius: 30%; + background-color: var(--brand-primary); + color: white; +} + +.actionIconGlyph { + height: 1.25rem; + width: 1.25rem; + color: currentColor; +} + +.favoritesIconGlyph { + height: 1.75rem; + width: 1.75rem; + color: currentColor; +} diff --git a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx new file mode 100644 index 000000000..4eaaa8381 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx @@ -0,0 +1,605 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css'; +import {resolveGuildListIndicatorBarTarget} from '@app/features/app/components/layout/sidebar_nav/GuildListIndicator'; +import styles from '@app/features/app/components/skeleton/GuildRailSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonGuildRailLayout, + type RememberedSkeletonGuildRailItem, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + SkeletonGuildRailItemIndicator, + SkeletonGuildRailItemKind, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {skeletonSurfaceVar} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import {Platform} from '@app/features/platform/types/Platform'; +import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; +import Dimension from '@app/features/ui/state/Dimension'; +import {flxElementClassName} from '@app/lib/react'; +import { + ChatCircleIcon, + CompassIcon, + DownloadSimpleIcon, + type Icon, + PlusIcon, + QuestionMarkIcon, + StarIcon, +} from '@phosphor-icons/react'; +import type React from 'react'; +import {useState} from 'react'; + +const GUILD_ICON_SIZE = skeletonSurfaceVar('--guild-icon-size'); +const FOLDER_ICON_SIZE = skeletonSurfaceVar('--guild-list-item-box-size'); +const MENTION_BADGE_SIZE = 'var(--guild-rail-skeleton-badge-size)'; +const GUILD_PLACEHOLDER_COUNT = 6; +const SHOWS_DOWNLOAD_ACTION = !Platform.isElectron && !Platform.isPWA; +const FALLBACK_ORGANIZED_ITEMS: ReadonlyArray = Object.freeze( + Array.from({length: GUILD_PLACEHOLDER_COUNT}, () => + Object.freeze({kind: SkeletonGuildRailItemKind.GUILD, indicator: SkeletonGuildRailItemIndicator.NONE} as const), + ), +); +const RAIL_ROW_PITCH_REM = 3.375; +const RAIL_DIVIDER_SLOT_REM = 0.8125; +const EMPTY_INLINE_DM_UNREAD_FLAGS: ReadonlyArray = Object.freeze([]); + +function countRailVisualRows(items: ReadonlyArray): number { + let visualRows = 0; + for (const item of items) { + if (item.kind === SkeletonGuildRailItemKind.EXPANDED_FOLDER) { + visualRows += item.childCount + 1; + continue; + } + visualRows += 1; + } + return visualRows; +} + +interface DrawableRailScrollTopRequest { + readonly bottomButtonCount: number; + readonly organizedItems: ReadonlyArray; + readonly outageVisible: boolean; + readonly remScale: number; + readonly scrollTopPx: number; + readonly topRowCount: number; + readonly viewportPx: number; +} + +function resolveDrawableRailScrollTopPx({ + bottomButtonCount, + organizedItems, + outageVisible, + remScale, + scrollTopPx, + topRowCount, + viewportPx, +}: DrawableRailScrollTopRequest): number { + if (scrollTopPx <= 0) { + return 0; + } + const outageRows = outageVisible ? 1 : 0; + const visualRows = topRowCount + outageRows + countRailVisualRows(organizedItems) + bottomButtonCount; + const drawableContentPx = (visualRows * RAIL_ROW_PITCH_REM + RAIL_DIVIDER_SLOT_REM) * remScale; + return Math.min(scrollTopPx, Math.max(0, drawableContentPx - viewportPx)); +} + +const GuildRailSkeletonIndicator = ({selected}: {readonly selected: boolean}) => ( + + + +); + +const InlineDMPlaceholder = ({selected, unread}: {readonly selected: boolean; readonly unread: boolean}) => ( + + {(selected || unread) && ( + + )} + + +); + +const RailActionButton = ({icon: Icon, selected = false}: {readonly icon: Icon; readonly selected?: boolean}) => ( + + + + + +); + +const FavoritesButtonPlaceholder = ({selected}: {readonly selected: boolean}) => ( + + {selected && ( + + )} + + + + +); + +function selectedAttribute(selected: boolean): string | undefined { + if (selected) { + return 'true'; + } + return undefined; +} + +interface SkeletonIndicatorVisibilityRequest { + readonly indicator: SkeletonGuildRailItemIndicator; + readonly selected: boolean; +} + +function showsSkeletonIndicator({indicator, selected}: SkeletonIndicatorVisibilityRequest): boolean { + if (selected) { + return true; + } + return indicator !== SkeletonGuildRailItemIndicator.NONE; +} + +const MentionBadgePlaceholder = () => ( + +); + +const GuildPlaceholder = ({ + indicator, + selected, +}: { + readonly indicator: SkeletonGuildRailItemIndicator; + readonly selected: boolean; +}) => ( + + {showsSkeletonIndicator({indicator, selected}) && ( + + )} + + {indicator === SkeletonGuildRailItemIndicator.MENTION && ( + + )} + +); + +const FluxerButtonPlaceholder = ({selected}: {readonly selected: boolean}) => ( + + {selected && ( + + )} + + + + +); + +const OutagePlaceholder = () => ( + + + +); + +const CollapsedFolderPlaceholder = ({ + childCount, + showIconWhenCollapsed, + indicator, + selected, +}: { + readonly childCount: number; + readonly showIconWhenCollapsed: boolean; + readonly indicator: SkeletonGuildRailItemIndicator; + readonly selected: boolean; +}) => ( + + {showsSkeletonIndicator({indicator, selected}) && ( + + )} + + + {showIconWhenCollapsed ? ( + + ) : ( + + {Array.from({length: childCount}, (_, index) => ( + + ))} + + )} + + {indicator === SkeletonGuildRailItemIndicator.MENTION && ( + + )} + +); + +const ExpandedFolderGuildPlaceholder = ({ + indicator, + selected, +}: { + readonly indicator: SkeletonGuildRailItemIndicator; + readonly selected: boolean; +}) => ( + + {showsSkeletonIndicator({indicator, selected}) && ( + + )} + + {indicator === SkeletonGuildRailItemIndicator.MENTION && ( + + )} + +); + +const ExpandedFolderPlaceholder = ({ + childCount, + childIndicators, + selectedChildIndex, +}: { + readonly childCount: number; + readonly childIndicators: ReadonlyArray; + readonly selectedChildIndex: number; +}) => ( + + + + + + + + {Array.from({length: childCount}, (_, index) => ( + + ))} + + + +); + +function renderOrganizedItem( + item: RememberedSkeletonGuildRailItem, + index: number, + selectedItemIndex: number, +): React.ReactNode { + const selected = index === selectedItemIndex; + switch (item.kind) { + case SkeletonGuildRailItemKind.GUILD: + return ( + + ); + case SkeletonGuildRailItemKind.COLLAPSED_FOLDER: + return ( + + ); + case SkeletonGuildRailItemKind.EXPANDED_FOLDER: + return ( + + ); + } +} + +interface GuildRailSkeletonProps { + readonly isFluxerSelected: boolean; + readonly isFavoritesSelected: boolean; + readonly isDiscoverySelected: boolean; +} + +export const GuildRailSkeleton: React.FC = ({ + isFluxerSelected, + isFavoritesSelected, + isDiscoverySelected, +}) => { + const [mountState] = useState(() => { + const rememberedLayout = getRememberedSkeletonGuildRailLayout(); + const remScale = getRemScaleForDocument(document); + const liveScrollTop = Math.max(0, Dimension.getGuildListDimensions().scrollTop); + let scrollTop = liveScrollTop; + if (liveScrollTop === 0 && rememberedLayout != null) { + scrollTop = rememberedLayout.scrollTopPx * remScale; + } + return Object.freeze({ + rememberedLayout, + remScale, + scrollTop, + viewportPx: document.documentElement.clientHeight, + }); + }); + const {rememberedLayout} = mountState; + const communityActionsAvailable = !RuntimeConfig.singleCommunityEnabled; + const inlineDmRowCount = rememberedLayout?.inlineDmRowCount ?? 0; + const inlineDmUnreadFlags = rememberedLayout?.inlineDmUnreadFlags ?? EMPTY_INLINE_DM_UNREAD_FLAGS; + const selectedInlineDmRowIndex = rememberedLayout?.selectedInlineDmRowIndex ?? SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + const inlineDmPlaceholders = Array.from({length: inlineDmRowCount}, (_, index) => index); + const outageVisible = rememberedLayout?.outageVisible ?? false; + const organizedItems = rememberedLayout?.organizedItems ?? FALLBACK_ORGANIZED_ITEMS; + const selectedItemIndex = rememberedLayout?.selectedItemIndex ?? SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + const fluxerVisible = rememberedLayout?.fluxerVisible ?? !RuntimeConfig.directMessagesDisabled; + const favoritesVisible = rememberedLayout?.favoritesVisible ?? true; + const discoveryVisible = rememberedLayout?.discoveryVisible ?? communityActionsAvailable; + const addGuildVisible = rememberedLayout?.addGuildVisible ?? communityActionsAvailable; + const downloadVisible = rememberedLayout?.downloadVisible ?? SHOWS_DOWNLOAD_ACTION; + const helpVisible = rememberedLayout?.helpVisible ?? true; + const hasGuildItems = outageVisible || organizedItems.length > 0; + const fluxerIsLastTopRow = fluxerVisible && !favoritesVisible && inlineDmRowCount === 0; + const hasBottomRailButtons = discoveryVisible || addGuildVisible || downloadVisible || helpVisible; + const itemsEndWithoutGap = outageVisible && organizedItems.length === 0 && hasBottomRailButtons; + let guildsSectionTrailingGap: boolean; + if (helpVisible) { + guildsSectionTrailingGap = false; + } else if (discoveryVisible || addGuildVisible || downloadVisible) { + guildsSectionTrailingGap = true; + } else { + guildsSectionTrailingGap = organizedItems.length > 0; + } + const topRowCount = (fluxerVisible ? 1 : 0) + (favoritesVisible ? 1 : 0) + inlineDmRowCount; + const bottomButtonCount = + (discoveryVisible ? 1 : 0) + (addGuildVisible ? 1 : 0) + (downloadVisible ? 1 : 0) + (helpVisible ? 1 : 0); + const scrollTop = resolveDrawableRailScrollTopPx({ + bottomButtonCount, + organizedItems, + outageVisible, + remScale: mountState.remScale, + scrollTopPx: mountState.scrollTop, + topRowCount, + viewportPx: mountState.viewportPx, + }); + let contentStyle: React.CSSProperties | undefined; + if (scrollTop > 0) { + contentStyle = {marginTop: `${-scrollTop}px`}; + } + return ( + + + + + {fluxerVisible && ( + + )} + {favoritesVisible && ( + + )} + {inlineDmPlaceholders.map((index) => ( + + ))} + + + + + + {hasGuildItems && ( + + {outageVisible && } + {organizedItems.map((item, index) => renderOrganizedItem(item, index, selectedItemIndex))} + + )} + {discoveryVisible && ( + + )} + {addGuildVisible && ( + + )} + {downloadVisible && ( + + )} + {helpVisible && ( + + )} + + + + + ); +}; diff --git a/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.module.css new file mode 100644 index 000000000..2395368a3 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.module.css @@ -0,0 +1,83 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.navigation { + width: 100%; + min-width: 0; + padding-right: var(--spacing-2); +} + +.detachedBanner { + position: relative; + width: 100%; + max-height: 30vh; + overflow: hidden; + border-bottom: 0.0625rem solid var(--user-area-divider-color); + background-color: var(--background-secondary); +} + +.topSpacer { + height: 0.625rem; +} + +.membersSection { + padding-bottom: 0.125rem; +} + +.rowMembers { + margin-top: 0; +} + +.membersSeparator { + height: 0.0625rem; + margin: 0.375rem 0.75rem; + background: var(--background-modifier-hover); +} + +.bottomDropZone { + height: 0.625rem; +} + +.groups { + display: flex; + flex-direction: column; + min-width: 0; +} + +.group { + display: flex; + flex-direction: column; + min-width: 0; +} + +.row { + display: flex; + min-width: 0; + align-items: center; + margin-left: 0.5rem; + padding: 0.375rem 0.5rem; +} + +.rowCategory { + box-sizing: border-box; + height: 2rem; + margin-top: 0.25rem; + gap: 0.25rem; +} + +.categoryCaret { + margin-left: auto; +} + +.rowChannel { + margin-top: 0.0625rem; + gap: 0.375rem; +} + +.channelIcon { + display: inline-block; +} + +.bottomSpacer { + flex: 0 0 auto; + height: calc(var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) + var(--spacing-2)); +} diff --git a/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.tsx new file mode 100644 index 000000000..6de3f9f86 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/GuildSidebarSkeleton.tsx @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/GuildSidebarSkeleton.module.css'; +import {SidebarShellSkeleton} from '@app/features/app/components/skeleton/SidebarShellSkeleton'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import { + getRememberedSkeletonGuildChannelList, + getRememberedSkeletonGuildPresentation, + type RememberedSkeletonGuildChannelList, + type RememberedSkeletonGuildPresentation, + SKELETON_UNMEASURED_WIDTH_PX, + SkeletonGuildBannerPlacement, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {flxElementClassName} from '@app/lib/react'; +import type React from 'react'; +import {useMemo} from 'react'; + +const FALLBACK_CATEGORY_CHANNEL_COUNTS: ReadonlyArray = [3, 5, 4, 6]; +const CATEGORY_CARET_SIZE = '0.75rem'; +const CATEGORY_LABEL_HEIGHT = '0.625rem'; +const CATEGORY_LABEL_WIDTH_MIN = 28; +const CATEGORY_LABEL_WIDTH_RANGE = 24; +const CHANNEL_ICON_SIZE = '1.25rem'; +const CHANNEL_LABEL_HEIGHT = '0.75rem'; +const CHANNEL_LABEL_WIDTH_MIN = 34; +const CHANNEL_LABEL_WIDTH_RANGE = 40; +const MEMBERS_LABEL_WIDTH = '4rem'; + +interface GuildChannelSpec { + readonly labelWidth: string; +} + +interface GuildCategorySpec { + readonly headerVisible: boolean; + readonly labelWidth: string; + readonly channels: ReadonlyArray; +} + +function resolveMeasuredWidth(widthPx: number, fallbackWidth: string): string { + if (widthPx === SKELETON_UNMEASURED_WIDTH_PX) { + return fallbackWidth; + } + return remFromPx(widthPx); +} + +function createFallbackCategorySpecs(): ReadonlyArray { + const random = createSkeletonRandomFromKey('guild-sidebar-skeleton-channels'); + return FALLBACK_CATEGORY_CHANNEL_COUNTS.map((channelCount) => ({ + headerVisible: true, + labelWidth: `${CATEGORY_LABEL_WIDTH_MIN + random() * CATEGORY_LABEL_WIDTH_RANGE}%`, + channels: Array.from({length: channelCount}, () => ({ + labelWidth: `${CHANNEL_LABEL_WIDTH_MIN + random() * CHANNEL_LABEL_WIDTH_RANGE}%`, + })), + })); +} + +function createRememberedCategorySpecs( + channelList: RememberedSkeletonGuildChannelList, +): ReadonlyArray { + const random = createSkeletonRandomFromKey('guild-sidebar-skeleton-channels'); + return channelList.groups.map((group) => ({ + headerVisible: group.categoryHeaderVisible, + labelWidth: resolveMeasuredWidth( + group.categoryNameWidthPx, + `${CATEGORY_LABEL_WIDTH_MIN + random() * CATEGORY_LABEL_WIDTH_RANGE}%`, + ), + channels: group.channels.map((channel) => ({ + labelWidth: resolveMeasuredWidth( + channel.nameWidthPx, + `${CHANNEL_LABEL_WIDTH_MIN + random() * CHANNEL_LABEL_WIDTH_RANGE}%`, + ), + })), + })); +} + +function GuildChannelSkeletonRow({width}: {readonly width: string}) { + return ( + + + + + ); +} + +function GuildCategorySkeletonGroup({category}: {readonly category: GuildCategorySpec}) { + return ( + + {category.headerVisible && ( + + + + + )} + {category.channels.map((channel, channelIndex) => ( + + ))} + + ); +} + +interface GuildChannelListSkeletonProps { + readonly channelList: RememberedSkeletonGuildChannelList | null; + readonly detachedBannerAspectRatio: number | null; +} + +export const GuildChannelListSkeleton: React.FC = ({ + channelList, + detachedBannerAspectRatio, +}) => { + const categories = useMemo>(() => { + if (channelList == null) { + return createFallbackCategorySpecs(); + } + return createRememberedCategorySpecs(channelList); + }, [channelList]); + const membersRowVisible = channelList?.membersRowVisible ?? false; + let detachedBannerStyle: React.CSSProperties | undefined; + if (detachedBannerAspectRatio != null) { + detachedBannerStyle = {aspectRatio: `${detachedBannerAspectRatio}`}; + } + return ( + + {detachedBannerStyle != null && ( + + + + )} + + {membersRowVisible && ( + <> + + + + + + + + + )} + + {categories.map((category, categoryIndex) => ( + + ))} + + {categories.length > 0 && ( + + )} + + ); +}; + +interface GuildSidebarSkeletonProps { + readonly guildId: string | null; +} + +function resolveDetachedBannerAspectRatio(presentation: RememberedSkeletonGuildPresentation | null): number | null { + if (presentation == null || presentation.bannerPlacement !== SkeletonGuildBannerPlacement.DETACHED) { + return null; + } + return presentation.bannerAspectRatio; +} + +export const GuildSidebarSkeleton: React.FC = ({guildId}) => { + const remembered = useMemo(() => { + if (guildId == null) { + return Object.freeze({ + presentation: null as RememberedSkeletonGuildPresentation | null, + channelList: null as RememberedSkeletonGuildChannelList | null, + }); + } + return Object.freeze({ + presentation: getRememberedSkeletonGuildPresentation(guildId), + channelList: getRememberedSkeletonGuildChannelList(guildId), + }); + }, [guildId]); + return ( + + + + + ); +}; diff --git a/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.module.css new file mode 100644 index 000000000..cebf90403 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.module.css @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + height: var(--mobile-bottom-nav-height); + align-items: center; + justify-content: space-around; + border-top: 0.0625rem solid var(--background-header-secondary); + background-color: var(--background-secondary); +} + +:global(.theme-light) .container { + background-color: var(--background-primary); +} + +.navItem { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.navItemLabel { + display: flex; + align-items: center; + justify-content: center; + height: 0.9375rem; +} diff --git a/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.tsx new file mode 100644 index 000000000..53fd28a70 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/MobileBottomNavSkeleton.tsx @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/MobileBottomNavSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {flxElementClassName} from '@app/lib/react'; +import type React from 'react'; + +const ICON_SIZE = '1.5rem'; +const LABEL_HEIGHT = '0.5rem'; + +const MobileBottomNavSkeletonGlyph = Object.freeze({ + ICON: 'icon', + AVATAR: 'avatar', +} as const); + +type MobileBottomNavSkeletonGlyph = (typeof MobileBottomNavSkeletonGlyph)[keyof typeof MobileBottomNavSkeletonGlyph]; + +interface MobileBottomNavItemSpec { + glyph: MobileBottomNavSkeletonGlyph; + labelWidth: string; +} + +const HOME_NAV_ITEM: MobileBottomNavItemSpec = {glyph: MobileBottomNavSkeletonGlyph.ICON, labelWidth: '2.25rem'}; +const VOICE_NAV_ITEM: MobileBottomNavItemSpec = {glyph: MobileBottomNavSkeletonGlyph.ICON, labelWidth: '2.5rem'}; +const NOTIFICATIONS_NAV_ITEM: MobileBottomNavItemSpec = {glyph: MobileBottomNavSkeletonGlyph.ICON, labelWidth: '4rem'}; +const YOU_NAV_ITEM: MobileBottomNavItemSpec = {glyph: MobileBottomNavSkeletonGlyph.AVATAR, labelWidth: '1.5rem'}; + +function resolveNavItems(voiceItemVisible: boolean): ReadonlyArray { + if (voiceItemVisible) { + return [HOME_NAV_ITEM, VOICE_NAV_ITEM, NOTIFICATIONS_NAV_ITEM, YOU_NAV_ITEM]; + } + return [HOME_NAV_ITEM, NOTIFICATIONS_NAV_ITEM, YOU_NAV_ITEM]; +} + +function renderNavItem(item: MobileBottomNavItemSpec, index: number): React.ReactNode { + let glyph: React.ReactNode; + if (item.glyph === MobileBottomNavSkeletonGlyph.AVATAR) { + glyph = ( + + ); + } else { + glyph = ( + + ); + } + return ( + + {glyph} + + + + + ); +} + +interface MobileBottomNavSkeletonProps { + readonly voiceItemVisible: boolean; +} + +export const MobileBottomNavSkeleton: React.FC = ({voiceItemVisible}) => ( + + {resolveNavItems(voiceItemVisible).map(renderNavItem)} + +); diff --git a/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.module.css new file mode 100644 index 000000000..860eba082 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.module.css @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.stack { + display: flex; + flex-direction: column; + width: 100%; +} + +.row { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 2.25rem; + padding: 0.25rem 0.5rem; + background: var(--background-secondary-lighter); +} + +.rowDismissible { + padding-right: 2.5rem; +} + +.rowMobile { + min-height: 3rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.rowMobileWithActions { + flex-direction: column; + min-height: 4.625rem; + gap: 0.5rem; +} + +.tinted { + background-color: currentcolor; +} diff --git a/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.tsx new file mode 100644 index 000000000..504b7fa0a --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/NagbarSkeleton.tsx @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {NAGBAR_TONES} from '@app/features/app/components/layout/NagbarTones'; +import styles from '@app/features/app/components/skeleton/NagbarSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import type {RememberedSkeletonNagbarRow} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {flxElementClassName} from '@app/lib/react'; +import type React from 'react'; + +const MESSAGE_WIDTH_DESKTOP = 'min(18rem, 60%)'; +const MESSAGE_WIDTH_MOBILE = 'min(12rem, 72%)'; +const MESSAGE_HEIGHT = '0.75rem'; +const ACTION_WIDTH = '6rem'; +const ACTION_HEIGHT = '2rem'; + +interface NagbarSkeletonProps { + readonly isMobile: boolean; + readonly rows: ReadonlyArray; +} + +function resolveRowStyle(row: RememberedSkeletonNagbarRow): React.CSSProperties { + const tone = NAGBAR_TONES[row.tone]; + return {backgroundColor: tone.backgroundColor, color: tone.textColor}; +} + +export const NagbarSkeleton: React.FC = ({isMobile, rows}) => { + if (rows.length === 0) { + return null; + } + let messageWidth = MESSAGE_WIDTH_DESKTOP; + if (isMobile) { + messageWidth = MESSAGE_WIDTH_MOBILE; + } + return ( + + {rows.map((row, index) => ( + + + {isMobile && row.hasActions && ( + + )} + + ))} + + ); +}; diff --git a/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts b/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts new file mode 100644 index 000000000..0617e02ad --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {MESSAGE_LAYOUT_SPEC} from '@app/features/theme/layout/MessageLayoutSpec'; +import {REM_BASE_PX} from '@app/features/theme/layout/RemFromPx'; +import {useMemo} from 'react'; + +function pxFromRemLength(value: `${number}rem`): number { + return Math.round(Number.parseFloat(value) * REM_BASE_PX); +} + +export interface PlaceholderAttachmentSize { + readonly width: number; + readonly height: number; +} + +export interface PlaceholderMessageGroup { + readonly lineWidths: ReadonlyArray; + readonly usernameWidth: number; + readonly timestampWidth: number; + readonly attachment: PlaceholderAttachmentSize | null; +} + +interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup { + attachment: PlaceholderAttachmentSize | null; +} + +export interface PlaceholderSpecs { + readonly compact: boolean; + readonly compactAvatarsVisible: boolean; + readonly groups: ReadonlyArray; + readonly totalHeight: number; + readonly groupSpacing: number; +} + +const MESSAGE_LINE_HEIGHT = pxFromRemLength(MESSAGE_LAYOUT_SPEC.lineHeight); +const MESSAGE_ROW_VERTICAL_PADDING = pxFromRemLength(MESSAGE_LAYOUT_SPEC.spacingY) * 2; +const MESSAGE_HEIGHT_COMPACT = MESSAGE_LINE_HEIGHT + MESSAGE_ROW_VERTICAL_PADDING; +const COZY_LEAD_MESSAGE_HEIGHT = MESSAGE_LINE_HEIGHT * 2 + MESSAGE_ROW_VERTICAL_PADDING; +const COZY_GROUPED_MESSAGE_HEIGHT = MESSAGE_LINE_HEIGHT + MESSAGE_ROW_VERTICAL_PADDING; +const SKELETON_PADDING_TOP = pxFromRemLength('1rem'); +const SKELETON_PADDING_BOTTOM = pxFromRemLength('0.75rem'); +const COZY_SKELETON_VERTICAL_PADDING = SKELETON_PADDING_TOP + SKELETON_PADDING_BOTTOM; +const ATTACHMENT_MARGIN = 8; +const ATTACHMENT_WIDTH_MIN = 140; +const ATTACHMENT_WIDTH_MAX = 400; +const ATTACHMENT_HEIGHT_MIN = 100; +const ATTACHMENT_HEIGHT_MAX = 250; +const USERNAME_WIDTH_MIN = 48; +const USERNAME_WIDTH_RANGE = 36; +const TIMESTAMP_WIDTH_MIN = 8; +const TIMESTAMP_WIDTH_RANGE = 12; +const LINE_WIDTH_MIN = 75; +const LINE_WIDTH_RANGE = 18; +const MIN_MESSAGE_GROUPS = 8; +const MAX_MESSAGE_GROUPS = 64; +const GROUP_LINE_RANGE = 4; +const ATTACHMENT_GROUPS = 8; + +interface PlaceholderGenerationOptions { + readonly compact: boolean; + readonly compactAvatarsVisible: boolean; + readonly messageGroups: number; + readonly attachments: number; + readonly groupSpacing: number; + readonly random: () => number; +} + +function randomInRange(random: () => number, min: number, max: number): number { + return Math.floor(random() * (max - min + 1)) + min; +} + +function resolveShortestGroupHeight(compact: boolean): number { + if (compact) { + return MESSAGE_HEIGHT_COMPACT; + } + return COZY_LEAD_MESSAGE_HEIGHT; +} + +function resolvePlaceholderMessageGroups(compact: boolean, viewportHeightPx: number): number { + const shortestGroupHeight = resolveShortestGroupHeight(compact); + const groups = Math.ceil(Math.max(0, viewportHeightPx) / shortestGroupHeight); + return Math.min(MAX_MESSAGE_GROUPS, Math.max(MIN_MESSAGE_GROUPS, groups)); +} + +function resolvePlaceholderDensityKey(compact: boolean): string { + if (compact) { + return '1'; + } + return '0'; +} + +function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): PlaceholderSpecs { + const {compact, compactAvatarsVisible, messageGroups, attachments, groupSpacing, random} = options; + const groups: Array = []; + let totalHeight = compact ? 0 : COZY_SKELETON_VERTICAL_PADDING; + for (let index = 0; index < messageGroups; index++) { + const lineCount = Math.floor(random() * GROUP_LINE_RANGE) + 1; + const lineWidths: Array = []; + for (let line = 0; line < lineCount; line++) { + lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE); + } + groups.push({ + lineWidths, + usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE, + timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE, + attachment: null, + }); + if (index > 0) { + totalHeight += groupSpacing; + } + if (compact) { + totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount; + } else { + totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1); + } + } + const availableGroupIndices = Array.from(Array(groups.length).keys()); + for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) { + const groupIndex = availableGroupIndices.splice(Math.floor(random() * availableGroupIndices.length), 1)[0]; + const attachment = { + width: randomInRange(random, ATTACHMENT_WIDTH_MIN, ATTACHMENT_WIDTH_MAX), + height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX), + }; + groups[groupIndex].attachment = attachment; + totalHeight += attachment.height + ATTACHMENT_MARGIN; + } + return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing}; +} + +export interface PlaceholderSpecsOptions { + readonly compact: boolean; + readonly compactAvatarsVisible: boolean; + readonly groupSpacing: number; + readonly viewportHeightPx: number; + readonly seedKey: string; +} + +export function usePlaceholderSpecs({ + compact, + compactAvatarsVisible, + groupSpacing, + viewportHeightPx, + seedKey, +}: PlaceholderSpecsOptions): PlaceholderSpecs { + const messageGroups = resolvePlaceholderMessageGroups(compact, viewportHeightPx); + return useMemo( + () => + generatePlaceholderSpecs({ + compact, + compactAvatarsVisible, + messageGroups, + attachments: Math.min(ATTACHMENT_GROUPS, messageGroups), + groupSpacing, + random: createSkeletonRandomFromKey([seedKey, resolvePlaceholderDensityKey(compact), groupSpacing].join('|')), + }), + [compact, compactAvatarsVisible, groupSpacing, messageGroups, seedKey], + ); +} diff --git a/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts b/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts new file mode 100644 index 000000000..3cb5ffd1b --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Routes} from '@app/app/Routes'; +import { + getRememberedSkeletonChannelHeaderLayout, + getRememberedSkeletonChannelProjection, + getRememberedSkeletonMemberGroups, + type RememberedSkeletonChannelProjection, + type RememberedSkeletonMemberGroup, + resolveSkeletonChannelProjectionKind, + resolveSkeletonMemberSurfaceKind, + SKELETON_DEFAULT_CHANNEL_HEADER_LAYOUT, + SKELETON_UNMEASURED_WIDTH_PX, + SkeletonChannelProjectionKind, + type SkeletonMemberSurfaceKind, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import Channels from '@app/features/channel/state/Channels'; +import MemberList from '@app/features/member/state/MemberList'; + +export const ChatSkeletonChannelKind = SkeletonChannelProjectionKind; + +export type ChatSkeletonChannelKind = SkeletonChannelProjectionKind; + +export interface ChatSkeletonPresentation { + readonly channelKind: ChatSkeletonChannelKind; + readonly showMemberList: boolean; + readonly showTopic: boolean; + readonly staffToolsVisible: boolean; + readonly updaterVisible: boolean; + readonly favoritesVisible: boolean; + readonly headerNameWidthPx: number; + readonly headerTopicWidthPx: number; + readonly headerDesktopLeadingActionCount: number | null; + readonly headerMobileActionCount: number | null; + readonly rememberedMemberGroups: ReadonlyArray | null; +} + +const CHANNEL_ID_PATH_INDEX = 3; + +function resolveChannelId(pathname: string): string | null { + if (!Routes.isChannelRoute(pathname)) { + return null; + } + const channelId = pathname.split('/')[CHANNEL_ID_PATH_INDEX]; + if (!channelId || channelId === 'members') { + return null; + } + return channelId; +} + +function resolveChannelKind( + pathname: string, + channelId: string | null, + rememberedProjection: RememberedSkeletonChannelProjection | null, +): ChatSkeletonChannelKind { + const channel = channelId ? Channels.getChannel(channelId) : undefined; + if (channel) { + return resolveSkeletonChannelProjectionKind(channel.type); + } + if (rememberedProjection) { + return rememberedProjection.channelKind; + } + if (Routes.isDMRoute(pathname)) { + return ChatSkeletonChannelKind.DM; + } + return ChatSkeletonChannelKind.GUILD; +} + +function resolveMemberListVisibility( + channelId: string | null, + channelKind: ChatSkeletonChannelKind, + canFitMemberList: boolean, + searchPanelOpen: boolean, + rememberedMemberListVisible: boolean | null, +): boolean { + if (!canFitMemberList || !channelId || searchPanelOpen) { + return false; + } + switch (channelKind) { + case ChatSkeletonChannelKind.GUILD: + case ChatSkeletonChannelKind.GROUP_DM: + return rememberedMemberListVisible ?? MemberList.isMembersVisible(); + case ChatSkeletonChannelKind.GUILD_VOICE: + return rememberedMemberListVisible ?? MemberList.isMembersVisible({channelId, defaultHiddenForChannel: true}); + case ChatSkeletonChannelKind.DM: + case ChatSkeletonChannelKind.PERSONAL_NOTES: + return false; + } +} + +export function resolveChatSkeletonPresentation(pathname: string, canFitMemberList: boolean): ChatSkeletonPresentation { + const channelId = resolveChannelId(pathname); + const channel = channelId ? Channels.getChannel(channelId) : undefined; + const rememberedProjection = channelId == null ? null : getRememberedSkeletonChannelProjection(channelId); + const rememberedHeader = getRememberedSkeletonChannelHeaderLayout() ?? SKELETON_DEFAULT_CHANNEL_HEADER_LAYOUT; + const channelKind = resolveChannelKind(pathname, channelId, rememberedProjection); + const memberSurfaceKind: SkeletonMemberSurfaceKind | null = resolveSkeletonMemberSurfaceKind(channelKind); + const guildChannel = + channelKind === ChatSkeletonChannelKind.GUILD || channelKind === ChatSkeletonChannelKind.GUILD_VOICE; + let showTopic = false; + if (guildChannel) { + if (channel != null) { + showTopic = Boolean(channel.topic); + } else { + showTopic = rememberedProjection?.showTopic ?? false; + } + } + let rememberedMemberGroups: ReadonlyArray | null = null; + if (channelId != null && memberSurfaceKind != null) { + rememberedMemberGroups = getRememberedSkeletonMemberGroups(channelId, memberSurfaceKind); + } + let headerTopicWidthPx = SKELETON_UNMEASURED_WIDTH_PX; + if (showTopic && rememberedProjection != null) { + headerTopicWidthPx = rememberedProjection.topicWidthPx; + } + return { + channelKind, + showMemberList: resolveMemberListVisibility( + channelId, + channelKind, + canFitMemberList, + rememberedProjection?.searchPanelOpen ?? false, + rememberedProjection?.memberListVisible ?? null, + ), + showTopic, + staffToolsVisible: rememberedHeader.staffToolsVisible, + updaterVisible: rememberedHeader.updaterVisible, + favoritesVisible: rememberedHeader.favoritesVisible, + headerNameWidthPx: rememberedProjection?.nameWidthPx ?? SKELETON_UNMEASURED_WIDTH_PX, + headerTopicWidthPx, + headerDesktopLeadingActionCount: rememberedProjection?.desktopLeadingActionCount ?? null, + headerMobileActionCount: rememberedProjection?.mobileActionCount ?? null, + rememberedMemberGroups, + }; +} diff --git a/fluxer_app/src/features/app/components/skeleton/ResolveSkeletonShell.ts b/fluxer_app/src/features/app/components/skeleton/ResolveSkeletonShell.ts new file mode 100644 index 000000000..cb55011b7 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ResolveSkeletonShell.ts @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Routes} from '@app/app/Routes'; +import {SkeletonSimplePageRoute} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; + +export const SkeletonSidebarVariant = Object.freeze({ + DM: 'dm', + GUILD: 'guild', + DISCOVERY: 'discovery', +} as const); + +export type SkeletonSidebarVariant = (typeof SkeletonSidebarVariant)[keyof typeof SkeletonSidebarVariant]; +export type SkeletonContent = + | {kind: 'chat'} + | {kind: 'friends'} + | {kind: 'discovery'} + | {kind: 'page'; route: SkeletonSimplePageRoute} + | {kind: 'empty'}; + +export type SkeletonShell = + | {chrome: 'guilds'; sidebar: SkeletonSidebarVariant; content: SkeletonContent} + | {chrome: 'bare'; content: SkeletonContent}; + +const GUILD_MEMBERS_PATH_SUFFIX = '/members'; +const USER_PROFILE_PATH_PREFIX = '/users/'; +const GUILD_ROOT_PATH_SEGMENT_COUNT = 3; + +function isGuildRootPath(pathname: string): boolean { + return pathname.split('/').length === GUILD_ROOT_PATH_SEGMENT_COUNT; +} + +function resolveDirectMessageContent(pathname: string): SkeletonContent { + if (pathname === Routes.ME) { + return {kind: 'friends'}; + } + return {kind: 'chat'}; +} + +function resolveFavoritesContent(pathname: string): SkeletonContent { + if (pathname === Routes.FAVORITES) { + return {kind: 'empty'}; + } + return {kind: 'chat'}; +} + +function resolveGuildContent(pathname: string): SkeletonContent { + if (pathname.endsWith(GUILD_MEMBERS_PATH_SUFFIX)) { + return {kind: 'page', route: SkeletonSimplePageRoute.GUILD_MEMBERS}; + } + return {kind: 'chat'}; +} + +export function showsMobileGuildRail(pathname: string): boolean { + if (pathname === Routes.ME || Routes.isDiscoverRoute(pathname)) { + return true; + } + return Routes.isChannelRoute(pathname) && isGuildRootPath(pathname); +} + +export function showsMobileSidebar(pathname: string): boolean { + if (Routes.isDiscoverRoute(pathname)) { + return false; + } + return showsMobileGuildRail(pathname); +} + +export function showsMobileBottomNav(pathname: string): boolean { + if (pathname === Routes.ME) return true; + if (pathname === Routes.FAVORITES) return true; + if (pathname === Routes.NOTIFICATIONS) return true; + if (pathname === Routes.YOU) return true; + return Routes.isGuildChannelRoute(pathname) && isGuildRootPath(pathname); +} + +export function reservesMobileBottomNavSpace(pathname: string): boolean { + return showsMobileBottomNav(pathname) || Routes.isDiscoverRoute(pathname); +} + +export function resolveSkeletonShell(pathname: string): SkeletonShell { + if (pathname === Routes.YOU || pathname.startsWith(USER_PROFILE_PATH_PREFIX)) { + return {chrome: 'bare', content: {kind: 'empty'}}; + } + if (pathname === Routes.NOTIFICATIONS) { + return {chrome: 'bare', content: {kind: 'page', route: SkeletonSimplePageRoute.NOTIFICATIONS}}; + } + if (Routes.isDiscoverRoute(pathname)) { + return {chrome: 'guilds', sidebar: SkeletonSidebarVariant.DISCOVERY, content: {kind: 'discovery'}}; + } + if (pathname === Routes.BOOKMARKS) { + return { + chrome: 'guilds', + sidebar: SkeletonSidebarVariant.DM, + content: {kind: 'page', route: SkeletonSimplePageRoute.BOOKMARKS}, + }; + } + if (pathname === Routes.MENTIONS) { + return { + chrome: 'guilds', + sidebar: SkeletonSidebarVariant.DM, + content: {kind: 'page', route: SkeletonSimplePageRoute.MENTIONS}, + }; + } + if (Routes.isDMRoute(pathname)) { + return { + chrome: 'guilds', + sidebar: SkeletonSidebarVariant.DM, + content: resolveDirectMessageContent(pathname), + }; + } + if (Routes.isFavoritesRoute(pathname)) { + return { + chrome: 'guilds', + sidebar: SkeletonSidebarVariant.GUILD, + content: resolveFavoritesContent(pathname), + }; + } + if (Routes.isGuildChannelRoute(pathname)) { + return { + chrome: 'guilds', + sidebar: SkeletonSidebarVariant.GUILD, + content: resolveGuildContent(pathname), + }; + } + return {chrome: 'guilds', sidebar: SkeletonSidebarVariant.DM, content: {kind: 'friends'}}; +} diff --git a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css new file mode 100644 index 000000000..2d4b228ac --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css @@ -0,0 +1,156 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.wrapper { + pointer-events: none; + user-select: none; + -webkit-user-select: none; + width: 100%; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + padding-top: 1rem; + padding-bottom: 0.75rem; + margin-left: calc(-1 * var(--message-mobile-margin)); + margin-right: calc(-1 * var(--message-mobile-margin)); + padding-left: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)); + padding-right: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)); +} + +.wrapperCompact { + margin-left: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))); + margin-right: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))); + padding-left: var(--message-compact-horizontal-padding); + padding-right: var(--message-compact-horizontal-padding); +} + +@media (min-width: 768px) { + .wrapper { + margin-left: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))); + margin-right: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))); + padding-left: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)); + padding-right: calc( + var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) + + var(--message-action-bar-offset) + ); + } + + .wrapperCompact { + padding-left: var(--message-compact-horizontal-padding); + padding-right: calc(var(--message-compact-horizontal-padding) + var(--message-action-bar-offset)); + } +} + +.messageGroup { + position: relative; + z-index: 0; +} + +.group { + display: flex; + gap: var(--message-gutter); + align-items: flex-start; + padding-block: var(--message-spacing-y); +} + +.avatar { + margin-top: calc(var(--message-spacing-y) + var(--message-avatar-align-offset)); +} + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + gap: 0.75rem; + height: var(--message-line-height); +} + +.cozyLeadLine { + display: flex; + align-items: center; + height: var(--message-line-height); +} + +.cozyContinuation { + display: grid; + grid-template-columns: var(--message-avatar-size) var(--message-gutter) minmax(0, 1fr); + align-items: center; + min-height: calc(var(--message-line-height) + 2 * var(--message-spacing-y)); +} + +.cozyContinuationLine { + grid-column: 3; +} + +.cozyAttachment { + display: block; + margin-top: 0.5rem; + margin-left: calc(var(--message-avatar-size) + var(--message-gutter)); +} + +.compactMessages { + display: flex; + flex-direction: column; +} + +.compactRow { + display: grid; + grid-template-columns: var(--message-compact-timestamp-width) var(--message-compact-gap) minmax(0, 1fr); + align-items: center; + min-height: calc(var(--message-line-height) + 2 * var(--message-spacing-y)); +} + +.compactTimestamp { + display: flex; + grid-column: 1; + align-items: center; + justify-content: flex-start; + min-width: 0; +} + +.compactBody { + display: flex; + grid-column: 3; + align-items: center; + gap: var(--message-compact-username-gap); + min-width: 0; +} + +.compactAuthorPrefix { + display: flex; + align-items: center; + flex-shrink: 0; + min-width: 0; +} + +.compactAvatar { + margin-inline-end: var(--message-compact-gap); + flex-shrink: 0; +} + +.compactUsername { + min-width: 3rem; + max-width: 8rem; +} + +.compactLine { + min-width: 0; + flex-shrink: 1; +} + +.compactAttachment { + display: block; + box-sizing: border-box; + padding-top: 0.5rem; + padding-left: calc( + var(--message-compact-timestamp-width) + + var(--message-compact-gap) + + var(--message-compact-text-indent-from-username) + ); +} diff --git a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx new file mode 100644 index 000000000..acca37819 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {PlaceholderMessageGroup, PlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs'; +import styles from '@app/features/app/components/skeleton/ScrollFillerSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {skeletonSurfaceVar} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {flxElementClassName} from '@app/lib/react'; +import type React from 'react'; + +const AVATAR_SIZE = skeletonSurfaceVar('--message-avatar-size'); +const COMPACT_AVATAR_SIZE = skeletonSurfaceVar('--message-avatar-size-compact'); +const USERNAME_HEIGHT = '0.75rem'; +const TIMESTAMP_HEIGHT = '0.625rem'; +const LINE_HEIGHT = '0.75rem'; +const COMPACT_TIMESTAMP_HEIGHT = '0.625rem'; +const COMPACT_USERNAME_HEIGHT = '0.75rem'; +const COMPACT_LINE_HEIGHT = '0.75rem'; + +function resolveSkeletonGroupMarginBottom( + groupIndex: number, + groupCount: number, + groupSpacing: number, +): number | `${number}rem` { + if (groupIndex === groupCount - 1) { + return 0; + } + return remFromPx(groupSpacing); +} + +interface ScrollFillerSkeletonGroupProps { + readonly group: PlaceholderMessageGroup; + readonly groupCount: number; + readonly groupIndex: number; + readonly groupSpacing: number; + readonly compactAvatarsVisible: boolean; +} + +function CozyScrollFillerSkeletonGroup({ + group, + groupCount, + groupIndex, + groupSpacing, +}: ScrollFillerSkeletonGroupProps): React.ReactElement { + const firstLineWidth = group.lineWidths[0]; + if (firstLineWidth == null) { + throw new Error('Cozy message skeleton groups must contain at least one message line.'); + } + return ( + + + + + + + + + + + + + + {group.lineWidths.map((lineWidth, lineIndex) => { + if (lineIndex === 0) { + return null; + } + return ( + + + + ); + })} + {group.attachment != null && ( + + + + )} + + ); +} + +function CompactScrollFillerSkeletonGroup({ + group, + groupCount, + groupIndex, + groupSpacing, + compactAvatarsVisible, +}: ScrollFillerSkeletonGroupProps): React.ReactElement { + const timestampWidth = `${Math.min(78, 55 + group.timestampWidth)}%`; + const usernameWidth = `${Math.min(32, group.usernameWidth * 0.45)}%`; + const usernameLine = ( + + ); + const authorPrefix = compactAvatarsVisible ? ( + + + {usernameLine} + + ) : ( + usernameLine + ); + return ( + + + {group.lineWidths.map((lineWidth, lineIndex) => ( + + + + + + {lineIndex === 0 && authorPrefix} + + + + ))} + + {group.attachment != null && ( + + + + )} + + ); +} + +export const ScrollFillerSkeleton = ({ + compact, + compactAvatarsVisible, + groups, + groupSpacing, + totalHeight, +}: PlaceholderSpecs) => ( + + {groups.map((group, groupIndex) => { + const props = { + group, + groupCount: groups.length, + groupIndex, + groupSpacing, + compactAvatarsVisible, + }; + if (compact) { + return ( + + ); + } + return ( + + ); + })} + +); diff --git a/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.module.css new file mode 100644 index 000000000..2bc77b888 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.module.css @@ -0,0 +1,76 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: grid; + grid-template-rows: auto 1fr; + height: calc( + 100% - + var(--layout-user-area-reserved-height, 0px) - + var(--layout-mobile-bottom-nav-reserved-height, 0px) + ); + min-height: 0; + width: var(--layout-sidebar-width); + min-width: 0; + overflow: hidden; + background-color: var(--background-secondary); +} + +.containerMobile { + width: 100%; +} + +:global(html.platform-native) .container { + border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem); + background-clip: padding-box; +} + +.headerFrame { + position: relative; + display: flex; + align-items: center; + box-sizing: border-box; + height: var(--layout-header-height); + min-height: var(--layout-header-height); + width: 100%; + min-width: 0; + overflow: hidden; + border-bottom: 0.0625rem solid var(--user-area-divider-color); +} + +.headerFrameBanner { + align-items: flex-start; + height: auto; + min-height: var(--layout-header-height); + max-height: 30vh; +} + +.headerBanner { + position: absolute; + inset: 0; +} + +.header { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: var(--spacing-1); + height: var(--layout-header-height); + min-height: var(--layout-header-height); + width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 var(--spacing-4); +} + +.headerCaret { + margin-left: auto; +} + +.content { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} diff --git a/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.tsx new file mode 100644 index 000000000..d6de2c03d --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SidebarShellSkeleton.tsx @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/SidebarShellSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import { + type RememberedSkeletonGuildPresentation, + SKELETON_UNMEASURED_WIDTH_PX, + SkeletonGuildBannerPlacement, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import {flxElementClassName} from '@app/lib/react'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; + +const HEADER_NAME_FALLBACK_WIDTH = '55%'; +const HEADER_NAME_HEIGHT = '0.75rem'; +const HEADER_BADGE_SIZE = '1.25rem'; +const HEADER_CARET_SIZE = '1rem'; +const HEADER_DOTS_SIZE = '1.5rem'; + +interface SidebarShellSkeletonProps { + readonly children: React.ReactNode; + readonly guildPresentation?: RememberedSkeletonGuildPresentation | null; +} + +function resolveHeaderNameWidth(guildPresentation: RememberedSkeletonGuildPresentation | null | undefined): string { + if (guildPresentation == null || guildPresentation.headerNameWidthPx === SKELETON_UNMEASURED_WIDTH_PX) { + return HEADER_NAME_FALLBACK_WIDTH; + } + return remFromPx(guildPresentation.headerNameWidthPx); +} + +export const SidebarShellSkeleton = observer(({children, guildPresentation}: SidebarShellSkeletonProps) => { + const isMobile = MobileLayout.enabled; + const showIntegratedBanner = guildPresentation?.bannerPlacement === SkeletonGuildBannerPlacement.INTEGRATED; + let trailingGlyphSize = HEADER_CARET_SIZE; + if (isMobile) { + trailingGlyphSize = HEADER_DOTS_SIZE; + } + let headerStyle: React.CSSProperties | undefined; + if (showIntegratedBanner && guildPresentation != null) { + headerStyle = {aspectRatio: `${guildPresentation.bannerAspectRatio}`}; + } + return ( + + + {showIntegratedBanner && ( + + )} + + {guildPresentation?.badgeVisible === true && ( + + )} + + + + + + {children} + + + ); +}); diff --git a/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.module.css new file mode 100644 index 000000000..2a0e51dca --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.module.css @@ -0,0 +1,294 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + width: 100%; + min-width: 0; + background-color: var(--background-secondary); + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + height: var(--layout-header-height); + min-height: var(--layout-header-height); + flex-shrink: 0; + padding: 0 var(--channel-header-padding-inline); + border-bottom: 0.0625rem solid var(--user-area-divider-color); +} + +@media (max-width: 767px) { + .header:not(.headerCompact) { + height: var(--layout-header-height-mobile); + min-height: var(--layout-header-height-mobile); + } +} + +.headerCompact { + height: 3rem; + min-height: 3rem; + border-bottom: none; +} + +.headerLeading { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.headerActions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.rows { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: var(--spacing-2) 0; + overflow: hidden; +} + +.row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + min-width: 0; +} + +.rowDetails { + display: flex; + flex-direction: column; + gap: 0.375rem; + flex: 1 1 0; + min-width: 0; + overflow: hidden; +} + +.channelGroups { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 0 0.75rem 0.75rem; + overflow: hidden; +} + +.channelGroupHeading { + display: flex; + align-items: center; + box-sizing: border-box; + min-height: 2.25rem; + padding: 0.75rem 0.25rem 0.5rem; +} + +.channelCard { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + min-width: 0; +} + +.channelCardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + box-sizing: border-box; + height: 3rem; + margin-bottom: 0.5rem; + padding: 0 0.25rem; +} + +.channelCardStream { + display: flex; + flex-direction: column; + padding: 0 0.5rem; + min-width: 0; + overflow: hidden; +} + +.channelCardFooter { + display: flex; + justify-content: flex-start; + padding: 0.5rem 0.75rem 0.625rem; +} + +.tableContent { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.75rem; + box-sizing: border-box; + height: 100%; + min-height: 0; + min-width: 0; + padding: 1rem 1.25rem; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-shrink: 0; + min-width: 0; +} + +.toolbarLeft { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + height: 1.5rem; +} + +.toolbarRight { + display: flex; + align-items: stretch; + gap: 0.5rem; + min-width: 0; +} + +.tableWrapper { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-width: 0; + min-height: 0; + height: 100%; + border: 0.0625rem solid var(--background-header-secondary); + border-radius: 0.5rem; + background-color: var(--background-secondary-alt); + overflow: hidden; +} + +.tableViewport { + min-width: 0; + min-height: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; +} + +.tableSurface { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-width: var(--guild-members-min-width); + min-height: 0; + height: 100%; + overflow: hidden; +} + +.tableSurfaceSelectable { + --guild-members-columns: var(--guild-members-columns-selectable); + --guild-members-min-width: var(--guild-members-min-width-selectable); +} + +.tableFooter { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: space-between; + box-sizing: border-box; + gap: 0.75rem; + min-width: 0; + flex-shrink: 0; + padding: 0.75rem 0.875rem; + border-top: var(--guild-members-table-row-border-width) solid var(--background-header-secondary); +} + +.tableFooterLeft { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.tableFooterRight { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + min-width: 0; +} + +.tableHeadRow { + display: grid; + grid-template-columns: var(--guild-members-columns); + min-width: 0; + flex-shrink: 0; + border-bottom: var(--guild-members-table-row-border-width) solid var(--background-header-secondary); +} + +.tableHeadCell { + display: flex; + align-items: center; + box-sizing: border-box; + min-width: 0; + padding: 0.3125rem 0.875rem; + height: 1.65625rem; +} + +.tableBody { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tableRow { + display: grid; + grid-template-columns: var(--guild-members-columns); + min-width: 0; + flex-shrink: 0; + border-bottom: var(--guild-members-table-row-border-width) solid var(--background-header-secondary); +} + +.tableRow:last-child { + border-bottom: none; +} + +.tableCell { + display: flex; + align-items: center; + box-sizing: border-box; + min-width: 0; + min-height: var(--guild-members-table-row-min-height); + padding: 0 0.875rem; +} + +.tableNameCell { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; + width: 100%; +} + +.tableNameInfo { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + flex: 1 1 0; + overflow: hidden; +} + +.tableActionsCell { + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; +} diff --git a/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.tsx new file mode 100644 index 000000000..0f76ea19e --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SimplePageSkeleton.tsx @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/SimplePageSkeleton.module.css'; +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + getRememberedSkeletonSimplePageLayout, + type RememberedSkeletonSimplePageLayout, + resolveDefaultSkeletonSimplePageLayout, + SkeletonSimplePageBody, + SkeletonSimplePageRoute, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {DEFAULT_PAGE_SIZE} from '@app/features/channel/components/guild_members_page/GuildMembersPageShared'; +import {flxElementClassName} from '@app/lib/react'; +import {Fragment, useMemo, useState} from 'react'; + +const MESSAGE_ROW_HEIGHT_PX = 60; +const MEMBER_TABLE_ROW_HEIGHT_PX = 49; +const CHANNEL_CARD_HEIGHT_PX = 256; +const MAX_DERIVED_ROW_COUNT = 64; +const HEADER_ICON_SIZE = '1.5rem'; +const HEADER_TITLE_WIDTH = '7rem'; +const HEADER_TITLE_HEIGHT = '0.875rem'; +const HEADER_FILTER_WIDTH = '6.5rem'; +const HEADER_ACTION_SIZE = '2rem'; +const ROW_AVATAR_SIZE = '2.25rem'; +const ROW_NAME_HEIGHT = '0.75rem'; +const ROW_SUBTEXT_HEIGHT = '0.625rem'; +const ROW_META_HEIGHT = '0.625rem'; +const ROW_NAME_WIDTH_MIN = 30; +const ROW_NAME_WIDTH_RANGE = 36; +const ROW_SUBTEXT_WIDTH_MIN = 45; +const ROW_SUBTEXT_WIDTH_RANGE = 45; +const ROW_META_WIDTH_MIN = 2.5; +const ROW_META_WIDTH_RANGE = 1.5; +const TABLE_HEAD_LABEL_WIDTHS: ReadonlyArray = ['4rem', '5.5rem', '6rem', '5.5rem', '3rem', '3.5rem']; +const TABLE_SELECT_COLUMN_LABEL_WIDTH = '1rem'; +const TABLE_SELECT_BOX_SIZE = '1.125rem'; +const TABLE_FOOTER_PAGE_SIZE_WIDTH = '5.5rem'; +const TABLE_FOOTER_PAGE_SIZE_HEIGHT = '2.75rem'; +const TABLE_FOOTER_PAGE_BUTTON_SIZE = '2rem'; +const TABLE_FOOTER_PAGE_BUTTON_COUNT = 5; +const TABLE_FOOTER_LABEL_WIDTH = '5.75rem'; +const TABLE_FOOTER_LABEL_HEIGHT = '0.6875rem'; +const TABLE_HEAD_LABEL_HEIGHT = '0.625rem'; +const TABLE_AVATAR_SIZE = '2rem'; +const TABLE_NAME_HEIGHT = '0.75rem'; +const TABLE_TAG_HEIGHT = '0.625rem'; +const TABLE_CELL_HEIGHT = '0.6875rem'; +const TABLE_ACTIONS_SIZE = '1.5rem'; +const TOOLBAR_TITLE_WIDTH = '5rem'; +const TOOLBAR_TITLE_HEIGHT = '0.875rem'; +const TOOLBAR_SUBTITLE_WIDTH = '7rem'; +const TOOLBAR_SUBTITLE_HEIGHT = '0.6875rem'; +const TOOLBAR_CONTROL_HEIGHT = '2.75rem'; +const TOOLBAR_SEARCH_WIDTH = '14rem'; +const TOOLBAR_SORT_WIDTH = '5.5rem'; +const CHANNEL_GROUP_HEADING_WIDTH = '6rem'; +const CHANNEL_GROUP_HEADING_HEIGHT = '0.625rem'; +const CHANNEL_CARD_ICON_SIZE = '1.25rem'; +const CHANNEL_CARD_TITLE_WIDTH = '9rem'; +const CHANNEL_CARD_TITLE_HEIGHT = '0.875rem'; +const CHANNEL_CARD_MESSAGE_COUNT = 2; +const CHANNEL_CARD_FOOTER_WIDTH = '5.5rem'; +const CHANNEL_CARD_FOOTER_HEIGHT = '1.75rem'; +const CHANNELS_PER_GROUP = 2; + +export interface SimplePageSkeletonProps { + readonly route: SkeletonSimplePageRoute; +} + +interface SimplePageRowSpec { + readonly nameWidth: string; + readonly subtextWidth: string; + readonly metaWidth: string; +} + +const BODY_ROW_HEIGHTS_PX: Readonly> = Object.freeze({ + [SkeletonSimplePageBody.MEMBER_TABLE]: MEMBER_TABLE_ROW_HEIGHT_PX, + [SkeletonSimplePageBody.MESSAGE_LIST]: MESSAGE_ROW_HEIGHT_PX, + [SkeletonSimplePageBody.CHANNEL_LIST]: CHANNEL_CARD_HEIGHT_PX, +}); + +function resolveViewportRowCount(body: SkeletonSimplePageBody): number { + if (typeof window === 'undefined') { + return 0; + } + const rowHeightPx = BODY_ROW_HEIGHTS_PX[body]; + return Math.min(MAX_DERIVED_ROW_COUNT, Math.max(1, Math.ceil(window.innerHeight / rowHeightPx))); +} + +function resolveSimplePageLayout(route: SkeletonSimplePageRoute): RememberedSkeletonSimplePageLayout { + const rememberedLayout = getRememberedSkeletonSimplePageLayout(route); + if (rememberedLayout != null) { + return rememberedLayout; + } + const defaultLayout = resolveDefaultSkeletonSimplePageLayout(route); + return {body: defaultLayout.body, rowCount: resolveViewportRowCount(defaultLayout.body), selectable: false}; +} + +function resolveRowSpecCount(layout: RememberedSkeletonSimplePageLayout): number { + if (layout.body === SkeletonSimplePageBody.CHANNEL_LIST) { + return layout.rowCount * CHANNEL_CARD_MESSAGE_COUNT; + } + return layout.rowCount; +} + +function createRowSpecs(rowCount: number): ReadonlyArray { + const random = createSkeletonRandomFromKey('simple-page-skeleton-rows'); + return Array.from({length: rowCount}, () => ({ + nameWidth: `${ROW_NAME_WIDTH_MIN + random() * ROW_NAME_WIDTH_RANGE}%`, + subtextWidth: `${ROW_SUBTEXT_WIDTH_MIN + random() * ROW_SUBTEXT_WIDTH_RANGE}%`, + metaWidth: `${ROW_META_WIDTH_MIN + random() * ROW_META_WIDTH_RANGE}rem`, + })); +} + +function SimplePageSkeletonRow({row}: {readonly row: SimplePageRowSpec}) { + return ( + + + + + + + + + ); +} + +function SimplePageMessageListSkeleton({rows}: {readonly rows: ReadonlyArray}) { + return ( + + {rows.map((row, index) => ( + + ))} + + ); +} + +function SimplePageChannelCardSkeleton({rows}: {readonly rows: ReadonlyArray}) { + return ( + + + + + + + {rows.map((row, index) => ( + + ))} + + + + + + ); +} + +function SimplePageChannelListSkeleton({rows}: {readonly rows: ReadonlyArray}) { + const cards = useMemo(() => { + const cardCount = Math.ceil(rows.length / CHANNEL_CARD_MESSAGE_COUNT); + return Array.from({length: cardCount}, (_unused, cardIndex) => + rows.slice(cardIndex * CHANNEL_CARD_MESSAGE_COUNT, (cardIndex + 1) * CHANNEL_CARD_MESSAGE_COUNT), + ); + }, [rows]); + return ( + + {cards.map((cardRows, cardIndex) => ( + + {cardIndex % CHANNELS_PER_GROUP === 0 && ( + + + + )} + + + ))} + + ); +} + +function SimplePageMemberTableRowSkeleton({ + row, + selectable, +}: { + readonly row: SimplePageRowSpec; + readonly selectable: boolean; +}) { + return ( + + {selectable && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function SimplePageMemberTableFooterSkeleton({paginationVisible}: {readonly paginationVisible: boolean}) { + return ( + + + + + + {paginationVisible && ( + + {Array.from({length: TABLE_FOOTER_PAGE_BUTTON_COUNT}, (_unused, buttonIndex) => ( + + ))} + + )} + + ); +} + +function SimplePageMemberTableSkeleton({ + rows, + selectable, +}: { + readonly rows: ReadonlyArray; + readonly selectable: boolean; +}) { + return ( + + + + + + + + + + + + + + + + {selectable && ( + + + + )} + {TABLE_HEAD_LABEL_WIDTHS.map((labelWidth, columnIndex) => ( + + + + ))} + + + {rows.map((row, index) => ( + + ))} + + + + = DEFAULT_PAGE_SIZE} + data-flx="app.skeleton.simple-page-skeleton.simple-page-member-table-skeleton.simple-page-member-table-footer-skeleton" + /> + + + ); +} + +function renderSimplePageBody( + body: SkeletonSimplePageBody, + rows: ReadonlyArray, + selectable: boolean, +) { + switch (body) { + case SkeletonSimplePageBody.MEMBER_TABLE: + return ( + + ); + case SkeletonSimplePageBody.CHANNEL_LIST: + return ( + + ); + case SkeletonSimplePageBody.MESSAGE_LIST: + return ( + + ); + } +} + +export const SimplePageSkeleton = ({route}: SimplePageSkeletonProps) => { + const [layout] = useState(() => resolveSimplePageLayout(route)); + const compactHeader = route === SkeletonSimplePageRoute.NOTIFICATIONS; + const rows = useMemo(() => createRowSpecs(resolveRowSpecCount(layout)), [layout]); + return ( + + + + {!compactHeader && ( + + )} + + + {compactHeader && ( + + + + + )} + + {renderSimplePageBody(layout.body, rows, layout.selectable)} + + ); +}; diff --git a/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css b/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css new file mode 100644 index 000000000..86fd83b23 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css @@ -0,0 +1,99 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +@property --skeleton-shimmer-position { + syntax: ''; + inherits: true; + initial-value: -150%; +} + +.skeleton { + --skeleton-opacity: var(--skeleton-opacity-default, 0.45); + --skeleton-width: 100%; + --skeleton-height: 0.75rem; + background-color: var(--background-modifier-accent); + background-image: linear-gradient( + 105deg, + transparent 46%, + color-mix(in srgb, var(--text-primary) 7%, transparent) 50%, + transparent 54% + ); + background-size: 200vw 100%; + background-position: var(--skeleton-shimmer-position) 0; + background-attachment: fixed; + opacity: var(--skeleton-opacity); + pointer-events: none; + user-select: none; + -webkit-user-select: none; + flex-shrink: 0; + width: var(--skeleton-width); + height: var(--skeleton-height); + max-width: 100%; +} + +:global(html):has(.skeleton) { + animation: skeleton-shimmer 3.2s ease-in-out infinite; +} + +@keyframes skeleton-shimmer { + from { + --skeleton-shimmer-position: -150%; + } + + to { + --skeleton-shimmer-position: 150%; + } +} + +@media (prefers-reduced-motion: reduce) { + :global(html):has(.skeleton) { + animation: none; + } + + .skeleton { + background-image: none; + } +} + +:global(html.reduced-motion):has(.skeleton) { + animation: none; +} + +:global(html.reduced-motion) .skeleton { + background-image: none; +} + +.emphasisStrong { + --skeleton-opacity: var(--skeleton-opacity-strong, 0.55); +} + +.emphasisMuted { + --skeleton-opacity: var(--skeleton-opacity-muted, 0.35); +} + +.radiusSharp { + border-radius: 0; +} + +.radiusSm { + border-radius: var(--radius-sm); +} + +.radiusMd { + border-radius: var(--radius-md); +} + +.radiusLg { + border-radius: var(--radius-lg); +} + +.radiusXl { + border-radius: var(--radius-xl); +} + +.radiusPill { + border-radius: var(--radius-full); +} + +.radiusCircle { + border-radius: 50%; +} diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonBlock.tsx b/fluxer_app/src/features/app/components/skeleton/SkeletonBlock.tsx new file mode 100644 index 000000000..d999a44f8 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonBlock.tsx @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + SkeletonEmphasis, + SkeletonRadius, + skeletonClassName, + skeletonDimensionsStyle, +} from '@app/features/app/components/skeleton/SkeletonStyle'; +import type React from 'react'; + +export interface SkeletonBlockProps { + readonly width?: string; + readonly height?: string; + readonly radius?: SkeletonRadius; + readonly emphasis?: SkeletonEmphasis; + readonly className?: string; + readonly style?: React.CSSProperties; +} + +export const SkeletonBlock = ({ + width, + height, + radius = SkeletonRadius.MEDIUM, + emphasis = SkeletonEmphasis.DEFAULT, + className, + style, +}: SkeletonBlockProps) => ( + +); diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonCircle.tsx b/fluxer_app/src/features/app/components/skeleton/SkeletonCircle.tsx new file mode 100644 index 000000000..be190984d --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonCircle.tsx @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + SkeletonEmphasis, + SkeletonRadius, + skeletonClassName, + skeletonDimensionsStyle, +} from '@app/features/app/components/skeleton/SkeletonStyle'; +import type React from 'react'; + +export interface SkeletonCircleProps { + readonly size: string; + readonly emphasis?: SkeletonEmphasis; + readonly className?: string; + readonly style?: React.CSSProperties; +} + +export const SkeletonCircle = ({size, emphasis = SkeletonEmphasis.DEFAULT, className, style}: SkeletonCircleProps) => ( + +); diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonLayoutMemory.ts b/fluxer_app/src/features/app/components/skeleton/SkeletonLayoutMemory.ts new file mode 100644 index 000000000..3fd7a136e --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonLayoutMemory.ts @@ -0,0 +1,3630 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {getDefaultMessageGroupSpacing} from '@app/features/accessibility/state/MessageGroupSpacing'; +import {DEFAULT_MESSAGE_GUTTER_PX} from '@app/features/accessibility/state/MessagePresentationDefaults'; +import {NagbarToneKind} from '@app/features/app/components/layout/NagbarTones'; +import SessionManager from '@app/features/platform/state/AuthSession'; +import { + AuthSessionStorageKey, + parseStoredSessionValue, +} from '@app/features/platform/state/auth_session/AuthSessionStorage'; +import AppStorage from '@app/features/platform/state/PersistentStorage'; +import {MESSAGE_LAYOUT_SPEC} from '@app/features/theme/layout/MessageLayoutSpec'; +import {getRemScaleForDocument, REM_BASE_PX} from '@app/features/theme/layout/RemFromPx'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; + +const STORAGE_KEY = 'SkeletonLayoutMemory'; +const STORAGE_VERSION = 9; +const MIN_READABLE_STORAGE_VERSION = 4; +const SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; +const FUTURE_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; +const WRITE_COALESCE_MS = 1000; +const MAX_DM_SIDEBAR_ROWS = 40; +const MAX_GUILD_RAIL_INLINE_DM_ROWS = 16; +export const SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT = 24; +const MAX_GUILD_RAIL_SCROLL_TOP_PX = 100_000; +export const SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT = 4; +const MAX_FRIEND_ROWS = 40; +const MAX_ACTIVE_NOW_CARDS = 40; +const MAX_ACTIVE_NOW_PARTICIPANTS = 99; +const MAX_NAGBAR_ROWS = 3; +const MAX_MEMBER_GROUPS = 8; +const MAX_MEMBER_GROUP_ROWS = 50; +const MAX_CHANNEL_MEMBER_LAYOUTS = 16; +const MAX_CHANNEL_PROJECTIONS = 16; +const MAX_GUILD_PRESENTATIONS = 8; +const MAX_GUILD_CHANNEL_LISTS = 6; +const MAX_GUILD_CHANNEL_GROUPS = 16; +const MAX_GUILD_CHANNEL_ROWS = 64; +const MAX_DISCOVERY_CATEGORY_TABS = 24; +const MAX_DISCOVERY_COLUMNS = 4; +const MAX_DISCOVERY_VISIBLE_ROWS = 64; +const MAX_SIMPLE_PAGE_ROWS = 200; +const MAX_HEADER_ACTION_COUNT = 8; +const MAX_COMPOSER_DESKTOP_ACTION_COUNT = 8; +const MAX_COMPOSER_MOBILE_ACTION_COUNT = 4; +const MAX_MEASURED_WIDTH_PX = 4096; +const MAX_MEASURED_HEIGHT_PX = 8192; +const MAX_MESSAGE_GUTTER_PX = 200; +const MAX_MESSAGE_GROUP_SPACING_PX = 64; +const MIN_MESSAGE_FONT_SIZE_PX = 8; +const MAX_MESSAGE_FONT_SIZE_PX = 64; +const MAX_VOICE_CONNECTION_HEIGHT_PX = 512; +const MIN_BANNER_ASPECT_RATIO = 0.25; +const MAX_BANNER_ASPECT_RATIO = 8; +const BANNER_ASPECT_RATIO_PRECISION = 1000; +const ACCOUNT_ID_PATTERN = /^[0-9]{1,32}$/u; +const CHANNEL_ID_PATTERN = /^[0-9]{1,32}$/u; +const GUILD_ID_PATTERN = /^[0-9]{1,32}$/u; +const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u; + +function pxFromSpecRem(value: `${number}rem`): number { + return Math.round(Number.parseFloat(value) * REM_BASE_PX); +} + +export const SKELETON_UNMEASURED_WIDTH_PX = 0; +export const SKELETON_NO_SELECTED_RAIL_ITEM_INDEX = -1; +export const SKELETON_DEFAULT_MESSAGE_GUTTER_PX = DEFAULT_MESSAGE_GUTTER_PX; +export const SKELETON_DEFAULT_MESSAGE_FONT_SIZE_PX = REM_BASE_PX; +export const SKELETON_DEFAULT_COMPACT_TIMESTAMP_WIDTH_PX = pxFromSpecRem(MESSAGE_LAYOUT_SPEC.dense.timestampWidth); +export const SKELETON_DEFAULT_MESSAGE_DISPLAY_COMPACT = false; +export const SKELETON_DEFAULT_COMPACT_AVATARS_VISIBLE = false; +export const SKELETON_DEFAULT_SEND_DIVIDER_VISIBLE = false; +export const SKELETON_COMPOSER_EXPRESSION_ACTION_COUNT_WITHOUT_GIF = 3; +export const SKELETON_COMPOSER_DEFAULT_MOBILE_ACTION_COUNT = 2; +export const SKELETON_DEFAULT_FAVORITES_VISIBLE = true; +export const SKELETON_DEFAULT_GUILD_BANNER_ASPECT_RATIO = 16 / 9; +export const SKELETON_DISCOVERY_MAX_COLUMNS = MAX_DISCOVERY_COLUMNS; +export const SKELETON_DISCOVERY_MIN_CARD_WIDTH_PX = 320; +export const SKELETON_DISCOVERY_MAX_CARD_WIDTH_PX = 400; +export const SKELETON_DISCOVERY_GRID_GAP_PX = 16; +export const SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX = 344; +const SKELETON_DISCOVERY_SHELL_CHROME_WIDTH_PX = pxFromSpecRem('24.5rem'); +const SKELETON_DISCOVERY_CONTENT_PADDING_PX = pxFromSpecRem('2rem'); + +export const SkeletonMemberSurfaceKind = Object.freeze({ + GUILD: 'guild', + GUILD_VOICE: 'guild_voice', + GROUP_DM: 'group_dm', +} as const); + +export type SkeletonMemberSurfaceKind = (typeof SkeletonMemberSurfaceKind)[keyof typeof SkeletonMemberSurfaceKind]; + +export const SkeletonChannelProjectionKind = Object.freeze({ + GUILD: 'guild', + GUILD_VOICE: 'guild_voice', + DM: 'dm', + GROUP_DM: 'group_dm', + PERSONAL_NOTES: 'personal_notes', +} as const); + +export type SkeletonChannelProjectionKind = + (typeof SkeletonChannelProjectionKind)[keyof typeof SkeletonChannelProjectionKind]; + +export function resolveSkeletonChannelProjectionKind(channelType: number): SkeletonChannelProjectionKind { + switch (channelType) { + case ChannelTypes.DM: + return SkeletonChannelProjectionKind.DM; + case ChannelTypes.GROUP_DM: + return SkeletonChannelProjectionKind.GROUP_DM; + case ChannelTypes.DM_PERSONAL_NOTES: + return SkeletonChannelProjectionKind.PERSONAL_NOTES; + case ChannelTypes.GUILD_VOICE: + return SkeletonChannelProjectionKind.GUILD_VOICE; + default: + return SkeletonChannelProjectionKind.GUILD; + } +} + +export function resolveSkeletonMemberSurfaceKind( + channelKind: SkeletonChannelProjectionKind, +): SkeletonMemberSurfaceKind | null { + switch (channelKind) { + case SkeletonChannelProjectionKind.GUILD: + return SkeletonMemberSurfaceKind.GUILD; + case SkeletonChannelProjectionKind.GUILD_VOICE: + return SkeletonMemberSurfaceKind.GUILD_VOICE; + case SkeletonChannelProjectionKind.GROUP_DM: + return SkeletonMemberSurfaceKind.GROUP_DM; + case SkeletonChannelProjectionKind.DM: + case SkeletonChannelProjectionKind.PERSONAL_NOTES: + return null; + } +} + +export const SkeletonGuildRailItemKind = Object.freeze({ + GUILD: 'guild', + COLLAPSED_FOLDER: 'collapsed_folder', + EXPANDED_FOLDER: 'expanded_folder', +} as const); + +export type SkeletonGuildRailItemKind = (typeof SkeletonGuildRailItemKind)[keyof typeof SkeletonGuildRailItemKind]; + +export const SkeletonGuildRailItemIndicator = Object.freeze({ + NONE: 'none', + UNREAD: 'unread', + MENTION: 'mention', +} as const); + +export type SkeletonGuildRailItemIndicator = + (typeof SkeletonGuildRailItemIndicator)[keyof typeof SkeletonGuildRailItemIndicator]; + +export const SkeletonGuildBannerPlacement = Object.freeze({ + NONE: 'none', + INTEGRATED: 'integrated', + DETACHED: 'detached', +} as const); + +export type SkeletonGuildBannerPlacement = + (typeof SkeletonGuildBannerPlacement)[keyof typeof SkeletonGuildBannerPlacement]; + +export const SkeletonFriendsTab = Object.freeze({ + ONLINE: 'online', + ALL: 'all', + PENDING: 'pending', + ADD: 'add', +} as const); + +export type SkeletonFriendsTab = (typeof SkeletonFriendsTab)[keyof typeof SkeletonFriendsTab]; + +export const SkeletonNagbarTone = NagbarToneKind; + +export type SkeletonNagbarTone = NagbarToneKind; + +export const SkeletonSimplePageRoute = Object.freeze({ + GUILD_MEMBERS: 'guild_members', + BOOKMARKS: 'bookmarks', + MENTIONS: 'mentions', + NOTIFICATIONS: 'notifications', +} as const); + +export type SkeletonSimplePageRoute = (typeof SkeletonSimplePageRoute)[keyof typeof SkeletonSimplePageRoute]; + +export const SkeletonSimplePageBody = Object.freeze({ + MEMBER_TABLE: 'member_table', + MESSAGE_LIST: 'message_list', + CHANNEL_LIST: 'channel_list', +} as const); + +export type SkeletonSimplePageBody = (typeof SkeletonSimplePageBody)[keyof typeof SkeletonSimplePageBody]; + +export const SKELETON_DEFAULT_SIMPLE_PAGE_BODY: Readonly> = + Object.freeze({ + [SkeletonSimplePageRoute.GUILD_MEMBERS]: SkeletonSimplePageBody.MEMBER_TABLE, + [SkeletonSimplePageRoute.BOOKMARKS]: SkeletonSimplePageBody.MESSAGE_LIST, + [SkeletonSimplePageRoute.MENTIONS]: SkeletonSimplePageBody.MESSAGE_LIST, + [SkeletonSimplePageRoute.NOTIFICATIONS]: SkeletonSimplePageBody.CHANNEL_LIST, + }); + +export type RememberedSkeletonGuildRailItem = + | {readonly kind: typeof SkeletonGuildRailItemKind.GUILD; readonly indicator: SkeletonGuildRailItemIndicator} + | { + readonly kind: typeof SkeletonGuildRailItemKind.COLLAPSED_FOLDER; + readonly indicator: SkeletonGuildRailItemIndicator; + readonly childCount: number; + readonly showIconWhenCollapsed: boolean; + } + | { + readonly kind: typeof SkeletonGuildRailItemKind.EXPANDED_FOLDER; + readonly indicator: SkeletonGuildRailItemIndicator; + readonly childCount: number; + readonly childIndicators: ReadonlyArray; + readonly selectedChildIndex: number; + }; + +export interface RememberedSkeletonGuildRailLayout { + readonly inlineDmRowCount: number; + readonly inlineDmUnreadFlags: ReadonlyArray; + readonly selectedInlineDmRowIndex: number; + readonly outageVisible: boolean; + readonly fluxerVisible: boolean; + readonly favoritesVisible: boolean; + readonly discoveryVisible: boolean; + readonly addGuildVisible: boolean; + readonly downloadVisible: boolean; + readonly helpVisible: boolean; + readonly selectedItemIndex: number; + readonly organizedItems: ReadonlyArray; + readonly scrollTopPx: number; +} + +export interface RememberedSkeletonDMSidebarLayout { + readonly isMobile: boolean; + readonly friendsVisible: boolean; + readonly personalNotesVisible: boolean; + readonly premiumVisible: boolean; + readonly sectionVisible: boolean; + readonly channelRowCount: number; + readonly channelSubtextFlags: ReadonlyArray; +} + +export interface RememberedSkeletonActiveNowCard { + readonly participantCount: number; + readonly streaming: boolean; +} + +export interface RememberedSkeletonFriendsLayout { + readonly activeTab: SkeletonFriendsTab; + readonly onlineRowCount: number; + readonly allRowCount: number; + readonly pendingRowCount: number; + readonly pendingIncomingRowCount: number; + readonly tabWidthsPx: ReadonlyArray; + readonly pendingBadgeVisible: boolean; + readonly activeNowVisible: boolean; + readonly activeNowCards: ReadonlyArray; +} + +export interface RememberedSkeletonChannelHeaderLayout { + readonly staffToolsVisible: boolean; + readonly updaterVisible: boolean; + readonly favoritesVisible: boolean; +} + +export interface RememberedSkeletonNagbarRow { + readonly tone: SkeletonNagbarTone; + readonly hasActions: boolean; + readonly dismissible: boolean; +} + +export interface RememberedSkeletonNagbarLayout { + readonly rows: ReadonlyArray; +} + +export interface RememberedSkeletonComposerLayout { + readonly desktopActionCount: number; + readonly mobileActionCount: number; + readonly sendDividerVisible: boolean; +} + +export interface RememberedSkeletonMessagePresentation { + readonly compact: boolean; + readonly messageGutterPx: number; + readonly fontSizePx: number; + readonly groupSpacingPx: number; + readonly compactAvatarsVisible: boolean; + readonly compactTimestampWidthPx: number; + readonly viewportHeightPx: number; +} + +export interface RememberedSkeletonVoicePresence { + readonly connected: boolean; + readonly panelHeightPx: number; +} + +export interface RememberedSkeletonDiscoveryLayout { + readonly columnCount: number; + readonly visibleRowCount: number; + readonly categoryTabWidthsPx: ReadonlyArray; +} + +export interface RememberedSkeletonSimplePageLayout { + readonly body: SkeletonSimplePageBody; + readonly rowCount: number; + readonly selectable: boolean; +} + +export interface RememberedSkeletonMemberGroup { + readonly rowCount: number; + readonly headingWidthPx: number; + readonly subtextFlags: ReadonlyArray; +} + +export interface RememberedSkeletonChannelProjection { + readonly channelKind: SkeletonChannelProjectionKind; + readonly showTopic: boolean; + readonly nameWidthPx: number; + readonly topicWidthPx: number; + readonly desktopLeadingActionCount: number; + readonly mobileActionCount: number; + readonly memberListVisible: boolean; + readonly searchPanelOpen: boolean; +} + +export interface RememberedSkeletonGuildPresentation { + readonly headerNameWidthPx: number; + readonly badgeVisible: boolean; + readonly bannerPlacement: SkeletonGuildBannerPlacement; + readonly bannerAspectRatio: number; +} + +export interface RememberedSkeletonGuildChannelRow { + readonly voice: boolean; + readonly nameWidthPx: number; +} + +export interface RememberedSkeletonGuildChannelGroup { + readonly categoryHeaderVisible: boolean; + readonly collapsed: boolean; + readonly categoryNameWidthPx: number; + readonly channels: ReadonlyArray; +} + +export interface RememberedSkeletonGuildChannelList { + readonly membersRowVisible: boolean; + readonly groups: ReadonlyArray; +} + +const EMPTY_ACTIVE_NOW_CARDS: ReadonlyArray = Object.freeze([]); + +export const SKELETON_DEFAULT_FRIENDS_LAYOUT: RememberedSkeletonFriendsLayout = Object.freeze({ + activeTab: SkeletonFriendsTab.ONLINE, + onlineRowCount: 0, + allRowCount: 0, + pendingRowCount: 0, + pendingIncomingRowCount: 0, + tabWidthsPx: Object.freeze([ + SKELETON_UNMEASURED_WIDTH_PX, + SKELETON_UNMEASURED_WIDTH_PX, + SKELETON_UNMEASURED_WIDTH_PX, + SKELETON_UNMEASURED_WIDTH_PX, + ]), + pendingBadgeVisible: false, + activeNowVisible: true, + activeNowCards: EMPTY_ACTIVE_NOW_CARDS, +}); + +export const SKELETON_DEFAULT_CHANNEL_HEADER_LAYOUT: RememberedSkeletonChannelHeaderLayout = Object.freeze({ + staffToolsVisible: false, + updaterVisible: false, + favoritesVisible: SKELETON_DEFAULT_FAVORITES_VISIBLE, +}); + +export const SKELETON_DEFAULT_NAGBAR_LAYOUT: RememberedSkeletonNagbarLayout = Object.freeze({ + rows: Object.freeze([]), +}); + +export const SKELETON_DEFAULT_VOICE_PRESENCE: RememberedSkeletonVoicePresence = Object.freeze({ + connected: false, + panelHeightPx: 0, +}); + +const SKELETON_DEFAULT_DISCOVERY_CATEGORY_TABS: ReadonlyArray = Object.freeze([SKELETON_UNMEASURED_WIDTH_PX]); + +export function resolveDefaultSkeletonDiscoveryLayout(): RememberedSkeletonDiscoveryLayout { + if (typeof window === 'undefined') { + return Object.freeze({ + columnCount: SKELETON_DISCOVERY_MAX_COLUMNS, + visibleRowCount: 1, + categoryTabWidthsPx: SKELETON_DEFAULT_DISCOVERY_CATEGORY_TABS, + }); + } + const remScale = getRemScaleForDocument(window.document); + const contentWidthPx = Math.max(0, window.innerWidth - SKELETON_DISCOVERY_SHELL_CHROME_WIDTH_PX * remScale); + const gridWidthPx = Math.max(0, contentWidthPx - SKELETON_DISCOVERY_CONTENT_PADDING_PX * remScale); + const gapPx = SKELETON_DISCOVERY_GRID_GAP_PX * remScale; + const columnsThatFit = Math.floor((gridWidthPx + gapPx) / (SKELETON_DISCOVERY_MIN_CARD_WIDTH_PX * remScale + gapPx)); + const visibleRowCount = Math.ceil(window.innerHeight / (SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX * remScale)); + return Object.freeze({ + columnCount: Math.min(SKELETON_DISCOVERY_MAX_COLUMNS, Math.max(1, columnsThatFit)), + visibleRowCount: Math.min(MAX_DISCOVERY_VISIBLE_ROWS, Math.max(1, visibleRowCount)), + categoryTabWidthsPx: SKELETON_DEFAULT_DISCOVERY_CATEGORY_TABS, + }); +} + +export function resolveDefaultSkeletonComposerLayout(gifButtonAvailable: boolean): RememberedSkeletonComposerLayout { + let desktopActionCount = SKELETON_COMPOSER_EXPRESSION_ACTION_COUNT_WITHOUT_GIF; + if (gifButtonAvailable) { + desktopActionCount += 1; + } + return Object.freeze({ + desktopActionCount, + mobileActionCount: SKELETON_COMPOSER_DEFAULT_MOBILE_ACTION_COUNT, + sendDividerVisible: SKELETON_DEFAULT_SEND_DIVIDER_VISIBLE, + }); +} + +export function resolveDefaultSkeletonChatViewportHeightPx(): number { + if (typeof window === 'undefined') { + return 0; + } + const remScaledHeight = window.innerHeight / getRemScaleForDocument(window.document); + return Math.min(MAX_MEASURED_HEIGHT_PX, Math.max(0, Math.round(remScaledHeight))); +} + +export function resolveDefaultSkeletonMessagePresentation(): RememberedSkeletonMessagePresentation { + return Object.freeze({ + compact: SKELETON_DEFAULT_MESSAGE_DISPLAY_COMPACT, + messageGutterPx: SKELETON_DEFAULT_MESSAGE_GUTTER_PX, + fontSizePx: SKELETON_DEFAULT_MESSAGE_FONT_SIZE_PX, + groupSpacingPx: getDefaultMessageGroupSpacing(SKELETON_DEFAULT_MESSAGE_DISPLAY_COMPACT), + compactAvatarsVisible: SKELETON_DEFAULT_COMPACT_AVATARS_VISIBLE, + compactTimestampWidthPx: SKELETON_DEFAULT_COMPACT_TIMESTAMP_WIDTH_PX, + viewportHeightPx: resolveDefaultSkeletonChatViewportHeightPx(), + }); +} + +export function resolveDefaultSkeletonSimplePageLayout( + route: SkeletonSimplePageRoute, +): RememberedSkeletonSimplePageLayout { + return Object.freeze({body: SKELETON_DEFAULT_SIMPLE_PAGE_BODY[route], rowCount: 0, selectable: false}); +} + +interface TimedSkeletonGuildRailLayout extends RememberedSkeletonGuildRailLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonDMSidebarLayout extends RememberedSkeletonDMSidebarLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonFriendsLayout extends RememberedSkeletonFriendsLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonChannelHeaderLayout extends RememberedSkeletonChannelHeaderLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonNagbarLayout extends RememberedSkeletonNagbarLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonComposerLayout extends RememberedSkeletonComposerLayout { + readonly capturedAt: number; +} + +interface TimedSkeletonMessagePresentation extends RememberedSkeletonMessagePresentation { + readonly capturedAt: number; +} + +interface TimedSkeletonVoicePresence extends RememberedSkeletonVoicePresence { + readonly capturedAt: number; +} + +interface TimedSkeletonDiscoveryLayout extends RememberedSkeletonDiscoveryLayout { + readonly capturedAt: number; +} + +interface PendingSkeletonSimplePageLayout extends RememberedSkeletonSimplePageLayout { + readonly route: SkeletonSimplePageRoute; +} + +interface TimedSkeletonSimplePageLayout extends PendingSkeletonSimplePageLayout { + readonly capturedAt: number; +} + +interface PendingSkeletonChannelMemberLayout { + readonly fingerprint: string; + readonly kind: SkeletonMemberSurfaceKind; + readonly memberGroups: ReadonlyArray; +} + +interface TimedSkeletonChannelMemberLayout extends PendingSkeletonChannelMemberLayout { + readonly capturedAt: number; +} + +interface PendingSkeletonChannelProjection extends RememberedSkeletonChannelProjection { + readonly fingerprint: string; +} + +interface TimedSkeletonChannelProjection extends PendingSkeletonChannelProjection { + readonly capturedAt: number; +} + +interface PendingSkeletonGuildPresentation extends RememberedSkeletonGuildPresentation { + readonly fingerprint: string; +} + +interface TimedSkeletonGuildPresentation extends PendingSkeletonGuildPresentation { + readonly capturedAt: number; +} + +interface PendingSkeletonGuildChannelList extends RememberedSkeletonGuildChannelList { + readonly fingerprint: string; +} + +interface TimedSkeletonGuildChannelList extends PendingSkeletonGuildChannelList { + readonly capturedAt: number; +} + +interface SkeletonChromeLayouts { + readonly dmSidebar?: TimedSkeletonDMSidebarLayout; + readonly guildRail?: TimedSkeletonGuildRailLayout; +} + +interface SkeletonLayoutState { + readonly chrome?: SkeletonChromeLayouts; + readonly friends?: TimedSkeletonFriendsLayout; + readonly channelHeader?: TimedSkeletonChannelHeaderLayout; + readonly nagbar?: TimedSkeletonNagbarLayout; + readonly composer?: TimedSkeletonComposerLayout; + readonly messagePresentation?: TimedSkeletonMessagePresentation; + readonly voice?: TimedSkeletonVoicePresence; + readonly discovery?: TimedSkeletonDiscoveryLayout; + readonly simplePages?: ReadonlyArray; + readonly channelMemberLayouts?: ReadonlyArray; + readonly channelProjections?: ReadonlyArray; + readonly guildPresentations?: ReadonlyArray; + readonly guildChannelLists?: ReadonlyArray; +} + +interface PendingFriendsLayout { + activeTab?: SkeletonFriendsTab; + onlineRowCount?: number; + allRowCount?: number; + pendingRowCount?: number; + pendingIncomingRowCount?: number; + tabWidthsPx?: ReadonlyArray; + pendingBadgeVisible?: boolean; + activeNowVisible?: boolean; + activeNowCards?: ReadonlyArray; +} + +interface PendingMessagePresentation { + compact?: boolean; + messageGutterPx?: number; + fontSizePx?: number; + groupSpacingPx?: number; + compactAvatarsVisible?: boolean; + compactTimestampWidthPx?: number; + viewportHeightPx?: number; +} + +interface PendingDiscoveryLayout { + columnCount?: number; + visibleRowCount?: number; + categoryTabWidthsPx?: ReadonlyArray; +} + +interface PendingChromeLayouts { + dmSidebar?: RememberedSkeletonDMSidebarLayout; + guildRail?: RememberedSkeletonGuildRailLayout; +} + +type OptionalParseResult = + | {readonly status: 'absent'} + | {readonly status: 'invalid'} + | {readonly status: 'valid'; readonly value: T}; + +const ABSENT_PARSE_RESULT = Object.freeze({status: 'absent'} as const); +const INVALID_PARSE_RESULT = Object.freeze({status: 'invalid'} as const); + +const SKELETON_LAYOUT_STATE_KEY_FLAGS: Readonly, true>> = Object.freeze({ + chrome: true, + friends: true, + channelHeader: true, + nagbar: true, + composer: true, + messagePresentation: true, + voice: true, + discovery: true, + simplePages: true, + channelMemberLayouts: true, + channelProjections: true, + guildPresentations: true, + guildChannelLists: true, +}); +const SKELETON_LAYOUT_STATE_KEYS = Object.keys(SKELETON_LAYOUT_STATE_KEY_FLAGS) as ReadonlyArray< + keyof SkeletonLayoutState +>; +const ROOT_ENVELOPE_KEYS = ['version', 'updatedAt', 'accountFingerprint'] as const; +const ROOT_KEYS = new Set([...ROOT_ENVELOPE_KEYS, ...SKELETON_LAYOUT_STATE_KEYS]); +const CHROME_KEYS = new Set(['dmSidebar', 'guildRail']); +const DM_SIDEBAR_KEYS = new Set([ + 'capturedAt', + 'isMobile', + 'friendsVisible', + 'personalNotesVisible', + 'premiumVisible', + 'sectionVisible', + 'channelRowCount', + 'channelSubtextFlags', +]); +const GUILD_RAIL_KEYS = new Set([ + 'capturedAt', + 'inlineDmRowCount', + 'inlineDmUnreadFlags', + 'selectedInlineDmRowIndex', + 'outageVisible', + 'fluxerVisible', + 'favoritesVisible', + 'discoveryVisible', + 'addGuildVisible', + 'downloadVisible', + 'helpVisible', + 'selectedItemIndex', + 'organizedItems', + 'scrollTopPx', +]); +const GUILD_RAIL_ITEM_KEYS = new Set(['kind', 'indicator']); +const COLLAPSED_GUILD_RAIL_ITEM_KEYS = new Set(['kind', 'indicator', 'childCount', 'showIconWhenCollapsed']); +const EXPANDED_GUILD_RAIL_ITEM_KEYS = new Set([ + 'kind', + 'indicator', + 'childCount', + 'childIndicators', + 'selectedChildIndex', +]); +const FRIENDS_KEYS = new Set([ + 'capturedAt', + 'activeTab', + 'onlineRowCount', + 'allRowCount', + 'pendingRowCount', + 'pendingIncomingRowCount', + 'tabWidthsPx', + 'pendingBadgeVisible', + 'activeNowVisible', + 'activeNowCards', +]); +const ACTIVE_NOW_CARD_KEYS = new Set(['participantCount', 'streaming']); +const CHANNEL_HEADER_KEYS = new Set(['capturedAt', 'staffToolsVisible', 'updaterVisible', 'favoritesVisible']); +const NAGBAR_KEYS = new Set(['capturedAt', 'rows']); +const NAGBAR_ROW_KEYS = new Set(['tone', 'hasActions', 'dismissible']); +const COMPOSER_KEYS = new Set(['capturedAt', 'desktopActionCount', 'mobileActionCount', 'sendDividerVisible']); +const MESSAGE_PRESENTATION_KEYS = new Set([ + 'capturedAt', + 'compact', + 'messageGutterPx', + 'fontSizePx', + 'groupSpacingPx', + 'compactAvatarsVisible', + 'compactTimestampWidthPx', + 'viewportHeightPx', +]); +const VOICE_KEYS = new Set(['capturedAt', 'connected', 'panelHeightPx']); +const DISCOVERY_KEYS = new Set(['capturedAt', 'columnCount', 'visibleRowCount', 'categoryTabWidthsPx']); +const SIMPLE_PAGE_KEYS = new Set(['capturedAt', 'route', 'body', 'rowCount', 'selectable']); +const CHANNEL_MEMBER_LAYOUT_KEYS = new Set(['capturedAt', 'fingerprint', 'kind', 'memberGroups']); +const MEMBER_GROUP_KEYS = new Set(['rowCount', 'headingWidthPx', 'subtextFlags']); +const CHANNEL_PROJECTION_KEYS = new Set([ + 'capturedAt', + 'fingerprint', + 'channelKind', + 'showTopic', + 'nameWidthPx', + 'topicWidthPx', + 'desktopLeadingActionCount', + 'mobileActionCount', + 'memberListVisible', + 'searchPanelOpen', +]); +const GUILD_PRESENTATION_KEYS = new Set([ + 'capturedAt', + 'fingerprint', + 'headerNameWidthPx', + 'badgeVisible', + 'bannerPlacement', + 'bannerAspectRatio', +]); +const GUILD_CHANNEL_LIST_KEYS = new Set(['capturedAt', 'fingerprint', 'membersRowVisible', 'groups']); +const GUILD_CHANNEL_GROUP_KEYS = new Set(['categoryHeaderVisible', 'collapsed', 'categoryNameWidthPx', 'channels']); +const GUILD_CHANNEL_ROW_KEYS = new Set(['voice', 'nameWidthPx']); +const MEMBER_SURFACE_KINDS = new Set(Object.values(SkeletonMemberSurfaceKind)); +const CHANNEL_PROJECTION_KINDS = new Set(Object.values(SkeletonChannelProjectionKind)); +const GUILD_RAIL_ITEM_KINDS = new Set(Object.values(SkeletonGuildRailItemKind)); +const GUILD_RAIL_ITEM_INDICATORS = new Set(Object.values(SkeletonGuildRailItemIndicator)); +const GUILD_BANNER_PLACEMENTS = new Set(Object.values(SkeletonGuildBannerPlacement)); +const FRIENDS_TABS = new Set(Object.values(SkeletonFriendsTab)); +const NAGBAR_TONES = new Set(Object.values(SkeletonNagbarTone)); +const SIMPLE_PAGE_ROUTES = new Set(Object.values(SkeletonSimplePageRoute)); +const SIMPLE_PAGE_BODIES = new Set(Object.values(SkeletonSimplePageBody)); +const SKELETON_FRIENDS_TAB_COUNT = FRIENDS_TABS.size; +const MAX_SIMPLE_PAGE_LAYOUTS = SIMPLE_PAGE_ROUTES.size; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null && !Array.isArray(value); +} + +function hasOnlyKeys(record: Record, allowedKeys: ReadonlySet): boolean { + return Object.keys(record).every((key) => allowedKeys.has(key)); +} + +function parseBoundedInteger(value: unknown, minimum: number, maximum: number): number | null { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + return null; + } + return value; +} + +function parseBoundedNumber(value: unknown, minimum: number, maximum: number): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) { + return null; + } + return value; +} + +function clampReportedInteger(value: number, minimum: number, maximum: number): number | null { + if (!Number.isFinite(value)) { + return null; + } + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + +function clampReportedRatio(value: number, minimum: number, maximum: number): number | null { + if (!Number.isFinite(value)) { + return null; + } + const clamped = Math.min(maximum, Math.max(minimum, value)); + return Math.round(clamped * BANNER_ASPECT_RATIO_PRECISION) / BANNER_ASPECT_RATIO_PRECISION; +} + +function parseBooleanArray(value: unknown, maximumLength: number): ReadonlyArray | null { + if (!Array.isArray(value) || value.length > maximumLength) { + return null; + } + for (const entry of value) { + if (typeof entry !== 'boolean') { + return null; + } + } + return Object.freeze([...(value as Array)]); +} + +function parseWidthArray(value: unknown, maximumLength: number): ReadonlyArray | null { + if (!Array.isArray(value) || value.length > maximumLength) { + return null; + } + const widths: Array = []; + for (const entry of value) { + const width = parseBoundedInteger(entry, 0, MAX_MEASURED_WIDTH_PX); + if (width == null) { + return null; + } + widths.push(width); + } + return Object.freeze(widths); +} + +function clampReportedWidths(values: ReadonlyArray, maximumLength: number): ReadonlyArray | null { + if (!Array.isArray(values)) { + return null; + } + const widths: Array = []; + for (const value of values.slice(0, maximumLength)) { + const width = clampReportedInteger(value, 0, MAX_MEASURED_WIDTH_PX); + if (width == null) { + return null; + } + widths.push(width); + } + return Object.freeze(widths); +} + +function isValidTimestamp(value: unknown, now: number): value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + return false; + } + return value <= now + FUTURE_TIMESTAMP_TOLERANCE_MS; +} + +function isFreshTimestamp(value: unknown, now: number): value is number { + return isValidTimestamp(value, now) && now - value <= SNAPSHOT_TTL_MS; +} + +function createFingerprint(input: string): string { + let left = 0x811c9dc5; + let right = 0x9e3779b9; + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + left = Math.imul(left ^ code, 0x01000193); + right = Math.imul(right ^ code, 0x85ebca6b); + right ^= right >>> 13; + } + left ^= left >>> 16; + right ^= right >>> 16; + return `${(left >>> 0).toString(16).padStart(8, '0')}${(right >>> 0).toString(16).padStart(8, '0')}`; +} + +function createAccountFingerprint(accountId: string): string { + return createFingerprint(`skeleton-account-v1:${accountId}`); +} + +function createChannelFingerprint(channelId: string): string { + return createFingerprint(`skeleton-channel-v1:${channelId}`); +} + +function createGuildFingerprint(guildId: string): string { + return createFingerprint(`skeleton-guild-v1:${guildId}`); +} + +function resolveActiveAccountFingerprint(): string | null { + try { + const accountId = + SessionManager.userId ?? parseStoredSessionValue(AppStorage.getItem(AuthSessionStorageKey.UserId)); + if (accountId == null || !ACCOUNT_ID_PATTERN.test(accountId)) { + return null; + } + return createAccountFingerprint(accountId); + } catch { + return null; + } +} + +function validParseResult(value: T): OptionalParseResult { + return Object.freeze({status: 'valid', value}); +} + +function isValidDMSidebarVisibility(layout: RememberedSkeletonDMSidebarLayout): boolean { + if (layout.channelSubtextFlags.length !== layout.channelRowCount) { + return false; + } + if (layout.isMobile) { + return !layout.friendsVisible && !layout.sectionVisible; + } + return layout.friendsVisible && layout.sectionVisible; +} + +function parseDMSidebarLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, DM_SIDEBAR_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const channelRowCount = parseBoundedInteger(value.channelRowCount, 0, MAX_DM_SIDEBAR_ROWS); + const channelSubtextFlags = parseBooleanArray(value.channelSubtextFlags, MAX_DM_SIDEBAR_ROWS); + if ( + channelRowCount == null || + channelSubtextFlags == null || + typeof value.isMobile !== 'boolean' || + typeof value.friendsVisible !== 'boolean' || + typeof value.personalNotesVisible !== 'boolean' || + typeof value.premiumVisible !== 'boolean' || + typeof value.sectionVisible !== 'boolean' + ) { + return INVALID_PARSE_RESULT; + } + const layout: RememberedSkeletonDMSidebarLayout = { + isMobile: value.isMobile, + friendsVisible: value.friendsVisible, + personalNotesVisible: value.personalNotesVisible, + premiumVisible: value.premiumVisible, + sectionVisible: value.sectionVisible, + channelRowCount, + channelSubtextFlags, + }; + if (!isValidDMSidebarVisibility(layout)) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult(Object.freeze({...layout, capturedAt: value.capturedAt})); +} + +function isSkeletonGuildRailItemKind(value: unknown): value is SkeletonGuildRailItemKind { + return typeof value === 'string' && GUILD_RAIL_ITEM_KINDS.has(value); +} + +function isSkeletonGuildRailItemIndicator(value: unknown): value is SkeletonGuildRailItemIndicator { + return typeof value === 'string' && GUILD_RAIL_ITEM_INDICATORS.has(value); +} + +const EMPTY_GUILD_RAIL_CHILD_INDICATORS: ReadonlyArray = Object.freeze([]); + +function normalizeGuildRailChildIndicators( + value: unknown, + childCount: number, +): ReadonlyArray { + if (!Array.isArray(value)) { + return EMPTY_GUILD_RAIL_CHILD_INDICATORS; + } + const indicators: Array = []; + let hasSignal = false; + for (let index = 0; index < childCount; index += 1) { + const entry = value[index]; + if (isSkeletonGuildRailItemIndicator(entry) && entry !== SkeletonGuildRailItemIndicator.NONE) { + indicators.push(entry); + hasSignal = true; + } else { + indicators.push(SkeletonGuildRailItemIndicator.NONE); + } + } + if (!hasSignal) { + return EMPTY_GUILD_RAIL_CHILD_INDICATORS; + } + return Object.freeze(indicators); +} + +function normalizeGuildRailSelectedChildIndex(value: unknown, childCount: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value >= childCount) { + return SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + } + return value; +} + +function parseGuildRailItem(value: unknown): RememberedSkeletonGuildRailItem | null { + if ( + !isRecord(value) || + !isSkeletonGuildRailItemKind(value.kind) || + !isSkeletonGuildRailItemIndicator(value.indicator) + ) { + return null; + } + const indicator = value.indicator; + if (value.kind === SkeletonGuildRailItemKind.EXPANDED_FOLDER) { + if (!hasOnlyKeys(value, EXPANDED_GUILD_RAIL_ITEM_KEYS)) { + return null; + } + const childCount = parseBoundedInteger(value.childCount, 0, SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT - 1); + if (childCount == null) { + return null; + } + const childIndicators = normalizeGuildRailChildIndicators(value.childIndicators, childCount); + const selectedChildIndex = normalizeGuildRailSelectedChildIndex(value.selectedChildIndex, childCount); + return Object.freeze({kind: value.kind, indicator, childCount, childIndicators, selectedChildIndex}); + } + if (value.kind === SkeletonGuildRailItemKind.COLLAPSED_FOLDER) { + if (!hasOnlyKeys(value, COLLAPSED_GUILD_RAIL_ITEM_KEYS)) { + return null; + } + const childCount = parseBoundedInteger(value.childCount, 0, SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT); + if (childCount == null || typeof value.showIconWhenCollapsed !== 'boolean') { + return null; + } + return Object.freeze({ + kind: value.kind, + indicator, + childCount, + showIconWhenCollapsed: value.showIconWhenCollapsed, + }); + } + if (!hasOnlyKeys(value, GUILD_RAIL_ITEM_KEYS)) { + return null; + } + return Object.freeze({kind: value.kind, indicator}); +} + +function parseGuildRailLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, GUILD_RAIL_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const inlineDmRowCount = parseBoundedInteger(value.inlineDmRowCount, 0, MAX_GUILD_RAIL_INLINE_DM_ROWS); + const inlineDmUnreadFlags = parseBooleanArray(value.inlineDmUnreadFlags, MAX_GUILD_RAIL_INLINE_DM_ROWS); + if ( + inlineDmRowCount == null || + inlineDmUnreadFlags == null || + inlineDmUnreadFlags.length !== inlineDmRowCount || + typeof value.outageVisible !== 'boolean' || + typeof value.fluxerVisible !== 'boolean' || + typeof value.favoritesVisible !== 'boolean' || + typeof value.discoveryVisible !== 'boolean' || + typeof value.addGuildVisible !== 'boolean' || + typeof value.downloadVisible !== 'boolean' || + typeof value.helpVisible !== 'boolean' || + value.discoveryVisible !== value.addGuildVisible || + (!value.fluxerVisible && inlineDmRowCount > 0) || + !Array.isArray(value.organizedItems) || + value.organizedItems.length > SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT + ) { + return INVALID_PARSE_RESULT; + } + const organizedItems: Array = []; + let visualRowCount = 0; + for (const rawItem of value.organizedItems) { + const item = parseGuildRailItem(rawItem); + if (item == null) { + return INVALID_PARSE_RESULT; + } + visualRowCount += 1; + if (item.kind === SkeletonGuildRailItemKind.EXPANDED_FOLDER) { + visualRowCount += item.childCount; + } + if (visualRowCount > SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT) { + return INVALID_PARSE_RESULT; + } + organizedItems.push(item); + } + const selectedItemIndex = parseBoundedInteger( + value.selectedItemIndex, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + Math.max(SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, organizedItems.length - 1), + ); + const scrollTopPx = + value.scrollTopPx === undefined ? 0 : parseBoundedInteger(value.scrollTopPx, 0, MAX_GUILD_RAIL_SCROLL_TOP_PX); + const selectedInlineDmRowIndex = parseBoundedInteger( + value.selectedInlineDmRowIndex, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + Math.max(SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, inlineDmRowCount - 1), + ); + if (selectedItemIndex == null || scrollTopPx == null || selectedInlineDmRowIndex == null) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + inlineDmRowCount, + inlineDmUnreadFlags, + selectedInlineDmRowIndex, + outageVisible: value.outageVisible, + fluxerVisible: value.fluxerVisible, + favoritesVisible: value.favoritesVisible, + discoveryVisible: value.discoveryVisible, + addGuildVisible: value.addGuildVisible, + downloadVisible: value.downloadVisible, + helpVisible: value.helpVisible, + selectedItemIndex, + organizedItems: Object.freeze(organizedItems), + scrollTopPx, + }), + ); +} + +function parseChromeLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, CHROME_KEYS)) { + return INVALID_PARSE_RESULT; + } + const dmSidebar = parseDMSidebarLayout(value.dmSidebar, now); + const guildRail = parseGuildRailLayout(value.guildRail, now); + if (dmSidebar.status !== 'valid' && guildRail.status !== 'valid') { + if (dmSidebar.status === 'invalid' || guildRail.status === 'invalid') { + return INVALID_PARSE_RESULT; + } + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + dmSidebar: dmSidebar.status === 'valid' ? dmSidebar.value : undefined, + guildRail: guildRail.status === 'valid' ? guildRail.value : undefined, + }), + ); +} + +function isSkeletonFriendsTab(value: unknown): value is SkeletonFriendsTab { + return typeof value === 'string' && FRIENDS_TABS.has(value); +} + +function parseActiveNowCards(value: unknown): ReadonlyArray | null { + if (!Array.isArray(value) || value.length > MAX_ACTIVE_NOW_CARDS) { + return null; + } + const cards: Array = []; + for (const rawCard of value) { + if (!isRecord(rawCard) || !hasOnlyKeys(rawCard, ACTIVE_NOW_CARD_KEYS) || typeof rawCard.streaming !== 'boolean') { + return null; + } + const participantCount = parseBoundedInteger(rawCard.participantCount, 0, MAX_ACTIVE_NOW_PARTICIPANTS); + if (participantCount == null) { + return null; + } + cards.push(Object.freeze({participantCount, streaming: rawCard.streaming})); + } + return Object.freeze(cards); +} + +function parseFriendsLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, FRIENDS_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const onlineRowCount = parseBoundedInteger(value.onlineRowCount, 0, MAX_FRIEND_ROWS); + const allRowCount = parseBoundedInteger(value.allRowCount, 0, MAX_FRIEND_ROWS); + const pendingRowCount = parseBoundedInteger(value.pendingRowCount, 0, MAX_FRIEND_ROWS); + const pendingIncomingRowCount = + value.pendingIncomingRowCount === undefined + ? 0 + : parseBoundedInteger(value.pendingIncomingRowCount, 0, MAX_FRIEND_ROWS); + const tabWidthsPx = parseWidthArray(value.tabWidthsPx, SKELETON_FRIENDS_TAB_COUNT); + const activeNowCards = parseActiveNowCards(value.activeNowCards); + if ( + onlineRowCount == null || + allRowCount == null || + pendingRowCount == null || + pendingIncomingRowCount == null || + pendingIncomingRowCount > pendingRowCount || + tabWidthsPx == null || + tabWidthsPx.length !== SKELETON_FRIENDS_TAB_COUNT || + activeNowCards == null || + !isSkeletonFriendsTab(value.activeTab) || + typeof value.pendingBadgeVisible !== 'boolean' || + typeof value.activeNowVisible !== 'boolean' || + (!value.activeNowVisible && activeNowCards.length !== 0) + ) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + activeTab: value.activeTab, + onlineRowCount, + allRowCount, + pendingRowCount, + pendingIncomingRowCount, + tabWidthsPx, + pendingBadgeVisible: value.pendingBadgeVisible, + activeNowVisible: value.activeNowVisible, + activeNowCards, + }), + ); +} + +function parseChannelHeaderLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if ( + !isRecord(value) || + !hasOnlyKeys(value, CHANNEL_HEADER_KEYS) || + !isValidTimestamp(value.capturedAt, now) || + typeof value.staffToolsVisible !== 'boolean' || + typeof value.updaterVisible !== 'boolean' || + typeof value.favoritesVisible !== 'boolean' + ) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + staffToolsVisible: value.staffToolsVisible, + updaterVisible: value.updaterVisible, + favoritesVisible: value.favoritesVisible, + }), + ); +} + +function isSkeletonNagbarTone(value: unknown): value is SkeletonNagbarTone { + return typeof value === 'string' && NAGBAR_TONES.has(value); +} + +function parseNagbarLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, NAGBAR_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + if (!Array.isArray(value.rows) || value.rows.length > MAX_NAGBAR_ROWS) { + return INVALID_PARSE_RESULT; + } + const rows: Array = []; + for (const rawRow of value.rows) { + if ( + !isRecord(rawRow) || + !hasOnlyKeys(rawRow, NAGBAR_ROW_KEYS) || + !isSkeletonNagbarTone(rawRow.tone) || + typeof rawRow.hasActions !== 'boolean' + ) { + return INVALID_PARSE_RESULT; + } + rows.push( + Object.freeze({tone: rawRow.tone, hasActions: rawRow.hasActions, dismissible: rawRow.dismissible === true}), + ); + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult(Object.freeze({capturedAt: value.capturedAt, rows: Object.freeze(rows)})); +} + +function parseComposerLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, COMPOSER_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const desktopActionCount = parseBoundedInteger(value.desktopActionCount, 0, MAX_COMPOSER_DESKTOP_ACTION_COUNT); + const mobileActionCount = parseBoundedInteger(value.mobileActionCount, 0, MAX_COMPOSER_MOBILE_ACTION_COUNT); + if (desktopActionCount == null || mobileActionCount == null || typeof value.sendDividerVisible !== 'boolean') { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + desktopActionCount, + mobileActionCount, + sendDividerVisible: value.sendDividerVisible, + }), + ); +} + +function parseMessagePresentation(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, MESSAGE_PRESENTATION_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const messageGutterPx = parseBoundedInteger(value.messageGutterPx, 0, MAX_MESSAGE_GUTTER_PX); + const fontSizePx = parseBoundedInteger(value.fontSizePx, MIN_MESSAGE_FONT_SIZE_PX, MAX_MESSAGE_FONT_SIZE_PX); + const groupSpacingPx = parseBoundedInteger(value.groupSpacingPx, 0, MAX_MESSAGE_GROUP_SPACING_PX); + const compactTimestampWidthPx = parseBoundedInteger(value.compactTimestampWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const viewportHeightPx = parseBoundedInteger(value.viewportHeightPx, 0, MAX_MEASURED_HEIGHT_PX); + if ( + messageGutterPx == null || + fontSizePx == null || + groupSpacingPx == null || + compactTimestampWidthPx == null || + viewportHeightPx == null || + typeof value.compact !== 'boolean' || + typeof value.compactAvatarsVisible !== 'boolean' + ) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + compact: value.compact, + messageGutterPx, + fontSizePx, + groupSpacingPx, + compactAvatarsVisible: value.compactAvatarsVisible, + compactTimestampWidthPx, + viewportHeightPx, + }), + ); +} + +function parseVoicePresence(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, VOICE_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const panelHeightPx = parseBoundedInteger(value.panelHeightPx, 0, MAX_VOICE_CONNECTION_HEIGHT_PX); + if (panelHeightPx == null || typeof value.connected !== 'boolean') { + return INVALID_PARSE_RESULT; + } + if (!value.connected && panelHeightPx !== 0) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult(Object.freeze({capturedAt: value.capturedAt, connected: value.connected, panelHeightPx})); +} + +function parseDiscoveryLayout(value: unknown, now: number): OptionalParseResult { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!isRecord(value) || !hasOnlyKeys(value, DISCOVERY_KEYS) || !isValidTimestamp(value.capturedAt, now)) { + return INVALID_PARSE_RESULT; + } + const columnCount = parseBoundedInteger(value.columnCount, 1, MAX_DISCOVERY_COLUMNS); + const visibleRowCount = parseBoundedInteger(value.visibleRowCount, 0, MAX_DISCOVERY_VISIBLE_ROWS); + const categoryTabWidthsPx = parseWidthArray(value.categoryTabWidthsPx, MAX_DISCOVERY_CATEGORY_TABS); + if (columnCount == null || visibleRowCount == null || categoryTabWidthsPx == null) { + return INVALID_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + columnCount, + visibleRowCount, + categoryTabWidthsPx, + }), + ); +} + +function isSimplePageRoute(value: unknown): value is SkeletonSimplePageRoute { + return typeof value === 'string' && SIMPLE_PAGE_ROUTES.has(value); +} + +function isSimplePageBody(value: unknown): value is SkeletonSimplePageBody { + return typeof value === 'string' && SIMPLE_PAGE_BODIES.has(value); +} + +function parseSimplePageLayouts( + value: unknown, + now: number, +): OptionalParseResult> { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!Array.isArray(value) || value.length > MAX_SIMPLE_PAGE_LAYOUTS) { + return INVALID_PARSE_RESULT; + } + const layouts: Array = []; + const seenRoutes = new Set(); + for (const rawLayout of value) { + if ( + !isRecord(rawLayout) || + !hasOnlyKeys(rawLayout, SIMPLE_PAGE_KEYS) || + !isValidTimestamp(rawLayout.capturedAt, now) || + !isSimplePageRoute(rawLayout.route) || + !isSimplePageBody(rawLayout.body) || + seenRoutes.has(rawLayout.route) + ) { + staleRecordNeedsRewrite = true; + continue; + } + const rowCount = parseBoundedInteger(rawLayout.rowCount, 0, MAX_SIMPLE_PAGE_ROWS); + if (rowCount == null) { + staleRecordNeedsRewrite = true; + continue; + } + seenRoutes.add(rawLayout.route); + if (!isFreshTimestamp(rawLayout.capturedAt, now)) { + continue; + } + layouts.push( + Object.freeze({ + capturedAt: rawLayout.capturedAt, + route: rawLayout.route, + body: rawLayout.body, + rowCount, + selectable: rawLayout.selectable === true, + }), + ); + } + return layouts.length > 0 ? validParseResult(Object.freeze(layouts)) : ABSENT_PARSE_RESULT; +} + +function isSkeletonMemberSurfaceKind(value: unknown): value is SkeletonMemberSurfaceKind { + return typeof value === 'string' && MEMBER_SURFACE_KINDS.has(value); +} + +const EMPTY_SUBTEXT_FLAGS: ReadonlyArray = Object.freeze([]); + +function normalizeSubtextFlags(value: unknown, rowCount: number): ReadonlyArray { + const source = Array.isArray(value) ? value : EMPTY_SUBTEXT_FLAGS; + let lastTrueIndex = -1; + for (let index = 0; index < rowCount; index += 1) { + if (source[index] === true) { + lastTrueIndex = index; + } + } + if (lastTrueIndex === -1) { + return EMPTY_SUBTEXT_FLAGS; + } + const flags: Array = []; + for (let index = 0; index <= lastTrueIndex; index += 1) { + flags.push(source[index] === true); + } + return Object.freeze(flags); +} + +function parseMemberGroups(value: unknown): ReadonlyArray | null { + if (!Array.isArray(value) || value.length > MAX_MEMBER_GROUPS) { + return null; + } + const groups: Array = []; + for (const rawGroup of value) { + if (!isRecord(rawGroup) || !hasOnlyKeys(rawGroup, MEMBER_GROUP_KEYS)) { + return null; + } + const rowCount = parseBoundedInteger(rawGroup.rowCount, 1, MAX_MEMBER_GROUP_ROWS); + const headingWidthPx = parseBoundedInteger(rawGroup.headingWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (rowCount == null || headingWidthPx == null) { + return null; + } + const storedSubtextFlags = + rawGroup.subtextFlags === undefined + ? EMPTY_SUBTEXT_FLAGS + : parseBooleanArray(rawGroup.subtextFlags, MAX_MEMBER_GROUP_ROWS); + if (storedSubtextFlags == null) { + return null; + } + groups.push( + Object.freeze({rowCount, headingWidthPx, subtextFlags: normalizeSubtextFlags(storedSubtextFlags, rowCount)}), + ); + } + return Object.freeze(groups); +} + +function parseChannelMemberLayout(value: unknown, now: number): OptionalParseResult { + if ( + !isRecord(value) || + !hasOnlyKeys(value, CHANNEL_MEMBER_LAYOUT_KEYS) || + !isValidTimestamp(value.capturedAt, now) || + typeof value.fingerprint !== 'string' || + !FINGERPRINT_PATTERN.test(value.fingerprint) || + !isSkeletonMemberSurfaceKind(value.kind) + ) { + return INVALID_PARSE_RESULT; + } + const memberGroups = parseMemberGroups(value.memberGroups); + if (memberGroups == null) { + return INVALID_PARSE_RESULT; + } + if (memberGroups.length === 0) { + return ABSENT_PARSE_RESULT; + } + if (!isFreshTimestamp(value.capturedAt, now)) { + return ABSENT_PARSE_RESULT; + } + return validParseResult( + Object.freeze({ + capturedAt: value.capturedAt, + fingerprint: value.fingerprint, + kind: value.kind, + memberGroups, + }), + ); +} + +function parseChannelMemberLayouts( + value: unknown, + now: number, +): OptionalParseResult> { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!Array.isArray(value) || value.length > MAX_CHANNEL_MEMBER_LAYOUTS) { + return INVALID_PARSE_RESULT; + } + const layouts: Array = []; + const seenFingerprints = new Set(); + for (const rawLayout of value) { + if ( + !isRecord(rawLayout) || + typeof rawLayout.fingerprint !== 'string' || + seenFingerprints.has(rawLayout.fingerprint) + ) { + staleRecordNeedsRewrite = true; + continue; + } + seenFingerprints.add(rawLayout.fingerprint); + const layout = parseChannelMemberLayout(rawLayout, now); + if (layout.status === 'invalid') { + staleRecordNeedsRewrite = true; + continue; + } + if (layout.status === 'valid') { + layouts.push(layout.value); + } + } + if (layouts.length === 0) { + return ABSENT_PARSE_RESULT; + } + return validParseResult(Object.freeze(layouts)); +} + +function isChannelProjectionKind(value: unknown): value is SkeletonChannelProjectionKind { + return typeof value === 'string' && CHANNEL_PROJECTION_KINDS.has(value); +} + +function canChannelProjectionShowTopic(channelKind: SkeletonChannelProjectionKind): boolean { + return ( + channelKind === SkeletonChannelProjectionKind.GUILD || channelKind === SkeletonChannelProjectionKind.GUILD_VOICE + ); +} + +function canChannelProjectionShowMemberList(channelKind: SkeletonChannelProjectionKind): boolean { + return ( + channelKind === SkeletonChannelProjectionKind.GUILD || + channelKind === SkeletonChannelProjectionKind.GUILD_VOICE || + channelKind === SkeletonChannelProjectionKind.GROUP_DM + ); +} + +function parseChannelProjections( + value: unknown, + now: number, +): OptionalParseResult> { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!Array.isArray(value) || value.length > MAX_CHANNEL_PROJECTIONS) { + return INVALID_PARSE_RESULT; + } + const projections: Array = []; + const seenFingerprints = new Set(); + for (const rawProjection of value) { + if ( + !isRecord(rawProjection) || + !hasOnlyKeys(rawProjection, CHANNEL_PROJECTION_KEYS) || + !isValidTimestamp(rawProjection.capturedAt, now) || + typeof rawProjection.fingerprint !== 'string' || + !FINGERPRINT_PATTERN.test(rawProjection.fingerprint) || + !isChannelProjectionKind(rawProjection.channelKind) || + typeof rawProjection.showTopic !== 'boolean' || + typeof rawProjection.memberListVisible !== 'boolean' || + typeof rawProjection.searchPanelOpen !== 'boolean' || + seenFingerprints.has(rawProjection.fingerprint) + ) { + staleRecordNeedsRewrite = true; + continue; + } + const nameWidthPx = parseBoundedInteger(rawProjection.nameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const topicWidthPx = parseBoundedInteger(rawProjection.topicWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const desktopLeadingActionCount = parseBoundedInteger( + rawProjection.desktopLeadingActionCount, + 0, + MAX_HEADER_ACTION_COUNT, + ); + const mobileActionCount = parseBoundedInteger(rawProjection.mobileActionCount, 0, MAX_HEADER_ACTION_COUNT); + if ( + nameWidthPx == null || + topicWidthPx == null || + desktopLeadingActionCount == null || + mobileActionCount == null || + (rawProjection.showTopic && !canChannelProjectionShowTopic(rawProjection.channelKind)) || + (!rawProjection.showTopic && topicWidthPx !== 0) || + (rawProjection.memberListVisible && !canChannelProjectionShowMemberList(rawProjection.channelKind)) + ) { + staleRecordNeedsRewrite = true; + continue; + } + seenFingerprints.add(rawProjection.fingerprint); + if (!isFreshTimestamp(rawProjection.capturedAt, now)) { + continue; + } + projections.push( + Object.freeze({ + capturedAt: rawProjection.capturedAt, + fingerprint: rawProjection.fingerprint, + channelKind: rawProjection.channelKind, + showTopic: rawProjection.showTopic, + nameWidthPx, + topicWidthPx, + desktopLeadingActionCount, + mobileActionCount, + memberListVisible: rawProjection.memberListVisible, + searchPanelOpen: rawProjection.searchPanelOpen, + }), + ); + } + return projections.length > 0 ? validParseResult(Object.freeze(projections)) : ABSENT_PARSE_RESULT; +} + +function isGuildBannerPlacement(value: unknown): value is SkeletonGuildBannerPlacement { + return typeof value === 'string' && GUILD_BANNER_PLACEMENTS.has(value); +} + +function parseGuildPresentations( + value: unknown, + now: number, +): OptionalParseResult> { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!Array.isArray(value) || value.length > MAX_GUILD_PRESENTATIONS) { + return INVALID_PARSE_RESULT; + } + const presentations: Array = []; + const seenFingerprints = new Set(); + for (const rawPresentation of value) { + if ( + !isRecord(rawPresentation) || + !hasOnlyKeys(rawPresentation, GUILD_PRESENTATION_KEYS) || + !isValidTimestamp(rawPresentation.capturedAt, now) || + typeof rawPresentation.fingerprint !== 'string' || + !FINGERPRINT_PATTERN.test(rawPresentation.fingerprint) || + typeof rawPresentation.badgeVisible !== 'boolean' || + !isGuildBannerPlacement(rawPresentation.bannerPlacement) || + seenFingerprints.has(rawPresentation.fingerprint) + ) { + staleRecordNeedsRewrite = true; + continue; + } + const headerNameWidthPx = parseBoundedInteger(rawPresentation.headerNameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const bannerAspectRatio = parseBoundedNumber( + rawPresentation.bannerAspectRatio, + MIN_BANNER_ASPECT_RATIO, + MAX_BANNER_ASPECT_RATIO, + ); + if (headerNameWidthPx == null || bannerAspectRatio == null) { + staleRecordNeedsRewrite = true; + continue; + } + seenFingerprints.add(rawPresentation.fingerprint); + if (!isFreshTimestamp(rawPresentation.capturedAt, now)) { + continue; + } + presentations.push( + Object.freeze({ + capturedAt: rawPresentation.capturedAt, + fingerprint: rawPresentation.fingerprint, + headerNameWidthPx, + badgeVisible: rawPresentation.badgeVisible, + bannerPlacement: rawPresentation.bannerPlacement, + bannerAspectRatio, + }), + ); + } + return presentations.length > 0 ? validParseResult(Object.freeze(presentations)) : ABSENT_PARSE_RESULT; +} + +function parseGuildChannelGroups(value: unknown): ReadonlyArray | null { + if (!Array.isArray(value) || value.length > MAX_GUILD_CHANNEL_GROUPS) { + return null; + } + const groups: Array = []; + let totalChannelRows = 0; + for (const rawGroup of value) { + if ( + !isRecord(rawGroup) || + !hasOnlyKeys(rawGroup, GUILD_CHANNEL_GROUP_KEYS) || + typeof rawGroup.categoryHeaderVisible !== 'boolean' || + typeof rawGroup.collapsed !== 'boolean' || + !Array.isArray(rawGroup.channels) + ) { + return null; + } + const categoryNameWidthPx = parseBoundedInteger(rawGroup.categoryNameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (categoryNameWidthPx == null) { + return null; + } + if (!rawGroup.categoryHeaderVisible && (rawGroup.collapsed || categoryNameWidthPx !== 0)) { + return null; + } + totalChannelRows += rawGroup.channels.length; + if (totalChannelRows > MAX_GUILD_CHANNEL_ROWS) { + return null; + } + const channels: Array = []; + for (const rawChannel of rawGroup.channels) { + if ( + !isRecord(rawChannel) || + !hasOnlyKeys(rawChannel, GUILD_CHANNEL_ROW_KEYS) || + typeof rawChannel.voice !== 'boolean' + ) { + return null; + } + const nameWidthPx = parseBoundedInteger(rawChannel.nameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (nameWidthPx == null) { + return null; + } + channels.push(Object.freeze({voice: rawChannel.voice, nameWidthPx})); + } + groups.push( + Object.freeze({ + categoryHeaderVisible: rawGroup.categoryHeaderVisible, + collapsed: rawGroup.collapsed, + categoryNameWidthPx, + channels: Object.freeze(channels), + }), + ); + } + return Object.freeze(groups); +} + +function parseGuildChannelLists( + value: unknown, + now: number, +): OptionalParseResult> { + if (value === undefined) { + return ABSENT_PARSE_RESULT; + } + if (!Array.isArray(value) || value.length > MAX_GUILD_CHANNEL_LISTS) { + return INVALID_PARSE_RESULT; + } + const lists: Array = []; + const seenFingerprints = new Set(); + for (const rawList of value) { + if ( + !isRecord(rawList) || + !hasOnlyKeys(rawList, GUILD_CHANNEL_LIST_KEYS) || + !isValidTimestamp(rawList.capturedAt, now) || + typeof rawList.fingerprint !== 'string' || + !FINGERPRINT_PATTERN.test(rawList.fingerprint) || + typeof rawList.membersRowVisible !== 'boolean' || + seenFingerprints.has(rawList.fingerprint) + ) { + staleRecordNeedsRewrite = true; + continue; + } + const groups = parseGuildChannelGroups(rawList.groups); + if (groups == null) { + staleRecordNeedsRewrite = true; + continue; + } + seenFingerprints.add(rawList.fingerprint); + if (!isFreshTimestamp(rawList.capturedAt, now)) { + continue; + } + lists.push( + Object.freeze({ + capturedAt: rawList.capturedAt, + fingerprint: rawList.fingerprint, + membersRowVisible: rawList.membersRowVisible, + groups, + }), + ); + } + return lists.length > 0 ? validParseResult(Object.freeze(lists)) : ABSENT_PARSE_RESULT; +} + +const EMPTY_STATE: SkeletonLayoutState = Object.freeze({}); + +function readStoredRecord(): unknown { + try { + return AppStorage.getJSON(STORAGE_KEY); + } catch { + return null; + } +} + +function removeStoredRecord(): void { + try { + AppStorage.removeItem(STORAGE_KEY); + } catch {} +} + +function upgradeLegacyGuildRailItems(items: unknown): unknown { + if (!Array.isArray(items)) { + return items; + } + return items.map((item) => { + if (!isRecord(item) || item.indicator !== undefined) { + return item; + } + return {...item, indicator: SkeletonGuildRailItemIndicator.NONE}; + }); +} + +function upgradeLegacyGuildRailUnreadFlags(guildRail: Record): unknown { + if (guildRail.inlineDmUnreadFlags !== undefined) { + return guildRail.inlineDmUnreadFlags; + } + let rowCount = 0; + if (typeof guildRail.inlineDmRowCount === 'number') { + rowCount = guildRail.inlineDmRowCount; + } + const flagCount = Math.min(MAX_GUILD_RAIL_INLINE_DM_ROWS, Math.max(0, rowCount)); + return Array.from({length: flagCount}, () => false); +} + +function upgradeLegacyGuildRail(guildRail: Record): Record { + return { + capturedAt: guildRail.capturedAt, + inlineDmRowCount: guildRail.inlineDmRowCount, + inlineDmUnreadFlags: upgradeLegacyGuildRailUnreadFlags(guildRail), + selectedInlineDmRowIndex: guildRail.selectedInlineDmRowIndex ?? SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + outageVisible: guildRail.outageVisible, + fluxerVisible: guildRail.fluxerVisible, + favoritesVisible: guildRail.favoritesVisible, + discoveryVisible: guildRail.discoveryVisible, + addGuildVisible: guildRail.addGuildVisible, + downloadVisible: guildRail.downloadVisible, + helpVisible: guildRail.helpVisible, + selectedItemIndex: guildRail.selectedItemIndex ?? SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + organizedItems: upgradeLegacyGuildRailItems(guildRail.organizedItems), + scrollTopPx: guildRail.scrollTopPx ?? 0, + }; +} + +function upgradeLegacyDMSidebar(dmSidebar: Record): Record { + let channelSubtextFlags = dmSidebar.channelSubtextFlags; + if (channelSubtextFlags === undefined) { + const rowCount = typeof dmSidebar.channelRowCount === 'number' ? dmSidebar.channelRowCount : 0; + const flagCount = Math.min(MAX_DM_SIDEBAR_ROWS, Math.max(0, rowCount)); + channelSubtextFlags = Array.from({length: flagCount}, () => false); + } + return { + capturedAt: dmSidebar.capturedAt, + isMobile: dmSidebar.isMobile, + friendsVisible: dmSidebar.friendsVisible, + personalNotesVisible: dmSidebar.personalNotesVisible, + premiumVisible: dmSidebar.premiumVisible, + sectionVisible: dmSidebar.sectionVisible, + channelRowCount: dmSidebar.channelRowCount, + channelSubtextFlags, + }; +} + +function upgradeLegacyChrome(chrome: unknown): unknown { + if (!isRecord(chrome)) { + return chrome; + } + const upgraded: Record = {}; + if (isRecord(chrome.guildRail)) { + upgraded.guildRail = upgradeLegacyGuildRail(chrome.guildRail); + } + if (isRecord(chrome.dmSidebar)) { + upgraded.dmSidebar = upgradeLegacyDMSidebar(chrome.dmSidebar); + } + return upgraded; +} + +function upgradeLegacyFriends(friends: unknown): unknown { + if (!isRecord(friends) || friends.rowCount === undefined) { + return friends; + } + const rowCount = typeof friends.rowCount === 'number' ? friends.rowCount : 0; + const reportedCardCount = typeof friends.activeNowCardCount === 'number' ? friends.activeNowCardCount : 0; + const activeNowCardCount = Math.min(MAX_ACTIVE_NOW_CARDS, Math.max(0, reportedCardCount)); + const activeNowVisible = friends.activeNowVisible === true; + return { + capturedAt: friends.capturedAt, + activeTab: SkeletonFriendsTab.ONLINE, + onlineRowCount: rowCount, + allRowCount: rowCount, + pendingRowCount: 0, + pendingIncomingRowCount: 0, + tabWidthsPx: Array.from({length: SKELETON_FRIENDS_TAB_COUNT}, () => SKELETON_UNMEASURED_WIDTH_PX), + pendingBadgeVisible: false, + activeNowVisible, + activeNowCards: activeNowVisible + ? Array.from({length: activeNowCardCount}, () => ({participantCount: 0, streaming: false})) + : [], + }; +} + +function upgradeLegacyChannelHeader(channelHeader: Record): Record { + return { + capturedAt: channelHeader.capturedAt, + staffToolsVisible: channelHeader.staffToolsVisible, + updaterVisible: channelHeader.updaterVisible, + favoritesVisible: channelHeader.favoritesVisible ?? SKELETON_DEFAULT_FAVORITES_VISIBLE, + }; +} + +function upgradeLegacyNagbar(nagbar: unknown): unknown { + if (!isRecord(nagbar) || Array.isArray(nagbar.rows)) { + return nagbar; + } + const storedRowCount = nagbar.visibleRowCount ?? nagbar.rowCount; + const reportedRowCount = typeof storedRowCount === 'number' ? storedRowCount : 0; + const rowCount = Math.min(MAX_NAGBAR_ROWS, Math.max(0, reportedRowCount)); + return { + capturedAt: nagbar.capturedAt, + rows: Array.from({length: rowCount}, () => ({ + tone: SkeletonNagbarTone.NEUTRAL, + hasActions: false, + dismissible: false, + })), + }; +} + +function upgradeLegacyChannelMemberLayouts(layouts: unknown): unknown { + if (!Array.isArray(layouts)) { + return layouts; + } + return layouts.map((layout) => { + if (!isRecord(layout)) { + return layout; + } + let memberGroups = layout.memberGroups; + if (Array.isArray(layout.memberGroupCounts)) { + memberGroups = layout.memberGroupCounts.map((rowCount) => ({ + rowCount, + headingWidthPx: SKELETON_UNMEASURED_WIDTH_PX, + })); + } + return { + capturedAt: layout.capturedAt, + fingerprint: layout.fingerprint, + kind: layout.kind, + memberGroups, + }; + }); +} + +function upgradeLegacyChannelProjectionMemberListVisible(projection: Record): boolean { + if (projection.memberListVisible !== undefined) { + return projection.memberListVisible === true; + } + return isChannelProjectionKind(projection.channelKind) && canChannelProjectionShowMemberList(projection.channelKind); +} + +function upgradeLegacyChannelProjections(projections: unknown): unknown { + if (!Array.isArray(projections)) { + return projections; + } + return projections.map((projection) => { + if (!isRecord(projection)) { + return projection; + } + return { + capturedAt: projection.capturedAt, + fingerprint: projection.fingerprint, + channelKind: projection.channelKind, + showTopic: projection.showTopic, + nameWidthPx: projection.nameWidthPx ?? SKELETON_UNMEASURED_WIDTH_PX, + topicWidthPx: projection.topicWidthPx ?? SKELETON_UNMEASURED_WIDTH_PX, + desktopLeadingActionCount: projection.desktopLeadingActionCount ?? 0, + mobileActionCount: projection.mobileActionCount ?? 0, + memberListVisible: upgradeLegacyChannelProjectionMemberListVisible(projection), + searchPanelOpen: projection.searchPanelOpen === true, + }; + }); +} + +function upgradeStoredRecord(raw: Record, storedVersion: number): Record { + if (storedVersion === STORAGE_VERSION) { + return raw; + } + const upgraded: Record = {...raw}; + upgraded.chrome = upgradeLegacyChrome(raw.chrome); + upgraded.friends = upgradeLegacyFriends(raw.friends); + if (isRecord(raw.channelHeader)) { + upgraded.channelHeader = upgradeLegacyChannelHeader(raw.channelHeader); + } + upgraded.nagbar = upgradeLegacyNagbar(raw.nagbar); + upgraded.channelMemberLayouts = upgradeLegacyChannelMemberLayouts(raw.channelMemberLayouts); + upgraded.channelProjections = upgradeLegacyChannelProjections(raw.channelProjections); + upgraded.version = STORAGE_VERSION; + return upgraded; +} + +function readInitialState(activeAccountFingerprint: string | null): SkeletonLayoutState { + if (activeAccountFingerprint == null) { + return EMPTY_STATE; + } + const raw = readStoredRecord(); + if (raw == null) { + return EMPTY_STATE; + } + if (!isRecord(raw)) { + removeStoredRecord(); + return EMPTY_STATE; + } + const now = Date.now(); + const storedVersion = parseBoundedInteger(raw.version, MIN_READABLE_STORAGE_VERSION, STORAGE_VERSION); + if ( + storedVersion == null || + typeof raw.accountFingerprint !== 'string' || + !FINGERPRINT_PATTERN.test(raw.accountFingerprint) || + !isFreshTimestamp(raw.updatedAt, now) + ) { + removeStoredRecord(); + return EMPTY_STATE; + } + if (raw.accountFingerprint !== activeAccountFingerprint) { + return EMPTY_STATE; + } + if (!hasOnlyKeys(raw, ROOT_KEYS)) { + staleRecordNeedsRewrite = true; + } + const record = upgradeStoredRecord(raw, storedVersion); + const chrome = parseChromeLayout(record.chrome, now); + const friends = parseFriendsLayout(record.friends, now); + const channelHeader = parseChannelHeaderLayout(record.channelHeader, now); + const nagbar = parseNagbarLayout(record.nagbar, now); + const composer = parseComposerLayout(record.composer, now); + const messagePresentation = parseMessagePresentation(record.messagePresentation, now); + const voice = parseVoicePresence(record.voice, now); + const discovery = parseDiscoveryLayout(record.discovery, now); + const simplePages = parseSimplePageLayouts(record.simplePages, now); + const channelMemberLayouts = parseChannelMemberLayouts(record.channelMemberLayouts, now); + const channelProjections = parseChannelProjections(record.channelProjections, now); + const guildPresentations = parseGuildPresentations(record.guildPresentations, now); + const guildChannelLists = parseGuildChannelLists(record.guildChannelLists, now); + const results = [ + chrome, + friends, + channelHeader, + nagbar, + composer, + messagePresentation, + voice, + discovery, + simplePages, + channelMemberLayouts, + channelProjections, + guildPresentations, + guildChannelLists, + ]; + if (results.every((result) => result.status !== 'valid')) { + removeStoredRecord(); + return EMPTY_STATE; + } + if (storedVersion !== STORAGE_VERSION || results.some((result) => result.status === 'invalid')) { + staleRecordNeedsRewrite = true; + } + return Object.freeze({ + chrome: chrome.status === 'valid' ? chrome.value : undefined, + friends: friends.status === 'valid' ? friends.value : undefined, + channelHeader: channelHeader.status === 'valid' ? channelHeader.value : undefined, + nagbar: nagbar.status === 'valid' ? nagbar.value : undefined, + composer: composer.status === 'valid' ? composer.value : undefined, + messagePresentation: messagePresentation.status === 'valid' ? messagePresentation.value : undefined, + voice: voice.status === 'valid' ? voice.value : undefined, + discovery: discovery.status === 'valid' ? discovery.value : undefined, + simplePages: simplePages.status === 'valid' ? simplePages.value : undefined, + channelMemberLayouts: channelMemberLayouts.status === 'valid' ? channelMemberLayouts.value : undefined, + channelProjections: channelProjections.status === 'valid' ? channelProjections.value : undefined, + guildPresentations: guildPresentations.status === 'valid' ? guildPresentations.value : undefined, + guildChannelLists: guildChannelLists.status === 'valid' ? guildChannelLists.value : undefined, + }); +} + +function areNumberArraysEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function areBooleanArraysEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function areMemberGroupsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + left[index].rowCount !== right[index].rowCount || + left[index].headingWidthPx !== right[index].headingWidthPx || + !areBooleanArraysEqual(left[index].subtextFlags, right[index].subtextFlags) + ) { + return false; + } + } + return true; +} + +function areActiveNowCardsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + left[index].participantCount !== right[index].participantCount || + left[index].streaming !== right[index].streaming + ) { + return false; + } + } + return true; +} + +function areNagbarRowsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if ( + left[index].tone !== right[index].tone || + left[index].hasActions !== right[index].hasActions || + left[index].dismissible !== right[index].dismissible + ) { + return false; + } + } + return true; +} + +function areGuildChannelGroupsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftGroup = left[index]; + const rightGroup = right[index]; + if ( + leftGroup.categoryHeaderVisible !== rightGroup.categoryHeaderVisible || + leftGroup.collapsed !== rightGroup.collapsed || + leftGroup.categoryNameWidthPx !== rightGroup.categoryNameWidthPx || + leftGroup.channels.length !== rightGroup.channels.length + ) { + return false; + } + for (let channelIndex = 0; channelIndex < leftGroup.channels.length; channelIndex += 1) { + if ( + leftGroup.channels[channelIndex].voice !== rightGroup.channels[channelIndex].voice || + leftGroup.channels[channelIndex].nameWidthPx !== rightGroup.channels[channelIndex].nameWidthPx + ) { + return false; + } + } + } + return true; +} + +function areGuildRailChildIndicatorsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function areGuildRailItemsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if (leftItem.kind !== rightItem.kind || leftItem.indicator !== rightItem.indicator) { + return false; + } + if ( + leftItem.kind === SkeletonGuildRailItemKind.EXPANDED_FOLDER && + (rightItem.kind !== SkeletonGuildRailItemKind.EXPANDED_FOLDER || + leftItem.childCount !== rightItem.childCount || + leftItem.selectedChildIndex !== rightItem.selectedChildIndex || + !areGuildRailChildIndicatorsEqual(leftItem.childIndicators, rightItem.childIndicators)) + ) { + return false; + } + if ( + leftItem.kind === SkeletonGuildRailItemKind.COLLAPSED_FOLDER && + (rightItem.kind !== SkeletonGuildRailItemKind.COLLAPSED_FOLDER || + leftItem.childCount !== rightItem.childCount || + leftItem.showIconWhenCollapsed !== rightItem.showIconWhenCollapsed) + ) { + return false; + } + } + return true; +} + +let writeTimer: number | null = null; +let writePending = false; +let runningPreFlush = false; +const preFlushCallbacks = new Set<() => void>(); +let staleRecordNeedsRewrite = false; +let activeAccountFingerprint = resolveActiveAccountFingerprint(); +let state = loadStateForActiveAccount(); +let captureEnabled = false; +let pendingChrome: PendingChromeLayouts = {}; +let pendingGuildRailScrollTopPx: number | null = null; +let pendingFriends: PendingFriendsLayout = {}; +let pendingChannelHeader: RememberedSkeletonChannelHeaderLayout | null = null; +let pendingNagbar: RememberedSkeletonNagbarLayout | null = null; +let pendingComposer: RememberedSkeletonComposerLayout | null = null; +let pendingMessagePresentation: PendingMessagePresentation = {}; +let pendingVoice: RememberedSkeletonVoicePresence | null = null; +let pendingDiscovery: PendingDiscoveryLayout = {}; +let pendingSimplePages: ReadonlyArray = Object.freeze([]); +let pendingChannelMemberLayouts: ReadonlyArray = Object.freeze([]); +let pendingChannelProjections: ReadonlyArray = Object.freeze([]); +let pendingGuildPresentations: ReadonlyArray = Object.freeze([]); +let pendingGuildChannelLists: ReadonlyArray = Object.freeze([]); +const capturedThisSession = new Set(); +const capturedMemberFingerprintsThisSession = new Set(); +const capturedChannelFingerprintsThisSession = new Set(); +const capturedGuildFingerprintsThisSession = new Set(); +const capturedGuildChannelListFingerprintsThisSession = new Set(); + +function clearPendingCaptureState(): void { + pendingChrome = {}; + pendingGuildRailScrollTopPx = null; + pendingFriends = {}; + pendingChannelHeader = null; + pendingNagbar = null; + pendingComposer = null; + pendingMessagePresentation = {}; + pendingVoice = null; + pendingDiscovery = {}; + pendingSimplePages = Object.freeze([]); + pendingChannelMemberLayouts = Object.freeze([]); + pendingChannelProjections = Object.freeze([]); + pendingGuildPresentations = Object.freeze([]); + pendingGuildChannelLists = Object.freeze([]); + capturedThisSession.clear(); + capturedMemberFingerprintsThisSession.clear(); + capturedChannelFingerprintsThisSession.clear(); + capturedGuildFingerprintsThisSession.clear(); + capturedGuildChannelListFingerprintsThisSession.clear(); +} + +function loadStateForActiveAccount(): SkeletonLayoutState { + staleRecordNeedsRewrite = false; + const nextState = readInitialState(activeAccountFingerprint); + if (staleRecordNeedsRewrite) { + staleRecordNeedsRewrite = false; + scheduleWrite(); + } + return nextState; +} + +function synchronizeActiveAccountState(): void { + const nextAccountFingerprint = resolveActiveAccountFingerprint(); + if (nextAccountFingerprint === activeAccountFingerprint) { + return; + } + flushSkeletonLayoutMemoryWrite(); + activeAccountFingerprint = nextAccountFingerprint; + state = EMPTY_STATE; + clearPendingCaptureState(); + state = loadStateForActiveAccount(); +} + +AppStorage.subscribe( + () => { + synchronizeActiveAccountState(); + }, + {key: AuthSessionStorageKey.UserId}, +); + +function createStoredRecord(source: SkeletonLayoutState): Record { + const record: Record = { + version: STORAGE_VERSION, + updatedAt: Date.now(), + accountFingerprint: activeAccountFingerprint, + }; + for (const key of SKELETON_LAYOUT_STATE_KEYS) { + record[key] = source[key]; + } + return record; +} + +function writeStoredRecord(source: SkeletonLayoutState): boolean { + try { + AppStorage.setJSON(STORAGE_KEY, createStoredRecord(source)); + return true; + } catch { + return false; + } +} + +function halvePerEntityRecords(source: SkeletonLayoutState): SkeletonLayoutState | null { + const halve = (entries: ReadonlyArray | undefined): ReadonlyArray | undefined => { + if (entries == null || entries.length <= 1) { + return entries; + } + return Object.freeze(entries.slice(-Math.max(1, Math.floor(entries.length / 2)))); + }; + const next = Object.freeze({ + ...source, + channelMemberLayouts: halve(source.channelMemberLayouts), + channelProjections: halve(source.channelProjections), + guildPresentations: halve(source.guildPresentations), + guildChannelLists: halve(source.guildChannelLists), + }); + if ( + next.channelMemberLayouts?.length === source.channelMemberLayouts?.length && + next.channelProjections?.length === source.channelProjections?.length && + next.guildPresentations?.length === source.guildPresentations?.length && + next.guildChannelLists?.length === source.guildChannelLists?.length + ) { + return null; + } + return next; +} + +function persistState(): void { + writeTimer = null; + if (activeAccountFingerprint == null) { + writePending = false; + return; + } + if (writeStoredRecord(state)) { + writePending = false; + return; + } + let candidate = halvePerEntityRecords(state); + while (candidate != null) { + if (writeStoredRecord(candidate)) { + state = candidate; + writePending = false; + return; + } + candidate = halvePerEntityRecords(candidate); + } +} + +function scheduleWrite(): void { + if (activeAccountFingerprint == null || typeof window === 'undefined') { + return; + } + writePending = true; + if (writeTimer != null) { + return; + } + writeTimer = window.setTimeout(persistState, WRITE_COALESCE_MS); +} + +function rememberFingerprintInSession(retention: Set, fingerprint: string, limit: number): void { + retention.delete(fingerprint); + retention.add(fingerprint); + while (retention.size > limit) { + const oldestFingerprint = retention.values().next().value; + if (oldestFingerprint == null) { + break; + } + retention.delete(oldestFingerprint); + } +} + +function commitDMSidebarLayout(layout: RememberedSkeletonDMSidebarLayout): void { + const previous = state.chrome?.dmSidebar; + const sessionKey = 'chrome:dm-sidebar'; + if ( + capturedThisSession.has(sessionKey) && + previous?.isMobile === layout.isMobile && + previous.friendsVisible === layout.friendsVisible && + previous.personalNotesVisible === layout.personalNotesVisible && + previous.premiumVisible === layout.premiumVisible && + previous.sectionVisible === layout.sectionVisible && + previous.channelRowCount === layout.channelRowCount && + areBooleanArraysEqual(previous.channelSubtextFlags, layout.channelSubtextFlags) + ) { + return; + } + capturedThisSession.add(sessionKey); + const dmSidebar = Object.freeze({...layout, capturedAt: Date.now()}); + state = Object.freeze({...state, chrome: Object.freeze({...state.chrome, dmSidebar})}); + scheduleWrite(); +} + +function commitGuildRailLayout(layout: RememberedSkeletonGuildRailLayout): void { + const previous = state.chrome?.guildRail; + const sessionKey = 'chrome:guild-rail'; + if ( + capturedThisSession.has(sessionKey) && + previous?.inlineDmRowCount === layout.inlineDmRowCount && + areBooleanArraysEqual(previous.inlineDmUnreadFlags, layout.inlineDmUnreadFlags) && + previous.selectedInlineDmRowIndex === layout.selectedInlineDmRowIndex && + previous.outageVisible === layout.outageVisible && + previous.fluxerVisible === layout.fluxerVisible && + previous.favoritesVisible === layout.favoritesVisible && + previous.discoveryVisible === layout.discoveryVisible && + previous.addGuildVisible === layout.addGuildVisible && + previous.downloadVisible === layout.downloadVisible && + previous.helpVisible === layout.helpVisible && + previous.selectedItemIndex === layout.selectedItemIndex && + previous.scrollTopPx === layout.scrollTopPx && + areGuildRailItemsEqual(previous.organizedItems, layout.organizedItems) + ) { + return; + } + capturedThisSession.add(sessionKey); + const guildRail = Object.freeze({ + ...layout, + capturedAt: Date.now(), + organizedItems: Object.freeze([...layout.organizedItems]), + }); + state = Object.freeze({...state, chrome: Object.freeze({...state.chrome, guildRail})}); + scheduleWrite(); +} + +function resolvePendingFriendsLayout(): RememberedSkeletonFriendsLayout { + const base = state.friends ?? SKELETON_DEFAULT_FRIENDS_LAYOUT; + const activeNowVisible = pendingFriends.activeNowVisible ?? base.activeNowVisible; + let activeNowCards = EMPTY_ACTIVE_NOW_CARDS; + if (activeNowVisible) { + activeNowCards = pendingFriends.activeNowCards ?? base.activeNowCards; + } + return Object.freeze({ + activeTab: pendingFriends.activeTab ?? base.activeTab, + onlineRowCount: pendingFriends.onlineRowCount ?? base.onlineRowCount, + allRowCount: pendingFriends.allRowCount ?? base.allRowCount, + pendingRowCount: pendingFriends.pendingRowCount ?? base.pendingRowCount, + pendingIncomingRowCount: pendingFriends.pendingIncomingRowCount ?? base.pendingIncomingRowCount, + tabWidthsPx: pendingFriends.tabWidthsPx ?? base.tabWidthsPx, + pendingBadgeVisible: pendingFriends.pendingBadgeVisible ?? base.pendingBadgeVisible, + activeNowVisible, + activeNowCards, + }); +} + +function commitFriendsLayout(): void { + const layout = resolvePendingFriendsLayout(); + const previous = state.friends; + const sessionKey = 'friends'; + if ( + capturedThisSession.has(sessionKey) && + previous?.activeTab === layout.activeTab && + previous.onlineRowCount === layout.onlineRowCount && + previous.allRowCount === layout.allRowCount && + previous.pendingRowCount === layout.pendingRowCount && + previous.pendingIncomingRowCount === layout.pendingIncomingRowCount && + previous.pendingBadgeVisible === layout.pendingBadgeVisible && + previous.activeNowVisible === layout.activeNowVisible && + areNumberArraysEqual(previous.tabWidthsPx, layout.tabWidthsPx) && + areActiveNowCardsEqual(previous.activeNowCards, layout.activeNowCards) + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, friends: Object.freeze({...layout, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function commitChannelHeaderLayout(layout: RememberedSkeletonChannelHeaderLayout): void { + const previous = state.channelHeader; + const sessionKey = 'channel-header'; + if ( + capturedThisSession.has(sessionKey) && + previous?.staffToolsVisible === layout.staffToolsVisible && + previous.updaterVisible === layout.updaterVisible && + previous.favoritesVisible === layout.favoritesVisible + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, channelHeader: Object.freeze({...layout, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function commitNagbarLayout(layout: RememberedSkeletonNagbarLayout): void { + const previous = state.nagbar; + const sessionKey = 'nagbar'; + if (capturedThisSession.has(sessionKey) && previous != null && areNagbarRowsEqual(previous.rows, layout.rows)) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({ + ...state, + nagbar: Object.freeze({rows: Object.freeze([...layout.rows]), capturedAt: Date.now()}), + }); + scheduleWrite(); +} + +function commitComposerLayout(layout: RememberedSkeletonComposerLayout): void { + const previous = state.composer; + const sessionKey = 'composer'; + if ( + capturedThisSession.has(sessionKey) && + previous?.desktopActionCount === layout.desktopActionCount && + previous.mobileActionCount === layout.mobileActionCount && + previous.sendDividerVisible === layout.sendDividerVisible + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, composer: Object.freeze({...layout, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function resolvePendingMessagePresentation(): RememberedSkeletonMessagePresentation { + const base = state.messagePresentation ?? resolveDefaultSkeletonMessagePresentation(); + return Object.freeze({ + compact: pendingMessagePresentation.compact ?? base.compact, + messageGutterPx: pendingMessagePresentation.messageGutterPx ?? base.messageGutterPx, + fontSizePx: pendingMessagePresentation.fontSizePx ?? base.fontSizePx, + groupSpacingPx: pendingMessagePresentation.groupSpacingPx ?? base.groupSpacingPx, + compactAvatarsVisible: pendingMessagePresentation.compactAvatarsVisible ?? base.compactAvatarsVisible, + compactTimestampWidthPx: pendingMessagePresentation.compactTimestampWidthPx ?? base.compactTimestampWidthPx, + viewportHeightPx: pendingMessagePresentation.viewportHeightPx ?? base.viewportHeightPx, + }); +} + +function commitMessagePresentation(): void { + const presentation = resolvePendingMessagePresentation(); + const previous = state.messagePresentation; + const sessionKey = 'message-presentation'; + if ( + capturedThisSession.has(sessionKey) && + previous?.compact === presentation.compact && + previous.messageGutterPx === presentation.messageGutterPx && + previous.fontSizePx === presentation.fontSizePx && + previous.groupSpacingPx === presentation.groupSpacingPx && + previous.compactAvatarsVisible === presentation.compactAvatarsVisible && + previous.compactTimestampWidthPx === presentation.compactTimestampWidthPx && + previous.viewportHeightPx === presentation.viewportHeightPx + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, messagePresentation: Object.freeze({...presentation, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function commitVoicePresence(presence: RememberedSkeletonVoicePresence): void { + const previous = state.voice; + const sessionKey = 'voice'; + if ( + capturedThisSession.has(sessionKey) && + previous?.connected === presence.connected && + previous.panelHeightPx === presence.panelHeightPx + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, voice: Object.freeze({...presence, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function resolvePendingDiscoveryLayout(): RememberedSkeletonDiscoveryLayout { + const base = state.discovery ?? resolveDefaultSkeletonDiscoveryLayout(); + return Object.freeze({ + columnCount: pendingDiscovery.columnCount ?? base.columnCount, + visibleRowCount: pendingDiscovery.visibleRowCount ?? base.visibleRowCount, + categoryTabWidthsPx: pendingDiscovery.categoryTabWidthsPx ?? base.categoryTabWidthsPx, + }); +} + +function commitDiscoveryLayout(): void { + const layout = resolvePendingDiscoveryLayout(); + const previous = state.discovery; + const sessionKey = 'discovery'; + if ( + capturedThisSession.has(sessionKey) && + previous?.columnCount === layout.columnCount && + previous.visibleRowCount === layout.visibleRowCount && + areNumberArraysEqual(previous.categoryTabWidthsPx, layout.categoryTabWidthsPx) + ) { + return; + } + capturedThisSession.add(sessionKey); + state = Object.freeze({...state, discovery: Object.freeze({...layout, capturedAt: Date.now()})}); + scheduleWrite(); +} + +function commitSimplePageLayout(layout: PendingSkeletonSimplePageLayout): void { + const current = state.simplePages ?? []; + const previous = current.find((entry) => entry.route === layout.route); + const sessionKey = `simple-page:${layout.route}`; + if ( + capturedThisSession.has(sessionKey) && + previous?.body === layout.body && + previous.rowCount === layout.rowCount && + previous.selectable === layout.selectable + ) { + return; + } + capturedThisSession.add(sessionKey); + const next = current.filter((entry) => entry.route !== layout.route); + next.push(Object.freeze({...layout, capturedAt: Date.now()})); + state = Object.freeze({...state, simplePages: Object.freeze(next.slice(-MAX_SIMPLE_PAGE_LAYOUTS))}); + scheduleWrite(); +} + +function commitChannelMemberLayout(layout: PendingSkeletonChannelMemberLayout): void { + const current = state.channelMemberLayouts ?? []; + const previous = current.find(({fingerprint}) => fingerprint === layout.fingerprint); + if ( + capturedMemberFingerprintsThisSession.has(layout.fingerprint) && + current[current.length - 1]?.fingerprint === layout.fingerprint && + previous?.kind === layout.kind && + areMemberGroupsEqual(previous.memberGroups, layout.memberGroups) + ) { + rememberFingerprintInSession(capturedMemberFingerprintsThisSession, layout.fingerprint, MAX_CHANNEL_MEMBER_LAYOUTS); + return; + } + rememberFingerprintInSession(capturedMemberFingerprintsThisSession, layout.fingerprint, MAX_CHANNEL_MEMBER_LAYOUTS); + const nextLayout = Object.freeze({ + ...layout, + capturedAt: Date.now(), + memberGroups: Object.freeze([...layout.memberGroups]), + }); + const next = current.filter(({fingerprint}) => fingerprint !== layout.fingerprint); + next.push(nextLayout); + state = Object.freeze({ + ...state, + channelMemberLayouts: Object.freeze(next.slice(-MAX_CHANNEL_MEMBER_LAYOUTS)), + }); + scheduleWrite(); +} + +function commitChannelProjection(projection: PendingSkeletonChannelProjection): void { + const current = state.channelProjections ?? []; + const previous = current.find((entry) => entry.fingerprint === projection.fingerprint); + if ( + capturedChannelFingerprintsThisSession.has(projection.fingerprint) && + current[current.length - 1]?.fingerprint === projection.fingerprint && + previous?.channelKind === projection.channelKind && + previous.showTopic === projection.showTopic && + previous.nameWidthPx === projection.nameWidthPx && + previous.topicWidthPx === projection.topicWidthPx && + previous.desktopLeadingActionCount === projection.desktopLeadingActionCount && + previous.mobileActionCount === projection.mobileActionCount && + previous.memberListVisible === projection.memberListVisible && + previous.searchPanelOpen === projection.searchPanelOpen + ) { + rememberFingerprintInSession( + capturedChannelFingerprintsThisSession, + projection.fingerprint, + MAX_CHANNEL_PROJECTIONS, + ); + return; + } + rememberFingerprintInSession(capturedChannelFingerprintsThisSession, projection.fingerprint, MAX_CHANNEL_PROJECTIONS); + const nextProjection = Object.freeze({...projection, capturedAt: Date.now()}); + const next = current.filter((entry) => entry.fingerprint !== projection.fingerprint); + next.push(nextProjection); + state = Object.freeze({...state, channelProjections: Object.freeze(next.slice(-MAX_CHANNEL_PROJECTIONS))}); + scheduleWrite(); +} + +function commitGuildPresentation(presentation: PendingSkeletonGuildPresentation): void { + const current = state.guildPresentations ?? []; + const previous = current.find((entry) => entry.fingerprint === presentation.fingerprint); + if ( + capturedGuildFingerprintsThisSession.has(presentation.fingerprint) && + current[current.length - 1]?.fingerprint === presentation.fingerprint && + previous?.headerNameWidthPx === presentation.headerNameWidthPx && + previous.badgeVisible === presentation.badgeVisible && + previous.bannerPlacement === presentation.bannerPlacement && + previous.bannerAspectRatio === presentation.bannerAspectRatio + ) { + rememberFingerprintInSession( + capturedGuildFingerprintsThisSession, + presentation.fingerprint, + MAX_GUILD_PRESENTATIONS, + ); + return; + } + rememberFingerprintInSession(capturedGuildFingerprintsThisSession, presentation.fingerprint, MAX_GUILD_PRESENTATIONS); + const next = current.filter((entry) => entry.fingerprint !== presentation.fingerprint); + next.push(Object.freeze({...presentation, capturedAt: Date.now()})); + state = Object.freeze({...state, guildPresentations: Object.freeze(next.slice(-MAX_GUILD_PRESENTATIONS))}); + scheduleWrite(); +} + +function commitGuildChannelList(list: PendingSkeletonGuildChannelList): void { + const current = state.guildChannelLists ?? []; + const previous = current.find((entry) => entry.fingerprint === list.fingerprint); + if ( + capturedGuildChannelListFingerprintsThisSession.has(list.fingerprint) && + current[current.length - 1]?.fingerprint === list.fingerprint && + previous?.membersRowVisible === list.membersRowVisible && + areGuildChannelGroupsEqual(previous.groups, list.groups) + ) { + rememberFingerprintInSession( + capturedGuildChannelListFingerprintsThisSession, + list.fingerprint, + MAX_GUILD_CHANNEL_LISTS, + ); + return; + } + rememberFingerprintInSession( + capturedGuildChannelListFingerprintsThisSession, + list.fingerprint, + MAX_GUILD_CHANNEL_LISTS, + ); + const next = current.filter((entry) => entry.fingerprint !== list.fingerprint); + next.push(Object.freeze({...list, capturedAt: Date.now()})); + state = Object.freeze({...state, guildChannelLists: Object.freeze(next.slice(-MAX_GUILD_CHANNEL_LISTS))}); + scheduleWrite(); +} + +function flushPendingReports(): void { + if (pendingChrome.dmSidebar != null) { + commitDMSidebarLayout(pendingChrome.dmSidebar); + } + if (pendingChrome.guildRail != null) { + commitGuildRailLayout(pendingChrome.guildRail); + } + if (Object.keys(pendingFriends).length > 0) { + commitFriendsLayout(); + } + if (pendingChannelHeader != null) { + commitChannelHeaderLayout(pendingChannelHeader); + } + if (pendingNagbar != null) { + commitNagbarLayout(pendingNagbar); + } + if (pendingComposer != null) { + commitComposerLayout(pendingComposer); + } + if (Object.keys(pendingMessagePresentation).length > 0) { + commitMessagePresentation(); + } + if (pendingVoice != null) { + commitVoicePresence(pendingVoice); + } + if (Object.keys(pendingDiscovery).length > 0) { + commitDiscoveryLayout(); + } + for (const layout of pendingSimplePages) { + commitSimplePageLayout(layout); + } + for (const layout of pendingChannelMemberLayouts) { + commitChannelMemberLayout(layout); + } + for (const projection of pendingChannelProjections) { + commitChannelProjection(projection); + } + for (const presentation of pendingGuildPresentations) { + commitGuildPresentation(presentation); + } + for (const list of pendingGuildChannelLists) { + commitGuildChannelList(list); + } +} + +export function setSkeletonLayoutCaptureEnabled(enabled: boolean): void { + synchronizeActiveAccountState(); + if (captureEnabled === enabled) { + return; + } + captureEnabled = enabled; + if (enabled) { + flushPendingReports(); + return; + } + clearPendingCaptureState(); +} + +export function registerSkeletonLayoutMemoryPreFlush(callback: () => void): () => void { + preFlushCallbacks.add(callback); + return () => { + preFlushCallbacks.delete(callback); + }; +} + +export function flushSkeletonLayoutMemoryWrite(): void { + if (!runningPreFlush) { + runningPreFlush = true; + try { + for (const callback of preFlushCallbacks) { + callback(); + } + } finally { + runningPreFlush = false; + } + } + if (writeTimer != null && typeof window !== 'undefined') { + window.clearTimeout(writeTimer); + } + writeTimer = null; + if (!writePending) { + return; + } + persistState(); +} + +export function reportSkeletonDMSidebarLayout(layout: RememberedSkeletonDMSidebarLayout): void { + synchronizeActiveAccountState(); + const channelRowCount = clampReportedInteger(layout.channelRowCount, 0, MAX_DM_SIDEBAR_ROWS); + if ( + channelRowCount == null || + typeof layout.isMobile !== 'boolean' || + typeof layout.friendsVisible !== 'boolean' || + typeof layout.personalNotesVisible !== 'boolean' || + typeof layout.premiumVisible !== 'boolean' || + typeof layout.sectionVisible !== 'boolean' || + !Array.isArray(layout.channelSubtextFlags) + ) { + return; + } + const channelSubtextFlags: Array = []; + for (let index = 0; index < channelRowCount; index += 1) { + channelSubtextFlags.push(layout.channelSubtextFlags[index] === true); + } + const normalizedLayout: RememberedSkeletonDMSidebarLayout = Object.freeze({ + isMobile: layout.isMobile, + friendsVisible: layout.friendsVisible, + personalNotesVisible: layout.personalNotesVisible, + premiumVisible: layout.premiumVisible, + sectionVisible: layout.sectionVisible, + channelRowCount, + channelSubtextFlags: Object.freeze(channelSubtextFlags), + }); + if (!isValidDMSidebarVisibility(normalizedLayout)) { + return; + } + pendingChrome = {...pendingChrome, dmSidebar: normalizedLayout}; + if (captureEnabled) { + commitDMSidebarLayout(normalizedLayout); + } +} + +export function reportSkeletonGuildRailLayout(layout: Omit): void { + synchronizeActiveAccountState(); + const inlineDmRowCount = clampReportedInteger(layout.inlineDmRowCount, 0, MAX_GUILD_RAIL_INLINE_DM_ROWS); + if ( + inlineDmRowCount == null || + typeof layout.outageVisible !== 'boolean' || + typeof layout.fluxerVisible !== 'boolean' || + typeof layout.favoritesVisible !== 'boolean' || + typeof layout.discoveryVisible !== 'boolean' || + typeof layout.addGuildVisible !== 'boolean' || + typeof layout.downloadVisible !== 'boolean' || + typeof layout.helpVisible !== 'boolean' || + layout.discoveryVisible !== layout.addGuildVisible || + (!layout.fluxerVisible && inlineDmRowCount > 0) || + !Array.isArray(layout.organizedItems) || + !Array.isArray(layout.inlineDmUnreadFlags) + ) { + return; + } + const organizedItems: Array = []; + let remainingVisualRows = SKELETON_GUILD_RAIL_ORGANIZED_VISUAL_ROW_LIMIT; + for (const item of layout.organizedItems) { + if (remainingVisualRows === 0) { + break; + } + if (!isSkeletonGuildRailItemIndicator(item.indicator)) { + return; + } + const indicator = item.indicator; + switch (item.kind) { + case SkeletonGuildRailItemKind.GUILD: + organizedItems.push(Object.freeze({kind: item.kind, indicator})); + remainingVisualRows -= 1; + break; + case SkeletonGuildRailItemKind.COLLAPSED_FOLDER: { + const childCount = clampReportedInteger(item.childCount, 0, SKELETON_GUILD_RAIL_COLLAPSED_FOLDER_CHILD_LIMIT); + if (childCount == null || typeof item.showIconWhenCollapsed !== 'boolean') { + return; + } + organizedItems.push( + Object.freeze({ + kind: item.kind, + indicator, + childCount, + showIconWhenCollapsed: item.showIconWhenCollapsed, + }), + ); + remainingVisualRows -= 1; + break; + } + case SkeletonGuildRailItemKind.EXPANDED_FOLDER: { + const childCount = clampReportedInteger(item.childCount, 0, remainingVisualRows - 1); + if (childCount == null) { + return; + } + organizedItems.push( + Object.freeze({ + kind: item.kind, + indicator, + childCount, + childIndicators: normalizeGuildRailChildIndicators(item.childIndicators, childCount), + selectedChildIndex: normalizeGuildRailSelectedChildIndex(item.selectedChildIndex, childCount), + }), + ); + remainingVisualRows -= childCount + 1; + break; + } + default: + return; + } + } + let selectedItemIndex = clampReportedInteger( + layout.selectedItemIndex, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + Math.max(SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, layout.organizedItems.length - 1), + ); + if (selectedItemIndex != null && selectedItemIndex > organizedItems.length - 1) { + selectedItemIndex = SKELETON_NO_SELECTED_RAIL_ITEM_INDEX; + } + if (selectedItemIndex == null) { + return; + } + const inlineDmUnreadFlags: Array = []; + for (let index = 0; index < inlineDmRowCount; index += 1) { + inlineDmUnreadFlags.push(layout.inlineDmUnreadFlags[index] === true); + } + const selectedInlineDmRowIndex = clampReportedInteger( + layout.selectedInlineDmRowIndex, + SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, + Math.max(SKELETON_NO_SELECTED_RAIL_ITEM_INDEX, inlineDmRowCount - 1), + ); + if (selectedInlineDmRowIndex == null) { + return; + } + const normalizedLayout: RememberedSkeletonGuildRailLayout = Object.freeze({ + inlineDmRowCount, + inlineDmUnreadFlags: Object.freeze(inlineDmUnreadFlags), + selectedInlineDmRowIndex, + outageVisible: layout.outageVisible, + fluxerVisible: layout.fluxerVisible, + favoritesVisible: layout.favoritesVisible, + discoveryVisible: layout.discoveryVisible, + addGuildVisible: layout.addGuildVisible, + downloadVisible: layout.downloadVisible, + helpVisible: layout.helpVisible, + selectedItemIndex, + organizedItems: Object.freeze(organizedItems), + scrollTopPx: + pendingGuildRailScrollTopPx ?? pendingChrome.guildRail?.scrollTopPx ?? state.chrome?.guildRail?.scrollTopPx ?? 0, + }); + pendingGuildRailScrollTopPx = null; + pendingChrome = {...pendingChrome, guildRail: normalizedLayout}; + if (captureEnabled) { + commitGuildRailLayout(normalizedLayout); + } +} + +export function reportSkeletonGuildRailScrollTop(scrollTopPx: number): void { + synchronizeActiveAccountState(); + const normalizedScrollTopPx = clampReportedInteger( + scrollTopPx / getRemScaleForDocument(document), + 0, + MAX_GUILD_RAIL_SCROLL_TOP_PX, + ); + if (normalizedScrollTopPx == null) { + return; + } + const base = pendingChrome.guildRail ?? state.chrome?.guildRail; + if (base == null) { + pendingGuildRailScrollTopPx = normalizedScrollTopPx; + return; + } + pendingGuildRailScrollTopPx = null; + if (base.scrollTopPx === normalizedScrollTopPx) { + return; + } + const normalizedLayout: RememberedSkeletonGuildRailLayout = Object.freeze({ + ...base, + scrollTopPx: normalizedScrollTopPx, + }); + pendingChrome = {...pendingChrome, guildRail: normalizedLayout}; + if (captureEnabled) { + commitGuildRailLayout(normalizedLayout); + } +} + +export function reportSkeletonFriendsRowCount(tab: SkeletonFriendsTab, rowCount: number, incomingRowCount = 0): void { + synchronizeActiveAccountState(); + const clampedRowCount = clampReportedInteger(rowCount, 0, MAX_FRIEND_ROWS); + if (clampedRowCount == null || !isSkeletonFriendsTab(tab)) { + return; + } + if (tab === SkeletonFriendsTab.ONLINE) { + pendingFriends.onlineRowCount = clampedRowCount; + } else if (tab === SkeletonFriendsTab.ALL) { + pendingFriends.allRowCount = clampedRowCount; + } else if (tab === SkeletonFriendsTab.PENDING) { + pendingFriends.pendingRowCount = clampedRowCount; + pendingFriends.pendingIncomingRowCount = clampReportedInteger(incomingRowCount, 0, clampedRowCount) ?? 0; + } + if (captureEnabled) { + commitFriendsLayout(); + } +} + +export interface SkeletonFriendsHeaderReport { + readonly activeTab: SkeletonFriendsTab; + readonly tabWidthsPx: ReadonlyArray; + readonly pendingBadgeVisible: boolean; +} + +function resolveReportedFriendsTabWidths(values: ReadonlyArray): ReadonlyArray { + const previous = + pendingFriends.tabWidthsPx ?? state.friends?.tabWidthsPx ?? SKELETON_DEFAULT_FRIENDS_LAYOUT.tabWidthsPx; + const widths: Array = []; + for (let index = 0; index < SKELETON_FRIENDS_TAB_COUNT; index += 1) { + const reported = Array.isArray(values) ? values[index] : undefined; + const measured = typeof reported === 'number' ? clampReportedInteger(reported, 0, MAX_MEASURED_WIDTH_PX) : null; + if (measured != null && measured !== SKELETON_UNMEASURED_WIDTH_PX) { + widths.push(measured); + continue; + } + widths.push(previous[index] ?? SKELETON_UNMEASURED_WIDTH_PX); + } + return Object.freeze(widths); +} + +export function reportSkeletonFriendsHeaderLayout(report: SkeletonFriendsHeaderReport): void { + synchronizeActiveAccountState(); + if (!isSkeletonFriendsTab(report.activeTab) || typeof report.pendingBadgeVisible !== 'boolean') { + return; + } + pendingFriends.activeTab = report.activeTab; + pendingFriends.pendingBadgeVisible = report.pendingBadgeVisible; + pendingFriends.tabWidthsPx = resolveReportedFriendsTabWidths(report.tabWidthsPx); + if (captureEnabled) { + commitFriendsLayout(); + } +} + +export function reportSkeletonActiveNowLayout( + visible: boolean, + cards: ReadonlyArray, +): void { + synchronizeActiveAccountState(); + if (typeof visible !== 'boolean' || !Array.isArray(cards)) { + return; + } + const normalizedCards: Array = []; + for (const card of cards.slice(0, MAX_ACTIVE_NOW_CARDS)) { + const participantCount = clampReportedInteger(card.participantCount, 0, MAX_ACTIVE_NOW_PARTICIPANTS); + if (participantCount == null || typeof card.streaming !== 'boolean') { + return; + } + normalizedCards.push(Object.freeze({participantCount, streaming: card.streaming})); + } + pendingFriends.activeNowVisible = visible; + pendingFriends.activeNowCards = EMPTY_ACTIVE_NOW_CARDS; + if (visible) { + pendingFriends.activeNowCards = Object.freeze(normalizedCards); + } + if (captureEnabled) { + commitFriendsLayout(); + } +} + +export function reportSkeletonChannelHeaderLayout(layout: RememberedSkeletonChannelHeaderLayout): void { + synchronizeActiveAccountState(); + if ( + typeof layout.staffToolsVisible !== 'boolean' || + typeof layout.updaterVisible !== 'boolean' || + typeof layout.favoritesVisible !== 'boolean' + ) { + return; + } + const normalizedLayout = Object.freeze({ + staffToolsVisible: layout.staffToolsVisible, + updaterVisible: layout.updaterVisible, + favoritesVisible: layout.favoritesVisible, + }); + pendingChannelHeader = normalizedLayout; + if (captureEnabled) { + commitChannelHeaderLayout(normalizedLayout); + } +} + +export function reportSkeletonNagbarLayout(rows: ReadonlyArray): void { + synchronizeActiveAccountState(); + if (!Array.isArray(rows)) { + return; + } + const normalizedRows: Array = []; + for (const row of rows.slice(0, MAX_NAGBAR_ROWS)) { + if (!isSkeletonNagbarTone(row.tone) || typeof row.hasActions !== 'boolean') { + return; + } + normalizedRows.push( + Object.freeze({tone: row.tone, hasActions: row.hasActions, dismissible: row.dismissible === true}), + ); + } + const normalizedLayout = Object.freeze({rows: Object.freeze(normalizedRows)}); + pendingNagbar = normalizedLayout; + if (captureEnabled) { + commitNagbarLayout(normalizedLayout); + } +} + +export function reportSkeletonComposerLayout(layout: RememberedSkeletonComposerLayout): void { + synchronizeActiveAccountState(); + const desktopActionCount = clampReportedInteger(layout.desktopActionCount, 0, MAX_COMPOSER_DESKTOP_ACTION_COUNT); + const mobileActionCount = clampReportedInteger(layout.mobileActionCount, 0, MAX_COMPOSER_MOBILE_ACTION_COUNT); + if (desktopActionCount == null || mobileActionCount == null || typeof layout.sendDividerVisible !== 'boolean') { + return; + } + const normalizedLayout = Object.freeze({ + desktopActionCount, + mobileActionCount, + sendDividerVisible: layout.sendDividerVisible, + }); + pendingComposer = normalizedLayout; + if (captureEnabled) { + commitComposerLayout(normalizedLayout); + } +} + +export interface SkeletonMessagePresentationReport { + readonly compact: boolean; + readonly messageGutterPx: number; + readonly fontSizePx: number; + readonly groupSpacingPx: number; + readonly compactAvatarsVisible: boolean; + readonly viewportHeightPx: number; +} + +export function reportSkeletonMessagePresentation(report: SkeletonMessagePresentationReport): void { + synchronizeActiveAccountState(); + const messageGutterPx = clampReportedInteger(report.messageGutterPx, 0, MAX_MESSAGE_GUTTER_PX); + const fontSizePx = clampReportedInteger(report.fontSizePx, MIN_MESSAGE_FONT_SIZE_PX, MAX_MESSAGE_FONT_SIZE_PX); + const groupSpacingPx = clampReportedInteger(report.groupSpacingPx, 0, MAX_MESSAGE_GROUP_SPACING_PX); + const viewportHeightPx = clampReportedInteger(report.viewportHeightPx, 0, MAX_MEASURED_HEIGHT_PX); + if ( + messageGutterPx == null || + fontSizePx == null || + groupSpacingPx == null || + viewportHeightPx == null || + typeof report.compact !== 'boolean' || + typeof report.compactAvatarsVisible !== 'boolean' + ) { + return; + } + pendingMessagePresentation.compact = report.compact; + pendingMessagePresentation.messageGutterPx = messageGutterPx; + pendingMessagePresentation.fontSizePx = fontSizePx; + pendingMessagePresentation.groupSpacingPx = groupSpacingPx; + pendingMessagePresentation.compactAvatarsVisible = report.compactAvatarsVisible; + pendingMessagePresentation.viewportHeightPx = viewportHeightPx; + if (captureEnabled) { + commitMessagePresentation(); + } +} + +export function reportSkeletonCompactTimestampWidth(widthPx: number): void { + synchronizeActiveAccountState(); + const compactTimestampWidthPx = clampReportedInteger(widthPx, 0, MAX_MEASURED_WIDTH_PX); + if (compactTimestampWidthPx == null || compactTimestampWidthPx === 0) { + return; + } + pendingMessagePresentation.compactTimestampWidthPx = compactTimestampWidthPx; + if (captureEnabled) { + commitMessagePresentation(); + } +} + +export function reportSkeletonVoicePresence(connected: boolean, panelHeightPx: number): void { + synchronizeActiveAccountState(); + const clampedPanelHeightPx = clampReportedInteger(panelHeightPx, 0, MAX_VOICE_CONNECTION_HEIGHT_PX); + if (clampedPanelHeightPx == null || typeof connected !== 'boolean') { + return; + } + let panelHeight = 0; + if (connected) { + panelHeight = clampedPanelHeightPx; + } + const normalizedPresence = Object.freeze({connected, panelHeightPx: panelHeight}); + pendingVoice = normalizedPresence; + if (captureEnabled) { + commitVoicePresence(normalizedPresence); + } +} + +export function reportSkeletonVoiceConnected(connected: boolean): void { + synchronizeActiveAccountState(); + if (typeof connected !== 'boolean') { + return; + } + const currentPanelHeightPx = pendingVoice?.panelHeightPx ?? state.voice?.panelHeightPx ?? 0; + let panelHeight = 0; + if (connected) { + panelHeight = currentPanelHeightPx; + } + const normalizedPresence = Object.freeze({connected, panelHeightPx: panelHeight}); + pendingVoice = normalizedPresence; + if (captureEnabled) { + commitVoicePresence(normalizedPresence); + } +} + +export function reportSkeletonDiscoveryGrid(columnCount: number, visibleRowCount: number): void { + synchronizeActiveAccountState(); + const clampedColumnCount = clampReportedInteger(columnCount, 1, MAX_DISCOVERY_COLUMNS); + const clampedVisibleRowCount = clampReportedInteger(visibleRowCount, 0, MAX_DISCOVERY_VISIBLE_ROWS); + if (clampedColumnCount == null || clampedVisibleRowCount == null) { + return; + } + pendingDiscovery.columnCount = clampedColumnCount; + pendingDiscovery.visibleRowCount = clampedVisibleRowCount; + if (captureEnabled) { + commitDiscoveryLayout(); + } +} + +export function reportSkeletonDiscoveryCategoryTabs(widthsPx: ReadonlyArray): void { + synchronizeActiveAccountState(); + const categoryTabWidthsPx = clampReportedWidths(widthsPx, MAX_DISCOVERY_CATEGORY_TABS); + if (categoryTabWidthsPx == null || categoryTabWidthsPx.length === 0) { + return; + } + pendingDiscovery.categoryTabWidthsPx = categoryTabWidthsPx; + if (captureEnabled) { + commitDiscoveryLayout(); + } +} + +export function reportSkeletonSimplePageLayout( + route: SkeletonSimplePageRoute, + body: SkeletonSimplePageBody, + rowCount: number, + selectable = false, +): void { + synchronizeActiveAccountState(); + const clampedRowCount = clampReportedInteger(rowCount, 0, MAX_SIMPLE_PAGE_ROWS); + if (clampedRowCount == null || !isSimplePageRoute(route) || !isSimplePageBody(body)) { + return; + } + const layout: PendingSkeletonSimplePageLayout = Object.freeze({ + route, + body, + rowCount: clampedRowCount, + selectable: selectable === true, + }); + const next = pendingSimplePages.filter((entry) => entry.route !== route); + next.push(layout); + pendingSimplePages = Object.freeze(next.slice(-MAX_SIMPLE_PAGE_LAYOUTS)); + if (captureEnabled) { + commitSimplePageLayout(layout); + } +} + +export interface SkeletonChannelProjectionReport { + readonly showTopic: boolean; + readonly nameWidthPx: number; + readonly topicWidthPx: number; + readonly desktopLeadingActionCount: number; + readonly mobileActionCount: number; + readonly memberListVisible: boolean; + readonly searchPanelOpen: boolean; +} + +export function reportSkeletonChannelProjection( + channelId: string, + channelType: number, + projection: SkeletonChannelProjectionReport, +): void { + synchronizeActiveAccountState(); + if (!CHANNEL_ID_PATTERN.test(channelId)) { + return; + } + const channelKind = resolveSkeletonChannelProjectionKind(channelType); + const nameWidthPx = clampReportedInteger(projection.nameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const topicWidthPx = clampReportedInteger(projection.topicWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const desktopLeadingActionCount = clampReportedInteger( + projection.desktopLeadingActionCount, + 0, + MAX_HEADER_ACTION_COUNT, + ); + const mobileActionCount = clampReportedInteger(projection.mobileActionCount, 0, MAX_HEADER_ACTION_COUNT); + if ( + nameWidthPx == null || + topicWidthPx == null || + desktopLeadingActionCount == null || + mobileActionCount == null || + typeof projection.showTopic !== 'boolean' || + typeof projection.memberListVisible !== 'boolean' || + typeof projection.searchPanelOpen !== 'boolean' + ) { + return; + } + const showTopic = projection.showTopic && canChannelProjectionShowTopic(channelKind); + let resolvedTopicWidthPx = 0; + if (showTopic) { + resolvedTopicWidthPx = topicWidthPx; + } + const normalizedProjection: PendingSkeletonChannelProjection = Object.freeze({ + fingerprint: createChannelFingerprint(channelId), + channelKind, + showTopic, + nameWidthPx, + topicWidthPx: resolvedTopicWidthPx, + desktopLeadingActionCount, + mobileActionCount, + memberListVisible: projection.memberListVisible && canChannelProjectionShowMemberList(channelKind), + searchPanelOpen: projection.searchPanelOpen, + }); + const next = pendingChannelProjections.filter((entry) => entry.fingerprint !== normalizedProjection.fingerprint); + next.push(normalizedProjection); + pendingChannelProjections = Object.freeze(next.slice(-MAX_CHANNEL_PROJECTIONS)); + if (captureEnabled) { + commitChannelProjection(normalizedProjection); + } +} + +export interface SkeletonMemberGroupReport { + readonly rowCount: number; + readonly headingWidthPx: number; + readonly subtextFlags?: ReadonlyArray; +} + +export function reportSkeletonMemberLayout( + channelId: string, + kind: SkeletonMemberSurfaceKind, + memberGroups: ReadonlyArray, +): void { + synchronizeActiveAccountState(); + if (!CHANNEL_ID_PATTERN.test(channelId) || !isSkeletonMemberSurfaceKind(kind) || !Array.isArray(memberGroups)) { + return; + } + const normalizedGroups: Array = []; + for (const group of memberGroups.slice(0, MAX_MEMBER_GROUPS)) { + const rowCount = clampReportedInteger(group.rowCount, 1, MAX_MEMBER_GROUP_ROWS); + const headingWidthPx = clampReportedInteger(group.headingWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (rowCount == null || headingWidthPx == null) { + return; + } + normalizedGroups.push( + Object.freeze({ + rowCount, + headingWidthPx, + subtextFlags: normalizeSubtextFlags(group.subtextFlags, rowCount), + }), + ); + } + const fingerprint = createChannelFingerprint(channelId); + const layout = Object.freeze({fingerprint, kind, memberGroups: Object.freeze(normalizedGroups)}); + const next = pendingChannelMemberLayouts.filter((entry) => entry.fingerprint !== fingerprint); + next.push(layout); + pendingChannelMemberLayouts = Object.freeze(next.slice(-MAX_CHANNEL_MEMBER_LAYOUTS)); + if (captureEnabled) { + commitChannelMemberLayout(layout); + } +} + +export function reportSkeletonGuildPresentation( + guildId: string, + presentation: RememberedSkeletonGuildPresentation, +): void { + synchronizeActiveAccountState(); + if (!GUILD_ID_PATTERN.test(guildId) || !isGuildBannerPlacement(presentation.bannerPlacement)) { + return; + } + const headerNameWidthPx = clampReportedInteger(presentation.headerNameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + const bannerAspectRatio = clampReportedRatio( + presentation.bannerAspectRatio, + MIN_BANNER_ASPECT_RATIO, + MAX_BANNER_ASPECT_RATIO, + ); + if (headerNameWidthPx == null || bannerAspectRatio == null || typeof presentation.badgeVisible !== 'boolean') { + return; + } + let resolvedBannerAspectRatio = SKELETON_DEFAULT_GUILD_BANNER_ASPECT_RATIO; + if (presentation.bannerPlacement !== SkeletonGuildBannerPlacement.NONE) { + resolvedBannerAspectRatio = bannerAspectRatio; + } + const normalizedPresentation: PendingSkeletonGuildPresentation = Object.freeze({ + fingerprint: createGuildFingerprint(guildId), + headerNameWidthPx, + badgeVisible: presentation.badgeVisible, + bannerPlacement: presentation.bannerPlacement, + bannerAspectRatio: resolvedBannerAspectRatio, + }); + const next = pendingGuildPresentations.filter((entry) => entry.fingerprint !== normalizedPresentation.fingerprint); + next.push(normalizedPresentation); + pendingGuildPresentations = Object.freeze(next.slice(-MAX_GUILD_PRESENTATIONS)); + if (captureEnabled) { + commitGuildPresentation(normalizedPresentation); + } +} + +export function reportSkeletonGuildChannelList(guildId: string, list: RememberedSkeletonGuildChannelList): void { + synchronizeActiveAccountState(); + if (!GUILD_ID_PATTERN.test(guildId) || typeof list.membersRowVisible !== 'boolean' || !Array.isArray(list.groups)) { + return; + } + const normalizedGroups: Array = []; + let remainingChannelRows = MAX_GUILD_CHANNEL_ROWS; + for (const group of list.groups.slice(0, MAX_GUILD_CHANNEL_GROUPS)) { + if ( + typeof group.categoryHeaderVisible !== 'boolean' || + typeof group.collapsed !== 'boolean' || + !Array.isArray(group.channels) + ) { + return; + } + const categoryNameWidthPx = clampReportedInteger(group.categoryNameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (categoryNameWidthPx == null) { + return; + } + const channels: Array = []; + for (const channel of group.channels.slice(0, remainingChannelRows)) { + const nameWidthPx = clampReportedInteger(channel.nameWidthPx, 0, MAX_MEASURED_WIDTH_PX); + if (nameWidthPx == null || typeof channel.voice !== 'boolean') { + return; + } + channels.push(Object.freeze({voice: channel.voice, nameWidthPx})); + } + remainingChannelRows -= channels.length; + let collapsed = false; + let resolvedCategoryNameWidthPx = 0; + if (group.categoryHeaderVisible) { + collapsed = group.collapsed; + resolvedCategoryNameWidthPx = categoryNameWidthPx; + } + normalizedGroups.push( + Object.freeze({ + categoryHeaderVisible: group.categoryHeaderVisible, + collapsed, + categoryNameWidthPx: resolvedCategoryNameWidthPx, + channels: Object.freeze(channels), + }), + ); + if (remainingChannelRows === 0) { + break; + } + } + const normalizedList: PendingSkeletonGuildChannelList = Object.freeze({ + fingerprint: createGuildFingerprint(guildId), + membersRowVisible: list.membersRowVisible, + groups: Object.freeze(normalizedGroups), + }); + const next = pendingGuildChannelLists.filter((entry) => entry.fingerprint !== normalizedList.fingerprint); + next.push(normalizedList); + pendingGuildChannelLists = Object.freeze(next.slice(-MAX_GUILD_CHANNEL_LISTS)); + if (captureEnabled) { + commitGuildChannelList(normalizedList); + } +} + +function ensureActiveAccountStateCurrent(): boolean { + synchronizeActiveAccountState(); + return activeAccountFingerprint != null; +} + +export function getRememberedSkeletonDMSidebarLayout(): RememberedSkeletonDMSidebarLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.chrome?.dmSidebar; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonGuildRailScrollTopPx(): number { + return getRememberedSkeletonGuildRailLayout()?.scrollTopPx ?? 0; +} + +export function getRememberedSkeletonGuildRailLayout(): RememberedSkeletonGuildRailLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.chrome?.guildRail; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonFriendsLayout(): RememberedSkeletonFriendsLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + if (state.friends == null || !isFreshTimestamp(state.friends.capturedAt, Date.now())) { + return null; + } + return state.friends; +} + +export function getRememberedSkeletonChannelHeaderLayout(): RememberedSkeletonChannelHeaderLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.channelHeader; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonNagbarLayout(): RememberedSkeletonNagbarLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.nagbar; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonComposerLayout(): RememberedSkeletonComposerLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.composer; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonMessagePresentation(): RememberedSkeletonMessagePresentation | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const presentation = state.messagePresentation; + if (presentation == null || !isFreshTimestamp(presentation.capturedAt, Date.now())) { + return null; + } + return presentation; +} + +export function getRememberedSkeletonVoicePresence(): RememberedSkeletonVoicePresence | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const presence = state.voice; + if (presence == null || !isFreshTimestamp(presence.capturedAt, Date.now())) { + return null; + } + return presence; +} + +export function getRememberedSkeletonDiscoveryLayout(): RememberedSkeletonDiscoveryLayout | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + const layout = state.discovery; + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonSimplePageLayout( + route: SkeletonSimplePageRoute, +): RememberedSkeletonSimplePageLayout | null { + if (!ensureActiveAccountStateCurrent() || !isSimplePageRoute(route)) { + return null; + } + const layout = state.simplePages?.find((entry) => entry.route === route); + if (layout == null || !isFreshTimestamp(layout.capturedAt, Date.now())) { + return null; + } + return layout; +} + +export function getRememberedSkeletonMemberGroups( + channelId: string, + kind: SkeletonMemberSurfaceKind, +): ReadonlyArray | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + if (!CHANNEL_ID_PATTERN.test(channelId) || !isSkeletonMemberSurfaceKind(kind)) { + return null; + } + const fingerprint = createChannelFingerprint(channelId); + const layout = state.channelMemberLayouts?.find((entry) => entry.fingerprint === fingerprint); + if ( + layout == null || + layout.kind !== kind || + layout.memberGroups.length === 0 || + !isFreshTimestamp(layout.capturedAt, Date.now()) + ) { + return null; + } + return layout.memberGroups; +} + +export function getRememberedSkeletonChannelProjection(channelId: string): RememberedSkeletonChannelProjection | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + if (!CHANNEL_ID_PATTERN.test(channelId)) { + return null; + } + const fingerprint = createChannelFingerprint(channelId); + const projection = state.channelProjections?.find((entry) => entry.fingerprint === fingerprint); + if (projection == null || !isFreshTimestamp(projection.capturedAt, Date.now())) { + return null; + } + return projection; +} + +export function getRememberedSkeletonGuildPresentation(guildId: string): RememberedSkeletonGuildPresentation | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + if (!GUILD_ID_PATTERN.test(guildId)) { + return null; + } + const fingerprint = createGuildFingerprint(guildId); + const presentation = state.guildPresentations?.find((entry) => entry.fingerprint === fingerprint); + if (presentation == null || !isFreshTimestamp(presentation.capturedAt, Date.now())) { + return null; + } + return presentation; +} + +export function getRememberedSkeletonGuildChannelList(guildId: string): RememberedSkeletonGuildChannelList | null { + if (!ensureActiveAccountStateCurrent()) { + return null; + } + if (!GUILD_ID_PATTERN.test(guildId)) { + return null; + } + const fingerprint = createGuildFingerprint(guildId); + const list = state.guildChannelLists?.find((entry) => entry.fingerprint === fingerprint); + if (list == null || !isFreshTimestamp(list.capturedAt, Date.now())) { + return null; + } + return list; +} diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonLine.tsx b/fluxer_app/src/features/app/components/skeleton/SkeletonLine.tsx new file mode 100644 index 000000000..c59eeb92e --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonLine.tsx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + SkeletonEmphasis, + SkeletonRadius, + skeletonClassName, + skeletonDimensionsStyle, +} from '@app/features/app/components/skeleton/SkeletonStyle'; +import type React from 'react'; + +export interface SkeletonLineProps { + readonly width?: string; + readonly height?: string; + readonly emphasis?: SkeletonEmphasis; + readonly className?: string; + readonly style?: React.CSSProperties; +} + +export const SkeletonLine = ({ + width, + height, + emphasis = SkeletonEmphasis.DEFAULT, + className, + style, +}: SkeletonLineProps) => ( + +); diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonSeed.ts b/fluxer_app/src/features/app/components/skeleton/SkeletonSeed.ts new file mode 100644 index 000000000..dd8a63620 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonSeed.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +function hashSkeletonSeed(input: string): number { + let hash = 2166136261; + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function createSkeletonRandom(seed: number): () => number { + let state = seed >>> 0; + if (seed === 0) { + state = 0x9e3779b9; + } + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 4294967296; + }; +} + +export function createSkeletonRandomFromKey(key: string): () => number { + return createSkeletonRandom(hashSkeletonSeed(key)); +} diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonStyle.ts b/fluxer_app/src/features/app/components/skeleton/SkeletonStyle.ts new file mode 100644 index 000000000..16bfef4df --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonStyle.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/app/components/skeleton/Skeleton.module.css'; +import {flxElementClassName} from '@app/lib/react'; +import type {CSSProperties} from 'react'; + +export const SkeletonEmphasis = Object.freeze({ + STRONG: 'strong', + DEFAULT: 'default', + MUTED: 'muted', +} as const); + +export type SkeletonEmphasis = (typeof SkeletonEmphasis)[keyof typeof SkeletonEmphasis]; + +export const SkeletonRadius = Object.freeze({ + SHARP: 'sharp', + SMALL: 'sm', + MEDIUM: 'md', + LARGE: 'lg', + EXTRA_LARGE: 'xl', + PILL: 'pill', + CIRCLE: 'circle', +} as const); + +export type SkeletonRadius = (typeof SkeletonRadius)[keyof typeof SkeletonRadius]; + +const EMPHASIS_CLASS_NAMES: Record = { + [SkeletonEmphasis.STRONG]: styles.emphasisStrong, + [SkeletonEmphasis.DEFAULT]: undefined, + [SkeletonEmphasis.MUTED]: styles.emphasisMuted, +}; + +const RADIUS_CLASS_NAMES: Record = { + [SkeletonRadius.SHARP]: styles.radiusSharp, + [SkeletonRadius.SMALL]: styles.radiusSm, + [SkeletonRadius.MEDIUM]: styles.radiusMd, + [SkeletonRadius.LARGE]: styles.radiusLg, + [SkeletonRadius.EXTRA_LARGE]: styles.radiusXl, + [SkeletonRadius.PILL]: styles.radiusPill, + [SkeletonRadius.CIRCLE]: styles.radiusCircle, +}; + +export interface SkeletonClassNameRequest { + readonly className: string | undefined; + readonly emphasis: SkeletonEmphasis; + readonly radius: SkeletonRadius; +} + +export interface SkeletonDimensionsStyle extends CSSProperties { + readonly '--skeleton-height': string | undefined; + readonly '--skeleton-width': string | undefined; +} + +export interface SkeletonDimensionsStyleRequest { + readonly height: string | undefined; + readonly style: CSSProperties | undefined; + readonly width: string | undefined; +} + +export function skeletonClassName({radius, emphasis, className}: SkeletonClassNameRequest): string { + return flxElementClassName(styles.skeleton, RADIUS_CLASS_NAMES[radius], EMPHASIS_CLASS_NAMES[emphasis], className); +} + +export function skeletonDimensionsStyle({ + height, + style, + width, +}: SkeletonDimensionsStyleRequest): SkeletonDimensionsStyle { + const dimensionsStyle: SkeletonDimensionsStyle = { + ...style, + '--skeleton-width': width, + '--skeleton-height': height, + }; + return dimensionsStyle; +} diff --git a/fluxer_app/src/features/app/components/skeleton/SkeletonSurfaceContract.ts b/fluxer_app/src/features/app/components/skeleton/SkeletonSurfaceContract.ts new file mode 100644 index 000000000..0594db79b --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/SkeletonSurfaceContract.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ThemeVariableName} from '@app/features/theme/variables/ThemeVariableManifest'; + +export const SKELETON_SURFACE_TOKENS = [ + '--active-now-action-gap', + '--active-now-action-height', + '--active-now-action-padding-block', + '--active-now-action-padding-inline', + '--active-now-action-radius', + '--active-now-card-gap', + '--active-now-card-padding-block', + '--active-now-card-padding-inline', + '--active-now-card-radius', + '--active-now-card-shadow', + '--active-now-context-gap', + '--active-now-sidebar-content-gap', + '--active-now-sidebar-content-padding-block-end', + '--active-now-sidebar-content-padding-inline', + '--active-now-sidebar-header-padding-block-end', + '--active-now-sidebar-header-padding-block-start', + '--active-now-sidebar-header-padding-inline', + '--active-now-sidebar-width', + '--avatar-stack-extra-height', + '--background-header-secondary', + '--background-modifier-accent', + '--background-modifier-hover', + '--background-modifier-selected', + '--background-primary', + '--background-secondary', + '--background-secondary-alt', + '--background-secondary-lighter', + '--background-textarea', + '--brand-primary', + '--channel-header-action-icon-size', + '--channel-header-action-size', + '--channel-header-action-size-mobile', + '--channel-header-actions-gap', + '--channel-header-back-button-gap', + '--channel-header-back-button-size', + '--channel-header-back-icon-size', + '--channel-header-caret-gap', + '--channel-header-caret-size', + '--channel-header-gap', + '--channel-header-icon-size', + '--channel-header-name-gap', + '--channel-header-name-gap-group-dm', + '--channel-header-padding-inline', + '--channel-header-topic-divider-gap', + '--chat-horizontal-padding', + '--chat-horizontal-padding-default', + '--chat-mobile-horizontal-padding', + '--composer-surface-color', + '--count-indicator-gap', + '--discovery-card-description-min-height', + '--dm-sidebar-mobile-header-height', + '--floating-surface-ring-color', + '--floating-surface-ring-color-strong', + '--folder-radius', + '--font-size', + '--footer-box-inset', + '--footer-box-inset-inline', + '--footer-box-radius', + '--form-surface-background', + '--friend-row-action-gap', + '--friend-row-content-gap', + '--friend-row-name-line-height', + '--friend-row-padding-block', + '--friend-row-padding-inline', + '--friend-row-separator-inset', + '--friend-row-separator-radius', + '--friend-row-subtext-line-height', + '--friend-row-subtext-offset', + '--guild-badge-surface', + '--guild-folder-expanded-surface-size', + '--guild-icon-size', + '--guild-list-divider-base-gap', + '--guild-list-divider-line-size', + '--guild-list-indicator-bar-width', + '--guild-list-indicator-inset', + '--guild-list-indicator-track-width', + '--guild-list-item-box-size', + '--guild-list-item-gap', + '--guild-list-item-target-half-extra', + '--guild-list-item-target-size', + '--guild-members-columns', + '--guild-members-columns-selectable', + '--guild-members-min-width', + '--guild-members-min-width-selectable', + '--guild-members-table-row-border-width', + '--guild-members-table-row-min-height', + '--guilds-layout-item-bg', + '--input-container-padding', + '--layout-guild-list-width', + '--layout-header-height', + '--layout-header-height-mobile', + '--layout-member-list-width', + '--layout-mobile-bottom-nav-reserved-height', + '--layout-sidebar-width', + '--layout-user-area-height', + '--layout-user-area-reserved-height', + '--layout-voice-connection-height', + '--member-list-group-spacer-height', + '--member-list-item-content-gap', + '--member-list-item-name-line-height', + '--member-list-item-padding-block', + '--member-list-item-padding-inline', + '--member-list-item-status-line-height', + '--member-list-row-gap', + '--member-list-scroller-padding-block-end', + '--member-list-scroller-padding-block-start', + '--member-list-scroller-padding-inline', + '--mention-badge-small-size', + '--message-action-bar-offset', + '--message-avatar-align-offset', + '--message-avatar-size', + '--message-avatar-size-compact', + '--message-compact-gap', + '--message-compact-horizontal-padding', + '--message-compact-text-indent-from-username', + '--message-compact-timestamp-width', + '--message-compact-username-gap', + '--message-gutter', + '--message-line-height', + '--message-mobile-margin', + '--message-search-bar-height', + '--message-search-bar-icon-gap', + '--message-search-bar-icon-size', + '--message-search-bar-width', + '--message-spacing-y', + '--messages-bottom-clearance', + '--mobile-bottom-nav-height', + '--native-titlebar-height', + '--outline-frame-border-width', + '--outline-frame-radius-native', + '--panel-control-bg', + '--radius-full', + '--radius-lg', + '--radius-md', + '--radius-sm', + '--radius-xl', + '--skeleton-chrome-border', + '--skeleton-opacity-default', + '--skeleton-opacity-muted', + '--skeleton-opacity-strong', + '--spacing-1', + '--spacing-10', + '--spacing-2', + '--spacing-3', + '--spacing-4', + '--spacing-5', + '--spacing-6', + '--spacing-8', + '--text-primary', + '--text-primary-muted', + '--textarea-button-height', + '--textarea-button-icon-size', + '--theme-border-width', + '--user-area-avatar-lead', + '--user-area-box-inset-block-end', + '--user-area-content-height', + '--user-area-divider-color', + '--user-area-padding-x', + '--user-area-padding-y', + '--z-index-elevated-1', + '--z-index-modal', +] as const satisfies ReadonlyArray; + +export type SkeletonSurfaceToken = (typeof SKELETON_SURFACE_TOKENS)[number]; + +export const SKELETON_SCOPED_TOKENS = ['--layout-user-area-overlay-height', '--outline-radius'] as const; + +export type SkeletonScopedToken = (typeof SKELETON_SCOPED_TOKENS)[number]; + +export const SKELETON_INJECTED_TOKENS = [ + '--active-now-participant-outline', + '--active-now-participant-overlap', + '--active-now-participant-size', + '--chat-horizontal-padding', + '--discovery-card-max-width', + '--discovery-card-min-width', + '--discovery-grid-gap', + '--font-size', + '--member-list-group-header-height', + '--member-list-item-height', + '--member-list-item-height-group-dm', + '--message-compact-timestamp-width', + '--message-group-spacing', +] as const; + +export type SkeletonInjectedToken = (typeof SKELETON_INJECTED_TOKENS)[number]; + +export function skeletonSurfaceVar(token: SkeletonSurfaceToken): `var(${SkeletonSurfaceToken})` { + return `var(${token})`; +} diff --git a/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.module.css new file mode 100644 index 000000000..7a5a78bd1 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.module.css @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.userArea { + position: absolute; + bottom: 0; + left: 0; + width: calc(var(--layout-guild-list-width) + var(--layout-sidebar-width) + 0.0625rem); + padding: 0 var(--footer-box-inset-inline) var(--user-area-box-inset-block-end); + box-sizing: border-box; + background-color: var(--background-secondary); + pointer-events: none; + z-index: var(--z-index-elevated-1); +} + +.panel { + width: 100%; + background-color: var(--panel-control-bg); + border-radius: var(--footer-box-radius); + box-shadow: inset 0 0 0 0.0625rem var(--floating-surface-ring-color); + overflow: hidden; +} + +.voicePanel { + display: block; + box-sizing: border-box; + width: 100%; + border-bottom: 0.0625rem solid var(--floating-surface-ring-color-strong); +} + +.content { + display: flex; + align-items: center; + gap: var(--spacing-3); + width: 100%; + min-height: var(--layout-user-area-height); + padding-block: var(--user-area-padding-y); + padding-inline: var(--user-area-avatar-lead) var(--user-area-padding-x); +} + +.identity { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: 1; + min-width: 0; + height: var(--user-area-content-height); + padding: 0; +} + +.identityText { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + gap: 0.0625rem; +} + +.nameRow { + display: flex; + align-items: center; + height: 1.125rem; +} + +.statusRow { + display: flex; + align-items: center; + height: 0.875rem; + margin-top: -0.0625rem; +} + +.controls { + display: flex; + align-items: center; + gap: var(--spacing-1); + flex-shrink: 0; + padding-left: var(--spacing-3); +} diff --git a/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.tsx new file mode 100644 index 000000000..305223158 --- /dev/null +++ b/fluxer_app/src/features/app/components/skeleton/UserAreaSkeleton.tsx @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SkeletonBlock} from '@app/features/app/components/skeleton/SkeletonBlock'; +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle'; +import styles from '@app/features/app/components/skeleton/UserAreaSkeleton.module.css'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {flxElementClassName} from '@app/lib/react'; +import type React from 'react'; + +const AVATAR_SIZE = remFromPx(32); +const NAME_LINE_WIDTH = '6.5rem'; +const NAME_LINE_HEIGHT = '0.75rem'; +const STATUS_LINE_WIDTH = '4.25rem'; +const STATUS_LINE_HEIGHT = '0.5rem'; +const CONTROL_BUTTON_SIZE = '2rem'; + +interface UserAreaSkeletonProps { + readonly voicePanelHeightPx: number; +} + +export const UserAreaSkeleton: React.FC = ({voicePanelHeightPx}) => ( + + + {voicePanelHeightPx > 0 && ( + + )} + + + + + + + + + + + + + + + + + + + + +); diff --git a/fluxer_app/src/features/app/config/I18nDisplayConstants.ts b/fluxer_app/src/features/app/config/I18nDisplayConstants.ts index 727e53be9..fc9e17baf 100644 --- a/fluxer_app/src/features/app/config/I18nDisplayConstants.ts +++ b/fluxer_app/src/features/app/config/I18nDisplayConstants.ts @@ -51,7 +51,6 @@ export const VISIONARY_LIFETIME_BADGE_LABEL = 'Visionary #42'; export const FLUXER_TAG_MIN_WITH_ZERO_LABEL = '#0000'; export const FLUXER_TAG_MIN_STANDARD_LABEL = '#0001'; export const FLUXER_TAG_MAX_LABEL = '#9999'; -export const EXAMPLE_INSTANCE_DOMAIN = 'fluxer.app'; export const LINK_PREVIEW_EXAMPLE_URL = 'https://fluxer.app'; export const EXAMPLE_MESSAGE_LINK = `${LINK_PREVIEW_EXAMPLE_URL}/channels/...`; export const EXAMPLE_GIF_URLS = `${EXAMPLE_URL}/gif1.gif\n${EXAMPLE_URL}/gif2.gif`; diff --git a/fluxer_app/src/features/app/hooks/useConnectionNotice.ts b/fluxer_app/src/features/app/hooks/useConnectionNotice.ts new file mode 100644 index 000000000..2e820f143 --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useConnectionNotice.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {canSwitchAccountFromStalledConnection} from '@app/features/app/ConnectionRecovery'; +import {isClientBooting, isClientReconnecting} from '@app/features/app/state/ClientReadiness'; +import Nagbar from '@app/features/ui/state/Nagbar'; +import StatusPage from '@app/features/user/state/StatusPage'; +import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls'; +import {msg} from '@lingui/core/macro'; +import {useLingui} from '@lingui/react/macro'; +import {useEffect, useState} from 'react'; + +const BOOT_NOTICE_DELAY_MS = 3_000; + +export const ConnectionNoticeTone = Object.freeze({ + NEUTRAL: 'neutral', + MAINTENANCE: 'maintenance', +} as const); + +export type ConnectionNoticeTone = (typeof ConnectionNoticeTone)[keyof typeof ConnectionNoticeTone]; + +export interface ConnectionNoticeAction { + readonly label: string; + readonly url: string; +} + +export interface ConnectionNotice { + readonly tone: ConnectionNoticeTone; + readonly message: string; + readonly action: ConnectionNoticeAction | null; + readonly showSwitchAccount: boolean; +} + +const CONNECTION_ISSUES_DESCRIPTOR = msg({ + message: 'Connection issues?', + comment: + 'Banner shown while the app is still loading and the connection is taking unusually long. Phrased as a question offering help.', +}); +const CONNECTION_LOST_DESCRIPTOR = msg({ + message: 'Connection lost. Reconnectingโ€ฆ', + comment: + 'Banner shown when the app loses its gateway connection after having loaded. The app stays usable, so keep the tone calm.', +}); +const VIEW_STATUS_PAGE_DESCRIPTOR = msg({ + message: 'View status page', + comment: 'Button on the connection banner. Opens the external service status page.', +}); +const VIEW_INCIDENT_DETAILS_DESCRIPTOR = msg({ + message: 'View incident details', + comment: 'Button on the connection banner when an incident is ongoing. Opens that incident on the status page.', +}); +const VIEW_MAINTENANCE_DETAILS_DESCRIPTOR = msg({ + message: 'View maintenance details', + comment: 'Button on the connection banner when maintenance is ongoing. Opens that maintenance on the status page.', +}); + +export interface ConnectionNoticeShape { + readonly tone: ConnectionNoticeTone; + readonly hasActions: boolean; +} + +export function resolveConnectionNoticeShape(): ConnectionNoticeShape { + if (isClientReconnecting()) { + return {tone: ConnectionNoticeTone.NEUTRAL, hasActions: false}; + } + if (StatusPage.scheduledMaintenance != null) { + return {tone: ConnectionNoticeTone.MAINTENANCE, hasActions: true}; + } + return {tone: ConnectionNoticeTone.NEUTRAL, hasActions: true}; +} + +export function useConnectionNotice(): ConnectionNotice | null { + const {i18n} = useLingui(); + const booting = isClientBooting(); + const reconnecting = isClientReconnecting(); + const forced = Nagbar.forceConnectionNotice; + const showSwitchAccount = canSwitchAccountFromStalledConnection(); + const [bootStalled, setBootStalled] = useState(false); + useEffect(() => { + if (!booting) { + setBootStalled(false); + return; + } + const timer = window.setTimeout(() => { + setBootStalled(true); + void StatusPage.checkIncidents(); + }, BOOT_NOTICE_DELAY_MS); + return () => window.clearTimeout(timer); + }, [booting]); + if (Nagbar.forceHideConnectionNotice) { + return null; + } + let connectionUnavailable = reconnecting; + if (booting) { + connectionUnavailable = bootStalled; + } + if (!forced && !connectionUnavailable) { + return null; + } + if (reconnecting) { + return { + tone: ConnectionNoticeTone.NEUTRAL, + message: i18n._(CONNECTION_LOST_DESCRIPTOR), + action: null, + showSwitchAccount: false, + }; + } + const maintenance = StatusPage.scheduledMaintenance; + if (maintenance != null) { + return { + tone: ConnectionNoticeTone.MAINTENANCE, + message: maintenance.name, + action: {label: i18n._(VIEW_MAINTENANCE_DETAILS_DESCRIPTOR), url: maintenance.url}, + showSwitchAccount, + }; + } + const incident = StatusPage.incident; + if (incident != null) { + return { + tone: ConnectionNoticeTone.NEUTRAL, + message: incident.name, + action: {label: i18n._(VIEW_INCIDENT_DETAILS_DESCRIPTOR), url: incident.url}, + showSwitchAccount, + }; + } + return { + tone: ConnectionNoticeTone.NEUTRAL, + message: i18n._(CONNECTION_ISSUES_DESCRIPTOR), + action: {label: i18n._(VIEW_STATUS_PAGE_DESCRIPTOR), url: ExternalUrls.SERVICE_STATUS}, + showSwitchAccount, + }; +} diff --git a/fluxer_app/src/features/app/hooks/useContextMenuHoverState.ts b/fluxer_app/src/features/app/hooks/useContextMenuHoverState.ts index 6b7948c58..bfe0cae32 100644 --- a/fluxer_app/src/features/app/hooks/useContextMenuHoverState.ts +++ b/fluxer_app/src/features/app/hooks/useContextMenuHoverState.ts @@ -4,6 +4,52 @@ import ContextMenu, {isContextMenuNodeTarget} from '@app/features/ui/state/Conte import {autorun} from 'mobx'; import {type RefObject, useEffect, useState} from 'react'; +interface ContextMenuHoverSubscriber { + readonly elementRef: RefObject; + readonly setContextMenuOpen: (contextMenuOpen: boolean) => void; + contextMenuOpen: boolean; +} + +const contextMenuHoverSubscribers = new Set(); +let contextMenuHoverDisposer: (() => void) | null = null; + +function resolveContextMenuTargetChain(): ReadonlySet | null { + const contextMenu = ContextMenu.contextMenu; + const target = contextMenu?.target?.target ?? null; + if (contextMenu == null || !isContextMenuNodeTarget(target)) return null; + const chain = new Set(); + for (let node: Node | null = target; node != null; node = node.parentNode) { + chain.add(node); + } + return chain; +} + +function syncContextMenuHoverSubscribers(): void { + const chain = resolveContextMenuTargetChain(); + for (const subscriber of Array.from(contextMenuHoverSubscribers)) { + const element = subscriber.elementRef.current; + const contextMenuOpen = chain != null && element != null && chain.has(element); + if (subscriber.contextMenuOpen === contextMenuOpen) continue; + subscriber.contextMenuOpen = contextMenuOpen; + subscriber.setContextMenuOpen(contextMenuOpen); + } +} + +function subscribeContextMenuHover(subscriber: ContextMenuHoverSubscriber): () => void { + contextMenuHoverSubscribers.add(subscriber); + if (contextMenuHoverDisposer == null) { + contextMenuHoverDisposer = autorun(syncContextMenuHoverSubscribers); + } else { + syncContextMenuHoverSubscribers(); + } + return () => { + contextMenuHoverSubscribers.delete(subscriber); + if (contextMenuHoverSubscribers.size > 0 || contextMenuHoverDisposer == null) return; + contextMenuHoverDisposer(); + contextMenuHoverDisposer = null; + }; +} + export function useContextMenuHoverState(elementRef: RefObject, enabled: boolean = true): boolean { const [contextMenuOpen, setContextMenuOpen] = useState(false); useEffect(() => { @@ -11,14 +57,7 @@ export function useContextMenuHoverState(elementRef: RefObject { - const contextMenu = ContextMenu.contextMenu; - const target = contextMenu?.target?.target ?? null; - const element = elementRef.current; - const isOpen = Boolean(contextMenu) && isContextMenuNodeTarget(target) && Boolean(element?.contains(target)); - setContextMenuOpen(isOpen); - }); - return () => disposer(); + return subscribeContextMenuHover({elementRef, setContextMenuOpen, contextMenuOpen: false}); }, [elementRef, enabled]); return contextMenuOpen; } diff --git a/fluxer_app/src/features/app/hooks/useDesktopElectronBridges.tsx b/fluxer_app/src/features/app/hooks/useDesktopElectronBridges.tsx index 06eb5d6a6..d4c9678f1 100644 --- a/fluxer_app/src/features/app/hooks/useDesktopElectronBridges.tsx +++ b/fluxer_app/src/features/app/hooks/useDesktopElectronBridges.tsx @@ -1,10 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; -import Initialization from '@app/features/app/state/Initialization'; +import {isClientBooting} from '@app/features/app/state/ClientReadiness'; import Authentication from '@app/features/auth/state/Authentication'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; -import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import {initializeDesktopTrayBridge} from '@app/features/platform/utils/DesktopTrayBridge'; import ThemeLibrary from '@app/features/theme/state/ThemeLibrary'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; @@ -22,12 +20,7 @@ export function useDesktopElectronBridges(): void { const unsubZoomReset = electronApi.onZoomReset?.(() => Accessibility.updateSettings({zoomLevel: 1.0})); const unsubOpenSettings = electronApi.onOpenSettings?.(() => { if (!Authentication.isAuthenticated) return; - if ( - !DeveloperOptions.bypassSplashScreen && - (GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes) - ) { - return; - } + if (isClientBooting()) return; void import('@app/features/user/components/modals/UserSettingsModal').then(({UserSettingsModal}) => { ModalCommands.push( ModalCommands.modal(() => ( diff --git a/fluxer_app/src/features/app/hooks/useHover.ts b/fluxer_app/src/features/app/hooks/useHover.ts index 7e482f5cc..7f8a03f76 100644 --- a/fluxer_app/src/features/app/hooks/useHover.ts +++ b/fluxer_app/src/features/app/hooks/useHover.ts @@ -16,6 +16,33 @@ import { type HoverHook = [React.RefCallback, boolean]; +const hoverWindowFocusListeners = new Set<() => void>(); +let disposeHoverWindowFocusBridge: (() => void) | null = null; + +function notifyHoverWindowFocusListeners(): void { + for (const listener of Array.from(hoverWindowFocusListeners)) { + listener(); + } +} + +function subscribeHoverWindowFocus(listener: () => void): () => void { + hoverWindowFocusListeners.add(listener); + if (disposeHoverWindowFocusBridge == null) { + const unsubscribeHoverControls = subscribeWindowHoverControlsChange(notifyHoverWindowFocusListeners); + window.addEventListener('blur', notifyHoverWindowFocusListeners); + disposeHoverWindowFocusBridge = () => { + unsubscribeHoverControls(); + window.removeEventListener('blur', notifyHoverWindowFocusListeners); + }; + } + return () => { + hoverWindowFocusListeners.delete(listener); + if (hoverWindowFocusListeners.size > 0 || disposeHoverWindowFocusBridge == null) return; + disposeHoverWindowFocusBridge(); + disposeHoverWindowFocusBridge = null; + }; +} + export const useHover = (delay = 0): HoverHook => { const [snapshot, setSnapshot] = useState(createHoverStateSnapshot); const snapshotRef = useRef(snapshot); @@ -61,27 +88,29 @@ export const useHover = (delay = 0): HoverHook => { }, delay); }, [clearHoverTimeout, delay, sendHoverEvent]); useEffect(() => { - const unsubscribe = subscribeWindowHoverControlsChange(syncHoverWithWindowFocus); - window.addEventListener('blur', syncHoverWithWindowFocus); + const unsubscribe = subscribeHoverWindowFocus(syncHoverWithWindowFocus); return () => { unsubscribe(); - window.removeEventListener('blur', syncHoverWithWindowFocus); clearHoverTimeout(); }; }, [clearHoverTimeout, syncHoverWithWindowFocus]); const customRef = useCallback( (node: HTMLElement | null) => { + if (previousNode.current === node) return; if (previousNode.current) { previousNode.current.removeEventListener('mouseenter', handleMouseEnter); previousNode.current.removeEventListener('mouseleave', handleMouseLeave); } + clearHoverTimeout(); + sendHoverEvent({type: 'hover.leave'}); + previousNode.current = node; if (node) { node.addEventListener('mouseenter', handleMouseEnter); node.addEventListener('mouseleave', handleMouseLeave); + if (node.matches(':hover')) handleMouseEnter(); } - previousNode.current = node; }, - [handleMouseEnter, handleMouseLeave], + [clearHoverTimeout, handleMouseEnter, handleMouseLeave, sendHoverEvent], ); return [customRef, selectIsHovering(snapshot)]; }; diff --git a/fluxer_app/src/features/app/hooks/useLoadingSkeletonRetention.ts b/fluxer_app/src/features/app/hooks/useLoadingSkeletonRetention.ts new file mode 100644 index 000000000..a2f05c436 --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useLoadingSkeletonRetention.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type React from 'react'; +import {useCallback, useEffect, useState} from 'react'; + +const SKELETON_FADE_OUT_CLEANUP_MS = 200; + +export interface LoadingSkeletonRetentionRequest { + readonly shouldShowLoadingSkeleton: boolean; +} + +export interface LoadingSkeletonRetention { + readonly isLoadingSkeletonRetained: boolean; + readonly handleLoadingSkeletonTransitionEnd: React.TransitionEventHandler; +} + +function completesLoadingSkeletonFadeOut(event: React.TransitionEvent): boolean { + if (event.target !== event.currentTarget) { + return false; + } + return event.propertyName === 'opacity'; +} + +export function useLoadingSkeletonRetention({ + shouldShowLoadingSkeleton, +}: LoadingSkeletonRetentionRequest): LoadingSkeletonRetention { + const [isLoadingSkeletonRetained, setIsLoadingSkeletonRetained] = useState(shouldShowLoadingSkeleton); + + useEffect(() => { + if (shouldShowLoadingSkeleton) { + setIsLoadingSkeletonRetained(true); + return; + } + if (!isLoadingSkeletonRetained) { + return; + } + const timeoutId = window.setTimeout(() => { + setIsLoadingSkeletonRetained(false); + }, SKELETON_FADE_OUT_CLEANUP_MS); + return () => window.clearTimeout(timeoutId); + }, [isLoadingSkeletonRetained, shouldShowLoadingSkeleton]); + + const handleLoadingSkeletonTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + if (shouldShowLoadingSkeleton) { + return; + } + if (!completesLoadingSkeletonFadeOut(event)) { + return; + } + setIsLoadingSkeletonRetained(false); + }, + [shouldShowLoadingSkeleton], + ); + + return {isLoadingSkeletonRetained, handleLoadingSkeletonTransitionEnd}; +} diff --git a/fluxer_app/src/features/app/hooks/useNavigationList.ts b/fluxer_app/src/features/app/hooks/useNavigationList.ts new file mode 100644 index 000000000..7db57692b --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useNavigationList.ts @@ -0,0 +1,980 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {TextEntryElementPolicy} from '@app/features/input/TextEntryElementPolicy'; +import {Edge, type VerticalEdge} from '@app/features/ui/AxisOrientation'; +import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; +import type React from 'react'; +import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; + +const NAVIGATION_INDEX_ATTRIBUTE = 'data-navigation-index'; + +export const NavigationAlignment = Object.freeze({ + AUTO: 'auto', + END: 'end', + START: 'start', +} as const); + +export type NavigationAlignment = (typeof NavigationAlignment)[keyof typeof NavigationAlignment]; + +export type NavigationEdgeAlignment = typeof NavigationAlignment.END | typeof NavigationAlignment.START; + +type NavigationScrollerAlignment = 'start' | 'end' | 'nearest'; + +const NavigationFocusableEdge = Object.freeze({ + FIRST: 'first', + LAST: 'last', +} as const); + +type NavigationFocusableEdge = (typeof NavigationFocusableEdge)[keyof typeof NavigationFocusableEdge]; + +export interface NavigationRow { + readonly key: string; + readonly focusable: boolean; + readonly focusTargetIdentity: string | object; +} + +export interface NavigationRowBounds { + readonly top: number; + readonly bottom: number; +} + +export interface NavigationListPosition { + readonly position: number; + readonly setSize: number; +} + +export interface ResolveMissingNavigationActiveKeyRequest { + readonly missingActiveKey: string; +} + +export type MissingNavigationActiveKeyResolver = (request: ResolveMissingNavigationActiveKeyRequest) => string | null; + +interface NavigationListOptions { + readonly scrollerRef: React.RefObject; + readonly rows: ReadonlyArray; + readonly focusableSelector: string; + readonly keyboardNavigationEnabled: boolean; + readonly preferredActiveKey: string | null; + readonly resolveMissingActiveKey: MissingNavigationActiveKeyResolver | null; +} + +interface NavigationListResult { + readonly layoutRevision: number; + readonly navigationRef: React.RefCallback; + readonly onKeyDownCapture: (event: React.KeyboardEvent) => void; + readonly onFocusCapture: (event: React.FocusEvent) => void; + readonly onBlurCapture: (event: React.FocusEvent) => void; + readonly getRowBounds: (keys: ReadonlyArray) => ReadonlyMap; + readonly getRowListPosition: (index: number) => NavigationListPosition | null; + readonly scrollToKey: (key: string, align: NavigationAlignment) => boolean; +} + +interface NavigationLayoutState { + readonly rows: ReadonlyArray; + readonly rowIndexByKey: ReadonlyMap; + readonly focusableRowIndexes: ReadonlyArray; + readonly focusPositionByRowIndex: ReadonlyMap; +} + +interface NavigationLayoutOwner { + readonly layout: NavigationLayout; + readonly revision: number; + readonly sourceRows: ReadonlyArray; +} + +class NavigationLayout { + readonly rows: ReadonlyArray; + readonly rowIndexByKey: ReadonlyMap; + readonly focusableRowIndexes: ReadonlyArray; + readonly focusPositionByRowIndex: ReadonlyMap; + + private constructor({rows, rowIndexByKey, focusableRowIndexes, focusPositionByRowIndex}: NavigationLayoutState) { + this.rows = rows; + this.rowIndexByKey = rowIndexByKey; + this.focusableRowIndexes = focusableRowIndexes; + this.focusPositionByRowIndex = focusPositionByRowIndex; + } + + static fromRows(sourceRows: ReadonlyArray): NavigationLayout { + const rows: Array = []; + const rowIndexByKey = new Map(); + const focusableRowIndexes: Array = []; + const focusPositionByRowIndex = new Map(); + for (let index = 0; index < sourceRows.length; index += 1) { + const sourceRow = sourceRows[index]; + if (sourceRow == null) { + throw new Error(`Navigation source row ${index} is missing`); + } + const row = Object.freeze({ + key: sourceRow.key, + focusable: sourceRow.focusable, + focusTargetIdentity: sourceRow.focusTargetIdentity, + }); + if (rowIndexByKey.has(row.key)) { + throw new Error(`Navigation row key ${row.key} is duplicated`); + } + rows.push(row); + rowIndexByKey.set(row.key, index); + if (!row.focusable) { + continue; + } + focusPositionByRowIndex.set(index, focusableRowIndexes.length); + focusableRowIndexes.push(index); + } + return new NavigationLayout({ + rows: Object.freeze(rows), + rowIndexByKey, + focusableRowIndexes: Object.freeze(focusableRowIndexes), + focusPositionByRowIndex, + }); + } + + matches(sourceRows: ReadonlyArray): boolean { + if (this.rows.length !== sourceRows.length) { + return false; + } + for (let index = 0; index < sourceRows.length; index += 1) { + const current = this.rows[index]; + if (current == null) { + throw new Error(`Navigation layout row ${index} is missing`); + } + const source = sourceRows[index]; + if (source == null) { + throw new Error(`Navigation source row ${index} is missing`); + } + if (current.key !== source.key) { + return false; + } + if (current.focusable !== source.focusable) { + return false; + } + } + return true; + } + + isFocusableRow(index: number): boolean { + const row = this.rows[index]; + if (row == null) { + return false; + } + return row.focusable; + } + + resolveListPosition(index: number): NavigationListPosition | null { + const position = this.focusPositionByRowIndex.get(index); + if (position == null) { + return null; + } + return { + position: position + 1, + setSize: this.focusableRowIndexes.length, + }; + } + + resolveActiveKey(preferredActiveKey: string | null): string | null { + if (preferredActiveKey == null) { + return this.resolveFirstFocusableKey(); + } + const preferredIndex = this.rowIndexByKey.get(preferredActiveKey); + if (preferredIndex == null) { + return this.resolveFirstFocusableKey(); + } + if (!this.isFocusableRow(preferredIndex)) { + return this.resolveFirstFocusableKey(); + } + return preferredActiveKey; + } + + private resolveFirstFocusableKey(): string | null { + const firstFocusableIndex = this.focusableRowIndexes[0]; + if (firstFocusableIndex == null) { + return null; + } + const firstFocusableRow = this.rows[firstFocusableIndex]; + if (firstFocusableRow == null) { + throw new Error(`Navigation focusable row ${firstFocusableIndex} is missing`); + } + return firstFocusableRow.key; + } +} + +function nextNavigationLayoutRevision(currentRevision: number): number { + if (!Number.isSafeInteger(currentRevision)) { + throw new Error('Navigation layout revision is not a safe integer'); + } + if (currentRevision < 0) { + throw new Error('Navigation layout revision is negative'); + } + if (currentRevision >= Number.MAX_SAFE_INTEGER) { + throw new Error('Navigation layout revision exhausted the safe integer range'); + } + return currentRevision + 1; +} + +function useNavigationLayout(rows: ReadonlyArray): NavigationLayoutOwner { + const ownerRef = useRef(null); + const current = ownerRef.current; + if (current == null) { + const initial = NavigationLayout.fromRows(rows); + const initialOwner = {layout: initial, revision: 0, sourceRows: rows}; + ownerRef.current = initialOwner; + return initialOwner; + } + if (current.sourceRows === rows) { + return current; + } + if (current.layout.matches(rows)) { + const matchingOwner = {layout: current.layout, revision: current.revision, sourceRows: rows}; + ownerRef.current = matchingOwner; + return matchingOwner; + } + const nextOwner = { + layout: NavigationLayout.fromRows(rows), + revision: nextNavigationLayoutRevision(current.revision), + sourceRows: rows, + }; + ownerRef.current = nextOwner; + return nextOwner; +} + +function resolveInitialActiveKey(preferredActiveKey: string | null): string | null { + if (preferredActiveKey == null) { + return null; + } + return preferredActiveKey; +} + +interface ResolveOptionalRowIndexQuery { + readonly key: string | null; + readonly rowIndexByKey: ReadonlyMap; +} + +function resolveOptionalRowIndex({key, rowIndexByKey}: ResolveOptionalRowIndexQuery): number | null { + if (key == null) { + return null; + } + const rowIndex = rowIndexByKey.get(key); + if (rowIndex == null) { + return null; + } + return rowIndex; +} + +function resolveScrollNode(scrollerRef: React.RefObject): HTMLElement | null { + const scroller = scrollerRef.current; + if (scroller == null) { + return null; + } + return scroller.getScrollerNode(); +} + +interface ResolveNavigationRowNodeQuery { + readonly index: number; + readonly navigationNode: HTMLElement | null; +} + +function resolveNavigationRowNode({index, navigationNode}: ResolveNavigationRowNodeQuery): HTMLElement | null { + if (navigationNode == null) { + return null; + } + return navigationNode.querySelector(`[${NAVIGATION_INDEX_ATTRIBUTE}="${index}"]`); +} + +function resolveNavigationScrollerAlignment(align: NavigationAlignment): NavigationScrollerAlignment { + if (align === NavigationAlignment.START) { + return 'start'; + } + if (align === NavigationAlignment.END) { + return 'end'; + } + return 'nearest'; +} + +interface ScrollNavigationRowIntoViewRequest { + readonly align: NavigationAlignment; + readonly index: number; + readonly navigationNode: HTMLElement | null; + readonly scrollerRef: React.RefObject; +} + +function scrollNavigationRowIntoView({ + align, + index, + navigationNode, + scrollerRef, +}: ScrollNavigationRowIntoViewRequest): boolean { + const scroller = scrollerRef.current; + if (scroller == null) { + return false; + } + const node = resolveNavigationRowNode({index, navigationNode}); + if (node == null) { + return false; + } + scroller.scrollIntoViewNode({node, alignment: resolveNavigationScrollerAlignment(align)}); + return true; +} + +interface ResolveNavigationRowBoundsRequest { + readonly keys: ReadonlyArray; + readonly navigationNode: HTMLElement | null; + readonly rowIndexByKey: ReadonlyMap; + readonly scrollerRef: React.RefObject; +} + +function resolveNavigationRowBounds({ + keys, + navigationNode, + rowIndexByKey, + scrollerRef, +}: ResolveNavigationRowBoundsRequest): ReadonlyMap { + const bounds = new Map(); + if (keys.length === 0) { + return bounds; + } + if (navigationNode == null) { + return bounds; + } + const scrollNode = resolveScrollNode(scrollerRef); + if (scrollNode == null) { + return bounds; + } + const containerRect = scrollNode.getBoundingClientRect(); + const scrollTop = scrollNode.scrollTop; + for (const key of keys) { + const index = rowIndexByKey.get(key); + if (index == null) { + continue; + } + const node = resolveNavigationRowNode({index, navigationNode}); + if (node == null) { + continue; + } + if (!node.isConnected) { + continue; + } + if (!scrollNode.contains(node)) { + continue; + } + const rect = node.getBoundingClientRect(); + bounds.set(key, { + top: scrollTop + rect.top - containerRect.top, + bottom: scrollTop + rect.bottom - containerRect.top, + }); + } + return bounds; +} + +interface ResolveFocusableElementQuery { + readonly edge: NavigationFocusableEdge; + readonly focusableSelector: string; + readonly row: Element | null; +} + +function resolveFocusableElement({row, focusableSelector, edge}: ResolveFocusableElementQuery): HTMLElement | null { + if (row == null) { + return null; + } + const focusableElements = row.querySelectorAll(focusableSelector); + if (focusableElements.length === 0) { + return null; + } + if (edge === NavigationFocusableEdge.LAST) { + return focusableElements.item(focusableElements.length - 1); + } + return focusableElements.item(0); +} + +interface ResolveCurrentNavigationRowRequest { + readonly eventTarget: EventTarget; + readonly navigationNode: HTMLElement; +} + +function resolveCurrentNavigationRow({ + eventTarget, + navigationNode, +}: ResolveCurrentNavigationRowRequest): Element | null { + const ownerWindow = navigationNode.ownerDocument.defaultView; + if (ownerWindow == null) { + return null; + } + if (!(eventTarget instanceof ownerWindow.Element)) { + return null; + } + return eventTarget.closest(`[${NAVIGATION_INDEX_ATTRIBUTE}]`); +} + +function resolveCurrentNavigationIndex(currentRow: Element | null, activeRowIndex: number | null): number | null { + if (currentRow == null) { + return activeRowIndex; + } + const parsedIndex = Number(currentRow.getAttribute(NAVIGATION_INDEX_ATTRIBUTE)); + if (!Number.isInteger(parsedIndex)) { + return activeRowIndex; + } + return parsedIndex; +} + +function resolveFocusablePosition( + rowIndex: number | null, + focusPositionByRowIndex: ReadonlyMap, +): number | null { + if (rowIndex == null) { + return null; + } + const focusablePosition = focusPositionByRowIndex.get(rowIndex); + if (focusablePosition == null) { + return null; + } + return focusablePosition; +} + +function resolveArrowDelta(key: string): number { + if (key === 'ArrowDown') { + return 1; + } + return -1; +} + +interface ResolveNextFocusablePositionArgs { + readonly key: string; + readonly currentPosition: number | null; + readonly focusableRowCount: number; +} + +function resolveNextFocusablePosition({ + key, + currentPosition, + focusableRowCount, +}: ResolveNextFocusablePositionArgs): number { + if (key === 'Home') { + return 0; + } + if (key === 'End') { + return focusableRowCount - 1; + } + if (currentPosition == null) { + if (key === 'ArrowDown') { + return 0; + } + return focusableRowCount - 1; + } + const delta = resolveArrowDelta(key); + return (currentPosition + delta + focusableRowCount) % focusableRowCount; +} + +function resolveFocusableEdge(key: string): NavigationFocusableEdge { + if (key === 'ArrowUp') { + return NavigationFocusableEdge.LAST; + } + if (key === 'End') { + return NavigationFocusableEdge.LAST; + } + return NavigationFocusableEdge.FIRST; +} + +interface FocusRenderedNavigationRowRequest { + readonly edge: NavigationFocusableEdge; + readonly focusableSelector: string; + readonly index: number; + readonly navigationNode: HTMLElement | null; + readonly shouldFocus: () => boolean; +} + +interface OwnedAnimationFrame { + readonly id: number; + readonly ownerWindow: Window; +} + +function focusRenderedNavigationRow({ + edge, + focusableSelector, + index, + navigationNode, + shouldFocus, +}: FocusRenderedNavigationRowRequest): boolean { + if (navigationNode == null) return false; + if (!shouldFocus()) return false; + const row = resolveNavigationRowNode({index, navigationNode}); + const target = resolveFocusableElement({row, focusableSelector, edge}); + if (target == null) return false; + target.focus(); + return target.ownerDocument.activeElement === target; +} + +function cancelOwnedAnimationFrame(frame: OwnedAnimationFrame | null): void { + if (frame == null) return; + frame.ownerWindow.cancelAnimationFrame(frame.id); +} + +function shouldAlwaysFocusNavigationRow(): boolean { + return true; +} + +type FocusNavigationRow = (request: FocusRenderedNavigationRowRequest) => boolean; + +function useNavigationFocusFrame(navigationNode: HTMLElement | null): FocusNavigationRow { + const frameRef = useRef(null); + const cancelPendingFocus = useCallback(() => { + cancelOwnedAnimationFrame(frameRef.current); + frameRef.current = null; + }, []); + const focusRow = useCallback( + (request: FocusRenderedNavigationRowRequest): boolean => { + cancelPendingFocus(); + if (focusRenderedNavigationRow(request)) return true; + if (navigationNode == null) return false; + if (!request.shouldFocus()) return false; + const ownerWindow = navigationNode.ownerDocument.defaultView; + if (ownerWindow == null) return false; + let frame: OwnedAnimationFrame | null = null; + const id = ownerWindow.requestAnimationFrame(() => { + if (frameRef.current !== frame) return; + frameRef.current = null; + focusRenderedNavigationRow(request); + }); + frame = {id, ownerWindow}; + frameRef.current = frame; + return false; + }, + [cancelPendingFocus, navigationNode], + ); + useLayoutEffect(() => cancelPendingFocus, [cancelPendingFocus, navigationNode]); + return focusRow; +} + +interface HandleNavigationKeyRequest { + readonly activeRowIndex: number | null; + readonly event: React.KeyboardEvent; + readonly focusPositionByRowIndex: ReadonlyMap; + readonly focusableRowIndexes: ReadonlyArray; + readonly focusableSelector: string; + readonly keyboardNavigationEnabled: boolean; + readonly moveFocus: (rowIndex: number, edge: NavigationFocusableEdge) => void; +} + +interface FocusWithinNavigationRowRequest { + readonly currentRow: Element | null; + readonly event: React.KeyboardEvent; + readonly focusableSelector: string; +} + +function isNavigationKey(key: string): boolean { + switch (key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Home': + case 'End': + return true; + default: + return false; + } +} + +function focusWithinNavigationRow({currentRow, event, focusableSelector}: FocusWithinNavigationRowRequest): boolean { + if (currentRow == null) return false; + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return false; + const ownerWindow = event.currentTarget.ownerDocument.defaultView; + if (ownerWindow == null) return false; + if (!(event.target instanceof ownerWindow.Element)) return false; + const focusableElements = Array.from(currentRow.querySelectorAll(focusableSelector)); + const currentFocusable = event.target.closest(focusableSelector); + if (currentFocusable == null) return false; + const currentFocusableIndex = focusableElements.indexOf(currentFocusable); + if (currentFocusableIndex < 0) return false; + const nextFocusableIndex = currentFocusableIndex + resolveArrowDelta(event.key); + const nextFocusable = focusableElements[nextFocusableIndex]; + if (nextFocusable == null) return false; + event.preventDefault(); + event.stopPropagation(); + nextFocusable.focus(); + return true; +} + +function handleNavigationKey({ + activeRowIndex, + event, + focusPositionByRowIndex, + focusableRowIndexes, + focusableSelector, + keyboardNavigationEnabled, + moveFocus, +}: HandleNavigationKeyRequest): void { + if (!keyboardNavigationEnabled) return; + if (!isNavigationKey(event.key)) return; + const ownerWindow = event.currentTarget.ownerDocument.defaultView; + if (ownerWindow == null) return; + if (event.target instanceof ownerWindow.HTMLElement && TextEntryElementPolicy.isTextEntry(event.target)) return; + if (focusableRowIndexes.length === 0) return; + const currentRow = resolveCurrentNavigationRow({eventTarget: event.target, navigationNode: event.currentTarget}); + if (focusWithinNavigationRow({currentRow, event, focusableSelector})) return; + const currentIndex = resolveCurrentNavigationIndex(currentRow, activeRowIndex); + const currentPosition = resolveFocusablePosition(currentIndex, focusPositionByRowIndex); + const nextPosition = resolveNextFocusablePosition({ + key: event.key, + currentPosition, + focusableRowCount: focusableRowIndexes.length, + }); + const nextRowIndex = focusableRowIndexes[nextPosition]; + if (nextRowIndex == null) return; + event.preventDefault(); + event.stopPropagation(); + moveFocus(nextRowIndex, resolveFocusableEdge(event.key)); +} + +export function resolveNavigationAlignment(direction: VerticalEdge): NavigationEdgeAlignment { + if (direction === Edge.TOP) { + return NavigationAlignment.START; + } + return NavigationAlignment.END; +} + +interface MutableFocusReference { + current: Value; +} + +interface NavigationFocusReferences { + readonly focusedElement: MutableFocusReference; + readonly focusedRowKey: MutableFocusReference; +} + +interface NavigationFocusOwnership extends NavigationFocusReferences { + readonly onFocusCapture: (event: React.FocusEvent) => void; + readonly onBlurCapture: (event: React.FocusEvent) => void; +} + +function clearNavigationFocus(references: NavigationFocusReferences): void { + references.focusedElement.current = null; + references.focusedRowKey.current = null; +} + +function useNavigationFocusOwnership(navigationRows: ReadonlyArray): NavigationFocusOwnership { + const focusedElement = useRef(null); + const focusedRowKey = useRef(null); + const references = useMemo(() => ({focusedElement, focusedRowKey}), [focusedElement, focusedRowKey]); + const onFocusCapture = useCallback( + (event: React.FocusEvent) => { + const ownerWindow = event.currentTarget.ownerDocument.defaultView; + if (ownerWindow == null) return; + if (!(event.target instanceof ownerWindow.HTMLElement)) return; + const row = event.target.closest(`[${NAVIGATION_INDEX_ATTRIBUTE}]`); + if (row == null) return; + const index = Number(row.getAttribute(NAVIGATION_INDEX_ATTRIBUTE)); + if (!Number.isInteger(index)) return; + const navigationRow = navigationRows[index]; + if (navigationRow == null) return; + focusedElement.current = event.target; + focusedRowKey.current = navigationRow.key; + }, + [navigationRows], + ); + const onBlurCapture = useCallback((event: React.FocusEvent) => { + const ownerWindow = event.currentTarget.ownerDocument.defaultView; + if (ownerWindow == null) { + clearNavigationFocus(references); + return; + } + const nextFocusedTarget = event.relatedTarget; + if (nextFocusedTarget instanceof ownerWindow.Node && event.currentTarget.contains(nextFocusedTarget)) return; + clearNavigationFocus(references); + }, []); + return useMemo( + () => ({focusedElement, focusedRowKey, onFocusCapture, onBlurCapture}), + [focusedElement, focusedRowKey, onBlurCapture, onFocusCapture], + ); +} + +interface RestoreOwnedNavigationFocusRequest { + readonly focusReferences: NavigationFocusReferences; + readonly focusRow: FocusNavigationRow; + readonly focusableSelector: string; + readonly navigationNode: HTMLElement | null; + readonly ownedRowKey: string; + readonly scrollerRef: React.RefObject; + readonly targetRowIndex: number; + readonly targetRowKey: string; +} + +function restoreOwnedNavigationFocus({ + focusReferences, + focusRow, + focusableSelector, + navigationNode, + ownedRowKey, + scrollerRef, + targetRowIndex, + targetRowKey, +}: RestoreOwnedNavigationFocusRequest): boolean { + if (focusReferences.focusedRowKey.current !== ownedRowKey) return false; + const focusedElement = focusReferences.focusedElement.current; + if (focusedElement == null) return false; + if (focusedElement.isConnected) return false; + if (navigationNode == null) return false; + const focusDocument = navigationNode.ownerDocument; + if (focusDocument.activeElement !== focusDocument.body) { + clearNavigationFocus(focusReferences); + return false; + } + focusReferences.focusedRowKey.current = targetRowKey; + const shouldRestoreFocus = (): boolean => { + if (focusReferences.focusedRowKey.current !== targetRowKey) return false; + if (focusDocument.activeElement === focusDocument.body) return true; + clearNavigationFocus(focusReferences); + return false; + }; + const focusedImmediately = focusRow({ + edge: NavigationFocusableEdge.FIRST, + focusableSelector, + index: targetRowIndex, + navigationNode, + shouldFocus: shouldRestoreFocus, + }); + if (!focusedImmediately) { + scrollNavigationRowIntoView({ + align: NavigationAlignment.AUTO, + index: targetRowIndex, + navigationNode, + scrollerRef, + }); + } + return true; +} + +interface ResolveReplacementActiveKeyRequest { + readonly activeKey: string | null; + readonly layout: NavigationLayout; + readonly preferredActiveKey: string | null; + readonly resolveMissingActiveKey: MissingNavigationActiveKeyResolver | null; +} + +function resolveReplacementActiveKey({ + activeKey, + layout, + preferredActiveKey, + resolveMissingActiveKey, +}: ResolveReplacementActiveKeyRequest): string | null { + let candidate: string | null = null; + if (activeKey != null && resolveMissingActiveKey != null) { + candidate = resolveMissingActiveKey({missingActiveKey: activeKey}); + } + if (candidate == null) { + candidate = preferredActiveKey; + } + return layout.resolveActiveKey(candidate); +} + +interface NavigationFocusReconciliationOptions { + readonly activeFocusTargetIdentity: string | object | null; + readonly activeKey: string | null; + readonly activeRowIndex: number | null; + readonly focusReferences: NavigationFocusReferences; + readonly focusRow: FocusNavigationRow; + readonly focusableSelector: string; + readonly layout: NavigationLayout; + readonly navigationNode: HTMLElement | null; + readonly preferredActiveKey: string | null; + readonly resolveMissingActiveKey: MissingNavigationActiveKeyResolver | null; + readonly rowIndexByKey: ReadonlyMap; + readonly scrollerRef: React.RefObject; + readonly setActiveKey: (key: string | null) => void; +} + +function useNavigationFocusReconciliation({ + activeFocusTargetIdentity, + activeKey, + activeRowIndex, + focusReferences, + focusRow, + focusableSelector, + layout, + navigationNode, + preferredActiveKey, + resolveMissingActiveKey, + rowIndexByKey, + scrollerRef, + setActiveKey, +}: NavigationFocusReconciliationOptions): void { + useLayoutEffect(() => { + if (activeKey != null && activeRowIndex != null && layout.isFocusableRow(activeRowIndex)) { + restoreOwnedNavigationFocus({ + focusReferences, + focusRow, + focusableSelector, + navigationNode, + ownedRowKey: activeKey, + scrollerRef, + targetRowIndex: activeRowIndex, + targetRowKey: activeKey, + }); + return; + } + const nextActiveKey = resolveReplacementActiveKey({ + activeKey, + layout, + preferredActiveKey, + resolveMissingActiveKey, + }); + if (nextActiveKey === activeKey) return; + if (activeKey != null && nextActiveKey != null) { + const nextActiveRowIndex = rowIndexByKey.get(nextActiveKey); + if (nextActiveRowIndex != null) { + setActiveKey(nextActiveKey); + if ( + restoreOwnedNavigationFocus({ + focusReferences, + focusRow, + focusableSelector, + navigationNode, + ownedRowKey: activeKey, + scrollerRef, + targetRowIndex: nextActiveRowIndex, + targetRowKey: nextActiveKey, + }) + ) { + return; + } + } + } + if (focusReferences.focusedRowKey.current === activeKey) { + clearNavigationFocus(focusReferences); + } + setActiveKey(nextActiveKey); + }, [ + activeFocusTargetIdentity, + activeKey, + activeRowIndex, + focusReferences, + focusRow, + focusableSelector, + layout, + navigationNode, + preferredActiveKey, + resolveMissingActiveKey, + rowIndexByKey, + scrollerRef, + setActiveKey, + ]); +} + +function resolveActiveFocusTargetIdentity( + rows: ReadonlyArray, + activeRowIndex: number | null, +): string | object | null { + if (activeRowIndex == null) return null; + const row = rows[activeRowIndex]; + if (row == null) { + throw new Error(`Navigation active source row ${activeRowIndex} is missing`); + } + return row.focusTargetIdentity; +} + +export function useNavigationList({ + scrollerRef, + rows, + focusableSelector, + keyboardNavigationEnabled, + preferredActiveKey, + resolveMissingActiveKey, +}: NavigationListOptions): NavigationListResult { + const [navigationNode, setNavigationNode] = useState(null); + const [activeKey, setActiveKey] = useState(() => resolveInitialActiveKey(preferredActiveKey)); + const layoutOwner = useNavigationLayout(rows); + const layout = layoutOwner.layout; + const navigationRows = layout.rows; + const rowIndexByKey = layout.rowIndexByKey; + const focusableRowIndexes = layout.focusableRowIndexes; + const focusPositionByRowIndex = layout.focusPositionByRowIndex; + const activeRowIndex = resolveOptionalRowIndex({key: activeKey, rowIndexByKey}); + const activeFocusTargetIdentity = resolveActiveFocusTargetIdentity(rows, activeRowIndex); + const focusOwnership = useNavigationFocusOwnership(navigationRows); + const focusRow = useNavigationFocusFrame(navigationNode); + const moveFocus = useCallback( + (rowIndex: number, edge: NavigationFocusableEdge) => { + const row = navigationRows[rowIndex]; + if (row == null) return; + setActiveKey(row.key); + scrollNavigationRowIntoView({ + align: NavigationAlignment.AUTO, + index: rowIndex, + navigationNode, + scrollerRef, + }); + focusRow({ + edge, + focusableSelector, + index: rowIndex, + navigationNode, + shouldFocus: shouldAlwaysFocusNavigationRow, + }); + }, + [focusRow, focusableSelector, navigationNode, navigationRows, scrollerRef], + ); + const onKeyDownCapture = useCallback( + (event: React.KeyboardEvent) => { + handleNavigationKey({ + activeRowIndex, + event, + focusPositionByRowIndex, + focusableRowIndexes, + focusableSelector, + keyboardNavigationEnabled, + moveFocus, + }); + }, + [ + activeRowIndex, + focusPositionByRowIndex, + focusableRowIndexes, + focusableSelector, + keyboardNavigationEnabled, + moveFocus, + ], + ); + const onFocusCapture = useCallback( + (event: React.FocusEvent) => { + focusOwnership.onFocusCapture(event); + const rowKey = focusOwnership.focusedRowKey.current; + if (rowKey != null) setActiveKey(rowKey); + }, + [focusOwnership], + ); + const getRowBounds = useCallback( + (keys: ReadonlyArray): ReadonlyMap => + resolveNavigationRowBounds({keys, navigationNode, rowIndexByKey, scrollerRef}), + [navigationNode, rowIndexByKey, scrollerRef], + ); + const scrollToKey = useCallback( + (key: string, align: NavigationAlignment): boolean => { + const index = rowIndexByKey.get(key); + if (index == null) return false; + return scrollNavigationRowIntoView({align, index, navigationNode, scrollerRef}); + }, + [navigationNode, rowIndexByKey, scrollerRef], + ); + const getRowListPosition = useCallback((index: number) => layout.resolveListPosition(index), [layout]); + useNavigationFocusReconciliation({ + activeFocusTargetIdentity, + activeKey, + activeRowIndex, + focusReferences: focusOwnership, + focusRow, + focusableSelector, + layout, + navigationNode, + preferredActiveKey, + resolveMissingActiveKey, + rowIndexByKey, + scrollerRef, + setActiveKey, + }); + return { + layoutRevision: layoutOwner.revision, + navigationRef: setNavigationNode, + onKeyDownCapture, + onFocusCapture, + onBlurCapture: focusOwnership.onBlurCapture, + getRowBounds, + getRowListPosition, + scrollToKey, + }; +} diff --git a/fluxer_app/src/features/app/hooks/useRovingFocusList.ts b/fluxer_app/src/features/app/hooks/useRovingFocusList.ts index a23d206a1..24a0adc72 100644 --- a/fluxer_app/src/features/app/hooks/useRovingFocusList.ts +++ b/fluxer_app/src/features/app/hooks/useRovingFocusList.ts @@ -57,6 +57,15 @@ const getFocusableElements = ( }); }; const ROVING_MANAGED_ATTR = 'data-roving-focus-managed'; +const hasFocusableNode = (nodes: NodeList, selector: string): boolean => { + for (let index = 0; index < nodes.length; index++) { + const node = nodes.item(index); + if (!(node instanceof HTMLElement)) continue; + if (node.matches(selector)) return true; + if (node.querySelector(selector) != null) return true; + } + return false; +}; const applyTabIndices = (focusable: Array, activeIndex: number): void => { if (focusable.length === 0) return; const clamped = activeIndex >= 0 && activeIndex < focusable.length ? activeIndex : 0; @@ -289,8 +298,12 @@ export const useRovingFocusList = (options: UseRovingFocu }; apply(); const observer = new MutationObserver((mutations) => { + const {focusableSelector: selector} = latestOptionsRef.current; for (const m of mutations) { if (m.type === 'childList') { + if (!hasFocusableNode(m.addedNodes, selector) && !hasFocusableNode(m.removedNodes, selector)) { + continue; + } invalidateFocusableCache(); schedule(); return; diff --git a/fluxer_app/src/features/app/hooks/useSidebarVoiceSummary.ts b/fluxer_app/src/features/app/hooks/useSidebarVoiceSummary.ts new file mode 100644 index 000000000..0d894225b --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useSidebarVoiceSummary.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge'; +import type {User} from '@app/features/user/models/User'; +import Users from '@app/features/user/state/Users'; +import { + createVoiceParticipantSortSnapshot, + sortVoiceParticipantItemsWithSnapshot, +} from '@app/features/voice/components/VoiceParticipantSortUtils'; +import type {NormalizedVoiceState} from '@app/features/voice/engine/VoiceGatewayStateMachine'; +import {useRef} from 'react'; + +export const SidebarVoiceSummaryScope = Object.freeze({ + GUILD: 'guild', + CHANNEL: 'channel', +} as const); + +export type SidebarVoiceSummaryScope = (typeof SidebarVoiceSummaryScope)[keyof typeof SidebarVoiceSummaryScope]; + +export type SidebarVoiceStatesByChannel = Readonly>>>; +export type SidebarVoiceStatesByConnection = Readonly>; + +interface GuildSidebarVoiceSummaryInput { + readonly scope: typeof SidebarVoiceSummaryScope.GUILD; + readonly voiceStates: SidebarVoiceStatesByChannel | undefined; + readonly guildId: string; +} + +interface ChannelSidebarVoiceSummaryInput { + readonly scope: typeof SidebarVoiceSummaryScope.CHANNEL; + readonly voiceStates: SidebarVoiceStatesByConnection | undefined; + readonly channelId: string; +} + +type SidebarVoiceSummaryInput = GuildSidebarVoiceSummaryInput | ChannelSidebarVoiceSummaryInput; + +export interface SidebarVoiceRow { + readonly key: 'voice' | 'screenshare'; + readonly users: ReadonlyArray; +} + +export interface SidebarVoiceSummary { + readonly voiceRows: ReadonlyArray; + readonly hasVoiceActivity: boolean; + readonly badgeActivity: VoiceBadgeActivity | null; +} + +const EMPTY_SIDEBAR_VOICE_ROWS: ReadonlyArray = Object.freeze([]); +const EMPTY_SIDEBAR_VOICE_SUMMARY: SidebarVoiceSummary = Object.freeze({ + voiceRows: EMPTY_SIDEBAR_VOICE_ROWS, + hasVoiceActivity: false, + badgeActivity: null, +}); + +function getUserId(user: User): string { + return user.id; +} + +function hasAnySidebarVoiceState(input: SidebarVoiceSummaryInput): boolean { + const voiceStates = input.voiceStates; + if (voiceStates == null) return false; + for (const key in voiceStates) { + if (voiceStates[key] != null) return true; + } + return false; +} + +function resolveVoiceBadgeActivity( + hasVoiceActivity: boolean, + hasScreenshare: boolean, + hasVideo: boolean, +): VoiceBadgeActivity | null { + if (!hasVoiceActivity) { + return null; + } + if (hasScreenshare) { + return 'screenshare'; + } + if (hasVideo) { + return 'video'; + } + return 'voice'; +} + +class SidebarVoiceParticipantCollector { + private readonly seenUserIds = new Set(); + private readonly collectedVoiceUsers: Array = []; + private readonly collectedStreamingUsers: Array = []; + private screensharePresent = false; + private videoPresent = false; + + get voiceUsers(): ReadonlyArray { + return this.collectedVoiceUsers; + } + + get streamingUsers(): ReadonlyArray { + return this.collectedStreamingUsers; + } + + get hasScreenshare(): boolean { + return this.screensharePresent; + } + + get hasVideo(): boolean { + return this.videoPresent; + } + + collectByConnection(voiceStates: SidebarVoiceStatesByConnection): void { + for (const connectionId in voiceStates) { + const voiceState = voiceStates[connectionId]; + if (!voiceState) continue; + this.collectVoiceState(voiceState); + } + } + + collectByChannel(voiceStates: SidebarVoiceStatesByChannel): void { + for (const channelId in voiceStates) { + const channelStates = voiceStates[channelId]; + if (!channelStates) continue; + this.collectByConnection(channelStates); + } + } + + private collectVoiceState(voiceState: NormalizedVoiceState): void { + const isScreensharing = voiceState.self_stream === true; + const isVideo = voiceState.self_video === true; + if (isScreensharing) { + this.screensharePresent = true; + } + if (isVideo) { + this.videoPresent = true; + } + if (this.seenUserIds.has(voiceState.user_id)) { + return; + } + const user = Users.getUser(voiceState.user_id); + if (!user) { + return; + } + if (isScreensharing) { + this.collectedStreamingUsers.push(user); + } else { + this.collectedVoiceUsers.push(user); + } + this.seenUserIds.add(user.id); + } +} + +export function useSidebarVoiceSummary(input: SidebarVoiceSummaryInput): SidebarVoiceSummary { + let guildId: string | null = null; + let channelId: string | null = null; + if (input.scope === SidebarVoiceSummaryScope.GUILD) { + guildId = input.guildId; + } else { + channelId = input.channelId; + } + const voiceUserSortSnapshotRef = useRef(createVoiceParticipantSortSnapshot()); + const streamingUserSortSnapshotRef = useRef(createVoiceParticipantSortSnapshot()); + if (!hasAnySidebarVoiceState(input)) { + voiceUserSortSnapshotRef.current.entries.clear(); + streamingUserSortSnapshotRef.current.entries.clear(); + return EMPTY_SIDEBAR_VOICE_SUMMARY; + } + const participantCollector = new SidebarVoiceParticipantCollector(); + if (input.voiceStates != null) { + if (input.scope === SidebarVoiceSummaryScope.GUILD) { + participantCollector.collectByChannel(input.voiceStates); + } else { + participantCollector.collectByConnection(input.voiceStates); + } + } + const sortedVoiceUsers = sortVoiceParticipantItemsWithSnapshot(participantCollector.voiceUsers, { + snapshot: voiceUserSortSnapshotRef.current, + getParticipantKey: getUserId, + getUserId, + guildId, + channelId, + getTieBreaker: undefined, + }); + const sortedStreamingUsers = sortVoiceParticipantItemsWithSnapshot(participantCollector.streamingUsers, { + snapshot: streamingUserSortSnapshotRef.current, + getParticipantKey: getUserId, + getUserId, + guildId, + channelId, + getTieBreaker: undefined, + }); + const voiceRows: Array = []; + if (sortedVoiceUsers.length > 0) { + voiceRows.push({key: 'voice', users: sortedVoiceUsers}); + } + if (sortedStreamingUsers.length > 0) { + voiceRows.push({key: 'screenshare', users: sortedStreamingUsers}); + } + const hasVoiceActivity = voiceRows.length > 0; + const badgeActivity = resolveVoiceBadgeActivity( + hasVoiceActivity, + participantCollector.hasScreenshare, + participantCollector.hasVideo, + ); + return {voiceRows, hasVoiceActivity, badgeActivity}; +} diff --git a/fluxer_app/src/features/app/hooks/useSkeletonLayoutMemoryCapture.ts b/fluxer_app/src/features/app/hooks/useSkeletonLayoutMemoryCapture.ts new file mode 100644 index 000000000..9b5ff3f41 --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useSkeletonLayoutMemoryCapture.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + flushSkeletonLayoutMemoryWrite, + SKELETON_UNMEASURED_WIDTH_PX, + setSkeletonLayoutCaptureEnabled, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; +import {useEffect, useRef} from 'react'; + +export function useSkeletonLayoutMemoryCapture(enabled: boolean): void { + useEffect(() => { + setSkeletonLayoutCaptureEnabled(enabled); + if (!enabled) { + return; + } + const flushOnLifecycleBoundary = (): void => { + flushSkeletonLayoutMemoryWrite(); + }; + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'hidden') { + flushOnLifecycleBoundary(); + } + }; + window.addEventListener('pagehide', flushOnLifecycleBoundary); + window.addEventListener('beforeunload', flushOnLifecycleBoundary); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.removeEventListener('pagehide', flushOnLifecycleBoundary); + window.removeEventListener('beforeunload', flushOnLifecycleBoundary); + document.removeEventListener('visibilitychange', handleVisibilityChange); + flushOnLifecycleBoundary(); + setSkeletonLayoutCaptureEnabled(false); + }; + }, [enabled]); +} + +export function measureSkeletonWidthPx(element: Element | null): number { + if (element == null) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + const width = element.getBoundingClientRect().width; + if (!Number.isFinite(width) || width <= 0) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + return Math.round(width / getRemScaleForDocument(element.ownerDocument)); +} + +export function measureSkeletonTextWidthPx(element: Element | null): number { + const ownerDocument = element?.ownerDocument; + if (element == null || ownerDocument == null) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + const range = ownerDocument.createRange(); + range.selectNodeContents(element); + const textWidth = range.getBoundingClientRect().width; + const boxWidth = element.getBoundingClientRect().width; + if (!Number.isFinite(textWidth) || textWidth <= 0 || !Number.isFinite(boxWidth) || boxWidth <= 0) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + return Math.round(Math.min(textWidth, boxWidth) / getRemScaleForDocument(ownerDocument)); +} + +export function measureSkeletonHeightPx(element: Element | null): number { + if (element == null) { + return 0; + } + const height = element.getBoundingClientRect().height; + if (!Number.isFinite(height) || height <= 0) { + return 0; + } + return Math.round(height / getRemScaleForDocument(element.ownerDocument)); +} + +export function useSkeletonLayoutReport(report: () => void, changeKey: string): void { + const reportRef = useRef(report); + reportRef.current = report; + useEffect(() => { + reportRef.current(); + }, [changeKey]); +} diff --git a/fluxer_app/src/features/app/hooks/useSplashScreenGuard.ts b/fluxer_app/src/features/app/hooks/useSplashScreenGuard.ts deleted file mode 100644 index 72e8a13d0..000000000 --- a/fluxer_app/src/features/app/hooks/useSplashScreenGuard.ts +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import KeybindManager from '@app/features/app/keybindings/KeybindManager'; -import ContextMenuState from '@app/features/ui/state/ContextMenu'; -import {type RefObject, useEffect} from 'react'; - -function clearDocumentSelection(): void { - if (typeof document === 'undefined') return; - document.getSelection()?.removeAllRanges(); -} - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - return ( - target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement - ); -} - -function isSelectAllShortcut(event: KeyboardEvent): boolean { - if (event.defaultPrevented || event.altKey || event.shiftKey) return false; - if (!event.ctrlKey && !event.metaKey) return false; - return event.key.toLowerCase() === 'a'; -} - -function selectSplashScreenContents(root: HTMLElement): void { - const ownerDocument = root.ownerDocument; - const selection = ownerDocument.getSelection(); - if (!selection) return; - const range = ownerDocument.createRange(); - range.selectNodeContents(root); - selection.removeAllRanges(); - selection.addRange(range); -} - -export function useSplashScreenGuard(selectionRootRef?: RefObject): void { - useEffect(() => { - KeybindManager.suspend(); - if (typeof document === 'undefined') { - return () => { - KeybindManager.resume(); - }; - } - clearDocumentSelection(); - ContextMenuState.close(); - const handleSelectAll = (event: KeyboardEvent) => { - if (!isSelectAllShortcut(event) || isEditableTarget(event.target)) return; - const selectionRoot = selectionRootRef?.current; - if (!selectionRoot) return; - event.preventDefault(); - selectSplashScreenContents(selectionRoot); - }; - document.addEventListener('keydown', handleSelectAll, {capture: true}); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - return () => { - document.removeEventListener('keydown', handleSelectAll, {capture: true}); - KeybindManager.resume(); - }; - }, [selectionRootRef]); -} diff --git a/fluxer_app/src/features/app/state/AppShellPresentationPolicy.ts b/fluxer_app/src/features/app/state/AppShellPresentationPolicy.ts new file mode 100644 index 000000000..70b13bd20 --- /dev/null +++ b/fluxer_app/src/features/app/state/AppShellPresentationPolicy.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Routes} from '@app/app/Routes'; + +const GUILD_ROOT_PATH_SEGMENT_COUNT = 3; + +export interface MobileBottomNavVisibilityQuery { + readonly mobileLayoutEnabled: boolean; + readonly pathname: string; +} + +export interface AppLoadingSkeletonVisibilityQuery { + readonly booting: boolean; + readonly isAuthenticated: boolean; + readonly isAuthSessionInitialized: boolean; + readonly shouldBypassGateway: boolean; +} + +const BOTTOM_NAV_ROOT_ROUTES = new Set([Routes.ME, Routes.FAVORITES, Routes.NOTIFICATIONS, Routes.YOU]); + +export function shouldShowMobileBottomNav({mobileLayoutEnabled, pathname}: MobileBottomNavVisibilityQuery): boolean { + if (!mobileLayoutEnabled) { + return false; + } + if (BOTTOM_NAV_ROOT_ROUTES.has(pathname)) { + return true; + } + if (!Routes.isGuildChannelRoute(pathname)) { + return false; + } + return isGuildRootRoute(pathname); +} + +export function shouldShowLoadingSkeleton({ + booting, + isAuthenticated, + isAuthSessionInitialized, + shouldBypassGateway, +}: AppLoadingSkeletonVisibilityQuery): boolean { + if (shouldBypassGateway) { + return false; + } + if (!isAuthSessionInitialized) { + return true; + } + if (!isAuthenticated) { + return true; + } + return booting; +} + +function isGuildRootRoute(pathname: string): boolean { + return pathname.split('/').length === GUILD_ROOT_PATH_SEGMENT_COUNT; +} diff --git a/fluxer_app/src/features/app/state/ClientReadiness.ts b/fluxer_app/src/features/app/state/ClientReadiness.ts new file mode 100644 index 000000000..dbc0cdec6 --- /dev/null +++ b/fluxer_app/src/features/app/state/ClientReadiness.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Initialization from '@app/features/app/state/Initialization'; +import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; +import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; + +export function isClientBooting(): boolean { + if (DeveloperOptions.bypassLoadingSkeleton) { + return false; + } + if (DeveloperOptions.forceLoadingSkeleton) { + return true; + } + return !Initialization.hasCompletedInitialLoad; +} + +export function isClientReconnecting(): boolean { + if (isClientBooting()) { + return false; + } + return GatewayConnection.isConnectionInterrupted || !Initialization.isReady; +} diff --git a/fluxer_app/src/features/app/state/Initialization.ts b/fluxer_app/src/features/app/state/Initialization.ts index 0da355af5..4f6dffd0e 100644 --- a/fluxer_app/src/features/app/state/Initialization.ts +++ b/fluxer_app/src/features/app/state/Initialization.ts @@ -14,6 +14,7 @@ type InitializationState = ValueOf; class Initialization { state: InitializationState = InitializationState.LOADING; + hasCompletedInitialLoad = false; error: string | null = null; readyPayload: unknown = null; @@ -58,6 +59,7 @@ class Initialization { @action setReady(payload: unknown): void { this.state = InitializationState.READY; + this.hasCompletedInitialLoad = true; this.error = null; this.readyPayload = payload; } @@ -72,6 +74,7 @@ class Initialization { @action reset(): void { this.state = InitializationState.LOADING; + this.hasCompletedInitialLoad = false; this.error = null; this.readyPayload = null; } diff --git a/fluxer_app/src/features/app/state/RuntimeConfig.ts b/fluxer_app/src/features/app/state/RuntimeConfig.ts index 60c33aaa6..3dc0302bf 100644 --- a/fluxer_app/src/features/app/state/RuntimeConfig.ts +++ b/fluxer_app/src/features/app/state/RuntimeConfig.ts @@ -64,6 +64,38 @@ export interface RuntimeConfigSnapshot { appPublic: InstanceAppPublic; } +function runtimeInstanceKey(snapshot: RuntimeConfigSnapshot): string | null { + try { + const endpoint = snapshot.apiEndpoint.trim(); + const url = new URL(endpoint); + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + url.search || + url.hash + ) { + return null; + } + const path = url.pathname.replace(/\/+$/u, ''); + return `${url.origin.toLowerCase()}${path}`; + } catch { + return null; + } +} + +export function runtimeConfigSnapshotsAreSameInstance( + left: RuntimeConfigSnapshot | undefined, + right: RuntimeConfigSnapshot, +): boolean { + if (!left) { + return false; + } + const leftKey = runtimeInstanceKey(left); + const rightKey = runtimeInstanceKey(right); + return leftKey !== null && leftKey === rightKey; +} + const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = { voice_enabled: false, stripe_enabled: false, @@ -244,7 +276,6 @@ export function normalizeAppPublicConfig(appPublic?: Partial } class RuntimeConfig { - private _connectSeq = 0; apiEndpoint: string = ''; apiPublicEndpoint: string = ''; gatewayEndpoint: string = ''; @@ -290,43 +321,6 @@ class RuntimeConfig { return Promise.resolve(); } - applySnapshot(snapshot: RuntimeConfigSnapshot): void { - const gifProviderInfo = normalizeGifProviderInfo({ - name: snapshot.gifProvider, - attributionRequired: snapshot.gifAttributionRequired, - }); - this.apiEndpoint = snapshot.apiEndpoint; - this.apiPublicEndpoint = snapshot.apiPublicEndpoint; - this.gatewayEndpoint = snapshot.gatewayEndpoint; - this.mediaEndpoint = snapshot.mediaEndpoint; - this.staticCdnEndpoint = snapshot.staticCdnEndpoint; - this.marketingEndpoint = snapshot.marketingEndpoint; - this.adminEndpoint = snapshot.adminEndpoint; - this.inviteEndpoint = snapshot.inviteEndpoint; - this.giftEndpoint = snapshot.giftEndpoint; - this.webAppEndpoint = snapshot.webAppEndpoint; - this.gifProvider = gifProviderInfo.name; - this.gifProviderDisplayName = gifProviderInfo.displayName; - this.gifAttributionRequired = gifProviderInfo.attributionRequired; - this.captchaProvider = snapshot.captchaProvider; - this.hcaptchaSiteKey = snapshot.hcaptchaSiteKey; - this.turnstileSiteKey = snapshot.turnstileSiteKey; - this.apiCodeVersion = snapshot.apiCodeVersion; - this.features = { - ...DEFAULT_INSTANCE_FEATURES, - ...snapshot.features, - }; - this.sso = snapshot.sso; - this.registration = normalizeInstanceRegistration(snapshot.registration); - this.community = normalizeInstanceCommunity(snapshot.community); - this.services = normalizeInstanceServices(snapshot.services); - this.publicPushVapidKey = snapshot.publicPushVapidKey; - this.limits = this.normalizeLimits(snapshot.limits ?? this.createEmptyLimitConfig()); - this.currentDefaultsHash = null; - this.appPublic = normalizeAppPublicConfig(snapshot.appPublic); - applyDocumentBranding(this.appPublic); - } - getSnapshot(): RuntimeConfigSnapshot { return { apiEndpoint: this.apiEndpoint, @@ -388,16 +382,6 @@ class RuntimeConfig { return this.normalizeLimits(limits as LimitConfigSnapshot | undefined); } - async withSnapshot(snapshot: RuntimeConfigSnapshot, fn: () => Promise): Promise { - const before = this.getSnapshot(); - this.applySnapshot(snapshot); - try { - return await fn(); - } finally { - this.applySnapshot(before); - } - } - applyAdminInstanceConfig(config: InstanceConfigResponse): void { const appPublic = normalizeAppPublicConfig({ branding: config.app_public.branding, @@ -431,50 +415,6 @@ class RuntimeConfig { applyDocumentBranding(appPublic); } - async connectToEndpoint(input: string): Promise { - const connectId = ++this._connectSeq; - const apiEndpoint = this.normalizeEndpoint(input); - const wellKnownUrl = this.buildWellKnownUrl(apiEndpoint); - const response = await http.get(wellKnownUrl); - if (connectId !== this._connectSeq) { - return; - } - if (!response.ok) { - throw new Error(`Failed to reach ${wellKnownUrl} (${response.status})`); - } - this.updateFromInstance(response.body); - } - - private buildWellKnownUrl(apiEndpoint: string): string { - try { - const url = new URL(apiEndpoint); - const isOfficialWebApp = url.hostname === 'web.fluxer.app' || url.hostname === 'web.canary.fluxer.app'; - url.pathname = isOfficialWebApp ? '/api/.well-known/fluxer' : '/.well-known/fluxer'; - return url.toString(); - } catch { - return `${apiEndpoint.replace(/\/api\/?$/, '')}/.well-known/fluxer`; - } - } - - private normalizeEndpoint(input: string): string { - const trimmed = input['trim'](); - if (!trimmed) { - throw new Error('API endpoint is required'); - } - let candidate = trimmed; - if (candidate.startsWith('/')) { - candidate = `${window.location.origin}${candidate}`; - } else if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) { - candidate = `https://${candidate}`; - } - const url = new URL(candidate); - if (url.pathname === '' || url.pathname === '/') { - url.pathname = '/api'; - } - url.pathname = url.pathname.replace(/\/+$/, ''); - return url.toString(); - } - private updateFromInstance(instance: InstanceDiscoveryResponse): void { this.assertCodeVersion(instance.api_code_version); const apiEndpoint = instance.endpoints.api_client ?? instance.endpoints.api; @@ -687,14 +627,4 @@ class RuntimeConfig { } } -export function describeApiEndpoint(endpoint: string): string { - try { - const url = new URL(endpoint); - const path = url.pathname === '/api' ? '' : url.pathname; - return `${url.host}${path}`; - } catch { - return endpoint; - } -} - export default new RuntimeConfig(); diff --git a/fluxer_app/src/features/app/utils/PlaceholderSpecs.ts b/fluxer_app/src/features/app/utils/PlaceholderSpecs.ts deleted file mode 100644 index 09d099cca..000000000 --- a/fluxer_app/src/features/app/utils/PlaceholderSpecs.ts +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {useMemo} from 'react'; - -function hashSeed(input: string): number { - let hash = 2166136261; - for (let index = 0; index < input.length; index++) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -function createSeededRandom(seed: number): () => number { - let state = seed === 0 ? 0x9e3779b9 : seed >>> 0; - return () => { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - return (state >>> 0) / 4294967296; - }; -} - -function generatePlaceholderSpecs(options: { - compact: boolean; - messageGroups: number; - groupRange: number; - attachments: number; - fontSize: number; - groupSpacing: number; - random: () => number; -}): { - messages: Array; - attachmentSpecs: Array< - | [ - number, - { - width: number; - height: number; - }, - ] - | undefined - >; - totalHeight: number; - groupSpacing: number; -} { - const {compact, messageGroups, groupRange, attachments, fontSize, groupSpacing, random} = options; - if (attachments > messageGroups) { - throw new Error( - `generatePlaceholderSpecs: too many attachments relative to messageGroups: ${messageGroups}, ${attachments}`, - ); - } - const DEFAULT_FONT_SIZE = 16; - const scale = fontSize / DEFAULT_FONT_SIZE; - const MESSAGE_HEIGHT_COZY = 22; - const MESSAGE_HEIGHT_COMPACT = 16; - const ATTACHMENT_MARGIN = 8; - const messageHeight = compact ? MESSAGE_HEIGHT_COMPACT : MESSAGE_HEIGHT_COZY; - let totalHeight = 0; - const messageCounts: Array = []; - for (let i = 0; i < messageGroups; i++) { - const count = Math.floor(random() * groupRange) + 1; - messageCounts.push(count); - totalHeight += groupSpacing * scale; - totalHeight += messageHeight * scale; - totalHeight += (count - 1) * messageHeight * scale; - } - const availableGroupIndices = messageCounts.map((_, i) => i); - const attachmentSpecs: Array< - | [ - number, - { - width: number; - height: number; - }, - ] - | undefined - > = Array(messageGroups).fill(undefined); - for (let i = 0; i < attachments; i++) { - const randomIndex = Math.floor(random() * availableGroupIndices.length); - const groupIndex = availableGroupIndices.splice(randomIndex, 1)[0]; - const width = Math.floor(random() * (400 - 140 + 1)) + 140; - const height = Math.floor(random() * (320 - 100 + 1)) + 100; - attachmentSpecs[groupIndex] = [groupIndex, {width, height}]; - totalHeight += height + ATTACHMENT_MARGIN * scale; - } - return { - messages: messageCounts, - attachmentSpecs, - totalHeight, - groupSpacing, - }; -} - -export function usePlaceholderSpecs(compact: boolean, groupSpacing: number, fontSize: number, seedKey: string) { - return useMemo(() => { - const seed = hashSeed(`${seedKey}|${compact ? '1' : '0'}|${groupSpacing}|${fontSize}`); - const random = createSeededRandom(seed); - return compact - ? generatePlaceholderSpecs({ - compact: true, - messageGroups: 30, - groupRange: 4, - attachments: 8, - fontSize, - groupSpacing, - random, - }) - : generatePlaceholderSpecs({ - compact: false, - messageGroups: 26, - groupRange: 4, - attachments: 8, - fontSize, - groupSpacing, - random, - }); - }, [compact, fontSize, groupSpacing, seedKey]); -} diff --git a/fluxer_app/src/features/auth/components/accounts/AccountListItem.module.css b/fluxer_app/src/features/auth/components/accounts/AccountListItem.module.css index b99a23787..846cd26c1 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountListItem.module.css +++ b/fluxer_app/src/features/auth/components/accounts/AccountListItem.module.css @@ -123,11 +123,6 @@ color: color-mix(in srgb, var(--surface-interactive-selected-color) 80%, transparent); } -.instanceLabel { - font-size: 0.6875rem; - color: var(--text-primary-muted); -} - .badge { flex-shrink: 0; font-size: 0.625rem; diff --git a/fluxer_app/src/features/auth/components/accounts/AccountListItem.tsx b/fluxer_app/src/features/auth/components/accounts/AccountListItem.tsx index b0085664f..635966b06 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountListItem.tsx +++ b/fluxer_app/src/features/auth/components/accounts/AccountListItem.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import RuntimeConfig, {describeApiEndpoint} from '@app/features/app/state/RuntimeConfig'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import styles from '@app/features/auth/components/accounts/AccountListItem.module.css'; import type {Account} from '@app/features/platform/state/AuthSession'; import {MockAvatar} from '@app/features/ui/components/MockAvatar'; @@ -24,7 +24,6 @@ interface AccountListItemProps { isCurrent?: boolean; onClick?: () => void; variant?: 'default' | 'compact'; - showInstance?: boolean; badge?: ReactNode; meta?: ReactNode; } @@ -32,7 +31,7 @@ interface AccountListItemProps { export const getAccountAvatarUrl = (account: Account): string | undefined => { const avatar = account.userData?.avatar ?? null; try { - const mediaEndpoint = account.instance?.mediaEndpoint ?? RuntimeConfig.getSnapshot().mediaEndpoint; + const mediaEndpoint = RuntimeConfig.mediaEndpoint; if (mediaEndpoint) { return AvatarUtils.getUserAvatarURLWithProxy({id: account.userId, avatar}, mediaEndpoint, false) ?? undefined; } @@ -50,7 +49,6 @@ export const AccountListItem = ({ isCurrent = false, onClick, variant = 'default', - showInstance = false, badge, meta, }: AccountListItemProps) => { @@ -93,11 +91,6 @@ export const AccountListItem = ({ {meta ?? defaultMeta} - {showInstance && account.instance && ( - - {describeApiEndpoint(account.instance.apiEndpoint)} - - )}
{badge} diff --git a/fluxer_app/src/features/auth/components/accounts/AccountRow.module.css b/fluxer_app/src/features/auth/components/accounts/AccountRow.module.css index 08369ddd1..55b2c6327 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountRow.module.css +++ b/fluxer_app/src/features/auth/components/accounts/AccountRow.module.css @@ -158,29 +158,6 @@ button.clickable:active { gap: 0.4rem; } -.globeButtonCompact { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 1.2rem; - height: 1.2rem; - padding: 0; - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - border-radius: 0.25rem; - transition: - background-color 0.2s ease, - color 0.2s ease; -} - -.globeButtonCompact:hover { - background-color: var(--background-modifier-hover); - color: var(--text-primary); -} - .checkIndicator { display: flex; align-items: center; diff --git a/fluxer_app/src/features/auth/components/accounts/AccountRow.tsx b/fluxer_app/src/features/auth/components/accounts/AccountRow.tsx index 333681082..f87f86fce 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountRow.tsx +++ b/fluxer_app/src/features/auth/components/accounts/AccountRow.tsx @@ -4,14 +4,12 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo import {getAccountAvatarUrl, getAccountDisplayName} from '@app/features/auth/components/accounts/AccountListItem'; import styles from '@app/features/auth/components/accounts/AccountRow.module.css'; import type {Account} from '@app/features/platform/state/AuthSession'; -import {Logger} from '@app/features/platform/utils/AppLogger'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {MockAvatar} from '@app/features/ui/components/MockAvatar'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; -import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; -import {CaretRightIcon, CheckIcon, DotsThreeIcon, GlobeIcon} from '@phosphor-icons/react'; +import {CaretRightIcon, CheckIcon, DotsThreeIcon} from '@phosphor-icons/react'; import clsx from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; @@ -25,26 +23,6 @@ const MORE_DESCRIPTOR = msg({ message: 'More', comment: 'Short label in the authentication account row. Keep the tone plain and specific.', }); -const STANDARD_INSTANCES = new Set(['web.fluxer.app', 'web.canary.fluxer.app']); -const logger = new Logger('AccountRow'); - -function getInstanceHost(account: Account): string | null { - const endpoint = account.instance?.apiEndpoint; - if (!endpoint) { - return null; - } - try { - return new URL(endpoint).hostname; - } catch (error) { - logger.error('Failed to parse instance host:', error); - return null; - } -} - -function getInstanceEndpoint(account: Account): string | null { - return account.instance?.apiEndpoint ?? null; -} - type AccountRowVariant = 'default' | 'manage' | 'compact'; interface AccountRowProps { @@ -52,7 +30,6 @@ interface AccountRowProps { variant?: AccountRowVariant; isCurrent?: boolean; isExpired?: boolean; - showInstance?: boolean; onMenuClick?: (event: React.MouseEvent) => void; onClick?: () => void; showCaretIndicator?: boolean; @@ -66,7 +43,6 @@ export const AccountRow = observer( variant = 'default', isCurrent = false, isExpired = false, - showInstance = false, onMenuClick, onClick, showCaretIndicator = false, @@ -77,9 +53,6 @@ export const AccountRow = observer( const avatarUrl = getAccountAvatarUrl(account); const displayName = getAccountDisplayName(account, '???'); const discriminator = account.userData?.discriminator ?? '0000'; - const instanceHost = showInstance ? getInstanceHost(account) : null; - const instanceEndpoint = showInstance ? getInstanceEndpoint(account) : null; - const shouldShowInstance = typeof instanceHost === 'string' && !STANDARD_INSTANCES.has(instanceHost); const menuButtonRef = useRef(null); const isContextMenuOpen = useContextMenuHoverState(menuButtonRef, Boolean(onMenuClick)); const handleMenuClick = useCallback( @@ -126,16 +99,6 @@ export const AccountRow = observer( #{discriminator} - {shouldShowInstance && instanceEndpoint ? ( - - - - - - ) : null}
) : ( <> @@ -158,20 +121,6 @@ export const AccountRow = observer( {displayName} )} - {shouldShowInstance && instanceEndpoint ? ( - - - - - - ) : null}
{variant !== 'manage' ? ( diff --git a/fluxer_app/src/features/auth/components/accounts/AccountSelector.tsx b/fluxer_app/src/features/auth/components/accounts/AccountSelector.tsx index c6fe2c0bb..504d902c5 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountSelector.tsx +++ b/fluxer_app/src/features/auth/components/accounts/AccountSelector.tsx @@ -35,7 +35,6 @@ interface AccountSelectorProps { description?: React.ReactNode; error?: string | null; disabled?: boolean; - showInstance?: boolean; clickableRows?: boolean; addButtonLabel?: React.ReactNode; onSelectAccount: (account: Account) => void; @@ -52,7 +51,6 @@ export const AccountSelector = observer( description, error, disabled = false, - showInstance = false, clickableRows = false, addButtonLabel, onSelectAccount, @@ -165,7 +163,6 @@ export const AccountSelector = observer( variant="manage" isCurrent={isCurrent} isExpired={account.isValid === false} - showInstance={showInstance} onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined} showCaretIndicator={clickableRows} onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined} diff --git a/fluxer_app/src/features/auth/components/accounts/AccountSwitcherModal.tsx b/fluxer_app/src/features/auth/components/accounts/AccountSwitcherModal.tsx index bd966ee75..19c114ecc 100644 --- a/fluxer_app/src/features/auth/components/accounts/AccountSwitcherModal.tsx +++ b/fluxer_app/src/features/auth/components/accounts/AccountSwitcherModal.tsx @@ -16,10 +16,12 @@ import {useCallback} from 'react'; interface AccountSwitcherModalProps { redirectAfterSwitch?: string | null; redirectAfterLogin?: string | null; + switchAccount?: (userId: string) => Promise; 'data-flx'?: string; } -const AccountSwitcherModal = observer(({redirectAfterSwitch, redirectAfterLogin}: AccountSwitcherModalProps) => { +const AccountSwitcherModal = observer((props: AccountSwitcherModalProps) => { + const {redirectAfterSwitch, redirectAfterLogin, switchAccount} = props; const { accounts, currentAccount, @@ -29,7 +31,7 @@ const AccountSwitcherModal = observer(({redirectAfterSwitch, redirectAfterLogin} handleAddAccount, handleLogout, handleLogoutStoredAccount, - } = useAccountSwitcherLogic({redirectAfterSwitch, redirectAfterLogin}); + } = useAccountSwitcherLogic({redirectAfterSwitch, redirectAfterLogin, switchAccount}); const hasMultipleAccounts = accounts.length > 1; const openMenu = useCallback( (account: (typeof accounts)[number]) => (event: React.MouseEvent) => { @@ -101,7 +103,6 @@ const AccountSwitcherModal = observer(({redirectAfterSwitch, redirectAfterLogin} variant="manage" isCurrent={isCurrent} isExpired={account.isValid === false} - showInstance onClick={isCurrent ? undefined : () => handleAccountClick(account)} onMenuClick={openMenu(account)} data-flx="auth.accounts.account-switcher-modal.account-row" diff --git a/fluxer_app/src/features/auth/components/pages/LoginPage.module.css b/fluxer_app/src/features/auth/components/pages/LoginPage.module.css index e2d428a70..1fd7f61ca 100644 --- a/fluxer_app/src/features/auth/components/pages/LoginPage.module.css +++ b/fluxer_app/src/features/auth/components/pages/LoginPage.module.css @@ -97,29 +97,6 @@ text-align: center; } -.instanceToggle { - display: block; - width: fit-content; - padding: 0; - border: none; - background: none; - font-size: 0.75rem; - color: var(--text-secondary); - text-align: left; - cursor: pointer; -} - -.instanceToggle:hover { - color: var(--text-primary); - text-decoration: underline; -} - -.instanceToggle:disabled { - color: var(--text-tertiary); - cursor: not-allowed; - text-decoration: none; -} - .passkeyActions { display: flex; flex-direction: column; @@ -299,10 +276,6 @@ flex-shrink: 0; } -.instanceNotice { - margin-top: 1rem; -} - .handoffSuccess { text-align: center; } @@ -432,10 +405,3 @@ font-size: 0.875rem; color: var(--text-tertiary); } - -.instanceSection { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1.5rem; -} diff --git a/fluxer_app/src/features/auth/components/pages/RegisterPage.tsx b/fluxer_app/src/features/auth/components/pages/RegisterPage.tsx index 7d31483db..cad90ec44 100644 --- a/fluxer_app/src/features/auth/components/pages/RegisterPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/RegisterPage.tsx @@ -22,7 +22,11 @@ const RegisterPageContent = observer(function RegisterPageContent() { if (isRuntimeSsoEnforced()) { return (
- +
); } diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAccountStep.tsx b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAccountStep.tsx index 9132b5885..304e2d1dc 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAccountStep.tsx +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAccountStep.tsx @@ -94,7 +94,6 @@ export const OAuthAccountStep: React.FC = observer(({clie variant="manage" isCurrent={isCurrent} isExpired={account.isValid === false} - showInstance onClick={canClick ? () => handleAccountClick(account) : undefined} data-flx="auth.o-auth-authorize-page.account-step.account-row" /> diff --git a/fluxer_app/src/features/auth/flow/AuthInstanceSelectorControl.tsx b/fluxer_app/src/features/auth/flow/AuthInstanceSelectorControl.tsx deleted file mode 100644 index e09c6d816..000000000 --- a/fluxer_app/src/features/auth/flow/AuthInstanceSelectorControl.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import RuntimeConfig, {describeApiEndpoint} from '@app/features/app/state/RuntimeConfig'; -import styles from '@app/features/auth/components/pages/LoginPage.module.css'; -import {InstanceSelector} from '@app/features/auth/flow/InstanceSelector'; -import clsx from 'clsx'; -import {observer} from 'mobx-react-lite'; -import {useEffect, useState} from 'react'; - -interface AuthInstanceSelectorControlProps { - className?: string; - dataFlx?: string; -} - -export const AuthInstanceSelectorControl = observer(function AuthInstanceSelectorControl({ - className, - dataFlx = 'auth.flow.auth-instance-selector-control.instance-selector', -}: AuthInstanceSelectorControlProps) { - const [instanceUrl, setInstanceUrl] = useState(() => describeApiEndpoint(RuntimeConfig.apiEndpoint)); - useEffect(() => { - setInstanceUrl(describeApiEndpoint(RuntimeConfig.apiEndpoint)); - }, [RuntimeConfig.apiEndpoint]); - return ( -
- -
- ); -}); diff --git a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx index 4117b8b02..9838e81cd 100644 --- a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx +++ b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx @@ -4,7 +4,6 @@ import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import {AccountSelector} from '@app/features/auth/components/accounts/AccountSelector'; import styles from '@app/features/auth/components/pages/LoginPage.module.css'; -import {AuthInstanceSelectorControl} from '@app/features/auth/flow/AuthInstanceSelectorControl'; import {AuthRouterLink} from '@app/features/auth/flow/AuthRouterLink'; import { AuthSsoPanel, @@ -275,7 +274,13 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ ); } if (authLoginStep === 'sso') { - return ; + return ( + + ); } if (authLoginStep === 'account') { return ( @@ -284,7 +289,6 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ currentAccountId={currentUserId} error={switchError} disabled={isSwitching} - showInstance clickableRows onSelectAccount={handleSelectExistingAccount} onAddAccount={handleAddAnotherAccount} @@ -312,7 +316,6 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ } return ( <> - {extraTopContent} {showTitle ? (

diff --git a/fluxer_app/src/features/auth/flow/AuthMinimalRegisterFormCore.tsx b/fluxer_app/src/features/auth/flow/AuthMinimalRegisterFormCore.tsx index e89b80d9a..207678600 100644 --- a/fluxer_app/src/features/auth/flow/AuthMinimalRegisterFormCore.tsx +++ b/fluxer_app/src/features/auth/flow/AuthMinimalRegisterFormCore.tsx @@ -244,7 +244,12 @@ export const AuthMinimalRegisterFormCore = observer(function AuthMinimalRegister /> ) : null} {extraContent} - + ) : null} {extraContent} - + - {extraTopContent} {showTitle ? (

diff --git a/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.module.css b/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.module.css index 381eb0f03..0340bc677 100644 --- a/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.module.css +++ b/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.module.css @@ -4,70 +4,6 @@ gap: 1.25rem; } -.codeInputSection { - padding: 1rem 0; -} - -.inputHelper { - font-size: 0.8125rem; - color: var(--text-primary-muted); - margin: 0.5rem 0 0; - line-height: 1.4; -} - -.instanceLink { - display: block; - width: 100%; - background: none; - border: none; - color: var(--text-primary-muted); - font-size: 0.8125rem; - cursor: pointer; - padding: 0; - text-align: center; -} - -.instanceLink:hover { - color: var(--text-primary-muted); - text-decoration: underline; -} - .prefillHint { text-align: center; } - -.instanceBadge { - display: flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - padding: 0.625rem 0.875rem; - border-radius: 0.375rem; -} - -.instanceBadgeIcon { - color: var(--status-online); - flex-shrink: 0; -} - -.instanceBadgeText { - font-size: 0.8125rem; - color: var(--status-online); - font-weight: 500; -} - -.instanceBadgeClear { - background: none; - border: none; - color: var(--text-primary-muted); - font-size: 0.75rem; - cursor: pointer; - padding: 0.125rem 0.375rem; - margin-left: 0.25rem; - border-radius: 0.25rem; - transition: color 0.15s ease; -} - -.instanceBadgeClear:hover { - color: var(--text-primary); -} diff --git a/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.tsx b/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.tsx index 368b9016f..3e7158dce 100644 --- a/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.tsx +++ b/fluxer_app/src/features/auth/flow/BrowserLoginHandoffModal.tsx @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as Modal from '@app/features/app/components/dialogs/Modal'; -import {EXAMPLE_DOMAIN, EXAMPLE_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import styles from '@app/features/auth/flow/BrowserLoginHandoffModal.module.css'; @@ -11,261 +10,153 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; -import {Input} from '@app/features/ui/components/form/FormInput'; -import {getElectronAPI, openExternalUrl} from '@app/features/ui/utils/NativeUtils'; +import {openExternalUrl} from '@app/features/ui/utils/NativeUtils'; import * as FormUtils from '@app/lib/forms'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {ArrowSquareOutIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; -import type React from 'react'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; -const THE_URL_OF_THE_INSTANCE_YOU_WANT_TO_DESCRIPTOR = msg({ - message: 'The URL of the {productName} instance you want to sign in to.', - comment: 'Browser login handoff modal field hint for the instance URL input. Product name is interpolated.', -}); -const INVALID_INSTANCE_URL_TRY_SOMETHING_LIKE_OR_DESCRIPTOR = msg({ - message: 'Invalid instance URL. Try something like "{exampleDomain}" or "{exampleUrl}".', - comment: - 'Browser login handoff modal form validation error for an unparseable instance URL. Example domain and URL are interpolated.', -}); const ADD_ACCOUNT_DESCRIPTOR = msg({ message: 'Add account', comment: 'Short label in the authentication browser login handoff modal. Keep the tone plain and specific.', }); -const INSTANCE_URL_DESCRIPTOR = msg({ - message: 'Instance URL', - comment: 'Short label in the authentication browser login handoff modal. Keep the tone plain and specific.', -}); interface BrowserLoginHandoffModalProps { onSuccess: (payload: LoginSuccessPayload) => Promise; - targetWebAppUrl?: string; prefillEmail?: string; } const POLL_INTERVAL_MS = 2000; -function normalizeInstanceOrigin(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error('Instance URL is required'); - } - const candidate = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; - const url = new URL(candidate); - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - throw new Error('Instance URL must use http or https'); - } - return url.origin; -} - -const BrowserLoginHandoffModal = observer( - ({onSuccess, targetWebAppUrl, prefillEmail}: BrowserLoginHandoffModalProps) => { - const {i18n} = useLingui(); - const electronApi = getElectronAPI(); - const switchInstanceUrl = electronApi?.switchInstanceUrl; - const canSwitchInstanceUrl = typeof switchInstanceUrl === 'function'; - const currentWebAppUrl = RuntimeConfig.webAppBaseUrl; - const [instanceUrl, setInstanceUrl] = useState(() => targetWebAppUrl ?? currentWebAppUrl); - const [instanceUrlError, setInstanceUrlError] = useState(null); - const [handoffCode, setHandoffCode] = useState(null); - const [handoffExpiresAt, setHandoffExpiresAt] = useState(null); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(null); - const pollingRef = useRef(false); - const completedRef = useRef(false); - const instanceUrlHelper = useMemo( - () => - canSwitchInstanceUrl - ? i18n._(THE_URL_OF_THE_INSTANCE_YOU_WANT_TO_DESCRIPTOR, {productName: PRODUCT_NAME}) - : null, - [canSwitchInstanceUrl, i18n.locale], - ); - const generateCode = useCallback(async () => { - setIsGenerating(true); - setError(null); - setHandoffCode(null); - setHandoffExpiresAt(null); +const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail}: BrowserLoginHandoffModalProps) => { + const {i18n} = useLingui(); + const currentWebAppUrl = RuntimeConfig.webAppBaseUrl; + const [handoffCode, setHandoffCode] = useState(null); + const [handoffExpiresAt, setHandoffExpiresAt] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const pollingRef = useRef(false); + const completedRef = useRef(false); + const generateCode = useCallback(async () => { + setIsGenerating(true); + setError(null); + setHandoffCode(null); + setHandoffExpiresAt(null); + try { + const result = await AuthenticationCommands.initiateDesktopHandoff(); + setHandoffCode(result.code); + setHandoffExpiresAt(result.expires_at); + } catch (e) { + setError(FormUtils.extractErrorMessage(i18n, e)); + } finally { + setIsGenerating(false); + } + }, [i18n]); + useEffect(() => { + void generateCode(); + }, [generateCode]); + useEffect(() => { + if (!handoffCode || completedRef.current) return; + pollingRef.current = true; + const timer = setInterval(async () => { + if (!pollingRef.current) return; try { - const result = await AuthenticationCommands.initiateDesktopHandoff(); - setHandoffCode(result.code); - setHandoffExpiresAt(result.expires_at); - } catch (e) { - setError(FormUtils.extractErrorMessage(i18n, e)); - } finally { - setIsGenerating(false); - } - }, [i18n]); - useEffect(() => { - void generateCode(); - }, [generateCode]); - useEffect(() => { - if (!handoffCode || completedRef.current) return; - pollingRef.current = true; - const timer = setInterval(async () => { - if (!pollingRef.current) return; - try { - const result = await AuthenticationCommands.pollDesktopHandoffStatus(handoffCode); - if (result.status === 'completed' && result.token && result.user_id) { - pollingRef.current = false; - completedRef.current = true; - const userData = AuthenticationCommands.authResponseUserToUserData(result.user); - await onSuccess({ - token: result.token, - userId: result.user_id, - ...(userData ? {userData} : {}), - }); - ModalCommands.pop(); - } else if (result.status === 'expired') { - pollingRef.current = false; - } - } catch { + const result = await AuthenticationCommands.pollDesktopHandoffStatus(handoffCode); + if (result.status === 'completed' && result.token && result.user_id) { + pollingRef.current = false; + completedRef.current = true; + const userData = AuthenticationCommands.authResponseUserToUserData(result.user); + await onSuccess({ + token: result.token, + userId: result.user_id, + ...(userData ? {userData} : {}), + }); + ModalCommands.pop(); + } else if (result.status === 'expired') { pollingRef.current = false; } - }, POLL_INTERVAL_MS); - return () => { + } catch { pollingRef.current = false; - clearInterval(timer); - }; - }, [handoffCode, onSuccess]); - const handleInstanceUrlChange = useCallback((e: React.ChangeEvent) => { - setInstanceUrl(e.target.value); - setInstanceUrlError(null); - }, []); - const handleOpenBrowser = useCallback(async () => { - const fallbackUrl = targetWebAppUrl || currentWebAppUrl; - let baseUrl = fallbackUrl; - if (canSwitchInstanceUrl && instanceUrl.trim()) { - try { - baseUrl = normalizeInstanceOrigin(instanceUrl); - } catch { - setInstanceUrlError( - i18n._(INVALID_INSTANCE_URL_TRY_SOMETHING_LIKE_OR_DESCRIPTOR, { - exampleDomain: EXAMPLE_DOMAIN, - exampleUrl: EXAMPLE_URL, - }), - ); - return; - } - if (baseUrl !== window.location.origin && handoffCode) { - try { - await switchInstanceUrl({ - instanceUrl: baseUrl, - desktopHandoffCode: handoffCode, - }); - } catch (switchError) { - const detail = switchError instanceof Error ? switchError.message : String(switchError); - setInstanceUrlError(detail); - } - return; - } } - const loginUrl = new URL('/login', baseUrl); - loginUrl.searchParams.set('handoff', '1'); - if (prefillEmail) { - loginUrl.searchParams.set('email', prefillEmail); - } - await openExternalUrl(loginUrl.toString()); - }, [ - canSwitchInstanceUrl, - currentWebAppUrl, - handoffCode, - i18n, - instanceUrl, - prefillEmail, - switchInstanceUrl, - targetWebAppUrl, - ]); - return ( - - - - - - Open your browser, sign in, then enter the code below to link your account. + }, POLL_INTERVAL_MS); + return () => { + pollingRef.current = false; + clearInterval(timer); + }; + }, [handoffCode, onSuccess]); + const handleOpenBrowser = useCallback(async () => { + const loginUrl = new URL('/login', currentWebAppUrl); + loginUrl.searchParams.set('handoff', '1'); + if (prefillEmail) { + loginUrl.searchParams.set('email', prefillEmail); + } + await openExternalUrl(loginUrl.toString()); + }, [currentWebAppUrl, prefillEmail]); + return ( + + + + + + Open your browser, sign in, then enter the code below to link your account. + + + {prefillEmail ? ( + + We will prefill {prefillEmail} once browser sign-in opens. - {canSwitchInstanceUrl ? ( -
- - {instanceUrlHelper} -

- ) : null - } - data-flx="auth.flow.browser-login-handoff-modal.input.instance-url-change" - /> -
- ) : null} - - {prefillEmail ? ( - - We will prefill {prefillEmail} once browser sign-in opens. - - ) : null} -
-
- - - - -
- ); - }, -); + ) : null} +
+
+ + + + +
+ ); +}); export function showBrowserLoginHandoffModal( onSuccess: (payload: LoginSuccessPayload) => Promise, - targetWebAppUrl?: string, prefillEmail?: string, ): void { ModalCommands.push( @@ -274,7 +165,6 @@ export function showBrowserLoginHandoffModal( onSuccess={async (payload) => { await onSuccess(payload); }} - targetWebAppUrl={targetWebAppUrl} prefillEmail={prefillEmail} data-flx="auth.flow.browser-login-handoff-modal.show-browser-login-handoff-modal.browser-login-handoff-modal" /> diff --git a/fluxer_app/src/features/auth/flow/DesktopHandoffAccountSelector.tsx b/fluxer_app/src/features/auth/flow/DesktopHandoffAccountSelector.tsx index 03bcb7583..d7fb500b1 100644 --- a/fluxer_app/src/features/auth/flow/DesktopHandoffAccountSelector.tsx +++ b/fluxer_app/src/features/auth/flow/DesktopHandoffAccountSelector.tsx @@ -69,7 +69,6 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel description={Select the account you want to sign in with on the desktop app.} disabled={isLoading} error={error} - showInstance clickableRows onSelectAccount={handleSelectAccount} onAddAccount={onSelectNewAccount} diff --git a/fluxer_app/src/features/auth/flow/InstanceSelector.module.css b/fluxer_app/src/features/auth/flow/InstanceSelector.module.css deleted file mode 100644 index 1a428df1f..000000000 --- a/fluxer_app/src/features/auth/flow/InstanceSelector.module.css +++ /dev/null @@ -1,174 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -.container { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; -} - -.inputContainer { - position: relative; - width: 100%; -} - -.inputActions { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.statusSpinner { - width: 1.125rem; - height: 1.125rem; -} - -.statusSuccess { - color: var(--status-online); -} - -.statusError { - color: var(--status-danger); -} - -.dropdownToggle { - display: flex; - align-items: center; - justify-content: center; - padding: 0.25rem; - background: none; - border: none; - cursor: pointer; - color: var(--text-tertiary); - border-radius: 0.25rem; - transition: - color 0.15s ease, - background-color 0.15s ease; -} - -.dropdownToggle:hover:not(:disabled) { - color: var(--text-primary); - background-color: var(--background-modifier-hover); -} - -.dropdownToggle:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.caretIcon { - transition: transform 0.2s ease; -} - -.caretIconOpen { - transform: rotate(180deg); -} - -.dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 100; - margin-top: 0.25rem; - background-color: var(--background-secondary); - border: 0.0625rem solid var(--background-modifier-accent); - border-radius: 0.5rem; - box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.2); - overflow: hidden; -} - -.dropdownHeader { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - font-weight: 600; - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 0.0625rem solid var(--background-modifier-accent); -} - -.dropdownList { - list-style: none; - margin: 0; - padding: 0.25rem; -} - -.dropdownItem { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.5rem; - background: none; - border: none; - border-radius: 0.25rem; - cursor: pointer; - text-align: left; - color: var(--text-primary); - transition: background-color 0.15s ease; -} - -.dropdownItem:hover { - background-color: var(--background-modifier-hover); -} - -.instanceIcon { - flex-shrink: 0; - color: var(--text-tertiary); -} - -.instanceDomain { - flex: 1; - font-size: 0.875rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.instanceName { - font-size: 0.75rem; - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.removeButton { - display: flex; - align-items: center; - justify-content: center; - padding: 0.25rem; - background: none; - border: none; - cursor: pointer; - color: var(--text-tertiary); - border-radius: 0.25rem; - opacity: 0; - transition: - opacity 0.15s ease, - color 0.15s ease, - background-color 0.15s ease; -} - -.dropdownItem:hover .removeButton { - opacity: 1; -} - -.removeButton:hover { - color: var(--status-danger); - background-color: var(--background-modifier-hover); -} - -.errorMessage { - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - background-color: hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.1); - border: 0.0625rem solid hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.2); - font-size: 0.8125rem; - color: var(--status-danger); -} - -.connectButton { - align-self: flex-start; -} diff --git a/fluxer_app/src/features/auth/flow/InstanceSelector.tsx b/fluxer_app/src/features/auth/flow/InstanceSelector.tsx deleted file mode 100644 index 630de558e..000000000 --- a/fluxer_app/src/features/auth/flow/InstanceSelector.tsx +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {EXAMPLE_INSTANCE_DOMAIN} from '@app/features/app/config/I18nDisplayConstants'; -import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; -import styles from '@app/features/auth/flow/InstanceSelector.module.css'; -import AppStorage from '@app/features/platform/state/PersistentStorage'; -import {remFromPx} from '@app/features/theme/layout/RemFromPx'; -import {Button} from '@app/features/ui/button/Button'; -import {Input} from '@app/features/ui/components/form/FormInput'; -import {Spinner} from '@app/features/ui/components/Spinner'; -import {msg} from '@lingui/core/macro'; -import {Trans, useLingui} from '@lingui/react/macro'; -import {CaretDownIcon, CheckCircleIcon, GlobeIcon, TrashIcon, WarningCircleIcon} from '@phosphor-icons/react'; -import {clsx} from 'clsx'; -import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; - -const FAILED_TO_CONNECT_TO_INSTANCE_DESCRIPTOR = msg({ - message: 'Failed to connect to instance', - comment: 'Short label in the authentication instance selector. Keep the tone plain and specific.', -}); -const ENTER_INSTANCE_URL_E_G_DESCRIPTOR = msg({ - message: 'Enter instance URL (e.g. {exampleInstanceDomain})', - comment: 'Instance selector text input placeholder. Example instance domain is interpolated.', -}); -const SHOW_RECENT_INSTANCES_DESCRIPTOR = msg({ - message: 'Show recent instances', - comment: 'Short label in the authentication instance selector. Keep the tone plain and specific.', -}); -const INSTANCE_URL_DESCRIPTOR = msg({ - message: 'Instance URL', - comment: 'Short label in the authentication instance selector. Keep the tone plain and specific.', -}); -const REMOVE_FROM_RECENT_INSTANCES_DESCRIPTOR = msg({ - message: 'Remove {domain} from recent instances', - comment: - 'Short label in the authentication instance selector. Preserve {domain}; it is inserted by code. Keep the tone plain and specific.', -}); -const RECENT_INSTANCES_KEY = 'recent_instances'; -const MAX_RECENT_INSTANCES = 5; - -export type InstanceDiscoveryStatus = 'idle' | 'discovering' | 'success' | 'error'; - -export interface InstanceInfo { - domain: string; - name?: string; - lastUsed: number; -} - -interface InstanceSelectorProps { - value: string; - onChange: (value: string) => void; - onInstanceDiscovered?: (domain: string) => void; - onDiscoveryStatusChange?: (status: InstanceDiscoveryStatus) => void; - disabled?: boolean; - className?: string; -} - -function loadRecentInstances(): Array { - const stored = AppStorage.getJSON>(RECENT_INSTANCES_KEY); - if (!stored || !Array.isArray(stored)) { - return []; - } - return stored.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, MAX_RECENT_INSTANCES); -} - -function saveRecentInstance(domain: string, name?: string): void { - const recent = loadRecentInstances(); - const normalizedDomain = domain.toLowerCase().trim(); - const existingIndex = recent.findIndex((inst) => inst.domain.toLowerCase() === normalizedDomain); - if (existingIndex !== -1) { - recent.splice(existingIndex, 1); - } - recent.unshift({ - domain: normalizedDomain, - name, - lastUsed: Date.now(), - }); - AppStorage.setJSON(RECENT_INSTANCES_KEY, recent.slice(0, MAX_RECENT_INSTANCES)); -} - -function removeRecentInstance(domain: string): void { - const recent = loadRecentInstances(); - const normalizedDomain = domain.toLowerCase().trim(); - const filtered = recent.filter((inst) => inst.domain.toLowerCase() !== normalizedDomain); - AppStorage.setJSON(RECENT_INSTANCES_KEY, filtered); -} - -export const InstanceSelector = observer(function InstanceSelector({ - value, - onChange, - onInstanceDiscovered, - onDiscoveryStatusChange, - disabled = false, - className, -}: InstanceSelectorProps) { - const {i18n} = useLingui(); - const [discoveryStatus, setDiscoveryStatus] = useState('idle'); - const [discoveryError, setDiscoveryError] = useState(null); - const [recentInstances, setRecentInstances] = useState>(() => loadRecentInstances()); - const [showDropdown, setShowDropdown] = useState(false); - const inputRef = useRef(null); - const dropdownRef = useRef(null); - const discoveryTimeoutRef = useRef(null); - const updateDiscoveryStatus = useCallback( - (status: InstanceDiscoveryStatus) => { - setDiscoveryStatus(status); - onDiscoveryStatusChange?.(status); - }, - [onDiscoveryStatusChange], - ); - const discoverInstance = useCallback( - async (instanceUrl: string) => { - if (!instanceUrl.trim()) { - updateDiscoveryStatus('idle'); - setDiscoveryError(null); - return; - } - updateDiscoveryStatus('discovering'); - setDiscoveryError(null); - try { - await RuntimeConfig.connectToEndpoint(instanceUrl); - updateDiscoveryStatus('success'); - saveRecentInstance(instanceUrl); - setRecentInstances(loadRecentInstances()); - onInstanceDiscovered?.(instanceUrl); - } catch (error) { - updateDiscoveryStatus('error'); - const errorMessage = error instanceof Error ? error.message : i18n._(FAILED_TO_CONNECT_TO_INSTANCE_DESCRIPTOR); - setDiscoveryError(errorMessage); - } - }, - [onInstanceDiscovered, updateDiscoveryStatus, i18n], - ); - const handleInputChange = useCallback( - (event: React.ChangeEvent) => { - const newValue = event.target.value; - onChange(newValue); - updateDiscoveryStatus('idle'); - setDiscoveryError(null); - if (discoveryTimeoutRef.current) { - clearTimeout(discoveryTimeoutRef.current); - } - if (newValue.trim()) { - discoveryTimeoutRef.current = setTimeout(() => { - discoverInstance(newValue); - }, 800); - } - }, - [onChange, discoverInstance, updateDiscoveryStatus], - ); - const handleSelectRecent = useCallback( - (instance: InstanceInfo) => { - onChange(instance.domain); - setShowDropdown(false); - discoverInstance(instance.domain); - }, - [onChange, discoverInstance], - ); - const handleRemoveRecent = useCallback((event: React.MouseEvent, domain: string) => { - event.stopPropagation(); - removeRecentInstance(domain); - setRecentInstances(loadRecentInstances()); - }, []); - const handleConnectClick = useCallback(() => { - if (value.trim()) { - discoverInstance(value); - } - }, [value, discoverInstance]); - const handleDropdownToggle = useCallback(() => { - if (recentInstances.length > 0 && !disabled) { - setShowDropdown((prev) => !prev); - } - }, [recentInstances.length, disabled]); - const handleInputFocus = useCallback(() => { - if (recentInstances.length > 0 && !value.trim()) { - setShowDropdown(true); - } - }, [recentInstances.length, value]); - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - inputRef.current && - !inputRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - useEffect(() => { - return () => { - if (discoveryTimeoutRef.current) { - clearTimeout(discoveryTimeoutRef.current); - } - }; - }, []); - const statusIcon = useMemo(() => { - if (discoveryStatus === 'discovering') { - return ( - - ); - } - if (discoveryStatus === 'success') { - return ( - - ); - } - if (discoveryStatus === 'error') { - return ( - - ); - } - return null; - }, [discoveryStatus]); - const placeholder = i18n._(ENTER_INSTANCE_URL_E_G_DESCRIPTOR, {exampleInstanceDomain: EXAMPLE_INSTANCE_DOMAIN}); - return ( -
-
- - } - rightElement={ -
- {statusIcon} - {recentInstances.length > 0 && ( - - )} -
- } - aria-label={i18n._(INSTANCE_URL_DESCRIPTOR)} - aria-describedby={discoveryError ? 'instance-error' : undefined} - data-flx="auth.flow.instance-selector.input" - /> - {showDropdown && recentInstances.length > 0 && ( -
-
- Recent instances -
-
    - {recentInstances.map((instance) => ( -
  • - - -
  • - ))} -
-
- )} -
- {discoveryError && ( -
- {discoveryError} -
- )} - {discoveryStatus !== 'success' && value.trim() && ( - - )} -
- ); -}); diff --git a/fluxer_app/src/features/auth/flow/InviteHeader.tsx b/fluxer_app/src/features/auth/flow/InviteHeader.tsx index 3ca32ba3e..6d1d86e28 100644 --- a/fluxer_app/src/features/auth/flow/InviteHeader.tsx +++ b/fluxer_app/src/features/auth/flow/InviteHeader.tsx @@ -62,6 +62,7 @@ interface PreviewGuildInviteHeaderProps { memberCount: number; previewIconUrl?: string | null; previewName?: string | null; + eyebrowText?: string | null; } function formatInviteCount(value: number): string { @@ -252,6 +253,7 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead memberCount, previewIconUrl, previewName, + eyebrowText, }: PreviewGuildInviteHeaderProps) { const displayName = previewName ?? guildName; const formattedPresenceCount = formatInviteCount(presenceCount); @@ -294,7 +296,7 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead data-flx="auth.flow.invite-header.preview-guild-invite-header.entity-details" >

- You've been invited to join + {eyebrowText ?? You've been invited to join}

{ await SessionManager.initialize(); - const account = SessionManager.accounts.find((a) => a.userId === userId); - if (!account) { - throw new Error(`No stored data found for account ${userId}`); - } - const ok = await SessionManager.validateToken(account.token, account.instance); + const account = SessionManager.requireAccountOnCurrentInstance(userId); + const ok = await SessionManager.validateToken(account.token); if (!ok) { SessionManager.markAccountInvalid(userId); throw new SessionExpiredError(); @@ -104,8 +101,12 @@ class AccountManager { } async switchToAccount(userId: string, redirectPath: string | null = Routes.ME): Promise { - if (userId !== SessionManager.userId && SessionManager.canSwitchAccount() && this.accounts.has(userId)) { - SessionManager.prepareForAccountTransition('account-switch'); + if (userId === SessionManager.userId) { + return; + } + SessionManager.requireAccountOnCurrentInstance(userId); + if (!SessionManager.canSwitchAccount()) { + throw new Error(`Cannot switch from state: ${SessionManager.state}`); } if (this.shouldManagePushSubscriptions()) { await PushSubscriptionService.unregisterAllPushSubscriptions(); diff --git a/fluxer_app/src/features/auth/state/AccountStorage.ts b/fluxer_app/src/features/auth/state/AccountStorage.ts index 192a766c0..f2d104f73 100644 --- a/fluxer_app/src/features/auth/state/AccountStorage.ts +++ b/fluxer_app/src/features/auth/state/AccountStorage.ts @@ -4,6 +4,7 @@ import { normalizeAppPublicConfig, normalizeInstanceRegistration, type RuntimeConfigSnapshot, + runtimeConfigSnapshotsAreSameInstance, } from '@app/features/app/state/RuntimeConfig'; import {getProtectedIndexedDB, getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage'; import {Logger} from '@app/features/platform/utils/AppLogger'; @@ -54,6 +55,13 @@ export interface StoredAccount { isValid?: boolean; } +export class StoredAccountInstanceMismatchError extends Error { + constructor(userId: string) { + super(`Stored account ${userId} does not belong to the current instance`); + this.name = 'StoredAccountInstanceMismatchError'; + } +} + type IdbOpenState = 'idle' | 'opening' | 'open' | 'failed'; const MANAGED_KEY_EXACT: ReadonlySet = new Set(['token', 'userId', 'runtimeConfig', 'AccountManager', 'token']); @@ -396,7 +404,7 @@ class AccountStorage { } } - async restoreAccountData(userId: string): Promise { + async restoreAccountData(userId: string, expectedInstance: RuntimeConfigSnapshot): Promise { if (!userId) { return null; } @@ -406,6 +414,9 @@ class AccountStorage { return null; } const normalized = this.normalizeRecord(record); + if (!runtimeConfigSnapshotsAreSameInstance(normalized.instance, expectedInstance)) { + throw new StoredAccountInstanceMismatchError(userId); + } await this.enqueueStorageSwap(async () => { await this.applyManagedStorageSnapshot(normalized.localStorageData ?? {}); }); diff --git a/fluxer_app/src/features/auth/utils/AccountSwitcherModalUtils.tsx b/fluxer_app/src/features/auth/utils/AccountSwitcherModalUtils.tsx index 3b248c4a0..1dd6cd7cd 100644 --- a/fluxer_app/src/features/auth/utils/AccountSwitcherModalUtils.tsx +++ b/fluxer_app/src/features/auth/utils/AccountSwitcherModalUtils.tsx @@ -4,7 +4,6 @@ import i18n from '@app/app/I18n'; import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {Endpoints} from '@app/features/app/constants/Endpoints'; -import {describeApiEndpoint} from '@app/features/app/state/RuntimeConfig'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import {getAccountAvatarUrl, getAccountDisplayName} from '@app/features/auth/components/accounts/AccountListItem'; import {showBrowserLoginHandoffModal} from '@app/features/auth/flow/BrowserLoginHandoffModal'; @@ -19,7 +18,6 @@ import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; -import {DEFAULT_API_VERSION} from '@fluxer/constants/src/AppConstants'; import type {MessageDescriptor} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {Trans} from '@lingui/react/macro'; @@ -52,9 +50,7 @@ function showAccountSwitcherErrorModal(message: MessageDescriptor): void { export interface AccountSwitcherLogic { currentAccount: Account | null; accounts: Array; - secondaryAccounts: Array; isBusy: boolean; - currentInstanceLabel: string | null; handleSwitchAccount: (userId: string) => Promise; handleLogout: () => Promise; handleLogoutStoredAccount: (account: Account) => Promise; @@ -67,40 +63,37 @@ export interface AccountSwitcherLogic { export interface AccountSwitcherLogicOptions { redirectAfterSwitch?: string | null; redirectAfterLogin?: string | null; -} - -function buildAccountLogoutUrl(account: Account): string { - const endpoint = account.instance?.apiEndpoint?.replace(/\/+$/, ''); - if (!endpoint) { - return Endpoints.AUTH_LOGOUT; - } - return `${endpoint}/v${DEFAULT_API_VERSION}${Endpoints.AUTH_LOGOUT}`; + switchAccount?: (userId: string) => Promise; } export function useAccountSwitcherLogic(options: AccountSwitcherLogicOptions = {}): AccountSwitcherLogic { - const {redirectAfterSwitch = undefined, redirectAfterLogin = undefined} = options; + const {redirectAfterSwitch = undefined, redirectAfterLogin = undefined, switchAccount} = options; const currentAccount = AccountManager.currentAccount; const accounts = AccountManager.getAllAccounts(); const isBusy = AccountManager.isSwitching || AccountManager.isLoading; - const secondaryAccounts = accounts.filter((a) => a.userId !== currentAccount?.userId); - const currentInstanceLabel = currentAccount?.instance - ? describeApiEndpoint(currentAccount.instance.apiEndpoint) - : null; const handleLoginSuccess = async (payload: LoginSuccessPayload): Promise => { await AuthenticationCommands.completeLogin(payload, {redirectPath: redirectAfterLogin}); ModalCommands.popAll(); }; const handleReLogin = (userId: string): void => { const account = AccountManager.accounts.get(userId); - const email = account?.userData?.email ?? undefined; - showBrowserLoginHandoffModal(handleLoginSuccess, undefined, email); + if (!account) { + showAccountSwitcherErrorModal(WE_COULDN_T_SWITCH_ACCOUNTS_PLEASE_TRY_AGAIN_DESCRIPTOR); + return; + } + const email = account.userData?.email ?? undefined; + showBrowserLoginHandoffModal(handleLoginSuccess, email); }; const handleSwitchAccount = async (userId: string): Promise => { if (isBusy) { return; } try { - await AccountManager.switchToAccount(userId, redirectAfterSwitch); + if (switchAccount) { + await switchAccount(userId); + } else { + await AccountManager.switchToAccount(userId, redirectAfterSwitch); + } ModalCommands.pop(); } catch (error) { if (error instanceof SessionExpiredError) { @@ -128,7 +121,7 @@ export function useAccountSwitcherLogic(options: AccountSwitcherLogicOptions = { return; } try { - await http.post(buildAccountLogoutUrl(account), { + await http.post(Endpoints.AUTH_LOGOUT, { headers: {Authorization: account.token}, timeoutMs: 5000, retries: 0, @@ -156,9 +149,7 @@ export function useAccountSwitcherLogic(options: AccountSwitcherLogicOptions = { return { currentAccount, accounts, - secondaryAccounts, isBusy, - currentInstanceLabel, handleSwitchAccount, handleLogout, handleLogoutStoredAccount, diff --git a/fluxer_app/src/features/channel/components/Autocomplete.tsx b/fluxer_app/src/features/channel/components/Autocomplete.tsx index 7a00aadb6..27c0f6778 100644 --- a/fluxer_app/src/features/channel/components/Autocomplete.tsx +++ b/fluxer_app/src/features/channel/components/Autocomplete.tsx @@ -420,6 +420,7 @@ export const Autocomplete = observer( onMouseLeave={handleMouseLeave} rowRefs={rowRefs} getOptionId={getOptionId} + data-flx="channel.autocomplete.autocomplete-command-choice.select" /> ) : type === 'commandOptionalAdd' ? ( ) : type === 'meme' ? ( @{option.role.name}} + name={ + + @{option.role.name} + + } description={i18n._(NOTIFY_USERS_WITH_THIS_ROLE_WHO_HAVE_PERMISSION_DESCRIPTOR)} isKeyboardSelected={currentIndex === keyboardFocusIndex} isHovered={currentIndex === hoverIndex} diff --git a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx index ae1684a5d..8d7f8a140 100644 --- a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx +++ b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx @@ -5,7 +5,7 @@ import {computeHorizontalDropPosition} from '@app/features/app/components/layout import { type AttachmentDragItem, type AttachmentDropResult, - DND_TYPES, + DragItemType, } from '@app/features/app/components/layout/types/DndTypes'; import styles from '@app/features/channel/components/ChannelAttachmentArea.module.css'; import EmbedVideo from '@app/features/channel/components/embeds/media/EmbedVideo'; @@ -213,7 +213,7 @@ const SortableAttachmentItem = observer( const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0; const dragItemData = useMemo( () => ({ - type: DND_TYPES.ATTACHMENT, + type: DragItemType.ATTACHMENT, id: attachment.id, channelId, }), @@ -244,7 +244,7 @@ const SortableAttachmentItem = observer( }, []); const [{isDragging}, dragRef, preview] = useDrag( () => ({ - type: DND_TYPES.ATTACHMENT, + type: DragItemType.ATTACHMENT, item: () => { onDragStateChange?.(dragItemData); return dragItemData; @@ -260,7 +260,7 @@ const SortableAttachmentItem = observer( ); const [{isOver}, dropRef] = useDrop( () => ({ - accept: DND_TYPES.ATTACHMENT, + accept: DragItemType.ATTACHMENT, canDrop: (item: AttachmentDragItem) => item.id !== attachment.id, hover: (item: AttachmentDragItem, monitor) => { if (item.id === attachment.id) { diff --git a/fluxer_app/src/features/channel/components/ChannelChatLayout.module.css b/fluxer_app/src/features/channel/components/ChannelChatLayout.module.css index edd2557c8..e0d3626cf 100644 --- a/fluxer_app/src/features/channel/components/ChannelChatLayout.module.css +++ b/fluxer_app/src/features/channel/components/ChannelChatLayout.module.css @@ -1,15 +1,6 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ .container { - --composer-surface-color: var(--background-secondary-lighter); - --composer-status-line-height: 1.125rem; - --composer-status-safe-gap: 0.5rem; - --composer-status-safe-area: calc( - var(--composer-status-line-height) + - var(--composer-status-safe-gap) + - var(--composer-status-safe-gap) - ); - --messages-bottom-clearance: max(0px, calc(var(--composer-status-safe-area) - var(--composer-box-inset, 0px))); display: flex; flex-direction: column; height: 100%; diff --git a/fluxer_app/src/features/channel/components/ChannelComposerStatusRail.tsx b/fluxer_app/src/features/channel/components/ChannelComposerStatusRail.tsx deleted file mode 100644 index f4382e07e..000000000 --- a/fluxer_app/src/features/channel/components/ChannelComposerStatusRail.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {EditBar} from '@app/features/channel/components/ChannelEditBar'; -import {ReplyBar} from '@app/features/channel/components/ChannelReplyBar'; -import {SlowmodeIndicator} from '@app/features/channel/components/SlowmodeIndicator'; -import {TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers'; -import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css'; -import type {Channel} from '@app/features/channel/models/Channel'; -import type {Message} from '@app/features/messaging/models/MessagingMessage'; -import {observer} from 'mobx-react-lite'; -import type React from 'react'; - -interface ChannelComposerStatusRailProps { - channel: Channel; - editingMessage: Message | null; - referencedMessage: Message | null; - shouldReplyMention: boolean; - setShouldReplyMention: (mentioning: boolean) => void; - autocompleteVisible: boolean; - showTypingStatus: boolean; - showSlowmodeStatus: boolean; - slowmodeEnabled: boolean; - slowmodeRemaining: number; - slowmodeImmune: boolean; - mobile: boolean; - onCancelEdit: () => void; -} - -export const ChannelComposerStatusRail = observer(function ChannelComposerStatusRail({ - channel, - editingMessage, - referencedMessage, - shouldReplyMention, - setShouldReplyMention, - autocompleteVisible, - showTypingStatus, - showSlowmodeStatus, - slowmodeEnabled, - slowmodeRemaining, - slowmodeImmune, - mobile, - onCancelEdit, -}: ChannelComposerStatusRailProps) { - const presentableTypingUsers = usePresentableTypingUsers(channel); - const mobileEditVisible = editingMessage !== null && mobile; - const replyVisible = !mobileEditVisible && referencedMessage !== null; - const leadingContentVisible = mobileEditVisible || replyVisible; - const typingVisible = showTypingStatus && !autocompleteVisible && presentableTypingUsers.length > 0; - const slowmodeVisible = showSlowmodeStatus && slowmodeEnabled; - let topBar: React.ReactNode = null; - if (mobileEditVisible) { - topBar = ; - } else if (referencedMessage !== null) { - topBar = ( - - ); - } - return ( - <> -
-
- {typingVisible && ( -
- -
- )} -
- {slowmodeVisible && ( -
- -
- )} -
- {leadingContentVisible && ( -
-
- {topBar} -
-
- )} - - ); -}); diff --git a/fluxer_app/src/features/channel/components/ChannelHeader.module.css b/fluxer_app/src/features/channel/components/ChannelHeader.module.css index 1fdfdb06a..fa60ef593 100644 --- a/fluxer_app/src/features/channel/components/ChannelHeader.module.css +++ b/fluxer_app/src/features/channel/components/ChannelHeader.module.css @@ -12,12 +12,12 @@ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, auto); align-items: center; - gap: var(--spacing-4); + gap: var(--channel-header-gap); height: var(--layout-header-height); min-height: var(--layout-header-height); border-bottom: 0.0625rem solid var(--user-area-divider-color); background-color: var(--background-secondary-lighter); - padding: 0 var(--spacing-4); + padding: 0 var(--channel-header-padding-inline); color: var(--channel-header-text-primary); } @@ -177,8 +177,8 @@ @media (max-width: 767px) { .headerContainer { - height: 4rem; - min-height: 4rem; + height: var(--layout-header-height-mobile); + min-height: var(--layout-header-height-mobile); } } @@ -191,15 +191,15 @@ } .backButton { - margin-right: var(--spacing-3); + margin-right: var(--channel-header-back-button-gap); flex-shrink: 0; cursor: pointer; -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; - height: 2rem; - width: 2rem; + height: var(--channel-header-back-button-size); + width: var(--channel-header-back-button-size); border-radius: var(--radius-full); background-color: transparent; color: var(--channel-header-text-muted); @@ -222,8 +222,8 @@ } .backIcon { - height: 1.5rem; - width: 1.5rem; + height: var(--channel-header-back-icon-size); + width: var(--channel-header-back-icon-size); } .backIconBold { @@ -309,7 +309,7 @@ flex: 1; min-width: 0; max-width: 100%; - gap: var(--spacing-2); + gap: var(--channel-header-name-gap-group-dm); position: relative; z-index: 1; } @@ -329,7 +329,7 @@ } .channelName { - margin-left: var(--spacing-3); + margin-left: var(--channel-header-name-gap); min-width: 0; flex-shrink: 1; font-weight: 500; @@ -358,16 +358,16 @@ } .channelIcon { - height: 1.5rem; - width: 1.5rem; + height: var(--channel-header-icon-size); + width: var(--channel-header-icon-size); flex-shrink: 0; color: var(--channel-header-text-muted); } .caretRight { - margin-left: var(--spacing-1); - height: 1rem; - width: 1rem; + margin-left: var(--channel-header-caret-gap); + height: var(--channel-header-caret-size); + width: var(--channel-header-caret-size); flex-shrink: 0; color: var(--channel-header-text-muted); } @@ -382,7 +382,7 @@ } .topicDivider { - margin: 0 var(--spacing-2); + margin: 0 var(--channel-header-topic-divider-gap); flex-shrink: 0; color: var(--channel-header-divider-color); } @@ -471,7 +471,7 @@ display: flex; align-items: center; justify-content: flex-end; - gap: var(--spacing-2); + gap: var(--channel-header-actions-gap); flex-shrink: 0; min-width: 0; -webkit-app-region: no-drag; @@ -495,8 +495,8 @@ display: flex; align-items: center; justify-content: center; - height: 2rem; - width: 2rem; + height: var(--channel-header-action-size); + width: var(--channel-header-action-size); flex-shrink: 0; cursor: pointer; border-radius: var(--radius-full); @@ -565,8 +565,8 @@ display: flex; align-items: center; justify-content: center; - height: 2.5rem; - width: 2.5rem; + height: var(--channel-header-action-size-mobile); + width: var(--channel-header-action-size-mobile); flex-shrink: 0; border-radius: var(--radius-full); background-color: var(--background-tertiary); @@ -575,8 +575,8 @@ } .buttonIcon { - height: 1.5rem; - width: 1.5rem; + height: var(--channel-header-action-icon-size); + width: var(--channel-header-action-icon-size); } .buttonIconMobile { diff --git a/fluxer_app/src/features/channel/components/ChannelHeader.tsx b/fluxer_app/src/features/channel/components/ChannelHeader.tsx index 97cdf3089..0cc941d9c 100644 --- a/fluxer_app/src/features/channel/components/ChannelHeader.tsx +++ b/fluxer_app/src/features/channel/components/ChannelHeader.tsx @@ -4,10 +4,20 @@ import {Routes} from '@app/app/Routes'; import Accessibility from '@app/features/accessibility/state/Accessibility'; import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion'; import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar'; +import { + reportSkeletonChannelHeaderLayout, + reportSkeletonChannelProjection, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import { + measureSkeletonTextWidthPx, + measureSkeletonWidthPx, + useSkeletonLayoutReport, +} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; import {useTextOverflow} from '@app/features/app/hooks/useTextOverflow'; import {ChannelDetailsBottomSheet} from '@app/features/channel/components/bottomsheets/ChannelDetailsBottomSheet'; import {ChannelSearchBottomSheet} from '@app/features/channel/components/bottomsheets/ChannelSearchBottomSheet'; import styles from '@app/features/channel/components/ChannelHeader.module.css'; +import {CHANNEL_HEADER_DM_AVATAR_SIZE_PX} from '@app/features/channel/components/ChannelHeaderMetrics'; import {UserTag} from '@app/features/channel/components/ChannelUserTag'; import { ADD_FRIENDS_TO_GROUP_DESCRIPTOR, @@ -32,8 +42,15 @@ import {CallButtons} from '@app/features/channel/components/channel_header_compo import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon'; import {ChannelNotificationSettingsButton} from '@app/features/channel/components/channel_header_components/ChannelNotificationSettingsButton'; import {ChannelPinsButton} from '@app/features/channel/components/channel_header_components/ChannelPinsButton'; -import {UpdaterIcon} from '@app/features/channel/components/channel_header_components/UpdaterIcon'; -import {InboxButton, StaffToolsButton} from '@app/features/channel/components/channel_header_components/UtilityButtons'; +import { + isUpdaterIconVisible, + UpdaterIcon, +} from '@app/features/channel/components/channel_header_components/UpdaterIcon'; +import { + InboxButton, + isStaffToolsButtonVisible, + StaffToolsButton, +} from '@app/features/channel/components/channel_header_components/UtilityButtons'; import {useChannelSearchState} from '@app/features/channel/components/channel_view/useChannelSearchState'; import {MessageSearchBar} from '@app/features/channel/components/message_search_bar/MessageSearchBar'; import {ChannelTopicModal} from '@app/features/channel/components/modals/ChannelTopicModal'; @@ -190,6 +207,16 @@ export const ChannelHeader = observer( ); const isHeaderPopoutOpen = PopoutState.isOpen('inbox') || PopoutState.isOpen('channel-pins'); const isVoiceCallChromePinned = isHeaderContextMenuOpen || isHeaderPopoutOpen; + const channelId = channel?.id; + const channelType = channel?.type; + const channelHasTopic = Boolean(channel?.topic); + const staffToolsVisible = isStaffToolsButtonVisible(); + const updaterVisible = isUpdaterIconVisible(); + const favoritesVisible = Accessibility.showFavorites; + useSkeletonLayoutReport( + () => reportSkeletonChannelHeaderLayout({staffToolsVisible, updaterVisible, favoritesVisible}), + `${staffToolsVisible}|${updaterVisible}|${favoritesVisible}`, + ); useEffect(() => { latestSearchQueryRef.current = searchQuery; latestSearchSegmentsRef.current = searchSegments; @@ -497,6 +524,59 @@ export const ChannelHeader = observer( Relationships.getRelationship(recipient.id)?.type === RelationshipTypes.FRIEND; const shouldShowCreateGroupButton = !!channel && !isMobile && !isPersonalNotes && isFriendDM && !isGroupDM; const shouldShowAddFriendsButton = !!channel && !isMobile && !isPersonalNotes && isGroupDM && !isGroupDMFull; + const hasChannel = Boolean(channel) && !isPersonalNotes; + const hasCallableRecipients = hasChannel && (isDM || isGroupDM); + let desktopLeadingActionCount = 0; + if (hasChannel && isGuildChannel) { + desktopLeadingActionCount += 1; + } + if (hasCallableRecipients && !(isDM && isBotDMRecipient)) { + desktopLeadingActionCount += 2; + } + if (showPins && Boolean(channel)) { + desktopLeadingActionCount += 1; + } + if (hasChannel && isFriendDM && !isGroupDM) { + desktopLeadingActionCount += 1; + } + if (hasChannel && isGroupDM && !isGroupDMFull) { + desktopLeadingActionCount += 1; + } + if (hasChannel && favoritesVisible) { + desktopLeadingActionCount += 1; + } + if (showMembersToggle) { + desktopLeadingActionCount += 1; + } + let mobileActionCount = 0; + if (hasChannel && favoritesVisible) { + mobileActionCount += 1; + } + if (hasCallableRecipients) { + mobileActionCount += 2; + } + if (isGuildChannel) { + mobileActionCount += 1; + } + const searchPanelOpen = Boolean(isSearchResultsVisible); + useSkeletonLayoutReport( + () => { + if (channelId == null || channelType == null) { + return; + } + const nameElement = dmNameRef.current ?? groupDMNameRef.current ?? guildChannelNameRef.current; + reportSkeletonChannelProjection(channelId, channelType, { + showTopic: channelHasTopic, + nameWidthPx: measureSkeletonWidthPx(nameElement), + topicWidthPx: measureSkeletonTextWidthPx(topicButtonRef.current), + desktopLeadingActionCount, + mobileActionCount, + memberListVisible: isMembersToggleOpen, + searchPanelOpen, + }); + }, + `${channelId}|${channelType ?? ''}|${channelHasTopic}|${channel?.topic ?? ''}|${channelName}|${directMessageName}|${groupDMName}|${desktopLeadingActionCount}|${mobileActionCount}|${searchPanelOpen}|${isMembersToggleOpen}|${Accessibility.fontSize}|${Accessibility.zoomLevel}`, + ); return ( <>
@@ -592,7 +672,7 @@ export const ChannelHeader = observer( <> @@ -681,7 +761,7 @@ export const ChannelHeader = observer(
diff --git a/fluxer_app/src/features/channel/components/ChannelHeaderMetrics.ts b/fluxer_app/src/features/channel/components/ChannelHeaderMetrics.ts new file mode 100644 index 000000000..4e8b0aa40 --- /dev/null +++ b/fluxer_app/src/features/channel/components/ChannelHeaderMetrics.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const CHANNEL_HEADER_DM_AVATAR_SIZE_PX = 32; diff --git a/fluxer_app/src/features/channel/components/ChannelMembers.module.css b/fluxer_app/src/features/channel/components/ChannelMembers.module.css index 3d4e918bc..4fd8034d6 100644 --- a/fluxer_app/src/features/channel/components/ChannelMembers.module.css +++ b/fluxer_app/src/features/channel/components/ChannelMembers.module.css @@ -41,7 +41,7 @@ .membersList { display: flex; flex-direction: column; - gap: 0.125rem; + gap: var(--member-list-row-gap); } .virtualListContent { @@ -55,8 +55,8 @@ right: 0; display: flex; align-items: center; - height: 2.75rem; - min-height: 2.75rem; + height: var(--member-list-item-height); + min-height: var(--member-list-item-height); } .virtualMemberRow { @@ -72,8 +72,8 @@ .virtualGroupRow { display: flex; - height: 2rem; - min-height: 2rem; + height: var(--member-list-group-header-height); + min-height: var(--member-list-group-header-height); align-items: flex-start; padding-left: 0.5rem; padding-right: 0.5rem; @@ -83,77 +83,5 @@ } .groupSpacer { - height: 0.25rem; -} - -.skeletonItem { - display: grid; - height: 2.125rem; - min-width: 0; - grid-template-columns: 1fr auto; - align-items: center; - gap: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - box-sizing: border-box; -} - -.virtualRow .skeletonItem { - height: 2.125rem; -} - -.skeletonContent { - display: flex; - min-width: 0; - align-items: center; - gap: 0.625rem; -} - -.skeletonAvatar { - width: 2rem; - height: 2rem; - border-radius: 50%; - flex-shrink: 0; - background: var(--background-modifier-accent); - opacity: 0.45; -} - -.skeletonUserInfoContainer { - display: flex; - flex-direction: column; - min-width: 0; - flex-grow: 1; - gap: 0.125rem; -} - -.skeletonName { - height: 0.625rem; - width: 60%; - border-radius: 0.125rem; - background: var(--background-modifier-accent); - opacity: 0.45; -} - -.skeletonStatus { - height: 0.5rem; - width: 40%; - border-radius: 0.125rem; - background: var(--background-modifier-accent); - opacity: 0.35; -} - -.skeletonHeader { - width: 5rem; - height: 0.875rem; - border-radius: 0.25rem; - background: var(--background-modifier-accent); - opacity: 0.45; -} - -.skeleton { - background: var(--background-modifier-accent); - opacity: 0.45; - border-radius: 0.25rem; + height: var(--member-list-group-spacer-height); } diff --git a/fluxer_app/src/features/channel/components/ChannelMembers.tsx b/fluxer_app/src/features/channel/components/ChannelMembers.tsx index d25586194..0f035df00 100644 --- a/fluxer_app/src/features/channel/components/ChannelMembers.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMembers.tsx @@ -2,10 +2,28 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame'; +import { + getRememberedSkeletonMemberGroups, + reportSkeletonMemberLayout, + SKELETON_UNMEASURED_WIDTH_PX, + SkeletonMemberSurfaceKind, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; import Authentication from '@app/features/auth/state/Authentication'; import styles from '@app/features/channel/components/ChannelMembers.module.css'; +import {hasVisibleCompactMemberCustomStatus} from '@app/features/channel/components/CompactMemberCustomStatus'; import {MemberListContainer} from '@app/features/channel/components/MemberListContainer'; import {MemberListItem} from '@app/features/channel/components/MemberListItem'; +import { + MEMBER_LIST_GROUP_HEADER_HEIGHT_PX, + MEMBER_LIST_ITEM_HEIGHT_PX, + MEMBER_LIST_METRICS_STYLE, +} from '@app/features/channel/components/MemberListMetrics'; +import { + MemberListSkeleton, + MemberListSkeletonRow, + MemberListSkeletonVariant, +} from '@app/features/channel/components/MemberListSkeleton'; import {MemberListUnavailableFallback} from '@app/features/channel/components/shared/MemberListUnavailableFallback'; import type {Channel} from '@app/features/channel/models/Channel'; import type {Guild} from '@app/features/guild/models/Guild'; @@ -32,9 +50,10 @@ import type {GroupDMMemberGroup} from '@app/features/member/utils/MemberListUtil import * as MemberListUtils from '@app/features/member/utils/MemberListUtils'; import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils'; import Presence from '@app/features/presence/state/Presence'; +import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; import {openRoleContextMenu, openRoleContextMenuForElement} from '@app/features/ui/action_menu/RoleContextMenu'; import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; -import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils'; +import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils'; import type {User} from '@app/features/user/models/User'; import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; @@ -46,10 +65,8 @@ import {useLingui} from '@lingui/react/macro'; import clsx from 'clsx'; import {observer} from 'mobx-react-lite'; import type {ReactNode, UIEvent} from 'react'; -import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -const MEMBER_ITEM_HEIGHT = 44; -const GROUP_HEADER_HEIGHT = 32; const INITIAL_SUBSCRIPTION_RANGE: [number, number] = [0, MEMBER_LIST_RANGE_MAX_SPAN]; const INITIAL_RENDER_RANGE: [number, number] = [0, 64]; const INITIAL_SUBSCRIPTION_RANGES = normalizeMemberListRanges([INITIAL_SUBSCRIPTION_RANGE]); @@ -60,55 +77,24 @@ const SUBSCRIPTION_OVERSCAN_PAGES = 0; const RENDER_BUFFER_ROWS = 6; const AVATAR_DEFER_AFTER_SCROLL_IDLE_MS = 180; const MEMBER_LIST_AVATAR_MEDIA_SIZE = 64; -const INITIAL_LOADING_SKELETON_COUNT = 24; -const MEMBER_LIST_SKELETON_INDEXES = Array.from({length: INITIAL_LOADING_SKELETON_COUNT}, (_, index) => index); -function getSeededRandom(seed: number): number { - const x = Math.sin(seed) * 10000; - return x - Math.floor(x); +function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number { + if (element == null) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + const firstChild = element.firstElementChild; + const lastChild = element.lastElementChild; + if (firstChild == null || lastChild == null) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + const left = firstChild.getBoundingClientRect().left; + const right = lastChild.getBoundingClientRect().right; + if (!Number.isFinite(left) || !Number.isFinite(right) || right <= left) { + return SKELETON_UNMEASURED_WIDTH_PX; + } + return Math.round((right - left) / getRemScaleForDocument(element.ownerDocument)); } -function SkeletonMemberItem({index}: {index: number}) { - const baseSeed = (index + 1) * 17; - const nameWidth = 40 + getSeededRandom(baseSeed) * 40; - const statusWidth = 30 + getSeededRandom(baseSeed + 1) * 50; - return ( -
-
-
-
-
-
-
-
-
- ); -} - -const MemberListLoadingSkeleton = memo(function MemberListLoadingSkeleton() { - return ( -
- {MEMBER_LIST_SKELETON_INDEXES.map((index) => ( - - ))} -
- ); -}); - interface MemberListGroupHeaderContentProps { name: string; count: number; @@ -117,9 +103,24 @@ interface MemberListGroupHeaderContentProps { function MemberListGroupHeaderContent({name, count}: MemberListGroupHeaderContentProps) { return ( <> - {name} - {'โ€”'} - {count} + + {name} + + + {'โ€”'} + + + {count} + ); } @@ -128,12 +129,21 @@ interface GroupDMMemberListGroupProps { group: GroupDMMemberGroup; channelId: string; ownerId: string | null; + onHeadingRef: (groupId: string, node: HTMLDivElement | null) => void; } -const GroupDMMemberListGroup = observer(({group, channelId, ownerId}: GroupDMMemberListGroupProps) => ( +const GroupDMMemberListGroup = observer(({group, channelId, ownerId, onHeadingRef}: GroupDMMemberListGroupProps) => (
-
- +
onHeadingRef(group.id, node)} + className={styles.groupHeader} + data-flx="channel.channel-members.group-dm-member-list-group.group-header" + > +
{group.users.map((user) => { @@ -184,6 +194,18 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe const memberListState = MemberSidebar.getList(guild.id, channel.id); const memberCount = memberListState?.memberCount ?? 0; const groups = memberListState?.groups ?? []; + const populatedGroups = useMemo(() => groups.filter((group) => group.count > 0), [groups]); + const memberGroupCountsKey = populatedGroups.map((group) => `${group.id}:${group.count}`).join(','); + const groupHeadingWidthsRef = useRef(new Map()); + const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => { + const widthPx = measureMemberGroupHeadingWidthPx(node); + if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) { + groupHeadingWidthsRef.current.set(groupId, widthPx); + } + }, []); + const memberSurfaceKind = + channel.type === ChannelTypes.GUILD_VOICE ? SkeletonMemberSurfaceKind.GUILD_VOICE : SkeletonMemberSurfaceKind.GUILD; + const rememberedMemberGroups = getRememberedSkeletonMemberGroups(channel.id, memberSurfaceKind); const zoomLevel = Accessibility.zoomLevel; const layouts = useMemo(() => buildMemberListLayout(groups), [groups]); const groupById = useMemo(() => new Map(groups.map((group) => [group.id, group])), [groups]); @@ -193,9 +215,9 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe } return memberCount; }, [layouts, memberCount]); - const zoomFactor = getAppZoomFactor(); - const scaledMemberItemHeight = MEMBER_ITEM_HEIGHT * zoomFactor; - const scaledGroupHeaderHeight = GROUP_HEADER_HEIGHT * zoomFactor; + const remScale = getAppRemScale(); + const scaledMemberItemHeight = MEMBER_LIST_ITEM_HEIGHT_PX * remScale; + const scaledGroupHeaderHeight = MEMBER_LIST_GROUP_HEADER_HEIGHT_PX * remScale; const rowOffsets = useMemo( () => layouts.length > 0 @@ -222,6 +244,23 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe [memberListState?.hasReceivedInitialPayload, renderWindowRanges, subscribedRanges, totalRows], ); const {isInitialLoading, renderRanges} = viewportModel; + const renderRangesKey = renderRanges.map(([start, end]) => `${start}-${end}`).join(','); + useSkeletonLayoutReport( + () => { + if (memberListUpdatesDisabled || lacksMemberViewPermission || !memberListState?.hasReceivedInitialPayload) { + return; + } + reportSkeletonMemberLayout( + channel.id, + memberSurfaceKind, + populatedGroups.map((group) => ({ + rowCount: group.count, + headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX, + })), + ); + }, + `${channel.id}|${memberSurfaceKind}|${memberGroupCountsKey}|${renderRangesKey}|${memberListState?.hasReceivedInitialPayload ?? false}|${lacksMemberViewPermission}|${memberListUpdatesDisabled}`, + ); const getGroupName = useCallback( (groupId: string) => { if (groupId === 'online') { @@ -379,7 +418,11 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe identityKey={memberListIdentityKey} data-flx="channel.channel-members.lazy-member-list.member-list-container--3" > - + ); } @@ -400,9 +443,10 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe style={rowStyle} data-flx="channel.channel-members.lazy-member-list.virtual-row.skeleton" > -
, ); @@ -456,11 +500,18 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe const group = groupById.get(layout.id) ?? {id: layout.id, count: layout.count}; const role = group.id === 'online' || group.id === 'offline' ? null : (guild.getRole(group.id) ?? null); const groupName = getGroupName(group.id); - const groupRowContent = ; + const groupRowContent = ( + + ); if (role) { virtualRows.push(
registerGroupHeading(group.id, node)} className={clsx(styles.virtualRow, styles.virtualGroupRow)} style={rowStyle} role="button" @@ -482,6 +533,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe virtualRows.push(
registerGroupHeading(group.id, node)} className={clsx(styles.virtualRow, styles.virtualGroupRow)} style={rowStyle} data-flx="channel.channel-members.lazy-member-list.virtual-row.group" @@ -501,9 +553,10 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe style={rowStyle} data-flx="channel.channel-members.lazy-member-list.virtual-row.skeleton--2" > -
, ); @@ -561,7 +614,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe >
{virtualRows} @@ -575,28 +628,52 @@ interface ChannelMembersProps { channel: Channel; } +const GroupDMChannelMembers = observer(function GroupDMChannelMembers({channel}: {channel: Channel}) { + const currentUserId = Authentication.currentUserId; + const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds; + const users = allUserIds.map((id) => Users.getUser(id)).filter((user): user is User => user != null); + const memberGroups = MemberListUtils.getGroupDMMemberGroups(users); + const memberGroupCountsKey = memberGroups.map((group) => `${group.id}:${group.count}`).join(','); + const groupHeadingWidthsRef = useRef(new Map()); + const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => { + const widthPx = measureMemberGroupHeadingWidthPx(node); + if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) { + groupHeadingWidthsRef.current.set(groupId, widthPx); + } + }, []); + useSkeletonLayoutReport(() => { + reportSkeletonMemberLayout( + channel.id, + SkeletonMemberSurfaceKind.GROUP_DM, + memberGroups.map((group) => ({ + rowCount: group.count, + headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX, + subtextFlags: group.users.map((user) => hasVisibleCompactMemberCustomStatus(Presence.getCustomStatus(user.id))), + })), + ); + }, `${channel.id}|${memberGroupCountsKey}`); + return ( + + + {memberGroups.map((group) => ( + + ))} + + + ); +}); + export const ChannelMembers = observer(function ChannelMembers({guild = null, channel}: ChannelMembersProps) { useLinguiRuntime(); if (channel.type === ChannelTypes.GROUP_DM) { - const currentUserId = Authentication.currentUserId; - const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds; - const users = allUserIds.map((id) => Users.getUser(id)).filter((user): user is User => user != null); - const memberGroups = MemberListUtils.getGroupDMMemberGroups(users); - return ( - - - {memberGroups.map((group) => ( - - ))} - - - ); + return ; } if (!guild) { return null; diff --git a/fluxer_app/src/features/channel/components/ChannelMessages.module.css b/fluxer_app/src/features/channel/components/ChannelMessages.module.css index 519dede26..f0d17ccf3 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessages.module.css +++ b/fluxer_app/src/features/channel/components/ChannelMessages.module.css @@ -63,8 +63,8 @@ flex-direction: column; justify-content: flex-end; min-height: 100%; - padding-left: 0.75rem; - padding-right: 0.75rem; + padding-left: var(--chat-mobile-horizontal-padding); + padding-right: var(--chat-mobile-horizontal-padding); position: relative; } diff --git a/fluxer_app/src/features/channel/components/ChannelMessages.tsx b/fluxer_app/src/features/channel/components/ChannelMessages.tsx index 806e719bc..56544b506 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessages.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessages.tsx @@ -1,13 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; -import {usePlaceholderSpecs} from '@app/features/app/utils/PlaceholderSpecs'; +import {usePlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs'; +import {ScrollFillerSkeleton} from '@app/features/app/components/skeleton/ScrollFillerSkeleton'; +import { + reportSkeletonMessagePresentation, + resolveDefaultSkeletonChatViewportHeightPx, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {measureSkeletonHeightPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture'; import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream'; import styles from '@app/features/channel/components/ChannelMessages.module.css'; import {ChannelWelcomeSection} from '@app/features/channel/components/ChannelWelcomeSection'; import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext'; import {NewMessagesBar} from '@app/features/channel/components/NewMessagesBar'; -import ScrollFillerSkeleton from '@app/features/channel/components/ScrollFillerSkeleton'; import {UploadManager} from '@app/features/channel/components/UploadManager'; import type {Channel} from '@app/features/channel/models/Channel'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; @@ -150,6 +155,7 @@ export const Messages = observer(function Messages({ }: MessagesProps) { const {i18n} = useLingui(); const scrollerInnerRef = useRef(null); + const scrollerContainerRef = useRef(null); const lastStateSnapshotRef = useRef(null); const recoveryFetchChannelIdRef = useRef(null); interface MessageState extends MessagesStateSnapshot { @@ -170,12 +176,13 @@ export const Messages = observer(function Messages({ const isModalOpen = Modal.hasModalOpen(); const isGatewayConnected = GatewayConnection.isConnected; const selectedChannelId = SelectedChannel.currentChannelId; - const placeholderSpecs = usePlaceholderSpecs( - state.messageDisplayCompact, - state.messageGroupSpacing, - state.fontSize, - channel.id, - ); + const placeholderSpecs = usePlaceholderSpecs({ + compact: state.messageDisplayCompact, + compactAvatarsVisible: Accessibility.showUserAvatarsInCompactMode, + groupSpacing: state.messageGroupSpacing, + viewportHeightPx: resolveDefaultSkeletonChatViewportHeightPx(), + seedKey: channel.id, + }); const safeMessages = state.messages ?? MessagesState.getMessages(channel.id); const canAutoAck = shouldAutoAck({ channelActive: allowAutoAck, @@ -617,6 +624,20 @@ export const Messages = observer(function Messages({ }), [state.messageGroupSpacing], ); + const compactAvatarsVisible = Accessibility.showUserAvatarsInCompactMode; + const messageGutter = Accessibility.messageGutter; + useSkeletonLayoutReport( + () => + reportSkeletonMessagePresentation({ + compact: state.messageDisplayCompact, + messageGutterPx: messageGutter, + fontSizePx: state.fontSize, + groupSpacingPx: state.messageGroupSpacing, + compactAvatarsVisible, + viewportHeightPx: measureSkeletonHeightPx(scrollerContainerRef.current), + }), + `${state.messageDisplayCompact}|${messageGutter}|${state.fontSize}|${state.messageGroupSpacing}|${compactAvatarsVisible}|${channel.id}|${state.messageVersion}`, + ); const messageListLabel = channel.name ? i18n._(MESSAGE_LIST_FOR_DESCRIPTOR, {channelName: channel.name}) : i18n._(MESSAGE_LIST_DESCRIPTOR); @@ -648,7 +669,11 @@ export const Messages = observer(function Messages({ data-flx="channel.messages.upload-manager" /> {topBar} -
+
= observer( }); return (
diff --git a/fluxer_app/src/features/channel/components/ChannelTextarea.tsx b/fluxer_app/src/features/channel/components/ChannelTextarea.tsx index 7e1074cd4..e7206911c 100644 --- a/fluxer_app/src/features/channel/components/ChannelTextarea.tsx +++ b/fluxer_app/src/features/channel/components/ChannelTextarea.tsx @@ -38,6 +38,7 @@ export const ChannelTextarea = observer(({channel, inputSuppressed = false}: Cha draftSegments={draftSegments} disabled={disabled} inputSuppressed={inputSuppressed} + data-flx="channel.channel-textarea.lexical-channel-textarea-content" /> ); }); diff --git a/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx b/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx index d347a347b..cb56c8031 100644 --- a/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx +++ b/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx @@ -34,6 +34,17 @@ function getStatusExpiryDelay(status: CustomStatus | null): number | null { return Math.max(0, expiresAtMs - Date.now()); } +export function hasVisibleCompactMemberCustomStatus(status: CustomStatus | null | undefined): boolean { + const normalized = normalizeCustomStatus(status ?? null); + if (!normalized || isCustomStatusExpired(normalized)) { + return false; + } + if (normalized.emojiId != null || normalized.emojiName != null) { + return true; + } + return sanitizeText(normalized.text ?? '').length > 0; +} + const loadedStatusMediaUrls = new Set(); function markStatusMediaLoaded(url: string): void { diff --git a/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx b/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx index e9f675994..e08a04e4a 100644 --- a/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx +++ b/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {reportSkeletonCompactTimestampWidth} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; import {UserTag} from '@app/features/channel/components/ChannelUserTag'; import {MessageAvatar} from '@app/features/channel/components/MessageAvatar'; import {MessageTimeoutIndicator} from '@app/features/channel/components/MessageTimeoutIndicator'; @@ -9,6 +10,7 @@ import type {Guild} from '@app/features/guild/models/Guild'; import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {compactMessagePrefixProps} from '@app/features/theme/layout/MessageLayoutAttributes'; +import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; import styles from '@app/features/theme/styles/Message.module.css'; import type {User} from '@app/features/user/models/User'; import * as DateUtils from '@app/features/user/utils/DateFormatting'; @@ -68,6 +70,7 @@ function useMeasuredCompactTimestampWidth(enabled: boolean): React.RefCallback ), position: 'top-end', @@ -311,6 +312,7 @@ export const EditingMessageInput = observer( onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} i18n={i18n} + data-flx="channel.editing-message-input.lexical-rich-input.submit" />
escape to{' '} - @@ -364,6 +372,7 @@ export const EditingMessageInput = observer( onClick={handleSubmit} disabled={editingDisabled} key="save" + data-flx="channel.editing-message-input.button.submit" > save diff --git a/fluxer_app/src/features/channel/components/EmojiPicker.tsx b/fluxer_app/src/features/channel/components/EmojiPicker.tsx index 0d766cf1e..38ab71e7e 100644 --- a/fluxer_app/src/features/channel/components/EmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/EmojiPicker.tsx @@ -139,7 +139,7 @@ export const EmojiPicker = observer( renderedEmojis, ); const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm; - const virtualRows = useVirtualRows( + const pickerRows = useVirtualRows( normalizedSearchTerm, renderedEmojis, favoriteEmojis, @@ -177,13 +177,13 @@ export const EmojiPicker = observer( lockedEmojiCount > 0; const sections = useMemo(() => { const result: Array = []; - for (const row of virtualRows) { + for (const row of pickerRows) { if (row.type === 'emoji-row') { result.push(row.emojis.length); } } return result; - }, [virtualRows]); + }, [pickerRows]); const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { @@ -216,10 +216,10 @@ export const EmojiPicker = observer( setSelectedColumn(column); setShouldScrollOnSelection(shouldScroll); let currentRow = 0; - for (const virtualRow of virtualRows) { - if (virtualRow.type === 'emoji-row') { - if (currentRow === row && column < virtualRow.emojis.length) { - const emoji = virtualRow.emojis[column]; + for (const pickerRow of pickerRows) { + if (pickerRow.type === 'emoji-row') { + if (currentRow === row && column < pickerRow.emojis.length) { + const emoji = pickerRow.emojis[column]; setHoveredEmoji(emoji); break; } @@ -227,7 +227,7 @@ export const EmojiPicker = observer( } } }, - [virtualRows], + [pickerRows], ); useEffect(() => { if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) { @@ -240,10 +240,10 @@ export const EmojiPicker = observer( return; } let currentRow = 0; - for (const virtualRow of virtualRows) { - if (virtualRow.type === 'emoji-row') { - if (currentRow === row && column < virtualRow.emojis.length) { - const emoji = virtualRow.emojis[column]; + for (const pickerRow of pickerRows) { + if (pickerRow.type === 'emoji-row') { + if (currentRow === row && column < pickerRow.emojis.length) { + const emoji = pickerRow.emojis[column]; handleEmojiSelect(emoji, event?.shiftKey); return; } @@ -251,7 +251,7 @@ export const EmojiPicker = observer( } } }, - [virtualRows, handleEmojiSelect], + [pickerRows, handleEmojiSelect], ); return (
@@ -294,9 +294,9 @@ export const EmojiPicker = observer( data-flx="channel.emoji-picker.premium-upsell-banner" /> )} - {virtualRows.map((row, index) => { - const emojiRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'emoji-row').length; - const needsSpacingAfter = row.type === 'emoji-row' && virtualRows[index + 1]?.type === 'header'; + {pickerRows.map((row, index) => { + const emojiRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'emoji-row').length; + const needsSpacingAfter = row.type === 'emoji-row' && pickerRows[index + 1]?.type === 'header'; return (
+ reportSkeletonComposerLayout({ + desktopActionCount: desktopComposerActionCount, + mobileActionCount: mobileComposerActionCount, + sendDividerVisible: showMessageSendButton, + }), + `${desktopComposerActionCount}|${mobileComposerActionCount}|${showMessageSendButton}`, + ); const mobileLayout = MobileLayout; const replyingMessage = MessageReply.getReplyingMessage(channel.id); const referencedMessage = MessageReply.getReferencedMessage(channel.id); @@ -403,6 +425,7 @@ export const LexicalChannelTextareaContent = observer( onSecondary={() => { handleMentionCancel(); }} + data-flx="channel.lexical-channel-textarea-content.confirm-modal" /> )), mentionModalKey, @@ -431,6 +454,7 @@ export const LexicalChannelTextareaContent = observer( handleMentionCancel(); onClose(); }} + data-flx="channel.lexical-channel-textarea-content.mention-everyone-popout" /> ), position: 'top-start', @@ -544,9 +568,20 @@ export const LexicalChannelTextareaContent = observer( ); if (!result.success) { if (result.error === 'too_many_attachments') { - ModalCommands.push(modal(() => )); + ModalCommands.push( + modal(() => ( + + )), + ); } else if (result.error === 'file_size_too_large') { - ModalCommands.push(modal(() => )); + ModalCommands.push( + modal(() => ( + + )), + ); } return; } @@ -573,9 +608,20 @@ export const LexicalChannelTextareaContent = observer( ); if (!result.success) { if (result.error === 'too_many_attachments') { - ModalCommands.push(modal(() => )); + ModalCommands.push( + modal(() => ( + + )), + ); } else if (result.error === 'file_size_too_large') { - ModalCommands.push(modal(() => )); + ModalCommands.push( + modal(() => ( + + )), + ); } return; } @@ -1065,6 +1111,7 @@ export const LexicalChannelTextareaContent = observer( } } } + data-flx="channel.lexical-channel-textarea-content.open-plus-menu.textarea-plus-menu" /> ), { @@ -1148,7 +1195,13 @@ export const LexicalChannelTextareaContent = observer( } let topBarContent: React.ReactNode = null; if (isMobileEditBarVisible) { - topBarContent = ; + topBarContent = ( + + ); } else if (referencedMessage !== null) { topBarContent = ( MessageCommands.setReplyMentioning(channel.id, mentioning)} channel={channel} + data-flx="channel.lexical-channel-textarea-content.reply-bar" /> ); } const renderSection = (content: React.ReactNode, sectionClassName?: string) => ( - + {content} ); @@ -1178,21 +1235,40 @@ export const LexicalChannelTextareaContent = observer( textareaInputDisabled && wrapperStyles.disabled, !mobileLayout.enabled && styles.textareaOuterRow, )} + data-flx="channel.lexical-channel-textarea-content.textarea-outer" > - - + + {isTypingStatusVisible && ( - - + + )} {isSlowmodeIndicatorVisible && ( - + )} @@ -1206,24 +1282,40 @@ export const LexicalChannelTextareaContent = observer( )} {slashCommandState.activeSlot !== null && (
- +
)}
)} {showAttachments && - renderSection(, styles.collapsibleSection)} + renderSection( + , + styles.collapsibleSection, + )} {showStickers && renderSection( - , + , styles.collapsibleSection, )} {renderSection( - + @@ -1304,6 +1402,7 @@ export const LexicalChannelTextareaContent = observer( onExpressionPickerToggle={handleExpressionPickerTabToggle} onSubmit={handleSubmit} channelId={channel.id} + data-flx="channel.lexical-channel-textarea-content.textarea-buttons.submit" /> , styles.inputSection, @@ -1313,6 +1412,7 @@ export const LexicalChannelTextareaContent = observer( maxLength={maxMessageLength} canUpgrade={maxMessageLength < premiumMaxLength} premiumMaxLength={premiumMaxLength} + data-flx="channel.lexical-channel-textarea-content.message-character-counter" /> {mobileLayout.enabled && ( @@ -1322,6 +1422,7 @@ export const LexicalChannelTextareaContent = observer( onClose={() => setExpressionPickerOpen(false)} channelId={channel.id} onEmojiSelect={handleEmojiSelect} + data-flx="channel.lexical-channel-textarea-content.expression-picker-sheet" /> )} diff --git a/fluxer_app/src/features/channel/components/MemberListContainer.module.css b/fluxer_app/src/features/channel/components/MemberListContainer.module.css index 45a63f9f7..bb7e31e32 100644 --- a/fluxer_app/src/features/channel/components/MemberListContainer.module.css +++ b/fluxer_app/src/features/channel/components/MemberListContainer.module.css @@ -14,7 +14,7 @@ @media (min-width: 768px) { .memberListContainer { - --member-list-width: 16.5rem; + --member-list-width: var(--layout-member-list-width); width: var(--member-list-width); } } @@ -23,8 +23,8 @@ flex: 1; min-height: 0; background-color: var(--background-secondary-lighter); - padding-top: 0.25rem; - padding-left: var(--spacing-2); - padding-right: var(--spacing-2); - padding-bottom: var(--spacing-4); + padding-top: var(--member-list-scroller-padding-block-start); + padding-left: var(--member-list-scroller-padding-inline); + padding-right: var(--member-list-scroller-padding-inline); + padding-bottom: var(--member-list-scroller-padding-block-end); } diff --git a/fluxer_app/src/features/channel/components/MemberListItem.module.css b/fluxer_app/src/features/channel/components/MemberListItem.module.css index 83ff65ada..62833500c 100644 --- a/fluxer_app/src/features/channel/components/MemberListItem.module.css +++ b/fluxer_app/src/features/channel/components/MemberListItem.module.css @@ -2,8 +2,8 @@ .button { position: relative; - padding-top: 0.25rem; - padding-bottom: 0.25rem; + padding-top: var(--member-list-item-padding-block); + padding-bottom: var(--member-list-item-padding-block); cursor: pointer; border-radius: 0.375rem; color: var(--text-chat); @@ -44,20 +44,20 @@ .grid { display: grid; - min-height: 2rem; + min-height: var(--member-list-item-content-height); min-width: 0; grid-template-columns: 1fr auto; align-items: center; gap: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; + padding-left: var(--member-list-item-padding-inline); + padding-right: var(--member-list-item-padding-inline); } .content { display: flex; min-width: 0; align-items: center; - gap: 0.625rem; + gap: var(--member-list-item-content-gap); font-weight: 500; } @@ -83,15 +83,15 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.25rem; - max-height: 1.25rem; + line-height: var(--member-list-item-name-line-height); + max-height: var(--member-list-item-name-line-height); } .memberCustomStatus { max-width: 100%; color: color-mix(in srgb, var(--text-primary-muted) 85%, transparent); font-size: 0.6875rem; - line-height: 0.875rem; + line-height: var(--member-list-item-status-line-height); font-weight: 500; overflow: hidden; text-overflow: ellipsis; diff --git a/fluxer_app/src/features/channel/components/MemberListMetrics.ts b/fluxer_app/src/features/channel/components/MemberListMetrics.ts new file mode 100644 index 000000000..a18097fce --- /dev/null +++ b/fluxer_app/src/features/channel/components/MemberListMetrics.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {SkeletonInjectedToken} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import type {CSSProperties} from 'react'; + +export const MEMBER_LIST_ITEM_HEIGHT_PX = 44; +export const MEMBER_LIST_GROUP_DM_ITEM_HEIGHT_PX = 40; +export const MEMBER_LIST_GROUP_HEADER_HEIGHT_PX = 32; + +const MEMBER_LIST_METRICS = { + '--member-list-item-height': remFromPx(MEMBER_LIST_ITEM_HEIGHT_PX), + '--member-list-item-height-group-dm': remFromPx(MEMBER_LIST_GROUP_DM_ITEM_HEIGHT_PX), + '--member-list-group-header-height': remFromPx(MEMBER_LIST_GROUP_HEADER_HEIGHT_PX), +} satisfies Partial>; + +export const MEMBER_LIST_METRICS_STYLE = MEMBER_LIST_METRICS as CSSProperties; diff --git a/fluxer_app/src/features/channel/components/MemberListSkeleton.module.css b/fluxer_app/src/features/channel/components/MemberListSkeleton.module.css new file mode 100644 index 000000000..a787417bd --- /dev/null +++ b/fluxer_app/src/features/channel/components/MemberListSkeleton.module.css @@ -0,0 +1,69 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.groups, +.group, +.rows { + display: flex; + flex-direction: column; +} + +.groupHeader { + display: flex; + align-items: flex-start; + box-sizing: border-box; + height: var(--member-list-group-header-height); + min-height: var(--member-list-group-header-height); + padding: 0.75rem var(--member-list-item-padding-inline) var(--member-list-item-padding-block); +} + +.row { + display: flex; + align-items: center; + box-sizing: border-box; + height: var(--member-list-item-height); + min-height: var(--member-list-item-height); + padding: var(--member-list-item-padding-block) var(--member-list-item-padding-inline); +} + +.rowGroupDM { + height: auto; + min-height: var(--member-list-item-height-group-dm); +} + +.rowContent { + display: flex; + align-items: center; + gap: var(--member-list-item-content-gap); + width: 100%; + min-width: 0; +} + +.rowInfo { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.nameSlot, +.statusSlot { + display: flex; + align-items: center; + min-width: 0; +} + +.nameSlot { + height: var(--member-list-item-name-line-height); +} + +.statusSlot { + height: var(--member-list-item-status-line-height); +} + +.rowsGroupDM { + gap: var(--member-list-row-gap); +} + +.groupGroupDM { + padding-bottom: var(--member-list-group-spacer-height); +} diff --git a/fluxer_app/src/features/channel/components/MemberListSkeleton.tsx b/fluxer_app/src/features/channel/components/MemberListSkeleton.tsx new file mode 100644 index 000000000..8a0014bc0 --- /dev/null +++ b/fluxer_app/src/features/channel/components/MemberListSkeleton.tsx @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SkeletonCircle} from '@app/features/app/components/skeleton/SkeletonCircle'; +import { + type RememberedSkeletonMemberGroup, + SKELETON_UNMEASURED_WIDTH_PX, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed'; +import {SkeletonEmphasis} from '@app/features/app/components/skeleton/SkeletonStyle'; +import {MEMBER_LIST_METRICS_STYLE} from '@app/features/channel/components/MemberListMetrics'; +import styles from '@app/features/channel/components/MemberListSkeleton.module.css'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {flxElementClassName} from '@app/lib/react'; + +const AVATAR_SIZE = '2rem'; +const GROUP_HEADING_HEIGHT = '0.625rem'; +const NAME_HEIGHT = '0.625rem'; +const STATUS_HEIGHT = '0.5rem'; +const NAME_WIDTH_MIN = 40; +const NAME_WIDTH_RANGE = 40; +const STATUS_WIDTH_MIN = 30; +const STATUS_WIDTH_RANGE = 50; + +export const MemberListSkeletonVariant = Object.freeze({ + GUILD: 'guild', + GROUP_DM: 'group-dm', +} as const); + +export type MemberListSkeletonVariant = (typeof MemberListSkeletonVariant)[keyof typeof MemberListSkeletonVariant]; + +interface MemberListSkeletonRowSpec { + readonly nameWidth: number; + readonly statusWidth: number; +} + +interface MemberListSkeletonGroupRequest { + readonly headingWidth: string; + readonly rowCount: number; + readonly subtextFlags: ReadonlyArray; +} + +const NO_SUBTEXT_FLAGS: ReadonlyArray = Object.freeze([]); + +const GUILD_GROUP_REQUESTS: ReadonlyArray = [ + {headingWidth: '5rem', rowCount: 7, subtextFlags: NO_SUBTEXT_FLAGS}, + {headingWidth: '6.25rem', rowCount: 9, subtextFlags: NO_SUBTEXT_FLAGS}, + {headingWidth: '4.5rem', rowCount: 8, subtextFlags: NO_SUBTEXT_FLAGS}, +]; + +const GROUP_DM_GROUP_REQUESTS: ReadonlyArray = [ + {headingWidth: '4.5rem', rowCount: 5, subtextFlags: NO_SUBTEXT_FLAGS}, + {headingWidth: '4rem', rowCount: 5, subtextFlags: NO_SUBTEXT_FLAGS}, +]; + +function createMemberListSkeletonRowSpec( + variant: MemberListSkeletonVariant, + rowIndex: number, +): MemberListSkeletonRowSpec { + const random = createSkeletonRandomFromKey(`member-list-skeleton|${variant}|${rowIndex}`); + return { + nameWidth: NAME_WIDTH_MIN + random() * NAME_WIDTH_RANGE, + statusWidth: STATUS_WIDTH_MIN + random() * STATUS_WIDTH_RANGE, + }; +} + +function resolveDefaultGroupRequests( + variant: MemberListSkeletonVariant, +): ReadonlyArray { + if (variant === MemberListSkeletonVariant.GROUP_DM) { + return GROUP_DM_GROUP_REQUESTS; + } + return GUILD_GROUP_REQUESTS; +} + +function resolveGroupRequests( + variant: MemberListSkeletonVariant, + memberGroups: ReadonlyArray | null, +): ReadonlyArray { + const defaults = resolveDefaultGroupRequests(variant); + if (memberGroups == null) { + return defaults; + } + return memberGroups.map((group, index) => { + if (group.headingWidthPx === SKELETON_UNMEASURED_WIDTH_PX) { + return { + headingWidth: defaults[index % defaults.length].headingWidth, + rowCount: group.rowCount, + subtextFlags: group.subtextFlags, + }; + } + return { + headingWidth: remFromPx(group.headingWidthPx), + rowCount: group.rowCount, + subtextFlags: group.subtextFlags, + }; + }); +} + +interface MemberListSkeletonRowShapeProps { + readonly row: MemberListSkeletonRowSpec; + readonly variant: MemberListSkeletonVariant; + readonly showSubtext: boolean; +} + +function MemberListSkeletonRowShape({row, variant, showSubtext}: MemberListSkeletonRowShapeProps) { + return ( + + + + + + + + {showSubtext && ( + + + + )} + + + + ); +} + +interface MemberListSkeletonRowProps { + readonly index: number; + readonly variant: MemberListSkeletonVariant; +} + +export function MemberListSkeletonRow({index, variant}: MemberListSkeletonRowProps) { + return ( + + ); +} + +interface MemberListSkeletonProps { + readonly variant: MemberListSkeletonVariant; + readonly memberGroups?: ReadonlyArray | null; +} + +export function MemberListSkeleton({variant, memberGroups = null}: MemberListSkeletonProps) { + const groups = resolveGroupRequests(variant, memberGroups); + let rowIndex = 0; + return ( + + {groups.map((group, groupIndex) => ( + + + + + + {Array.from({length: group.rowCount}, (_, rowInGroup) => { + const index = rowIndex; + rowIndex += 1; + return ( + + ); + })} + + + ))} + + ); +} diff --git a/fluxer_app/src/features/channel/components/MessageActionBottomSheet.module.css b/fluxer_app/src/features/channel/components/MessageActionBottomSheet.module.css index edea6edbe..3c712f946 100644 --- a/fluxer_app/src/features/channel/components/MessageActionBottomSheet.module.css +++ b/fluxer_app/src/features/channel/components/MessageActionBottomSheet.module.css @@ -29,7 +29,7 @@ width: 1.75rem; } -.addReactionIcon { +.reactionPickerIcon { height: 1.25rem; width: 1.25rem; } diff --git a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx index eb161f19b..eade67973 100644 --- a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx @@ -184,7 +184,7 @@ export const MobileEmojiPicker = observer( } return Math.max(0, effectiveViewportWidth - 24); }, [effectiveViewportWidth]); - const virtualRows = useVirtualRows( + const pickerRows = useVirtualRows( normalizedSearchTerm, renderedEmojis, favoriteEmojis, @@ -269,7 +269,7 @@ export const MobileEmojiPicker = observer( data-flx="channel.mobile-emoji-picker.premium-upsell-banner" /> )} - {virtualRows.map((row) => ( + {pickerRows.map((row) => (
getMobileStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]); - const virtualRows = useVirtualRows( + const pickerRows = useVirtualRows( searchTerm, renderedStickers, favoriteStickers, @@ -264,8 +264,8 @@ export const MobileStickersPicker = observer( data-flx="channel.mobile-stickers-picker.premium-upsell-banner" /> )} - {virtualRows.map((row, index) => { - const stickerRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'sticker-row').length; + {pickerRows.map((row, index) => { + const stickerRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'sticker-row').length; return (
; - attachmentSpecs: Array<[number, {width: number; height: number}] | undefined>; - groupSpacing: number; - totalHeight: number; -} - -const ScrollFillerSkeleton = observer( - forwardRef(function ScrollFillerSkeleton( - {messages, attachmentSpecs, groupSpacing, totalHeight}, - ref, - ) { - const seededRandom = useMemo( - () => (seed: number) => { - const x = Math.sin(seed) * 10000; - return x - Math.floor(x); - }, - [], - ); - return ( -