If not enough messages are loaded (on tall screens), fix jankiness

Co-authored-by: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2022-03-11 14:53:08 -08:00 committed by GitHub
parent b471e4f360
commit ef4435ca1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 523 additions and 111 deletions

View File

@ -5462,9 +5462,12 @@ button.module-image__border-overlay:focus {
} }
.module-timeline__messages { .module-timeline__messages {
display: flex;
flex-direction: column;
flex: 1 1; flex: 1 1;
padding-bottom: 6px; padding-bottom: 6px;
position: relative; position: relative;
justify-content: flex-end;
// This is a modified version of ["Pin Scrolling to Bottom"][0]. // This is a modified version of ["Pin Scrolling to Bottom"][0].
// [0]: https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ // [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 { &__at-bottom-detector {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@ -505,10 +505,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
'isIncomingMessageRequest', 'isIncomingMessageRequest',
overrideProps.isIncomingMessageRequest === true overrideProps.isIncomingMessageRequest === true
), ),
isLoadingMessages: boolean(
'isLoadingMessages',
overrideProps.isLoadingMessages === false
),
items: overrideProps.items || Object.keys(items), items: overrideProps.items || Object.keys(items),
scrollToIndex: overrideProps.scrollToIndex, scrollToIndex: overrideProps.scrollToIndex,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,

View File

