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 { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
||||
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
||||
import {
|
||||
fromItemIndexToRow,
|
||||
fromRowToItemIndex,
|
||||
getEphemeralRows,
|
||||
getHeroRow,
|
||||
getLastSeenIndicatorRow,
|
||||
getRowCount,
|
||||
getTypingBubbleRow,
|
||||
getWidthBreakpoint,
|
||||
} from '../../util/timelineUtil';
|
||||
|
||||
const AT_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 oneTimeScrollRow = isIncomingMessageRequest
|
||||
? undefined
|
||||
: this.getLastSeenIndicatorRow();
|
||||
: getLastSeenIndicatorRow(props);
|
||||
|
||||
// We only stick to the bottom if this is not an incoming message request.
|
||||
const atBottom = !isIncomingMessageRequest;
|
||||
|
@ -389,7 +399,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
|
||||
private getScrollContainer = (): HTMLDivElement | undefined => {
|
||||
// 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
|
||||
const grid: any = this.getGrid();
|
||||
if (!grid) {
|
||||
|
@ -463,7 +473,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return;
|
||||
}
|
||||
|
||||
const row = this.fromItemIndexToRow(index);
|
||||
const row = fromItemIndexToRow(index, this.props);
|
||||
this.resize(row);
|
||||
};
|
||||
|
||||
|
@ -699,8 +709,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
|
||||
markMessageRead(newestFullyVisible.id);
|
||||
|
||||
const newestRow = this.getRowCount() - 1;
|
||||
const oldestRow = this.fromItemIndexToRow(0);
|
||||
const newestRow = getRowCount(this.props) - 1;
|
||||
const oldestRow = fromItemIndexToRow(0, this.props);
|
||||
|
||||
// Loading newer messages (that go below current messages) is pain-free and quick
|
||||
// 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 lastItemRow = this.fromItemIndexToRow(lastIndex);
|
||||
const lastItemRow = fromItemIndexToRow(lastIndex, this.props);
|
||||
const areUnreadBelowCurrentPosition = Boolean(
|
||||
isNumber(unreadCount) &&
|
||||
unreadCount > 0 &&
|
||||
|
@ -767,11 +777,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
};
|
||||
|
||||
private rowRenderer = ({
|
||||
index,
|
||||
index: rowIndex,
|
||||
key,
|
||||
parent,
|
||||
style,
|
||||
}: RowRendererParamsType): JSX.Element => {
|
||||
}: Readonly<RowRendererParamsType>): JSX.Element => {
|
||||
const {
|
||||
id,
|
||||
i18n,
|
||||
|
@ -786,82 +796,82 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
} = this.props;
|
||||
const { lastMeasuredWarningHeight, widthBreakpoint } = this.state;
|
||||
|
||||
const styleWithWidth = {
|
||||
...style,
|
||||
width: `${this.mostRecentWidth}px`,
|
||||
const commonProps = {
|
||||
'data-row': rowIndex,
|
||||
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) {
|
||||
rowContents = (
|
||||
<div data-row={row} style={styleWithWidth} role="row">
|
||||
{Timeline.getWarning(this.props, this.state) ? (
|
||||
<div style={{ height: lastMeasuredWarningHeight }} />
|
||||
) : null}
|
||||
{renderHeroRow(
|
||||
id,
|
||||
this.resizeHeroRow,
|
||||
unblurAvatar,
|
||||
updateSharedGroups
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (oldestUnreadRow === row) {
|
||||
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}`
|
||||
let rowContents: ReactChild;
|
||||
switch (rowIndex) {
|
||||
case getHeroRow(this.props):
|
||||
rowContents = (
|
||||
<div {...commonProps}>
|
||||
{Timeline.getWarning(this.props, this.state) ? (
|
||||
<div style={{ height: lastMeasuredWarningHeight }} />
|
||||
) : null}
|
||||
{renderHeroRow(
|
||||
id,
|
||||
this.resizeHeroRow,
|
||||
unblurAvatar,
|
||||
updateSharedGroups
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const previousMessageId: undefined | string = items[itemIndex - 1];
|
||||
const messageId = items[itemIndex];
|
||||
const nextMessageId: undefined | string = items[itemIndex + 1];
|
||||
break;
|
||||
case getLastSeenIndicatorRow(this.props):
|
||||
rowContents = <div {...commonProps}>{renderLastSeenIndicator(id)}</div>;
|
||||
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 = (
|
||||
<div
|
||||
id={messageId}
|
||||
data-row={row}
|
||||
className="module-timeline__message-container"
|
||||
style={styleWithWidth}
|
||||
role="row"
|
||||
>
|
||||
<ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}>
|
||||
{renderItem({
|
||||
actionProps,
|
||||
containerElementRef: this.containerRef,
|
||||
containerWidthBreakpoint: widthBreakpoint,
|
||||
conversationId: id,
|
||||
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
||||
messageId,
|
||||
nextMessageId,
|
||||
onHeightChange: this.resizeMessage,
|
||||
previousMessageId,
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
rowContents = (
|
||||
<div
|
||||
{...commonProps}
|
||||
id={messageId}
|
||||
className="module-timeline__message-container"
|
||||
>
|
||||
<ErrorBoundary
|
||||
i18n={i18n}
|
||||
showDebugLog={() => window.showDebugLog()}
|
||||
>
|
||||
{renderItem({
|
||||
actionProps,
|
||||
containerElementRef: this.containerRef,
|
||||
containerWidthBreakpoint: widthBreakpoint,
|
||||
conversationId: id,
|
||||
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
||||
messageId,
|
||||
nextMessageId,
|
||||
onHeightChange: this.resizeMessage,
|
||||
previousMessageId,
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -870,7 +880,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
columnIndex={0}
|
||||
key={key}
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
rowIndex={rowIndex}
|
||||
width={this.mostRecentWidth}
|
||||
>
|
||||
{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 => {
|
||||
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 lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
||||
const lastSeenIndicatorRow = getLastSeenIndicatorRow(this.props);
|
||||
|
||||
const { visibleRows } = this.state;
|
||||
if (!visibleRows) {
|
||||
|
@ -1063,7 +988,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
|
||||
// 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)
|
||||
const hadOldest = prevProps.haveOldest;
|
||||
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
|
||||
const oneTimeScrollRow = isIncomingMessageRequest
|
||||
? undefined
|
||||
: this.getLastSeenIndicatorRow();
|
||||
: getLastSeenIndicatorRow(this.props);
|
||||
const atBottom = !isIncomingMessageRequest;
|
||||
|
||||
// TODO: DESKTOP-688
|
||||
|
@ -1111,7 +1036,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
let resizeStartRow: number | undefined;
|
||||
|
||||
if (isNumber(messageHeightChangeIndex)) {
|
||||
resizeStartRow = this.fromItemIndexToRow(messageHeightChangeIndex);
|
||||
resizeStartRow = fromItemIndexToRow(messageHeightChangeIndex, this.props);
|
||||
clearChangedMessages(id, messageHeightChangeBaton);
|
||||
}
|
||||
|
||||
|
@ -1137,7 +1062,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return;
|
||||
}
|
||||
|
||||
const newRow = this.fromItemIndexToRow(newFirstIndex);
|
||||
const newRow = fromItemIndexToRow(newFirstIndex, this.props);
|
||||
if (newRow > 0) {
|
||||
// We're loading more new messages at the top; we want to stay at the top
|
||||
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
|
||||
// consecutive rows (from start of the list) the are the same in both
|
||||
// lists.
|
||||
const rowsIterator = Timeline.getEphemeralRows({
|
||||
items,
|
||||
oldestUnreadIndex,
|
||||
hasTypingContact: Boolean(typingContactId),
|
||||
haveOldest,
|
||||
});
|
||||
const prevRowsIterator = Timeline.getEphemeralRows({
|
||||
items: prevProps.items,
|
||||
oldestUnreadIndex: prevProps.oldestUnreadIndex,
|
||||
hasTypingContact: Boolean(prevProps.typingContactId),
|
||||
haveOldest: prevProps.haveOldest,
|
||||
});
|
||||
const rowsIterator = getEphemeralRows(this.props);
|
||||
const prevRowsIterator = getEphemeralRows(prevProps);
|
||||
|
||||
let firstChangedRow = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
@ -1216,9 +1131,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
private getScrollTarget = (): number | undefined => {
|
||||
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
||||
|
||||
const rowCount = this.getRowCount();
|
||||
const rowCount = getRowCount(this.props);
|
||||
const targetMessageRow = isNumber(propScrollToIndex)
|
||||
? this.fromItemIndexToRow(propScrollToIndex)
|
||||
? fromItemIndexToRow(propScrollToIndex, this.props)
|
||||
: undefined;
|
||||
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
|
||||
|
||||
|
@ -1369,7 +1284,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
widthBreakpoint,
|
||||
} = this.state;
|
||||
|
||||
const rowCount = this.getRowCount();
|
||||
const rowCount = getRowCount(this.props);
|
||||
const scrollToIndex = this.getScrollTarget();
|
||||
|
||||
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(
|
||||
{ warning }: PropsType,
|
||||
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…
Reference in a new issue