If not enough messages are loaded (on tall screens), fix jankiness
This commit is contained in:
parent
6e77d4b2c8
commit
72c6c57186
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: [],
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue