From 27ad6f32947c689f33b3934ed7dd2ff7d3a47816 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Sat, 21 Mar 2026 02:58:24 +1000 Subject: [PATCH] Collapse already-seen sets of timeline items --- .github/workflows/ci.yml | 1 + _locales/en/messages.json | 28 ++ stylesheets/_modules.scss | 40 ++ .../conversation/CollapseSet.dom.stories.tsx | 248 ++++++++++ .../conversation/CollapseSet.dom.tsx | 323 +++++++++++++ ts/components/conversation/Message.dom.tsx | 4 +- .../conversation/Timeline.dom.stories.tsx | 22 +- ts/components/conversation/Timeline.dom.tsx | 308 +++++++++--- .../conversation/TimelineItem.dom.stories.tsx | 4 + .../conversation/TimelineItem.dom.tsx | 27 ++ .../PinnedMessagesPanel.dom.tsx | 9 +- ts/state/ducks/conversations.preload.ts | 40 +- ts/state/selectors/conversations.dom.ts | 11 +- ts/state/smart/ChatsTab.preload.tsx | 2 +- ts/state/smart/Timeline.preload.tsx | 439 ++++++++++++++++-- ts/state/smart/TimelineItem.preload.tsx | 70 ++- ts/test-node/util/timelineUtil_test.std.ts | 15 +- 17 files changed, 1422 insertions(+), 169 deletions(-) create mode 100644 ts/components/conversation/CollapseSet.dom.stories.tsx create mode 100644 ts/components/conversation/CollapseSet.dom.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aeab8c610..a32f787091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: - run: pnpm run lint - run: pnpm run lint-deps - run: pnpm run lint-license-comments + - run: pnpm run lint-intl - name: Check acknowledgments file is up to date run: pnpm run build:acknowledgments diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 53a445e8e0..85c0f820ee 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3384,6 +3384,34 @@ "messageformat": "You updated the group.", "description": "Shown in the conversation history when you update a group" }, + "icu:collapsedGroupUpdates": { + "messageformat": "{count, plural, one {# group update} other {# group updates}}", + "description": "Label for a button giving access to a collection of group updates (count will always be 2+)" + }, + "icu:collapsedChatUpdates": { + "messageformat": "{count, plural, one {# chat update} other {# chat updates}}", + "description": "Label for a button giving access to a collection of chat updates (only in 1:1 conversations, count will always be 2+)" + }, + "icu:collapsedTimerChanges": { + "messageformat": "{count, plural, one {# disappearing timer change} other {# disappearing message timer changes}} · {endingState}", + "description": "Label for a button giving access to a collection of timer updates, also showing the ending state of the timer - like '15 minutes' (count will always be 2+)" + }, + "icu:collapsedTimerChanges--disabled": { + "messageformat": "{count, plural, one {# disappearing timer change} other {# disappearing message timer changes}} · Disabled", + "description": "Label for a button giving access to a collection of timer updates when the final state of the timer is 'Disabled' (count will always be 2+)" + }, + "icu:collapsedCallEvents": { + "messageformat": "{count, plural, one {# call event} other {# call events}}", + "description": "Label for button giving access to a collection of call events (count will always be 2+)" + }, + "icu:collapsedItems--collapsed": { + "messageformat": "Set of items is collapsed - click to expand", + "description": "Accessibility label for the down chevron which shows if the collapsed set of items is closed" + }, + "icu:collapsedItems--expanded": { + "messageformat": "Set of items is expanded - click to collapse", + "description": "Accessibility label for the up chevron which shows if the collapsed set of items is open" + }, "icu:updatedGroupAvatar": { "messageformat": "Group avatar was updated.", "description": "Shown in the conversation history when someone updates the group" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index aaf9794bb4..a758df7465 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5779,6 +5779,46 @@ button.module-calling-participants-list__contact { outline: none; } +.CollapseSet__height-container { + max-height: 0px; + overflow: hidden; + transition: max-height 200ms + linear( + 0, + 0.024 1.7%, + 0.097 3.7%, + 0.55 12%, + 0.75 16.6%, + 0.887 21.4%, + 0.935 24%, + 0.97 26.8%, + 1.002 31.5%, + 1.013 37.5%, + 1.001 62.7%, + 1 + ); + + visibility: hidden; + pointer-events: none; + user-select: none; +} + +.CollapseSet__height-container--expanded { + visibility: visible; + pointer-events: auto; + user-select: auto; +} + +.CollapseSet__transparency-container { + opacity: 0; + transition: opacity 120ms linear 200ms; +} + +.CollapseSet__transparency-container--expanded { + opacity: 0; + transition: opacity 25ms; +} + // Module: Last Seen Indicator .module-last-seen-indicator { diff --git a/ts/components/conversation/CollapseSet.dom.stories.tsx b/ts/components/conversation/CollapseSet.dom.stories.tsx new file mode 100644 index 0000000000..2a84e218b2 --- /dev/null +++ b/ts/components/conversation/CollapseSet.dom.stories.tsx @@ -0,0 +1,248 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import type { Meta } from '@storybook/react'; + +import { CollapseSetViewer } from './CollapseSet.dom.js'; + +import type { Props } from './CollapseSet.dom.js'; +import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; +import { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js'; +import { WidthBreakpoint } from '../_util.std.js'; +import { tw } from '../../axo/tw.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/Conversation/CollapseSet', +} satisfies Meta; + +function renderItem({ item }: RenderItemProps) { + return ( +
+ Message {item.id} - Use Signal +
+ ); +} + +const defaultProps: Props = { + containerElementRef: React.createRef(), + containerWidthBreakpoint: WidthBreakpoint.Wide, + conversationId: 'c1', + i18n, + id: 'id1', + isBlocked: false, + isGroup: true, + messages: undefined, + renderItem, + targetedMessage: undefined, + type: 'none', +}; + +export function GroupWithTwo(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + }; + return ; +} + +export function AutoexpandIfTargeted(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + targetedMessage: { + id: 'id1', + counter: 1, + }, + }; + return ; +} + +export function GroupWithOneThatHasExtra(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [{ id: 'id1 (with one extra)', isUnseen: false, extraItems: 1 }], + }; + return ; +} + +export function GroupWithTwoThatHaveExtra(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1 (with one extra)', isUnseen: false, extraItems: 1 }, + { id: 'id2 (with two extra)', isUnseen: false, extraItems: 2 }, + ], + }; + return ; +} + +export function GroupWithTen(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + { id: 'id3', isUnseen: false }, + { id: 'id4', isUnseen: false }, + { id: 'id5', isUnseen: false }, + { id: 'id6', isUnseen: false }, + { id: 'id7', isUnseen: false }, + { id: 'id8', isUnseen: false }, + { id: 'id9', isUnseen: false }, + { id: 'id10', isUnseen: false }, + ], + }; + return ( +
+
+ Message id0 - Use Signal +
+ +
+ Message id11 - Use Signal +
+
+ ); +} + +export function TimerWithTwoUndefined(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'timer-changes', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + endingState: undefined, + }; + return ; +} + +export function TimerWithTwoZero(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'timer-changes', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + endingState: DurationInSeconds.fromSeconds(0), + }; + return ; +} + +export function TimerWithTwoAt15m(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'timer-changes', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + endingState: DurationInSeconds.fromSeconds(60 * 15), + }; + return ; +} + +export function TimerWithTenAt1Hr(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'timer-changes', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + { id: 'id3', isUnseen: false }, + { id: 'id4', isUnseen: false }, + { id: 'id5', isUnseen: false }, + { id: 'id6', isUnseen: false }, + { id: 'id7', isUnseen: false }, + { id: 'id8', isUnseen: false }, + { id: 'id9', isUnseen: false }, + { id: 'id10', isUnseen: false }, + ], + endingState: DurationInSeconds.fromSeconds(60 * 60), + }; + return ; +} + +export function GroupWithTwoOneUnseen(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: true }, + ], + }; + return ; +} + +export function GroupWithFourTwoUnseen(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + { id: 'id3', isUnseen: true }, + { id: 'id4', isUnseen: true }, + ], + }; + return ; +} + +export function GroupWithFourThreeUnseen(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: true }, + { id: 'id3', isUnseen: true }, + { id: 'id4', isUnseen: true }, + ], + }; + return ; +} + +export function GroupWithWithUpdateAfterDelay(): React.JSX.Element { + const [props, setProps] = React.useState({ + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + { id: 'id3', isUnseen: true }, + { id: 'id4', isUnseen: true }, + ], + }); + + setTimeout(() => { + setProps({ + ...defaultProps, + type: 'group-updates', + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + { id: 'id3', isUnseen: false }, + { id: 'id4', isUnseen: false }, + { id: 'id5', isUnseen: true }, + ], + }); + }, 1000); + return ; +} diff --git a/ts/components/conversation/CollapseSet.dom.tsx b/ts/components/conversation/CollapseSet.dom.tsx new file mode 100644 index 0000000000..25d5629a76 --- /dev/null +++ b/ts/components/conversation/CollapseSet.dom.tsx @@ -0,0 +1,323 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; + +import type { RefObject } from 'react'; + +import { MessageInteractivity } from './Message.dom.js'; +import { format } from '../../util/expirationTimer.std.js'; +import { strictAssert } from '../../util/assert.std.js'; +import { missingCaseError } from '../../util/missingCaseError.std.js'; +import { AxoSymbol } from '../../axo/AxoSymbol.dom.js'; +import { tw } from '../../axo/tw.dom.js'; +import { AxoButton } from '../../axo/AxoButton.dom.js'; + +import type { WidthBreakpoint } from '../_util.std.js'; +import type { + CollapsedMessage, + CollapseSet, +} from '../../state/smart/Timeline.preload.js'; +import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; +import type { LocalizerType } from '../../types/I18N.std.js'; +import type { TargetedMessageType } from '../../state/selectors/conversations.dom.js'; + +export type Props = CollapseSet & { + containerElementRef: RefObject; + containerWidthBreakpoint: WidthBreakpoint; + conversationId: string; + i18n: LocalizerType; + isBlocked: boolean; + isGroup: boolean; + renderItem: (props: RenderItemProps) => React.JSX.Element; + targetedMessage: TargetedMessageType | undefined; +}; + +export function CollapseSetViewer(props: Props): React.JSX.Element { + strictAssert( + props.type !== 'none', + "CollapseSetViewer should never render a 'none' set" + ); + + const { + containerElementRef, + containerWidthBreakpoint, + conversationId, + isBlocked, + isGroup, + messages, + renderItem, + targetedMessage, + } = props; + const [isExpanded, setIsExpanded] = useState(false); + const [messageCache, setMessageCache] = useState< + Record + >({}); + const previousTargetedMessage = useRef(undefined); + + useEffect(() => { + if (!targetedMessage) { + previousTargetedMessage.current = undefined; + return; + } + + const match = messages.find(message => message.id === targetedMessage.id); + + if ( + match && + (targetedMessage.id !== previousTargetedMessage.current?.id || + targetedMessage.counter !== previousTargetedMessage.current?.counter) + ) { + setIsExpanded(true); + } + + previousTargetedMessage.current = targetedMessage; + }, [messages, setIsExpanded, targetedMessage]); + + // We want to capture the initial unseen value of every message we see + useLayoutEffect(() => { + const newCache = { ...messageCache }; + let hasChanged = false; + + messages?.forEach(message => { + if (newCache[message.id] != null) { + return; + } + + hasChanged = true; + newCache[message.id] = message; + }); + + if (hasChanged) { + setMessageCache(newCache); + } + }, [messages, messageCache, setMessageCache]); + + // Inner messages will never count as an oldest timeline item + const isOldestTimelineItem = false; + + let oldestOriginallyUnseenIndex; + const max = messages?.length; + for (let i = 0; i < max; i += 1) { + const message = messages[i]; + strictAssert( + message, + 'CollapseSet finding oldestOriginallyUnseenIndex in messages' + ); + if (messageCache[message.id]?.isUnseen) { + oldestOriginallyUnseenIndex = i; + break; + } + } + + // We only want to show the button if we have at least two items + const shouldShowButton = + oldestOriginallyUnseenIndex === undefined || + oldestOriginallyUnseenIndex > 1; + const shouldShowPassThrough = + !shouldShowButton || + (oldestOriginallyUnseenIndex && oldestOriginallyUnseenIndex < max); + + const collapsedMessages = messages.slice( + 0, + !shouldShowButton ? 0 : oldestOriginallyUnseenIndex + ); + let collapsedCount = collapsedMessages.length; + collapsedMessages.forEach(message => { + collapsedCount += message.extraItems ?? 0; + }); + + const passThroughMessages = messages.slice( + !shouldShowButton ? 0 : oldestOriginallyUnseenIndex + ); + + const transparencyRef = React.useRef(null); + + return ( +
+ {shouldShowButton ? ( +
+ { + setIsExpanded(value => !value); + }} + /> +
+ ) : undefined} +
+
{ + const expandedClass = + 'CollapseSet__transparency-container--expanded'; + const ref = transparencyRef.current; + if (!ref) { + return; + } + + if (ref.classList.contains(expandedClass)) { + ref.classList.remove(expandedClass); + } else { + ref.classList.add(expandedClass); + } + }} + className={classNames('CollapseSet__transparency-container')} + style={{ + opacity: isExpanded ? '1' : undefined, + }} + > + {shouldShowButton ? ( + <> + {collapsedMessages.map((child, index) => { + const previousMessage = messages[index - 1]; + const nextMessage = messages[index + 1]; + const indexItem = { + type: 'none' as const, + id: child.id, + messages: undefined, + }; + + return ( +
+ {renderItem({ + containerElementRef, + containerWidthBreakpoint, + conversationId, + interactivity: isExpanded + ? MessageInteractivity.Normal + : MessageInteractivity.Hidden, + isBlocked, + isGroup, + isOldestTimelineItem, + item: indexItem, + nextMessageId: nextMessage?.id, + previousMessageId: previousMessage?.id, + unreadIndicatorPlacement: undefined, + })} +
+ ); + })} + + ) : undefined} +
+
+ {shouldShowPassThrough + ? passThroughMessages.map((child, index) => { + const previousMessage = passThroughMessages[index - 1]; + const nextMessage = passThroughMessages[index + 1]; + const indexItem = { + type: 'none' as const, + id: child.id, + messages: undefined, + }; + + return ( +
+ {renderItem({ + containerElementRef, + containerWidthBreakpoint, + conversationId, + interactivity: MessageInteractivity.Normal, + isBlocked, + isGroup, + isOldestTimelineItem, + item: indexItem, + nextMessageId: nextMessage?.id, + previousMessageId: previousMessage?.id, + unreadIndicatorPlacement: undefined, + })} +
+ ); + }) + : undefined} +
+ ); +} + +function CollapseSetButton( + props: CollapseSet & { + count: number; + isExpanded: boolean; + isGroup: boolean; + i18n: LocalizerType; + onClick: () => unknown; + } +): React.JSX.Element { + const { count, i18n, isExpanded, onClick, type } = props; + + let leadingIcon; + let text; + + strictAssert( + type !== 'none', + "CollapseSetViewer should never render a 'none' set" + ); + + // Note: no need for labels for these icons, since they have full text descriptions + if (type === 'group-updates') { + if (props.isGroup) { + leadingIcon = ; + text = i18n('icu:collapsedGroupUpdates', { count }); + } else { + leadingIcon = ( + + ); + text = i18n('icu:collapsedChatUpdates', { count }); + } + } else if (type === 'timer-changes') { + leadingIcon = ; + if (props.endingState) { + text = i18n('icu:collapsedTimerChanges', { + count, + endingState: format(i18n, props.endingState), + }); + } else { + text = i18n('icu:collapsedTimerChanges--disabled', { + count, + }); + } + } else if (type === 'call-events') { + leadingIcon = ; + text = i18n('icu:collapsedCallEvents', { count }); + } else { + throw missingCaseError(type); + } + + const trailingIcon = isExpanded ? ( + + ) : ( + + ); + + return ( + +
+ {leadingIcon} {text} {trailingIcon} +
+
+ ); +} diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 3be0c58498..d63198528c 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -181,6 +181,8 @@ export enum MessageInteractivity { Static = 'Static', /** Enable some interactions for embedded messages (ex: PinnedMessagesPanel) */ Embed = 'Embed', + /** Hidden, like in a collapsed CollapseSet */ + Hidden = 'Hidden', } export type AudioAttachmentProps = { @@ -3465,7 +3467,7 @@ export class Message extends React.PureComponent { 'aria-checked': isSelected, 'aria-labelledby': `message-accessibility-label:${id}`, 'aria-describedby': `message-accessibility-description:${id}`, - tabIndex: 0, + tabIndex: interactivity !== MessageInteractivity.Hidden ? 0 : undefined, onClick: event => { event.preventDefault(); onToggleSelect(!isSelected, event.shiftKey); diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 4d0f0919ba..074dc5196a 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -15,11 +15,11 @@ import { ConversationHero } from './ConversationHero.dom.js'; import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.js'; import { TypingBubble } from './TypingBubble.dom.js'; import { ReadStatus } from '../../messages/MessageReadStatus.std.js'; -import type { WidthBreakpoint } from '../_util.std.js'; import { ThemeType } from '../../types/Util.std.js'; import { MessageInteractivity, TextDirection } from './Message.dom.js'; import { PaymentEventKind } from '../../types/Payment.std.js'; import type { PropsData as TimelineMessageProps } from './TimelineMessage.dom.js'; +import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; const { i18n } = window.SignalContext; @@ -349,14 +349,10 @@ const actions = () => ({ }); const renderItem = ({ - messageId, + item, containerElementRef, containerWidthBreakpoint, -}: { - messageId: string; - containerElementRef: React.RefObject; - containerWidthBreakpoint: WidthBreakpoint; -}) => ( +}: RenderItemProps) => ( undefined} getSharedGroupNames={() => []} @@ -373,10 +369,11 @@ const renderItem = ({ containerElementRef={containerElementRef} containerWidthBreakpoint={containerWidthBreakpoint} conversationId="" - item={items[messageId]} + item={items[item.id]} handleDebugMessage={action('handleDebugMessage')} renderAudioAttachment={() =>
*AudioAttachment*
} renderContact={() =>
*ContactName*
} + renderItem={renderItem} renderReactionPicker={() =>
} renderUniversalTimerNotification={() => (
*UniversalTimerNotification*
@@ -385,6 +382,7 @@ const renderItem = ({ shouldCollapseBelow={false} shouldHideMetadata={false} shouldRenderDateHeader={false} + targetedMessage={undefined} {...actions()} /> ); @@ -457,7 +455,13 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ isConversationSelected: true, isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false, isInFullScreenCall: false, - items: overrideProps.items ?? Object.keys(items), + items: + overrideProps.items ?? + Object.keys(items).map(id => ({ + type: 'none' as const, + id, + messages: undefined, + })), messageChangeCounter: 0, messageLoadingState: null, isNearBottom: null, diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index 258ee4c5ab..4b1d68a58f 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -3,7 +3,7 @@ import lodash from 'lodash'; import classNames from 'classnames'; -import type { ReactNode, RefObject, UIEvent } from 'react'; +import type { ReactNode, UIEvent } from 'react'; import React from 'react'; import { @@ -43,6 +43,8 @@ import { ScrollerLockContext, } from '../../hooks/useScrollLock.dom.js'; import { MessageInteractivity } from './Message.dom.js'; +import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; +import type { CollapseSet } from '../../state/smart/Timeline.preload.js'; const { first, get, isNumber, last, throttle } = lodash; @@ -61,7 +63,7 @@ export type PropsDataType = { messageChangeCounter: number; messageLoadingState: TimelineMessageLoadingState | null; isNearBottom: boolean | null; - items: ReadonlyArray; + items: ReadonlyArray; oldestUnseenIndex: number | null; scrollToIndex: number | null; scrollToIndexCounter: number; @@ -105,20 +107,7 @@ type PropsHousekeepingType = { props: SmartContactSpoofingReviewDialogPropsType ) => React.JSX.Element; renderHeroRow: (id: string) => React.JSX.Element; - renderItem: (props: { - containerElementRef: RefObject; - containerWidthBreakpoint: WidthBreakpoint; - conversationId: string; - interactivity: MessageInteractivity; - isBlocked: boolean; - isGroup: boolean; - isOldestTimelineItem: boolean; - messageId: string; - nextMessageId: undefined | string; - previousMessageId: undefined | string; - unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; - }) => React.JSX.Element; - + renderItem: (props: RenderItemProps) => React.JSX.Element; renderTypingBubble: (id: string) => React.JSX.Element; }; @@ -238,6 +227,12 @@ export class Timeline extends React.Component< this.#hasRecentlyScrolledTimeout = setTimeout(() => { this.setState({ hasRecentlyScrolled: false }); }, 3000); + + // Because CollapseSets might house many unseen messages, we can't wait for our + // intersection observer to tell us that a new item is now fully visible or fully not + // visible. We need to check more often to see how many messages are visible within + // a given CollapseSet. + this.#markNewestBottomVisibleMessageReadAfterDelay(); }; #scrollToItemIndex(itemIndex: number): void { @@ -259,9 +254,9 @@ export class Timeline extends React.Component< if (setFocus && items && items.length > 0) { const lastIndex = items.length - 1; - const lastMessageId = items[lastIndex]; - strictAssert(lastMessageId, 'Missing lastMessageId'); - targetMessage(lastMessageId, id); + const lastItem = items[lastIndex]; + strictAssert(lastItem, 'Missing lastItem'); + targetMessage(lastItem.id, id); } else { const containerEl = this.#containerRef.current; if (containerEl) { @@ -303,22 +298,22 @@ export class Timeline extends React.Component< if ( newestBottomVisibleMessageId && isNumber(oldestUnseenIndex) && - items.findIndex(item => item === newestBottomVisibleMessageId) < + items.findIndex(item => item.id === newestBottomVisibleMessageId) < oldestUnseenIndex ) { if (setFocus) { - const messageId = items[oldestUnseenIndex]; - strictAssert(messageId, 'Missing messageId'); - targetMessage(messageId, id); + const item = items[oldestUnseenIndex]; + strictAssert(item, 'Missing item at oldestUnseenIndex'); + targetMessage(item.id, id); } else { this.#lastSeenIndicatorRef.current?.scrollIntoView(); } } else if (haveNewest) { this.#scrollToBottom(setFocus); } else { - const lastId = last(items); - if (lastId) { - loadNewestMessages(id, lastId, setFocus); + const lastItem = last(items); + if (lastItem) { + loadNewestMessages(id, lastItem.id, setFocus); } } }; @@ -455,7 +450,7 @@ export class Timeline extends React.Component< !messageLoadingState && !haveOldest && oldestPartiallyVisibleMessageId && - oldestPartiallyVisibleMessageId === items[0] + oldestPartiallyVisibleMessageId === items[0]?.id ) { loadOlderMessages(id, oldestPartiallyVisibleMessageId); } @@ -530,13 +525,82 @@ export class Timeline extends React.Component< return centerMessageId; } - #markNewestBottomVisibleMessageRead = throttle((messageId?: string): void => { - const { id, markMessageRead } = this.props; + #markNewestBottomVisibleMessageRead = throttle((itemId?: string): void => { + const { id, items, markMessageRead } = this.props; const messageIdToMarkRead = - messageId ?? this.state.newestBottomVisibleMessageId; - if (messageIdToMarkRead) { - markMessageRead(id, messageIdToMarkRead); + itemId ?? this.state.newestBottomVisibleMessageId; + + if (!messageIdToMarkRead) { + return; } + + const lastIndex = items.length - 1; + const newestBottomVisibleItemIndex = items.findIndex( + item => item.id === messageIdToMarkRead + ); + + // Mark the newest visible message read if we're at the bottom, or override provided + if ( + messageIdToMarkRead && + (itemId || lastIndex === newestBottomVisibleItemIndex) + ) { + markMessageRead(id, messageIdToMarkRead); + return; + } + + // We can return early if the newest partially-visible item is not a CollapseSet + const newestPartiallyVisibleIndex = newestBottomVisibleItemIndex + 1; + const newestPartiallyVisibleItem = items[newestPartiallyVisibleIndex]; + if ( + newestPartiallyVisibleItem && + newestPartiallyVisibleItem.type === 'none' + ) { + markMessageRead(id, messageIdToMarkRead); + return; + } + + // Now we need to figure out which of the CollapseSet's inner messages are visible + const collapseSetEl = this.#messagesRef.current?.querySelector( + `[data-item-index="${newestPartiallyVisibleIndex}"]` + ); + const containerWindowRect = + this.#containerRef.current?.getBoundingClientRect(); + if (!collapseSetEl || !containerWindowRect) { + markMessageRead(id, messageIdToMarkRead); + return; + } + + const messageEls = collapseSetEl.querySelectorAll('[data-message-id]'); + const containerWindowBottom = + containerWindowRect.y + containerWindowRect.height; + + let newestFullyVisibleMessage; + for (let i = messageEls.length - 1; i >= 0; i -= 1) { + const messageEl = messageEls[i]; + strictAssert(messageEl, 'No messageEl at index i'); + + // The messages might be rendered, but opacity = 0 + if (!messageEl.checkVisibility({ opacityProperty: true })) { + break; + } + + // Make sure the messages are scrolled into view + const messageRect = messageEl.getBoundingClientRect(); + const bottom = messageRect.y + messageRect.height; + + if (bottom <= containerWindowBottom) { + newestFullyVisibleMessage = messageEl; + break; + } + } + + if (!newestFullyVisibleMessage) { + markMessageRead(id, messageIdToMarkRead); + return; + } + + const messageId = newestFullyVisibleMessage.getAttribute('data-message-id'); + markMessageRead(id, messageId || messageIdToMarkRead); }, 500); // When the the window becomes active, or when a fullsceen call is ended, we mark read @@ -809,63 +873,164 @@ export class Timeline extends React.Component< if ( targetedMessageId && !commandOrCtrl && - (event.key === 'ArrowUp' || event.key === 'PageUp') + (event.key === 'ArrowUp' || event.key === 'ArrowDown') ) { - const targetedMessageIndex = items.findIndex( - item => item === targetedMessageId + const direction = event.key === 'ArrowUp' ? -1 : 1; + const currentTargetIndex = items.findIndex( + item => + item.id === targetedMessageId || + item.messages?.some(message => message.id === targetedMessageId) ); - if (targetedMessageIndex < 0) { + if (currentTargetIndex < 0) { return; } - const indexIncrement = event.key === 'PageUp' ? 10 : 1; - const targetIndex = targetedMessageIndex - indexIncrement; - if (targetIndex < 0) { + const currentItem = items[currentTargetIndex]; + strictAssert(currentItem, 'No item at currentTargetIndex'); + + if (currentItem.type !== 'none') { + const innerIndex = currentItem.messages.findIndex( + message => message.id === targetedMessageId + ); + const targetIndex = innerIndex + direction; + + if (targetIndex >= 0 && targetIndex < currentItem.messages.length) { + const targetInnerMessage = currentItem.messages[targetIndex]; + strictAssert( + targetInnerMessage, + 'No message at targetIndex in items.messages' + ); + targetMessage(targetInnerMessage.id, id); + + event.preventDefault(); + event.stopPropagation(); + + return; + } + } + + const targetIndex = currentTargetIndex + direction; + if (targetIndex < 0 || targetIndex >= items.length) { return; } - const messageId = items[targetIndex]; - strictAssert(messageId, 'Missing messageId'); - targetMessage(messageId, id); + const targetItem = items[targetIndex]; + strictAssert(targetItem, 'Missing item at targetIndex'); + if (targetItem.type === 'none') { + targetMessage(targetItem.id, id); + + event.preventDefault(); + event.stopPropagation(); + return; + } + + const targetInnerMessage = + direction === -1 + ? last(targetItem.messages) + : first(targetItem.messages); + + strictAssert(targetInnerMessage, 'Expect to get first/last of target'); + targetMessage(targetInnerMessage.id, id); event.preventDefault(); event.stopPropagation(); - return; } if ( targetedMessageId && !commandOrCtrl && - (event.key === 'ArrowDown' || event.key === 'PageDown') + (event.key === 'PageUp' || event.key === 'PageDown') ) { - const targetedMessageIndex = items.findIndex( - item => item === targetedMessageId + if (!this.#containerRef.current) { + return; + } + + const direction = event.key === 'PageUp' ? -1 : 1; + const currentTargetIndex = items.findIndex( + item => + item.id === targetedMessageId || + item.messages?.some(message => message.id === targetedMessageId) ); - if (targetedMessageIndex < 0) { + if (currentTargetIndex < 0) { return; } - const indexIncrement = event.key === 'PageDown' ? 10 : 1; - const targetIndex = targetedMessageIndex + indexIncrement; - if (targetIndex >= items.length) { + const currentItem = items[currentTargetIndex]; + strictAssert(currentItem, 'No item at currentTargetIndex'); + + let startingEl = this.#containerRef.current.querySelector( + `[data-item-index='${currentTargetIndex}']` + ); + if (currentItem.type !== 'none') { + const innerIndex = currentItem.messages.findIndex( + message => message.id === targetedMessageId + ); + const message = currentItem.messages[innerIndex]; + strictAssert(message, 'No message found at innerIndex'); + + startingEl = this.#containerRef.current.querySelector( + `[data-message-id='${message.id}']` + ); + } + + if (!startingEl) { + return; + } + const allMessageList = + this.#containerRef.current.querySelectorAll('[data-message-id]'); + if (!allMessageList) { + return; + } + const allMessageEls = Array.from(allMessageList); + const startingIndex = allMessageEls.findIndex(el => el === startingEl); + if (startingIndex < 0) { return; } - const messageId = items[targetIndex]; - strictAssert(messageId, 'Missing messageId'); - targetMessage(messageId, id); + const containerRect = this.#containerRef.current.getBoundingClientRect(); + const startingRect = startingEl.getBoundingClientRect(); + const targetTop = startingRect.y - containerRect.height; + const targetBottom = startingRect.y + containerRect.height; - event.preventDefault(); - event.stopPropagation(); + let index = startingIndex + direction; + const max = allMessageEls.length; - return; + while (index >= 0 && index < max) { + const currentEl = allMessageEls[index]; + strictAssert(currentEl, 'No currentEl in allMessageEls at index'); + const currentMessageId = currentEl.getAttribute('data-message-id'); + strictAssert(currentMessageId, 'No data-message-id in currentEl'); + const currentRect = currentEl.getBoundingClientRect(); + const currentTop = currentRect.y; + + if (direction === -1) { + if (currentTop <= targetTop || index === 0) { + targetMessage(currentMessageId, id); + + event.preventDefault(); + event.stopPropagation(); + return; + } + } else { + const currentBottom = currentTop + currentRect.height; + + if (currentBottom > targetBottom || index === max - 1) { + targetMessage(currentMessageId, id); + + event.preventDefault(); + event.stopPropagation(); + return; + } + } + index += direction; + } } if (event.key === 'Home' || (commandOrCtrl && event.key === 'ArrowUp')) { const firstMessageId = first(items); if (firstMessageId) { - targetMessage(firstMessageId, id); + targetMessage(firstMessageId.id, id); event.preventDefault(); event.stopPropagation(); } @@ -926,18 +1091,19 @@ export class Timeline extends React.Component< const isGroup = conversationType === 'group'; const areThereAnyMessages = items.length > 0; const areAnyMessagesUnread = Boolean(unreadCount); + const lastItem = last(items); const areAnyMessagesBelowCurrentPosition = !haveNewest || Boolean( newestBottomVisibleMessageId && - newestBottomVisibleMessageId !== last(items) + newestBottomVisibleMessageId !== lastItem?.id ); - const areSomeMessagesBelowCurrentPosition = + const areAboveScrollDownButtonThreshold = !haveNewest || (newestBottomVisibleMessageId && !items .slice(-SCROLL_DOWN_BUTTON_THRESHOLD) - .includes(newestBottomVisibleMessageId)); + .find(item => item.id === newestBottomVisibleMessageId)); const areUnreadBelowCurrentPosition = Boolean( areThereAnyMessages && @@ -946,7 +1112,7 @@ export class Timeline extends React.Component< ); const shouldShowScrollDownButtons = Boolean( areThereAnyMessages && - (areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition) + (areUnreadBelowCurrentPosition || areAboveScrollDownButtonThreshold) ); let floatingHeader: ReactNode; @@ -968,7 +1134,7 @@ export class Timeline extends React.Component< timestamp={oldestPartiallyVisibleMessageTimestamp} visible={ (hasRecentlyScrolled || isLoadingMessages) && - (!haveOldest || oldestPartiallyVisibleMessageId !== items[0]) + (!haveOldest || oldestPartiallyVisibleMessageId !== items[0]?.id) } /> ); @@ -979,11 +1145,11 @@ export class Timeline extends React.Component< const previousItemIndex = itemIndex - 1; const nextItemIndex = itemIndex + 1; - const previousMessageId: undefined | string = items[previousItemIndex]; - const nextMessageId: undefined | string = items[nextItemIndex]; - const messageId = items[itemIndex]; + const previousItem: CollapseSet | undefined = items[previousItemIndex]; + const nextItem: CollapseSet | undefined = items[nextItemIndex]; + const item = items[itemIndex]; - if (!messageId) { + if (!item) { assertDev( false, ' iterated through items and got an empty message ID' @@ -1008,7 +1174,7 @@ export class Timeline extends React.Component< messageNodes.push(
@@ -1031,9 +1197,9 @@ export class Timeline extends React.Component< interactivity: MessageInteractivity.Normal, isGroup, isOldestTimelineItem: haveOldest && itemIndex === 0, - messageId, - nextMessageId, - previousMessageId, + item, + nextMessageId: nextItem?.id, + previousMessageId: previousItem?.id, unreadIndicatorPlacement, })} diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index 698209c5fe..e950057b21 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -48,6 +48,7 @@ const getDefaultProps = () => ({ isGroup: false, interactivity: MessageInteractivity.Normal, interactionMode: 'keyboard' as const, + targetedMessage: undefined, theme: ThemeType.light, platform: 'darwin', handleDebugMessage: action('handleDebugMessage'), @@ -104,6 +105,9 @@ const getDefaultProps = () => ({ scrollToQuotedMessage: action('scrollToQuotedMessage'), showSpoiler: action('showSpoiler'), startConversation: action('startConversation'), + renderItem: () => { + throw new Error('not implemented'); + }, returnToActiveCall: action('returnToActiveCall'), shouldCollapseAbove: false, shouldCollapseBelow: false, diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index 0365746347..bffce4172b 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -71,6 +71,10 @@ import type { MessageRequestState } from './MessageRequestActionsConfirmation.do import type { MessageInteractivity } from './Message.dom.js'; import type { PinMessageData } from '../../model-types.js'; import type { AciString } from '../../types/ServiceId.std.js'; +import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; +import type { CollapseSet } from '../../state/smart/Timeline.preload.js'; +import { CollapseSetViewer } from './CollapseSet.dom.js'; +import type { TargetedMessageType } from '../../state/selectors/conversations.dom.js'; type CallHistoryType = { type: 'callHistory'; @@ -80,6 +84,10 @@ type ChatSessionRefreshedType = { type: 'chatSessionRefreshed'; data: null; }; +type CollapseSetType = { + type: 'collapseSet'; + data: CollapseSet; +}; type DeliveryIssueType = { type: 'deliveryIssue'; data: DeliveryIssueProps; @@ -173,6 +181,7 @@ export type TimelineItemType = ( | CallHistoryType | ChangeNumberNotificationType | ChatSessionRefreshedType + | CollapseSetType | ConversationMergeNotificationType | DeliveryIssueType | GroupNotificationType @@ -220,8 +229,10 @@ type PropsLocalType = { platform: string; renderContact: SmartContactRendererType; renderUniversalTimerNotification: () => React.JSX.Element; + renderItem: (props: RenderItemProps) => React.JSX.Element; i18n: LocalizerType; interactionMode: InteractionModeType; + targetedMessage: TargetedMessageType | undefined; theme: ThemeType; }; @@ -263,6 +274,7 @@ export const TimelineItem = memo(function TimelineItem({ platform, renderUniversalTimerNotification, returnToActiveCall, + renderItem, scrollToPinnedMessage, scrollToPollMessage, targetMessage, @@ -271,6 +283,7 @@ export const TimelineItem = memo(function TimelineItem({ shouldCollapseBelow, shouldHideMetadata, shouldRenderDateHeader, + targetedMessage, theme, ...reducedProps }: PropsType): React.JSX.Element | null { @@ -304,6 +317,20 @@ export const TimelineItem = memo(function TimelineItem({ theme={theme} /> ); + } else if (item.type === 'collapseSet') { + itemContents = ( + + ); } else { let notification; diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx index 6f42cf6403..fdcde0682f 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx @@ -83,9 +83,16 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel( isBlocked: props.conversation.isBlocked ?? false, isGroup: props.conversation.type === 'group', isOldestTimelineItem: pinnedMessageIndex === 0, - messageId: pinnedMessage.messageId, + item: { + type: 'none' as const, + id: pinnedMessage.messageId, + messages: undefined, + }, nextMessageId: next?.messageId, previousMessageId: prev?.messageId, + renderItem: () => { + throw new Error('not implemented'); + }, unreadIndicatorPlacement: undefined, })} diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 9b0739b26f..ca1d068361 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -4914,7 +4914,8 @@ function onConversationOpened( | SetQuotedMessageActionType | SetViewOnceActionType > { - return async dispatch => { + return async (dispatch, getState) => { + const state = getState().conversations; const promises: Array> = []; const conversation = window.ConversationController.get(conversationId); if (!conversation) { @@ -4929,12 +4930,20 @@ function onConversationOpened( log.info(`${logId}: Updating newly opened conversation state`); + // Restore scroll position if there are no unread messages. + let lastCenterMessageId; + if (conversation.get('unreadCount') === 0) { + lastCenterMessageId = + state.lastCenterMessageByConversation[conversationId]; + } + const targetMessageId = messageId ?? lastCenterMessageId; + let isMessageTargeted = false; - if (messageId) { - isMessageTargeted = Boolean(await getMessageById(messageId)); + if (targetMessageId) { + isMessageTargeted = Boolean(await getMessageById(targetMessageId)); if (isMessageTargeted) { - drop(conversation.loadAndScroll(messageId)); + drop(conversation.loadAndScroll(targetMessageId)); } else { log.warn(`${logId}: Did not find message ${messageId}`); } @@ -6858,23 +6867,7 @@ export function reducer( if (action.type === TARGETED_CONVERSATION_CHANGED) { const { payload } = action; const { conversationId, messageId, switchToAssociatedView } = payload; - - let conversation: ConversationType | undefined; - let lastCenterMessageId: string | undefined; - - if (conversationId) { - conversation = getOwn(state.conversationLookup, conversationId); - if (!conversation) { - log.error(`Unknown conversation selected, id: [${conversationId}]`); - return state; - } - - // Restore scroll position if there are no unread messages. - if (conversation.unreadCount === 0) { - lastCenterMessageId = - state.lastCenterMessageByConversation[conversationId]; - } - } + const { conversationLookup } = state; const nextState: ConversationsStateType = { ...state, @@ -6883,12 +6876,15 @@ export function reducer( ? state.preloadData : undefined, hasContactSpoofingReview: false, - targetedMessage: messageId ?? lastCenterMessageId, + targetedMessage: messageId, targetedMessageSource: messageId ? TargetedMessageSource.NavigateToMessage : TargetedMessageSource.Reset, }; + const conversation = conversationId + ? conversationLookup[conversationId] + : undefined; if (switchToAssociatedView && conversation) { return { ...omit(nextState, 'composer', 'selectedMessageIds'), diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 4d2ac2f5b7..238b131e02 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -201,7 +201,7 @@ export const getSafeConversationWithSameTitle = createSelector( } ); -type TargetedMessageType = { +export type TargetedMessageType = { id: string; counter: number; }; @@ -1223,9 +1223,12 @@ export const getContactNameColor = ( return color; }; +type TimelinePropsWithRawItems = Omit & { + items: ReadonlyArray; +}; export function _conversationMessagesSelector( conversation: ConversationMessageType -): TimelinePropsType { +): TimelinePropsWithRawItems { const { isNearBottom = null, messageChangeCounter, @@ -1276,7 +1279,7 @@ export function _conversationMessagesSelector( type CachedConversationMessagesSelectorType = ( conversation: ConversationMessageType -) => TimelinePropsType; +) => TimelinePropsWithRawItems; export const getCachedSelectorForConversationMessages = createSelector( getRegionCode, getUserNumber, @@ -1294,7 +1297,7 @@ export const getConversationMessagesSelector = createSelector( conversationMessagesSelector: CachedConversationMessagesSelectorType, messagesByConversation: MessagesByConversationType ) => { - return (id: string): TimelinePropsType => { + return (id: string): TimelinePropsWithRawItems => { const conversation = messagesByConversation[id]; if (!conversation) { // TODO: DESKTOP-2340 diff --git a/ts/state/smart/ChatsTab.preload.tsx b/ts/state/smart/ChatsTab.preload.tsx index 09820fe417..05bd3bb987 100644 --- a/ts/state/smart/ChatsTab.preload.tsx +++ b/ts/state/smart/ChatsTab.preload.tsx @@ -84,7 +84,7 @@ export const SmartChatsTab = memo(function SmartChatsTab() { } else if ( selectedConversationId && targetedMessageId && - targetedMessageSource !== TargetedMessageSource.Focus + targetedMessageSource === TargetedMessageSource.NavigateToMessage ) { scrollToMessage(selectedConversationId, targetedMessageId); } diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx index 9b27ad0349..98d56130d9 100644 --- a/ts/state/smart/Timeline.preload.tsx +++ b/ts/state/smart/Timeline.preload.tsx @@ -3,6 +3,8 @@ import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { isEqual, last } from 'lodash'; + import { Timeline } from '../../components/conversation/Timeline.dom.js'; import { useCallingActions } from '../ducks/calling.preload.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; @@ -17,48 +19,68 @@ import { } from '../selectors/conversations.dom.js'; import { getSelectedConversationId } from '../selectors/nav.std.js'; import { getIntl, getTheme } from '../selectors/user.std.js'; -import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog.preload.js'; import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog.preload.js'; import { SmartHeroRow } from './HeroRow.preload.js'; -import { - SmartTimelineItem, - type SmartTimelineItemProps, -} from './TimelineItem.preload.js'; +import { SmartTimelineItem } from './TimelineItem.preload.js'; import { SmartTypingBubble } from './TypingBubble.preload.js'; import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager.preload.js'; -import { isInFullScreenCall as getIsInFullScreenCall } from '../selectors/calling.std.js'; +import { + getActiveCall, + getCallSelector, + isInFullScreenCall as getIsInFullScreenCall, +} from '../selectors/calling.std.js'; +import type { CallStateType } from '../selectors/calling.std.js'; +import { getMidnight } from '../../types/NotificationProfile.std.js'; +import { strictAssert } from '../../util/assert.std.js'; +import { missingCaseError } from '../../util/missingCaseError.std.js'; + +import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog.preload.js'; +import type { RenderItemProps } from './TimelineItem.preload.js'; +import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js'; +import type { MessageType } from '../ducks/conversations.preload.js'; +import { SeenStatus } from '../../MessageSeenStatus.std.js'; +import { getCallHistorySelector } from '../selectors/callHistory.std.js'; +import type { CallHistorySelectorType } from '../selectors/callHistory.std.js'; +import { CallMode } from '../../types/CallDisposition.std.js'; +import { getCallIdFromEra } from '../../util/callDisposition.preload.js'; type ExternalProps = { id: string; }; -function renderItem({ - containerElementRef, - containerWidthBreakpoint, - conversationId, - interactivity, - isBlocked, - isGroup, - isOldestTimelineItem, - messageId, - nextMessageId, - previousMessageId, - unreadIndicatorPlacement, -}: SmartTimelineItemProps): React.JSX.Element { +export type CollapsedMessage = { + id: string; + isUnseen: boolean; + // A single group-v2-change message can have more than one change in it + extraItems?: number; +}; + +export type CollapseSet = + | { + type: 'none'; + id: string; + messages: undefined; + } + | { + type: 'group-updates'; + id: string; + messages: Array; + } + | { + type: 'timer-changes'; + id: string; + messages: Array; + endingState: DurationInSeconds | undefined; + } + | { + type: 'call-events'; + id: string; + messages: Array; + }; + +function renderItem(props: RenderItemProps): React.JSX.Element { return ( - + ); } @@ -75,6 +97,87 @@ function renderTypingBubble(conversationId: string): React.JSX.Element { return ; } +function canCollapseForGroupSet(type: MessageType['type']): boolean { + if ( + type === 'group-v2-change' || + type === 'keychange' || + type === 'profile-change' + ) { + return true; + } + + return false; +} + +function canCollapseForTimerSet(message: MessageType): boolean { + if (message.type === 'timer-notification') { + return true; + } + + // Found some examples of messages with type = 'incoming' and an expirationTimerUpdate + if (message.expirationTimerUpdate) { + return true; + } + + return false; +} + +function canCollapseForCallSet( + message: MessageType, + options: { + activeCall: CallStateType | undefined; + callHistorySelector: CallHistorySelectorType; + callSelector: (conversationId: string) => CallStateType | undefined; + } +): boolean { + if (message.type !== 'call-history') { + return false; + } + + const { callId, conversationId } = message; + if (!callId) { + return true; + } + + const callHistory = options.callHistorySelector(callId); + if (!callHistory) { + return true; + } + + // If a direct call is currently ongoing, we don't want to group it + if (callHistory.mode === CallMode.Direct) { + const isActiveCall = options.activeCall?.conversationId === conversationId; + + return !isActiveCall; + } + + const conversationCall = options.callSelector(conversationId); + if (!conversationCall) { + return true; + } + + strictAssert( + conversationCall?.callMode === CallMode.Group, + 'canCollapseForCallSet: Call was expected to be a group call' + ); + + const conversationCallId = + conversationCall?.peekInfo?.eraId != null && + getCallIdFromEra(conversationCall.peekInfo.eraId); + + const deviceCount = conversationCall?.peekInfo?.deviceCount ?? 0; + + // Don't group if current call in the converasation, or there are devices in the call + if ( + callHistory.mode === CallMode.Group && + (callId === conversationCallId || deviceCount > 0) + ) { + return false; + } + + return true; +} + export const SmartTimeline = memo(function SmartTimeline({ id, }: ExternalProps) { @@ -95,6 +198,9 @@ export const SmartTimeline = memo(function SmartTimeline({ const isInFullScreenCall = useSelector(getIsInFullScreenCall); const conversation = conversationSelector(id); const conversationMessages = conversationMessagesSelector(id); + const callHistorySelector = useSelector(getCallHistorySelector); + const activeCall = useSelector(getActiveCall); + const callSelector = useSelector(getCallSelector); const { clearInvitedServiceIdsForNewlyCreatedGroup, @@ -142,6 +248,269 @@ export const SmartTimeline = memo(function SmartTimeline({ totalUnseen, } = conversationMessages; + const previousCollapseSet = React.useRef | undefined>( + undefined + ); + const { collapseSets, updatedOldestLastSeenIndex, updatedScrollToIndex } = + React.useMemo(() => { + let resultSets: Array = []; + let resultUnseenIndex = oldestUnseenIndex; + let resultScrollToIndex = scrollToIndex; + + const max = items.length; + + for (let i = 0; i < max; i += 1) { + const previousId = items[i - 1]; + const lastCollapseSet = last(resultSets); + + const currentId = items[i]; + strictAssert(currentId, 'no item at index i'); + + const currentMessage = messages[currentId]; + const previousMessage = previousId ? messages[previousId] : undefined; + + const changeLength = currentMessage?.groupV2Change?.details.length; + const extraItems = + changeLength && changeLength > 1 ? changeLength - 1 : undefined; + + const DEFAULT_SET: CollapseSet = + currentMessage && + canCollapseForGroupSet(currentMessage.type) && + extraItems && + extraItems > 0 + ? { + // A group-v2-change message with more than one inner change detail can be + // a set all by itself! + type: 'group-updates', + id: currentId, + messages: [ + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }, + ], + } + : { + type: 'none' as const, + id: currentId, + messages: undefined, + }; + + // scrollToIndex needs to be translated to collapseSets. + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length; + } + + // Start a new group if we just crossed the last seen indicator + if (i === oldestUnseenIndex) { + resultSets.push(DEFAULT_SET); + resultUnseenIndex = resultSets.length - 1; + continue; + } + + // Start a new set if we just started looping + if (!previousId) { + resultSets.push(DEFAULT_SET); + continue; + } + + // Start a new set if we can't find message details + if (!currentMessage || !previousMessage) { + resultSets.push(DEFAULT_SET); + continue; + } + + // Start a new set if previous message and current message are on different days + const currentDay = getMidnight( + currentMessage.received_at_ms || currentMessage.timestamp + ); + const previousDay = getMidnight( + previousMessage.received_at_ms || previousMessage.timestamp + ); + if (currentDay !== previousDay) { + resultSets.push(DEFAULT_SET); + continue; + } + + strictAssert( + lastCollapseSet, + 'collapseSets: expect lastCollapseSet to be defined' + ); + + // Add to current set if previous and current messages are both group updates + if ( + canCollapseForGroupSet(currentMessage.type) && + canCollapseForGroupSet(previousMessage.type) + ) { + strictAssert( + lastCollapseSet.type !== 'timer-changes' && + lastCollapseSet.type !== 'call-events', + 'Should never have two matching group items, but be in a timer or call set' + ); + + if (lastCollapseSet.type === 'group-updates') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }); + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'group-updates', + id: previousId, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + + continue; + } + + // Add to current set if previous and current messages are both timer updates + if ( + canCollapseForTimerSet(currentMessage) && + canCollapseForTimerSet(previousMessage) + ) { + strictAssert( + lastCollapseSet.type !== 'group-updates' && + lastCollapseSet.type !== 'call-events', + 'Should never have two matching timer items, but be in a group or call set' + ); + + if (lastCollapseSet.type === 'timer-changes') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }); + lastCollapseSet.endingState = + currentMessage.expirationTimerUpdate?.expireTimer; + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'timer-changes', + id: previousId, + endingState: currentMessage.expirationTimerUpdate?.expireTimer, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + + continue; + } + + // Add to current set if previous and current messages are both call events + if ( + canCollapseForCallSet(currentMessage, { + activeCall, + callHistorySelector, + callSelector, + }) && + canCollapseForCallSet(previousMessage, { + activeCall, + callHistorySelector, + callSelector, + }) + ) { + strictAssert( + lastCollapseSet.type !== 'group-updates' && + lastCollapseSet.type !== 'timer-changes', + 'Should never have two matching timer items, but be in a group or timer set' + ); + + if (lastCollapseSet.type === 'call-events') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }); + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'call-events', + id: previousId, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + + continue; + } + + // Finally, just add a new empty set if no situations above triggered + resultSets.push(DEFAULT_SET); + } + + // Params messages changes a lot, items less often, call selectors possibly a lot. + // But we need to massage items based on the values from these params. So, if we + // generate the same data, we would like to return the same object. + if ( + previousCollapseSet.current && + isEqual(resultSets, previousCollapseSet.current) + ) { + resultSets = previousCollapseSet.current; + } + + previousCollapseSet.current = resultSets; + + return { + collapseSets: resultSets, + updatedOldestLastSeenIndex: resultUnseenIndex, + updatedScrollToIndex: resultScrollToIndex, + }; + }, [ + activeCall, + callHistorySelector, + callSelector, + items, + messages, + oldestUnseenIndex, + scrollToIndex, + ]); + const isConversationSelected = selectedConversationId === id; const isIncomingMessageRequest = !acceptedMessageRequest && removalStage !== 'justNotification'; @@ -172,7 +541,7 @@ export const SmartTimeline = memo(function SmartTimeline({ isIncomingMessageRequest={isIncomingMessageRequest} isNearBottom={isNearBottom} isSomeoneTyping={isSomeoneTyping} - items={items} + items={collapseSets} loadNewerMessages={loadNewerMessages} loadNewestMessages={loadNewestMessages} loadOlderMessages={loadOlderMessages} @@ -183,12 +552,12 @@ export const SmartTimeline = memo(function SmartTimeline({ updateVisibleMessages={ AttachmentDownloadManager.updateVisibleTimelineMessages } - oldestUnseenIndex={oldestUnseenIndex} + oldestUnseenIndex={updatedOldestLastSeenIndex} renderContactSpoofingReviewDialog={renderContactSpoofingReviewDialog} renderHeroRow={renderHeroRow} renderItem={renderItem} renderTypingBubble={renderTypingBubble} - scrollToIndex={scrollToIndex} + scrollToIndex={updatedScrollToIndex} scrollToIndexCounter={scrollToIndexCounter} scrollToOldestUnreadMention={scrollToOldestUnreadMention} setCenterMessage={setCenterMessage} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 5d639b3517..62074f255c 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -41,10 +41,13 @@ import { renderAudioAttachment } from './renderAudioAttachment.preload.js'; import { renderReactionPicker } from './renderReactionPicker.dom.js'; import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation.dom.js'; import { TargetedMessageSource } from '../ducks/conversationsEnums.std.js'; -import type { MessageInteractivity } from '../../components/conversation/Message.dom.js'; +import { MessageInteractivity } from '../../components/conversation/Message.dom.js'; import { useNavActions } from '../ducks/nav.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import { isInternalFeaturesEnabled } from '../../util/isInternalFeaturesEnabled.dom.js'; +import type { CollapseSet } from './Timeline.preload.js'; + +export type RenderItemProps = Omit; export type SmartTimelineItemProps = { containerElementRef: RefObject; @@ -54,9 +57,10 @@ export type SmartTimelineItemProps = { isBlocked: boolean; isGroup: boolean; isOldestTimelineItem: boolean; - messageId: string; + item: CollapseSet; nextMessageId: undefined | string; previousMessageId: undefined | string; + renderItem: (props: RenderItemProps) => React.JSX.Element; unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; }; @@ -78,55 +82,73 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( isBlocked, isGroup, isOldestTimelineItem, - messageId, + item, nextMessageId, previousMessageId, + renderItem, unreadIndicatorPlacement, } = props; + const messageId = item.id; const i18n = useSelector(getIntl); const getPreferredBadge = useSelector(getPreferredBadgeSelector); const interactionMode = useSelector(getInteractionMode); const theme = useSelector(getTheme); const platform = useSelector(getPlatform); - const item = useTimelineItem(messageId, conversationId); + const itemFromSelector = useTimelineItem(messageId, conversationId); const previousItem = useTimelineItem(previousMessageId, conversationId); const nextItem = useTimelineItem(nextMessageId, conversationId); const targetedMessage = useSelector(getTargetedMessage); const targetedMessageSource = useSelector(getTargetedMessageSource); const isTargeted = Boolean( + interactivity !== MessageInteractivity.Hidden && targetedMessage && messageId === targetedMessage.id && targetedMessageSource !== TargetedMessageSource.Reset ); 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 shouldCollapseAbove = + item.type === 'none' && + areMessagesInSameGroup( + previousItem, + unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove, + itemFromSelector + ); + const shouldCollapseBelow = + item.type === 'none' && + areMessagesInSameGroup( + itemFromSelector, + unreadIndicatorPlacement === UnreadIndicatorPlacement.JustBelow, + nextItem + ); + const shouldHideMetadata = + item.type === 'none' && + shouldCurrentMessageHideMetadata( + shouldCollapseBelow, + itemFromSelector, + nextItem + ); const shouldRenderDateHeader = isOldestTimelineItem || Boolean( - item && + itemFromSelector && previousItem && // This comparison avoids strange header behavior for out-of-order messages. - item.timestamp > previousItem.timestamp && - !isSameDay(previousItem.timestamp, item.timestamp) + itemFromSelector.timestamp > previousItem.timestamp && + !isSameDay(previousItem.timestamp, itemFromSelector.timestamp) ); + const processedTimelineItem = + item.type !== 'none' && itemFromSelector + ? { + type: 'collapseSet' as const, + data: item, + timestamp: itemFromSelector.timestamp, + } + : itemFromSelector; + const { blockGroupLinkRequests, cancelAttachmentDownload, @@ -213,7 +235,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( return ( utilities', () => { }); describe('getScrollAnchorBeforeUpdate', () => { - const fakeItems = (count: number) => times(count, () => uuid()); + const fakeItems = (count: number) => + times(count, () => ({ + type: 'none' as const, + id: uuid(), + messages: undefined, + })); const defaultProps = { haveNewest: true, @@ -400,7 +405,13 @@ describe(' utilities', () => { describe('when a new message comes in', () => { const oldItems = fakeItems(5); const prevProps = { ...defaultProps, items: oldItems }; - const props = { ...defaultProps, items: [...oldItems, uuid()] }; + const props = { + ...defaultProps, + items: [ + ...oldItems, + { type: 'none' as const, id: uuid(), messages: undefined }, + ], + }; it('does nothing if not scrolled to the bottom', () => { const isAtBottom = false;