mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): drive the message list window from one load state machine (#1903)
This commit is contained in:
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';
|
||||||
|
}
|
||||||
Reference in new issue
Block a user