Group disparate status together, but show metadata if different

This commit is contained in:
Scott Nonnenberg 2022-03-28 15:55:12 -07:00 committed by GitHub
parent 2602db97f0
commit 1ad284d22c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 282 additions and 146 deletions

View file

@ -18,8 +18,8 @@ const story = storiesOf('Components/Conversation/CallingNotification', module);
const getCommonProps = () => ({ const getCommonProps = () => ({
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
i18n, i18n,
isNextItemCallingNotification: false,
messageId: 'fake-message-id', messageId: 'fake-message-id',
nextItem: undefined,
now: Date.now(), now: Date.now(),
returnToActiveCall: action('returnToActiveCall'), returnToActiveCall: action('returnToActiveCall'),
startCallingLobby: action('startCallingLobby'), startCallingLobby: action('startCallingLobby'),
@ -70,7 +70,7 @@ story.add('Two incoming direct calls back-to-back', () => {
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps()}
{...call1} {...call1}
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }} isNextItemCallingNotification
/> />
<CallingNotification {...getCommonProps()} {...call2} /> <CallingNotification {...getCommonProps()} {...call2} />
</> </>
@ -99,7 +99,7 @@ story.add('Two outgoing direct calls back-to-back', () => {
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps()}
{...call1} {...call1}
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }} isNextItemCallingNotification
/> />
<CallingNotification {...getCommonProps()} {...call2} /> <CallingNotification {...getCommonProps()} {...call2} />
</> </>

View file

@ -17,7 +17,6 @@ import {
} from '../../util/callingNotification'; } from '../../util/callingNotification';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { Tooltip, TooltipPlacement } from '../Tooltip'; import { Tooltip, TooltipPlacement } from '../Tooltip';
import type { TimelineItemType } from './TimelineItem';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
export type PropsActionsType = { export type PropsActionsType = {
@ -31,7 +30,7 @@ export type PropsActionsType = {
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
conversationId: string; conversationId: string;
nextItem: undefined | TimelineItemType; isNextItemCallingNotification: boolean;
}; };
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
@ -86,12 +85,12 @@ function renderCallingNotificationButton(
activeCallConversationId, activeCallConversationId,
conversationId, conversationId,
i18n, i18n,
nextItem, isNextItemCallingNotification,
returnToActiveCall, returnToActiveCall,
startCallingLobby, startCallingLobby,
} = props; } = props;
if (nextItem?.type === 'callHistory') { if (isNextItemCallingNotification) {
return null; return null;
} }

View file

@ -171,6 +171,15 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'), scrollToQuotedMessage: action('scrollToQuotedMessage'),
selectMessage: action('selectMessage'), selectMessage: action('selectMessage'),
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
? overrideProps.shouldCollapseAbove
: false,
shouldCollapseBelow: isBoolean(overrideProps.shouldCollapseBelow)
? overrideProps.shouldCollapseBelow
: false,
shouldHideMetadata: isBoolean(overrideProps.shouldHideMetadata)
? overrideProps.shouldHideMetadata
: false,
showContactDetail: action('showContactDetail'), showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(
@ -202,9 +211,9 @@ const renderMany = (propsArray: ReadonlyArray<Props>) =>
<Message <Message
key={message.text} key={message.text}
{...message} {...message}
previousItem={createTimelineItem(propsArray[index - 1])} shouldCollapseAbove={Boolean(propsArray[index - 1])}
item={createTimelineItem(message)} item={createTimelineItem(message)}
nextItem={createTimelineItem(propsArray[index + 1])} shouldCollapseBelow={Boolean(propsArray[index + 1])}
/> />
)); ));

View file

