diff --git a/patches/react-virtualized+9.21.0.patch b/patches/react-virtualized+9.21.0.patch deleted file mode 100644 index ab34c9be68f2..000000000000 --- a/patches/react-virtualized+9.21.0.patch +++ /dev/null @@ -1,408 +0,0 @@ -diff --git a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js -index d9716a0..e7a9f9f 100644 ---- a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js -+++ b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js -@@ -166,13 +166,19 @@ var CellMeasurer = function (_React$PureComponent) { - height = _getCellMeasurements2.height, - width = _getCellMeasurements2.width; - -+ - cache.set(rowIndex, columnIndex, width, height); - - // If size has changed, let Grid know to re-render. - if (parent && typeof parent.invalidateCellSizeAfterRender === 'function') { -+ const heightChange = height - cache.defaultHeight; -+ const widthChange = width - cache.defaultWidth; -+ - parent.invalidateCellSizeAfterRender({ - columnIndex: columnIndex, -- rowIndex: rowIndex -+ rowIndex: rowIndex, -+ heightChange: heightChange, -+ widthChange: widthChange, - }); - } - } -diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js -index e1b959a..09c16c5 100644 ---- a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js -+++ b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js -@@ -132,6 +132,9 @@ var Grid = function (_React$PureComponent) { - _this._renderedRowStopIndex = 0; - _this._styleCache = {}; - _this._cellCache = {}; -+ _this._cellUpdates = []; -+ this._hasScrolledToColumnTarget = false; -+ this._hasScrolledToRowTarget = false; - - _this._debounceScrollEndedCallback = function () { - _this._disablePointerEventsTimeoutId = null; -@@ -345,7 +348,11 @@ var Grid = function (_React$PureComponent) { - scrollLeft: scrollLeft, - scrollTop: scrollTop, - totalColumnsWidth: totalColumnsWidth, -- totalRowsHeight: totalRowsHeight -+ totalRowsHeight: totalRowsHeight, -+ scrollToColumn: this.props.scrollToColumn, -+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, -+ scrollToRow: this.props.scrollToRow, -+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, - }); - } - -@@ -363,6 +370,13 @@ var Grid = function (_React$PureComponent) { - var columnIndex = _ref3.columnIndex, - rowIndex = _ref3.rowIndex; - -+ if (columnIndex < this._lastColumnStartIndex) { -+ this._cellUpdates.push({ columnIndex, widthChange: _ref3.widthChange }); -+ } -+ if (rowIndex < this._lastRowStartIndex) { -+ this._cellUpdates.push({ rowIndex, heightChange: _ref3.heightChange }); -+ } -+ - this._deferredInvalidateColumnIndex = typeof this._deferredInvalidateColumnIndex === 'number' ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) : columnIndex; - this._deferredInvalidateRowIndex = typeof this._deferredInvalidateRowIndex === 'number' ? Math.min(this._deferredInvalidateRowIndex, rowIndex) : rowIndex; - } -@@ -381,8 +395,12 @@ var Grid = function (_React$PureComponent) { - rowCount = _props2.rowCount; - var instanceProps = this.state.instanceProps; - -- instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1); -- instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1); -+ if (columnCount > 0) { -+ instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1); -+ } -+ if (rowCount > 0) { -+ instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1); -+ } - } - - /** -@@ -415,6 +433,16 @@ var Grid = function (_React$PureComponent) { - this._recomputeScrollLeftFlag = scrollToColumn >= 0 && (this.state.scrollDirectionHorizontal === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? columnIndex <= scrollToColumn : columnIndex >= scrollToColumn); - this._recomputeScrollTopFlag = scrollToRow >= 0 && (this.state.scrollDirectionVertical === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? rowIndex <= scrollToRow : rowIndex >= scrollToRow); - -+ // Important to ensure that when we, say, change the width of the viewport, -+ // we don't re-render, capture deltas, and move the scroll location around. -+ if (rowIndex === 0 && columnIndex === 0) { -+ this._disableCellUpdates = true; -+ } -+ -+ // Global notification that we should retry our scroll to props-requested indices -+ this._hasScrolledToColumnTarget = false; -+ this._hasScrolledToRowTarget = false; -+ - // Clear cell cache in case we are scrolling; - // Invalid row heights likely mean invalid cached content as well. - this._styleCache = {}; -@@ -526,7 +554,11 @@ var Grid = function (_React$PureComponent) { - scrollLeft: scrollLeft || 0, - scrollTop: scrollTop || 0, - totalColumnsWidth: instanceProps.columnSizeAndPositionManager.getTotalSize(), -- totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize() -+ totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize(), -+ scrollToColumn: scrollToColumn, -+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, -+ scrollToRow: scrollToRow, -+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, - }); - - this._maybeCallOnScrollbarPresenceChange(); -@@ -584,6 +616,65 @@ var Grid = function (_React$PureComponent) { - } - } - -+ var totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); -+ var totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); -+ -+ // We reset our hasScrolled flags if our target has changed, or if target is not longer set -+ if (scrollToColumn !== prevProps.scrollToColumn || scrollToColumn == null || scrollToColumn < 0) { -+ this._hasScrolledToColumnTarget = false; -+ } -+ if (scrollToRow !== prevProps.scrollToRow || scrollToRow == null || scrollToRow < 0) { -+ this._hasScrolledToRowTarget = false; -+ } -+ -+ // We deactivate our forced scrolling if the user scrolls -+ if (scrollLeft !== prevState.scrollLeft && scrollToColumn >= 0 && scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.OBSERVED) { -+ this._hasScrolledToColumnTarget = true; -+ } -+ if (scrollTop !== prevState.scrollTop && scrollToRow >= 0 && scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.OBSERVED) { -+ this._hasScrolledToRowTarget = true; -+ } -+ -+ if (scrollToColumn >= 0 && !this._hasScrolledToColumnTarget && scrollLeft + width <= totalColumnsWidth) { -+ const scrollRight = scrollLeft + width; -+ const targetColumn = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(scrollToColumn); -+ -+ let isVisible = false; -+ if (targetColumn.size <= width) { -+ const targetColumnRight = targetColumn.offset + targetColumn.size; -+ isVisible = (targetColumn.offset >= scrollLeft && targetColumnRight <= scrollRight); -+ } else { -+ isVisible = (targetColumn.offset >= scrollLeft && targetColumn.offset <= scrollRight); -+ } -+ -+ if (isVisible) { -+ const maxScroll = totalColumnsWidth - width; -+ this._hasScrolledToColumnTarget = (scrollLeft >= maxScroll || targetColumn.offset === scrollLeft); -+ } -+ } -+ if (scrollToRow >= 0 && !this._hasScrolledToRowTarget && scrollTop + height <= totalRowsHeight) { -+ const scrollBottom = scrollTop + height; -+ const targetRow = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(scrollToRow); -+ const maxScroll = totalRowsHeight - height; -+ -+ // When scrolling to bottom row, we want to go all the way to the bottom -+ if (scrollToRow === rowCount - 1) { -+ this._hasScrolledToRowTarget = scrollTop >= maxScroll; -+ } else { -+ let isVisible = false; -+ if (targetRow.size <= height) { -+ const targetRowBottom = targetRow.offset + targetRow.size; -+ isVisible = (targetRow.offset >= scrollTop && targetRowBottom <= scrollBottom); -+ } else { -+ isVisible = (targetRow.offset >= scrollTop && targetRow.offset <= scrollBottom); -+ } -+ -+ if (isVisible) { -+ this._hasScrolledToRowTarget = (scrollTop >= maxScroll || targetRow.offset === scrollTop); -+ } -+ } -+ } -+ - // Special case where the previous size was 0: - // In this case we don't show any windowed cells at all. - // So we should always recalculate offset afterwards. -@@ -594,6 +685,8 @@ var Grid = function (_React$PureComponent) { - if (this._recomputeScrollLeftFlag) { - this._recomputeScrollLeftFlag = false; - this._updateScrollLeftForScrollToColumn(this.props); -+ } else if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) { -+ this._updateScrollLeftForScrollToColumn(this.props); - } else { - (0, _updateScrollIndexHelper2.default)({ - cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager, -@@ -616,6 +709,8 @@ var Grid = function (_React$PureComponent) { - if (this._recomputeScrollTopFlag) { - this._recomputeScrollTopFlag = false; - this._updateScrollTopForScrollToRow(this.props); -+ } else if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) { -+ this._updateScrollTopForScrollToRow(this.props); - } else { - (0, _updateScrollIndexHelper2.default)({ - cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, -@@ -635,19 +730,50 @@ var Grid = function (_React$PureComponent) { - }); - } - -+ -+ if (this._disableCellUpdates) { -+ this._cellUpdates = []; -+ } -+ this._disableCellUpdates = false; -+ if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) { -+ this._cellUpdates = []; -+ } -+ if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) { -+ this._cellUpdates = []; -+ } -+ if (this._cellUpdates.length && scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.OBSERVED) { -+ let item; -+ let verticalDelta = 0; -+ let horizontalDelta = 0; -+ -+ while (item = this._cellUpdates.shift()) { -+ verticalDelta += item.heightChange || 0; -+ horizontalDelta += item.widthChange || 0; -+ } -+ -+ if (verticalDelta !== 0 || horizontalDelta !== 0) { -+ this.setState(Grid._getScrollToPositionStateUpdate({ -+ prevState: this.state, -+ scrollTop: scrollTop + verticalDelta, -+ scrollLeft: scrollLeft + horizontalDelta, -+ })); -+ } -+ } -+ - // Update onRowsRendered callback if start/stop indices have changed - this._invokeOnGridRenderedHelper(); - - // Changes to :scrollLeft or :scrollTop should also notify :onScroll listeners - if (scrollLeft !== prevState.scrollLeft || scrollTop !== prevState.scrollTop) { -- var totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); -- var totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); -- - this._invokeOnScrollMemoizer({ - scrollLeft: scrollLeft, - scrollTop: scrollTop, - totalColumnsWidth: totalColumnsWidth, -- totalRowsHeight: totalRowsHeight -+ totalRowsHeight: totalRowsHeight, -+ scrollToColumn: scrollToColumn, -+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, -+ scrollToRow: scrollToRow, -+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, - }); - } - -@@ -750,6 +876,7 @@ var Grid = function (_React$PureComponent) { - }, containerProps, { - 'aria-label': this.props['aria-label'], - 'aria-readonly': this.props['aria-readonly'], -+ 'aria-rowcount': this.props['rowCount'], - className: (0, _classnames2.default)('ReactVirtualized__Grid', className), - id: id, - onScroll: this._onScroll, -@@ -909,6 +1036,11 @@ var Grid = function (_React$PureComponent) { - visibleRowIndices: visibleRowIndices - }); - -+ this._lastColumnStartIndex = this._columnStartIndex; -+ this._lastColumnStopIndex = this._columnStopIndex; -+ this._lastRowStartIndex = this._rowStartIndex; -+ this._lastRowStopIndex = this._rowStopIndex; -+ - // update the indices - this._columnStartIndex = columnStartIndex; - this._columnStopIndex = columnStopIndex; -@@ -962,7 +1094,11 @@ var Grid = function (_React$PureComponent) { - var scrollLeft = _ref6.scrollLeft, - scrollTop = _ref6.scrollTop, - totalColumnsWidth = _ref6.totalColumnsWidth, -- totalRowsHeight = _ref6.totalRowsHeight; -+ totalRowsHeight = _ref6.totalRowsHeight, -+ scrollToColumn = _ref6.scrollToColumn, -+ _hasScrolledToColumnTarget = _ref6._hasScrolledToColumnTarget, -+ scrollToRow = _ref6.scrollToRow, -+ _hasScrolledToRowTarget = _ref6._hasScrolledToRowTarget; - - this._onScrollMemoizer({ - callback: function callback(_ref7) { -@@ -973,19 +1109,26 @@ var Grid = function (_React$PureComponent) { - onScroll = _props7.onScroll, - width = _props7.width; - -- - onScroll({ - clientHeight: height, - clientWidth: width, - scrollHeight: totalRowsHeight, - scrollLeft: scrollLeft, - scrollTop: scrollTop, -- scrollWidth: totalColumnsWidth -+ scrollWidth: totalColumnsWidth, -+ scrollToColumn: scrollToColumn, -+ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget, -+ scrollToRow: scrollToRow, -+ _hasScrolledToRowTarget: _hasScrolledToRowTarget, - }); - }, - indices: { - scrollLeft: scrollLeft, -- scrollTop: scrollTop -+ scrollTop: scrollTop, -+ scrollToColumn: scrollToColumn, -+ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget, -+ scrollToRow: scrollToRow, -+ _hasScrolledToRowTarget: _hasScrolledToRowTarget, - } - }); - } -@@ -1325,6 +1468,15 @@ var Grid = function (_React$PureComponent) { - var totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - var scrollBarSize = instanceProps.scrollbarSizeMeasured && totalColumnsWidth > width ? instanceProps.scrollbarSize : 0; - -+ // If we're scrolling to the last row, then we scroll as far as we can, -+ // even if we can't see the entire row. We need to be at the bottom. -+ if (targetIndex === finalRow) { -+ const totalHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); -+ const maxScroll = totalHeight - height; -+ -+ return maxScroll; -+ } -+ - return instanceProps.rowSizeAndPositionManager.getUpdatedOffsetForIndex({ - align: scrollToAlignment, - containerSize: height - scrollBarSize, -diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js -index 70b0abe..8e12ffc 100644 ---- a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js -+++ b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js -@@ -32,15 +32,8 @@ function defaultOverscanIndicesGetter(_ref) { - // For more info see issues #625 - overscanCellsCount = Math.max(1, overscanCellsCount); - -- if (scrollDirection === SCROLL_DIRECTION_FORWARD) { -- return { -- overscanStartIndex: Math.max(0, startIndex - 1), -- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) -- }; -- } else { -- return { -- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), -- overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1) -- }; -- } -+ return { -+ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), -+ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount), -+ }; - } -\ No newline at end of file -diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js -index d5f6d04..c4b3d84 100644 ---- a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js -+++ b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js -@@ -27,15 +27,8 @@ function defaultOverscanIndicesGetter(_ref) { - startIndex = _ref.startIndex, - stopIndex = _ref.stopIndex; - -- if (scrollDirection === SCROLL_DIRECTION_FORWARD) { -- return { -- overscanStartIndex: Math.max(0, startIndex), -- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) -- }; -- } else { -- return { -- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), -- overscanStopIndex: Math.min(cellCount - 1, stopIndex) -- }; -- } -+ return { -+ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), -+ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount), -+ }; - } -\ No newline at end of file -diff --git a/node_modules/react-virtualized/dist/commonjs/List/List.js b/node_modules/react-virtualized/dist/commonjs/List/List.js -index b5ad0eb..efb2cd7 100644 ---- a/node_modules/react-virtualized/dist/commonjs/List/List.js -+++ b/node_modules/react-virtualized/dist/commonjs/List/List.js -@@ -112,13 +112,8 @@ var List = function (_React$PureComponent) { - }, _this._setRef = function (ref) { - _this.Grid = ref; - }, _this._onScroll = function (_ref3) { -- var clientHeight = _ref3.clientHeight, -- scrollHeight = _ref3.scrollHeight, -- scrollTop = _ref3.scrollTop; - var onScroll = _this.props.onScroll; -- -- -- onScroll({ clientHeight: clientHeight, scrollHeight: scrollHeight, scrollTop: scrollTop }); -+ onScroll(_ref3); - }, _this._onSectionRendered = function (_ref4) { - var rowOverscanStartIndex = _ref4.rowOverscanStartIndex, - rowOverscanStopIndex = _ref4.rowOverscanStopIndex, -diff --git a/node_modules/react-virtualized/dist/es/WindowScroller/utils/onScroll.js b/node_modules/react-virtualized/dist/es/WindowScroller/utils/onScroll.js -index 6418a78..afbc3c3 100644 ---- a/node_modules/react-virtualized/dist/es/WindowScroller/utils/onScroll.js -+++ b/node_modules/react-virtualized/dist/es/WindowScroller/utils/onScroll.js -@@ -72,4 +72,3 @@ export function unregisterScrollListener(component, element) { - } - } - } --import { bpfrpt_proptype_WindowScroller } from '../WindowScroller.js'; -\ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e1059b5c4e65..231bd58f8aaa 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -73,12 +73,12 @@ .module-message { position: relative; - display: inline-flex; + display: flex; flex-direction: row; align-items: stretch; outline: none; - margin-left: 16px; - margin-right: 16px; + padding-left: 16px; + padding-right: 16px; } .module-message--expired { @@ -104,8 +104,7 @@ } .module-message--outgoing { - float: right; - justify-content: flex-end; + flex-direction: row-reverse; } .module-message__buttons { @@ -316,7 +315,6 @@ line-height: 0; display: flex; flex-direction: column; - width: 100%; min-width: 0; max-width: 306px; @@ -336,8 +334,9 @@ position: relative; display: inline-block; border-radius: 16px; + margin-bottom: 4px; + margin-top: 4px; min-width: 0px; - width: 100%; overflow: hidden; // These should match the margins in .module-message__attachment-container. @@ -5495,21 +5494,26 @@ button.module-image__border-overlay:focus { // Module: Timeline .module-timeline { + display: flex; height: 100%; overflow: hidden; - - .ReactVirtualized__List { - @include scrollbar; - } } .module-timeline--disabled { user-select: none; } -.module-timeline__message-container { - padding-top: 4px; - padding-bottom: 4px; +.module-timeline__messages__container { + flex: 1 1; + overflow-x: hidden; + overflow-y: overlay; + display: flex; + flex-direction: column; +} + +.module-timeline__messages { + flex: 1 1; + padding-bottom: 6px; } .ReactVirtualized__List { diff --git a/stylesheets/components/TimelineWarnings.scss b/stylesheets/components/TimelineWarnings.scss index d7b4e10e5130..5b1b2db71880 100644 --- a/stylesheets/components/TimelineWarnings.scss +++ b/stylesheets/components/TimelineWarnings.scss @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only .module-TimelineWarnings { @@ -6,7 +6,7 @@ position: absolute; top: 0; width: 100%; - z-index: $z-index-base; + z-index: $z-index-above-above-base; display: flex; flex-direction: column; diff --git a/ts/components/ContactPills.tsx b/ts/components/ContactPills.tsx index 4305d7a394e0..08ea6eb76d35 100644 --- a/ts/components/ContactPills.tsx +++ b/ts/components/ContactPills.tsx @@ -1,11 +1,11 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent, ReactNode } from 'react'; import React, { useRef, useEffect, Children } from 'react'; import { usePrevious } from '../hooks/usePrevious'; -import { scrollToBottom } from '../util/scrollToBottom'; +import { scrollToBottom } from '../util/scrollUtil'; type PropsType = { children?: ReactNode; diff --git a/ts/components/SampleMessageBubbles.tsx b/ts/components/SampleMessageBubbles.tsx index d3305d5e575f..ae6814fcabcb 100644 --- a/ts/components/SampleMessageBubbles.tsx +++ b/ts/components/SampleMessageBubbles.tsx @@ -21,7 +21,7 @@ const SampleMessage = ({ direction, i18n, text, - timestamp, + timestampDeltaFromNow, status, style, }: { @@ -29,7 +29,7 @@ const SampleMessage = ({ direction: 'incoming' | 'outgoing'; i18n: LocalizerType; text: string; - timestamp: number; + timestampDeltaFromNow: number; status: 'delivered' | 'read' | 'sent'; style?: CSSProperties; }): JSX.Element => ( @@ -51,7 +51,7 @@ const SampleMessage = ({ - {formatTime(i18n, timestamp)} + {formatTime(i18n, Date.now() - timestampDeltaFromNow, Date.now())} {direction === 'outgoing' && (
@@ -91,7 +91,7 @@ export const SampleMessageBubbles = ({ direction="incoming" i18n={i18n} text={i18n('ChatColorPicker__sampleBubble2')} - timestamp={Date.now() - A_FEW_DAYS_AGO / 2} + timestampDeltaFromNow={A_FEW_DAYS_AGO / 2} status="read" />
@@ -103,7 +103,7 @@ export const SampleMessageBubbles = ({ direction="outgoing" i18n={i18n} text={i18n('ChatColorPicker__sampleBubble3')} - timestamp={Date.now()} + timestampDeltaFromNow={0} status="delivered" style={backgroundStyle} /> diff --git a/ts/components/conversation/CallingNotification.stories.tsx b/ts/components/conversation/CallingNotification.stories.tsx index d998dfe338a1..de31dac0d194 100644 --- a/ts/components/conversation/CallingNotification.stories.tsx +++ b/ts/components/conversation/CallingNotification.stories.tsx @@ -19,8 +19,8 @@ const getCommonProps = () => ({ conversationId: 'fake-conversation-id', i18n, messageId: 'fake-message-id', - messageSizeChanged: action('messageSizeChanged'), nextItem: undefined, + now: Date.now(), returnToActiveCall: action('returnToActiveCall'), startCallingLobby: action('startCallingLobby'), }); diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index f22585bfdadb..38a97662cea7 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -2,8 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; -import React, { useState, useEffect } from 'react'; -import Measure from 'react-measure'; +import React from 'react'; import { noop } from 'lodash'; import { SystemMessage } from './SystemMessage'; @@ -16,14 +15,12 @@ import { getCallingIcon, getCallingNotificationText, } from '../../util/callingNotification'; -import { usePrevious } from '../../hooks/usePrevious'; import { missingCaseError } from '../../util/missingCaseError'; import { Tooltip, TooltipPlacement } from '../Tooltip'; import type { TimelineItemType } from './TimelineItem'; import * as log from '../../logging/log'; export type PropsActionsType = { - messageSizeChanged: (messageId: string, conversationId: string) => void; returnToActiveCall: () => void; startCallingLobby: (_: { conversationId: string; @@ -34,27 +31,14 @@ export type PropsActionsType = { type PropsHousekeeping = { i18n: LocalizerType; conversationId: string; - messageId: string; nextItem: undefined | TimelineItemType; + now: number; }; type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; export const CallingNotification: React.FC = React.memo(props => { - const { conversationId, i18n, messageId, messageSizeChanged } = props; - - const [height, setHeight] = useState(null); - const previousHeight = usePrevious(null, height); - - useEffect(() => { - if (height === null) { - return; - } - - if (previousHeight !== null && height !== previousHeight) { - messageSizeChanged(messageId, conversationId); - } - }, [height, previousHeight, conversationId, messageId, messageSizeChanged]); + const { i18n, now } = props; let timestamp: number; let wasMissed = false; @@ -75,38 +59,25 @@ export const CallingNotification: React.FC = React.memo(props => { const icon = getCallingIcon(props); return ( - { - if (!bounds) { - log.error('We should be measuring the bounds'); - return; - } - setHeight(bounds.height); - }} - > - {({ measureRef }) => ( - - {getCallingNotificationText(props, i18n)} ·{' '} - - - } - icon={icon} - isError={wasMissed} - ref={measureRef} - /> - )} - + + {getCallingNotificationText(props, i18n)} ·{' '} + + + } + icon={icon} + isError={wasMissed} + /> ); }); diff --git a/ts/components/conversation/ChangeNumberNotification.stories.tsx b/ts/components/conversation/ChangeNumberNotification.stories.tsx index 9e0bc69b17c4..a35ac5ae7de6 100644 --- a/ts/components/conversation/ChangeNumberNotification.stories.tsx +++ b/ts/components/conversation/ChangeNumberNotification.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -19,6 +19,7 @@ const i18n = setupI18n('en', enMessages); story.add('Default', () => ( ( story.add('Long name', () => ( = props => { - const { i18n, sender, timestamp } = props; + const { i18n, now, sender, timestamp } = props; return ( = props => { i18n={i18n} />  ยท  - + } icon="phone" diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index 58d94906f249..1af66a35d248 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -55,7 +55,6 @@ storiesOf('Components/Conversation/ConversationHero', module) 'Fifth', ]} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} />
); @@ -83,7 +82,6 @@ storiesOf('Components/Conversation/ConversationHero', module) 'Fourth', ]} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -106,7 +104,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party', 'Friends ๐ŸŒฟ']} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -129,7 +126,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -152,7 +148,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={['NYC Rock Climbers']} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -175,7 +170,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -198,7 +192,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -221,7 +214,6 @@ storiesOf('Components/Conversation/ConversationHero', module) updateSharedGroups={updateSharedGroups} sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} - onHeightChange={action('onHeightChange')} /> ); @@ -243,7 +235,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -265,7 +256,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -285,7 +275,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -305,7 +294,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -326,7 +314,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -347,7 +334,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -367,7 +353,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); @@ -386,7 +371,6 @@ storiesOf('Components/Conversation/ConversationHero', module) sharedGroupNames={[]} unblurAvatar={action('unblurAvatar')} updateSharedGroups={updateSharedGroups} - onHeightChange={action('onHeightChange')} /> ); diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index e38846313d58..9459a35eae66 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -1,7 +1,7 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { Props as AvatarProps } from '../Avatar'; import { Avatar, AvatarBlur } from '../Avatar'; import { ContactName } from './ContactName'; @@ -12,7 +12,6 @@ import type { LocalizerType, ThemeType } from '../../types/Util'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { Button, ButtonSize, ButtonVariant } from '../Button'; import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; -import * as log from '../../logging/log'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; export type Props = { @@ -22,7 +21,6 @@ export type Props = { i18n: LocalizerType; isMe: boolean; membersCount?: number; - onHeightChange: () => unknown; phoneNumber?: string; sharedGroupNames?: Array; unblurAvatar: () => void; @@ -111,13 +109,10 @@ export const ConversationHero = ({ profileName, theme, title, - onHeightChange, unblurAvatar, unblurredAvatarPath, updateSharedGroups, }: Props): JSX.Element => { - const firstRenderRef = useRef(true); - const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] = useState(false); const closeMessageRequestWarning = () => { @@ -129,30 +124,6 @@ export const ConversationHero = ({ updateSharedGroups(); }, [updateSharedGroups]); - const sharedGroupNamesStringified = JSON.stringify(sharedGroupNames); - useEffect(() => { - const isFirstRender = firstRenderRef.current; - if (isFirstRender) { - firstRenderRef.current = false; - return; - } - - log.info('ConversationHero: calling onHeightChange'); - onHeightChange(); - }, [ - about, - conversationType, - groupDescription, - isMe, - membersCount, - name, - onHeightChange, - phoneNumber, - profileName, - title, - sharedGroupNamesStringified, - ]); - let avatarBlur: AvatarBlur; let avatarOnClick: undefined | (() => void); if ( diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 93c5ecdf2fa6..6d91e35c0a94 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -83,6 +83,7 @@ const MessageAudioContainer: React.FC = props => { audio={audio} computePeaks={computePeaks} setActiveAudioID={(id, context) => setActive({ id, context })} + now={Date.now()} onFirstPlayed={action('onFirstPlayed')} activeAudioID={active.id} activeAudioContext={active.context} @@ -131,6 +132,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined), i18n, id: text('id', overrideProps.id || ''), + now: Date.now(), renderingContext: 'storybook', interactionMode: overrideProps.interactionMode || 'keyboard', isSticker: isBoolean(overrideProps.isSticker) @@ -149,7 +151,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markViewed: action('markViewed'), messageExpanded: action('messageExpanded'), - onHeightChange: action('onHeightChange'), openConversation: action('openConversation'), openLink: action('openLink'), previews: overrideProps.previews || [], diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index e216cf3ca99f..49017516587a 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1,7 +1,7 @@ // Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import React from 'react'; import ReactDOM, { createPortal } from 'react-dom'; import classNames from 'classnames'; @@ -118,6 +118,7 @@ export type AudioAttachmentProps = { expirationLength?: number; expirationTimestamp?: number; id: string; + now: number; played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; @@ -210,6 +211,7 @@ export type PropsHousekeeping = { containerWidthBreakpoint: WidthBreakpoint; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; + now: number; interactionMode: InteractionModeType; theme: ThemeType; disableMenu?: boolean; @@ -224,7 +226,6 @@ export type PropsHousekeeping = { export type PropsActions = { clearSelectedMessage: () => unknown; doubleCheckMissingQuoteReference: (messageId: string) => unknown; - onHeightChange: () => unknown; messageExpanded: (id: string, displayLimit: number) => unknown; checkForAccount: (identifier: string) => unknown; @@ -471,7 +472,6 @@ export class Message extends React.PureComponent { } this.checkExpired(); - this.checkForHeightChange(prevProps); if ( prevProps.status === 'sending' && @@ -491,24 +491,6 @@ export class Message extends React.PureComponent { } } - public checkForHeightChange(prevProps: Props): void { - const { contact, onHeightChange } = this.props; - const willRenderSendMessageButton = Boolean( - contact && contact.firstNumber && contact.isNumberOnSignal - ); - - const { contact: previousContact } = prevProps; - const previouslyRenderedSendMessageButton = Boolean( - previousContact && - previousContact.firstNumber && - previousContact.isNumberOnSignal - ); - - if (willRenderSendMessageButton !== previouslyRenderedSendMessageButton) { - onHeightChange(); - } - } - public startSelectedTimer(): void { const { clearSelectedMessage, interactionMode } = this.props; const { isSelected } = this.state; @@ -609,6 +591,7 @@ export class Message extends React.PureComponent { isTapToViewExpired, status, i18n, + now, text, textPending, timestamp, @@ -640,6 +623,7 @@ export class Message extends React.PureComponent { isShowingImage={this.isShowingImage()} isSticker={isStickerLike} isTapToViewExpired={isTapToViewExpired} + now={now} showMessageDetail={showMessageDetail} status={status} textPending={textPending} @@ -705,6 +689,7 @@ export class Message extends React.PureComponent { kickOffAttachmentDownload, markAttachmentAsCorrupted, markViewed, + now, quote, readStatus, reducedMotion, @@ -834,6 +819,7 @@ export class Message extends React.PureComponent { expirationLength, expirationTimestamp, id, + now, played, showMessageDetail, status, @@ -1238,7 +1224,6 @@ export class Message extends React.PureComponent { i18n, id, messageExpanded, - onHeightChange, openConversation, status, text, @@ -1276,7 +1261,6 @@ export class Message extends React.PureComponent { id={id} messageExpanded={messageExpanded} openConversation={openConversation} - onHeightChange={onHeightChange} text={contents || ''} textPending={textPending} /> @@ -1284,13 +1268,9 @@ export class Message extends React.PureComponent { ); } - public renderError(isCorrectSide: boolean): JSX.Element | null { + private renderError(): ReactNode { const { status, direction } = this.props; - if (!isCorrectSide) { - return null; - } - if ( status !== 'paused' && status !== 'error' && @@ -1312,10 +1292,7 @@ export class Message extends React.PureComponent { ); } - public renderMenu( - isCorrectSide: boolean, - triggerId: string - ): JSX.Element | null { + private renderMenu(triggerId: string): ReactNode { const { attachments, canDownload, @@ -1334,7 +1311,7 @@ export class Message extends React.PureComponent { selectedReaction, } = this.props; - if (!isCorrectSide || disableMenu) { + if (disableMenu) { return null; } @@ -2462,12 +2439,10 @@ export class Message extends React.PureComponent { onFocus={this.handleFocus} ref={this.focusRef} > - {this.renderError(direction === 'incoming')} - {this.renderMenu(direction === 'outgoing', triggerId)} + {this.renderError()} {this.renderAvatar()} {this.renderContainer()} - {this.renderError(direction === 'outgoing')} - {this.renderMenu(direction === 'incoming', triggerId)} + {this.renderMenu(triggerId)} {this.renderContextMenu(triggerId)} ); diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 7a99b909cafc..a4ec647684ae 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useRef, useEffect, useState } from 'react'; @@ -27,6 +27,7 @@ export type Props = { expirationLength?: number; expirationTimestamp?: number; id: string; + now: number; played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; @@ -157,6 +158,7 @@ export const MessageAudio: React.FC = (props: Props) => { expirationLength, expirationTimestamp, id, + now, played, showMessageDetail, status, @@ -539,6 +541,7 @@ export const MessageAudio: React.FC = (props: Props) => { isShowingImage={false} isSticker={false} isTapToViewExpired={false} + now={now} showMessageDetail={showMessageDetail} status={status} textPending={textPending} diff --git a/ts/components/conversation/MessageBodyReadMore.stories.tsx b/ts/components/conversation/MessageBodyReadMore.stories.tsx index 55cdb2e6e59a..e4ba4fa35961 100644 --- a/ts/components/conversation/MessageBodyReadMore.stories.tsx +++ b/ts/components/conversation/MessageBodyReadMore.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useState } from 'react'; @@ -23,7 +23,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ i18n, id: 'some-id', messageExpanded: action('messageExpanded'), - onHeightChange: action('onHeightChange'), text: text('text', overrideProps.text || ''), }); diff --git a/ts/components/conversation/MessageBodyReadMore.tsx b/ts/components/conversation/MessageBodyReadMore.tsx index 6ca1d57b246b..32e0cb3badf4 100644 --- a/ts/components/conversation/MessageBodyReadMore.tsx +++ b/ts/components/conversation/MessageBodyReadMore.tsx @@ -1,11 +1,10 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect } from 'react'; +import React from 'react'; import type { Props as MessageBodyPropsType } from './MessageBody'; import { MessageBody } from './MessageBody'; -import { usePrevious } from '../../hooks/usePrevious'; export type Props = Pick< MessageBodyPropsType, @@ -20,7 +19,6 @@ export type Props = Pick< id: string; displayLimit?: number; messageExpanded: (id: string, displayLimit: number) => unknown; - onHeightChange: () => unknown; }; const INITIAL_LENGTH = 800; @@ -70,19 +68,11 @@ export function MessageBodyReadMore({ i18n, id, messageExpanded, - onHeightChange, openConversation, text, textPending, }: Props): JSX.Element { const maxLength = displayLimit || INITIAL_LENGTH; - const previousMaxLength = usePrevious(maxLength, maxLength); - - useEffect(() => { - if (previousMaxLength !== maxLength) { - onHeightChange(); - } - }, [maxLength, previousMaxLength, onHeightChange]); const { hasReadMore, text: slicedText } = graphemeAwareSlice(text, maxLength); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index fb0bdaf24cda..c17c5505c788 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -23,6 +23,8 @@ import { SendStatus } from '../../messages/MessageSendState'; import { WidthBreakpoint } from '../_util'; import * as log from '../../logging/log'; import { formatDateTimeLong } from '../../util/timestamp'; +import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; +import { MINUTE } from '../../util/durations'; export type Contact = Pick< ConversationType, @@ -98,16 +100,20 @@ export type PropsReduxActions = Pick< export type ExternalProps = PropsData & PropsBackboneActions; export type Props = PropsData & PropsBackboneActions & PropsReduxActions; +type State = { nowThatUpdatesEveryMinute: number }; + const contactSortCollator = new Intl.Collator(); const _keyForError = (error: Error): string => { return `${error.name}-${error.message}`; }; -export class MessageDetail extends React.Component { - private readonly focusRef = React.createRef(); +export class MessageDetail extends React.Component { + override state = { nowThatUpdatesEveryMinute: Date.now() }; + private readonly focusRef = React.createRef(); private readonly messageContainerRef = React.createRef(); + private nowThatUpdatesEveryMinuteInterval?: ReturnType; public override componentDidMount(): void { // When this component is created, it's initially not part of the DOM, and then it's @@ -117,6 +123,14 @@ export class MessageDetail extends React.Component { this.focusRef.current.focus(); } }); + + this.nowThatUpdatesEveryMinuteInterval = setInterval(() => { + this.setState({ nowThatUpdatesEveryMinute: Date.now() }); + }, MINUTE); + } + + public override componentWillUnmount(): void { + clearTimeoutIfNecessary(this.nowThatUpdatesEveryMinuteInterval); } public renderAvatar(contact: Contact): JSX.Element { @@ -298,6 +312,7 @@ export class MessageDetail extends React.Component { showVisualAttachment, theme, } = this.props; + const { nowThatUpdatesEveryMinute } = this.state; return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex @@ -335,7 +350,7 @@ export class MessageDetail extends React.Component { markAttachmentAsCorrupted={markAttachmentAsCorrupted} markViewed={markViewed} messageExpanded={noop} - onHeightChange={noop} + now={nowThatUpdatesEveryMinute} openConversation={openConversation} openLink={openLink} reactToMessage={reactToMessage} diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx index ac68748de52b..c1450add72a1 100644 --- a/ts/components/conversation/MessageMetadata.tsx +++ b/ts/components/conversation/MessageMetadata.tsx @@ -22,6 +22,7 @@ type PropsType = { isShowingImage: boolean; isSticker?: boolean; isTapToViewExpired?: boolean; + now: number; showMessageDetail: (id: string) => void; status?: MessageStatusType; textPending?: boolean; @@ -40,6 +41,7 @@ export const MessageMetadata: FunctionComponent = props => { isShowingImage, isSticker, isTapToViewExpired, + now, showMessageDetail, status, textPending, @@ -97,6 +99,7 @@ export const MessageMetadata: FunctionComponent = props => { => [ const createProps = (overrideProps: Partial = {}): Props => ({ i18n, timestamp: overrideProps.timestamp, + now: Date.now(), module: text('module', ''), withImageNoCaption: boolean('withImageNoCaption', false), withSticker: boolean('withSticker', false), diff --git a/ts/components/conversation/MessageTimestamp.tsx b/ts/components/conversation/MessageTimestamp.tsx index dbc94bb297a8..930209dc56d9 100644 --- a/ts/components/conversation/MessageTimestamp.tsx +++ b/ts/components/conversation/MessageTimestamp.tsx @@ -1,16 +1,17 @@ // Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; -import moment from 'moment'; import { formatTime } from '../../util/timestamp'; -import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import type { LocalizerType } from '../../types/Util'; +import { Time } from '../Time'; export type Props = { + now: number; timestamp?: number; module?: string; withImageNoCaption?: boolean; @@ -20,63 +21,36 @@ export type Props = { i18n: LocalizerType; }; -const UPDATE_FREQUENCY = 60 * 1000; +export function MessageTimestamp({ + direction, + i18n, + module, + now, + timestamp, + withImageNoCaption, + withSticker, + withTapToViewExpired, +}: Readonly): null | ReactElement { + const moduleName = module || 'module-timestamp'; -export class MessageTimestamp extends React.Component { - private interval: NodeJS.Timeout | null; - - constructor(props: Props) { - super(props); - - this.interval = null; + if (timestamp === null || timestamp === undefined) { + return null; } - public override componentDidMount(): void { - const update = () => { - this.setState({ - // Used to trigger renders - // eslint-disable-next-line react/no-unused-state - lastUpdated: Date.now(), - }); - }; - this.interval = setInterval(update, UPDATE_FREQUENCY); - } - - public override componentWillUnmount(): void { - clearTimeoutIfNecessary(this.interval); - } - - public override render(): JSX.Element | null { - const { - direction, - i18n, - module, - timestamp, - withImageNoCaption, - withSticker, - withTapToViewExpired, - } = this.props; - const moduleName = module || 'module-timestamp'; - - if (timestamp === null || timestamp === undefined) { - return null; - } - - return ( - - {formatTime(i18n, timestamp)} - - ); - } + return ( + + ); } diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 9914b0867d5d..c1ff1ef7ef36 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -59,6 +59,7 @@ const defaultMessageProps: MessagesProps = { getPreferredBadge: () => undefined, i18n, id: 'messageId', + now: Date.now(), renderingContext: 'storybook', interactionMode: 'keyboard', isBlocked: false, @@ -67,7 +68,6 @@ const defaultMessageProps: MessagesProps = { markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), markViewed: action('default--markViewed'), messageExpanded: action('default--message-expanded'), - onHeightChange: action('default--onHeightChange'), openConversation: action('default--openConversation'), openLink: action('default--openLink'), previews: [], diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index aaaabbf62c3b..f47b0e219fca 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -324,11 +324,9 @@ const actions = () => ({ 'acknowledgeGroupMemberNameCollisions' ), checkForAccount: action('checkForAccount'), - clearChangedMessages: action('clearChangedMessages'), clearInvitedUuidsForNewlyCreatedGroup: action( 'clearInvitedUuidsForNewlyCreatedGroup' ), - setLoadCountdownStart: action('setLoadCountdownStart'), setIsNearBottom: action('setIsNearBottom'), learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'), loadAndScroll: action('loadAndScroll'), @@ -358,7 +356,6 @@ const actions = () => ({ displayTapToViewMessage: action('displayTapToViewMessage'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), - onHeightChange: action('onHeightChange'), openLink: action('openLink'), scrollToQuotedMessage: action('scrollToQuotedMessage'), showExpiredIncomingTapToViewToast: action( @@ -373,7 +370,6 @@ const actions = () => ({ downloadNewVersion: action('downloadNewVersion'), - messageSizeChanged: action('messageSizeChanged'), startCallingLobby: action('startCallingLobby'), returnToActiveCall: action('returnToActiveCall'), @@ -401,11 +397,13 @@ const renderItem = ({ containerElementRef, containerWidthBreakpoint, isOldestTimelineItem, + now, }: { messageId: string; containerElementRef: React.RefObject; containerWidthBreakpoint: WidthBreakpoint; isOldestTimelineItem: boolean; + now: number; }) => ( undefined} @@ -417,6 +415,7 @@ const renderItem = ({ item={items[messageId]} previousItem={undefined} nextItem={undefined} + now={now} i18n={i18n} interactionMode="keyboard" theme={ThemeType.light} @@ -460,7 +459,6 @@ const renderHeroRow = () => { profileName={getProfileName()} phoneNumber={getPhoneNumber()} conversationType="direct" - onHeightChange={action('onHeightChange in ConversationHero')} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} theme={theme} unblurAvatar={action('unblurAvatar')} @@ -486,6 +484,7 @@ const renderTypingBubble = () => ( ); const useProps = (overrideProps: Partial = {}): PropsType => ({ + discardMessages: action('discardMessages'), getPreferredBadge: () => undefined, i18n, theme: React.useContext(StorybookThemeContext), @@ -493,6 +492,7 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ getTimestampForMessage: Date.now, haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false), haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false), + isConversationSelected: true, isIncomingMessageRequest: boolean( 'isIncomingMessageRequest', overrideProps.isIncomingMessageRequest === true @@ -502,7 +502,6 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ overrideProps.isLoadingMessages === false ), items: overrideProps.items || Object.keys(items), - resetCounter: 0, scrollToIndex: overrideProps.scrollToIndex, scrollToIndexCounter: 0, totalUnread: number('totalUnread', overrideProps.totalUnread || 0), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 29acf2b9afb4..d113de479dcf 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -1,13 +1,11 @@ // Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { debounce, get, isEqual, isNumber, pick } from 'lodash'; +import { first, get, isNumber, last, pick, throttle } from 'lodash'; import classNames from 'classnames'; import type { ReactChild, ReactNode, RefObject } from 'react'; -import React from 'react'; +import React, { Fragment } from 'react'; import { createSelector } from 'reselect'; -import type { Grid, ListRowProps } from 'react-virtualized'; -import { AutoSizer, CellMeasurer, List } from 'react-virtualized'; import Measure from 'react-measure'; import { ScrollDownButton } from './ScrollDownButton'; @@ -15,9 +13,8 @@ import { ScrollDownButton } from './ScrollDownButton'; import type { AssertProps, LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; -import { assert } from '../../util/assert'; +import { assert, strictAssert } from '../../util/assert'; import { missingCaseError } from '../../util/missingCaseError'; -import { createRefMerger } from '../../util/refMerger'; import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { WidthBreakpoint } from '../_util'; @@ -35,25 +32,17 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions'; import { TimelineFloatingHeader } from './TimelineFloatingHeader'; +import { getWidthBreakpoint } from '../../util/timelineUtil'; import { - RowHeightCache, - fromItemIndexToRow, - fromRowToItemIndex, - getEphemeralRows, - getHeroRow, - getLastSeenIndicatorRow, - getRowCount, - getTypingBubbleRow, - getWidthBreakpoint, -} from '../../util/timelineUtil'; + getScrollBottom, + scrollToBottom, + setScrollBottom, +} from '../../util/scrollUtil'; +import { MINUTE } from '../../util/durations'; -const ESTIMATED_ROW_HEIGHT = 64; const AT_BOTTOM_THRESHOLD = 15; -const NEAR_BOTTOM_THRESHOLD = 15; -const AT_TOP_THRESHOLD = 10; -const LOAD_MORE_THRESHOLD = 30; +const MIN_ROW_HEIGHT = 18; const SCROLL_DOWN_BUTTON_THRESHOLD = 8; -export const LOAD_COUNTDOWN = 1; export type WarningType = | { @@ -89,11 +78,7 @@ export type PropsDataType = { isLoadingMessages: boolean; isNearBottom?: boolean; items: ReadonlyArray; - loadCountdownStart?: number; - messageHeightChangeBaton?: unknown; - messageHeightChangeIndex?: number; oldestUnreadIndex?: number; - resetCounter: number; scrollToIndex?: number; scrollToIndexCounter: number; totalUnread: number; @@ -102,6 +87,7 @@ export type PropsDataType = { type PropsHousekeepingType = { id: string; areWeAdmin?: boolean; + isConversationSelected: boolean; isGroupV1AndDisabled?: boolean; isIncomingMessageRequest: boolean; typingContactId?: string; @@ -113,6 +99,9 @@ type PropsHousekeepingType = { warning?: WarningType; contactSpoofingReview?: ContactSpoofingReviewPropType; + discardMessages: ( + _: Readonly<{ conversationId: string; numberToKeepAtBottom: number }> + ) => void; getTimestampForMessage: (messageId: string) => undefined | number; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; @@ -126,13 +115,12 @@ type PropsHousekeepingType = { isOldestTimelineItem: boolean; messageId: string; nextMessageId: undefined | string; - onHeightChange: (messageId: string) => unknown; + now: number; previousMessageId: undefined | string; }) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element; renderHeroRow: ( id: string, - resizeHeroRow: () => unknown, unblurAvatar: () => void, updateSharedGroups: () => unknown ) => JSX.Element; @@ -143,13 +131,8 @@ export type PropsActionsType = { acknowledgeGroupMemberNameCollisions: ( groupNameCollisions: Readonly ) => void; - clearChangedMessages: (conversationId: string, baton: unknown) => unknown; clearInvitedUuidsForNewlyCreatedGroup: () => void; closeContactSpoofingReview: () => void; - setLoadCountdownStart: ( - conversationId: string, - loadCountdownStart?: number - ) => unknown; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown; reviewGroupMemberNameCollision: (groupConversationId: string) => void; reviewMessageRequestNameCollision: ( @@ -174,7 +157,7 @@ export type PropsActionsType = { clearSelectedMessage: () => unknown; unblurAvatar: () => void; updateSharedGroups: () => unknown; -} & Omit & +} & MessageActionsType & SafetyNumberActionsType & UnsupportedMessageActionsType & ChatSessionRefreshedNotificationActionsType; @@ -183,50 +166,22 @@ export type PropsType = PropsDataType & PropsHousekeepingType & PropsActionsType; -type OnScrollParamsType = { - scrollTop: number; - clientHeight: number; - scrollHeight: number; - - clientWidth: number; - scrollWidth?: number; - scrollLeft?: number; - scrollToColumn?: number; - _hasScrolledToColumnTarget?: boolean; - scrollToRow?: number; - _hasScrolledToRowTarget?: boolean; -}; - -type VisibleRowType = { - id: string; - offsetTop: number; - row: number; -}; - type StateType = { - atBottom: boolean; - atTop: boolean; - hasRecentlyScrolled: boolean; - oneTimeScrollRow?: number; - visibleRows?: { - newestFullyVisible?: VisibleRowType; - oldestPartiallyVisibleMessageId?: string; - oldestFullyVisible?: VisibleRowType; - }; - - widthBreakpoint: WidthBreakpoint; - - prevPropScrollToIndex?: number; - prevPropScrollToIndexCounter?: number; - propScrollToIndex?: number; - - shouldShowScrollDownButton: boolean; - areUnreadBelowCurrentPosition: boolean; - hasDismissedDirectContactSpoofingWarning: boolean; + hasRecentlyScrolled: boolean; lastMeasuredWarningHeight: number; + newestFullyVisibleMessageId?: string; + nowThatUpdatesEveryMinute: number; + oldestPartiallyVisibleMessageId?: string; + widthBreakpoint: WidthBreakpoint; }; +type SnapshotType = + | null + | { scrollToIndex: number } + | { scrollTop: number } + | { scrollBottom: number }; + const getActions = createSelector( // It is expensive to pick so many properties out of the `props` object so we // use `createSelector` to memoize them by the last seen `props` object. @@ -235,10 +190,8 @@ const getActions = createSelector( (props: PropsType): PropsActionsType => { const unsafe = pick(props, [ 'acknowledgeGroupMemberNameCollisions', - 'clearChangedMessages', 'clearInvitedUuidsForNewlyCreatedGroup', 'closeContactSpoofingReview', - 'setLoadCountdownStart', 'setIsNearBottom', 'reviewGroupMemberNameCollision', 'reviewMessageRequestNameCollision', @@ -296,580 +249,56 @@ const getActions = createSelector( } ); -export class Timeline extends React.PureComponent { - private cellSizeCache = new RowHeightCache(ESTIMATED_ROW_HEIGHT); - - private mostRecentWidth = 0; - - private mostRecentHeight = 0; - - private offsetFromBottom: number | undefined = 0; - - private resizeFlag = false; - +export class Timeline extends React.Component< + PropsType, + StateType, + SnapshotType +> { private readonly containerRef = React.createRef(); + private readonly messagesRef = React.createRef(); + private intersectionObserver?: IntersectionObserver; + private messagesResizeObserver?: ResizeObserver; - private readonly listRef = React.createRef(); - - private loadCountdownTimeout: NodeJS.Timeout | null = null; + // This is a best guess. It will likely be overridden when the timeline is measured. + private maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT); private hasRecentlyScrolledTimeout?: NodeJS.Timeout; - private delayedPeekTimeout?: NodeJS.Timeout; + private nowThatUpdatesEveryMinuteInterval?: NodeJS.Timeout; - private containerRefMerger = createRefMerger(); + override state: StateType = { + hasRecentlyScrolled: true, + hasDismissedDirectContactSpoofingWarning: false, + nowThatUpdatesEveryMinute: Date.now(), - constructor(props: PropsType) { - super(props); - - const { scrollToIndex, isIncomingMessageRequest } = this.props; - const oneTimeScrollRow = isIncomingMessageRequest - ? undefined - : getLastSeenIndicatorRow(props); - - // We only stick to the bottom if this is not an incoming message request. - const atBottom = !isIncomingMessageRequest; - - this.state = { - atBottom, - atTop: false, - hasRecentlyScrolled: true, - oneTimeScrollRow, - propScrollToIndex: scrollToIndex, - prevPropScrollToIndex: scrollToIndex, - shouldShowScrollDownButton: false, - areUnreadBelowCurrentPosition: false, - hasDismissedDirectContactSpoofingWarning: false, - lastMeasuredWarningHeight: 0, - // This may be swiftly overridden. - widthBreakpoint: WidthBreakpoint.Wide, - }; - } - - public static getDerivedStateFromProps( - props: PropsType, - state: StateType - ): StateType { - if ( - isNumber(props.scrollToIndex) && - (props.scrollToIndex !== state.prevPropScrollToIndex || - props.scrollToIndexCounter !== state.prevPropScrollToIndexCounter) - ) { - return { - ...state, - propScrollToIndex: props.scrollToIndex, - prevPropScrollToIndex: props.scrollToIndex, - prevPropScrollToIndexCounter: props.scrollToIndexCounter, - }; - } - - return state; - } - - private getList = (): List | null => { - if (!this.listRef) { - return null; - } - - const { current } = this.listRef; - - return current; + // These may be swiftly overridden. + lastMeasuredWarningHeight: 0, + widthBreakpoint: WidthBreakpoint.Wide, }; - private getGrid = (): Grid | undefined => { - const list = this.getList(); - if (!list) { - return; - } + private onScroll = (): void => { + const { id, setIsNearBottom } = this.props; - return list.Grid; - }; + setIsNearBottom(id, this.isAtBottom()); - private getScrollContainer = (): HTMLDivElement | undefined => { - // We're using an internal variable (_scrollingContainer)) here, - // so cannot rely on the public type. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const grid: any = this.getGrid(); - if (!grid) { - return; - } - - return grid._scrollingContainer as HTMLDivElement; - }; - - private recomputeRowHeights = (row?: number): void => { - const list = this.getList(); - if (!list) { - return; - } - - list.recomputeRowHeights(row); - }; - - private onHeightOnlyChange = (): void => { - const grid = this.getGrid(); - const scrollContainer = this.getScrollContainer(); - if (!grid || !scrollContainer) { - return; - } - - if (!isNumber(this.offsetFromBottom)) { - return; - } - - const { clientHeight, scrollHeight, scrollTop } = scrollContainer; - const newOffsetFromBottom = Math.max( - 0, - scrollHeight - clientHeight - scrollTop + this.setState(oldState => + // `onScroll` is called frequently, so it's performance-sensitive. We try our best + // to return `null` from this updater because [that won't cause a re-render][0]. + // + // [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactUpdateQueue.js#L401-L404 + oldState.hasRecentlyScrolled ? null : { hasRecentlyScrolled: true } ); - const delta = newOffsetFromBottom - this.offsetFromBottom; - - // TODO: DESKTOP-687 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (grid as any).scrollToPosition({ - scrollTop: scrollContainer.scrollTop + delta, - }); - }; - - private resize = (row?: number): void => { - this.offsetFromBottom = undefined; - this.resizeFlag = false; - if (isNumber(row) && row > 0) { - this.cellSizeCache.clearPlus(row); - } else { - this.cellSizeCache.clearAll(); - } - - this.recomputeRowHeights(row || 0); - }; - - private resizeHeroRow = (): void => { - this.resize(0); - }; - - private resizeMessage = (messageId: string): void => { - const { items } = this.props; - - if (!items || !items.length) { - return; - } - - const index = items.findIndex(item => item === messageId); - if (index < 0) { - return; - } - - const row = fromItemIndexToRow(index, this.props); - this.resize(row); - }; - - private onScroll = (data: OnScrollParamsType): void => { - // Ignore scroll events generated as react-virtualized recursively scrolls and - // re-measures to get us where we want to go. - if ( - isNumber(data.scrollToRow) && - data.scrollToRow >= 0 && - !data._hasScrolledToRowTarget - ) { - return; - } - - // Sometimes react-virtualized ends up with some incorrect math - we've scrolled below - // what should be possible. In this case, we leave everything the same and ask - // react-virtualized to try again. Without this, we'll set atBottom to true and - // pop the user back down to the bottom. - const { clientHeight, scrollHeight, scrollTop } = data; - if (scrollTop + clientHeight > scrollHeight) { - return; - } - - this.setState({ hasRecentlyScrolled: true }); clearTimeoutIfNecessary(this.hasRecentlyScrolledTimeout); this.hasRecentlyScrolledTimeout = setTimeout(() => { this.setState({ hasRecentlyScrolled: false }); }, 3000); - - this.updateScrollMetrics(data); - this.updateWithVisibleRows(); }; - private onRowsRendered = (): void => { - // React Virtualized doesn't respect `scrollToIndex` in some cases, likely - // because it hasn't rendered that row yet. - const { oneTimeScrollRow } = this.state; - if (isNumber(oneTimeScrollRow)) { - this.getList()?.scrollToRow(oneTimeScrollRow); - } - }; - - private updateScrollMetrics = debounce( - (data: OnScrollParamsType) => { - const { clientHeight, clientWidth, scrollHeight, scrollTop } = data; - - if (clientHeight <= 0 || scrollHeight <= 0) { - return; - } - - const { - haveNewest, - haveOldest, - id, - isIncomingMessageRequest, - setIsNearBottom, - setLoadCountdownStart, - } = this.props; - - if ( - this.mostRecentHeight && - clientHeight !== this.mostRecentHeight && - this.mostRecentWidth && - clientWidth === this.mostRecentWidth - ) { - this.onHeightOnlyChange(); - } - - // If we've scrolled, we want to reset these - const oneTimeScrollRow = undefined; - const propScrollToIndex = undefined; - - this.offsetFromBottom = Math.max( - 0, - scrollHeight - clientHeight - scrollTop - ); - - // If there's an active message request, we won't stick to the bottom of the - // conversation as new messages come in. - const atBottom = isIncomingMessageRequest - ? false - : haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD; - - const isNearBottom = - haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD; - const atTop = scrollTop <= AT_TOP_THRESHOLD; - const loadCountdownStart = atTop && !haveOldest ? Date.now() : undefined; - - clearTimeoutIfNecessary(this.loadCountdownTimeout); - this.loadCountdownTimeout = null; - if (isNumber(loadCountdownStart)) { - this.loadCountdownTimeout = setTimeout( - this.loadOlderMessages, - LOAD_COUNTDOWN - ); - } - - // Variable collision - // eslint-disable-next-line react/destructuring-assignment - if (loadCountdownStart !== this.props.loadCountdownStart) { - setLoadCountdownStart(id, loadCountdownStart); - } - - // Variable collision - // eslint-disable-next-line react/destructuring-assignment - if (isNearBottom !== this.props.isNearBottom) { - setIsNearBottom(id, isNearBottom); - } - - this.setState({ - atBottom, - atTop, - oneTimeScrollRow, - propScrollToIndex, - }); - }, - 50, - { maxWait: 50 } - ); - - private updateVisibleRows = (): void => { - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - if (scrollContainer.clientHeight === 0) { - return; - } - - const innerScrollContainer = scrollContainer.children[0]; - if (!innerScrollContainer) { - return; - } - - let newestFullyVisible: undefined | VisibleRowType; - let oldestPartiallyVisibleMessageId: undefined | string; - let oldestFullyVisible: undefined | VisibleRowType; - - const { children } = innerScrollContainer; - const visibleTop = scrollContainer.scrollTop; - const visibleBottom = visibleTop + scrollContainer.clientHeight; - - for (let i = children.length - 1; i >= 0; i -= 1) { - const child = children[i] as HTMLDivElement; - const { id, offsetTop, offsetHeight } = child; - - if (!id) { - continue; - } - - const bottom = offsetTop + offsetHeight; - - if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) { - const row = parseInt(child.getAttribute('data-row') || '-1', 10); - newestFullyVisible = { offsetTop, row, id }; - - break; - } - } - - const max = children.length; - for (let i = 0; i < max; i += 1) { - const child = children[i] as HTMLDivElement; - const { id, offsetTop, offsetHeight } = child; - - if (!id) { - continue; - } - - const bottom = offsetTop + offsetHeight; - - if (bottom >= visibleTop && !oldestPartiallyVisibleMessageId) { - oldestPartiallyVisibleMessageId = id; - } - - if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) { - oldestFullyVisible = { - offsetTop, - row: parseInt(child.getAttribute('data-row') || '-1', 10), - id, - }; - break; - } - } - - this.setState(oldState => { - const visibleRows = { - newestFullyVisible, - oldestPartiallyVisibleMessageId, - oldestFullyVisible, - }; - - // This avoids a render loop. - return isEqual(oldState.visibleRows, visibleRows) - ? null - : { visibleRows }; - }); - }; - - private updateWithVisibleRows = debounce( - () => { - const { - unreadCount, - haveNewest, - haveOldest, - isLoadingMessages, - items, - loadNewerMessages, - markMessageRead, - } = this.props; - - if (!items || items.length < 1) { - return; - } - - this.updateVisibleRows(); - const { visibleRows } = this.state; - if (!visibleRows) { - return; - } - - const { newestFullyVisible, oldestFullyVisible } = visibleRows; - if (!newestFullyVisible) { - return; - } - - markMessageRead(newestFullyVisible.id); - - 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. - if ( - !isLoadingMessages && - !haveNewest && - newestFullyVisible.row > newestRow - LOAD_MORE_THRESHOLD - ) { - const lastId = items[items.length - 1]; - loadNewerMessages(lastId); - } - - // Loading older messages is more destructive, as they requires a recalculation of - // all locations of things below. So we need to be careful with these loads. - // Generally we hid this behind a countdown spinner at the top of the window, but - // this is a special-case for the situation where the window is so large and that - // all the messages are visible. - const oldestVisible = Boolean( - oldestFullyVisible && oldestRow === oldestFullyVisible.row - ); - const newestVisible = newestRow === newestFullyVisible.row; - if (oldestVisible && newestVisible && !haveOldest) { - this.loadOlderMessages(); - } - - const lastIndex = items.length - 1; - const lastItemRow = fromItemIndexToRow(lastIndex, this.props); - const areUnreadBelowCurrentPosition = Boolean( - isNumber(unreadCount) && - unreadCount > 0 && - (!haveNewest || newestFullyVisible.row < lastItemRow) - ); - - const shouldShowScrollDownButton = Boolean( - !haveNewest || - areUnreadBelowCurrentPosition || - newestFullyVisible.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD - ); - - this.setState({ - shouldShowScrollDownButton, - areUnreadBelowCurrentPosition, - }); - }, - 500, - { maxWait: 500 } - ); - - private loadOlderMessages = (): void => { - const { haveOldest, isLoadingMessages, items, loadOlderMessages } = - this.props; - - clearTimeoutIfNecessary(this.loadCountdownTimeout); - this.loadCountdownTimeout = null; - - if (isLoadingMessages || haveOldest || !items || items.length < 1) { - return; - } - - const oldestId = items[0]; - loadOlderMessages(oldestId); - }; - - private rowRenderer = ({ - index: rowIndex, - key, - parent, - style, - }: Readonly): JSX.Element => { - const { - id, - i18n, - haveOldest, - items, - renderItem, - renderHeroRow, - renderLastSeenIndicator, - renderTypingBubble, - unblurAvatar, - updateSharedGroups, - } = this.props; - const { lastMeasuredWarningHeight, widthBreakpoint } = this.state; - - const commonProps = { - 'data-row': rowIndex, - style: { - ...style, - width: `${this.mostRecentWidth}px`, - }, - role: 'row', - }; - - let rowContents: ReactChild; - switch (rowIndex) { - case getHeroRow(this.props): - rowContents = ( -
- {Timeline.getWarning(this.props, this.state) ? ( -
- ) : null} - {renderHeroRow( - id, - this.resizeHeroRow, - unblurAvatar, - updateSharedGroups - )} -
- ); - 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); - - rowContents = ( -
- window.showDebugLog()} - > - {renderItem({ - actionProps, - containerElementRef: this.containerRef, - containerWidthBreakpoint: widthBreakpoint, - conversationId: id, - isOldestTimelineItem: haveOldest && itemIndex === 0, - messageId, - nextMessageId, - onHeightChange: this.resizeMessage, - previousMessageId, - })} - -
- ); - } - break; - } - - return ( - - {rowContents} - - ); - }; - - private getRowHeightFromCache = ({ - index, - }: Readonly<{ index: number }>): number => - this.cellSizeCache.getHeight(index); + private scrollToItemIndex(itemIndex: number): void { + this.messagesRef.current + ?.querySelector(`[data-item-index="${itemIndex}"]`) + ?.scrollIntoViewIfNeeded(); + } private scrollToBottom = (setFocus?: boolean): void => { const { selectMessage, id, items } = this.props; @@ -878,15 +307,12 @@ export class Timeline extends React.PureComponent { const lastIndex = items.length - 1; const lastMessageId = items[lastIndex]; selectMessage(lastMessageId, id); + } else { + const containerEl = this.containerRef.current; + if (containerEl) { + scrollToBottom(containerEl); + } } - - const oneTimeScrollRow = - items && items.length > 0 ? items.length - 1 : undefined; - - this.setState({ - propScrollToIndex: undefined, - oneTimeScrollRow, - }); }; private onClickScrollDownButton = (): void => { @@ -903,243 +329,308 @@ export class Timeline extends React.PureComponent { oldestUnreadIndex, selectMessage, } = this.props; + const { newestFullyVisibleMessageId } = this.state; + if (!items || items.length < 1) { return; } - const lastId = items[items.length - 1]; - const lastSeenIndicatorRow = getLastSeenIndicatorRow(this.props); - - const { visibleRows } = this.state; - if (!visibleRows) { - if (haveNewest) { - this.scrollToBottom(setFocus); - } else if (!isLoadingMessages) { - loadNewestMessages(lastId, setFocus); - } - + if (isLoadingMessages) { + this.scrollToBottom(setFocus); return; } - const { newestFullyVisible } = visibleRows; - if ( - newestFullyVisible && - isNumber(lastSeenIndicatorRow) && - newestFullyVisible.row < lastSeenIndicatorRow + newestFullyVisibleMessageId && + isNumber(oldestUnreadIndex) && + items.findIndex(item => item === newestFullyVisibleMessageId) < + oldestUnreadIndex ) { - if (setFocus && isNumber(oldestUnreadIndex)) { + if (setFocus) { const messageId = items[oldestUnreadIndex]; selectMessage(messageId, id); + } else { + this.scrollToItemIndex(oldestUnreadIndex); } - this.setState({ - oneTimeScrollRow: lastSeenIndicatorRow, - }); } else if (haveNewest) { this.scrollToBottom(setFocus); - } else if (!isLoadingMessages) { - loadNewestMessages(lastId, setFocus); + } else { + const lastId = last(items); + if (lastId) { + loadNewestMessages(lastId, setFocus); + } } }; + private isAtBottom(): boolean { + const containerEl = this.containerRef.current; + return Boolean( + containerEl && getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD + ); + } + + /** + * Re-initialize our `IntersectionObserver`. This replaces the old observer because (1) + * we don't want stale references to old props (2) we care about the order of the + * `IntersectionObserverEntry`s. + * + * This isn't the only way to solve this problem. For example, we could have a single + * observer for the lifetime of the component and update it intelligently. This approach + * seems to work, though! + */ + private updateIntersectionObserver(): void { + const containerEl = this.containerRef.current; + const messagesEl = this.messagesRef.current; + if (!containerEl || !messagesEl) { + return; + } + + const { + haveNewest, + haveOldest, + isLoadingMessages, + items, + loadNewerMessages, + loadOlderMessages, + } = this.props; + + this.intersectionObserver?.disconnect(); + + // Keys are message IDs. Values are intersection ratios. + const visibleMessages = new Map(); + + const intersectionObserverCallback: IntersectionObserverCallback = + entries => { + entries.forEach(entry => { + const { intersectionRatio, target } = entry; + const { + dataset: { messageId }, + } = target as HTMLElement; + if (!messageId) { + return; + } + visibleMessages.set(messageId, intersectionRatio); + }); + + let oldestPartiallyVisibleMessageId: undefined | string; + let newestFullyVisibleMessageId: undefined | string; + + for (const [messageId, intersectionRatio] of visibleMessages) { + if (intersectionRatio > 0 && !oldestPartiallyVisibleMessageId) { + oldestPartiallyVisibleMessageId = messageId; + } + if (intersectionRatio >= 1) { + newestFullyVisibleMessageId = messageId; + } + } + + this.setState({ + oldestPartiallyVisibleMessageId, + newestFullyVisibleMessageId, + }); + + if (newestFullyVisibleMessageId) { + this.markNewestFullyVisibleMessageRead(); + + if ( + !isLoadingMessages && + !haveNewest && + newestFullyVisibleMessageId === last(items) + ) { + loadNewerMessages(newestFullyVisibleMessageId); + } + } + + if ( + !isLoadingMessages && + !haveOldest && + oldestPartiallyVisibleMessageId && + oldestPartiallyVisibleMessageId === items[0] + ) { + loadOlderMessages(oldestPartiallyVisibleMessageId); + } + }; + + this.intersectionObserver = new IntersectionObserver( + intersectionObserverCallback, + { + root: containerEl, + threshold: [0, 1], + } + ); + + for (const child of messagesEl.children) { + if ((child as HTMLElement).dataset.messageId) { + this.intersectionObserver.observe(child); + } + } + } + + private markNewestFullyVisibleMessageRead = throttle( + (): void => { + const { markMessageRead } = this.props; + const { newestFullyVisibleMessageId } = this.state; + if (newestFullyVisibleMessageId) { + markMessageRead(newestFullyVisibleMessageId); + } + }, + 500, + { leading: false } + ); + public override componentDidMount(): void { - this.updateWithVisibleRows(); - window.registerForActive(this.updateWithVisibleRows); + const containerEl = this.containerRef.current; + const messagesEl = this.messagesRef.current; + strictAssert( + containerEl && messagesEl, + ' mounted without some refs' + ); + + // This observer is necessary to keep the scroll position locked to the bottom when + // messages change height without "telling" the timeline about it. This can happen + // if messages animate their height, if reactions are changed, etc. + // + // We do this synchronously (i.e., without react-measure) to avoid jitter. + this.messagesResizeObserver = new ResizeObserver(() => { + const { haveNewest } = this.props; + if (haveNewest && this.isAtBottom()) { + scrollToBottom(containerEl); + } + }); + this.messagesResizeObserver.observe(messagesEl); + + this.updateIntersectionObserver(); + + window.registerForActive(this.markNewestFullyVisibleMessageRead); this.delayedPeekTimeout = setTimeout(() => { const { id, peekGroupCallForTheFirstTime } = this.props; peekGroupCallForTheFirstTime(id); }, 500); + + this.nowThatUpdatesEveryMinuteInterval = setInterval(() => { + this.setState({ nowThatUpdatesEveryMinute: Date.now() }); + }, MINUTE); } public override componentWillUnmount(): void { - const { delayedPeekTimeout } = this; + const { + delayedPeekTimeout, + nowThatUpdatesEveryMinuteInterval: nowThatUpdatesEveryMinuteTimeout, + } = this; - window.unregisterForActive(this.updateWithVisibleRows); + window.unregisterForActive(this.markNewestFullyVisibleMessageRead); + + this.messagesResizeObserver?.disconnect(); + this.intersectionObserver?.disconnect(); clearTimeoutIfNecessary(delayedPeekTimeout); + clearTimeoutIfNecessary(nowThatUpdatesEveryMinuteTimeout); + } + + public override getSnapshotBeforeUpdate( + prevProps: Readonly + ): SnapshotType { + const containerEl = this.containerRef.current; + if (!containerEl) { + return null; + } + + const { + isLoadingMessages: wasLoadingMessages, + items: oldItems, + scrollToIndexCounter: oldScrollToIndexCounter, + typingContactId: oldTypingContactId, + } = prevProps; + const { + isIncomingMessageRequest, + isLoadingMessages, + items: newItems, + scrollToIndex, + scrollToIndexCounter: newScrollToIndexCounter, + typingContactId, + } = this.props; + + const isDoingInitialLoad = isLoadingMessages && newItems.length === 0; + const wasDoingInitialLoad = wasLoadingMessages && oldItems.length === 0; + const justFinishedInitialLoad = wasDoingInitialLoad && !isDoingInitialLoad; + + if (isDoingInitialLoad) { + return null; + } + + if ( + isNumber(scrollToIndex) && + (oldScrollToIndexCounter !== newScrollToIndexCounter || + justFinishedInitialLoad) + ) { + return { scrollToIndex }; + } + + if (justFinishedInitialLoad) { + return isIncomingMessageRequest ? { scrollTop: 0 } : { scrollBottom: 0 }; + } + + if ( + Boolean(typingContactId) !== Boolean(oldTypingContactId) && + this.isAtBottom() + ) { + return { scrollBottom: 0 }; + } + + // This method assumes that item operations happen one at a time. For example, items + // are not added and removed in the same render pass. + if (oldItems.length === newItems.length) { + return null; + } + + let scrollAnchor: 'top' | 'bottom'; + if (this.isAtBottom()) { + const justLoadedAPage = wasLoadingMessages && !isLoadingMessages; + scrollAnchor = justLoadedAPage ? 'top' : 'bottom'; + } else { + scrollAnchor = last(oldItems) !== last(newItems) ? 'top' : 'bottom'; + } + + return scrollAnchor === 'top' + ? { scrollTop: containerEl.scrollTop } + : { scrollBottom: getScrollBottom(containerEl) }; } public override componentDidUpdate( prevProps: Readonly, - prevState: Readonly + _prevState: Readonly, + snapshot: Readonly ): void { - const { - clearChangedMessages, - haveOldest, - id, - isIncomingMessageRequest, - items, - messageHeightChangeIndex, - messageHeightChangeBaton, - oldestUnreadIndex, - resetCounter, - scrollToIndex, - typingContactId, - } = this.props; + const { items: oldItems } = prevProps; + const { discardMessages, id, items: newItems } = this.props; - // We recompute the hero row's height if: - // - // 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)); - const haveWarning = Boolean(Timeline.getWarning(this.props, this.state)); - const shouldRecomputeRowHeights = - (!hadOldest && haveOldest) || hadWarning !== haveWarning; - if (shouldRecomputeRowHeights) { - this.resizeHeroRow(); - } - - // There are a number of situations which can necessitate that we forget about row - // heights previously calculated. We reset the minimum number of rows to minimize - // unexpected changes to the scroll position. Those changes happen because - // react-virtualized doesn't know what to expect (variable row heights) when it - // renders, so it does have a fixed row it's attempting to scroll to, and you ask it - // to render a given point it space, it will do pretty random things. - - if ( - !prevProps.items || - prevProps.items.length === 0 || - resetCounter !== prevProps.resetCounter - ) { - if (prevProps.items && prevProps.items.length > 0) { - this.resize(); - } - - // We want to come in at the top of the conversation if it's a message request - const oneTimeScrollRow = isIncomingMessageRequest - ? undefined - : getLastSeenIndicatorRow(this.props); - const atBottom = !isIncomingMessageRequest; - - // TODO: DESKTOP-688 - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - oneTimeScrollRow, - atBottom, - propScrollToIndex: scrollToIndex, - prevPropScrollToIndex: scrollToIndex, - }); - - return; - } - - let resizeStartRow: number | undefined; - - if (isNumber(messageHeightChangeIndex)) { - resizeStartRow = fromItemIndexToRow(messageHeightChangeIndex, this.props); - clearChangedMessages(id, messageHeightChangeBaton); - } - - if ( - items !== prevProps.items || - oldestUnreadIndex !== prevProps.oldestUnreadIndex || - Boolean(typingContactId) !== Boolean(prevProps.typingContactId) - ) { - const { atTop } = this.state; - - // This clause handles prepended messages when user scrolls up. New - // messages are added to `items`, but we want to keep the scroll position - // at the first previously visible message even though the row numbers - // have now changed. - if (atTop) { - const oldFirstIndex = 0; - const oldFirstId = prevProps.items[oldFirstIndex]; - - const newFirstIndex = items.findIndex(item => item === oldFirstId); - if (newFirstIndex < 0) { - this.resize(); - - return; - } - - 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(); - // TODO: DESKTOP-688 - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ oneTimeScrollRow: newRow }); - - return; - } - } - - // 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 = getEphemeralRows(this.props); - const prevRowsIterator = getEphemeralRows(prevProps); - - let firstChangedRow = 0; - // eslint-disable-next-line no-constant-condition - while (true) { - const row = rowsIterator.next(); - if (row.done) { - break; - } - - const prevRow = prevRowsIterator.next(); - if (prevRow.done) { - break; - } - - if (prevRow.value !== row.value) { - break; - } - - firstChangedRow += 1; - } - - // If either: - // - // - Row count has changed after props update - // - There are some different rows (and the loop above was interrupted) - // - // Recompute heights of all rows starting from the first changed row or - // the last row in the previous row list. - if (!rowsIterator.next().done || !prevRowsIterator.next().done) { - resizeStartRow = Math.min( - resizeStartRow ?? firstChangedRow, - firstChangedRow - ); + const containerEl = this.containerRef.current; + if (containerEl && snapshot) { + if ('scrollToIndex' in snapshot) { + this.scrollToItemIndex(snapshot.scrollToIndex); + } else if ('scrollTop' in snapshot) { + containerEl.scrollTop = snapshot.scrollTop; + } else { + setScrollBottom(containerEl, snapshot.scrollBottom); } } - if (this.resizeFlag) { - this.resize(); + if (oldItems.length !== newItems.length) { + this.updateIntersectionObserver(); - return; + // This condition is somewhat arbitrary. + const shouldDiscardOlderMessages: boolean = + this.isAtBottom() && newItems.length >= this.maxVisibleRows * 1.5; + if (shouldDiscardOlderMessages) { + discardMessages({ + conversationId: id, + numberToKeepAtBottom: this.maxVisibleRows, + }); + } } - - if (resizeStartRow !== undefined) { - this.resize(resizeStartRow); - } - - this.updateWithVisibleRows(); } - private getScrollTarget = (): number | undefined => { - const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state; - - const rowCount = getRowCount(this.props); - const targetMessageRow = isNumber(propScrollToIndex) - ? fromItemIndexToRow(propScrollToIndex, this.props) - : undefined; - const scrollToBottom = atBottom ? rowCount - 1 : undefined; - - if (isNumber(targetMessageRow)) { - return targetMessageRow; - } - - if (isNumber(oneTimeScrollRow)) { - return oneTimeScrollRow; - } - - return scrollToBottom; - }; - private handleBlur = (event: React.FocusEvent): void => { const { clearSelectedMessage } = this.props; @@ -1221,20 +712,17 @@ export class Timeline extends React.PureComponent { } if (commandOrCtrl && event.key === 'ArrowUp') { - this.setState({ oneTimeScrollRow: 0 }); - - const firstMessageId = items[0]; - selectMessage(firstMessageId, id); - - event.preventDefault(); - event.stopPropagation(); - + const firstMessageId = first(items); + if (firstMessageId) { + selectMessage(firstMessageId, id); + event.preventDefault(); + event.stopPropagation(); + } return; } if (commandOrCtrl && event.key === 'ArrowDown') { this.scrollDown(true); - event.preventDefault(); event.stopPropagation(); } @@ -1249,49 +737,87 @@ export class Timeline extends React.PureComponent { contactSpoofingReview, getPreferredBadge, getTimestampForMessage, + haveNewest, haveOldest, i18n, id, invitedContactsForNewlyCreatedGroup, + isConversationSelected, isGroupV1AndDisabled, isLoadingMessages, items, + oldestUnreadIndex, onBlock, onBlockAndReportSpam, onDelete, onUnblock, - showContactModal, removeMember, + renderHeroRow, + renderItem, + renderLastSeenIndicator, + renderTypingBubble, reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, + showContactModal, theme, + typingContactId, + unblurAvatar, + unreadCount, + updateSharedGroups, } = this.props; const { - shouldShowScrollDownButton, - areUnreadBelowCurrentPosition, hasRecentlyScrolled, lastMeasuredWarningHeight, - visibleRows, + newestFullyVisibleMessageId, + nowThatUpdatesEveryMinute, + oldestPartiallyVisibleMessageId, widthBreakpoint, } = this.state; - const rowCount = getRowCount(this.props); - const scrollToIndex = this.getScrollTarget(); - - if (!items || rowCount === 0) { + // As a performance optimization, we don't need to render anything if this + // conversation isn't the active one. + if (!isConversationSelected) { return null; } + const areThereAnyMessages = items.length > 0; + const areAnyMessagesUnread = Boolean(unreadCount); + const areAnyMessagesBelowCurrentPosition = + !haveNewest || + Boolean( + newestFullyVisibleMessageId && + newestFullyVisibleMessageId !== last(items) + ); + const areSomeMessagesBelowCurrentPosition = + !haveNewest || + (newestFullyVisibleMessageId && + !items + .slice(-SCROLL_DOWN_BUTTON_THRESHOLD) + .includes(newestFullyVisibleMessageId)); + + const areUnreadBelowCurrentPosition = Boolean( + areThereAnyMessages && + areAnyMessagesUnread && + areAnyMessagesBelowCurrentPosition + ); + const shouldShowScrollDownButton = Boolean( + areThereAnyMessages && + (areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition) + ); + + const actionProps = getActions(this.props); + let floatingHeader: ReactNode; - const oldestPartiallyVisibleMessageId = - visibleRows?.oldestPartiallyVisibleMessageId; // It's possible that a message was removed from `items` but we still have its ID in // state. `getTimestampForMessage` might return undefined in that case. const oldestPartiallyVisibleMessageTimestamp = oldestPartiallyVisibleMessageId ? getTimestampForMessage(oldestPartiallyVisibleMessageId) : undefined; - if (oldestPartiallyVisibleMessageTimestamp) { + if ( + oldestPartiallyVisibleMessageId && + oldestPartiallyVisibleMessageTimestamp + ) { floatingHeader = ( { ); } - const autoSizer = ( - - {({ height, width }) => { - if (this.mostRecentWidth && this.mostRecentWidth !== width) { - this.resizeFlag = true; + const messageNodes: Array = []; + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const previousMessageId: undefined | string = items[itemIndex - 1]; + const nextMessageId: undefined | string = items[itemIndex + 1]; + const messageId = items[itemIndex]; - setTimeout(this.resize, 0); - } else if ( - this.mostRecentHeight && - this.mostRecentHeight !== height - ) { - setTimeout(this.onHeightOnlyChange, 0); - } + if (!messageId) { + assert( + false, + ' iterated through items and got an empty message ID' + ); + continue; + } - this.mostRecentWidth = width; - this.mostRecentHeight = height; + if (oldestUnreadIndex === itemIndex) { + messageNodes.push( + {renderLastSeenIndicator(id)} + ); + } - return ( - - ); - }} - - ); + messageNodes.push( +
+ + {renderItem({ + actionProps, + containerElementRef: this.containerRef, + containerWidthBreakpoint: widthBreakpoint, + conversationId: id, + isOldestTimelineItem: haveOldest && itemIndex === 0, + messageId, + nextMessageId, + now: nowThatUpdatesEveryMinute, + previousMessageId, + })} + +
+ ); + } const warning = Timeline.getWarning(this.props, this.state); let timelineWarning: ReactNode; @@ -1502,9 +1018,20 @@ export class Timeline extends React.PureComponent { { + const { isNearBottom } = this.props; + + strictAssert(bounds, 'We should be measuring the bounds'); + this.setState({ - widthBreakpoint: getWidthBreakpoint(bounds?.width || 0), + widthBreakpoint: getWidthBreakpoint(bounds.width), }); + + this.maxVisibleRows = Math.ceil(bounds.height / MIN_ROW_HEIGHT); + + const containerEl = this.containerRef.current; + if (containerEl && isNearBottom) { + scrollToBottom(containerEl); + } }} > {({ measureRef }) => ( @@ -1518,13 +1045,35 @@ export class Timeline extends React.PureComponent { tabIndex={-1} onBlur={this.handleBlur} onKeyDown={this.handleKeyDown} - ref={this.containerRefMerger(measureRef)} + ref={measureRef} > {timelineWarning} {floatingHeader} - {autoSizer} +
+
+ {haveOldest && ( + <> + {Timeline.getWarning(this.props, this.state) && ( +
+ )} + {renderHeroRow(id, unblurAvatar, updateSharedGroups)} + + )} + + {messageNodes} + + {typingContactId && renderTypingBubble(id)} +
+
{shouldShowScrollDownButton ? ( { } } } + +function showDebugLog() { + window.showDebugLog(); +} diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index a66b6ed6144a..a2e5a3cccc91 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -86,16 +86,15 @@ const getDefaultProps = () => ({ showExpiredOutgoingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), - onHeightChange: action('onHeightChange'), openLink: action('openLink'), scrollToQuotedMessage: action('scrollToQuotedMessage'), downloadNewVersion: action('downloadNewVersion'), showIdentity: action('showIdentity'), - messageSizeChanged: action('messageSizeChanged'), startCallingLobby: action('startCallingLobby'), returnToActiveCall: action('returnToActiveCall'), previousItem: undefined, nextItem: undefined, + now: Date.now(), renderContact, renderUniversalTimerNotification, diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index e894053fdbf7..f8f488bacc1b 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -54,7 +54,6 @@ import type { SmartContactRendererType } from '../../groupChange'; import { ResetSessionNotification } from './ResetSessionNotification'; import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification'; import { ProfileChangeNotification } from './ProfileChangeNotification'; -import * as log from '../../logging/log'; import type { FullJSXType } from '../Intl'; type CallHistoryType = { @@ -156,6 +155,7 @@ type PropsLocalType = { theme: ThemeType; previousItem: undefined | TimelineItemType; nextItem: undefined | TimelineItemType; + now: number; }; type PropsActionsType = MessageActionsType & @@ -188,8 +188,8 @@ export class TimelineItem extends React.PureComponent { item, i18n, theme, - messageSizeChanged, nextItem, + now, previousItem, renderContact, renderUniversalTimerNotification, @@ -199,8 +199,12 @@ export class TimelineItem extends React.PureComponent { } = this.props; if (!item) { - log.warn(`TimelineItem: item ${id} provided was falsey`); - + // This can happen under normal conditions. + // + // `` and `` are connected to Redux separately. If a + // timeline item is removed from Redux, `` might re-render before + // `` does, which means we'll try to render nothing. This should resolve + // itself quickly, as soon as `` re-renders. return null; } @@ -229,9 +233,8 @@ export class TimelineItem extends React.PureComponent { ; isLoadingMessages: boolean; isNearBottom?: boolean; - loadCountdownStart?: number; messageIds: Array; metrics: MessageMetricsType; - resetCounter: number; scrollToMessageId?: string; scrollToMessageCounter: number; }; @@ -397,6 +392,7 @@ const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR'; const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED'; const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION = 'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION'; +const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES'; const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS'; const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE'; @@ -480,6 +476,10 @@ type CustomColorRemovedActionType = { colorId: string; }; }; +type DiscardMessagesActionType = { + type: typeof DISCARD_MESSAGES; + payload: { conversationId: string; numberToKeepAtBottom: number }; +}; type SetPreJoinConversationActionType = { type: 'SET_PRE_JOIN_CONVERSATION'; payload: { @@ -566,13 +566,6 @@ export type MessageExpandedActionType = { }; }; -type MessageSizeChangedActionType = { - type: 'MESSAGE_SIZE_CHANGED'; - payload: { - id: string; - conversationId: string; - }; -}; export type MessagesAddedActionType = { type: 'MESSAGES_ADDED'; payload: { @@ -615,13 +608,6 @@ export type SetMessagesLoadingActionType = { isLoadingMessages: boolean; }; }; -export type SetLoadCountdownStartActionType = { - type: 'SET_LOAD_COUNTDOWN_START'; - payload: { - conversationId: string; - loadCountdownStart?: number; - }; -}; export type SetIsNearBottomActionType = { type: 'SET_NEAR_BOTTOM'; payload: { @@ -644,13 +630,6 @@ export type ScrollToMessageActionType = { messageId: string; }; }; -export type ClearChangedMessagesActionType = { - type: 'CLEAR_CHANGED_MESSAGES'; - payload: { - conversationId: string; - baton: unknown; - }; -}; export type ClearSelectedMessageActionType = { type: 'CLEAR_SELECTED_MESSAGE'; payload: null; @@ -759,7 +738,6 @@ export type ConversationActionType = | CancelVerificationDataByConversationActionType | CantAddContactToGroupActionType | ClearCancelledVerificationActionType - | ClearChangedMessagesActionType | ClearVerificationDataByConversationActionType | ClearGroupCreationErrorActionType | ClearInvitedUuidsForNewlyCreatedGroupActionType @@ -783,11 +761,11 @@ export type ConversationActionType = | CreateGroupPendingActionType | CreateGroupRejectedActionType | CustomColorRemovedActionType + | DiscardMessagesActionType | MessageChangedActionType | MessageDeletedActionType | MessageExpandedActionType | MessageSelectedActionType - | MessageSizeChangedActionType | MessagesAddedActionType | MessagesResetActionType | RemoveAllConversationsActionType @@ -805,7 +783,6 @@ export type ConversationActionType = | SetConversationHeaderTitleActionType | SetIsFetchingUsernameActionType | SetIsNearBottomActionType - | SetLoadCountdownStartActionType | SetMessagesLoadingActionType | SetPreJoinConversationActionType | SetRecentMediaItemsActionType @@ -826,7 +803,6 @@ export const actions = { cancelConversationVerification, cantAddContactToGroup, clearCancelledConversationVerification, - clearChangedMessages, clearGroupCreationError, clearInvitedUuidsForNewlyCreatedGroup, clearSelectedMessage, @@ -847,11 +823,11 @@ export const actions = { conversationUnloaded, createGroup, deleteAvatarFromDisk, + discardMessages, doubleCheckMissingQuoteReference, messageChanged, messageDeleted, messageExpanded, - messageSizeChanged, messagesAdded, messagesReset, myProfileChanged, @@ -875,7 +851,6 @@ export const actions = { setComposeGroupName, setComposeSearchTerm, setIsNearBottom, - setLoadCountdownStart, setMessagesLoading, setPreJoinConversation, setRecentMediaItems, @@ -970,6 +945,12 @@ function deleteAvatarFromDisk( }; } +function discardMessages( + payload: Readonly +): DiscardMessagesActionType { + return { type: DISCARD_MESSAGES, payload }; +} + function replaceAvatar( curr: AvatarDataType, prev?: AvatarDataType, @@ -1581,18 +1562,6 @@ function messageExpanded( }, }; } -function messageSizeChanged( - id: string, - conversationId: string -): MessageSizeChangedActionType { - return { - type: 'MESSAGE_SIZE_CHANGED', - payload: { - id, - conversationId, - }, - }; -} function messagesAdded({ conversationId, isActive, @@ -1694,18 +1663,6 @@ function setMessagesLoading( }, }; } -function setLoadCountdownStart( - conversationId: string, - loadCountdownStart?: number -): SetLoadCountdownStartActionType { - return { - type: 'SET_LOAD_COUNTDOWN_START', - payload: { - conversationId, - loadCountdownStart, - }, - }; -} function setIsNearBottom( conversationId: string, isNearBottom: boolean @@ -1743,18 +1700,6 @@ function setRecentMediaItems( payload: { id, recentMediaItems }, }; } -function clearChangedMessages( - conversationId: string, - baton: unknown -): ClearChangedMessagesActionType { - return { - type: 'CLEAR_CHANGED_MESSAGES', - payload: { - conversationId, - baton, - }, - }; -} function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType { return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' }; } @@ -2125,71 +2070,6 @@ export function getEmptyState(): ConversationsStateType { }; } -function hasMessageHeightChanged( - message: MessageAttributesType, - previous: MessageAttributesType -): boolean { - const messageAttachments = message.attachments || []; - const previousAttachments = previous.attachments || []; - - const errorStatusChanged = - (!message.errors && previous.errors) || - (message.errors && !previous.errors) || - (message.errors && - previous.errors && - message.errors.length !== previous.errors.length); - if (errorStatusChanged) { - return true; - } - - const groupUpdateChanged = message.group_update !== previous.group_update; - if (groupUpdateChanged) { - return true; - } - - const stickerPendingChanged = - message.sticker && - message.sticker.data && - previous.sticker && - previous.sticker.data && - !previous.sticker.data.blurHash && - previous.sticker.data.pending !== message.sticker.data.pending; - if (stickerPendingChanged) { - return true; - } - - const longMessageAttachmentLoaded = - previous.bodyPending && !message.bodyPending; - if (longMessageAttachmentLoaded) { - return true; - } - - const firstAttachmentNoLongerPending = - previousAttachments[0] && - previousAttachments[0].pending && - messageAttachments[0] && - !messageAttachments[0].pending; - if (firstAttachmentNoLongerPending) { - return true; - } - - const currentReactions = message.reactions || []; - const lastReactions = previous.reactions || []; - const reactionsChanged = - (currentReactions.length === 0) !== (lastReactions.length === 0); - if (reactionsChanged) { - return true; - } - - const isDeletedForEveryone = message.deletedForEveryone; - const wasDeletedForEveryone = previous.deletedForEveryone; - if (isDeletedForEveryone !== wasDeletedForEveryone) { - return true; - } - - return false; -} - export function updateConversationLookups( added: ConversationType | undefined, removed: ConversationType | undefined, @@ -2417,6 +2297,38 @@ export function reducer( return closeComposerModal(state, 'recommendedGroupSizeModalState' as const); } + if (action.type === DISCARD_MESSAGES) { + const { conversationId, numberToKeepAtBottom } = action.payload; + + const conversationMessages = getOwn( + state.messagesByConversation, + conversationId + ); + if (!conversationMessages) { + return state; + } + + const { messageIds: oldMessageIds } = conversationMessages; + if (oldMessageIds.length <= numberToKeepAtBottom) { + return state; + } + + const messageIdsToRemove = oldMessageIds.slice(0, -numberToKeepAtBottom); + const messageIdsToKeep = oldMessageIds.slice(-numberToKeepAtBottom); + + return { + ...state, + messagesLookup: omit(state.messagesLookup, messageIdsToRemove), + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: { + ...conversationMessages, + messageIds: messageIdsToKeep, + }, + }, + }; + } + if (action.type === 'SET_PRE_JOIN_CONVERSATION') { const { payload } = action; const { data } = payload; @@ -2645,15 +2557,6 @@ export function reducer( return state; } - // Check for changes which could affect height - that's why we need this - // heightChangeMessageIds field. It tells Timeline to recalculate all of its heights - const hasHeightChanged = hasMessageHeightChanged(data, existingMessage); - - const { heightChangeMessageIds } = existingConversation; - const updatedChanges = hasHeightChanged - ? uniq([...heightChangeMessageIds, id]) - : heightChangeMessageIds; - return { ...state, messagesLookup: { @@ -2663,13 +2566,6 @@ export function reducer( displayLimit: existingMessage.displayLimit, }, }, - messagesByConversation: { - ...state.messagesByConversation, - [conversationId]: { - ...existingConversation, - heightChangeMessageIds: updatedChanges, - }, - }, }; } if (action.type === 'MESSAGE_EXPANDED') { @@ -2691,31 +2587,6 @@ export function reducer( }, }; } - if (action.type === 'MESSAGE_SIZE_CHANGED') { - const { id, conversationId } = action.payload; - - const existingConversation = getOwn( - state.messagesByConversation, - conversationId - ); - if (!existingConversation) { - return state; - } - - return { - ...state, - messagesByConversation: { - ...state.messagesByConversation, - [conversationId]: { - ...existingConversation, - heightChangeMessageIds: uniq([ - ...existingConversation.heightChangeMessageIds, - id, - ]), - }, - }, - }; - } if (action.type === 'MESSAGES_RESET') { const { conversationId, @@ -2727,9 +2598,6 @@ export function reducer( const { messagesByConversation, messagesLookup } = state; const existingConversation = messagesByConversation[conversationId]; - const resetCounter = existingConversation - ? existingConversation.resetCounter + 1 - : 0; const lookup = fromPairs(messages.map(message => [message.id, message])); const sorted = orderBy( @@ -2780,8 +2648,6 @@ export function reducer( newest, oldest, }, - resetCounter, - heightChangeMessageIds: [], }, }, }; @@ -2803,34 +2669,11 @@ export function reducer( ...messagesByConversation, [conversationId]: { ...existingConversation, - loadCountdownStart: undefined, isLoadingMessages, }, }, }; } - if (action.type === 'SET_LOAD_COUNTDOWN_START') { - const { payload } = action; - const { conversationId, loadCountdownStart } = payload; - - const { messagesByConversation } = state; - const existingConversation = messagesByConversation[conversationId]; - - if (!existingConversation) { - return state; - } - - return { - ...state, - messagesByConversation: { - ...messagesByConversation, - [conversationId]: { - ...existingConversation, - loadCountdownStart, - }, - }, - }; - } if (action.type === 'SET_NEAR_BOTTOM') { const { payload } = action; const { conversationId, isNearBottom } = payload; @@ -2838,7 +2681,10 @@ export function reducer( const { messagesByConversation } = state; const existingConversation = messagesByConversation[conversationId]; - if (!existingConversation) { + if ( + !existingConversation || + existingConversation.isNearBottom === isNearBottom + ) { return state; } @@ -2921,10 +2767,6 @@ export function reducer( // Removing it from our caches const messageIds = without(existingConversation.messageIds, id); - const heightChangeMessageIds = without( - existingConversation.heightChangeMessageIds, - id - ); let metrics; if (messageIds.length === 0) { @@ -2946,7 +2788,6 @@ export function reducer( [conversationId]: { ...existingConversation, messageIds, - heightChangeMessageIds, metrics, }, }, @@ -3135,12 +2976,6 @@ export function reducer( totalUnread = (totalUnread || 0) + newUnread; } - const changedIds = intersection(newIds, existingConversation.messageIds); - const heightChangeMessageIds = uniq([ - ...changedIds, - ...existingConversation.heightChangeMessageIds, - ]); - return { ...state, messagesLookup: { @@ -3153,7 +2988,6 @@ export function reducer( ...existingConversation, isLoadingMessages: false, messageIds, - heightChangeMessageIds, scrollToMessageId: isJustSent ? last.id : undefined, metrics: { ...existingConversation.metrics, @@ -3172,30 +3006,6 @@ export function reducer( selectedMessage: undefined, }; } - if (action.type === 'CLEAR_CHANGED_MESSAGES') { - const { payload } = action; - const { conversationId, baton } = payload; - const existingConversation = state.messagesByConversation[conversationId]; - - if ( - !existingConversation || - existingConversation.heightChangeMessageIds !== baton - ) { - log.warn('CLEAR_CHANGED_MESSAGES used expired baton'); - return state; - } - - return { - ...state, - messagesByConversation: { - ...state.messagesByConversation, - [conversationId]: { - ...existingConversation, - heightChangeMessageIds: [], - }, - }, - }; - } if (action.type === 'CLEAR_UNREAD_METRICS') { const { payload } = action; const { conversationId } = payload; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 29fa86e5fbc1..909d0280b210 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import memoizee from 'memoizee'; -import { fromPairs, isNumber } from 'lodash'; +import { isNumber } from 'lodash'; import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; @@ -837,13 +837,10 @@ export function _conversationMessagesSelector( conversation: ConversationMessageType ): TimelinePropsType { const { - heightChangeMessageIds, isLoadingMessages, isNearBottom, - loadCountdownStart, messageIds, metrics, - resetCounter, scrollToMessageId, scrollToMessageCounter, } = conversation; @@ -860,14 +857,6 @@ export function _conversationMessagesSelector( const items = messageIds; - const messageHeightChangeLookup = - heightChangeMessageIds && heightChangeMessageIds.length - ? fromPairs(heightChangeMessageIds.map(id => [id, true])) - : null; - const messageHeightChangeIndex = messageHeightChangeLookup - ? messageIds.findIndex(id => messageHeightChangeLookup[id]) - : undefined; - const oldestUnreadIndex = oldestUnread ? messageIds.findIndex(id => id === oldestUnread.id) : undefined; @@ -880,19 +869,12 @@ export function _conversationMessagesSelector( haveNewest, haveOldest, isLoadingMessages, - loadCountdownStart, - items, isNearBottom, - messageHeightChangeBaton: heightChangeMessageIds, - messageHeightChangeIndex: - isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0 - ? messageHeightChangeIndex - : undefined, + items, oldestUnreadIndex: isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0 ? oldestUnreadIndex : undefined, - resetCounter, scrollToIndex: isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, scrollToIndexCounter: scrollToMessageCounter, @@ -927,8 +909,7 @@ export const getConversationMessagesSelector = createSelector( return { haveNewest: false, haveOldest: false, - isLoadingMessages: false, - resetCounter: 0, + isLoadingMessages: true, scrollToIndexCounter: 0, totalUnread: 0, items: [], diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index bda9f6960ec1..8de51d56ee4d 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; @@ -28,6 +28,7 @@ export type Props = { expirationLength?: number; expirationTimestamp?: number; id: string; + now: number; played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 04d0ee16b688..f35fee3ae004 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -5,7 +5,6 @@ import { isEmpty, mapValues, pick } from 'lodash'; import type { RefObject } from 'react'; import React from 'react'; import { connect } from 'react-redux'; -import memoizee from 'memoizee'; import { mapDispatchToProps } from '../actions'; import type { @@ -99,16 +98,6 @@ export type TimelinePropsType = ExternalProps & | 'updateSharedGroups' >; -const createBoundOnHeightChange = memoizee( - ( - onHeightChange: (messageId: string) => unknown, - messageId: string - ): (() => unknown) => { - return () => onHeightChange(messageId); - }, - { max: 500 } -); - function renderItem({ actionProps, containerElementRef, @@ -117,7 +106,7 @@ function renderItem({ isOldestTimelineItem, messageId, nextMessageId, - onHeightChange, + now, previousMessageId, }: { actionProps: TimelineActionsType; @@ -127,7 +116,7 @@ function renderItem({ isOldestTimelineItem: boolean; messageId: string; nextMessageId: undefined | string; - onHeightChange: (messageId: string) => unknown; + now: number; previousMessageId: undefined | string; }): JSX.Element { return ( @@ -140,7 +129,7 @@ function renderItem({ messageId={messageId} previousMessageId={previousMessageId} nextMessageId={nextMessageId} - onHeightChange={createBoundOnHeightChange(onHeightChange, messageId)} + now={now} renderEmojiPicker={renderEmojiPicker} renderReactionPicker={renderReactionPicker} renderAudioAttachment={renderAudioAttachment} @@ -154,14 +143,12 @@ function renderLastSeenIndicator(id: string): JSX.Element { function renderHeroRow( id: string, - onHeightChange: () => unknown, unblurAvatar: () => void, updateSharedGroups: () => unknown ): JSX.Element { return ( @@ -306,6 +293,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { 'typingContactId', 'isGroupV1AndDisabled', ]), + isConversationSelected: state.conversations.selectedConversationId === id, isIncomingMessageRequest: Boolean( conversation.messageRequestsEnabled && !conversation.acceptedMessageRequest diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index fafd6c0bd376..08c8beaaa093 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -27,6 +27,7 @@ type ExternalProps = { messageId: string; nextMessageId: undefined | string; previousMessageId: undefined | string; + now: number; }; function renderContact(conversationId: string): JSX.Element { @@ -45,6 +46,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { messageId, nextMessageId, previousMessageId, + now, } = props; const messageSelector = getMessageSelector(state); @@ -66,6 +68,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { item, previousItem, nextItem, + now, id: messageId, containerElementRef, conversationId, diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts deleted file mode 100644 index 2e71ccde7874..000000000000 --- a/ts/test-both/util/timelineUtil_test.ts +++ /dev/null @@ -1,299 +0,0 @@ -// 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/test-both/util/timestamp_test.ts b/ts/test-both/util/timestamp_test.ts index 9a4b88f9f81b..df56b2a6bcdd 100644 --- a/ts/test-both/util/timestamp_test.ts +++ b/ts/test-both/util/timestamp_test.ts @@ -187,44 +187,42 @@ describe('timestamp', () => { }); describe('formatTime', () => { - useFakeTimers(); - it('returns "Now" for times within the last minute, including unexpected times in the future', () => { [ - Date.now(), - moment().subtract(1, 'second'), - moment().subtract(59, 'seconds'), - moment().add(1, 'minute'), - moment().add(1, 'year'), + FAKE_NOW, + moment(FAKE_NOW).subtract(1, 'second'), + moment(FAKE_NOW).subtract(59, 'seconds'), + moment(FAKE_NOW).add(1, 'minute'), + moment(FAKE_NOW).add(1, 'year'), ].forEach(timestamp => { - assert.strictEqual(formatTime(i18n, timestamp), 'Now'); + assert.strictEqual(formatTime(i18n, timestamp, FAKE_NOW), 'Now'); }); }); it('returns "X minutes ago" for times in the last hour, but older than 1 minute', () => { assert.strictEqual( - formatTime(i18n, moment().subtract(1, 'minute')), + formatTime(i18n, moment(FAKE_NOW).subtract(1, 'minute'), FAKE_NOW), '1m' ); assert.strictEqual( - formatTime(i18n, moment().subtract(30, 'minutes')), + formatTime(i18n, moment(FAKE_NOW).subtract(30, 'minutes'), FAKE_NOW), '30m' ); assert.strictEqual( - formatTime(i18n, moment().subtract(59, 'minutes')), + formatTime(i18n, moment(FAKE_NOW).subtract(59, 'minutes'), FAKE_NOW), '59m' ); }); it('returns hh:mm-like times for times older than 1 hour from now', () => { const oneHourAgo = new Date('2020-01-23T03:56:00.000'); - assert.deepEqual(formatTime(i18n, oneHourAgo), '3:56 AM'); + assert.deepEqual(formatTime(i18n, oneHourAgo, FAKE_NOW), '3:56 AM'); const oneDayAgo = new Date('2020-01-22T04:56:00.000'); - assert.deepEqual(formatTime(i18n, oneDayAgo), '4:56 AM'); + assert.deepEqual(formatTime(i18n, oneDayAgo, FAKE_NOW), '4:56 AM'); const oneYearAgo = new Date('2019-01-23T04:56:00.000'); - assert.deepEqual(formatTime(i18n, oneYearAgo), '4:56 AM'); + assert.deepEqual(formatTime(i18n, oneYearAgo, FAKE_NOW), '4:56 AM'); }); }); diff --git a/ts/test-electron/scrollToBottom_test.ts b/ts/test-electron/scrollToBottom_test.ts deleted file mode 100644 index 711ed32174b7..000000000000 --- a/ts/test-electron/scrollToBottom_test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2021-2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; - -import { scrollToBottom } from '../util/scrollToBottom'; - -describe('scrollToBottom', () => { - let sandbox: HTMLDivElement; - - // This test seems to be flaky on Windows CI, sometimes timing out. That doesn't really - // make sense because the test is synchronous, but this quick-and-dirty fix is - // probably better than a full investigation. - before(function thisNeeded() { - if (process.platform === 'win32') { - this.skip(); - } - }); - - beforeEach(() => { - sandbox = document.createElement('div'); - document.body.appendChild(sandbox); - }); - - afterEach(() => { - sandbox.remove(); - }); - - it("sets the element's scrollTop to the element's scrollHeight", () => { - const el = document.createElement('div'); - el.innerText = 'a'.repeat(50000); - Object.assign(el.style, { - height: '50px', - overflow: 'scroll', - whiteSpace: 'wrap', - width: '100px', - wordBreak: 'break-word', - }); - sandbox.appendChild(el); - - assert.strictEqual( - el.scrollTop, - 0, - 'Test is not set up correctly. Element is already scrolled' - ); - assert.isAtLeast( - el.scrollHeight, - 50, - 'Test is not set up correctly. scrollHeight is too low' - ); - - scrollToBottom(el); - - assert.isAtLeast(el.scrollTop, el.scrollHeight - 50); - }); -}); diff --git a/ts/test-electron/scrollUtil_test.ts b/ts/test-electron/scrollUtil_test.ts new file mode 100644 index 000000000000..87f4867678c1 --- /dev/null +++ b/ts/test-electron/scrollUtil_test.ts @@ -0,0 +1,86 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { + getScrollBottom, + scrollToBottom, + setScrollBottom, +} from '../util/scrollUtil'; + +describe('scroll utilities', () => { + let sandbox: HTMLDivElement; + let el: HTMLDivElement; + + // These tests to be flaky on Windows CI, sometimes timing out. That doesn't really + // make sense because the test is synchronous, but this quick-and-dirty fix is + // probably better than a full investigation. + before(function thisNeeded() { + if (process.platform === 'win32') { + this.skip(); + } + }); + + beforeEach(() => { + sandbox = document.createElement('div'); + document.body.appendChild(sandbox); + + el = document.createElement('div'); + el.innerText = 'a'.repeat(50000); + Object.assign(el.style, { + height: '50px', + overflow: 'scroll', + whiteSpace: 'wrap', + width: '100px', + wordBreak: 'break-word', + }); + sandbox.appendChild(el); + + assert.strictEqual( + el.scrollTop, + 0, + 'Test is not set up correctly. Element is already scrolled' + ); + assert.isAtLeast( + el.scrollHeight, + 50, + 'Test is not set up correctly. scrollHeight is too low' + ); + }); + + afterEach(() => { + sandbox.remove(); + }); + + describe('getScrollBottom', () => { + it('gets the distance from the bottom', () => { + assert.strictEqual( + getScrollBottom(el), + el.scrollHeight - el.clientHeight + ); + + el.scrollTop = 999999; + + assert.strictEqual(getScrollBottom(el), 0); + }); + }); + + describe('setScrollBottom', () => { + it('sets the distance from the bottom', () => { + setScrollBottom(el, 12); + assert.strictEqual(getScrollBottom(el), 12); + + setScrollBottom(el, 9999999); + assert.strictEqual(el.scrollTop, 0); + }); + }); + + describe('scrollToBottom', () => { + it("sets the element's scrollTop to the element's scrollHeight", () => { + scrollToBottom(el); + + assert.isAtLeast(el.scrollTop, el.scrollHeight - 50); + }); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 2870d8d2d145..04175ca0f227 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -5,7 +5,6 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { v4 as uuid } from 'uuid'; import { times } from 'lodash'; -import { set } from 'lodash/fp'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { @@ -55,9 +54,8 @@ const { closeContactSpoofingReview, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, - createGroup, - messageSizeChanged, conversationStoppedByMissingVerification, + createGroup, openConversationInternal, repairNewestMessage, repairOldestMessage, @@ -334,13 +332,11 @@ describe('both/state/ducks/conversations', () => { function getDefaultConversationMessage(): ConversationMessageType { return { - heightChangeMessageIds: [], isLoadingMessages: false, messageIds: [], metrics: { totalUnread: 0, }, - resetCounter: 0, scrollToMessageCounter: 0, }; } @@ -832,76 +828,6 @@ describe('both/state/ducks/conversations', () => { }); }); - describe('MESSAGE_SIZE_CHANGED', () => { - const stateWithActiveConversation = { - ...getEmptyState(), - messagesByConversation: { - [conversationId]: { - heightChangeMessageIds: [], - isLoadingMessages: false, - isNearBottom: true, - messageIds: [messageId], - metrics: { totalUnread: 0 }, - resetCounter: 0, - scrollToMessageCounter: 0, - }, - }, - messagesLookup: { - [messageId]: getDefaultMessage(messageId), - }, - }; - - it('does nothing if no conversation is active', () => { - const state = getEmptyState(); - - assert.strictEqual( - reducer(state, messageSizeChanged('messageId', 'convoId')), - state - ); - }); - - it('does nothing if a different conversation is active', () => { - assert.deepEqual( - reducer( - stateWithActiveConversation, - messageSizeChanged(messageId, 'another-conversation-guid') - ), - stateWithActiveConversation - ); - }); - - it('adds the message ID to the list of messages with changed heights', () => { - const result = reducer( - stateWithActiveConversation, - messageSizeChanged(messageId, conversationId) - ); - - assert.sameMembers( - result.messagesByConversation[conversationId] - ?.heightChangeMessageIds || [], - [messageId] - ); - }); - - it("doesn't add duplicates to the list of changed-heights messages", () => { - const state = set( - ['messagesByConversation', conversationId, 'heightChangeMessageIds'], - [messageId], - stateWithActiveConversation - ); - const result = reducer( - state, - messageSizeChanged(messageId, conversationId) - ); - - assert.sameMembers( - result.messagesByConversation[conversationId] - ?.heightChangeMessageIds || [], - [messageId] - ); - }); - }); - describe('CONVERSATION_STOPPED_BY_MISSING_VERIFICATION', () => { it('adds to state, removing duplicates', () => { const first = reducer( diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1a537d943031..0a830c343116 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -7662,14 +7662,6 @@ "updated": "2021-01-18T22:24:05.937Z", "reasonDetail": "Used to reference popup menu boundaries element" }, - { - "rule": "React-useRef", - "path": "ts/components/conversation/ConversationHero.tsx", - "line": " const firstRenderRef = useRef(true);", - "reasonCategory": "falseMatch", - "updated": "2020-10-26T19:12:24.410Z", - "reasonDetail": "Doesn't refer to a DOM element." - }, { "rule": "React-useRef", "path": "ts/components/conversation/GIF.tsx", diff --git a/ts/util/scrollToBottom.ts b/ts/util/scrollToBottom.ts deleted file mode 100644 index 0ac3b55f2c16..000000000000 --- a/ts/util/scrollToBottom.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export function scrollToBottom(el: HTMLElement): void { - // We want to mutate the parameter here. - // eslint-disable-next-line no-param-reassign - el.scrollTop = el.scrollHeight; -} diff --git a/ts/util/scrollUtil.ts b/ts/util/scrollUtil.ts new file mode 100644 index 000000000000..47255400299f --- /dev/null +++ b/ts/util/scrollUtil.ts @@ -0,0 +1,23 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const getScrollBottom = ( + el: Readonly> +): number => el.scrollHeight - el.scrollTop - el.clientHeight; + +export function setScrollBottom( + el: Pick, + newScrollBottom: number +): void { + // We want to mutate the parameter here. + // eslint-disable-next-line no-param-reassign + el.scrollTop = el.scrollHeight - newScrollBottom - el.clientHeight; +} + +export function scrollToBottom( + el: Pick +): void { + // We want to mutate the parameter here. + // eslint-disable-next-line no-param-reassign + el.scrollTop = el.scrollHeight; +} diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts index 6fc08489869b..303c4dcaa8b1 100644 --- a/ts/util/timelineUtil.ts +++ b/ts/util/timelineUtil.ts @@ -1,208 +1,8 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { CellMeasurerCacheInterface } from 'react-virtualized/dist/es/CellMeasurer'; - -import { isNumber } from 'lodash'; -import type { PropsType } from '../components/conversation/Timeline'; import { WidthBreakpoint } from '../components/_util'; -export class RowHeightCache implements CellMeasurerCacheInterface { - private readonly cache = new Map(); - - private highestRowIndexSeen = 0; - - constructor(private readonly estimatedRowHeight: number) {} - - hasFixedWidth(): boolean { - return true; - } - - getWidth(): number { - // If the cache has a fixed width, we can just return a fixed value. See [the - // React Virtualized source code][0] for an example. - // [0]: https://github.com/bvaughn/react-virtualized/blob/abe0530a512639c042e74009fbf647abdb52d661/source/CellMeasurer/CellMeasurerCache.js#L6 - return 100; - } - - hasFixedHeight(): boolean { - return false; - } - - getHeight(rowIndex: number): number { - return this.cache.get(rowIndex) ?? this.estimatedRowHeight; - } - - has(rowIndex: number): boolean { - return this.cache.has(rowIndex); - } - - set( - rowIndex: number, - _columnIndex: number, - _width: number, - height: number - ): void { - this.cache.set(rowIndex, height); - this.highestRowIndexSeen = Math.max(this.highestRowIndexSeen, rowIndex); - } - - clearPlus(rowIndex: number): void { - if (rowIndex <= 0) { - this.clearAll(); - } else { - for (let i = rowIndex; i <= this.highestRowIndexSeen; i += 1) { - this.cache.delete(i); - } - this.highestRowIndexSeen = Math.min( - this.highestRowIndexSeen, - rowIndex - 1 - ); - } - } - - clearAll(): void { - this.cache.clear(); - this.highestRowIndexSeen = 0; - } -} - -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; diff --git a/ts/util/timestamp.ts b/ts/util/timestamp.ts index be8ebfe310b9..739de59eedcc 100644 --- a/ts/util/timestamp.ts +++ b/ts/util/timestamp.ts @@ -67,7 +67,7 @@ export function formatDateTimeShort( const diff = now - timestamp; if (diff < HOUR || isToday(timestamp)) { - return formatTime(i18n, rawTimestamp); + return formatTime(i18n, rawTimestamp, now); } const m = moment(timestamp); @@ -102,10 +102,11 @@ export function formatDateTimeLong( export function formatTime( i18n: LocalizerType, - rawTimestamp: RawTimestamp + rawTimestamp: RawTimestamp, + now: RawTimestamp ): string { const timestamp = rawTimestamp.valueOf(); - const diff = Date.now() - timestamp; + const diff = now.valueOf() - timestamp; if (diff < MINUTE) { return i18n('justNow');