Collapse already-seen sets of timeline items

This commit is contained in:
Scott Nonnenberg
2026-03-21 02:58:24 +10:00
committed by GitHub
parent 3f34ef9693
commit 27ad6f3294
17 changed files with 1422 additions and 169 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Props>;
function renderItem({ item }: RenderItemProps) {
return (
<div className={tw('py-2.5 text-center')}>
Message {item.id} - <a href="https://signal.org">Use Signal</a>
</div>
);
}
const defaultProps: Props = {
containerElementRef: React.createRef<HTMLElement | null>(),
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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
export function GroupWithOneThatHasExtra(): React.JSX.Element {
const props: Props = {
...defaultProps,
type: 'group-updates',
messages: [{ id: 'id1 (with one extra)', isUnseen: false, extraItems: 1 }],
};
return <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
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 (
<div>
<div className={tw('py-2.5 text-center')}>
Message id0 - <a href="https://signal.org">Use Signal</a>
</div>
<CollapseSetViewer {...props} />
<div className={tw('py-2.5 text-center')}>
Message id11 - <a href="https://signal.org">Use Signal</a>
</div>
</div>
);
}
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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
export function GroupWithTwoOneUnseen(): React.JSX.Element {
const props: Props = {
...defaultProps,
type: 'group-updates',
messages: [
{ id: 'id1', isUnseen: false },
{ id: 'id2', isUnseen: true },
],
};
return <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
export function GroupWithWithUpdateAfterDelay(): React.JSX.Element {
const [props, setProps] = React.useState<Props>({
...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 <CollapseSetViewer {...props} />;
}

View File

@@ -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<HTMLElement | null>;
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<string, CollapsedMessage>
>({});
const previousTargetedMessage = useRef<TargetedMessageType>(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<HTMLDivElement>(null);
return (
<div>
{shouldShowButton ? (
<div className={tw('my-2.5 text-center')}>
<CollapseSetButton
{...props}
count={collapsedCount}
isExpanded={isExpanded}
onClick={() => {
setIsExpanded(value => !value);
}}
/>
</div>
) : undefined}
<div
className={classNames(
'CollapseSet__height-container',
isExpanded ? 'CollapseSet__height-container--expanded' : undefined
)}
style={{
maxHeight: isExpanded
? `${transparencyRef.current?.clientHeight ?? 5000}px`
: undefined,
}}
>
<div
ref={transparencyRef}
onTransitionEnd={() => {
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 (
<div
data-message-id={child.id}
role="listitem"
key={child.id}
>
{renderItem({
containerElementRef,
containerWidthBreakpoint,
conversationId,
interactivity: isExpanded
? MessageInteractivity.Normal
: MessageInteractivity.Hidden,
isBlocked,
isGroup,
isOldestTimelineItem,
item: indexItem,
nextMessageId: nextMessage?.id,
previousMessageId: previousMessage?.id,
unreadIndicatorPlacement: undefined,
})}
</div>
);
})}
</>
) : undefined}
</div>
</div>
{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 (
<div data-message-id={child.id} role="listitem" key={child.id}>
{renderItem({
containerElementRef,
containerWidthBreakpoint,
conversationId,
interactivity: MessageInteractivity.Normal,
isBlocked,
isGroup,
isOldestTimelineItem,
item: indexItem,
nextMessageId: nextMessage?.id,
previousMessageId: previousMessage?.id,
unreadIndicatorPlacement: undefined,
})}
</div>
);
})
: undefined}
</div>
);
}
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 = <AxoSymbol.InlineGlyph symbol="group" label={null} />;
text = i18n('icu:collapsedGroupUpdates', { count });
} else {
leadingIcon = (
<AxoSymbol.InlineGlyph symbol="message-thread" label={null} />
);
text = i18n('icu:collapsedChatUpdates', { count });
}
} else if (type === 'timer-changes') {
leadingIcon = <AxoSymbol.InlineGlyph symbol="timer" label={null} />;
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 = <AxoSymbol.InlineGlyph symbol="phone" label={null} />;
text = i18n('icu:collapsedCallEvents', { count });
} else {
throw missingCaseError(type);
}
const trailingIcon = isExpanded ? (
<AxoSymbol.InlineGlyph
symbol="chevron-up"
label={i18n('icu:collapsedItems--expanded')}
/>
) : (
<AxoSymbol.InlineGlyph
symbol="chevron-down"
label={i18n('icu:collapsedItems--collapsed')}
/>
);
return (
<AxoButton.Root size="lg" variant="secondary" onClick={onClick}>
<div className={tw('font-semibold text-label-secondary')}>
{leadingIcon} {text} {trailingIcon}
</div>
</AxoButton.Root>
);
}

View File