@ -32,9 +32,12 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions'; import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
import { TimelineFloatingHeader } from './TimelineFloatingHeader'; import { TimelineFloatingHeader } from './TimelineFloatingHeader';
import type { TimelineMessageLoadingState } from '../../util/timelineUtil';
import { import {
getWidthBreakpoint, ScrollAnchor,
UnreadIndicatorPlacement, UnreadIndicatorPlacement,
getScrollAnchorBeforeUpdate,
getWidthBreakpoint,
} from '../../util/timelineUtil'; } from '../../util/timelineUtil';
import { import {
getScrollBottom, getScrollBottom,
@ -80,7 +83,7 @@ export type ContactSpoofingReviewPropType =
export type PropsDataType = { export type PropsDataType = {
haveNewest: boolean; haveNewest: boolean;
haveOldest: boolean; haveOldest: boolean;
isLoadingMessages: boolean; messageLoadingState?: TimelineMessageLoadingState;
isNearBottom?: boolean; isNearBottom?: boolean;
items: ReadonlyArray<string>; items: ReadonlyArray<string>;
oldestUnreadIndex?: number; oldestUnreadIndex?: number;
@ -325,9 +328,9 @@ export class Timeline extends React.Component<
const { const {
haveNewest, haveNewest,
id, id,
isLoadingMessages,
items, items,
loadNewestMessages, loadNewestMessages,
messageLoadingState,
oldestUnreadIndex, oldestUnreadIndex,
selectMessage, selectMessage,
} = this.props; } = this.props;
@ -337,7 +340,7 @@ export class Timeline extends React.Component<
return; return;
} }
if (isLoadingMessages) { if (messageLoadingState) {
this.scrollToBottom(setFocus); this.scrollToBottom(setFocus);
return; return;
} }
@ -366,9 +369,13 @@ export class Timeline extends React.Component<
private isAtBottom(): boolean { private isAtBottom(): boolean {
const containerEl = this.containerRef.current; const containerEl = this.containerRef.current;
return Boolean( if (!containerEl) {
containerEl && getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD return false;
); }
const isScrolledNearBottom =
getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD;
const hasScrollbars = containerEl.clientHeight < containerEl.scrollHeight;
return isScrolledNearBottom || !hasScrollbars;
} }
private updateIntersectionObserver(): void { private updateIntersectionObserver(): void {
@ -383,10 +390,10 @@ export class Timeline extends React.Component<
haveNewest, haveNewest,
haveOldest, haveOldest,
id, id,
isLoadingMessages,
items, items,
loadNewerMessages, loadNewerMessages,
loadOlderMessages, loadOlderMessages,
messageLoadingState,
setIsNearBottom, setIsNearBottom,
} = this.props; } = this.props;
@ -466,7 +473,7 @@ export class Timeline extends React.Component<
this.markNewestBottomVisibleMessageRead(); this.markNewestBottomVisibleMessageRead();
if ( if (
!isLoadingMessages && !messageLoadingState &&
!haveNewest && !haveNewest &&
newestBottomVisibleMessageId === last(items) newestBottomVisibleMessageId === last(items)
) { ) {
@ -475,7 +482,7 @@ export class Timeline extends React.Component<
} }
if ( if (
!isLoadingMessages && !messageLoadingState &&
!haveOldest && !haveOldest &&
oldestPartiallyVisibleMessageId && oldestPartiallyVisibleMessageId &&
oldestPartiallyVisibleMessageId === items[0] oldestPartiallyVisibleMessageId === items[0]
@ -548,69 +555,38 @@ export class Timeline extends React.Component<
return null; return null;
} }
const { const { props } = this;
isLoadingMessages: wasLoadingMessages, const { scrollToIndex } = props;
isSomeoneTyping: wasSomeoneTyping,
items: oldItems,
scrollToIndexCounter: oldScrollToIndexCounter,
} = prevProps;
const {
isIncomingMessageRequest,
isLoadingMessages,
isSomeoneTyping,
items: newItems,
oldestUnreadIndex,
scrollToIndex,
scrollToIndexCounter: newScrollToIndexCounter,
} = this.props;
const isDoingInitialLoad = isLoadingMessages && newItems.length === 0; const scrollAnchor = getScrollAnchorBeforeUpdate(
const wasDoingInitialLoad = wasLoadingMessages && oldItems.length === 0; prevProps,
const justFinishedInitialLoad = wasDoingInitialLoad && !isDoingInitialLoad; props,
this.isAtBottom()
);
if (isDoingInitialLoad) { switch (scrollAnchor) {
return null; case ScrollAnchor.ChangeNothing:
} return null;
case ScrollAnchor.ScrollToBottom:
if ( return { scrollBottom: 0 };
isNumber(scrollToIndex) && case ScrollAnchor.ScrollToIndex:
(oldScrollToIndexCounter !== newScrollToIndexCounter || if (scrollToIndex === undefined) {
justFinishedInitialLoad) assert(
) { false,
return { scrollToIndex }; '<Timeline> got "scroll to index" scroll anchor, but no index'
} );
return null;
if (justFinishedInitialLoad) { }
if (isIncomingMessageRequest) { return { scrollToIndex };
return { scrollTop: 0 }; case ScrollAnchor.ScrollToUnreadIndicator:
}
if (isNumber(oldestUnreadIndex)) {
return scrollToUnreadIndicator; return scrollToUnreadIndicator;
} case ScrollAnchor.Top:
return { scrollBottom: 0 }; 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( public override componentDidUpdate(
@ -771,9 +747,9 @@ export class Timeline extends React.Component<
invitedContactsForNewlyCreatedGroup, invitedContactsForNewlyCreatedGroup,
isConversationSelected, isConversationSelected,
isGroupV1AndDisabled, isGroupV1AndDisabled,
isLoadingMessages,
isSomeoneTyping, isSomeoneTyping,
items, items,
messageLoadingState,
oldestUnreadIndex, oldestUnreadIndex,
onBlock, onBlock,
onBlockAndReportSpam, onBlockAndReportSpam,
@ -844,6 +820,7 @@ export class Timeline extends React.Component<
oldestPartiallyVisibleMessageId && oldestPartiallyVisibleMessageId &&
oldestPartiallyVisibleMessageTimestamp oldestPartiallyVisibleMessageTimestamp
) { ) {
const isLoadingMessages = Boolean(messageLoadingState);
floatingHeader = ( floatingHeader = (
<TimelineFloatingHeader <TimelineFloatingHeader
i18n={i18n} i18n={i18n}
@ -1097,7 +1074,8 @@ export class Timeline extends React.Component<
<div <div
className={classNames( className={classNames(
'module-timeline__messages', 'module-timeline__messages',
haveNewest && 'module-timeline__messages--have-newest' haveNewest && 'module-timeline__messages--have-newest',
haveOldest && 'module-timeline__messages--have-oldest'
)} )}
ref={this.messagesRef} ref={this.messagesRef}
> >
@ -1112,7 +1090,7 @@ export class Timeline extends React.Component<
{messageNodes} {messageNodes}
{isSomeoneTyping && renderTypingBubble(id)} {isSomeoneTyping && haveNewest && renderTypingBubble(id)}
<div <div
className="module-timeline__messages__at-bottom-detector" className="module-timeline__messages__at-bottom-detector"

View File

@ -113,6 +113,7 @@ import * as Errors from '../types/errors';
import { isMessageUnread } from '../util/isMessageUnread'; import { isMessageUnread } from '../util/isMessageUnread';
import type { SenderKeyTargetType } from '../util/sendToGroup'; import type { SenderKeyTargetType } from '../util/sendToGroup';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { TimelineMessageLoadingState } from '../util/timelineUtil';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -1409,11 +1410,14 @@ export class ConversationModel extends window.Backbone
newestMessageId: string | undefined, newestMessageId: string | undefined,
setFocus: boolean | undefined setFocus: boolean | undefined
): Promise<void> { ): Promise<void> {
const { messagesReset, setMessagesLoading } = const { messagesReset, setMessageLoadingState } =
window.reduxActions.conversations; window.reduxActions.conversations;
const conversationId = this.id; const conversationId = this.id;
setMessagesLoading(conversationId, true); setMessageLoadingState(
conversationId,
TimelineMessageLoadingState.DoingInitialLoad
);
const finish = this.setInProgressFetch(); const finish = this.setInProgressFetch();
try { try {
@ -1476,18 +1480,21 @@ export class ConversationModel extends window.Backbone
unboundedFetch, unboundedFetch,
}); });
} catch (error) { } catch (error) {
setMessagesLoading(conversationId, false); setMessageLoadingState(conversationId, undefined);
throw error; throw error;
} finally { } finally {
finish(); finish();
} }
} }
async loadOlderMessages(oldestMessageId: string): Promise<void> { async loadOlderMessages(oldestMessageId: string): Promise<void> {
const { messagesAdded, setMessagesLoading, repairOldestMessage } = const { messagesAdded, setMessageLoadingState, repairOldestMessage } =
window.reduxActions.conversations; window.reduxActions.conversations;
const conversationId = this.id; const conversationId = this.id;
setMessagesLoading(conversationId, true); setMessageLoadingState(
conversationId,
TimelineMessageLoadingState.LoadingOlderMessages
);
const finish = this.setInProgressFetch(); const finish = this.setInProgressFetch();
try { try {
@ -1514,6 +1521,7 @@ export class ConversationModel extends window.Backbone
} }
const cleaned = await this.cleanModels(models); const cleaned = await this.cleanModels(models);
messagesAdded({ messagesAdded({
conversationId, conversationId,
messages: cleaned.map((messageModel: MessageModel) => ({ messages: cleaned.map((messageModel: MessageModel) => ({
@ -1524,7 +1532,7 @@ export class ConversationModel extends window.Backbone
isNewMessage: false, isNewMessage: false,
}); });
} catch (error) { } catch (error) {
setMessagesLoading(conversationId, true); setMessageLoadingState(conversationId, undefined);
throw error; throw error;
} finally { } finally {
finish(); finish();
@ -1532,11 +1540,14 @@ export class ConversationModel extends window.Backbone
} }
async loadNewerMessages(newestMessageId: string): Promise<void> { async loadNewerMessages(newestMessageId: string): Promise<void> {
const { messagesAdded, setMessagesLoading, repairNewestMessage } = const { messagesAdded, setMessageLoadingState, repairNewestMessage } =
window.reduxActions.conversations; window.reduxActions.conversations;
const conversationId = this.id; const conversationId = this.id;
setMessagesLoading(conversationId, true); setMessageLoadingState(
conversationId,
TimelineMessageLoadingState.LoadingNewerMessages
);
const finish = this.setInProgressFetch(); const finish = this.setInProgressFetch();
try { try {
@ -1572,7 +1583,7 @@ export class ConversationModel extends window.Backbone
isNewMessage: false, isNewMessage: false,
}); });
} catch (error) { } catch (error) {
setMessagesLoading(conversationId, false); setMessageLoadingState(conversationId, undefined);
throw error; throw error;
} finally { } finally {
finish(); finish();
@ -1583,11 +1594,14 @@ export class ConversationModel extends window.Backbone
messageId: string, messageId: string,
options?: { disableScroll?: boolean } options?: { disableScroll?: boolean }
): Promise<void> { ): Promise<void> {
const { messagesReset, setMessagesLoading } = const { messagesReset, setMessageLoadingState } =
window.reduxActions.conversations; window.reduxActions.conversations;
const conversationId = this.id; const conversationId = this.id;
setMessagesLoading(conversationId, true); setMessageLoadingState(
conversationId,
TimelineMessageLoadingState.DoingInitialLoad
);
const finish = this.setInProgressFetch(); const finish = this.setInProgressFetch();
try { try {
@ -1623,7 +1637,7 @@ export class ConversationModel extends window.Backbone
scrollToMessageId, scrollToMessageId,
}); });
} catch (error) { } catch (error) {
setMessagesLoading(conversationId, false); setMessageLoadingState(conversationId, undefined);
throw error; throw error;
} finally { } finally {
finish(); finish();

View File

@ -82,6 +82,7 @@ import { useBoundActions } from '../../hooks/useBoundActions';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
import { conversationJobQueue } from '../../jobs/conversationJobQueue'; import { conversationJobQueue } from '../../jobs/conversationJobQueue';
import type { TimelineMessageLoadingState } from '../../util/timelineUtil';
// State // State
@ -242,9 +243,9 @@ export type MessageLookupType = {
[key: string]: MessageWithUIFieldsType; [key: string]: MessageWithUIFieldsType;
}; };
export type ConversationMessageType = { export type ConversationMessageType = {
isLoadingMessages: boolean;
isNearBottom?: boolean; isNearBottom?: boolean;
messageIds: Array<string>; messageIds: Array<string>;
messageLoadingState?: undefined | TimelineMessageLoadingState;
metrics: MessageMetricsType; metrics: MessageMetricsType;
scrollToMessageId?: string; scrollToMessageId?: string;
scrollToMessageCounter: number; scrollToMessageCounter: number;
@ -592,11 +593,11 @@ export type MessagesResetActionType = {
unboundedFetch: boolean; unboundedFetch: boolean;
}; };
}; };
export type SetMessagesLoadingActionType = { export type SetMessageLoadingStateActionType = {
type: 'SET_MESSAGES_LOADING'; type: 'SET_MESSAGE_LOADING_STATE';
payload: { payload: {
conversationId: string; conversationId: string;
isLoadingMessages: boolean; messageLoadingState: undefined | TimelineMessageLoadingState;
}; };
}; };
export type SetIsNearBottomActionType = { export type SetIsNearBottomActionType = {
@ -772,7 +773,7 @@ export type ConversationActionType =
| SetConversationHeaderTitleActionType | SetConversationHeaderTitleActionType
| SetIsFetchingUsernameActionType | SetIsFetchingUsernameActionType
| SetIsNearBottomActionType | SetIsNearBottomActionType
| SetMessagesLoadingActionType | SetMessageLoadingStateActionType
| SetPreJoinConversationActionType | SetPreJoinConversationActionType
| SetRecentMediaItemsActionType | SetRecentMediaItemsActionType
| SetSelectedConversationPanelDepthActionType | SetSelectedConversationPanelDepthActionType
@ -838,7 +839,7 @@ export const actions = {
setComposeGroupName, setComposeGroupName,
setComposeSearchTerm, setComposeSearchTerm,
setIsNearBottom, setIsNearBottom,
setMessagesLoading, setMessageLoadingState,
setPreJoinConversation, setPreJoinConversation,
setRecentMediaItems, setRecentMediaItems,
setSelectedConversationHeaderTitle, setSelectedConversationHeaderTitle,
@ -1634,15 +1635,15 @@ function messagesReset({
}, },
}; };
} }
function setMessagesLoading( function setMessageLoadingState(
conversationId: string, conversationId: string,
isLoadingMessages: boolean messageLoadingState: undefined | TimelineMessageLoadingState
): SetMessagesLoadingActionType { ): SetMessageLoadingStateActionType {
return { return {
type: 'SET_MESSAGES_LOADING', type: 'SET_MESSAGE_LOADING_STATE',
payload: { payload: {
conversationId, conversationId,
isLoadingMessages, messageLoadingState,
}, },
}; };
} }
@ -2599,7 +2600,6 @@ export function reducer(
messagesByConversation: { messagesByConversation: {
...messagesByConversation, ...messagesByConversation,
[conversationId]: { [conversationId]: {
isLoadingMessages: false,
scrollToMessageId, scrollToMessageId,
scrollToMessageCounter: existingConversation scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1 ? 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 { payload } = action;
const { conversationId, isLoadingMessages } = payload; const { conversationId, messageLoadingState } = payload;
const { messagesByConversation } = state; const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId]; const existingConversation = messagesByConversation[conversationId];
@ -2631,7 +2631,7 @@ export function reducer(
...messagesByConversation, ...messagesByConversation,
[conversationId]: { [conversationId]: {
...existingConversation, ...existingConversation,
isLoadingMessages, messageLoadingState,
}, },
}, },
}; };
@ -2686,7 +2686,7 @@ export function reducer(
...messagesByConversation, ...messagesByConversation,
[conversationId]: { [conversationId]: {
...existingConversation, ...existingConversation,
isLoadingMessages: false, messageLoadingState: undefined,
scrollToMessageId: messageId, scrollToMessageId: messageId,
scrollToMessageCounter: scrollToMessageCounter:
existingConversation.scrollToMessageCounter + 1, existingConversation.scrollToMessageCounter + 1,
@ -2949,8 +2949,8 @@ export function reducer(
...messagesByConversation, ...messagesByConversation,
[conversationId]: { [conversationId]: {
...existingConversation, ...existingConversation,
isLoadingMessages: false,
messageIds, messageIds,
messageLoadingState: undefined,
scrollToMessageId: isJustSent ? last.id : undefined, scrollToMessageId: isJustSent ? last.id : undefined,
metrics: { metrics: {
...existingConversation.metrics, ...existingConversation.metrics,

View File

@ -57,6 +57,7 @@ import { getActiveCall, getCallSelector } from './calling';
import type { AccountSelectorType } from './accounts'; import type { AccountSelectorType } from './accounts';
import { getAccountSelector } from './accounts'; import { getAccountSelector } from './accounts';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { TimelineMessageLoadingState } from '../../util/timelineUtil';
let placeholderContact: ConversationType; let placeholderContact: ConversationType;
export const getPlaceholderContact = (): ConversationType => { export const getPlaceholderContact = (): ConversationType => {
@ -815,12 +816,12 @@ export function _conversationMessagesSelector(
conversation: ConversationMessageType conversation: ConversationMessageType
): TimelinePropsType { ): TimelinePropsType {
const { const {
isLoadingMessages,
isNearBottom, isNearBottom,
messageIds, messageIds,
messageLoadingState,
metrics, metrics,
scrollToMessageId,
scrollToMessageCounter, scrollToMessageCounter,
scrollToMessageId,
} = conversation; } = conversation;
const firstId = messageIds[0]; const firstId = messageIds[0];
@ -846,9 +847,9 @@ export function _conversationMessagesSelector(
return { return {
haveNewest, haveNewest,
haveOldest, haveOldest,
isLoadingMessages,
isNearBottom, isNearBottom,
items, items,
messageLoadingState,
oldestUnreadIndex: oldestUnreadIndex:
isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0 isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0
? oldestUnreadIndex ? oldestUnreadIndex
@ -887,7 +888,7 @@ export const getConversationMessagesSelector = createSelector(
return { return {
haveNewest: false, haveNewest: false,
haveOldest: false, haveOldest: false,
isLoadingMessages: true, messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: 0, totalUnread: 0,
items: [], items: [],

View File

@ -2,9 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { times } from 'lodash';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { MINUTE, SECOND } from '../../util/durations'; import { MINUTE, SECOND } from '../../util/durations';
import { areMessagesInSameGroup } from '../../util/timelineUtil'; import {
ScrollAnchor,
areMessagesInSameGroup,
getScrollAnchorBeforeUpdate,
TimelineMessageLoadingState,
} from '../../util/timelineUtil';
describe('<Timeline> utilities', () => { describe('<Timeline> utilities', () => {
describe('areMessagesInSameGroup', () => { describe('areMessagesInSameGroup', () => {
@ -113,4 +119,328 @@ describe('<Timeline> utilities', () => {
assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer)); 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
);
});
});
});
}); });

View File

@ -330,7 +330,6 @@ describe('both/state/ducks/conversations', () => {
function getDefaultConversationMessage(): ConversationMessageType { function getDefaultConversationMessage(): ConversationMessageType {
return { return {
isLoadingMessages: false,
messageIds: [], messageIds: [],
metrics: { metrics: {
totalUnread: 0, totalUnread: 0,

View File

@ -1,13 +1,32 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 type { TimelineItemType } from '../components/conversation/TimelineItem';
import { WidthBreakpoint } from '../components/_util'; import { WidthBreakpoint } from '../components/_util';
import { MINUTE } from './durations'; import { MINUTE } from './durations';
import { missingCaseError } from './missingCaseError';
import { isSameDay } from './timestamp'; import { isSameDay } from './timestamp';
const COLLAPSE_WITHIN = 3 * MINUTE; 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 { export enum UnreadIndicatorPlacement {
JustAbove, JustAbove,
JustBelow, 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 { export function getWidthBreakpoint(width: number): WidthBreakpoint {
if (width > 606) { if (width > 606) {
return WidthBreakpoint.Wide; return WidthBreakpoint.Wide;