undefined}
id=""
- isOldestTimelineItem={isOldestTimelineItem}
isSelected={false}
renderEmojiPicker={() => }
renderReactionPicker={() => }
item={items[messageId]}
- previousItem={undefined}
- nextItem={undefined}
i18n={i18n}
interactionMode="keyboard"
+ isNextItemCallingNotification={false}
theme={ThemeType.light}
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
@@ -449,6 +445,10 @@ const renderItem = ({
*UniversalTimerNotification*
)}
renderAudioAttachment={() => *AudioAttachment*
}
+ shouldCollapseAbove={false}
+ shouldCollapseBelow={false}
+ shouldHideMetadata={false}
+ shouldRenderDateHeader={false}
{...actions()}
/>
);
diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx
index f19c14745d..ade8bb02e8 100644
--- a/ts/components/conversation/TimelineItem.stories.tsx
+++ b/ts/components/conversation/TimelineItem.stories.tsx
@@ -53,7 +53,7 @@ const getDefaultProps = () => ({
conversationId: 'conversation-id',
getPreferredBadge: () => undefined,
id: 'asdf',
- isOldestTimelineItem: false,
+ isNextItemCallingNotification: false,
isSelected: false,
interactionMode: 'keyboard' as const,
theme: ThemeType.light,
@@ -94,8 +94,11 @@ const getDefaultProps = () => ({
showIdentity: action('showIdentity'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
- previousItem: undefined,
- nextItem: undefined,
+ shouldCollapseAbove: false,
+ shouldCollapseBelow: false,
+ shouldHideMetadata: false,
+ shouldRenderDateHeader: false,
+
now: Date.now(),
renderContact,
diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx
index 90189789e1..35dd1b512e 100644
--- a/ts/components/conversation/TimelineItem.tsx
+++ b/ts/components/conversation/TimelineItem.tsx
@@ -5,7 +5,6 @@ import type { ReactChild, RefObject } from 'react';
import React from 'react';
import type { LocalizerType, ThemeType } from '../../types/Util';
-import { isSameDay } from '../../util/timestamp';
import type { InteractionModeType } from '../../state/ducks/conversations';
import { TimelineDateHeader } from './TimelineDateHeader';
@@ -56,7 +55,6 @@ import type { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification';
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
import { ProfileChangeNotification } from './ProfileChangeNotification';
-import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { FullJSXType } from '../Intl';
type CallHistoryType = {
@@ -148,17 +146,15 @@ type PropsLocalType = {
conversationId: string;
item?: TimelineItemType;
id: string;
+ isNextItemCallingNotification: boolean;
isSelected: boolean;
selectMessage: (messageId: string, conversationId: string) => unknown;
+ shouldRenderDateHeader: boolean;
renderContact: SmartContactRendererType;
renderUniversalTimerNotification: () => JSX.Element;
i18n: LocalizerType;
interactionMode: InteractionModeType;
- isOldestTimelineItem: boolean;
theme: ThemeType;
- previousItem: undefined | TimelineItemType;
- nextItem: undefined | TimelineItemType;
- unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
};
type PropsActionsType = MessageActionsType &
@@ -178,6 +174,9 @@ export type PropsType = PropsLocalType &
| 'renderEmojiPicker'
| 'renderAudioAttachment'
| 'renderReactionPicker'
+ | 'shouldCollapseAbove'
+ | 'shouldCollapseBelow'
+ | 'shouldHideMetadata'
>;
export class TimelineItem extends React.PureComponent {
@@ -186,19 +185,20 @@ export class TimelineItem extends React.PureComponent {
containerElementRef,
conversationId,
getPreferredBadge,
+ i18n,
id,
- isOldestTimelineItem,
+ isNextItemCallingNotification,
isSelected,
item,
- i18n,
- theme,
- nextItem,
- previousItem,
renderUniversalTimerNotification,
returnToActiveCall,
selectMessage,
+ shouldCollapseAbove,
+ shouldCollapseBelow,
+ shouldHideMetadata,
+ shouldRenderDateHeader,
startCallingLobby,
- unreadIndicatorPlacement,
+ theme,
} = this.props;
if (!item) {
@@ -217,12 +217,14 @@ export class TimelineItem extends React.PureComponent {
);
} else {
@@ -237,7 +239,7 @@ export class TimelineItem extends React.PureComponent {
{
);
}
- const shouldRenderDateHeader =
- isOldestTimelineItem ||
- Boolean(
- previousItem &&
- // This comparison avoids strange header behavior for out-of-order messages.
- item.timestamp > previousItem.timestamp &&
- !isSameDay(previousItem.timestamp, item.timestamp)
- );
if (shouldRenderDateHeader) {
return (
<>
diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx
index 72e6671a6b..ef125b5e8e 100644
--- a/ts/state/smart/TimelineItem.tsx
+++ b/ts/state/smart/TimelineItem.tsx
@@ -16,10 +16,15 @@ import {
getMessageSelector,
getSelectedMessage,
} from '../selectors/conversations';
-import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
+import {
+ areMessagesInSameGroup,
+ shouldCurrentMessageHideMetadata,
+ UnreadIndicatorPlacement,
+} from '../../util/timelineUtil';
import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
+import { isSameDay } from '../../util/timestamp';
type ExternalProps = {
containerElementRef: RefObject;
@@ -65,24 +70,52 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversation = getConversationSelector(state)(conversationId);
+ const isNextItemCallingNotification = nextItem?.type === 'callHistory';
+
+ const shouldCollapseAbove = areMessagesInSameGroup(
+ previousItem,
+ unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove,
+ item
+ );
+ const shouldCollapseBelow = areMessagesInSameGroup(
+ item,
+ unreadIndicatorPlacement === UnreadIndicatorPlacement.JustBelow,
+ nextItem
+ );
+ const shouldHideMetadata = shouldCurrentMessageHideMetadata(
+ shouldCollapseBelow,
+ item,
+ nextItem
+ );
+ const shouldRenderDateHeader =
+ isOldestTimelineItem ||
+ Boolean(
+ item &&
+ previousItem &&
+ // This comparison avoids strange header behavior for out-of-order messages.
+ item.timestamp > previousItem.timestamp &&
+ !isSameDay(previousItem.timestamp, item.timestamp)
+ );
+
return {
item,
- previousItem,
- nextItem,
id: messageId,
containerElementRef,
conversationId,
conversationColor: conversation?.conversationColor,
customColor: conversation?.customColor,
getPreferredBadge: getPreferredBadgeSelector(state),
- isOldestTimelineItem,
+ isNextItemCallingNotification,
isSelected,
renderContact,
renderUniversalTimerNotification,
+ shouldCollapseAbove,
+ shouldCollapseBelow,
+ shouldHideMetadata,
+ shouldRenderDateHeader,
i18n: getIntl(state),
interactionMode: getInteractionMode(state),
theme: getTheme(state),
- unreadIndicatorPlacement,
};
};
diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts
index 646cd5dc95..a6ce61065d 100644
--- a/ts/test-both/util/timelineUtil_test.ts
+++ b/ts/test-both/util/timelineUtil_test.ts
@@ -4,12 +4,14 @@
import { assert } from 'chai';
import { times } from 'lodash';
import { v4 as uuid } from 'uuid';
+import type { LastMessageStatus } from '../../model-types.d';
import { MINUTE, SECOND } from '../../util/durations';
import type { MaybeMessageTimelineItemType } from '../../util/timelineUtil';
import {
ScrollAnchor,
areMessagesInSameGroup,
getScrollAnchorBeforeUpdate,
+ shouldCurrentMessageHideMetadata,
TimelineMessageLoadingState,
} from '../../util/timelineUtil';
@@ -118,60 +120,123 @@ describe(' utilities', () => {
assert.isFalse(areMessagesInSameGroup(defaultOlder, true, defaultNewer));
});
- it("returns false if they don't have matching sent status (and not delivered)", () => {
- const older = {
- ...defaultOlder,
- data: { ...defaultOlder.data, status: 'sent' as const },
- };
-
- assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
- });
-
- it("returns false if newer is deletedForEveryone and older isn't", () => {
- const newer = {
- ...defaultNewer,
- data: { ...defaultNewer.data, deletedForEveryone: true },
- };
-
- assert.isFalse(areMessagesInSameGroup(defaultOlder, false, newer));
- });
-
- it("returns true if older is deletedForEveryone and newer isn't", () => {
- const older = {
- ...defaultOlder,
- data: { ...defaultOlder.data, deletedForEveryone: true },
- };
-
- assert.isTrue(areMessagesInSameGroup(older, false, defaultNewer));
- });
-
- it('returns true if both are deletedForEveryone', () => {
- const older = {
- ...defaultOlder,
- data: { ...defaultOlder.data, deletedForEveryone: true },
- };
- const newer = {
- ...defaultNewer,
- data: { ...defaultNewer.data, deletedForEveryone: true },
- };
-
- assert.isTrue(areMessagesInSameGroup(older, false, newer));
- });
-
- it('returns true if they have delivered status or above', () => {
- const older = {
- ...defaultOlder,
- data: { ...defaultOlder.data, status: 'read' as const },
- };
-
- assert.isTrue(areMessagesInSameGroup(older, false, defaultNewer));
- });
-
it('returns true if everything above works out', () => {
assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer));
});
});
+ describe('shouldCurrentMessageHideMetadata', () => {
+ const defaultNewer: MaybeMessageTimelineItemType = {
+ type: 'message' as const,
+ data: {
+ author: { id: uuid() },
+ timestamp: new Date(1998, 10, 21, 12, 34, 56, 123).valueOf(),
+ status: 'delivered',
+ },
+ };
+ const defaultCurrent: MaybeMessageTimelineItemType = {
+ type: 'message' as const,
+ data: {
+ author: { id: uuid() },
+ timestamp: defaultNewer.data.timestamp - MINUTE,
+ status: 'delivered',
+ },
+ };
+
+ it("returns false if messages aren't grouped", () => {
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(false, defaultCurrent, defaultNewer)
+ );
+ });
+
+ it('returns false if newer item is missing', () => {
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(true, defaultCurrent, undefined)
+ );
+ });
+
+ it('returns false if newer item is not a message', () => {
+ const linkNotification = {
+ type: 'linkNotification' as const,
+ data: null,
+ timestamp: Date.now(),
+ };
+
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(true, defaultCurrent, linkNotification)
+ );
+ });
+
+ it('returns false if newer is deletedForEveryone', () => {
+ const newer = {
+ ...defaultNewer,
+ data: { ...defaultNewer.data, deletedForEveryone: true },
+ };
+
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(true, defaultCurrent, newer)
+ );
+ });
+
+ it('returns false if current message is unsent, even if its status matches the newer one', () => {
+ const statuses: ReadonlyArray = [
+ 'paused',
+ 'error',
+ 'partial-sent',
+ 'sending',
+ ];
+ for (const status of statuses) {
+ const sameStatusNewer = {
+ ...defaultNewer,
+ data: { ...defaultNewer.data, status },
+ };
+ const current = {
+ ...defaultCurrent,
+ data: { ...defaultCurrent.data, status },
+ };
+
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(true, current, defaultNewer)
+ );
+ assert.isFalse(
+ shouldCurrentMessageHideMetadata(true, current, sameStatusNewer)
+ );
+ }
+ });
+
+ it('returns true if all messages are sent (but no higher)', () => {
+ const newer = {
+ ...defaultNewer,
+ data: { ...defaultNewer.data, status: 'sent' as const },
+ };
+ const current = {
+ ...defaultCurrent,
+ data: { ...defaultCurrent.data, status: 'sent' as const },
+ };
+
+ assert.isTrue(shouldCurrentMessageHideMetadata(true, current, newer));
+ });
+
+ it('returns true if all three have delivered status or above', () => {
+ assert.isTrue(
+ shouldCurrentMessageHideMetadata(true, defaultCurrent, defaultNewer)
+ );
+ });
+
+ it('returns true if both the current and next messages are deleted for everyone', () => {
+ const current = {
+ ...defaultCurrent,
+ data: { ...defaultCurrent.data, deletedForEveryone: true },
+ };
+ const newer = {
+ ...defaultNewer,
+ data: { ...defaultNewer.data, deletedForEveryone: true },
+ };
+
+ assert.isTrue(shouldCurrentMessageHideMetadata(true, current, newer));
+ });
+ });
+
describe('getScrollAnchorBeforeUpdate', () => {
const fakeItems = (count: number) => times(count, () => uuid());
diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts
index 3d40f76f32..cfd6a3f774 100644
--- a/ts/util/timelineUtil.ts
+++ b/ts/util/timelineUtil.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
+import * as log from '../logging/log';
import type { PropsType as TimelinePropsType } from '../components/conversation/Timeline';
import type { TimelineItemType } from '../components/conversation/TimelineItem';
import { WidthBreakpoint } from '../components/_util';
@@ -54,8 +55,52 @@ const getMessageTimelineItemData = (
): undefined | MessageTimelineItemDataType =>
timelineItem?.type === 'message' ? timelineItem.data : undefined;
-function isDelivered(status?: LastMessageStatus) {
- return status === 'delivered' || status === 'read' || status === 'viewed';
+export function shouldCurrentMessageHideMetadata(
+ areMessagesGrouped: boolean,
+ item: MaybeMessageTimelineItemType,
+ newerTimelineItem: MaybeMessageTimelineItemType
+): boolean {
+ if (!areMessagesGrouped) {
+ return false;
+ }
+
+ const message = getMessageTimelineItemData(item);
+ if (!message) {
+ return false;
+ }
+
+ const newerMessage = getMessageTimelineItemData(newerTimelineItem);
+ if (!newerMessage) {
+ return false;
+ }
+
+ // If newer message is deleted, but current isn't, we'll show metadata.
+ if (newerMessage.deletedForEveryone && !message.deletedForEveryone) {
+ return false;
+ }
+
+ switch (message.status) {
+ case undefined:
+ return true;
+ case 'paused':
+ case 'error':
+ case 'partial-sent':
+ case 'sending':
+ return false;
+ case 'sent':
+ return newerMessage.status === 'sent';
+ case 'delivered':
+ case 'read':
+ case 'viewed':
+ return (
+ newerMessage.status === 'delivered' ||
+ newerMessage.status === 'read' ||
+ newerMessage.status === 'viewed'
+ );
+ default:
+ log.error(missingCaseError(message.status));
+ return false;
+ }
}
export function areMessagesInSameGroup(
@@ -77,20 +122,12 @@ export function areMessagesInSameGroup(
return false;
}
- // We definitely don't want to group if we transition from non-deleted to deleted, since
- // deleted messages don't show status.
- if (newerMessage.deletedForEveryone && !olderMessage.deletedForEveryone) {
- return false;
- }
-
return Boolean(
!olderMessage.reactions?.length &&
olderMessage.author.id === newerMessage.author.id &&
newerMessage.timestamp >= olderMessage.timestamp &&
newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN &&
- isSameDay(olderMessage.timestamp, newerMessage.timestamp) &&
- (olderMessage.status === newerMessage.status ||
- (isDelivered(newerMessage.status) && isDelivered(olderMessage.status)))
+ isSameDay(olderMessage.timestamp, newerMessage.timestamp)
);
}