@ -83,10 +83,6 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import { offsetDistanceModifier } from '../../util/popperUtil'; import { offsetDistanceModifier } from '../../util/popperUtil';
import * as KeyboardLayout from '../../services/keyboardLayout'; import * as KeyboardLayout from '../../services/keyboardLayout';
import { StopPropagation } from '../StopPropagation'; import { StopPropagation } from '../StopPropagation';
import {
areMessagesInSameGroup,
UnreadIndicatorPlacement,
} from '../../util/timelineUtil';
type Trigger = { type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void; handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -269,14 +265,14 @@ export type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
item?: TimelineItemType; item?: TimelineItemType;
nextItem?: TimelineItemType;
previousItem?: TimelineItemType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderReactionPicker: ( renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker> props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element; ) => JSX.Element;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
shouldHideMetadata: boolean;
theme: ThemeType; theme: ThemeType;
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
}; };
export type PropsActions = { export type PropsActions = {
@ -554,6 +550,7 @@ export class Message extends React.PureComponent<Props, State> {
attachments, attachments,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
shouldHideMetadata,
status, status,
text, text,
textDirection, textDirection,
@ -565,7 +562,7 @@ export class Message extends React.PureComponent<Props, State> {
!expirationLength && !expirationLength &&
!expirationTimestamp && !expirationTimestamp &&
(!status || SENT_STATUSES.has(status)) && (!status || SENT_STATUSES.has(status)) &&
this.isCollapsedBelow() shouldHideMetadata
) { ) {
return MetadataPlacement.NotRendered; return MetadataPlacement.NotRendered;
} }
@ -688,34 +685,14 @@ export class Message extends React.PureComponent<Props, State> {
return isMessageRequestAccepted && !isBlocked; return isMessageRequestAccepted && !isBlocked;
} }
private isCollapsedAbove(
{ item, previousItem, unreadIndicatorPlacement }: Readonly<Props> = this
.props
): boolean {
return areMessagesInSameGroup(
previousItem,
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove,
item
);
}
private isCollapsedBelow(
{ item, nextItem, unreadIndicatorPlacement }: Readonly<Props> = this.props
): boolean {
return areMessagesInSameGroup(
item,
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustBelow,
nextItem
);
}
private shouldRenderAuthor(): boolean { private shouldRenderAuthor(): boolean {
const { author, conversationType, direction } = this.props; const { author, conversationType, direction, shouldCollapseAbove } =
this.props;
return Boolean( return Boolean(
direction === 'incoming' && direction === 'incoming' &&
conversationType === 'group' && conversationType === 'group' &&
author.title && author.title &&
!this.isCollapsedAbove() !shouldCollapseAbove
); );
} }
@ -850,6 +827,8 @@ export class Message extends React.PureComponent<Props, State> {
renderingContext, renderingContext,
showMessageDetail, showMessageDetail,
showVisualAttachment, showVisualAttachment,
shouldCollapseAbove,
shouldCollapseBelow,
status, status,
text, text,
textPending, textPending,
@ -925,10 +904,10 @@ export class Message extends React.PureComponent<Props, State> {
<ImageGrid <ImageGrid
attachments={attachments} attachments={attachments}
withContentAbove={ withContentAbove={
isSticker || withContentAbove || this.isCollapsedAbove() isSticker || withContentAbove || shouldCollapseAbove
} }
withContentBelow={ withContentBelow={
isSticker || withContentBelow || this.isCollapsedBelow() isSticker || withContentBelow || shouldCollapseBelow
} }
isSticker={isSticker} isSticker={isSticker}
stickerSize={STICKER_SIZE} stickerSize={STICKER_SIZE}
@ -1223,6 +1202,7 @@ export class Message extends React.PureComponent<Props, State> {
id, id,
quote, quote,
scrollToQuotedMessage, scrollToQuotedMessage,
shouldCollapseAbove,
} = this.props; } = this.props;
if (!quote) { if (!quote) {
@ -1248,11 +1228,11 @@ export class Message extends React.PureComponent<Props, State> {
curveTopLeft = false; curveTopLeft = false;
curveTopRight = false; curveTopRight = false;
} else if (isIncoming) { } else if (isIncoming) {
curveTopLeft = !this.isCollapsedAbove(); curveTopLeft = !shouldCollapseAbove;
curveTopRight = true; curveTopRight = true;
} else { } else {
curveTopLeft = true; curveTopLeft = true;
curveTopRight = !this.isCollapsedAbove(); curveTopRight = !shouldCollapseAbove;
} }
return ( return (
@ -1285,6 +1265,7 @@ export class Message extends React.PureComponent<Props, State> {
direction, direction,
i18n, i18n,
storyReplyContext, storyReplyContext,
shouldCollapseAbove,
} = this.props; } = this.props;
if (!storyReplyContext) { if (!storyReplyContext) {
@ -1299,11 +1280,11 @@ export class Message extends React.PureComponent<Props, State> {
curveTopLeft = false; curveTopLeft = false;
curveTopRight = false; curveTopRight = false;
} else if (isIncoming) { } else if (isIncoming) {
curveTopLeft = !this.isCollapsedAbove(); curveTopLeft = !shouldCollapseAbove;
curveTopRight = true; curveTopRight = true;
} else { } else {
curveTopLeft = true; curveTopLeft = true;
curveTopRight = !this.isCollapsedAbove(); curveTopRight = !shouldCollapseAbove;
} }
return ( return (
@ -1400,6 +1381,7 @@ export class Message extends React.PureComponent<Props, State> {
direction, direction,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
shouldCollapseBelow,
showContactModal, showContactModal,
theme, theme,
} = this.props; } = this.props;
@ -1415,7 +1397,7 @@ export class Message extends React.PureComponent<Props, State> {
this.hasReactions(), this.hasReactions(),
})} })}
> >
{this.isCollapsedBelow() ? ( {shouldCollapseBelow ? (
<AvatarSpacer size={GROUP_AVATAR_SIZE} /> <AvatarSpacer size={GROUP_AVATAR_SIZE} />
) : ( ) : (
<Avatar <Avatar
@ -2660,8 +2642,16 @@ export class Message extends React.PureComponent<Props, State> {
} }
public override render(): JSX.Element | null { public override render(): JSX.Element | null {
const { author, attachments, direction, id, isSticker, timestamp } = const {
this.props; author,
attachments,
direction,
id,
isSticker,
shouldCollapseAbove,
shouldCollapseBelow,
timestamp,
} = this.props;
const { expired, expiring, imageBroken, isSelected } = this.state; const { expired, expiring, imageBroken, isSelected } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu. // This id is what connects our triple-dot click with our associated pop-up menu.
@ -2681,8 +2671,8 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames( className={classNames(
'module-message', 'module-message',
`module-message--${direction}`, `module-message--${direction}`,
this.isCollapsedAbove() && 'module-message--collapsed-above', shouldCollapseAbove && 'module-message--collapsed-above',
this.isCollapsedBelow() && 'module-message--collapsed-below', shouldCollapseBelow && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null, isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null expiring ? 'module-message--expired' : null
)} )}

View file

@ -345,6 +345,9 @@ export class MessageDetail extends React.Component<Props> {
replyToMessage={replyToMessage} replyToMessage={replyToMessage}
retryDeleteForEveryone={retryDeleteForEveryone} retryDeleteForEveryone={retryDeleteForEveryone}
retrySend={retrySend} retrySend={retrySend}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
showForwardMessageModal={showForwardMessageModal} showForwardMessageModal={showForwardMessageModal}
scrollToQuotedMessage={() => { scrollToQuotedMessage={() => {
log.warn('MessageDetail: scrollToQuotedMessage called!'); log.warn('MessageDetail: scrollToQuotedMessage called!');

View file

@ -82,6 +82,9 @@ const defaultMessageProps: MessagesProps = {
retryDeleteForEveryone: action('default--retryDeleteForEveryone'), retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'), scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
selectMessage: action('default--selectMessage'), selectMessage: action('default--selectMessage'),
shouldCollapseAbove: false,
shouldCollapseBelow: false,
shouldHideMetadata: false,
showContactDetail: action('default--showContactDetail'), showContactDetail: action('default--showContactDetail'),
showContactModal: action('default--showContactModal'), showContactModal: action('default--showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(

View file

@ -423,25 +423,21 @@ const renderItem = ({
messageId, messageId,
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
isOldestTimelineItem,
}: { }: {
messageId: string; messageId: string;
containerElementRef: React.RefObject<HTMLElement>; containerElementRef: React.RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
isOldestTimelineItem: boolean;
}) => ( }) => (
<TimelineItem <TimelineItem
getPreferredBadge={() => undefined} getPreferredBadge={() => undefined}
id="" id=""
isOldestTimelineItem={isOldestTimelineItem}
isSelected={false} isSelected={false}
renderEmojiPicker={() => <div />} renderEmojiPicker={() => <div />}
renderReactionPicker={() => <div />} renderReactionPicker={() => <div />}
item={items[messageId]} item={items[messageId]}
previousItem={undefined}
nextItem={undefined}
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
isNextItemCallingNotification={false}
theme={ThemeType.light} theme={ThemeType.light}
containerElementRef={containerElementRef} containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint} containerWidthBreakpoint={containerWidthBreakpoint}
@ -451,6 +447,10 @@ const renderItem = ({
<div>*UniversalTimerNotification*</div> <div>*UniversalTimerNotification*</div>
)} )}
renderAudioAttachment={() => <div>*AudioAttachment*</div>} renderAudioAttachment={() => <div>*AudioAttachment*</div>}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
shouldRenderDateHeader={false}
{...actions()} {...actions()}
/> />
); );

View file

@ -53,7 +53,7 @@ const getDefaultProps = () => ({
conversationId: 'conversation-id', conversationId: 'conversation-id',
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
id: 'asdf', id: 'asdf',
isOldestTimelineItem: false, isNextItemCallingNotification: false,
isSelected: false, isSelected: false,
interactionMode: 'keyboard' as const, interactionMode: 'keyboard' as const,
theme: ThemeType.light, theme: ThemeType.light,
@ -94,8 +94,11 @@ const getDefaultProps = () => ({
showIdentity: action('showIdentity'), showIdentity: action('showIdentity'),
startCallingLobby: action('startCallingLobby'), startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'), returnToActiveCall: action('returnToActiveCall'),
previousItem: undefined, shouldCollapseAbove: false,
nextItem: undefined, shouldCollapseBelow: false,
shouldHideMetadata: false,
shouldRenderDateHeader: false,
now: Date.now(), now: Date.now(),
renderContact, renderContact,

View file

@ -5,7 +5,6 @@ import type { ReactChild, RefObject } from 'react';
import React from 'react'; import React from 'react';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import { isSameDay } from '../../util/timestamp';
import type { InteractionModeType } from '../../state/ducks/conversations'; import type { InteractionModeType } from '../../state/ducks/conversations';
import { TimelineDateHeader } from './TimelineDateHeader'; import { TimelineDateHeader } from './TimelineDateHeader';
@ -56,7 +55,6 @@ import type { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification'; import { ResetSessionNotification } from './ResetSessionNotification';
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification'; import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
import { ProfileChangeNotification } from './ProfileChangeNotification'; import { ProfileChangeNotification } from './ProfileChangeNotification';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { FullJSXType } from '../Intl'; import type { FullJSXType } from '../Intl';
type CallHistoryType = { type CallHistoryType = {
@ -148,17 +146,15 @@ type PropsLocalType = {
conversationId: string; conversationId: string;
item?: TimelineItemType; item?: TimelineItemType;
id: string; id: string;
isNextItemCallingNotification: boolean;
isSelected: boolean; isSelected: boolean;
selectMessage: (messageId: string, conversationId: string) => unknown; selectMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean;
renderContact: SmartContactRendererType<FullJSXType>; renderContact: SmartContactRendererType<FullJSXType>;
renderUniversalTimerNotification: () => JSX.Element; renderUniversalTimerNotification: () => JSX.Element;
i18n: LocalizerType; i18n: LocalizerType;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
isOldestTimelineItem: boolean;
theme: ThemeType; theme: ThemeType;
previousItem: undefined | TimelineItemType;
nextItem: undefined | TimelineItemType;
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
}; };
type PropsActionsType = MessageActionsType & type PropsActionsType = MessageActionsType &
@ -178,6 +174,9 @@ export type PropsType = PropsLocalType &
| 'renderEmojiPicker' | 'renderEmojiPicker'
| 'renderAudioAttachment' | 'renderAudioAttachment'
| 'renderReactionPicker' | 'renderReactionPicker'
| 'shouldCollapseAbove'
| 'shouldCollapseBelow'
| 'shouldHideMetadata'
>; >;
export class TimelineItem extends React.PureComponent<PropsType> { export class TimelineItem extends React.PureComponent<PropsType> {
@ -186,19 +185,20 @@ export class TimelineItem extends React.PureComponent<PropsType> {
containerElementRef, containerElementRef,
conversationId, conversationId,
getPreferredBadge, getPreferredBadge,
i18n,
id, id,
isOldestTimelineItem, isNextItemCallingNotification,
isSelected, isSelected,
item, item,
i18n,
theme,
nextItem,
previousItem,
renderUniversalTimerNotification, renderUniversalTimerNotification,
returnToActiveCall, returnToActiveCall,
selectMessage, selectMessage,
shouldCollapseAbove,
shouldCollapseBelow,
shouldHideMetadata,
shouldRenderDateHeader,
startCallingLobby, startCallingLobby,
unreadIndicatorPlacement, theme,
} = this.props; } = this.props;
if (!item) { if (!item) {
@ -217,12 +217,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<Message <Message
{...this.props} {...this.props}
{...item.data} {...item.data}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata}
containerElementRef={containerElementRef} containerElementRef={containerElementRef}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
renderingContext="conversation/TimelineItem" renderingContext="conversation/TimelineItem"
unreadIndicatorPlacement={unreadIndicatorPlacement}
/> />
); );
} else { } else {
@ -237,7 +239,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<CallingNotification <CallingNotification
conversationId={conversationId} conversationId={conversationId}
i18n={i18n} i18n={i18n}
nextItem={nextItem} isNextItemCallingNotification={isNextItemCallingNotification}
returnToActiveCall={returnToActiveCall} returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby} startCallingLobby={startCallingLobby}
{...item.data} {...item.data}
@ -340,14 +342,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
); );
} }
const shouldRenderDateHeader =
isOldestTimelineItem ||
Boolean(
previousItem &&
// This comparison avoids strange header behavior for out-of-order messages.
item.timestamp > previousItem.timestamp &&
!isSameDay(previousItem.timestamp, item.timestamp)
);
if (shouldRenderDateHeader) { if (shouldRenderDateHeader) {
return ( return (
<> <>

View file

@ -16,10 +16,15 @@ import {
getMessageSelector, getMessageSelector,
getSelectedMessage, getSelectedMessage,
} from '../selectors/conversations'; } from '../selectors/conversations';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil'; import {
areMessagesInSameGroup,
shouldCurrentMessageHideMetadata,
UnreadIndicatorPlacement,
} from '../../util/timelineUtil';
import { SmartContactName } from './ContactName'; import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification'; import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
import { isSameDay } from '../../util/timestamp';
type ExternalProps = { type ExternalProps = {
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
@ -65,24 +70,52 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversation = getConversationSelector(state)(conversationId); const conversation = getConversationSelector(state)(conversationId);
const isNextItemCallingNotification = nextItem?.type === 'callHistory';
const shouldCollapseAbove = areMessagesInSameGroup(
previousItem,
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove,
item
);
const shouldCollapseBelow = areMessagesInSameGroup(
item,
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustBelow,
nextItem
);
const shouldHideMetadata = shouldCurrentMessageHideMetadata(
shouldCollapseBelow,
item,
nextItem
);
const shouldRenderDateHeader =
isOldestTimelineItem ||
Boolean(
item &&
previousItem &&
// This comparison avoids strange header behavior for out-of-order messages.
item.timestamp > previousItem.timestamp &&
!isSameDay(previousItem.timestamp, item.timestamp)
);
return { return {
item, item,
previousItem,
nextItem,
id: messageId, id: messageId,
containerElementRef, containerElementRef,
conversationId, conversationId,
conversationColor: conversation?.conversationColor, conversationColor: conversation?.conversationColor,
customColor: conversation?.customColor, customColor: conversation?.customColor,
getPreferredBadge: getPreferredBadgeSelector(state), getPreferredBadge: getPreferredBadgeSelector(state),
isOldestTimelineItem, isNextItemCallingNotification,
isSelected, isSelected,
renderContact, renderContact,
renderUniversalTimerNotification, renderUniversalTimerNotification,
shouldCollapseAbove,
shouldCollapseBelow,
shouldHideMetadata,
shouldRenderDateHeader,
i18n: getIntl(state), i18n: getIntl(state),
interactionMode: getInteractionMode(state), interactionMode: getInteractionMode(state),
theme: getTheme(state), theme: getTheme(state),
unreadIndicatorPlacement,
}; };
}; };

View file

@ -4,12 +4,14 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { times } from 'lodash'; import { times } from 'lodash';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { LastMessageStatus } from '../../model-types.d';
import { MINUTE, SECOND } from '../../util/durations'; import { MINUTE, SECOND } from '../../util/durations';
import type { MaybeMessageTimelineItemType } from '../../util/timelineUtil'; import type { MaybeMessageTimelineItemType } from '../../util/timelineUtil';
import { import {
ScrollAnchor, ScrollAnchor,
areMessagesInSameGroup, areMessagesInSameGroup,
getScrollAnchorBeforeUpdate, getScrollAnchorBeforeUpdate,
shouldCurrentMessageHideMetadata,
TimelineMessageLoadingState, TimelineMessageLoadingState,
} from '../../util/timelineUtil'; } from '../../util/timelineUtil';
@ -118,60 +120,123 @@ describe('<Timeline> utilities', () => {
assert.isFalse(areMessagesInSameGroup(defaultOlder, true, defaultNewer)); assert.isFalse(areMessagesInSameGroup(defaultOlder, true, defaultNewer));
}); });
it("returns false if they don't have matching sent status (and not delivered)", () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, status: 'sent' as const },
};
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
});
it("returns false if newer is deletedForEveryone and older isn't", () => {
const newer = {
...defaultNewer,
data: { ...defaultNewer.data, deletedForEveryone: true },
};
assert.isFalse(areMessagesInSameGroup(defaultOlder, false, newer));
});
it("returns true if older is deletedForEveryone and newer isn't", () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, deletedForEveryone: true },
};
assert.isTrue(areMessagesInSameGroup(older, false, defaultNewer));
});
it('returns true if both are deletedForEveryone', () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, deletedForEveryone: true },
};
const newer = {
...defaultNewer,
data: { ...defaultNewer.data, deletedForEveryone: true },
};
assert.isTrue(areMessagesInSameGroup(older, false, newer));
});
it('returns true if they have delivered status or above', () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, status: 'read' as const },
};
assert.isTrue(areMessagesInSameGroup(older, false, defaultNewer));
});
it('returns true if everything above works out', () => { it('returns true if everything above works out', () => {
assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer)); assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer));
}); });
}); });
describe('shouldCurrentMessageHideMetadata', () => {
const defaultNewer: MaybeMessageTimelineItemType = {
type: 'message' as const,
data: {
author: { id: uuid() },
timestamp: new Date(1998, 10, 21, 12, 34, 56, 123).valueOf(),
status: 'delivered',
},
};
const defaultCurrent: MaybeMessageTimelineItemType = {
type: 'message' as const,
data: {
author: { id: uuid() },
timestamp: defaultNewer.data.timestamp - MINUTE,
status: 'delivered',
},
};
it("returns false if messages aren't grouped", () => {
assert.isFalse(
shouldCurrentMessageHideMetadata(false, defaultCurrent, defaultNewer)
);
});
it('returns false if newer item is missing', () => {
assert.isFalse(
shouldCurrentMessageHideMetadata(true, defaultCurrent, undefined)
);
});
it('returns false if newer item is not a message', () => {
const linkNotification = {
type: 'linkNotification' as const,
data: null,
timestamp: Date.now(),
};
assert.isFalse(
shouldCurrentMessageHideMetadata(true, defaultCurrent, linkNotification)
);
});
it('returns false if newer is deletedForEveryone', () => {
const newer = {
...defaultNewer,
data: { ...defaultNewer.data, deletedForEveryone: true },
};
assert.isFalse(
shouldCurrentMessageHideMetadata(true, defaultCurrent, newer)
);
});
it('returns false if current message is unsent, even if its status matches the newer one', () => {
const statuses: ReadonlyArray<LastMessageStatus> = [
'paused',
'error',
'partial-sent',
'sending',
];
for (const status of statuses) {
const sameStatusNewer = {
...defaultNewer,
data: { ...defaultNewer.data, status },
};
const current = {
...defaultCurrent,
data: { ...defaultCurrent.data, status },
};
assert.isFalse(
shouldCurrentMessageHideMetadata(true, current, defaultNewer)
);
assert.isFalse(
shouldCurrentMessageHideMetadata(true, current, sameStatusNewer)
);
}
});
it('returns true if all messages are sent (but no higher)', () => {
const newer = {
...defaultNewer,
data: { ...defaultNewer.data, status: 'sent' as const },
};
const current = {
...defaultCurrent,
data: { ...defaultCurrent.data, status: 'sent' as const },
};
assert.isTrue(shouldCurrentMessageHideMetadata(true, current, newer));
});
it('returns true if all three have delivered status or above', () => {
assert.isTrue(
shouldCurrentMessageHideMetadata(true, defaultCurrent, defaultNewer)
);
});
it('returns true if both the current and next messages are deleted for everyone', () => {
const current = {
...defaultCurrent,
data: { ...defaultCurrent.data, deletedForEveryone: true },
};
const newer = {
...defaultNewer,
data: { ...defaultNewer.data, deletedForEveryone: true },
};
assert.isTrue(shouldCurrentMessageHideMetadata(true, current, newer));
});
});
describe('getScrollAnchorBeforeUpdate', () => { describe('getScrollAnchorBeforeUpdate', () => {
const fakeItems = (count: number) => times(count, () => uuid()); const fakeItems = (count: number) => times(count, () => uuid());

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import * as log from '../logging/log';
import type { PropsType as TimelinePropsType } from '../components/conversation/Timeline'; import type { PropsType as TimelinePropsType } from '../components/conversation/Timeline';
import type { TimelineItemType } from '../components/conversation/TimelineItem'; import type { TimelineItemType } from '../components/conversation/TimelineItem';
import { WidthBreakpoint } from '../components/_util'; import { WidthBreakpoint } from '../components/_util';
@ -54,8 +55,52 @@ const getMessageTimelineItemData = (
): undefined | MessageTimelineItemDataType => ): undefined | MessageTimelineItemDataType =>
timelineItem?.type === 'message' ? timelineItem.data : undefined; timelineItem?.type === 'message' ? timelineItem.data : undefined;
function isDelivered(status?: LastMessageStatus) { export function shouldCurrentMessageHideMetadata(
return status === 'delivered' || status === 'read' || status === 'viewed'; areMessagesGrouped: boolean,
item: MaybeMessageTimelineItemType,
newerTimelineItem: MaybeMessageTimelineItemType
): boolean {
if (!areMessagesGrouped) {
return false;
}
const message = getMessageTimelineItemData(item);
if (!message) {
return false;
}
const newerMessage = getMessageTimelineItemData(newerTimelineItem);
if (!newerMessage) {
return false;
}
// If newer message is deleted, but current isn't, we'll show metadata.
if (newerMessage.deletedForEveryone && !message.deletedForEveryone) {
return false;
}
switch (message.status) {
case undefined:
return true;
case 'paused':
case 'error':
case 'partial-sent':
case 'sending':
return false;
case 'sent':
return newerMessage.status === 'sent';
case 'delivered':
case 'read':
case 'viewed':
return (
newerMessage.status === 'delivered' ||
newerMessage.status === 'read' ||
newerMessage.status === 'viewed'
);
default:
log.error(missingCaseError(message.status));
return false;
}
} }
export function areMessagesInSameGroup( export function areMessagesInSameGroup(
@ -77,20 +122,12 @@ export function areMessagesInSameGroup(
return false; return false;
} }
// We definitely don't want to group if we transition from non-deleted to deleted, since
// deleted messages don't show status.
if (newerMessage.deletedForEveryone && !olderMessage.deletedForEveryone) {
return false;
}
return Boolean( return Boolean(
!olderMessage.reactions?.length && !olderMessage.reactions?.length &&
olderMessage.author.id === newerMessage.author.id && olderMessage.author.id === newerMessage.author.id &&
newerMessage.timestamp >= olderMessage.timestamp && newerMessage.timestamp >= olderMessage.timestamp &&
newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN && newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN &&
isSameDay(olderMessage.timestamp, newerMessage.timestamp) && isSameDay(olderMessage.timestamp, newerMessage.timestamp)
(olderMessage.status === newerMessage.status ||
(isDelivered(newerMessage.status) && isDelivered(olderMessage.status)))
); );
} }