211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { isNumber } from 'lodash';
|
|
import * as log from '../logging/log';
|
|
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';
|
|
import type { LastMessageStatus } from '../model-types.d';
|
|
|
|
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,
|
|
}
|
|
|
|
export type MessageTimelineItemDataType = Readonly<{
|
|
author: { id: string };
|
|
deletedForEveryone?: boolean;
|
|
isEditedMessage?: boolean;
|
|
reactions?: ReadonlyArray<unknown>;
|
|
status?: LastMessageStatus;
|
|
timestamp: number;
|
|
}>;
|
|
|
|
// This lets us avoid passing a full `MessageType`. That's useful for tests and for
|
|
// documentation.
|
|
export type MaybeMessageTimelineItemType = Readonly<
|
|
| undefined
|
|
| TimelineItemType
|
|
| { type: 'message'; data: MessageTimelineItemDataType }
|
|
>;
|
|
|
|
const getMessageTimelineItemData = (
|
|
timelineItem: MaybeMessageTimelineItemType
|
|
): undefined | MessageTimelineItemDataType =>
|
|
timelineItem?.type === 'message' ? timelineItem.data : undefined;
|
|
|
|
export function shouldCurrentMessageHideMetadata(
|
|
areMessagesGrouped: boolean,
|
|
item: MaybeMessageTimelineItemType,
|
|
newerTimelineItem: MaybeMessageTimelineItemType
|
|
): boolean {
|
|
if (!areMessagesGrouped) {
|
|
return false;
|
|
}
|
|
|
|
const message = getMessageTimelineItemData(item);
|
|
if (!message) {
|
|
return false;
|
|
}
|
|
|
|
if (message.isEditedMessage) {
|
|
return false;
|
|
}
|
|
|
|
const newerMessage = getMessageTimelineItemData(newerTimelineItem);
|
|
if (!newerMessage) {
|
|
return false;
|
|
}
|
|
|
|
// If newer message is deleted, but current isn't, we'll show metadata.
|
|
if (newerMessage.deletedForEveryone && !message.deletedForEveryone) {
|
|
return false;
|
|
}
|
|
|
|
switch (message.status) {
|
|
case undefined:
|
|
return true;
|
|
case 'paused':
|
|
case 'error':
|
|
case 'partial-sent':
|
|
case 'sending':
|
|
return false;
|
|
case 'sent':
|
|
case 'delivered':
|
|
case 'read':
|
|
case 'viewed':
|
|
return true;
|
|
default:
|
|
log.error(missingCaseError(message.status));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function areMessagesInSameGroup(
|
|
olderTimelineItem: MaybeMessageTimelineItemType,
|
|
unreadIndicator: boolean,
|
|
newerTimelineItem: MaybeMessageTimelineItemType
|
|
): boolean {
|
|
if (unreadIndicator) {
|
|
return false;
|
|
}
|
|
|
|
const olderMessage = getMessageTimelineItemData(olderTimelineItem);
|
|
if (!olderMessage) {
|
|
return false;
|
|
}
|
|
|
|
const newerMessage = getMessageTimelineItemData(newerTimelineItem);
|
|
if (!newerMessage) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(
|
|
!olderMessage.reactions?.length &&
|
|
olderMessage.author.id === newerMessage.author.id &&
|
|
(olderMessage.isEditedMessage ||
|
|
newerMessage.timestamp >= olderMessage.timestamp) &&
|
|
newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN &&
|
|
isSameDay(olderMessage.timestamp, newerMessage.timestamp)
|
|
);
|
|
}
|
|
|
|
type ScrollAnchorBeforeUpdateProps = Readonly<
|
|
Pick<
|
|
TimelinePropsType,
|
|
| 'haveNewest'
|
|
| 'isIncomingMessageRequest'
|
|
| 'isSomeoneTyping'
|
|
| 'items'
|
|
| 'messageLoadingState'
|
|
| 'oldestUnseenIndex'
|
|
| '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.oldestUnseenIndex)) {
|
|
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;
|
|
}
|
|
if (width > 514) {
|
|
return WidthBreakpoint.Medium;
|
|
}
|
|
return WidthBreakpoint.Narrow;
|
|
}
|