@@ -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<Props, State> {
'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);

View File

@@ -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<HTMLElement | null>;
containerWidthBreakpoint: WidthBreakpoint;
}) => (
}: RenderItemProps) => (
<TimelineItem
getPreferredBadge={() => undefined}
getSharedGroupNames={() => []}
@@ -373,10 +369,11 @@ const renderItem = ({
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
conversationId=""
item={items[messageId]}
item={items[item.id]}
handleDebugMessage={action('handleDebugMessage')}
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
renderContact={() => <div>*ContactName*</div>}
renderItem={renderItem}
renderReactionPicker={() => <div />}
renderUniversalTimerNotification={() => (
<div>*UniversalTimerNotification*</div>
@@ -385,6 +382,7 @@ const renderItem = ({
shouldCollapseBelow={false}
shouldHideMetadata={false}
shouldRenderDateHeader={false}
targetedMessage={undefined}
{...actions()}
/>
);
@@ -457,7 +455,13 @@ const useProps = (overrideProps: Partial<PropsType> = {}): 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,

View File

@@ -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<string>;
items: ReadonlyArray<CollapseSet>;
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<HTMLElement | null>;
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,
'<Timeline> iterated through items and got an empty message ID'
@@ -1008,7 +1174,7 @@ export class Timeline extends React.Component<
messageNodes.push(
<div
key={messageId}
key={item.id}
className={
itemIndex === items.length - 1
? 'module-timeline__last-message'
@@ -1019,7 +1185,7 @@ export class Timeline extends React.Component<
(!oldestUnseenIndex && itemIndex === items.length - 1)
}
data-item-index={itemIndex}
data-message-id={messageId}
data-message-id={item.id}
role="listitem"
>
<ErrorBoundary i18n={i18n} showDebugLog={showDebugLog}>
@@ -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,
})}
</ErrorBoundary>

View File

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

View File

@@ -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<React.JSX.Element>;
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 = (
<CollapseSetViewer
{...item.data}
containerElementRef={containerElementRef}
containerWidthBreakpoint={reducedProps.containerWidthBreakpoint}
conversationId={conversationId}
isBlocked={isBlocked}
isGroup={isGroup}
renderItem={renderItem}
targetedMessage={targetedMessage}
i18n={i18n}
/>
);
} else {
let notification;

View File

@@ -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,
})}
</Fragment>

View File

@@ -4914,7 +4914,8 @@ function onConversationOpened(
| SetQuotedMessageActionType
| SetViewOnceActionType
> {
return async dispatch => {
return async (dispatch, getState) => {
const state = getState().conversations;
const promises: Array<Promise<void>> = [];
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'),

View File

@@ -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<TimelinePropsType, 'items'> & {
items: ReadonlyArray<string>;
};
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

View File

@@ -84,7 +84,7 @@ export const SmartChatsTab = memo(function SmartChatsTab() {
} else if (
selectedConversationId &&
targetedMessageId &&
targetedMessageSource !== TargetedMessageSource.Focus
targetedMessageSource === TargetedMessageSource.NavigateToMessage
) {
scrollToMessage(selectedConversationId, targetedMessageId);
}

View File

@@ -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<CollapsedMessage>;
}
| {
type: 'timer-changes';
id: string;
messages: Array<CollapsedMessage>;
endingState: DurationInSeconds | undefined;
}
| {
type: 'call-events';
id: string;
messages: Array<CollapsedMessage>;
};
function renderItem(props: RenderItemProps): React.JSX.Element {
return (
<SmartTimelineItem
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
conversationId={conversationId}
interactivity={interactivity}
isBlocked={isBlocked}
isGroup={isGroup}
isOldestTimelineItem={isOldestTimelineItem}
messageId={messageId}
previousMessageId={previousMessageId}
nextMessageId={nextMessageId}
unreadIndicatorPlacement={unreadIndicatorPlacement}
/>
<SmartTimelineItem key={props.item.id} {...props} renderItem={renderItem} />
);
}
@@ -75,6 +97,87 @@ function renderTypingBubble(conversationId: string): React.JSX.Element {
return <SmartTypingBubble conversationId={conversationId} />;
}
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<Array<CollapseSet> | undefined>(
undefined
);
const { collapseSets, updatedOldestLastSeenIndex, updatedScrollToIndex } =
React.useMemo(() => {
let resultSets: Array<CollapseSet> = [];
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}

View File

@@ -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<SmartTimelineItemProps, 'renderItem'>;
export type SmartTimelineItemProps = {
containerElementRef: RefObject<HTMLElement | null>;
@@ -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 (
<TimelineItem
item={item}
item={processedTimelineItem}
id={messageId}
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
@@ -264,12 +286,14 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
sendPollVote={sendPollVote}
renderItem={renderItem}
returnToActiveCall={returnToActiveCall}
saveAttachment={saveAttachment}
saveAttachments={saveAttachments}
scrollToPollMessage={scrollToPollMessage}
scrollToQuotedMessage={scrollToQuotedMessage}
targetMessage={targetMessage}
targetedMessage={targetedMessage}
setQuoteByMessageId={setQuoteByMessageId}
setMessageToEdit={setMessageToEdit}
showContactModal={showContactModal}

View File

@@ -210,7 +210,12 @@ describe('<Timeline> 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('<Timeline> 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;