fix(app): drive the message list window from one load state machine (#1903)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:42:42 +02:00
1 parent aabc13e3cb
commit 95559f17d8
9 files changed
+606 -92

No files matched your search

@@ -115,9 +115,6 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
const unreadDividerBeforeMessageId = getUnreadDividerBeforeMessageId(pendingStreamItems, suppressUnreadIndicator); const unreadDividerBeforeMessageId = getUnreadDividerBeforeMessageId(pendingStreamItems, suppressUnreadIndicator);
const firstMessageHasUnreadDivider = unreadDividerBeforeMessageId === pendingMessages[0].id; const firstMessageHasUnreadDivider = unreadDividerBeforeMessageId === pendingMessages[0].id;
pushSpacerIfNeeded(groupKind, groupKey, firstMessageHasUnreadDivider); pushSpacerIfNeeded(groupKind, groupKey, firstMessageHasUnreadDivider);
const getUnreadDividerVisibility = (messageId: string, position: 'before' | 'after') => {
return position === 'before' && unreadDividerBeforeMessageId === messageId;
};
nodes.push( nodes.push(
<MessageGroup <MessageGroup
key={groupKey} key={groupKey}
@@ -127,7 +124,8 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
messageDisplayCompact={messageDisplayCompact} messageDisplayCompact={messageDisplayCompact}
flashKey={pendingFlashKey} flashKey={pendingFlashKey}
getUnreadDividerVisibility={getUnreadDividerVisibility} showUnreadDividerSlots={true}
unreadDividerBeforeMessageId={unreadDividerBeforeMessageId}
idPrefix="chat-messages" idPrefix="chat-messages"
messageRowClassName={messageRowClassName} messageRowClassName={messageRowClassName}
messageActionsClassName={messageActionsClassName} messageActionsClassName={messageActionsClassName}
@@ -40,7 +40,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.placeholderSpacer { .scrollerSpacerAfterFiller {
height: calc(var(--messages-bottom-clearance, var(--scroller-spacer-height)) - var(--message-filler-padding-bottom));
}
.fillerBuffer {
flex: 0 0 auto; flex: 0 0 auto;
height: 1rem; height: 1rem;
} }
@@ -18,8 +18,15 @@ import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescri
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessageGetter} from '@app/features/messaging/hooks/useMessageSelectionCopy'; import {useMessageSelectionCopyForMessageGetter} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import {
resolveChannelMessagesWindowStatus,
selectChannelMessagesFillerVisible,
selectChannelMessagesSpacerHeight,
selectChannelMessagesWindowBar,
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import MessageEdit from '@app/features/messaging/state/MessageEdit'; import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageFocus from '@app/features/messaging/state/MessageFocus'; import MessageFocus from '@app/features/messaging/state/MessageFocus';
import MessagesState from '@app/features/messaging/state/MessagingMessages'; import MessagesState from '@app/features/messaging/state/MessagingMessages';
@@ -51,6 +58,7 @@ import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils'; import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {runInAction} from 'mobx'; import {runInAction} from 'mobx';
import {observer, useLocalObservable} from 'mobx-react-lite'; import {observer, useLocalObservable} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
@@ -180,6 +188,16 @@ export const Messages = observer(function Messages({
groupSpacing: state.messageGroupSpacing, groupSpacing: state.messageGroupSpacing,
}); });
const safeMessages = state.messages ?? MessagesState.getMessages(channel.id); const safeMessages = state.messages ?? MessagesState.getMessages(channel.id);
const windowStatus = resolveChannelMessagesWindowStatus({
ready: safeMessages.ready,
loading: safeMessages.loadingMore,
failed: safeMessages.error,
messageCount: safeMessages.length,
hasMoreBefore: safeMessages.hasMoreBefore,
hasMoreAfter: safeMessages.hasMoreAfter,
});
const windowBar = selectChannelMessagesWindowBar(windowStatus);
const windowNeedsPage = windowStatus.needsPage;
const canAutoAck = shouldAutoAck({ const canAutoAck = shouldAutoAck({
channelActive: allowAutoAck, channelActive: allowAutoAck,
windowFocused: isWindowFocused, windowFocused: isWindowFocused,
@@ -220,7 +238,7 @@ export const Messages = observer(function Messages({
compact: state.messageDisplayCompact, compact: state.messageDisplayCompact,
hasPendingUnreads: state.unreadCount > 0, hasPendingUnreads: state.unreadCount > 0,
focusAnchorId: null, focusAnchorId: null,
unloadedSpacerHeight: placeholderSpecs.totalHeight, unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
allowHistoryFetch: true, allowHistoryFetch: true,
windowId, windowId,
notifyPinnedToBottom: () => { notifyPinnedToBottom: () => {
@@ -237,6 +255,10 @@ export const Messages = observer(function Messages({
canAutoAck, canAutoAck,
handleJumpHighlight, handleJumpHighlight,
}); });
const resolveMessageScrollSurface = useMemo(
() => () => scrollManager.ref.current?.getViewportElement() ?? null,
[scrollManager],
);
useEffect(() => { useEffect(() => {
ChannelMessages.retainChannel(channel.id); ChannelMessages.retainChannel(channel.id);
return () => { return () => {
@@ -370,9 +392,9 @@ export const Messages = observer(function Messages({
const data = (payload ?? {}) as {channelId?: string}; const data = (payload ?? {}) as {channelId?: string};
if (!data.channelId || data.channelId !== channel.id) return; if (!data.channelId || data.channelId !== channel.id) return;
const scroller = scrollManager.ref.current?.getViewportElement(); const scroller = scrollManager.ref.current?.getViewportElement();
const scrollerInner = scrollerInnerRef.current; const innerElement = scrollerInnerRef.current;
if (!scroller || !scrollerInner) return; if (!scroller || !innerElement) return;
const messageElements = scrollerInner.querySelectorAll<HTMLElement>('[data-message-id]'); const messageElements = innerElement.querySelectorAll<HTMLElement>('[data-message-id]');
if (!messageElements.length) return; if (!messageElements.length) return;
const scrollerRect = scroller.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect();
let bottomMostVisibleMessage: HTMLElement | null = null; let bottomMostVisibleMessage: HTMLElement | null = null;
@@ -424,16 +446,7 @@ export const Messages = observer(function Messages({
} }
}, [state.editingMessageId, scrollManager]); }, [state.editingMessageId, scrollManager]);
useEffect(() => { useEffect(() => {
const messages = state.messages; if (!windowNeedsPage || !isGatewayConnected || selectedChannelId !== channel.id) {
if (
!messages ||
messages.ready ||
messages.loadingMore ||
messages.error ||
messages.length > 0 ||
!isGatewayConnected ||
selectedChannelId !== channel.id
) {
if (recoveryFetchChannelIdRef.current === channel.id) { if (recoveryFetchChannelIdRef.current === channel.id) {
recoveryFetchChannelIdRef.current = null; recoveryFetchChannelIdRef.current = null;
} }
@@ -448,15 +461,7 @@ export const Messages = observer(function Messages({
recoveryFetchChannelIdRef.current = null; recoveryFetchChannelIdRef.current = null;
} }
}); });
}, [ }, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]);
channel.id,
isGatewayConnected,
selectedChannelId,
state.messages?.ready,
state.messages?.loadingMore,
state.messages?.error,
state.messageVersion,
]);
useMessageListKeyboardNavigation({ useMessageListKeyboardNavigation({
containerRef: scrollManager.ref, containerRef: scrollManager.ref,
channelId: channel.id, channelId: channel.id,
@@ -506,14 +511,14 @@ export const Messages = observer(function Messages({
}, [channel.id]); }, [channel.id]);
const spammerOverrideVersion = LocalUserSpamOverride.version; const spammerOverrideVersion = LocalUserSpamOverride.version;
const channelStream = useMemo<Array<ChannelStreamItem>>(() => { const channelStream = useMemo<Array<ChannelStreamItem>>(() => {
if (!state.messages?.ready) return []; if (!state.messages) return [];
return createChannelStream({ return createChannelStream({
channel, channel,
messages: state.messages, messages: state.messages,
oldestUnreadMessageId: state.visualUnreadMessageId, oldestUnreadMessageId: state.visualUnreadMessageId,
treatSpam: true, treatSpam: true,
}); });
}, [channel, state.messages?.ready, state.messageVersion, state.visualUnreadMessageId, spammerOverrideVersion]); }, [channel, state.messages, state.messageVersion, state.visualUnreadMessageId, spammerOverrideVersion]);
useEffect(() => { useEffect(() => {
const messages = state.messages; const messages = state.messages;
if (!messages?.ready) { if (!messages?.ready) {
@@ -552,7 +557,7 @@ export const Messages = observer(function Messages({
[channel.id, channel.guildId, state.permissionVersion], [channel.id, channel.guildId, state.permissionVersion],
); );
const streamMarkup = useMemo(() => { const streamMarkup = useMemo(() => {
if (!state.messages?.ready) return null; if (!state.messages) return null;
return renderChannelStream({ return renderChannelStream({
channelStream, channelStream,
messages: state.messages, messages: state.messages,
@@ -566,7 +571,7 @@ export const Messages = observer(function Messages({
}); });
}, [ }, [
channelStream, channelStream,
state.messages?.ready, state.messages,
channel, channel,
highlightedMessageId, highlightedMessageId,
state.messageDisplayCompact, state.messageDisplayCompact,
@@ -575,9 +580,7 @@ export const Messages = observer(function Messages({
onMessageEdit, onMessageEdit,
onReveal, onReveal,
]); ]);
const hasJumpToPresentBar = Boolean(state.messages?.ready && state.messages.hasMoreAfter); const hasBottomBar = windowBar !== 'none';
const hasLoadErrorBar = Boolean(state.messages?.error);
const hasBottomBar = hasJumpToPresentBar || hasLoadErrorBar;
useEffect(() => { useEffect(() => {
onBottomBarVisibilityChange?.(hasBottomBar); onBottomBarVisibilityChange?.(hasBottomBar);
}, [hasBottomBar, onBottomBarVisibilityChange]); }, [hasBottomBar, onBottomBarVisibilityChange]);
@@ -586,34 +589,33 @@ export const Messages = observer(function Messages({
onBottomBarVisibilityChange?.(false); onBottomBarVisibilityChange?.(false);
}; };
}, [onBottomBarVisibilityChange]); }, [onBottomBarVisibilityChange]);
const jumpToPresentBar = hasJumpToPresentBar ? ( const bottomBar =
<JumpToPresentBar windowBar === 'retry' ? (
loadingMore={state.messages.loadingMore} <LoadErrorBar
jumpedToPresent={state.messages.jumpedToPresent} loading={safeMessages.loadingMore}
onJumpToPresent={onScrollToPresent} onRetry={onRetryLoadMessages}
data-flx="channel.messages.jump-to-present-bar" data-flx="channel.messages.load-error-bar"
/> />
) : null; ) : windowBar === 'present' ? (
const loadErrorBar = hasLoadErrorBar ? ( <JumpToPresentBar
<LoadErrorBar loadingMore={safeMessages.loadingMore}
loading={state.messages.loadingMore} landedAtLiveEdge={safeMessages.landedAtLiveEdge}
onRetry={onRetryLoadMessages} onJumpToPresent={onScrollToPresent}
data-flx="channel.messages.load-error-bar" data-flx="channel.messages.jump-to-present-bar"
/> />
) : null; ) : null;
const showNewMessagesBar = Boolean(state.messages?.ready && state.unreadCount > 0); const showNewMessagesBar = Boolean(windowStatus.phase === 'stream' && state.unreadCount > 0);
const unreadTimestampMessageId = state.oldestUnreadMessageId ?? state.ackMessageId; const unreadTimestampMessageId = state.oldestUnreadMessageId ?? state.ackMessageId;
const topBar = showNewMessagesBar ? ( const topBar = showNewMessagesBar ? (
<NewMessagesBar <NewMessagesBar
unreadCount={state.unreadCount} unreadCount={state.unreadCount}
oldestUnreadTimestamp={unreadTimestampMessageId ? extractTimestamp(unreadTimestampMessageId) : 0} oldestUnreadMessageTimestamp={unreadTimestampMessageId ? extractTimestamp(unreadTimestampMessageId) : 0}
isEstimated={state.isEstimated} isEstimated={state.isEstimated}
onJumpToOldestUnread={onJumpToOldestUnread} onJumpToOldestUnread={onJumpToOldestUnread}
onJumpToNewMessages={onScrollToPresentAndAck} onJumpToNewMessages={onScrollToPresentAndAck}
data-flx="channel.messages.new-messages-bar" data-flx="channel.messages.new-messages-bar"
/> />
) : null; ) : null;
const readyMessages = state.messages?.ready ? state.messages : null;
const messagesWrapperStyle = useMemo<MessagesWrapperStyle>( const messagesWrapperStyle = useMemo<MessagesWrapperStyle>(
() => ({ () => ({
'--message-group-spacing': remFromPx(state.messageGroupSpacing), '--message-group-spacing': remFromPx(state.messageGroupSpacing),
@@ -638,24 +640,36 @@ export const Messages = observer(function Messages({
? i18n._(MESSAGE_LIST_FOR_DESCRIPTOR, {channelName: channel.name}) ? i18n._(MESSAGE_LIST_FOR_DESCRIPTOR, {channelName: channel.name})
: i18n._(MESSAGE_LIST_DESCRIPTOR); : i18n._(MESSAGE_LIST_DESCRIPTOR);
const messageListLiveMode = Accessibility.screenReaderAnnounceNewMessages && state.isAtBottom ? 'polite' : 'off'; const messageListLiveMode = Accessibility.screenReaderAnnounceNewMessages && state.isAtBottom ? 'polite' : 'off';
const scrollerInner = readyMessages ? ( const topFillerVisible = selectChannelMessagesFillerVisible({
reducedMotion: Accessibility.useReducedMotion,
scrollManagerInitialized: scrollManager.lifecycleIsInitialized(),
ready: safeMessages.ready,
});
const headFillerVisible = windowStatus.olderPageAvailable && topFillerVisible;
const tailFillerVisible = windowStatus.newerPageAvailable;
const streamHasRows = streamMarkup != null && streamMarkup.length > 0;
const spacerFollowsFiller = tailFillerVisible || (headFillerVisible && !streamHasRows);
const scrollerInner = (
<> <>
{!readyMessages.hasMoreBefore && ( {headFillerVisible && (
<ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton" {...placeholderSpecs} />
)}
{windowStatus.olderPageAvailable && safeMessages.length > 0 && (
<div className={styles.fillerBuffer} data-flx="channel.messages.filler-buffer" />
)}
{!windowStatus.olderPageAvailable && (
<ChannelWelcomeSection channel={channel} data-flx="channel.messages.channel-welcome-section" /> <ChannelWelcomeSection channel={channel} data-flx="channel.messages.channel-welcome-section" />
)} )}
{readyMessages.hasMoreBefore && (
<>
<div className={styles.placeholderSpacer} data-flx="channel.messages.placeholder-spacer" />
<ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton" {...placeholderSpecs} />
</>
)}
{streamMarkup} {streamMarkup}
{readyMessages.hasMoreAfter && ( {tailFillerVisible && (
<ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton--2" {...placeholderSpecs} /> <ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton--2" {...placeholderSpecs} />
)} )}
<div className={styles.scrollerSpacer} data-flx="channel.messages.scroller-spacer" /> <div
className={clsx(styles.scrollerSpacer, spacerFollowsFiller && styles.scrollerSpacerAfterFiller)}
data-flx="channel.messages.scroller-spacer"
/>
</> </>
) : null; );
return ( return (
<div className={styles.messagesWrapper} style={messagesWrapperStyle} data-flx="channel.messages.messages-wrapper"> <div className={styles.messagesWrapper} style={messagesWrapperStyle} data-flx="channel.messages.messages-wrapper">
<UploadManager <UploadManager
@@ -692,34 +706,36 @@ export const Messages = observer(function Messages({
aria-live={messageListLiveMode} aria-live={messageListLiveMode}
aria-relevant="additions text" aria-relevant="additions text"
aria-atomic="false" aria-atomic="false"
aria-busy={state.messages?.loadingMore ? true : undefined} aria-busy={safeMessages.loadingMore ? true : undefined}
data-flx="channel.messages.scroller-inner" data-flx="channel.messages.scroller-inner"
> >
<CollapsedMessageVisibilityProvider <NearViewportSurfaceContext.Provider value={resolveMessageScrollSurface}>
value={collapsedMessageVisibility} <CollapsedMessageVisibilityProvider
data-flx="channel.messages.collapsed-message-visibility-provider" value={collapsedMessageVisibility}
> data-flx="channel.messages.collapsed-message-visibility-provider"
{scrollerInner} >
</CollapsedMessageVisibilityProvider> {scrollerInner}
</CollapsedMessageVisibilityProvider>
</NearViewportSurfaceContext.Provider>
</div> </div>
</div> </div>
</Scroller> </Scroller>
</div> </div>
{loadErrorBar !== null ? loadErrorBar : jumpToPresentBar} {bottomBar}
</div> </div>
); );
}); });
const JumpToPresentBar = observer(function JumpToPresentBar({ const JumpToPresentBar = observer(function JumpToPresentBar({
loadingMore, loadingMore,
jumpedToPresent, landedAtLiveEdge,
onJumpToPresent, onJumpToPresent,
}: { }: {
loadingMore: boolean; loadingMore: boolean;
jumpedToPresent: boolean; landedAtLiveEdge: boolean;
onJumpToPresent: () => void; onJumpToPresent: () => void;
}) { }) {
const {i18n} = useLingui(); const {i18n} = useLingui();
const jumpIsActiveNow = loadingMore && jumpedToPresent; const jumpIsActiveNow = loadingMore && landedAtLiveEdge;
return ( return (
<div <div
className={[styles.newMessagesBar, styles.messageBottomPill, styles.jumpToPresentBar].join(' ')} className={[styles.newMessagesBar, styles.messageBottomPill, styles.jumpToPresentBar].join(' ')}
@@ -120,7 +120,7 @@ import type React from 'react';
import {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react';
const PLUS_MENU_DOUBLE_CLICK_MS = 500; const PLUS_MENU_DOUBLE_CLICK_MS = 500;
const MESSAGE_SCROLLER_SELECTOR = '[data-fluxer-scroll-container="true"]'; const MESSAGE_SCROLLER_SELECTOR = '[data-flx="channel.messages.scroller"][data-fluxer-scroll-container="true"]';
const MESSAGE_SCROLLER_BOTTOM_THRESHOLD = 16; const MESSAGE_SCROLLER_BOTTOM_THRESHOLD = 16;
const getActiveMessageScroller = (): HTMLElement | null => const getActiveMessageScroller = (): HTMLElement | null =>
document.querySelector<HTMLElement>(MESSAGE_SCROLLER_SELECTOR); document.querySelector<HTMLElement>(MESSAGE_SCROLLER_SELECTOR);
@@ -12,7 +12,7 @@ import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {Fragment, useMemo} from 'react'; import {Fragment, memo, useMemo} from 'react';
const MESSAGE_GROUP_DESCRIPTOR = msg({ const MESSAGE_GROUP_DESCRIPTOR = msg({
message: 'Message group', message: 'Message group',
@@ -31,11 +31,12 @@ export interface MessageGroupProps {
messages: Array<Message>; messages: Array<Message>;
channel: Channel; channel: Channel;
onEdit?: (targetNode: HTMLElement) => void; onEdit?: (targetNode: HTMLElement) => void;
jumpSequenceId?: number; jumpTicket?: number;
highlightedMessageId?: string | null; highlightedMessageId?: string | null;
messageDisplayCompact?: boolean; messageDisplayCompact?: boolean;
flashKey?: number; flashKey?: number;
getUnreadDividerVisibility?: (messageId: string, position: 'before' | 'after') => boolean; showUnreadDividerSlots?: boolean;
unreadDividerBeforeMessageId?: string | null;
idPrefix?: string; idPrefix?: string;
messageRowClassName?: string; messageRowClassName?: string;
messageActionsClassName?: string; messageActionsClassName?: string;
@@ -47,16 +48,17 @@ export interface MessageGroupProps {
getMessageHeadingActivate?: (message: Message) => (() => void) | undefined; getMessageHeadingActivate?: (message: Message) => (() => void) | undefined;
} }
export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => { const MessageGroupBase: React.FC<MessageGroupProps> = observer((props) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const { const {
messages, messages,
channel, channel,
onEdit, onEdit,
jumpSequenceId, jumpTicket,
highlightedMessageId, highlightedMessageId,
messageDisplayCompact = false, messageDisplayCompact = false,
getUnreadDividerVisibility, showUnreadDividerSlots = false,
unreadDividerBeforeMessageId = null,
idPrefix, idPrefix,
messageRowClassName, messageRowClassName,
messageActionsClassName, messageActionsClassName,
@@ -109,10 +111,10 @@ export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => {
); );
return ( return (
<Fragment key={message.id}> <Fragment key={message.id}>
{getUnreadDividerVisibility && ( {showUnreadDividerSlots && (
<UnreadDividerSlot <UnreadDividerSlot
beforeId={message.id} beforeId={message.id}
visible={getUnreadDividerVisibility(message.id, 'before')} visible={unreadDividerBeforeMessageId === message.id}
data-flx="channel.message-group.rendered-messages.unread-divider-slot" data-flx="channel.message-group.rendered-messages.unread-divider-slot"
/> />
)} )}
@@ -145,7 +147,8 @@ export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => {
highlightedMessageId, highlightedMessageId,
messageDisplayCompact, messageDisplayCompact,
idPrefix, idPrefix,
getUnreadDividerVisibility, showUnreadDividerSlots,
unreadDividerBeforeMessageId,
messageRowClassName, messageRowClassName,
messageActionsClassName, messageActionsClassName,
renderMessageActions, renderMessageActions,
@@ -158,7 +161,7 @@ export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => {
); );
return ( return (
<div <div
data-jump-sequence-id={jumpSequenceId} data-jump-sequence-id={jumpTicket}
data-group-id={groupId} data-group-id={groupId}
role="group" role="group"
aria-label={i18n._(MESSAGE_GROUP_DESCRIPTOR)} aria-label={i18n._(MESSAGE_GROUP_DESCRIPTOR)}
@@ -168,3 +171,36 @@ export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => {
</div> </div>
); );
}); });
function areMessagesEqual(previous: Array<Message>, next: Array<Message>): boolean {
if (previous === next) {
return true;
}
if (previous.length !== next.length) {
return false;
}
for (let index = 0; index < previous.length; index++) {
if (previous[index] !== next[index]) {
return false;
}
}
return true;
}
function arePropsEqual(previous: MessageGroupProps, next: MessageGroupProps): boolean {
const previousKeys = Object.keys(previous) as Array<keyof MessageGroupProps>;
if (previousKeys.length !== Object.keys(next).length) {
return false;
}
for (const key of previousKeys) {
if (key === 'messages') {
continue;
}
if (previous[key] !== next[key]) {
return false;
}
}
return areMessagesEqual(previous.messages, next.messages);
}
export const MessageGroup = memo(MessageGroupBase, arePropsEqual);
@@ -36,22 +36,22 @@ const MARK_READ_DESCRIPTOR = msg({
export const NewMessagesBar = observer( export const NewMessagesBar = observer(
({ ({
unreadCount, unreadCount,
oldestUnreadTimestamp, oldestUnreadMessageTimestamp,
isEstimated, isEstimated,
onJumpToOldestUnread, onJumpToOldestUnread,
onJumpToNewMessages, onJumpToNewMessages,
}: { }: {
unreadCount: number; unreadCount: number;
oldestUnreadTimestamp: number; oldestUnreadMessageTimestamp: number;
isEstimated: boolean; isEstimated: boolean;
onJumpToOldestUnread: () => void; onJumpToOldestUnread: () => void;
onJumpToNewMessages: () => void; onJumpToNewMessages: () => void;
}) => { }) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const isMobile = MobileLayout.isMobileLayout(); const isMobile = MobileLayout.isMobileLayout();
const sameDay = isSameDayBase(oldestUnreadTimestamp); const sameDay = isSameDayBase(oldestUnreadMessageTimestamp);
const compactTime = DateUtils.getFormattedCompactDateTime(oldestUnreadTimestamp); const compactTime = DateUtils.getFormattedCompactDateTime(oldestUnreadMessageTimestamp);
const shortTime = sameDay ? DateUtils.getFormattedTime(oldestUnreadTimestamp) : compactTime; const shortTime = sameDay ? DateUtils.getFormattedTime(oldestUnreadMessageTimestamp) : compactTime;
return ( return (
<div className={styles.newMessagesBarBanner} data-flx="channel.new-messages-bar.new-messages-bar"> <div className={styles.newMessagesBarBanner} data-flx="channel.new-messages-bar.new-messages-bar">
<button <button
@@ -293,7 +293,6 @@ export class ChannelMessages {
jumpDestinationOffset = 0; jumpDestinationOffset = 0;
jumpTicket = 1; jumpTicket = 1;
hasJumped = false; hasJumped = false;
jumpedToPresent = false;
landedAtLiveEdge = false; landedAtLiveEdge = false;
jumpHighlight = true; jumpHighlight = true;
jumpReturnMessageId: string | null = null; jumpReturnMessageId: string | null = null;
@@ -3,10 +3,19 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {
type ChannelMessagesLoadInput, type ChannelMessagesLoadInput,
type ChannelMessagesWindowInput,
type ChannelMessagesWindowStatus,
createChannelMessagesLoadSnapshot, createChannelMessagesLoadSnapshot,
createChannelMessagesWindowSnapshot,
resolveChannelMessagesLoadDecision, resolveChannelMessagesLoadDecision,
resolveChannelMessagesWindowStatus,
selectChannelMessagesFillerVisible,
selectChannelMessagesLoadDecision, selectChannelMessagesLoadDecision,
selectChannelMessagesSpacerHeight,
selectChannelMessagesWindowBar,
selectChannelMessagesWindowStatus,
transitionChannelMessagesLoadSnapshot, transitionChannelMessagesLoadSnapshot,
transitionChannelMessagesWindowSnapshot,
} from './ChannelMessagesLoadStateMachine'; } from './ChannelMessagesLoadStateMachine';
function input(overrides: Partial<ChannelMessagesLoadInput> = {}): ChannelMessagesLoadInput { function input(overrides: Partial<ChannelMessagesLoadInput> = {}): ChannelMessagesLoadInput {
@@ -74,3 +83,309 @@ describe('channelMessagesLoadMachine', () => {
expect(selectChannelMessagesLoadDecision(replacementSnapshot)).toMatchObject({mode: 'replace'}); expect(selectChannelMessagesLoadDecision(replacementSnapshot)).toMatchObject({mode: 'replace'});
}); });
}); });
function windowInput(overrides: Partial<ChannelMessagesWindowInput> = {}): ChannelMessagesWindowInput {
return {
ready: false,
loading: false,
failed: false,
messageCount: 0,
hasMoreBefore: true,
hasMoreAfter: false,
...overrides,
};
}
function drawsAFiller(status: ChannelMessagesWindowStatus): boolean {
return status.olderPageAvailable || status.newerPageAvailable;
}
describe('channelMessagesWindowMachine', () => {
it('holds a fresh channel in the placeholder phase and asks for a page', () => {
expect(resolveChannelMessagesWindowStatus(windowInput())).toEqual({
phase: 'placeholder',
olderPageAvailable: true,
newerPageAvailable: false,
needsPage: true,
retryVisible: false,
});
expect(resolveChannelMessagesWindowStatus(windowInput({loading: true}))).toEqual({
phase: 'placeholder',
olderPageAvailable: true,
newerPageAvailable: false,
needsPage: false,
retryVisible: false,
});
});
it('streams a loaded page with its own paging edges', () => {
expect(
resolveChannelMessagesWindowStatus(
windowInput({ready: true, messageCount: 50, hasMoreBefore: true, hasMoreAfter: true}),
),
).toEqual({
phase: 'stream',
olderPageAvailable: true,
newerPageAvailable: true,
needsPage: false,
retryVisible: false,
});
expect(selectChannelMessagesWindowBar(resolveChannelMessagesWindowStatus(windowInput({ready: true})))).toBe('none');
});
it('streams a fully loaded empty channel so the welcome header keeps rendering', () => {
const status = resolveChannelMessagesWindowStatus(
windowInput({ready: true, hasMoreBefore: false, hasMoreAfter: false}),
);
expect(status).toEqual({
phase: 'stream',
olderPageAvailable: false,
newerPageAvailable: false,
needsPage: false,
retryVisible: false,
});
expect(drawsAFiller(status)).toBe(false);
expect(selectChannelMessagesWindowBar(status)).toBe('none');
});
it('prefers the retry phase over a placeholder when the page failed with nothing loaded', () => {
const status = resolveChannelMessagesWindowStatus(windowInput({failed: true, hasMoreAfter: true}));
expect(status).toEqual({
phase: 'retry',
olderPageAvailable: true,
newerPageAvailable: true,
needsPage: false,
retryVisible: true,
});
expect(drawsAFiller(status)).toBe(true);
expect(selectChannelMessagesWindowBar(status)).toBe('retry');
});
it('keeps a loaded page visible when a follow-up page fails and swaps the bar for retry', () => {
const status = resolveChannelMessagesWindowStatus(
windowInput({ready: true, failed: true, messageCount: 50, hasMoreAfter: true}),
);
expect(status).toEqual({
phase: 'stream',
olderPageAvailable: true,
newerPageAvailable: true,
needsPage: false,
retryVisible: true,
});
expect(selectChannelMessagesWindowBar(status)).toBe('retry');
});
it('decides the filler from the paging edges alone, never from the phase', () => {
for (const ready of [false, true]) {
for (const loading of [false, true]) {
for (const failed of [false, true]) {
for (const messageCount of [0, 50]) {
for (const hasMoreBefore of [false, true]) {
for (const hasMoreAfter of [false, true]) {
const status = resolveChannelMessagesWindowStatus({
ready,
loading,
failed,
messageCount,
hasMoreBefore,
hasMoreAfter,
});
expect(drawsAFiller(status)).toBe(hasMoreBefore || hasMoreAfter);
expect(status.phase === 'placeholder' && selectChannelMessagesWindowBar(status) !== 'none').toBe(false);
}
}
}
}
}
}
});
it('collapses the empty around-page reply into a placeholder that asks for a page', () => {
let snapshot = createChannelMessagesWindowSnapshot(windowInput());
expect(selectChannelMessagesWindowStatus(snapshot)).toEqual({
phase: 'placeholder',
olderPageAvailable: true,
newerPageAvailable: false,
needsPage: true,
retryVisible: false,
});
snapshot = transitionChannelMessagesWindowSnapshot(snapshot, {
type: 'channelMessagesWindow.updated',
input: windowInput({loading: true}),
});
expect(selectChannelMessagesWindowStatus(snapshot)).toEqual({
phase: 'placeholder',
olderPageAvailable: true,
newerPageAvailable: false,
needsPage: false,
retryVisible: false,
});
snapshot = transitionChannelMessagesWindowSnapshot(snapshot, {
type: 'channelMessagesWindow.updated',
input: windowInput({ready: true, messageCount: 0, hasMoreBefore: true, hasMoreAfter: true}),
});
const afterEmptyAroundPage = selectChannelMessagesWindowStatus(snapshot);
expect(afterEmptyAroundPage).toEqual({
phase: 'placeholder',
olderPageAvailable: true,
newerPageAvailable: true,
needsPage: true,
retryVisible: false,
});
expect(drawsAFiller(afterEmptyAroundPage)).toBe(true);
expect(selectChannelMessagesWindowBar(afterEmptyAroundPage)).toBe('none');
snapshot = transitionChannelMessagesWindowSnapshot(snapshot, {
type: 'channelMessagesWindow.updated',
input: windowInput({ready: true, messageCount: 0, hasMoreBefore: false, hasMoreAfter: false}),
});
expect(selectChannelMessagesWindowStatus(snapshot)).toEqual({
phase: 'stream',
olderPageAvailable: false,
newerPageAvailable: false,
needsPage: false,
retryVisible: false,
});
});
});
const FILLER_HEIGHT = 240;
function loadedWindowInput(overrides: Partial<ChannelMessagesWindowInput> = {}): ChannelMessagesWindowInput {
return {
ready: true,
loading: false,
failed: false,
messageCount: 40,
hasMoreBefore: false,
hasMoreAfter: false,
...overrides,
};
}
function spacerFor(overrides: Partial<ChannelMessagesWindowInput> = {}): number {
return selectChannelMessagesSpacerHeight(
resolveChannelMessagesWindowStatus(loadedWindowInput(overrides)),
FILLER_HEIGHT,
);
}
describe('selectChannelMessagesSpacerHeight', () => {
it('reports the filler height during retry too, because retry now draws the same list', () => {
const status = resolveChannelMessagesWindowStatus(
loadedWindowInput({ready: false, failed: true, messageCount: 0, hasMoreBefore: true, hasMoreAfter: true}),
);
expect(status.phase).toBe('retry');
expect(selectChannelMessagesSpacerHeight(status, FILLER_HEIGHT)).toBe(FILLER_HEIGHT);
});
it('withholds the filler height during retry once both paging edges are closed', () => {
const status = resolveChannelMessagesWindowStatus(
loadedWindowInput({ready: false, failed: true, messageCount: 0, hasMoreBefore: false, hasMoreAfter: false}),
);
expect(status.phase).toBe('retry');
expect(selectChannelMessagesSpacerHeight(status, FILLER_HEIGHT)).toBe(0);
});
it('withholds the filler height from a stream with both paging edges closed', () => {
expect(spacerFor({hasMoreBefore: false, hasMoreAfter: false})).toBe(0);
});
it('reports the filler height for whichever single stream edge is still open', () => {
expect(spacerFor({hasMoreBefore: true, hasMoreAfter: false})).toBe(FILLER_HEIGHT);
expect(spacerFor({hasMoreBefore: false, hasMoreAfter: true})).toBe(FILLER_HEIGHT);
expect(spacerFor({hasMoreBefore: true, hasMoreAfter: true})).toBe(FILLER_HEIGHT);
});
it('reports the filler height for a placeholder whose older edge is open', () => {
expect(spacerFor({ready: false, messageCount: 0, hasMoreBefore: true})).toBe(FILLER_HEIGHT);
expect(spacerFor({ready: false, loading: true, messageCount: 0, hasMoreBefore: true})).toBe(FILLER_HEIGHT);
});
it('withholds it from a placeholder with both paging edges closed, which draws no filler', () => {
expect(spacerFor({ready: false, messageCount: 0, hasMoreBefore: false, hasMoreAfter: false})).toBe(0);
});
it('is the filler height exactly when a paging edge is open, in every phase', () => {
const seen = new Set<string>();
let zeroCases = 0;
let fillerCases = 0;
for (const ready of [false, true]) {
for (const loading of [false, true]) {
for (const failed of [false, true]) {
for (const messageCount of [0, 40]) {
for (const hasMoreBefore of [false, true]) {
for (const hasMoreAfter of [false, true]) {
const input = {ready, loading, failed, messageCount, hasMoreBefore, hasMoreAfter};
const status = resolveChannelMessagesWindowStatus(input);
const height = selectChannelMessagesSpacerHeight(status, FILLER_HEIGHT);
expect(height).toBe(hasMoreBefore || hasMoreAfter ? FILLER_HEIGHT : 0);
seen.add(status.phase);
if (height === 0) zeroCases += 1;
else fillerCases += 1;
}
}
}
}
}
}
expect([...seen].sort()).toEqual(['placeholder', 'retry', 'stream']);
expect(zeroCases).toBeGreaterThan(0);
expect(fillerCases).toBeGreaterThan(0);
});
});
describe('selectChannelMessagesFillerVisible', () => {
it('always draws the filler when motion is not reduced', () => {
for (const scrollManagerInitialized of [false, true]) {
for (const ready of [false, true]) {
expect(selectChannelMessagesFillerVisible({reducedMotion: false, scrollManagerInitialized, ready})).toBe(true);
}
}
});
it('holds the filler back under reduced motion until the manager is initialized or the page is ready', () => {
expect(
selectChannelMessagesFillerVisible({reducedMotion: true, scrollManagerInitialized: false, ready: false}),
).toBe(false);
expect(
selectChannelMessagesFillerVisible({reducedMotion: true, scrollManagerInitialized: true, ready: false}),
).toBe(true);
expect(
selectChannelMessagesFillerVisible({reducedMotion: true, scrollManagerInitialized: false, ready: true}),
).toBe(true);
expect(selectChannelMessagesFillerVisible({reducedMotion: true, scrollManagerInitialized: true, ready: true})).toBe(
true,
);
});
it('cannot suppress the stream filler, because the stream phase is only reachable when the page is ready', () => {
let streamCases = 0;
for (const ready of [false, true]) {
for (const loading of [false, true]) {
for (const failed of [false, true]) {
for (const messageCount of [0, 40]) {
for (const hasMoreBefore of [false, true]) {
for (const hasMoreAfter of [false, true]) {
const input = {ready, loading, failed, messageCount, hasMoreBefore, hasMoreAfter};
if (resolveChannelMessagesWindowStatus(input).phase !== 'stream') continue;
streamCases += 1;
expect(input.ready).toBe(true);
expect(
selectChannelMessagesFillerVisible({
reducedMotion: true,
scrollManagerInitialized: false,
ready: input.ready,
}),
).toBe(true);
}
}
}
}
}
}
expect(streamCases).toBeGreaterThan(0);
});
});
@@ -140,3 +140,149 @@ export function selectChannelMessagesLoadDecision(snapshot: ChannelMessagesLoadS
export function resolveChannelMessagesLoadDecision(input: ChannelMessagesLoadInput): ChannelMessagesLoadDecision { export function resolveChannelMessagesLoadDecision(input: ChannelMessagesLoadInput): ChannelMessagesLoadDecision {
return buildChannelMessagesLoadDecision(getLoadModeFromInput(input)); return buildChannelMessagesLoadDecision(getLoadModeFromInput(input));
} }
export interface ChannelMessagesWindowInput {
ready: boolean;
loading: boolean;
failed: boolean;
messageCount: number;
hasMoreBefore: boolean;
hasMoreAfter: boolean;
}
export type ChannelMessagesWindowStatus = {
phase: ChannelMessagesWindowPhase;
olderPageAvailable: boolean;
newerPageAvailable: boolean;
needsPage: boolean;
retryVisible: boolean;
};
export type ChannelMessagesWindowPhase = 'placeholder' | 'retry' | 'stream';
export type ChannelMessagesWindowBar = 'none' | 'retry' | 'present';
export type ChannelMessagesWindowEvent = {
type: 'channelMessagesWindow.updated';
input: ChannelMessagesWindowInput;
};
function hasLoadedWindow(context: ChannelMessagesWindowInput): boolean {
if (!context.ready) return false;
if (context.messageCount > 0) return true;
return !context.hasMoreBefore && !context.hasMoreAfter;
}
function getWindowPhase(snapshot: ChannelMessagesWindowSnapshot): ChannelMessagesWindowPhase {
switch (snapshot.value) {
case 'stream':
return 'stream';
case 'retry':
return 'retry';
default:
return 'placeholder';
}
}
function getWindowPhaseFromInput(input: ChannelMessagesWindowInput): ChannelMessagesWindowPhase {
if (hasLoadedWindow(input)) return 'stream';
if (input.failed) return 'retry';
return 'placeholder';
}
function buildChannelMessagesWindowStatus(
phase: ChannelMessagesWindowPhase,
input: ChannelMessagesWindowInput,
): ChannelMessagesWindowStatus {
return {
phase,
olderPageAvailable: input.hasMoreBefore,
newerPageAvailable: input.hasMoreAfter,
needsPage: phase === 'placeholder' && !input.loading,
retryVisible: input.failed,
};
}
export const channelMessagesWindowMachine = setup({
types: {} as {
context: ChannelMessagesWindowInput;
events: ChannelMessagesWindowEvent;
input: ChannelMessagesWindowInput;
},
actions: {
applyInput: assign(({event}) => {
if (event.type !== 'channelMessagesWindow.updated') return {};
return event.input;
}),
},
guards: {
hasLoadedWindow: ({context}) => hasLoadedWindow(context),
hasFailedLoad: ({context}) => context.failed,
},
}).createMachine({
id: 'channelMessagesWindow',
context: ({input}) => input,
initial: 'routing',
states: {
routing: {
always: [
{guard: 'hasLoadedWindow', target: 'stream'},
{guard: 'hasFailedLoad', target: 'retry'},
{target: 'placeholder'},
],
},
stream: {
on: {'channelMessagesWindow.updated': {target: 'routing', actions: 'applyInput'}},
},
retry: {
on: {'channelMessagesWindow.updated': {target: 'routing', actions: 'applyInput'}},
},
placeholder: {
on: {'channelMessagesWindow.updated': {target: 'routing', actions: 'applyInput'}},
},
},
});
export type ChannelMessagesWindowSnapshot = SnapshotFrom<typeof channelMessagesWindowMachine>;
export function createChannelMessagesWindowSnapshot(input: ChannelMessagesWindowInput): ChannelMessagesWindowSnapshot {
return getInitialSnapshot(channelMessagesWindowMachine, input);
}
export function transitionChannelMessagesWindowSnapshot(
snapshot: ChannelMessagesWindowSnapshot,
event: ChannelMessagesWindowEvent,
): ChannelMessagesWindowSnapshot {
return transition(channelMessagesWindowMachine, snapshot, event)[0] as ChannelMessagesWindowSnapshot;
}
export function selectChannelMessagesWindowStatus(
snapshot: ChannelMessagesWindowSnapshot,
): ChannelMessagesWindowStatus {
return buildChannelMessagesWindowStatus(getWindowPhase(snapshot), snapshot.context);
}
export function resolveChannelMessagesWindowStatus(input: ChannelMessagesWindowInput): ChannelMessagesWindowStatus {
return buildChannelMessagesWindowStatus(getWindowPhaseFromInput(input), input);
}
export interface ChannelMessagesFillerMotionInput {
reducedMotion: boolean;
scrollManagerInitialized: boolean;
ready: boolean;
}
export function selectChannelMessagesFillerVisible(input: ChannelMessagesFillerMotionInput): boolean {
if (!input.reducedMotion) return true;
return input.scrollManagerInitialized || input.ready;
}
export function selectChannelMessagesSpacerHeight(status: ChannelMessagesWindowStatus, fillerHeight: number): number {
return status.olderPageAvailable || status.newerPageAvailable ? fillerHeight : 0;
}
export function selectChannelMessagesWindowBar(status: ChannelMessagesWindowStatus): ChannelMessagesWindowBar {
if (status.phase === 'placeholder') return 'none';
if (status.phase === 'retry' || status.retryVisible) return 'retry';
return status.newerPageAvailable ? 'present' : 'none';
}