Fix timeline item sizing bug, and test timeline logic
This commit is contained in:
parent
3864b941b9
commit
8fa4cd68d5
3 changed files with 551 additions and 220 deletions
|
@ -39,6 +39,16 @@ 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 {
|
||||||
|
fromItemIndexToRow,
|
||||||
|
fromRowToItemIndex,
|
||||||
|
getEphemeralRows,
|
||||||
|
getHeroRow,
|
||||||
|
getLastSeenIndicatorRow,
|
||||||
|
getRowCount,
|
||||||
|
getTypingBubbleRow,
|
||||||
|
getWidthBreakpoint,
|
||||||
|
} from '../../util/timelineUtil';
|
||||||
|
|
||||||
const AT_BOTTOM_THRESHOLD = 15;
|
const AT_BOTTOM_THRESHOLD = 15;
|
||||||
const NEAR_BOTTOM_THRESHOLD = 15;
|
const NEAR_BOTTOM_THRESHOLD = 15;
|
||||||
|
@ -327,7 +337,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
const { scrollToIndex, isIncomingMessageRequest } = this.props;
|
const { scrollToIndex, isIncomingMessageRequest } = this.props;
|
||||||
const oneTimeScrollRow = isIncomingMessageRequest
|
const oneTimeScrollRow = isIncomingMessageRequest
|
||||||
? undefined
|
? undefined
|
||||||
: this.getLastSeenIndicatorRow();
|
: getLastSeenIndicatorRow(props);
|
||||||
|
|
||||||
// We only stick to the bottom if this is not an incoming message request.
|
// We only stick to the bottom if this is not an incoming message request.
|
||||||
const atBottom = !isIncomingMessageRequest;
|
const atBottom = !isIncomingMessageRequest;
|
||||||
|
@ -389,7 +399,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
|
|
||||||
private getScrollContainer = (): HTMLDivElement | undefined => {
|
private getScrollContainer = (): HTMLDivElement | undefined => {
|
||||||
// We're using an internal variable (_scrollingContainer)) here,
|
// We're using an internal variable (_scrollingContainer)) here,
|
||||||
// so cannot rely on the private type.
|
// so cannot rely on the public type.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const grid: any = this.getGrid();
|
const grid: any = this.getGrid();
|
||||||
if (!grid) {
|
if (!grid) {
|
||||||
|
@ -463,7 +473,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = this.fromItemIndexToRow(index);
|
const row = fromItemIndexToRow(index, this.props);
|
||||||
this.resize(row);
|
this.resize(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -699,8 +709,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
|
|
||||||
markMessageRead(newestFullyVisible.id);
|
markMessageRead(newestFullyVisible.id);
|
||||||
|
|
||||||
const newestRow = this.getRowCount() - 1;
|
const newestRow = getRowCount(this.props) - 1;
|
||||||
const oldestRow = this.fromItemIndexToRow(0);
|
const oldestRow = fromItemIndexToRow(0, this.props);
|
||||||
|
|
||||||
// Loading newer messages (that go below current messages) is pain-free and quick
|
// Loading newer messages (that go below current messages) is pain-free and quick
|
||||||
// we'll just kick these off immediately.
|
// we'll just kick these off immediately.
|
||||||
|
@ -727,7 +737,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastIndex = items.length - 1;
|
const lastIndex = items.length - 1;
|
||||||
const lastItemRow = this.fromItemIndexToRow(lastIndex);
|
const lastItemRow = fromItemIndexToRow(lastIndex, this.props);
|
||||||
const areUnreadBelowCurrentPosition = Boolean(
|
const areUnreadBelowCurrentPosition = Boolean(
|
||||||
isNumber(unreadCount) &&
|
isNumber(unreadCount) &&
|
||||||
unreadCount > 0 &&
|
unreadCount > 0 &&
|
||||||
|
@ -767,11 +777,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private rowRenderer = ({
|
private rowRenderer = ({
|
||||||
index,
|
index: rowIndex,
|
||||||
key,
|
key,
|
||||||
parent,
|
parent,
|
||||||
style,
|
style,
|
||||||
}: RowRendererParamsType): JSX.Element => {
|
}: Readonly<RowRendererParamsType>): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -786,82 +796,82 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { lastMeasuredWarningHeight, widthBreakpoint } = this.state;
|
const { lastMeasuredWarningHeight, widthBreakpoint } = this.state;
|
||||||
|
|
||||||
const styleWithWidth = {
|
const commonProps = {
|
||||||
...style,
|
'data-row': rowIndex,
|
||||||
width: `${this.mostRecentWidth}px`,
|
style: {
|
||||||
|
...style,
|
||||||
|
width: `${this.mostRecentWidth}px`,
|
||||||
|
},
|
||||||
|
role: 'row',
|
||||||
};
|
};
|
||||||
const row = index;
|
|
||||||
const oldestUnreadRow = this.getLastSeenIndicatorRow();
|
|
||||||
const typingBubbleRow = this.getTypingBubbleRow();
|
|
||||||
let rowContents: ReactNode;
|
|
||||||
|
|
||||||
if (haveOldest && row === 0) {
|
let rowContents: ReactChild;
|
||||||
rowContents = (
|
switch (rowIndex) {
|
||||||
<div data-row={row} style={styleWithWidth} role="row">
|
case getHeroRow(this.props):
|
||||||
{Timeline.getWarning(this.props, this.state) ? (
|
rowContents = (
|
||||||
<div style={{ height: lastMeasuredWarningHeight }} />
|
<div {...commonProps}>
|
||||||
) : null}
|
{Timeline.getWarning(this.props, this.state) ? (
|
||||||
{renderHeroRow(
|
<div style={{ height: lastMeasuredWarningHeight }} />
|
||||||
id,
|
) : null}
|
||||||
this.resizeHeroRow,
|
{renderHeroRow(
|
||||||
unblurAvatar,
|
id,
|
||||||
updateSharedGroups
|
this.resizeHeroRow,
|
||||||
)}
|
unblurAvatar,
|
||||||
</div>
|
updateSharedGroups
|
||||||
);
|
)}
|
||||||
} else if (oldestUnreadRow === row) {
|
</div>
|
||||||
rowContents = (
|
|
||||||
<div data-row={row} style={styleWithWidth} role="row">
|
|
||||||
{renderLastSeenIndicator(id)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (typingBubbleRow === row) {
|
|
||||||
rowContents = (
|
|
||||||
<div
|
|
||||||
data-row={row}
|
|
||||||
className="module-timeline__message-container"
|
|
||||||
style={styleWithWidth}
|
|
||||||
role="row"
|
|
||||||
>
|
|
||||||
{renderTypingBubble(id)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const itemIndex = this.fromRowToItemIndex(row);
|
|
||||||
if (typeof itemIndex !== 'number') {
|
|
||||||
throw new Error(
|
|
||||||
`Attempted to render item with undefined index - row ${row}`
|
|
||||||
);
|
);
|
||||||
}
|
break;
|
||||||
const previousMessageId: undefined | string = items[itemIndex - 1];
|
case getLastSeenIndicatorRow(this.props):
|
||||||
const messageId = items[itemIndex];
|
rowContents = <div {...commonProps}>{renderLastSeenIndicator(id)}</div>;
|
||||||
const nextMessageId: undefined | string = items[itemIndex + 1];
|
break;
|
||||||
|
case getTypingBubbleRow(this.props):
|
||||||
|
rowContents = (
|
||||||
|
<div {...commonProps} className="module-timeline__message-container">
|
||||||
|
{renderTypingBubble(id)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
const itemIndex = fromRowToItemIndex(rowIndex, this.props);
|
||||||
|
if (typeof itemIndex !== 'number') {
|
||||||
|
throw new Error(
|
||||||
|
`Attempted to render item with undefined index - row ${rowIndex}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const previousMessageId: undefined | string = items[itemIndex - 1];
|
||||||
|
const messageId = items[itemIndex];
|
||||||
|
const nextMessageId: undefined | string = items[itemIndex + 1];
|
||||||
|
|
||||||
const actionProps = getActions(this.props);
|
const actionProps = getActions(this.props);
|
||||||
|
|
||||||
rowContents = (
|
rowContents = (
|
||||||
<div
|
<div
|
||||||
id={messageId}
|
{...commonProps}
|
||||||
data-row={row}
|
id={messageId}
|
||||||
className="module-timeline__message-container"
|
className="module-timeline__message-container"
|
||||||
style={styleWithWidth}
|
>
|
||||||
role="row"
|
<ErrorBoundary
|
||||||
>
|
i18n={i18n}
|
||||||
<ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}>
|
showDebugLog={() => window.showDebugLog()}
|
||||||
{renderItem({
|
>
|
||||||
actionProps,
|
{renderItem({
|
||||||
containerElementRef: this.containerRef,
|
actionProps,
|
||||||
containerWidthBreakpoint: widthBreakpoint,
|
containerElementRef: this.containerRef,
|
||||||
conversationId: id,
|
containerWidthBreakpoint: widthBreakpoint,
|
||||||
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
conversationId: id,
|
||||||
messageId,
|
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
||||||
nextMessageId,
|
messageId,
|
||||||
onHeightChange: this.resizeMessage,
|
nextMessageId,
|
||||||
previousMessageId,
|
onHeightChange: this.resizeMessage,
|
||||||
})}
|
previousMessageId,
|
||||||
</ErrorBoundary>
|
})}
|
||||||
</div>
|
</ErrorBoundary>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -870,7 +880,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
columnIndex={0}
|
columnIndex={0}
|
||||||
key={key}
|
key={key}
|
||||||
parent={parent}
|
parent={parent}
|
||||||
rowIndex={index}
|
rowIndex={rowIndex}
|
||||||
width={this.mostRecentWidth}
|
width={this.mostRecentWidth}
|
||||||
>
|
>
|
||||||
{rowContents}
|
{rowContents}
|
||||||
|
@ -878,91 +888,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
private fromItemIndexToRow(index: number): number {
|
|
||||||
const { haveOldest, oldestUnreadIndex } = this.props;
|
|
||||||
|
|
||||||
let result = index;
|
|
||||||
|
|
||||||
// Hero row
|
|
||||||
if (haveOldest) {
|
|
||||||
result += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unread indicator
|
|
||||||
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
|
|
||||||
result += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRowCount(): number {
|
|
||||||
const { haveOldest, items, oldestUnreadIndex, typingContactId } =
|
|
||||||
this.props;
|
|
||||||
|
|
||||||
let result = items?.length || 0;
|
|
||||||
|
|
||||||
// Hero row
|
|
||||||
if (haveOldest) {
|
|
||||||
result += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unread indicator
|
|
||||||
if (isNumber(oldestUnreadIndex)) {
|
|
||||||
result += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typing indicator
|
|
||||||
if (typingContactId) {
|
|
||||||
result += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fromRowToItemIndex(row: number): number | undefined {
|
|
||||||
const { haveOldest, items } = this.props;
|
|
||||||
|
|
||||||
let result = row;
|
|
||||||
|
|
||||||
// Hero row
|
|
||||||
if (haveOldest) {
|
|
||||||
result -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unread indicator
|
|
||||||
const oldestUnreadRow = this.getLastSeenIndicatorRow();
|
|
||||||
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
|
|
||||||
result -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result < 0 || result >= items.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLastSeenIndicatorRow(): number | undefined {
|
|
||||||
const { oldestUnreadIndex } = this.props;
|
|
||||||
if (!isNumber(oldestUnreadIndex)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTypingBubbleRow(): number | undefined {
|
|
||||||
const { items } = this.props;
|
|
||||||
if (!items || items.length < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = items.length - 1;
|
|
||||||
|
|
||||||
return this.fromItemIndexToRow(last) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollToBottom = (setFocus?: boolean): void => {
|
private scrollToBottom = (setFocus?: boolean): void => {
|
||||||
const { selectMessage, id, items } = this.props;
|
const { selectMessage, id, items } = this.props;
|
||||||
|
|
||||||
|
@ -1000,7 +925,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastId = items[items.length - 1];
|
const lastId = items[items.length - 1];
|
||||||
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
const lastSeenIndicatorRow = getLastSeenIndicatorRow(this.props);
|
||||||
|
|
||||||
const { visibleRows } = this.state;
|
const { visibleRows } = this.state;
|
||||||
if (!visibleRows) {
|
if (!visibleRows) {
|
||||||
|
@ -1063,7 +988,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
|
|
||||||
// We recompute the hero row's height if:
|
// We recompute the hero row's height if:
|
||||||
//
|
//
|
||||||
// 1. We just started showing it (a loading row changes to a hero row)
|
// 1. We just started showing it (the user has scrolled up to see the hero row)
|
||||||
// 2. Warnings were shown (they add padding to the hero for the floating warning)
|
// 2. Warnings were shown (they add padding to the hero for the floating warning)
|
||||||
const hadOldest = prevProps.haveOldest;
|
const hadOldest = prevProps.haveOldest;
|
||||||
const hadWarning = Boolean(Timeline.getWarning(prevProps, prevState));
|
const hadWarning = Boolean(Timeline.getWarning(prevProps, prevState));
|
||||||
|
@ -1093,7 +1018,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
// We want to come in at the top of the conversation if it's a message request
|
// We want to come in at the top of the conversation if it's a message request
|
||||||
const oneTimeScrollRow = isIncomingMessageRequest
|
const oneTimeScrollRow = isIncomingMessageRequest
|
||||||
? undefined
|
? undefined
|
||||||
: this.getLastSeenIndicatorRow();
|
: getLastSeenIndicatorRow(this.props);
|
||||||
const atBottom = !isIncomingMessageRequest;
|
const atBottom = !isIncomingMessageRequest;
|
||||||
|
|
||||||
// TODO: DESKTOP-688
|
// TODO: DESKTOP-688
|
||||||
|
@ -1111,7 +1036,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
let resizeStartRow: number | undefined;
|
let resizeStartRow: number | undefined;
|
||||||
|
|
||||||
if (isNumber(messageHeightChangeIndex)) {
|
if (isNumber(messageHeightChangeIndex)) {
|
||||||
resizeStartRow = this.fromItemIndexToRow(messageHeightChangeIndex);
|
resizeStartRow = fromItemIndexToRow(messageHeightChangeIndex, this.props);
|
||||||
clearChangedMessages(id, messageHeightChangeBaton);
|
clearChangedMessages(id, messageHeightChangeBaton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1137,7 +1062,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRow = this.fromItemIndexToRow(newFirstIndex);
|
const newRow = fromItemIndexToRow(newFirstIndex, this.props);
|
||||||
if (newRow > 0) {
|
if (newRow > 0) {
|
||||||
// We're loading more new messages at the top; we want to stay at the top
|
// We're loading more new messages at the top; we want to stay at the top
|
||||||
this.resize();
|
this.resize();
|
||||||
|
@ -1152,18 +1077,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
// Compare current rows against previous rows to identify the number of
|
// Compare current rows against previous rows to identify the number of
|
||||||
// consecutive rows (from start of the list) the are the same in both
|
// consecutive rows (from start of the list) the are the same in both
|
||||||
// lists.
|
// lists.
|
||||||
const rowsIterator = Timeline.getEphemeralRows({
|
const rowsIterator = getEphemeralRows(this.props);
|
||||||
items,
|
const prevRowsIterator = getEphemeralRows(prevProps);
|
||||||
oldestUnreadIndex,
|
|
||||||
hasTypingContact: Boolean(typingContactId),
|
|
||||||
haveOldest,
|
|
||||||
});
|
|
||||||
const prevRowsIterator = Timeline.getEphemeralRows({
|
|
||||||
items: prevProps.items,
|
|
||||||
oldestUnreadIndex: prevProps.oldestUnreadIndex,
|
|
||||||
hasTypingContact: Boolean(prevProps.typingContactId),
|
|
||||||
haveOldest: prevProps.haveOldest,
|
|
||||||
});
|
|
||||||
|
|
||||||
let firstChangedRow = 0;
|
let firstChangedRow = 0;
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
@ -1216,9 +1131,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
private getScrollTarget = (): number | undefined => {
|
private getScrollTarget = (): number | undefined => {
|
||||||
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
||||||
|
|
||||||
const rowCount = this.getRowCount();
|
const rowCount = getRowCount(this.props);
|
||||||
const targetMessageRow = isNumber(propScrollToIndex)
|
const targetMessageRow = isNumber(propScrollToIndex)
|
||||||
? this.fromItemIndexToRow(propScrollToIndex)
|
? fromItemIndexToRow(propScrollToIndex, this.props)
|
||||||
: undefined;
|
: undefined;
|
||||||
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
|
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
|
||||||
|
|
||||||
|
@ -1369,7 +1284,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
widthBreakpoint,
|
widthBreakpoint,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const rowCount = this.getRowCount();
|
const rowCount = getRowCount(this.props);
|
||||||
const scrollToIndex = this.getScrollTarget();
|
const scrollToIndex = this.getScrollTarget();
|
||||||
|
|
||||||
if (!items || rowCount === 0) {
|
if (!items || rowCount === 0) {
|
||||||
|
@ -1636,31 +1551,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static *getEphemeralRows({
|
|
||||||
hasTypingContact,
|
|
||||||
haveOldest,
|
|
||||||
items,
|
|
||||||
oldestUnreadIndex,
|
|
||||||
}: {
|
|
||||||
items: ReadonlyArray<string>;
|
|
||||||
hasTypingContact: boolean;
|
|
||||||
oldestUnreadIndex?: number;
|
|
||||||
haveOldest: boolean;
|
|
||||||
}): Iterator<string> {
|
|
||||||
yield haveOldest ? 'hero' : 'loading';
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
if (i === oldestUnreadIndex) {
|
|
||||||
yield 'oldest-unread';
|
|
||||||
}
|
|
||||||
yield `item:${items[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTypingContact) {
|
|
||||||
yield 'typing-contact';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getWarning(
|
private static getWarning(
|
||||||
{ warning }: PropsType,
|
{ warning }: PropsType,
|
||||||
state: StateType
|
state: StateType
|
||||||
|
@ -1686,13 +1576,3 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWidthBreakpoint(width: number): WidthBreakpoint {
|
|
||||||
if (width > 606) {
|
|
||||||
return WidthBreakpoint.Wide;
|
|
||||||
}
|
|
||||||
if (width > 514) {
|
|
||||||
return WidthBreakpoint.Medium;
|
|
||||||
}
|
|
||||||
return WidthBreakpoint.Narrow;
|
|
||||||
}
|
|
||||||
|
|
299
ts/test-both/util/timelineUtil_test.ts
Normal file
299
ts/test-both/util/timelineUtil_test.ts
Normal file
|
@ -0,0 +1,299 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { times } from 'lodash';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import {
|
||||||
|
fromItemIndexToRow,
|
||||||
|
fromRowToItemIndex,
|
||||||
|
getEphemeralRows,
|
||||||
|
getHeroRow,
|
||||||
|
getLastSeenIndicatorRow,
|
||||||
|
getRowCount,
|
||||||
|
getTypingBubbleRow,
|
||||||
|
} from '../../util/timelineUtil';
|
||||||
|
|
||||||
|
describe('<Timeline> utilities', () => {
|
||||||
|
const getItems = (count: number): Array<string> => times(count, () => uuid());
|
||||||
|
|
||||||
|
describe('fromItemIndexToRow', () => {
|
||||||
|
it('returns the same number under normal conditions', () => {
|
||||||
|
times(5, index => {
|
||||||
|
assert.strictEqual(
|
||||||
|
fromItemIndexToRow(index, { haveOldest: false }),
|
||||||
|
index
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 1 (for the hero row) if you have the oldest messages', () => {
|
||||||
|
times(5, index => {
|
||||||
|
assert.strictEqual(
|
||||||
|
fromItemIndexToRow(index, { haveOldest: true }),
|
||||||
|
index + 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 1 (for the unread indicator) once crossing the unread indicator index', () => {
|
||||||
|
const props = { haveOldest: false, oldestUnreadIndex: 2 };
|
||||||
|
[0, 1].forEach(index => {
|
||||||
|
assert.strictEqual(fromItemIndexToRow(index, props), index);
|
||||||
|
});
|
||||||
|
[2, 3, 4].forEach(index => {
|
||||||
|
assert.strictEqual(fromItemIndexToRow(index, props), index + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can include the hero row and the unread indicator', () => {
|
||||||
|
const props = { haveOldest: true, oldestUnreadIndex: 2 };
|
||||||
|
[0, 1].forEach(index => {
|
||||||
|
assert.strictEqual(fromItemIndexToRow(index, props), index + 1);
|
||||||
|
});
|
||||||
|
[2, 3, 4].forEach(index => {
|
||||||
|
assert.strictEqual(fromItemIndexToRow(index, props), index + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromRowToItemIndex', () => {
|
||||||
|
it('returns the item index under normal conditions', () => {
|
||||||
|
const props = { haveOldest: false, items: getItems(5) };
|
||||||
|
times(5, row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(5, props));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the unread indicator', () => {
|
||||||
|
const props = {
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(4),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
[0, 1].forEach(row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(2, props));
|
||||||
|
[3, 4].forEach(row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(5, props));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the hero row', () => {
|
||||||
|
const props = { haveOldest: true, items: getItems(3) };
|
||||||
|
|
||||||
|
assert.isUndefined(fromRowToItemIndex(0, props));
|
||||||
|
[1, 2, 3].forEach(row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(4, props));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the whole enchilada', () => {
|
||||||
|
const props = {
|
||||||
|
haveOldest: true,
|
||||||
|
items: getItems(4),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.isUndefined(fromRowToItemIndex(0, props));
|
||||||
|
[1, 2].forEach(row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(3, props));
|
||||||
|
[4, 5].forEach(row => {
|
||||||
|
assert.strictEqual(fromRowToItemIndex(row, props), row - 2);
|
||||||
|
});
|
||||||
|
assert.isUndefined(fromRowToItemIndex(6, props));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRowCount', () => {
|
||||||
|
it('returns 1 (for the hero row) if the conversation is empty', () => {
|
||||||
|
assert.strictEqual(getRowCount({ haveOldest: true, items: [] }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the number of items under normal conditions', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getRowCount({ haveOldest: false, items: getItems(4) }),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 1 (for the hero row) if you have the oldest messages', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getRowCount({ haveOldest: true, items: getItems(4) }),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 1 (for the unread indicator) if you have unread messages', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getRowCount({
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(4),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
}),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 1 (for the typing contact) if you have unread messages', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getRowCount({
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(4),
|
||||||
|
typingContactId: uuid(),
|
||||||
|
}),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can have the whole enchilada', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getRowCount({
|
||||||
|
haveOldest: true,
|
||||||
|
items: getItems(4),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
typingContactId: uuid(),
|
||||||
|
}),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHeroRow', () => {
|
||||||
|
it("returns undefined if there's no hero row", () => {
|
||||||
|
assert.isUndefined(getHeroRow({ haveOldest: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 if there's a hero row", () => {
|
||||||
|
assert.strictEqual(getHeroRow({ haveOldest: true }), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLastSeenIndicatorRow', () => {
|
||||||
|
it('returns undefined with no unread messages', () => {
|
||||||
|
assert.isUndefined(getLastSeenIndicatorRow({ haveOldest: false }));
|
||||||
|
assert.isUndefined(getLastSeenIndicatorRow({ haveOldest: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same number if the oldest messages are loaded', () => {
|
||||||
|
[0, 1, 2].forEach(oldestUnreadIndex => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getLastSeenIndicatorRow({ haveOldest: false, oldestUnreadIndex }),
|
||||||
|
oldestUnreadIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increases the number by 1 if there's a hero row", () => {
|
||||||
|
[0, 1, 2].forEach(oldestUnreadIndex => {
|
||||||
|
assert.strictEqual(
|
||||||
|
getLastSeenIndicatorRow({ haveOldest: true, oldestUnreadIndex }),
|
||||||
|
oldestUnreadIndex + 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTypingBubbleRow', () => {
|
||||||
|
it('returns undefined if nobody is typing', () => {
|
||||||
|
assert.isUndefined(
|
||||||
|
getTypingBubbleRow({ haveOldest: false, items: getItems(3) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the last row if people are typing', () => {
|
||||||
|
[
|
||||||
|
{ haveOldest: true, items: [], typingContactId: uuid() },
|
||||||
|
{ haveOldest: false, items: getItems(3), typingContactId: uuid() },
|
||||||
|
{ haveOldest: true, items: getItems(3), typingContactId: uuid() },
|
||||||
|
{
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(3),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
typingContactId: uuid(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
haveOldest: true,
|
||||||
|
items: getItems(3),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
typingContactId: uuid(),
|
||||||
|
},
|
||||||
|
].forEach(props => {
|
||||||
|
assert.strictEqual(getTypingBubbleRow(props), getRowCount(props) - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEphemeralRows', () => {
|
||||||
|
function iterate<T>(iterator: Iterator<T>): Array<T> {
|
||||||
|
const result: Array<T> = [];
|
||||||
|
let iteration = iterator.next();
|
||||||
|
while (!iteration.done) {
|
||||||
|
result.push(iteration.value);
|
||||||
|
iteration = iterator.next();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('yields each row under normal conditions', () => {
|
||||||
|
const result = getEphemeralRows({
|
||||||
|
haveOldest: false,
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(iterate(result), ['item:a', 'item:b', 'item:c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields a hero row if there is one', () => {
|
||||||
|
const result = getEphemeralRows({ haveOldest: true, items: getItems(3) });
|
||||||
|
const iterated = iterate(result);
|
||||||
|
assert.lengthOf(iterated, 4);
|
||||||
|
assert.strictEqual(iterated[0], 'hero');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields an unread indicator if there is one', () => {
|
||||||
|
const result = getEphemeralRows({
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(3),
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
});
|
||||||
|
const iterated = iterate(result);
|
||||||
|
assert.lengthOf(iterated, 4);
|
||||||
|
assert.strictEqual(iterated[2], 'oldest-unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields a typing row if there is one', () => {
|
||||||
|
const result = getEphemeralRows({
|
||||||
|
haveOldest: false,
|
||||||
|
items: getItems(3),
|
||||||
|
typingContactId: uuid(),
|
||||||
|
});
|
||||||
|
const iterated = iterate(result);
|
||||||
|
assert.lengthOf(iterated, 4);
|
||||||
|
assert.strictEqual(iterated[3], 'typing-contact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the whole enchilada', () => {
|
||||||
|
const result = getEphemeralRows({
|
||||||
|
haveOldest: true,
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
oldestUnreadIndex: 2,
|
||||||
|
typingContactId: uuid(),
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(iterate(result), [
|
||||||
|
'hero',
|
||||||
|
'item:a',
|
||||||
|
'item:b',
|
||||||
|
'oldest-unread',
|
||||||
|
'item:c',
|
||||||
|
'typing-contact',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
152
ts/util/timelineUtil.ts
Normal file
152
ts/util/timelineUtil.ts
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
import type { PropsType } from '../components/conversation/Timeline';
|
||||||
|
import { WidthBreakpoint } from '../components/_util';
|
||||||
|
|
||||||
|
export function fromItemIndexToRow(
|
||||||
|
itemIndex: number,
|
||||||
|
{
|
||||||
|
haveOldest,
|
||||||
|
oldestUnreadIndex,
|
||||||
|
}: Readonly<Pick<PropsType, 'haveOldest' | 'oldestUnreadIndex'>>
|
||||||
|
): number {
|
||||||
|
let result = itemIndex;
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
|
if (isNumber(oldestUnreadIndex) && itemIndex >= oldestUnreadIndex) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromRowToItemIndex(
|
||||||
|
row: number,
|
||||||
|
props: Readonly<Pick<PropsType, 'haveOldest' | 'items' | 'oldestUnreadIndex'>>
|
||||||
|
): undefined | number {
|
||||||
|
const { haveOldest, items, oldestUnreadIndex } = props;
|
||||||
|
|
||||||
|
let result = row;
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
|
if (isNumber(oldestUnreadIndex)) {
|
||||||
|
if (result === oldestUnreadIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result > oldestUnreadIndex) {
|
||||||
|
result -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result < 0 || result >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRowCount({
|
||||||
|
haveOldest,
|
||||||
|
items,
|
||||||
|
oldestUnreadIndex,
|
||||||
|
typingContactId,
|
||||||
|
}: Readonly<
|
||||||
|
Pick<
|
||||||
|
PropsType,
|
||||||
|
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
|
||||||
|
>
|
||||||
|
>): number {
|
||||||
|
let result = items?.length || 0;
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
|
if (isNumber(oldestUnreadIndex)) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
if (typingContactId) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeroRow({
|
||||||
|
haveOldest,
|
||||||
|
}: Readonly<Pick<PropsType, 'haveOldest'>>): undefined | number {
|
||||||
|
return haveOldest ? 0 : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastSeenIndicatorRow(
|
||||||
|
props: Readonly<Pick<PropsType, 'haveOldest' | 'oldestUnreadIndex'>>
|
||||||
|
): undefined | number {
|
||||||
|
const { oldestUnreadIndex } = props;
|
||||||
|
return isNumber(oldestUnreadIndex)
|
||||||
|
? fromItemIndexToRow(oldestUnreadIndex, props) - 1
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTypingBubbleRow(
|
||||||
|
props: Readonly<
|
||||||
|
Pick<
|
||||||
|
PropsType,
|
||||||
|
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
|
||||||
|
>
|
||||||
|
>
|
||||||
|
): undefined | number {
|
||||||
|
return props.typingContactId ? getRowCount(props) - 1 : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getEphemeralRows({
|
||||||
|
haveOldest,
|
||||||
|
items,
|
||||||
|
oldestUnreadIndex,
|
||||||
|
typingContactId,
|
||||||
|
}: Readonly<
|
||||||
|
Pick<
|
||||||
|
PropsType,
|
||||||
|
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
|
||||||
|
>
|
||||||
|
>): Iterator<string> {
|
||||||
|
if (haveOldest) {
|
||||||
|
yield 'hero';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
if (i === oldestUnreadIndex) {
|
||||||
|
yield 'oldest-unread';
|
||||||
|
}
|
||||||
|
yield `item:${items[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typingContactId) {
|
||||||
|
yield 'typing-contact';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWidthBreakpoint(width: number): WidthBreakpoint {
|
||||||
|
if (width > 606) {
|
||||||
|
return WidthBreakpoint.Wide;
|
||||||
|
}
|
||||||
|
if (width > 514) {
|
||||||
|
return WidthBreakpoint.Medium;
|
||||||
|
}
|
||||||
|
return WidthBreakpoint.Narrow;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue