If not enough messages are loaded (on tall screens), fix jankiness
This commit is contained in:
parent
6e77d4b2c8
commit
72c6c57186
9 changed files with 523 additions and 111 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -330,7 +330,6 @@ describe('both/state/ducks/conversations', () => {
|
|||
|
||||
function getDefaultConversationMessage(): ConversationMessageType {
|
||||
return {
|
||||
isLoadingMessages: false,
|
||||
messageIds: [],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue