diff --git a/fluxer_app/src/app/globals.css b/fluxer_app/src/app/globals.css index fdbf0a5fe..79d86be76 100644 --- a/fluxer_app/src/app/globals.css +++ b/fluxer_app/src/app/globals.css @@ -186,6 +186,7 @@ var { --typing-indicator-height: 1rem; --typing-pill-height: 1rem; --slowmode-indicator-height: 1rem; + --message-filler-padding-bottom: 0.75rem; --scroller-spacer-height: 1.75rem; --typing-avatar-size: 0.75rem; --typing-indicator-animation-size: 1rem; @@ -681,9 +682,11 @@ img::before { } } -html.reduced-motion * { - animation-duration: 0.01ms; - animation-iteration-count: 1; - transition-duration: 0.01ms; - scroll-behavior: auto; +html.reduced-motion *, +html.reduced-motion *::before, +html.reduced-motion *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } diff --git a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css index cfb15461a..ee8eb0a3c 100644 --- a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css +++ b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.module.css @@ -182,7 +182,7 @@ overflow: hidden; padding-left: var(--chat-mobile-horizontal-padding); padding-right: var(--chat-mobile-horizontal-padding); - padding-bottom: var(--messages-bottom-clearance); + padding-bottom: calc(var(--messages-bottom-clearance) - var(--message-filler-padding-bottom)); } @media (min-width: 768px) { diff --git a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx index 753a0ff12..a8c7c7f60 100644 --- a/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx +++ b/fluxer_app/src/features/app/components/skeleton/ChatSkeleton.tsx @@ -1,7 +1,7 @@ // 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 {useMessageListPlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs'; import { ChatSkeletonChannelKind, type ChatSkeletonPresentation, @@ -34,8 +34,6 @@ 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); @@ -176,12 +174,11 @@ export const ChatSkeleton = observer(function ChatSkeleton({presentation}: ChatS composerActionCount = composerLayout.mobileActionCount; composerDividerVisible = false; } - const messageSpecs = usePlaceholderSpecs({ + const messageSpecs = useMessageListPlaceholderSpecs({ + channelId: presentation.channelId, 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), diff --git a/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts b/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts index 496f853b4..8f2f60d39 100644 --- a/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts +++ b/fluxer_app/src/features/app/components/skeleton/PlaceholderSpecs.ts @@ -1,9 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import { + getRememberedSkeletonMessagePresentation, + resolveDefaultSkeletonChatViewportHeightPx, +} from '@app/features/app/components/skeleton/SkeletonLayoutMemory'; 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'; +import {useMemo, useState} from 'react'; function pxFromRemLength(value: `${number}rem`): number { return Math.round(Number.parseFloat(value) * REM_BASE_PX); @@ -35,29 +39,35 @@ export interface PlaceholderSpecs { readonly groupSpacing: number; } +export const FILLER_WRAPPER_PADDING_TOP = '1rem' as const; +export const FILLER_WRAPPER_PADDING_BOTTOM = '0.75rem' as const; +export const FILLER_WRAPPER_VERTICAL_PADDING = + pxFromRemLength(FILLER_WRAPPER_PADDING_TOP) + pxFromRemLength(FILLER_WRAPPER_PADDING_BOTTOM); 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 ATTACHMENT_HEIGHT_MAX = 320; 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 MIN_MESSAGE_GROUPS = 10; +const MAX_MESSAGE_GROUPS = 30; const GROUP_LINE_RANGE = 4; const ATTACHMENT_GROUPS = 8; +const GROUP_COUNT_OVERSHOOT = 1.05; +const COZY_GROUP_COUNT_FACTOR = 0.87; +const MIN_ESTIMATED_VIEWPORT_HEIGHT = 400; +const MEAN_GROUP_LINE_COUNT = (1 + GROUP_LINE_RANGE) / 2; +const MESSAGE_LIST_FALLBACK_SEED_KEY = 'message-list-placeholder'; interface PlaceholderGenerationOptions { readonly compact: boolean; @@ -72,17 +82,22 @@ function randomInRange(random: () => number, min: number, max: number): number { return Math.floor(random() * (max - min + 1)) + min; } -function resolveShortestGroupHeight(compact: boolean): number { +export function resolveAverageGroupHeight(compact: boolean): number { if (compact) { - return MESSAGE_HEIGHT_COMPACT; + return MESSAGE_HEIGHT_COMPACT * MEAN_GROUP_LINE_COUNT; } - return COZY_LEAD_MESSAGE_HEIGHT; + return COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (MEAN_GROUP_LINE_COUNT - 1); } -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)); +export function resolvePlaceholderMessageGroups(compact: boolean, viewportHeightPx: number): number { + const usableHeight = Math.max(viewportHeightPx, MIN_ESTIMATED_VIEWPORT_HEIGHT); + const rows = Math.ceil(usableHeight / resolveAverageGroupHeight(compact)); + const clamped = Math.min(MAX_MESSAGE_GROUPS, Math.max(MIN_MESSAGE_GROUPS, Math.ceil(GROUP_COUNT_OVERSHOOT * rows))); + return compact ? clamped : Math.round(COZY_GROUP_COUNT_FACTOR * clamped); +} + +export function resolvePlaceholderAttachmentCount(messageGroups: number): number { + return Math.max(1, Math.round((messageGroups / MAX_MESSAGE_GROUPS) * ATTACHMENT_GROUPS)); } function resolvePlaceholderDensityKey(compact: boolean): string { @@ -95,7 +110,7 @@ function resolvePlaceholderDensityKey(compact: boolean): string { function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): PlaceholderSpecs { const {compact, compactAvatarsVisible, messageGroups, attachments, groupSpacing, random} = options; const groups: Array = []; - let totalHeight = compact ? 0 : COZY_SKELETON_VERTICAL_PADDING; + let totalHeight = FILLER_WRAPPER_VERTICAL_PADDING; for (let index = 0; index < messageGroups; index++) { const lineCount = Math.floor(random() * GROUP_LINE_RANGE) + 1; const lineWidths: Array = []; @@ -131,7 +146,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing}; } -export interface PlaceholderSpecsOptions { +interface PlaceholderSpecsOptions { readonly compact: boolean; readonly compactAvatarsVisible: boolean; readonly groupSpacing: number; @@ -139,7 +154,7 @@ export interface PlaceholderSpecsOptions { readonly seedKey: string; } -export function usePlaceholderSpecs({ +function usePlaceholderSpecs({ compact, compactAvatarsVisible, groupSpacing, @@ -153,10 +168,35 @@ export function usePlaceholderSpecs({ compact, compactAvatarsVisible, messageGroups, - attachments: Math.min(ATTACHMENT_GROUPS, messageGroups), + attachments: Math.min(messageGroups, resolvePlaceholderAttachmentCount(messageGroups)), groupSpacing, random: createSkeletonRandomFromKey([seedKey, resolvePlaceholderDensityKey(compact), groupSpacing].join('|')), }), [compact, compactAvatarsVisible, groupSpacing, messageGroups, seedKey], ); } + +export interface MessageListPlaceholderSpecsOptions { + readonly channelId: string | null; + readonly compact: boolean; + readonly compactAvatarsVisible: boolean; + readonly groupSpacing: number; +} + +export function useMessageListPlaceholderSpecs({ + channelId, + compact, + compactAvatarsVisible, + groupSpacing, +}: MessageListPlaceholderSpecsOptions): PlaceholderSpecs { + const [viewportHeightPx] = useState( + () => getRememberedSkeletonMessagePresentation()?.viewportHeightPx ?? resolveDefaultSkeletonChatViewportHeightPx(), + ); + return usePlaceholderSpecs({ + compact, + compactAvatarsVisible, + groupSpacing, + viewportHeightPx, + seedKey: channelId ?? MESSAGE_LIST_FALLBACK_SEED_KEY, + }); +} diff --git a/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts b/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts index 3cb5ffd1b..548326d74 100644 --- a/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts +++ b/fluxer_app/src/features/app/components/skeleton/ResolveChatSkeleton.ts @@ -22,6 +22,7 @@ export const ChatSkeletonChannelKind = SkeletonChannelProjectionKind; export type ChatSkeletonChannelKind = SkeletonChannelProjectionKind; export interface ChatSkeletonPresentation { + readonly channelId: string | null; readonly channelKind: ChatSkeletonChannelKind; readonly showMemberList: boolean; readonly showTopic: boolean; @@ -114,6 +115,7 @@ export function resolveChatSkeletonPresentation(pathname: string, canFitMemberLi headerTopicWidthPx = rememberedProjection.topicWidthPx; } return { + channelId, channelKind, showMemberList: resolveMemberListVisibility( channelId, diff --git a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css index f0d68947e..33f565583 100644 --- a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css +++ b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.module.css @@ -1,16 +1,19 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ .wrapper { + box-sizing: border-box; pointer-events: none; user-select: none; -webkit-user-select: none; width: 100%; display: flex; flex-direction: column; + flex-shrink: 0; position: relative; overflow: hidden; + contain: strict; padding-top: 1rem; - padding-bottom: 0.75rem; + padding-bottom: var(--message-filler-padding-bottom); 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)); @@ -44,7 +47,6 @@ .messageGroup { position: relative; z-index: 0; - content-visibility: auto; } .group { diff --git a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx index 0ef7ba97e..b96bc8ba6 100644 --- a/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx +++ b/fluxer_app/src/features/app/components/skeleton/ScrollFillerSkeleton.tsx @@ -52,10 +52,7 @@ function CozyScrollFillerSkeletonGroup({ return ( ( {groups.map((group, groupIndex) => { diff --git a/fluxer_app/src/features/channel/components/ChannelMessages.tsx b/fluxer_app/src/features/channel/components/ChannelMessages.tsx index 56544b506..1373bb9a2 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessages.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessages.tsx @@ -1,12 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; -import {usePlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs'; +import {useMessageListPlaceholderSpecs} 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 {reportSkeletonMessagePresentation} 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'; @@ -176,12 +173,11 @@ export const Messages = observer(function Messages({ const isModalOpen = Modal.hasModalOpen(); const isGatewayConnected = GatewayConnection.isConnected; const selectedChannelId = SelectedChannel.currentChannelId; - const placeholderSpecs = usePlaceholderSpecs({ + const placeholderSpecs = useMessageListPlaceholderSpecs({ + channelId: channel.id, 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({ diff --git a/fluxer_app/src/features/theme/variables/ThemeVariableManifest.ts b/fluxer_app/src/features/theme/variables/ThemeVariableManifest.ts index 88d446040..5693ff8c0 100644 --- a/fluxer_app/src/features/theme/variables/ThemeVariableManifest.ts +++ b/fluxer_app/src/features/theme/variables/ThemeVariableManifest.ts @@ -309,6 +309,7 @@ export type ThemeVariableName = | "--message-failed-indicator-font-size" | "--message-failed-indicator-gap" | "--message-failed-opacity" + | "--message-filler-padding-bottom" | "--message-group-spacing" | "--message-gutter" | "--message-highlight-bar-width" @@ -768,6 +769,7 @@ export const THEME_VARIABLES: ReadonlyArray = [ {name: "--message-failed-indicator-font-size", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "message-layout"}, {name: "--message-failed-indicator-gap", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "message-layout"}, {name: "--message-failed-opacity", kind: "number", groupId: "messages", groupLabel: "Messages", source: "features/theme/styles/Message.module.css"}, + {name: "--message-filler-padding-bottom", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "globals"}, {name: "--message-group-spacing", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "runtime-accessibility"}, {name: "--message-gutter", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "message-layout"}, {name: "--message-highlight-bar-width", kind: "dimension", groupId: "messages", groupLabel: "Messages", source: "features/theme/styles/Message.module.css"}, @@ -1228,6 +1230,7 @@ export const THEME_VARIABLE_NAMES: ReadonlyArray = [ "--message-failed-indicator-font-size", "--message-failed-indicator-gap", "--message-failed-opacity", + "--message-filler-padding-bottom", "--message-group-spacing", "--message-gutter", "--message-highlight-bar-width", @@ -1879,6 +1882,7 @@ export const THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES: Readonly