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

This commit is contained in:
Evan Hahn 2022-03-11 16:31:21 -06:00 committed by GitHub
parent 6e77d4b2c8
commit 72c6c57186
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 {
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;

View File

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

View File

@ -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<string>;
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,
'<Timeline> 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 = (
<TimelineFloatingHeader
i18n={i18n}
@ -1097,7 +1074,8 @@ export class Timeline extends React.Component<
<div
className={classNames(
'module-timeline__messages',
haveNewest && 'module-timeline__messages--have-newest'
haveNewest && 'module-timeline__messages--have-newest',
haveOldest && 'module-timeline__messages--have-oldest'
)}
ref={this.messagesRef}
>
@ -1112,7 +1090,7 @@ export class Timeline extends React.Component<
{messageNodes}
{isSomeoneTyping && renderTypingBubble(id)}
{isSomeoneTyping && haveNewest && renderTypingBubble(id)}
<div
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 type { SenderKeyTargetType } from '../util/sendToGroup';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { TimelineMessageLoadingState } from '../util/timelineUtil';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -1409,11 +1410,14 @@ export class ConversationModel extends window.Backbone
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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<string>;
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,

View File

@ -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: [],

View File

@ -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('<Timeline> utilities', () => {
describe('areMessagesInSameGroup', () => {
@ -113,4 +119,328 @@ describe('<Timeline> 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
);
});
});
});
});

View File

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

View File

@ -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;