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');