Remove React Virtualized from `<Timeline>`

This commit is contained in:
Evan Hahn 2022-03-03 14:23:10 -06:00 committed by GitHub
parent 1eafe79905
commit 0c31ad25ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 798 additions and 2512 deletions

View File

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

View File

@ -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 {

View File

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

View File

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

View File

@ -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 = ({
<span
className={`module-message__metadata__date module-message__metadata__date--${direction}`}
>
{formatTime(i18n, timestamp)}
{formatTime(i18n, Date.now() - timestampDeltaFromNow, Date.now())}
</span>
{direction === 'outgoing' && (
<div
@ -78,7 +78,7 @@ export const SampleMessageBubbles = ({
direction={includeAnotherBubble ? 'outgoing' : 'incoming'}
i18n={i18n}
text={i18n('ChatColorPicker__sampleBubble1')}
timestamp={Date.now() - A_FEW_DAYS_AGO}
timestampDeltaFromNow={A_FEW_DAYS_AGO}
status="read"
style={firstBubbleStyle}
/>
@ -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"
/>
<br />
@ -103,7 +103,7 @@ export const SampleMessageBubbles = ({
direction="outgoing"
i18n={i18n}
text={i18n('ChatColorPicker__sampleBubble3')}
timestamp={Date.now()}
timestampDeltaFromNow={0}
status="delivered"
style={backgroundStyle}
/>

View File

@ -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'),
});

View File

@ -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<PropsType> = React.memo(props => {
const { conversationId, i18n, messageId, messageSizeChanged } = props;
const [height, setHeight] = useState<null | number>(null);
const previousHeight = usePrevious<null | number>(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<PropsType> = React.memo(props => {
const icon = getCallingIcon(props);
return (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
log.error('We should be measuring the bounds');
return;
}
setHeight(bounds.height);
}}
>
{({ measureRef }) => (
<SystemMessage
button={renderCallingNotificationButton(props)}
contents={
<>
{getCallingNotificationText(props, i18n)} &middot;{' '}
<MessageTimestamp
direction="outgoing"
i18n={i18n}
timestamp={timestamp}
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
/>
</>
}
icon={icon}
isError={wasMissed}
ref={measureRef}
/>
)}
</Measure>
<SystemMessage
button={renderCallingNotificationButton(props)}
contents={
<>
{getCallingNotificationText(props, i18n)} &middot;{' '}
<MessageTimestamp
direction="outgoing"
i18n={i18n}
now={now}
timestamp={timestamp}
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
/>
</>
}
icon={icon}
isError={wasMissed}
/>
);
});

View File

@ -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', () => (
<ChangeNumberNotification
now={Date.now()}
sender={getDefaultConversation()}
timestamp={1618894800000}
i18n={i18n}
@ -27,6 +28,7 @@ story.add('Default', () => (
story.add('Long name', () => (
<ChangeNumberNotification
now={Date.now()}
sender={getDefaultConversation({
firstName: '💅😇🖋'.repeat(50),
})}

View File

@ -18,12 +18,13 @@ export type PropsData = {
export type PropsHousekeeping = {
i18n: LocalizerType;
now: number;
};
export type Props = PropsData & PropsHousekeeping;
export const ChangeNumberNotification: React.FC<Props> = props => {
const { i18n, sender, timestamp } = props;
const { i18n, now, sender, timestamp } = props;
return (
<SystemMessage
@ -37,7 +38,7 @@ export const ChangeNumberNotification: React.FC<Props> = props => {
i18n={i18n}
/>
&nbsp;·&nbsp;
<MessageTimestamp i18n={i18n} timestamp={timestamp} />
<MessageTimestamp i18n={i18n} now={now} timestamp={timestamp} />
</>
}
icon="phone"

View File

@ -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')}
/>
</div>
);
@ -83,7 +82,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
'Fourth',
]}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -106,7 +104,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -129,7 +126,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -152,7 +148,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers']}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -175,7 +170,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -198,7 +192,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -221,7 +214,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -243,7 +235,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -265,7 +256,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -285,7 +275,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -305,7 +294,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -326,7 +314,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -347,7 +334,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -367,7 +353,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);
@ -386,7 +371,6 @@ storiesOf('Components/Conversation/ConversationHero', module)
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
onHeightChange={action('onHeightChange')}
/>
</div>
);

View File

@ -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<string>;
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 (

View File

@ -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<AudioAttachmentProps> = 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> = {}): 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> = {}): Props => ({
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
messageExpanded: action('messageExpanded'),
onHeightChange: action('onHeightChange'),
openConversation: action('openConversation'),
openLink: action('openLink'),
previews: overrideProps.previews || [],

View File

@ -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<Props, State> {
}
this.checkExpired();
this.checkForHeightChange(prevProps);
if (
prevProps.status === 'sending' &&
@ -491,24 +491,6 @@ export class Message extends React.PureComponent<Props, State> {
}
}
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<Props, State> {
isTapToViewExpired,
status,
i18n,
now,
text,
textPending,
timestamp,
@ -640,6 +623,7 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
markViewed,
now,
quote,
readStatus,
reducedMotion,
@ -834,6 +819,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationLength,
expirationTimestamp,
id,
now,
played,
showMessageDetail,
status,
@ -1238,7 +1224,6 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
id,
messageExpanded,
onHeightChange,
openConversation,
status,
text,
@ -1276,7 +1261,6 @@ export class Message extends React.PureComponent<Props, State> {
id={id}
messageExpanded={messageExpanded}
openConversation={openConversation}
onHeightChange={onHeightChange}
text={contents || ''}
textPending={textPending}
/>
@ -1284,13 +1268,9 @@ export class Message extends React.PureComponent<Props, State> {
);
}
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<Props, State> {
);
}
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<Props, State> {
selectedReaction,
} = this.props;
if (!isCorrectSide || disableMenu) {
if (disableMenu) {
return null;
}
@ -2462,12 +2439,10 @@ export class Message extends React.PureComponent<Props, State> {
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)}
</div>
);

View File

@ -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: Props) => {
expirationLength,
expirationTimestamp,
id,
now,
played,
showMessageDetail,
status,
@ -539,6 +541,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
isShowingImage={false}
isSticker={false}
isTapToViewExpired={false}
now={now}
showMessageDetail={showMessageDetail}
status={status}
textPending={textPending}

View File

@ -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> = {}): Props => ({
i18n,
id: 'some-id',
messageExpanded: action('messageExpanded'),
onHeightChange: action('onHeightChange'),
text: text('text', overrideProps.text || ''),
});

View File

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

View File

@ -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<Props> {
private readonly focusRef = React.createRef<HTMLDivElement>();
export class MessageDetail extends React.Component<Props, State> {
override state = { nowThatUpdatesEveryMinute: Date.now() };
private readonly focusRef = React.createRef<HTMLDivElement>();
private readonly messageContainerRef = React.createRef<HTMLDivElement>();
private nowThatUpdatesEveryMinuteInterval?: ReturnType<typeof setInterval>;
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<Props> {
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<Props> {
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<Props> {
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
markViewed={markViewed}
messageExpanded={noop}
onHeightChange={noop}
now={nowThatUpdatesEveryMinute}
openConversation={openConversation}
openLink={openLink}
reactToMessage={reactToMessage}

View File

@ -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<PropsType> = props => {
isShowingImage,
isSticker,
isTapToViewExpired,
now,
showMessageDetail,
status,
textPending,
@ -97,6 +99,7 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
<MessageTimestamp
i18n={i18n}
timestamp={timestamp}
now={now}
direction={metadataDirection}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}

View File

@ -42,6 +42,7 @@ const times = (): Array<[string, number]> => [
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
timestamp: overrideProps.timestamp,
now: Date.now(),
module: text('module', ''),
withImageNoCaption: boolean('withImageNoCaption', false),
withSticker: boolean('withSticker', false),

View File

@ -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<Props>): null | ReactElement {
const moduleName = module || 'module-timestamp';
export class MessageTimestamp extends React.Component<Props> {
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 (
<span
className={classNames(
moduleName,
direction ? `${moduleName}--${direction}` : null,
withTapToViewExpired && direction
? `${moduleName}--${direction}-with-tap-to-view-expired`
: null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
withSticker ? `${moduleName}--with-sticker` : null
)}
title={moment(timestamp).format('llll')}
>
{formatTime(i18n, timestamp)}
</span>
);
}
return (
<Time
className={classNames(
moduleName,
direction ? `${moduleName}--${direction}` : null,
withTapToViewExpired && direction
? `${moduleName}--${direction}-with-tap-to-view-expired`
: null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
withSticker ? `${moduleName}--with-sticker` : null
)}
timestamp={timestamp}
>
{formatTime(i18n, timestamp, now)}
</Time>
);
}

View File

@ -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: [],

View File

@ -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<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
isOldestTimelineItem: boolean;
now: number;
}) => (
<TimelineItem
getPreferredBadge={() => 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> = {}): PropsType => ({
discardMessages: action('discardMessages'),
getPreferredBadge: () => undefined,
i18n,
theme: React.useContext(StorybookThemeContext),
@ -493,6 +492,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): 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> = {}): PropsType => ({
overrideProps.isLoadingMessages === false
),
items: overrideProps.items || Object.keys(items),
resetCounter: 0,
scrollToIndex: overrideProps.scrollToIndex,
scrollToIndexCounter: 0,
totalUnread: number('totalUnread', overrideProps.totalUnread || 0),

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -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<PropsType> {
item,
i18n,
theme,
messageSizeChanged,
nextItem,
now,
previousItem,
renderContact,
renderUniversalTimerNotification,
@ -199,8 +199,12 @@ export class TimelineItem extends React.PureComponent<PropsType> {
} = this.props;
if (!item) {
log.warn(`TimelineItem: item ${id} provided was falsey`);
// This can happen under normal conditions.
//
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a
// timeline item is removed from Redux, `<TimelineItem>` might re-render before
// `<Timeline>` does, which means we'll try to render nothing. This should resolve
// itself quickly, as soon as `<Timeline>` re-renders.
return null;
}
@ -229,9 +233,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<CallingNotification
conversationId={conversationId}
i18n={i18n}
messageId={id}
messageSizeChanged={messageSizeChanged}
nextItem={nextItem}
now={now}
returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby}
{...item.data}

View File

@ -6,11 +6,9 @@ import type { ThunkAction } from 'redux-thunk';
import {
difference,
fromPairs,
intersection,
omit,
orderBy,
pick,
uniq,
values,
without,
} from 'lodash';
@ -243,13 +241,10 @@ export type MessageLookupType = {
[key: string]: MessageWithUIFieldsType;
};
export type ConversationMessageType = {
heightChangeMessageIds: Array<string>;
isLoadingMessages: boolean;
isNearBottom?: boolean;
loadCountdownStart?: number;
messageIds: Array<string>;
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['payload']>
): 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;

View File

@ -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: [],

View File

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

View File

@ -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 (
<SmartHeroRow
id={id}
onHeightChange={onHeightChange}
unblurAvatar={unblurAvatar}
updateSharedGroups={updateSharedGroups}
/>
@ -306,6 +293,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'typingContactId',
'isGroupV1AndDisabled',
]),
isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean(
conversation.messageRequestsEnabled &&
!conversation.acceptedMessageRequest

View File

@ -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,

View File

@ -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('<Timeline> utilities', () => {
const getItems = (count: number): Array<string> => times(count, () => uuid());
describe('fromItemIndexToRow', () => {
it('returns the same number under normal conditions', () => {
times(5, index => {
assert.strictEqual(
fromItemIndexToRow(index, { haveOldest: false }),
index
);
});
});
it('adds 1 (for the hero row) if you have the oldest messages', () => {
times(5, index => {
assert.strictEqual(
fromItemIndexToRow(index, { haveOldest: true }),
index + 1
);
});
});
it('adds 1 (for the unread indicator) once crossing the unread indicator index', () => {
const props = { haveOldest: false, oldestUnreadIndex: 2 };
[0, 1].forEach(index => {
assert.strictEqual(fromItemIndexToRow(index, props), index);
});
[2, 3, 4].forEach(index => {
assert.strictEqual(fromItemIndexToRow(index, props), index + 1);
});
});
it('can include the hero row and the unread indicator', () => {
const props = { haveOldest: true, oldestUnreadIndex: 2 };
[0, 1].forEach(index => {
assert.strictEqual(fromItemIndexToRow(index, props), index + 1);
});
[2, 3, 4].forEach(index => {
assert.strictEqual(fromItemIndexToRow(index, props), index + 2);
});
});
});
describe('fromRowToItemIndex', () => {
it('returns the item index under normal conditions', () => {
const props = { haveOldest: false, items: getItems(5) };
times(5, row => {
assert.strictEqual(fromRowToItemIndex(row, props), row);
});
assert.isUndefined(fromRowToItemIndex(5, props));
});
it('handles the unread indicator', () => {
const props = {
haveOldest: false,
items: getItems(4),
oldestUnreadIndex: 2,
};
[0, 1].forEach(row => {
assert.strictEqual(fromRowToItemIndex(row, props), row);
});
assert.isUndefined(fromRowToItemIndex(2, props));
[3, 4].forEach(row => {
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
});
assert.isUndefined(fromRowToItemIndex(5, props));
});
it('handles the hero row', () => {
const props = { haveOldest: true, items: getItems(3) };
assert.isUndefined(fromRowToItemIndex(0, props));
[1, 2, 3].forEach(row => {
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
});
assert.isUndefined(fromRowToItemIndex(4, props));
});
it('handles the whole enchilada', () => {
const props = {
haveOldest: true,
items: getItems(4),
oldestUnreadIndex: 2,
};
assert.isUndefined(fromRowToItemIndex(0, props));
[1, 2].forEach(row => {
assert.strictEqual(fromRowToItemIndex(row, props), row - 1);
});
assert.isUndefined(fromRowToItemIndex(3, props));
[4, 5].forEach(row => {
assert.strictEqual(fromRowToItemIndex(row, props), row - 2);
});
assert.isUndefined(fromRowToItemIndex(6, props));
});
});
describe('getRowCount', () => {
it('returns 1 (for the hero row) if the conversation is empty', () => {
assert.strictEqual(getRowCount({ haveOldest: true, items: [] }), 1);
});
it('returns the number of items under normal conditions', () => {
assert.strictEqual(
getRowCount({ haveOldest: false, items: getItems(4) }),
4
);
});
it('adds 1 (for the hero row) if you have the oldest messages', () => {
assert.strictEqual(
getRowCount({ haveOldest: true, items: getItems(4) }),
5
);
});
it('adds 1 (for the unread indicator) if you have unread messages', () => {
assert.strictEqual(
getRowCount({
haveOldest: false,
items: getItems(4),
oldestUnreadIndex: 2,
}),
5
);
});
it('adds 1 (for the typing contact) if you have unread messages', () => {
assert.strictEqual(
getRowCount({
haveOldest: false,
items: getItems(4),
typingContactId: uuid(),
}),
5
);
});
it('can have the whole enchilada', () => {
assert.strictEqual(
getRowCount({
haveOldest: true,
items: getItems(4),
oldestUnreadIndex: 2,
typingContactId: uuid(),
}),
7
);
});
});
describe('getHeroRow', () => {
it("returns undefined if there's no hero row", () => {
assert.isUndefined(getHeroRow({ haveOldest: false }));
});
it("returns 0 if there's a hero row", () => {
assert.strictEqual(getHeroRow({ haveOldest: true }), 0);
});
});
describe('getLastSeenIndicatorRow', () => {
it('returns undefined with no unread messages', () => {
assert.isUndefined(getLastSeenIndicatorRow({ haveOldest: false }));
assert.isUndefined(getLastSeenIndicatorRow({ haveOldest: true }));
});
it('returns the same number if the oldest messages are loaded', () => {
[0, 1, 2].forEach(oldestUnreadIndex => {
assert.strictEqual(
getLastSeenIndicatorRow({ haveOldest: false, oldestUnreadIndex }),
oldestUnreadIndex
);
});
});
it("increases the number by 1 if there's a hero row", () => {
[0, 1, 2].forEach(oldestUnreadIndex => {
assert.strictEqual(
getLastSeenIndicatorRow({ haveOldest: true, oldestUnreadIndex }),
oldestUnreadIndex + 1
);
});
});
});
describe('getTypingBubbleRow', () => {
it('returns undefined if nobody is typing', () => {
assert.isUndefined(
getTypingBubbleRow({ haveOldest: false, items: getItems(3) })
);
});
it('returns the last row if people are typing', () => {
[
{ haveOldest: true, items: [], typingContactId: uuid() },
{ haveOldest: false, items: getItems(3), typingContactId: uuid() },
{ haveOldest: true, items: getItems(3), typingContactId: uuid() },
{
haveOldest: false,
items: getItems(3),
oldestUnreadIndex: 2,
typingContactId: uuid(),
},
{
haveOldest: true,
items: getItems(3),
oldestUnreadIndex: 2,
typingContactId: uuid(),
},
].forEach(props => {
assert.strictEqual(getTypingBubbleRow(props), getRowCount(props) - 1);
});
});
});
describe('getEphemeralRows', () => {
function iterate<T>(iterator: Iterator<T>): Array<T> {
const result: Array<T> = [];
let iteration = iterator.next();
while (!iteration.done) {
result.push(iteration.value);
iteration = iterator.next();
}
return result;
}
it('yields each row under normal conditions', () => {
const result = getEphemeralRows({
haveOldest: false,
items: ['a', 'b', 'c'],
});
assert.deepStrictEqual(iterate(result), ['item:a', 'item:b', 'item:c']);
});
it('yields a hero row if there is one', () => {
const result = getEphemeralRows({ haveOldest: true, items: getItems(3) });
const iterated = iterate(result);
assert.lengthOf(iterated, 4);
assert.strictEqual(iterated[0], 'hero');
});
it('yields an unread indicator if there is one', () => {
const result = getEphemeralRows({
haveOldest: false,
items: getItems(3),
oldestUnreadIndex: 2,
});
const iterated = iterate(result);
assert.lengthOf(iterated, 4);
assert.strictEqual(iterated[2], 'oldest-unread');
});
it('yields a typing row if there is one', () => {
const result = getEphemeralRows({
haveOldest: false,
items: getItems(3),
typingContactId: uuid(),
});
const iterated = iterate(result);
assert.lengthOf(iterated, 4);
assert.strictEqual(iterated[3], 'typing-contact');
});
it('handles the whole enchilada', () => {
const result = getEphemeralRows({
haveOldest: true,
items: ['a', 'b', 'c'],
oldestUnreadIndex: 2,
typingContactId: uuid(),
});
assert.deepStrictEqual(iterate(result), [
'hero',
'item:a',
'item:b',
'oldest-unread',
'item:c',
'typing-contact',
]);
});
});
});

View File

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

View File

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

View File

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

View File

@ -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(

View File

@ -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",

View File

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

23
ts/util/scrollUtil.ts Normal file
View File

@ -0,0 +1,23 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const getScrollBottom = (
el: Readonly<Pick<HTMLElement, 'clientHeight' | 'scrollHeight' | 'scrollTop'>>
): number => el.scrollHeight - el.scrollTop - el.clientHeight;
export function setScrollBottom(
el: Pick<HTMLElement, 'clientHeight' | 'scrollHeight' | 'scrollTop'>,
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<HTMLElement, 'scrollHeight' | 'scrollTop'>
): void {
// We want to mutate the parameter here.
// eslint-disable-next-line no-param-reassign
el.scrollTop = el.scrollHeight;
}

View File

@ -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<number, number>();
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<Pick<PropsType, 'haveOldest' | 'oldestUnreadIndex'>>
): number {
let result = itemIndex;
// Hero row
if (haveOldest) {
result += 1;
}
// Unread indicator
if (isNumber(oldestUnreadIndex) && itemIndex >= oldestUnreadIndex) {
result += 1;
}
return result;
}
export function fromRowToItemIndex(
row: number,
props: Readonly<Pick<PropsType, 'haveOldest' | 'items' | 'oldestUnreadIndex'>>
): undefined | number {
const { haveOldest, items, oldestUnreadIndex } = props;
let result = row;
// Hero row
if (haveOldest) {
result -= 1;
}
// Unread indicator
if (isNumber(oldestUnreadIndex)) {
if (result === oldestUnreadIndex) {
return;
}
if (result > oldestUnreadIndex) {
result -= 1;
}
}
if (result < 0 || result >= items.length) {
return;
}
return result;
}
export function getRowCount({
haveOldest,
items,
oldestUnreadIndex,
typingContactId,
}: Readonly<
Pick<
PropsType,
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
>
>): number {
let result = items?.length || 0;
// Hero row
if (haveOldest) {
result += 1;
}
// Unread indicator
if (isNumber(oldestUnreadIndex)) {
result += 1;
}
// Typing indicator
if (typingContactId) {
result += 1;
}
return result;
}
export function getHeroRow({
haveOldest,
}: Readonly<Pick<PropsType, 'haveOldest'>>): undefined | number {
return haveOldest ? 0 : undefined;
}
export function getLastSeenIndicatorRow(
props: Readonly<Pick<PropsType, 'haveOldest' | 'oldestUnreadIndex'>>
): undefined | number {
const { oldestUnreadIndex } = props;
return isNumber(oldestUnreadIndex)
? fromItemIndexToRow(oldestUnreadIndex, props) - 1
: undefined;
}
export function getTypingBubbleRow(
props: Readonly<
Pick<
PropsType,
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
>
>
): undefined | number {
return props.typingContactId ? getRowCount(props) - 1 : undefined;
}
export function* getEphemeralRows({
haveOldest,
items,
oldestUnreadIndex,
typingContactId,
}: Readonly<
Pick<
PropsType,
'haveOldest' | 'items' | 'oldestUnreadIndex' | 'typingContactId'
>
>): Iterator<string> {
if (haveOldest) {
yield 'hero';
}
for (let i = 0; i < items.length; i += 1) {
if (i === oldestUnreadIndex) {
yield 'oldest-unread';
}
yield `item:${items[i]}`;
}
if (typingContactId) {
yield 'typing-contact';
}
}
export function getWidthBreakpoint(width: number): WidthBreakpoint {
if (width > 606) {
return WidthBreakpoint.Wide;

View File

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