Fix timeline item sizing bug, and test timeline logic

This commit is contained in:
Evan Hahn 2022-01-27 14:10:24 -06:00 committed by GitHub
parent 3864b941b9
commit 8fa4cd68d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 551 additions and 220 deletions

View File

@ -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;
}

View 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
View 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;
}