mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-01 15:57:42 +01:00
Collapse already-seen sets of timeline items
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
248
ts/components/conversation/CollapseSet.dom.stories.tsx
Normal file
248
ts/components/conversation/CollapseSet.dom.stories.tsx
Normal 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} />;
|
||||
}
|
||||
323
ts/components/conversation/CollapseSet.dom.tsx
Normal file
323
ts/components/conversation/CollapseSet.dom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@ export const SmartChatsTab = memo(function SmartChatsTab() {
|
||||
} else if (
|
||||
selectedConversationId &&
|
||||
targetedMessageId &&
|
||||
targetedMessageSource !== TargetedMessageSource.Focus
|
||||
targetedMessageSource === TargetedMessageSource.NavigateToMessage
|
||||
) {
|
||||
scrollToMessage(selectedConversationId, targetedMessageId);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user