fix(app): size the chat skeleton from the remembered viewport height (#1843)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:29:26 +02:00
1 parent bd381ccc74
commit 01a29d629b
9 files changed
+88 -49

No files matched your search

@@ -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) {
@@ -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),
@@ -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<MutablePlaceholderMessageGroup> = [];
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<number> = [];
@@ -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,
});
}
@@ -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,
@@ -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 {
@@ -52,10 +52,7 @@ function CozyScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-group-row
@@ -173,10 +170,7 @@ function CompactScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-compact-messages
@@ -242,7 +236,7 @@ export const ScrollFillerSkeleton = ({
}: PlaceholderSpecs) => (
<flx-message-list-skeleton
className={flxElementClassName(styles.wrapper, compact && styles.wrapperCompact)}
style={{minHeight: remFromPx(totalHeight)}}
style={{height: remFromPx(totalHeight)}}
data-flx="app.skeleton.scroll-filler-skeleton.wrapper"
>
{groups.map((group, groupIndex) => {
@@ -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({
@@ -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<ThemeVariableDefinition> = [
{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<string> = [
"--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<Record<string,
"--message-failed-indicator-font-size": "0.75rem",
"--message-failed-indicator-gap": "0.375rem",
"--message-failed-opacity": "0.5",
"--message-filler-padding-bottom": "0.75rem",
"--message-group-spacing": "1rem",
"--message-gutter": "0.75rem",
"--message-highlight-bar-width": "0.125rem",
@@ -2339,6 +2343,7 @@ export const THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES: Readonly<Record<string,
"--message-failed-indicator-font-size": "0.75rem",
"--message-failed-indicator-gap": "0.375rem",
"--message-failed-opacity": "0.5",
"--message-filler-padding-bottom": "0.75rem",
"--message-group-spacing": "1rem",
"--message-gutter": "0.75rem",
"--message-highlight-bar-width": "0.125rem",