diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index ed9993791..9eebf40e9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5462,9 +5462,12 @@ button.module-image__border-overlay:focus { } .module-timeline__messages { + display: flex; + flex-direction: column; flex: 1 1; padding-bottom: 6px; position: relative; + justify-content: flex-end; // This is a modified version of ["Pin Scrolling to Bottom"][0]. // [0]: https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ @@ -5481,6 +5484,10 @@ button.module-image__border-overlay:focus { } } + &--have-oldest { + justify-content: flex-start; + } + &__at-bottom-detector { position: absolute; bottom: 0; diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index a1f4a7dca..0f36714c0 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -505,10 +505,6 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ 'isIncomingMessageRequest', overrideProps.isIncomingMessageRequest === true ), - isLoadingMessages: boolean( - 'isLoadingMessages', - overrideProps.isLoadingMessages === false - ), items: overrideProps.items || Object.keys(items), scrollToIndex: overrideProps.scrollToIndex, scrollToIndexCounter: 0, diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 50194d616..3609addac 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -32,9 +32,12 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions'; import { TimelineFloatingHeader } from './TimelineFloatingHeader'; +import type { TimelineMessageLoadingState } from '../../util/timelineUtil'; import { - getWidthBreakpoint, + ScrollAnchor, UnreadIndicatorPlacement, + getScrollAnchorBeforeUpdate, + getWidthBreakpoint, } from '../../util/timelineUtil'; import { getScrollBottom, @@ -80,7 +83,7 @@ export type ContactSpoofingReviewPropType = export type PropsDataType = { haveNewest: boolean; haveOldest: boolean; - isLoadingMessages: boolean; + messageLoadingState?: TimelineMessageLoadingState; isNearBottom?: boolean; items: ReadonlyArray; oldestUnreadIndex?: number; @@ -325,9 +328,9 @@ export class Timeline extends React.Component< const { haveNewest, id, - isLoadingMessages, items, loadNewestMessages, + messageLoadingState, oldestUnreadIndex, selectMessage, } = this.props; @@ -337,7 +340,7 @@ export class Timeline extends React.Component< return; } - if (isLoadingMessages) { + if (messageLoadingState) { this.scrollToBottom(setFocus); return; } @@ -366,9 +369,13 @@ export class Timeline extends React.Component< private isAtBottom(): boolean { const containerEl = this.containerRef.current; - return Boolean( - containerEl && getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD - ); + if (!containerEl) { + return false; + } + const isScrolledNearBottom = + getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD; + const hasScrollbars = containerEl.clientHeight < containerEl.scrollHeight; + return isScrolledNearBottom || !hasScrollbars; } private updateIntersectionObserver(): void { @@ -383,10 +390,10 @@ export class Timeline extends React.Component< haveNewest, haveOldest, id, - isLoadingMessages, items, loadNewerMessages, loadOlderMessages, + messageLoadingState, setIsNearBottom, } = this.props; @@ -466,7 +473,7 @@ export class Timeline extends React.Component< this.markNewestBottomVisibleMessageRead(); if ( - !isLoadingMessages && + !messageLoadingState && !haveNewest && newestBottomVisibleMessageId === last(items) ) { @@ -475,7 +482,7 @@ export class Timeline extends React.Component< } if ( - !isLoadingMessages && + !messageLoadingState && !haveOldest && oldestPartiallyVisibleMessageId && oldestPartiallyVisibleMessageId === items[0] @@ -548,69 +555,38 @@ export class Timeline extends React.Component< return null; } - const { - isLoadingMessages: wasLoadingMessages, - isSomeoneTyping: wasSomeoneTyping, - items: oldItems, - scrollToIndexCounter: oldScrollToIndexCounter, - } = prevProps; - const { - isIncomingMessageRequest, - isLoadingMessages, - isSomeoneTyping, - items: newItems, - oldestUnreadIndex, - scrollToIndex, - scrollToIndexCounter: newScrollToIndexCounter, - } = this.props; + const { props } = this; + const { scrollToIndex } = props; - const isDoingInitialLoad = isLoadingMessages && newItems.length === 0; - const wasDoingInitialLoad = wasLoadingMessages && oldItems.length === 0; - const justFinishedInitialLoad = wasDoingInitialLoad && !isDoingInitialLoad; + const scrollAnchor = getScrollAnchorBeforeUpdate( + prevProps, + props, + this.isAtBottom() + ); - if (isDoingInitialLoad) { - return null; - } - - if ( - isNumber(scrollToIndex) && - (oldScrollToIndexCounter !== newScrollToIndexCounter || - justFinishedInitialLoad) - ) { - return { scrollToIndex }; - } - - if (justFinishedInitialLoad) { - if (isIncomingMessageRequest) { - return { scrollTop: 0 }; - } - if (isNumber(oldestUnreadIndex)) { + switch (scrollAnchor) { + case ScrollAnchor.ChangeNothing: + return null; + case ScrollAnchor.ScrollToBottom: + return { scrollBottom: 0 }; + case ScrollAnchor.ScrollToIndex: + if (scrollToIndex === undefined) { + assert( + false, + ' got "scroll to index" scroll anchor, but no index' + ); + return null; + } + return { scrollToIndex }; + case ScrollAnchor.ScrollToUnreadIndicator: return scrollToUnreadIndicator; - } - return { scrollBottom: 0 }; + case ScrollAnchor.Top: + return { scrollTop: containerEl.scrollTop }; + case ScrollAnchor.Bottom: + return { scrollBottom: getScrollBottom(containerEl) }; + default: + throw missingCaseError(scrollAnchor); } - - if (isSomeoneTyping !== wasSomeoneTyping && this.isAtBottom()) { - return { scrollBottom: 0 }; - } - - // This method assumes that item operations happen one at a time. For example, items - // are not added and removed in the same render pass. - if (oldItems.length === newItems.length) { - return null; - } - - let scrollAnchor: 'top' | 'bottom'; - if (this.isAtBottom()) { - const justLoadedAPage = wasLoadingMessages && !isLoadingMessages; - scrollAnchor = justLoadedAPage ? 'top' : 'bottom'; - } else { - scrollAnchor = last(oldItems) !== last(newItems) ? 'top' : 'bottom'; - } - - return scrollAnchor === 'top' - ? { scrollTop: containerEl.scrollTop } - : { scrollBottom: getScrollBottom(containerEl) }; } public override componentDidUpdate( @@ -771,9 +747,9 @@ export class Timeline extends React.Component< invitedContactsForNewlyCreatedGroup, isConversationSelected, isGroupV1AndDisabled, - isLoadingMessages, isSomeoneTyping, items, + messageLoadingState, oldestUnreadIndex, onBlock, onBlockAndReportSpam, @@ -844,6 +820,7 @@ export class Timeline extends React.Component< oldestPartiallyVisibleMessageId && oldestPartiallyVisibleMessageTimestamp ) { + const isLoadingMessages = Boolean(messageLoadingState); floatingHeader = ( @@ -1112,7 +1090,7 @@ export class Timeline extends React.Component< {messageNodes} - {isSomeoneTyping && renderTypingBubble(id)} + {isSomeoneTyping && haveNewest && renderTypingBubble(id)}
{ - const { messagesReset, setMessagesLoading } = + const { messagesReset, setMessageLoadingState } = window.reduxActions.conversations; const conversationId = this.id; - setMessagesLoading(conversationId, true); + setMessageLoadingState( + conversationId, + TimelineMessageLoadingState.DoingInitialLoad + ); const finish = this.setInProgressFetch(); try { @@ -1476,18 +1480,21 @@ export class ConversationModel extends window.Backbone unboundedFetch, }); } catch (error) { - setMessagesLoading(conversationId, false); + setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); } } async loadOlderMessages(oldestMessageId: string): Promise { - const { messagesAdded, setMessagesLoading, repairOldestMessage } = + const { messagesAdded, setMessageLoadingState, repairOldestMessage } = window.reduxActions.conversations; const conversationId = this.id; - setMessagesLoading(conversationId, true); + setMessageLoadingState( + conversationId, + TimelineMessageLoadingState.LoadingOlderMessages + ); const finish = this.setInProgressFetch(); try { @@ -1514,6 +1521,7 @@ export class ConversationModel extends window.Backbone } const cleaned = await this.cleanModels(models); + messagesAdded({ conversationId, messages: cleaned.map((messageModel: MessageModel) => ({ @@ -1524,7 +1532,7 @@ export class ConversationModel extends window.Backbone isNewMessage: false, }); } catch (error) { - setMessagesLoading(conversationId, true); + setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); @@ -1532,11 +1540,14 @@ export class ConversationModel extends window.Backbone } async loadNewerMessages(newestMessageId: string): Promise { - const { messagesAdded, setMessagesLoading, repairNewestMessage } = + const { messagesAdded, setMessageLoadingState, repairNewestMessage } = window.reduxActions.conversations; const conversationId = this.id; - setMessagesLoading(conversationId, true); + setMessageLoadingState( + conversationId, + TimelineMessageLoadingState.LoadingNewerMessages + ); const finish = this.setInProgressFetch(); try { @@ -1572,7 +1583,7 @@ export class ConversationModel extends window.Backbone isNewMessage: false, }); } catch (error) { - setMessagesLoading(conversationId, false); + setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); @@ -1583,11 +1594,14 @@ export class ConversationModel extends window.Backbone messageId: string, options?: { disableScroll?: boolean } ): Promise { - const { messagesReset, setMessagesLoading } = + const { messagesReset, setMessageLoadingState } = window.reduxActions.conversations; const conversationId = this.id; - setMessagesLoading(conversationId, true); + setMessageLoadingState( + conversationId, + TimelineMessageLoadingState.DoingInitialLoad + ); const finish = this.setInProgressFetch(); try { @@ -1623,7 +1637,7 @@ export class ConversationModel extends window.Backbone scrollToMessageId, }); } catch (error) { - setMessagesLoading(conversationId, false); + setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 0f3f00b23..50833c753 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -82,6 +82,7 @@ import { useBoundActions } from '../../hooks/useBoundActions'; import type { NoopActionType } from './noop'; import { conversationJobQueue } from '../../jobs/conversationJobQueue'; +import type { TimelineMessageLoadingState } from '../../util/timelineUtil'; // State @@ -242,9 +243,9 @@ export type MessageLookupType = { [key: string]: MessageWithUIFieldsType; }; export type ConversationMessageType = { - isLoadingMessages: boolean; isNearBottom?: boolean; messageIds: Array; + messageLoadingState?: undefined | TimelineMessageLoadingState; metrics: MessageMetricsType; scrollToMessageId?: string; scrollToMessageCounter: number; @@ -592,11 +593,11 @@ export type MessagesResetActionType = { unboundedFetch: boolean; }; }; -export type SetMessagesLoadingActionType = { - type: 'SET_MESSAGES_LOADING'; +export type SetMessageLoadingStateActionType = { + type: 'SET_MESSAGE_LOADING_STATE'; payload: { conversationId: string; - isLoadingMessages: boolean; + messageLoadingState: undefined | TimelineMessageLoadingState; }; }; export type SetIsNearBottomActionType = { @@ -772,7 +773,7 @@ export type ConversationActionType = | SetConversationHeaderTitleActionType | SetIsFetchingUsernameActionType | SetIsNearBottomActionType - | SetMessagesLoadingActionType + | SetMessageLoadingStateActionType | SetPreJoinConversationActionType | SetRecentMediaItemsActionType | SetSelectedConversationPanelDepthActionType @@ -838,7 +839,7 @@ export const actions = { setComposeGroupName, setComposeSearchTerm, setIsNearBottom, - setMessagesLoading, + setMessageLoadingState, setPreJoinConversation, setRecentMediaItems, setSelectedConversationHeaderTitle, @@ -1634,15 +1635,15 @@ function messagesReset({ }, }; } -function setMessagesLoading( +function setMessageLoadingState( conversationId: string, - isLoadingMessages: boolean -): SetMessagesLoadingActionType { + messageLoadingState: undefined | TimelineMessageLoadingState +): SetMessageLoadingStateActionType { return { - type: 'SET_MESSAGES_LOADING', + type: 'SET_MESSAGE_LOADING_STATE', payload: { conversationId, - isLoadingMessages, + messageLoadingState, }, }; } @@ -2599,7 +2600,6 @@ export function reducer( messagesByConversation: { ...messagesByConversation, [conversationId]: { - isLoadingMessages: false, scrollToMessageId, scrollToMessageCounter: existingConversation ? existingConversation.scrollToMessageCounter + 1 @@ -2614,9 +2614,9 @@ export function reducer( }, }; } - if (action.type === 'SET_MESSAGES_LOADING') { + if (action.type === 'SET_MESSAGE_LOADING_STATE') { const { payload } = action; - const { conversationId, isLoadingMessages } = payload; + const { conversationId, messageLoadingState } = payload; const { messagesByConversation } = state; const existingConversation = messagesByConversation[conversationId]; @@ -2631,7 +2631,7 @@ export function reducer( ...messagesByConversation, [conversationId]: { ...existingConversation, - isLoadingMessages, + messageLoadingState, }, }, }; @@ -2686,7 +2686,7 @@ export function reducer( ...messagesByConversation, [conversationId]: { ...existingConversation, - isLoadingMessages: false, + messageLoadingState: undefined, scrollToMessageId: messageId, scrollToMessageCounter: existingConversation.scrollToMessageCounter + 1, @@ -2949,8 +2949,8 @@ export function reducer( ...messagesByConversation, [conversationId]: { ...existingConversation, - isLoadingMessages: false, messageIds, + messageLoadingState: undefined, scrollToMessageId: isJustSent ? last.id : undefined, metrics: { ...existingConversation.metrics, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 39554c72a..50d9e929a 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -57,6 +57,7 @@ import { getActiveCall, getCallSelector } from './calling'; import type { AccountSelectorType } from './accounts'; import { getAccountSelector } from './accounts'; import * as log from '../../logging/log'; +import { TimelineMessageLoadingState } from '../../util/timelineUtil'; let placeholderContact: ConversationType; export const getPlaceholderContact = (): ConversationType => { @@ -815,12 +816,12 @@ export function _conversationMessagesSelector( conversation: ConversationMessageType ): TimelinePropsType { const { - isLoadingMessages, isNearBottom, messageIds, + messageLoadingState, metrics, - scrollToMessageId, scrollToMessageCounter, + scrollToMessageId, } = conversation; const firstId = messageIds[0]; @@ -846,9 +847,9 @@ export function _conversationMessagesSelector( return { haveNewest, haveOldest, - isLoadingMessages, isNearBottom, items, + messageLoadingState, oldestUnreadIndex: isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0 ? oldestUnreadIndex @@ -887,7 +888,7 @@ export const getConversationMessagesSelector = createSelector( return { haveNewest: false, haveOldest: false, - isLoadingMessages: true, + messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad, scrollToIndexCounter: 0, totalUnread: 0, items: [], diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts index 762e39e18..a32290d79 100644 --- a/ts/test-both/util/timelineUtil_test.ts +++ b/ts/test-both/util/timelineUtil_test.ts @@ -2,9 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { times } from 'lodash'; import { v4 as uuid } from 'uuid'; import { MINUTE, SECOND } from '../../util/durations'; -import { areMessagesInSameGroup } from '../../util/timelineUtil'; +import { + ScrollAnchor, + areMessagesInSameGroup, + getScrollAnchorBeforeUpdate, + TimelineMessageLoadingState, +} from '../../util/timelineUtil'; describe(' utilities', () => { describe('areMessagesInSameGroup', () => { @@ -113,4 +119,328 @@ describe(' utilities', () => { assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer)); }); }); + + describe('getScrollAnchorBeforeUpdate', () => { + const fakeItems = (count: number) => times(count, () => uuid()); + + const defaultProps = { + haveNewest: true, + isIncomingMessageRequest: false, + isSomeoneTyping: false, + items: fakeItems(10), + scrollToIndexCounter: 0, + } as const; + + describe('during initial load', () => { + it('does nothing if messages are loading for the first time', () => { + const prevProps = { + ...defaultProps, + haveNewest: false, + items: [], + messageLoadingStates: TimelineMessageLoadingState.DoingInitialLoad, + }; + const props = { ...prevProps, isSomeoneTyping: true }; + const isAtBottom = true; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + }); + + it('scrolls to an index when applicable', () => { + const props1 = defaultProps; + const props2 = { + ...defaultProps, + scrollToIndex: 123, + scrollToIndexCounter: 1, + }; + const props3 = { + ...defaultProps, + scrollToIndex: 123, + scrollToIndexCounter: 2, + }; + const props4 = { + ...defaultProps, + scrollToIndex: 456, + scrollToIndexCounter: 2, + }; + const isAtBottom = false; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(props1, props2, isAtBottom), + ScrollAnchor.ScrollToIndex + ); + assert.strictEqual( + getScrollAnchorBeforeUpdate(props2, props3, isAtBottom), + ScrollAnchor.ScrollToIndex + ); + assert.strictEqual( + getScrollAnchorBeforeUpdate(props3, props4, isAtBottom), + ScrollAnchor.ScrollToIndex + ); + }); + + describe('when initial load completes', () => { + const defaultPrevProps = { + ...defaultProps, + haveNewest: false, + items: [], + messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad, + }; + const isAtBottom = true; + + it('does nothing if there are no items', () => { + const props = { ...defaultProps, items: [] }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(defaultPrevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + + it('scrolls to the item index if applicable', () => { + const prevProps = { ...defaultPrevProps, scrollToIndex: 3 }; + const props = { + ...defaultProps, + items: fakeItems(10), + scrollToIndex: 3, + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ScrollToIndex + ); + }); + + it("does nothing if it's an incoming message request", () => { + const prevProps = { + ...defaultPrevProps, + isIncomingMessageRequest: true, + }; + const props = { + ...defaultProps, + items: fakeItems(10), + isIncomingMessageRequest: true, + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + + it('scrolls to the unread indicator if one exists', () => { + const props = { + ...defaultProps, + items: fakeItems(10), + oldestUnreadIndex: 3, + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(defaultPrevProps, props, isAtBottom), + ScrollAnchor.ScrollToUnreadIndicator + ); + }); + + it('scrolls to the bottom in normal cases', () => { + const props = { + ...defaultProps, + items: fakeItems(3), + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(defaultPrevProps, props, isAtBottom), + ScrollAnchor.ScrollToBottom + ); + }); + }); + + describe('when a page of messages is loaded at the top', () => { + it('uses bottom-anchored scrolling', () => { + const oldItems = fakeItems(5); + const prevProps = { + ...defaultProps, + messageLoadingState: TimelineMessageLoadingState.LoadingOlderMessages, + items: oldItems, + }; + const props = { + ...defaultProps, + items: [...fakeItems(10), ...oldItems], + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, false), + ScrollAnchor.Bottom + ); + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, true), + ScrollAnchor.Bottom + ); + }); + }); + + describe('when a page of messages is loaded at the bottom', () => { + it('uses top-anchored scrolling', () => { + const oldItems = fakeItems(5); + const prevProps = { + ...defaultProps, + messageLoadingState: TimelineMessageLoadingState.LoadingNewerMessages, + items: oldItems, + }; + const props = { + ...defaultProps, + items: [...oldItems, ...fakeItems(10)], + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, false), + ScrollAnchor.Top + ); + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, true), + ScrollAnchor.Top + ); + }); + }); + + describe('when a new message comes in', () => { + const oldItems = fakeItems(5); + const prevProps = { ...defaultProps, items: oldItems }; + const props = { ...defaultProps, items: [...oldItems, uuid()] }; + + it('does nothing if not scrolled to the bottom', () => { + const isAtBottom = false; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + + it('stays at the bottom if already there', () => { + const isAtBottom = true; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ScrollToBottom + ); + }); + }); + + describe('when items are removed', () => { + const oldItems = fakeItems(5); + const prevProps = { ...defaultProps, items: oldItems }; + + const propsWithSomethingRemoved = [ + { ...defaultProps, items: oldItems.slice(1) }, + { + ...defaultProps, + items: oldItems.filter((_value, index) => index !== 2), + }, + { ...defaultProps, items: oldItems.slice(0, -1) }, + ]; + + it('does nothing if not scrolled to the bottom', () => { + const isAtBottom = false; + + propsWithSomethingRemoved.forEach(props => { + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + }); + + it('stays at the bottom if already there', () => { + const isAtBottom = true; + + propsWithSomethingRemoved.forEach(props => { + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ScrollToBottom + ); + }); + }); + }); + + describe('when the typing indicator appears', () => { + const prevProps = defaultProps; + + it("does nothing if we don't have the newest messages (and therefore shouldn't show the indicator)", () => { + [true, false].forEach(isAtBottom => { + const props = { + ...defaultProps, + haveNewest: false, + isSomeoneTyping: true, + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + }); + + it('does nothing if not scrolled to the bottom', () => { + const props = { ...defaultProps, isSomeoneTyping: true }; + const isAtBottom = false; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + + it('uses bottom-anchored scrolling if scrolled to the bottom', () => { + const props = { ...defaultProps, isSomeoneTyping: true }; + const isAtBottom = true; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ScrollToBottom + ); + }); + }); + + describe('when the typing indicator disappears', () => { + const prevProps = { ...defaultProps, isSomeoneTyping: true }; + + it("does nothing if we don't have the newest messages (and therefore shouldn't show the indicator)", () => { + [true, false].forEach(isAtBottom => { + const props = { + ...defaultProps, + haveNewest: false, + isSomeoneTyping: false, + }; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + }); + + it('does nothing if not scrolled to the bottom', () => { + const props = { ...defaultProps, isSomeoneTyping: false }; + const isAtBottom = false; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ChangeNothing + ); + }); + + it('uses bottom-anchored scrolling if scrolled to the bottom', () => { + const props = { ...defaultProps, isSomeoneTyping: false }; + const isAtBottom = true; + + assert.strictEqual( + getScrollAnchorBeforeUpdate(prevProps, props, isAtBottom), + ScrollAnchor.ScrollToBottom + ); + }); + }); + }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 897fe0504..3345c2383 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -330,7 +330,6 @@ describe('both/state/ducks/conversations', () => { function getDefaultConversationMessage(): ConversationMessageType { return { - isLoadingMessages: false, messageIds: [], metrics: { totalUnread: 0, diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts index 323c7239c..81899cab1 100644 --- a/ts/util/timelineUtil.ts +++ b/ts/util/timelineUtil.ts @@ -1,13 +1,32 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isNumber } from 'lodash'; +import type { PropsType as TimelinePropsType } from '../components/conversation/Timeline'; import type { TimelineItemType } from '../components/conversation/TimelineItem'; import { WidthBreakpoint } from '../components/_util'; import { MINUTE } from './durations'; +import { missingCaseError } from './missingCaseError'; import { isSameDay } from './timestamp'; const COLLAPSE_WITHIN = 3 * MINUTE; +export enum TimelineMessageLoadingState { + // We start the enum at 1 because the default starting value of 0 is falsy. + DoingInitialLoad = 1, + LoadingOlderMessages, + LoadingNewerMessages, +} + +export enum ScrollAnchor { + ChangeNothing, + ScrollToBottom, + ScrollToIndex, + ScrollToUnreadIndicator, + Top, + Bottom, +} + export enum UnreadIndicatorPlacement { JustAbove, JustBelow, @@ -60,6 +79,74 @@ export function areMessagesInSameGroup( ); } +type ScrollAnchorBeforeUpdateProps = Readonly< + Pick< + TimelinePropsType, + | 'haveNewest' + | 'isIncomingMessageRequest' + | 'isSomeoneTyping' + | 'items' + | 'messageLoadingState' + | 'oldestUnreadIndex' + | 'scrollToIndex' + | 'scrollToIndexCounter' + > +>; + +export function getScrollAnchorBeforeUpdate( + prevProps: ScrollAnchorBeforeUpdateProps, + props: ScrollAnchorBeforeUpdateProps, + isAtBottom: boolean +): ScrollAnchor { + if (props.messageLoadingState || !props.items.length) { + return ScrollAnchor.ChangeNothing; + } + + const loadingStateThatJustFinished: undefined | TimelineMessageLoadingState = + !props.messageLoadingState && prevProps.messageLoadingState + ? prevProps.messageLoadingState + : undefined; + + if ( + isNumber(props.scrollToIndex) && + (loadingStateThatJustFinished === + TimelineMessageLoadingState.DoingInitialLoad || + prevProps.scrollToIndex !== props.scrollToIndex || + prevProps.scrollToIndexCounter !== props.scrollToIndexCounter) + ) { + return ScrollAnchor.ScrollToIndex; + } + + switch (loadingStateThatJustFinished) { + case TimelineMessageLoadingState.DoingInitialLoad: + if (props.isIncomingMessageRequest) { + return ScrollAnchor.ChangeNothing; + } + if (isNumber(props.oldestUnreadIndex)) { + return ScrollAnchor.ScrollToUnreadIndicator; + } + return ScrollAnchor.ScrollToBottom; + case TimelineMessageLoadingState.LoadingOlderMessages: + return ScrollAnchor.Bottom; + case TimelineMessageLoadingState.LoadingNewerMessages: + return ScrollAnchor.Top; + case undefined: { + const didSomethingChange = + prevProps.items.length !== props.items.length || + (props.haveNewest && + prevProps.isSomeoneTyping !== props.isSomeoneTyping); + if (didSomethingChange && isAtBottom) { + return ScrollAnchor.ScrollToBottom; + } + break; + } + default: + throw missingCaseError(loadingStateThatJustFinished); + } + + return ScrollAnchor.ChangeNothing; +} + export function getWidthBreakpoint(width: number): WidthBreakpoint { if (width > 606) { return WidthBreakpoint.Wide;