From 8fa4cd68d55f3ef2311efb7a489f2d2bbea0da3b Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 27 Jan 2022 14:10:24 -0600 Subject: [PATCH] Fix timeline item sizing bug, and test timeline logic --- ts/components/conversation/Timeline.tsx | 320 ++++++++---------------- ts/test-both/util/timelineUtil_test.ts | 299 ++++++++++++++++++++++ ts/util/timelineUtil.ts | 152 +++++++++++ 3 files changed, 551 insertions(+), 220 deletions(-) create mode 100644 ts/test-both/util/timelineUtil_test.ts create mode 100644 ts/util/timelineUtil.ts diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index db399ae6c..843b938b2 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -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 { 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 { 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 { 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 { 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 { } 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 { }; private rowRenderer = ({ - index, + index: rowIndex, key, parent, style, - }: RowRendererParamsType): JSX.Element => { + }: Readonly): JSX.Element => { const { id, i18n, @@ -786,82 +796,82 @@ export class Timeline extends React.PureComponent { } = 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 = ( -
- {Timeline.getWarning(this.props, this.state) ? ( -
- ) : null} - {renderHeroRow( - id, - this.resizeHeroRow, - unblurAvatar, - updateSharedGroups - )} -
- ); - } else if (oldestUnreadRow === row) { - rowContents = ( -
- {renderLastSeenIndicator(id)} -
- ); - } else if (typingBubbleRow === row) { - rowContents = ( -
- {renderTypingBubble(id)} -
- ); - } 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 = ( +
+ {Timeline.getWarning(this.props, this.state) ? ( +
+ ) : null} + {renderHeroRow( + id, + this.resizeHeroRow, + unblurAvatar, + updateSharedGroups + )} +
); - } - const previousMessageId: undefined | string = items[itemIndex - 1]; - const messageId = items[itemIndex]; - const nextMessageId: undefined | string = items[itemIndex + 1]; + break; + case getLastSeenIndicatorRow(this.props): + rowContents =
{renderLastSeenIndicator(id)}
; + break; + case getTypingBubbleRow(this.props): + rowContents = ( +
+ {renderTypingBubble(id)} +
+ ); + 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 = ( -
- window.showDebugLog()}> - {renderItem({ - actionProps, - containerElementRef: this.containerRef, - containerWidthBreakpoint: widthBreakpoint, - conversationId: id, - isOldestTimelineItem: haveOldest && itemIndex === 0, - messageId, - nextMessageId, - onHeightChange: this.resizeMessage, - previousMessageId, - })} - -
- ); + rowContents = ( +
+ window.showDebugLog()} + > + {renderItem({ + actionProps, + containerElementRef: this.containerRef, + containerWidthBreakpoint: widthBreakpoint, + conversationId: id, + isOldestTimelineItem: haveOldest && itemIndex === 0, + messageId, + nextMessageId, + onHeightChange: this.resizeMessage, + previousMessageId, + })} + +
+ ); + } + break; } return ( @@ -870,7 +880,7 @@ export class Timeline extends React.PureComponent { columnIndex={0} key={key} parent={parent} - rowIndex={index} + rowIndex={rowIndex} width={this.mostRecentWidth} > {rowContents} @@ -878,91 +888,6 @@ export class Timeline extends React.PureComponent { ); }; - 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 { } 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 { // 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 { // 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 { 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 { 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 { // 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 { 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 { 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 { ); } - private static *getEphemeralRows({ - hasTypingContact, - haveOldest, - items, - oldestUnreadIndex, - }: { - items: ReadonlyArray; - hasTypingContact: boolean; - oldestUnreadIndex?: number; - haveOldest: boolean; - }): Iterator { - 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 { } } } - -function getWidthBreakpoint(width: number): WidthBreakpoint { - if (width > 606) { - return WidthBreakpoint.Wide; - } - if (width > 514) { - return WidthBreakpoint.Medium; - } - return WidthBreakpoint.Narrow; -} diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts new file mode 100644 index 000000000..2e71ccde7 --- /dev/null +++ b/ts/test-both/util/timelineUtil_test.ts @@ -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(' utilities', () => { + const getItems = (count: number): Array => 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(iterator: Iterator): Array { + const result: Array = []; + 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', + ]); + }); + }); +}); diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts new file mode 100644 index 000000000..cd949470d --- /dev/null +++ b/ts/util/timelineUtil.ts @@ -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> +): 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> +): 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>): undefined | number { + return haveOldest ? 0 : undefined; +} + +export function getLastSeenIndicatorRow( + props: Readonly> +): 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 { + 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; +}