Collapse message bubbles when applicable

This commit is contained in:
Evan Hahn 2022-03-08 08:32:42 -06:00 committed by GitHub
parent 16cd115530
commit c527de0a8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 707 additions and 383 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
@import '../../stylesheets/variables'; @import '../../stylesheets/variables';
@ -6,7 +6,7 @@
.base { .base {
background-color: $color-ultramarine; background-color: $color-ultramarine;
padding: 6px 12px; padding: 6px 12px;
border-radius: 16px; border-radius: 18px;
color: $color-white-alpha-90; color: $color-white-alpha-90;
font: { font: {
size: 12px; size: 12px;

View File

@ -51,26 +51,6 @@
// Module: Message // Module: Message
// Note: this does the same thing as module-timeline__message-container but
// can be used outside the Timeline contact more easily.
.module-message-container {
@include button-reset;
cursor: inherit;
width: 100%;
margin-top: 4px;
margin-bottom: 4px;
&::after {
visibility: hidden;
display: block;
font-size: 0;
content: ' ';
clear: both;
height: 0;
}
}
.module-message { .module-message {
position: relative; position: relative;
display: flex; display: flex;
@ -79,6 +59,7 @@
outline: none; outline: none;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
transition: background 0.1s ease-out;
} }
.module-message--expired { .module-message--expired {
@ -141,114 +122,68 @@
} }
} }
.module-message__buttons__download { @mixin module-message__buttons__button($light-icon, $dark-icon: $light-icon) {
cursor: pointer;
height: 24px; height: 24px;
width: 24px; width: 24px;
cursor: pointer;
@include light-theme { @include light-theme {
@include color-svg( @include color-svg($light-icon, $color-gray-45);
'../images/icons/v2/save-outline-24.svg',
$color-gray-45
);
&:hover { &:hover {
@include color-svg( @include color-svg($light-icon, $color-gray-90);
'../images/icons/v2/save-outline-24.svg',
$color-gray-90
);
} }
} }
@include dark-theme { @include dark-theme {
@include color-svg('../images/icons/v2/save-solid-24.svg', $color-gray-45); @include color-svg($dark-icon, $color-gray-45);
&:hover { &:hover {
@include color-svg( @include color-svg($light-icon, $color-gray-02);
'../images/icons/v2/save-solid-24.svg',
$color-gray-02
);
} }
} }
.module-message--selected & {
@include mouse-mode {
background-color: $color-ultramarine;
}
@include dark-mouse-mode {
background-color: $color-white;
}
}
.module-message:focus & {
@include keyboard-mode {
background-color: $color-ultramarine;
}
@include dark-keyboard-mode {
background-color: $color-white;
}
}
}
.module-message__buttons__download {
@include module-message__buttons__button(
'../images/icons/v2/save-outline-24.svg',
'../images/icons/v2/save-solid-24.svg'
);
} }
.module-message__buttons__react { .module-message__buttons__react {
height: 24px; @include module-message__buttons__button(
width: 24px; '../images/icons/v2/add-emoji-outline-24.svg'
cursor: pointer; );
@include light-theme {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-45
);
&:hover {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-90
);
}
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-45
);
&:hover {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-02
);
}
}
} }
.module-message__buttons__reply { .module-message__buttons__reply {
height: 24px; @include module-message__buttons__button(
width: 24px; '../images/icons/v2/reply-outline-24.svg',
cursor: pointer; '../images/icons/v2/reply-solid-24.svg'
);
@include light-theme {
@include color-svg(
'../images/icons/v2/reply-outline-24.svg',
$color-gray-45
);
&:hover {
@include color-svg(
'../images/icons/v2/reply-outline-24.svg',
$color-gray-90
);
}
}
@include dark-theme {
@include color-svg('../images/icons/v2/reply-solid-24.svg', $color-gray-45);
&:hover {
@include color-svg(
'../images/icons/v2/reply-solid-24.svg',
$color-gray-02
);
}
}
} }
.module-message__buttons__menu { .module-message__buttons__menu {
height: 24px; @include module-message__buttons__button(
width: 24px; '../images/icons/v2/more-horiz-24.svg'
display: inline-block; );
cursor: pointer;
@include color-svg('../images/icons/v2/more-horiz-24.svg', $color-gray-45);
@include light-theme {
&:hover {
@include color-svg(
'../images/icons/v2/more-horiz-24.svg',
$color-gray-90
);
}
}
@include dark-theme {
&:hover {
@include color-svg(
'../images/icons/v2/more-horiz-24.svg',
$color-gray-02
);
}
}
&--container { &--container {
border-radius: 4px; border-radius: 4px;
@ -256,13 +191,6 @@
// the z-index here is so that this container is above the message and when // the z-index here is so that this container is above the message and when
// clicked on, doesn't propagate the click event to the message. // clicked on, doesn't propagate the click event to the message.
z-index: $z-index-above-base; z-index: $z-index-above-base;
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-95;
}
} }
} }
@ -331,9 +259,11 @@
} }
} }
.module-message__container { .module-message__container {
$collapsed-border-radius: 4px;
position: relative; position: relative;
display: inline-block; display: inline-block;
border-radius: 16px; border-radius: 18px;
margin-bottom: 4px; margin-bottom: 4px;
margin-top: 4px; margin-top: 4px;
min-width: 0px; min-width: 0px;
@ -343,71 +273,47 @@
padding: { padding: {
left: 12px; left: 12px;
right: 12px; right: 12px;
top: 10px; top: 8px;
bottom: 7px; bottom: 8px;
} }
@include light-theme { .module-message--collapsed-above & {
border: 1px solid $color-white; margin-top: 1px;
}
.module-message--collapsed-below & {
margin-bottom: 1px;
} }
@include dark-theme { .module-message--incoming.module-message--collapsed-above & {
border: 1px solid $color-gray-95; border-top-left-radius: $collapsed-border-radius;
}
.module-message--incoming.module-message--collapsed-below & {
border-bottom-left-radius: $collapsed-border-radius;
}
.module-message--outgoing.module-message--collapsed-above & {
border-top-right-radius: $collapsed-border-radius;
}
.module-message--outgoing.module-message--collapsed-below & {
border-bottom-right-radius: $collapsed-border-radius;
} }
} }
// This is the component we put the outline around when the whole message is selected .module-message--selected {
.module-message--selected .module-message__container {
@include mouse-mode { @include mouse-mode {
animation: message--mouse-selected 1s $ease-out-expo; background: $color-selected-message-background-light;
}
@include dark-mouse-mode {
background: $color-selected-message-background-dark;
} }
} }
.module-message:focus .module-message__container {
.module-message:focus {
@include keyboard-mode { @include keyboard-mode {
box-shadow: 0 0 0 3px $color-ultramarine; background: $color-selected-message-background-light;
} }
} @include dark-keyboard-mode {
background: $color-selected-message-background-dark;
@keyframes message--mouse-selected {
0% {
box-shadow: 0 0 0 5px transparent;
} }
10% {
box-shadow: 0 0 0 5px $color-ultramarine;
}
70% {
box-shadow: 0 0 0 5px $color-ultramarine;
}
100% {
box-shadow: 0 0 0 5px transparent;
}
}
// We disable this highlight for messages with stickers, instead highlighting the sticker
.module-message--selected .module-message__container--with-sticker {
@include mouse-mode {
animation: none;
}
}
.module-message:focus .module-message__container--with-sticker {
@include keyboard-mode {
box-shadow: none;
}
}
.module-message__container--with-sticker {
@include light-theme {
border: none;
}
@include dark-theme {
border: none;
}
/* Leave some padding to eat the negative margin-bottom from
* .module-message__metadata
*/
padding-bottom: 3px;
} }
.module-message__container--emoji { .module-message__container--emoji {
@ -600,8 +506,8 @@
margin: { margin: {
left: -12px; left: -12px;
right: -12px; right: -12px;
top: -10px; top: -8px;
bottom: -7px; bottom: -8px;
} }
line-height: 0; line-height: 0;
@ -1055,13 +961,15 @@
} }
.module-message__metadata { .module-message__metadata {
align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; justify-content: flex-end;
margin-top: 3px; margin-top: 3px;
&--outgoing { &--inline {
justify-content: flex-end; float: right;
margin-top: -14px;
} }
} }
@ -1242,9 +1150,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-right: 8px; margin-right: 8px;
padding-bottom: 6px;
&--with-reactions { &--with-reactions {
padding-bottom: 12px; padding-bottom: 15px;
} }
} }
@ -1533,6 +1442,14 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
} }
} }
.module-quote--curve-top-left {
border-top-left-radius: 12px;
}
.module-quote--curve-top-right {
border-top-right-radius: 12px;
}
.module-quote__primary { .module-quote__primary {
flex-grow: 1; flex-grow: 1;
padding-left: 8px; padding-left: 8px;
@ -2037,10 +1954,10 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
&:focus { &:focus {
@include keyboard-mode { @include keyboard-mode {
background-color: $color-gray-02; background: $color-selected-message-background-light;
} }
@include dark-keyboard-mode { @include dark-keyboard-mode {
background-color: $color-gray-80; background: $color-selected-message-background-dark;
} }
} }
} }
@ -2974,16 +2891,16 @@ button.ConversationDetails__action-button {
} }
.module-image--curved-top-left { .module-image--curved-top-left {
border-top-left-radius: 16px; border-top-left-radius: 18px;
} }
.module-image--curved-top-right { .module-image--curved-top-right {
border-top-right-radius: 16px; border-top-right-radius: 18px;
} }
.module-image--curved-bottom-left { .module-image--curved-bottom-left {
border-bottom-left-radius: 16px; border-bottom-left-radius: 18px;
} }
.module-image--curved-bottom-right { .module-image--curved-bottom-right {
border-bottom-right-radius: 16px; border-bottom-right-radius: 18px;
} }
.module-image--small-curved-top-left { .module-image--small-curved-top-left {
border-top-left-radius: 10px; border-top-left-radius: 10px;
@ -3039,38 +2956,6 @@ button.ConversationDetails__action-button {
} }
} }
// Only if it's a sticker do we put the outline inside it
.module-message--selected
.module-message__container--with-sticker
.module-image__border-overlay {
@include mouse-mode {
top: 1px;
bottom: 1px;
left: 1px;
right: 1px;
border-radius: 10px;
animation: message--mouse-selected 1s $ease-out-expo;
}
}
.module-message:focus .module-message__container--with-sticker {
$border-radius: 10px;
.module-image__image {
@include keyboard-mode {
border-radius: $border-radius;
}
}
.module-image__border-overlay {
@include keyboard-mode {
border-radius: $border-radius;
box-shadow: 0 0 0 3px $color-ultramarine;
}
}
}
button.module-image__border-overlay:focus { button.module-image__border-overlay:focus {
@include keyboard-mode { @include keyboard-mode {
box-shadow: inset 0px 0px 0px 2px $color-ultramarine; box-shadow: inset 0px 0px 0px 2px $color-ultramarine;
@ -8303,7 +8188,7 @@ button.module-image__border-overlay:focus {
} }
&--with-reactions { &--with-reactions {
margin-bottom: -9px; margin-bottom: -6px;
} }
&--deleted-for-everyone { &--deleted-for-everyone {

View File

@ -230,6 +230,9 @@ $color-white-alpha-40: rgba($color-white, 0.4);
// Used in tap-to-view error states // Used in tap-to-view error states
$color-deep-red: #ff261f; $color-deep-red: #ff261f;
$color-selected-message-background-light: rgba(44, 107, 237, 0.24);
$color-selected-message-background-dark: $color-gray-65;
// -- A few layout variables used cross-file // -- A few layout variables used cross-file
$header-height: 52px; $header-height: 52px;

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
@mixin system-message-icon($light, $dark) { @mixin system-message-icon($light, $dark) {
@ -19,8 +19,8 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
line-height: 16px; line-height: 16px;
margin-bottom: 16px; padding-bottom: 16px;
margin-top: 16px; padding-top: 16px;
@include light-theme { @include light-theme {
color: $color-gray-60; color: $color-gray-60;

View File

@ -0,0 +1,12 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
import type { AvatarSize } from './Avatar';
export const AvatarSpacer = ({
size,
}: Readonly<{ size: AvatarSize }>): ReactElement => (
<div style={{ minWidth: size, height: size, width: size }} />
);

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -383,17 +383,11 @@ book.add('GroupNotification', () =>
stories.map(([title, propsArray]) => ( stories.map(([title, propsArray]) => (
<> <>
<h3>{title}</h3> <h3>{title}</h3>
{propsArray.map((props, i) => { {propsArray.map((props, i) => (
return ( <div key={i} className="module-inline-notification-wrapper">
<> <GroupNotification {...props} />
<div key={i} className="module-message-container"> </div>
<div className="module-inline-notification-wrapper"> ))}
<GroupNotification {...props} />
</div>
</div>
</>
);
})}
</> </>
)) ))
); );

View File

@ -29,6 +29,7 @@ import enMessages from '../../../_locales/en/messages.json';
import { pngUrl } from '../../storybook/Fixtures'; import { pngUrl } from '../../storybook/Fixtures';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { WidthBreakpoint } from '../_util'; import { WidthBreakpoint } from '../_util';
import { MINUTE } from '../../util/durations';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment'; import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge'; import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
@ -108,7 +109,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false, canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'), clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata,
containerElementRef: React.createRef<HTMLElement>(), containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide, containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor: conversationColor:
@ -188,13 +188,33 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
timestamp: number('timestamp', overrideProps.timestamp || Date.now()), timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
}); });
const renderBothDirections = (props: Props) => ( const createTimelineItem = (data: undefined | Props) =>
<> data && {
<Message {...props} /> type: 'message' as const,
<br /> data,
<Message {...props} direction="outgoing" /> timestamp: data.timestamp,
</> };
);
const renderMany = (propsArray: ReadonlyArray<Props>) =>
propsArray.map((message, index) => (
<Message
key={message.text}
{...message}
previousItem={createTimelineItem(propsArray[index - 1])}
item={createTimelineItem(message)}
nextItem={createTimelineItem(propsArray[index + 1])}
/>
));
const renderBothDirections = (props: Props) =>
renderMany([
props,
{
...props,
author: { ...props.author, id: getDefaultConversation().id },
direction: 'outgoing',
},
]);
story.add('Plain Message', () => { story.add('Plain Message', () => {
const props = createProps({ const props = createProps({
@ -350,17 +370,6 @@ story.add('Pending', () => {
return renderBothDirections(props); return renderBothDirections(props);
}); });
story.add('Collapsed Metadata', () => {
const props = createProps({
author: getDefaultConversation({ title: 'Fred Willard' }),
collapseMetadata: true,
conversationType: 'group',
text: 'Hello there from a pal!',
});
return renderBothDirections(props);
});
story.add('Recent', () => { story.add('Recent', () => {
const props = createProps({ const props = createProps({
text: 'Hello there from a pal!', text: 'Hello there from a pal!',
@ -1392,3 +1401,67 @@ story.add('Custom Color', () => (
/> />
</> </>
)); ));
story.add('Collapsing text-only DMs', () => {
const them = getDefaultConversation();
const me = getDefaultConversation({ isMe: true });
return renderMany([
createProps({
author: them,
text: 'One',
timestamp: Date.now() - 5 * MINUTE,
}),
createProps({
author: them,
text: 'Two',
timestamp: Date.now() - 4 * MINUTE,
}),
createProps({
author: them,
text: 'Three',
timestamp: Date.now() - 3 * MINUTE,
}),
createProps({
author: me,
direction: 'outgoing',
text: 'Four',
timestamp: Date.now() - 2 * MINUTE,
}),
createProps({
text: 'Five',
author: me,
timestamp: Date.now() - MINUTE,
direction: 'outgoing',
}),
createProps({
author: me,
direction: 'outgoing',
text: 'Six',
}),
]);
});
story.add('Collapsing text-only group messages', () => {
const author = getDefaultConversation();
return renderMany([
createProps({
author,
conversationType: 'group',
text: 'One',
timestamp: Date.now() - 2 * MINUTE,
}),
createProps({
author,
conversationType: 'group',
text: 'Two',
timestamp: Date.now() - MINUTE,
}),
createProps({
author,
conversationType: 'group',
text: 'Three',
}),
]);
});

View File

@ -15,14 +15,17 @@ import type {
ConversationTypeType, ConversationTypeType,
InteractionModeType, InteractionModeType,
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import type { TimelineItemType } from './TimelineItem';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar } from '../Avatar'; import { Avatar, AvatarSize } from '../Avatar';
import { AvatarSpacer } from '../AvatarSpacer';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import { import {
doesMessageBodyOverflow, doesMessageBodyOverflow,
MessageBodyReadMore, MessageBodyReadMore,
} from './MessageBodyReadMore'; } from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata'; import { MessageMetadata } from './MessageMetadata';
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
import { ImageGrid } from './ImageGrid'; import { ImageGrid } from './ImageGrid';
import { GIF } from './GIF'; import { GIF } from './GIF';
import { Image } from './Image'; import { Image } from './Image';
@ -80,15 +83,36 @@ 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;
}; };
const DEFAULT_METADATA_WIDTH = 20;
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
const STICKER_SIZE = 200; const STICKER_SIZE = 200;
const GIF_SIZE = 300; const GIF_SIZE = 300;
const SELECTED_TIMEOUT = 1000; const SELECTED_TIMEOUT = 1000;
const THREE_HOURS = 3 * 60 * 60 * 1000; const THREE_HOURS = 3 * 60 * 60 * 1000;
const SENT_STATUSES = new Set<MessageStatusType>([
'delivered',
'read',
'sent',
'viewed',
]);
enum MetadataPlacement {
NotRendered,
RenderedByMessageAudioComponent,
InlineWithText,
Bottom,
}
export const MessageStatuses = [ export const MessageStatuses = [
'delivered', 'delivered',
@ -111,6 +135,7 @@ export type AudioAttachmentProps = {
buttonRef: React.RefObject<HTMLButtonElement>; buttonRef: React.RefObject<HTMLButtonElement>;
theme: ThemeType | undefined; theme: ThemeType | undefined;
attachment: AttachmentType; attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean; withContentAbove: boolean;
withContentBelow: boolean; withContentBelow: boolean;
@ -211,18 +236,21 @@ export type PropsData = {
export type PropsHousekeeping = { export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
disableMenu?: boolean;
disableScroll?: boolean;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
now: number; now: number;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
theme: ThemeType; item?: TimelineItemType;
disableMenu?: boolean; nextItem?: TimelineItemType;
disableScroll?: boolean; previousItem?: TimelineItemType;
collapseMetadata?: boolean;
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;
theme: ThemeType;
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
}; };
export type PropsActions = { export type PropsActions = {
@ -287,6 +315,8 @@ export type Props = PropsData &
Pick<ReactionPickerProps, 'renderEmojiPicker'>; Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type State = { type State = {
metadataWidth: number;
expiring: boolean; expiring: boolean;
expired: boolean; expired: boolean;
imageBroken: boolean; imageBroken: boolean;
@ -300,9 +330,6 @@ type State = {
hasDeleteForEveryoneTimerExpired: boolean; hasDeleteForEveryoneTimerExpired: boolean;
}; };
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
export class Message extends React.PureComponent<Props, State> { export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined; public menuTriggerRef: Trigger | undefined;
@ -327,6 +354,8 @@ export class Message extends React.PureComponent<Props, State> {
super(props); super(props);
this.state = { this.state = {
metadataWidth: DEFAULT_METADATA_WIDTH,
expiring: false, expiring: false,
expired: false, expired: false,
imageBroken: false, imageBroken: false,
@ -464,7 +493,7 @@ export class Message extends React.PureComponent<Props, State> {
this.toggleReactionPicker(true); this.toggleReactionPicker(true);
} }
public override componentDidUpdate(prevProps: Props): void { public override componentDidUpdate(prevProps: Readonly<Props>): void {
const { isSelected, status, timestamp } = this.props; const { isSelected, status, timestamp } = this.props;
this.startSelectedTimer(); this.startSelectedTimer();
@ -494,6 +523,37 @@ export class Message extends React.PureComponent<Props, State> {
} }
} }
private getMetadataPlacement(
{
attachments,
expirationLength,
expirationTimestamp,
status,
text,
}: Readonly<Props> = this.props
): MetadataPlacement {
if (
!expirationLength &&
!expirationTimestamp &&
(!status || SENT_STATUSES.has(status)) &&
this.isCollapsedBelow()
) {
return MetadataPlacement.NotRendered;
}
if (!text) {
return isAudio(attachments)
? MetadataPlacement.RenderedByMessageAudioComponent
: MetadataPlacement.Bottom;
}
if (this.canRenderStickerLikeEmoji()) {
return MetadataPlacement.Bottom;
}
return MetadataPlacement.InlineWithText;
}
public startSelectedTimer(): void { public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props; const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state; const { isSelected } = this.state;
@ -569,6 +629,37 @@ 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 {
const { author, conversationType, direction } = this.props;
return Boolean(
direction === 'incoming' &&
conversationType === 'group' &&
author.title &&
!this.isCollapsedAbove()
);
}
private canRenderStickerLikeEmoji(): boolean { private canRenderStickerLikeEmoji(): boolean {
const { text, quote, attachments, previews } = this.props; const { text, quote, attachments, previews } = this.props;
@ -582,10 +673,34 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
public renderMetadata(): JSX.Element | null { private updateMetadataWidth = (newMetadataWidth: number): void => {
this.setState(({ metadataWidth }) => ({
// We don't want text to jump around if the metadata shrinks, but we want to make
// sure we have enough room.
metadataWidth: Math.max(metadataWidth, newMetadataWidth),
}));
};
private renderMetadata(): ReactNode {
let isInline: boolean;
const metadataPlacement = this.getMetadataPlacement();
switch (metadataPlacement) {
case MetadataPlacement.NotRendered:
case MetadataPlacement.RenderedByMessageAudioComponent:
return null;
case MetadataPlacement.InlineWithText:
isInline = true;
break;
case MetadataPlacement.Bottom:
isInline = false;
break;
default:
log.error(missingCaseError(metadataPlacement));
isInline = false;
break;
}
const { const {
attachments,
collapseMetadata,
deletedForEveryone, deletedForEveryone,
direction, direction,
expirationLength, expirationLength,
@ -602,16 +717,6 @@ export class Message extends React.PureComponent<Props, State> {
showMessageDetail, showMessageDetail,
} = this.props; } = this.props;
if (collapseMetadata) {
return null;
}
// The message audio component renders its own metadata because it positions the
// metadata in line with some of its own.
if (isAudio(attachments) && !text) {
return null;
}
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji(); const isStickerLike = isSticker || this.canRenderStickerLikeEmoji();
return ( return (
@ -623,10 +728,12 @@ export class Message extends React.PureComponent<Props, State> {
hasText={Boolean(text)} hasText={Boolean(text)}
i18n={i18n} i18n={i18n}
id={id} id={id}
isInline={isInline}
isShowingImage={this.isShowingImage()} isShowingImage={this.isShowingImage()}
isSticker={isStickerLike} isSticker={isStickerLike}
isTapToViewExpired={isTapToViewExpired} isTapToViewExpired={isTapToViewExpired}
now={now} now={now}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
showMessageDetail={showMessageDetail} showMessageDetail={showMessageDetail}
status={status} status={status}
textPending={textPending} textPending={textPending}
@ -635,27 +742,16 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
public renderAuthor(): JSX.Element | null { private renderAuthor(): ReactNode {
const { const {
author, author,
collapseMetadata,
contactNameColor, contactNameColor,
conversationType,
direction,
isSticker, isSticker,
isTapToView, isTapToView,
isTapToViewExpired, isTapToViewExpired,
} = this.props; } = this.props;
if (collapseMetadata) { if (!this.shouldRenderAuthor()) {
return null;
}
if (
direction !== 'incoming' ||
conversationType !== 'group' ||
!author.title
) {
return null; return null;
} }
@ -681,8 +777,6 @@ export class Message extends React.PureComponent<Props, State> {
public renderAttachment(): JSX.Element | null { public renderAttachment(): JSX.Element | null {
const { const {
attachments, attachments,
collapseMetadata,
conversationType,
direction, direction,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
@ -709,6 +803,9 @@ export class Message extends React.PureComponent<Props, State> {
const { imageBroken } = this.state; const { imageBroken } = this.state;
const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
if (!attachments || !attachments[0]) { if (!attachments || !attachments[0]) {
return null; return null;
} }
@ -716,9 +813,7 @@ export class Message extends React.PureComponent<Props, State> {
// For attachments which aren't full-frame // For attachments which aren't full-frame
const withContentBelow = Boolean(text); const withContentBelow = Boolean(text);
const withContentAbove = const withContentAbove = Boolean(quote) || this.shouldRenderAuthor();
Boolean(quote) ||
(conversationType === 'group' && direction === 'incoming');
const displayImage = canDisplayImage(attachments); const displayImage = canDisplayImage(attachments);
if (displayImage && !imageBroken) { if (displayImage && !imageBroken) {
@ -773,8 +868,12 @@ export class Message extends React.PureComponent<Props, State> {
<div className={containerClassName}> <div className={containerClassName}>
<ImageGrid <ImageGrid
attachments={attachments} attachments={attachments}
withContentAbove={isSticker || withContentAbove} withContentAbove={
withContentBelow={isSticker || withContentBelow} isSticker || withContentAbove || this.isCollapsedAbove()
}
withContentBelow={
isSticker || withContentBelow || this.isCollapsedBelow()
}
isSticker={isSticker} isSticker={isSticker}
stickerSize={STICKER_SIZE} stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay} bottomOverlay={bottomOverlay}
@ -815,6 +914,7 @@ export class Message extends React.PureComponent<Props, State> {
renderingContext, renderingContext,
theme, theme,
attachment: firstAttachment, attachment: firstAttachment,
collapseMetadata,
withContentAbove, withContentAbove,
withContentBelow, withContentBelow,
@ -1085,17 +1185,34 @@ export class Message extends React.PureComponent<Props, State> {
}); });
}; };
const isIncoming = direction === 'incoming';
let curveTopLeft: boolean;
let curveTopRight: boolean;
if (this.shouldRenderAuthor()) {
curveTopLeft = false;
curveTopRight = false;
} else if (isIncoming) {
curveTopLeft = !this.isCollapsedAbove();
curveTopRight = true;
} else {
curveTopLeft = true;
curveTopRight = !this.isCollapsedAbove();
}
return ( return (
<Quote <Quote
i18n={i18n} i18n={i18n}
onClick={clickHandler} onClick={clickHandler}
text={quote.text} text={quote.text}
rawAttachment={quote.rawAttachment} rawAttachment={quote.rawAttachment}
isIncoming={direction === 'incoming'} isIncoming={isIncoming}
authorTitle={quote.authorTitle} authorTitle={quote.authorTitle}
bodyRanges={quote.bodyRanges} bodyRanges={quote.bodyRanges}
conversationColor={conversationColor} conversationColor={conversationColor}
customColor={customColor} customColor={customColor}
curveTopLeft={curveTopLeft}
curveTopRight={curveTopRight}
isViewOnce={isViewOnce} isViewOnce={isViewOnce}
referencedMessageNotFound={referencedMessageNotFound} referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe} isFromMe={quote.isFromMe}
@ -1108,7 +1225,6 @@ export class Message extends React.PureComponent<Props, State> {
public renderEmbeddedContact(): JSX.Element | null { public renderEmbeddedContact(): JSX.Element | null {
const { const {
collapseMetadata,
contact, contact,
conversationType, conversationType,
direction, direction,
@ -1123,7 +1239,9 @@ export class Message extends React.PureComponent<Props, State> {
const withCaption = Boolean(text); const withCaption = Boolean(text);
const withContentAbove = const withContentAbove =
conversationType === 'group' && direction === 'incoming'; conversationType === 'group' && direction === 'incoming';
const withContentBelow = withCaption || !collapseMetadata; const withContentBelow =
withCaption ||
this.getMetadataPlacement() !== MetadataPlacement.NotRendered;
const otherContent = const otherContent =
(contact && contact.firstNumber && contact.isNumberOnSignal) || (contact && contact.firstNumber && contact.isNumberOnSignal) ||
@ -1166,22 +1284,19 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
public hasAvatar(): boolean { private renderAvatar(): ReactNode {
const { collapseMetadata, conversationType, direction } = this.props; const {
author,
getPreferredBadge,
i18n,
showContactModal,
theme,
conversationType,
direction,
} = this.props;
return Boolean( if (conversationType !== 'group' || direction !== 'incoming') {
!collapseMetadata && return null;
conversationType === 'group' &&
direction !== 'outgoing'
);
}
public renderAvatar(): JSX.Element | undefined {
const { author, getPreferredBadge, i18n, showContactModal, theme } =
this.props;
if (!this.hasAvatar()) {
return undefined;
} }
return ( return (
@ -1191,29 +1306,33 @@ export class Message extends React.PureComponent<Props, State> {
this.hasReactions(), this.hasReactions(),
})} })}
> >
<Avatar {this.isCollapsedBelow() ? (
acceptedMessageRequest={author.acceptedMessageRequest} <AvatarSpacer size={GROUP_AVATAR_SIZE} />
avatarPath={author.avatarPath} ) : (
badge={getPreferredBadge(author.badges)} <Avatar
color={author.color} acceptedMessageRequest={author.acceptedMessageRequest}
conversationType="direct" avatarPath={author.avatarPath}
i18n={i18n} badge={getPreferredBadge(author.badges)}
isMe={author.isMe} color={author.color}
name={author.name} conversationType="direct"
onClick={event => { i18n={i18n}
event.stopPropagation(); isMe={author.isMe}
event.preventDefault(); name={author.name}
onClick={event => {
event.stopPropagation();
event.preventDefault();
showContactModal(author.id); showContactModal(author.id);
}} }}
phoneNumber={author.phoneNumber} phoneNumber={author.phoneNumber}
profileName={author.profileName} profileName={author.profileName}
sharedGroupNames={author.sharedGroupNames} sharedGroupNames={author.sharedGroupNames}
size={28} size={GROUP_AVATAR_SIZE}
theme={theme} theme={theme}
title={author.title} title={author.title}
unblurredAvatarPath={author.unblurredAvatarPath} unblurredAvatarPath={author.unblurredAvatarPath}
/> />
)}
</div> </div>
); );
} }
@ -1232,6 +1351,7 @@ export class Message extends React.PureComponent<Props, State> {
text, text,
textPending, textPending,
} = this.props; } = this.props;
const { metadataWidth } = this.state;
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
const contents = deletedForEveryone const contents = deletedForEveryone
@ -1267,6 +1387,9 @@ export class Message extends React.PureComponent<Props, State> {
text={contents || ''} text={contents || ''}
textPending={textPending} textPending={textPending}
/> />
{this.getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
</div> </div>
); );
} }
@ -1680,14 +1803,13 @@ export class Message extends React.PureComponent<Props, State> {
} }
if (isSticker) { if (isSticker) {
// Padding is 8px, on both sides, plus two for 1px border // Padding is 8px, on both sides
return STICKER_SIZE + 8 * 2 + 2; return STICKER_SIZE + 8 * 2;
} }
const dimensions = getGridDimensions(attachments); const dimensions = getGridDimensions(attachments);
if (dimensions) { if (dimensions) {
// Add two for 1px border return dimensions.width;
return dimensions.width + 2;
} }
} }
@ -1699,8 +1821,7 @@ export class Message extends React.PureComponent<Props, State> {
) { ) {
const dimensions = getImageDimensions(firstLinkPreview.image); const dimensions = getImageDimensions(firstLinkPreview.image);
if (dimensions) { if (dimensions) {
// Add two for 1px border return dimensions.width;
return dimensions.width + 2;
} }
} }
@ -1797,13 +1918,14 @@ export class Message extends React.PureComponent<Props, State> {
public renderTapToView(): JSX.Element { public renderTapToView(): JSX.Element {
const { const {
collapseMetadata,
conversationType, conversationType,
direction, direction,
isTapToViewExpired, isTapToViewExpired,
isTapToViewError, isTapToViewError,
} = this.props; } = this.props;
const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
const withContentBelow = !collapseMetadata; const withContentBelow = !collapseMetadata;
const withContentAbove = const withContentAbove =
!collapseMetadata && !collapseMetadata &&
@ -2372,7 +2494,6 @@ export class Message extends React.PureComponent<Props, State> {
isSelected && !isStickerLike isSelected && !isStickerLike
? 'module-message__container--selected' ? 'module-message__container--selected'
: null, : null,
isStickerLike ? 'module-message__container--with-sticker' : null,
!isStickerLike ? `module-message__container--${direction}` : null, !isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null, isEmojiOnly ? 'module-message__container--emoji' : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null, isTapToView ? 'module-message__container--with-tap-to-view' : null,
@ -2440,9 +2561,10 @@ 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',
this.isCollapsedBelow() && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null, isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null, expiring ? 'module-message--expired' : null
this.hasAvatar() ? 'module-message--with-avatar' : null
)} )}
tabIndex={0} tabIndex={0}
// We pretend to be a button because we sometimes contain buttons and a button // We pretend to be a button because we sometimes contain buttons and a button

View File

@ -19,6 +19,7 @@ export type Props = {
renderingContext: string; renderingContext: string;
i18n: LocalizerType; i18n: LocalizerType;
attachment: AttachmentType; attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean; withContentAbove: boolean;
withContentBelow: boolean; withContentBelow: boolean;
@ -151,6 +152,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
i18n, i18n,
renderingContext, renderingContext,
attachment, attachment,
collapseMetadata,
withContentAbove, withContentAbove,
withContentBelow, withContentBelow,
@ -530,7 +532,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
const metadata = ( const metadata = (
<div className={`${CSS_BASE}__metadata`}> <div className={`${CSS_BASE}__metadata`}>
{!withContentBelow && ( {!withContentBelow && !collapseMetadata && (
<MessageMetadata <MessageMetadata
direction={direction} direction={direction}
expirationLength={expirationLength} expirationLength={expirationLength}

View File

@ -1,9 +1,10 @@
// Copyright 2018-2022 Signal Messenger, LLC // Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent, ReactChild } from 'react'; import type { ReactChild, ReactElement } from 'react';
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Measure from 'react-measure';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import type { DirectionType, MessageStatusType } from './Message'; import type { DirectionType, MessageStatusType } from './Message';
@ -19,35 +20,37 @@ type PropsType = {
hasText: boolean; hasText: boolean;
i18n: LocalizerType; i18n: LocalizerType;
id: string; id: string;
isInline?: boolean;
isShowingImage: boolean; isShowingImage: boolean;
isSticker?: boolean; isSticker?: boolean;
isTapToViewExpired?: boolean; isTapToViewExpired?: boolean;
now: number; now: number;
onWidthMeasured?: (width: number) => unknown;
showMessageDetail: (id: string) => void; showMessageDetail: (id: string) => void;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
timestamp: number; timestamp: number;
}; };
export const MessageMetadata: FunctionComponent<PropsType> = props => { export const MessageMetadata = ({
const { deletedForEveryone,
deletedForEveryone, direction,
direction, expirationLength,
expirationLength, expirationTimestamp,
expirationTimestamp, hasText,
hasText, i18n,
i18n, id,
id, isInline,
isShowingImage, isShowingImage,
isSticker, isSticker,
isTapToViewExpired, isTapToViewExpired,
now, now,
showMessageDetail, onWidthMeasured,
status, showMessageDetail,
textPending, status,
timestamp, textPending,
} = props; timestamp,
}: Readonly<PropsType>): ReactElement => {
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage); const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
const metadataDirection = isSticker ? undefined : direction; const metadataDirection = isSticker ? undefined : direction;
@ -114,16 +117,13 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
} }
} }
return ( const className = classNames(
<div 'module-message__metadata',
className={classNames( isInline && 'module-message__metadata--inline',
'module-message__metadata', withImageNoCaption && 'module-message__metadata--with-image-no-caption'
`module-message__metadata--${direction}`, );
withImageNoCaption const children = (
? 'module-message__metadata--with-image-no-caption' <>
: null
)}
>
{timestampNode} {timestampNode}
{expirationLength && expirationTimestamp ? ( {expirationLength && expirationTimestamp ? (
<ExpireTimer <ExpireTimer
@ -161,6 +161,25 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
)} )}
/> />
) : null} ) : null}
</div> </>
); );
if (onWidthMeasured) {
return (
<Measure
bounds
onResize={({ bounds }) => {
onWidthMeasured(bounds?.width || 0);
}}
>
{({ measureRef }) => (
<div className={className} ref={measureRef}>
{children}
</div>
)}
</Measure>
);
}
return <div className={className}>{children}</div>;
}; };

View File

@ -0,0 +1,13 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
const SPACING = 10;
export const MessageTextMetadataSpacer = ({
metadataWidth,
}: Readonly<{ metadataWidth: number }>): ReactElement => (
<span style={{ display: 'inline-block', width: metadataWidth + SPACING }} />
);

View File

@ -23,6 +23,8 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
export type Props = { export type Props = {
authorTitle: string; authorTitle: string;
conversationColor: ConversationColorType; conversationColor: ConversationColorType;
curveTopLeft?: boolean;
curveTopRight?: boolean;
customColor?: CustomColorType; customColor?: CustomColorType;
bodyRanges?: BodyRangesType; bodyRanges?: BodyRangesType;
i18n: LocalizerType; i18n: LocalizerType;
@ -422,6 +424,8 @@ export class Quote extends React.Component<Props, State> {
public override render(): JSX.Element | null { public override render(): JSX.Element | null {
const { const {
conversationColor, conversationColor,
curveTopLeft,
curveTopRight,
customColor, customColor,
isIncoming, isIncoming,
onClick, onClick,
@ -444,10 +448,10 @@ export class Quote extends React.Component<Props, State> {
isIncoming isIncoming
? `module-quote--incoming-${conversationColor}` ? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`, : `module-quote--outgoing-${conversationColor}`,
!onClick ? 'module-quote--no-click' : null, !onClick && 'module-quote--no-click',
referencedMessageNotFound referencedMessageNotFound && 'module-quote--with-reference-warning',
? 'module-quote--with-reference-warning' curveTopLeft && 'module-quote--curve-top-left',
: null curveTopRight && 'module-quote--curve-top-right'
)} )}
style={{ ...getCustomColorStyle(customColor, true) }} style={{ ...getCustomColorStyle(customColor, true) }}
> >

View File

@ -32,7 +32,10 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions'; import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
import { TimelineFloatingHeader } from './TimelineFloatingHeader'; import { TimelineFloatingHeader } from './TimelineFloatingHeader';
import { getWidthBreakpoint } from '../../util/timelineUtil'; import {
getWidthBreakpoint,
UnreadIndicatorPlacement,
} from '../../util/timelineUtil';
import { import {
getScrollBottom, getScrollBottom,
scrollToBottom, scrollToBottom,
@ -117,6 +120,7 @@ type PropsHousekeepingType = {
nextMessageId: undefined | string; nextMessageId: undefined | string;
now: number; now: number;
previousMessageId: undefined | string; previousMessageId: undefined | string;
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}) => JSX.Element; }) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: ( renderHeroRow: (
@ -839,8 +843,11 @@ export class Timeline extends React.Component<
const messageNodes: Array<ReactChild> = []; const messageNodes: Array<ReactChild> = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const previousMessageId: undefined | string = items[itemIndex - 1]; const previousItemIndex = itemIndex - 1;
const nextMessageId: undefined | string = items[itemIndex + 1]; const nextItemIndex = itemIndex + 1;
const previousMessageId: undefined | string = items[previousItemIndex];
const nextMessageId: undefined | string = items[nextItemIndex];
const messageId = items[itemIndex]; const messageId = items[itemIndex];
if (!messageId) { if (!messageId) {
@ -851,10 +858,14 @@ export class Timeline extends React.Component<
continue; continue;
} }
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnreadIndex === itemIndex) { if (oldestUnreadIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push( messageNodes.push(
<Fragment key="unread">{renderLastSeenIndicator(id)}</Fragment> <Fragment key="unread">{renderLastSeenIndicator(id)}</Fragment>
); );
} else if (oldestUnreadIndex === nextItemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
} }
messageNodes.push( messageNodes.push(
@ -874,6 +885,7 @@ export class Timeline extends React.Component<
nextMessageId, nextMessageId,
now: nowThatUpdatesEveryMinute, now: nowThatUpdatesEveryMinute,
previousMessageId, previousMessageId,
unreadIndicatorPlacement,
})} })}
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@ -3,7 +3,6 @@
import type { ReactChild, RefObject } from 'react'; import type { ReactChild, RefObject } from 'react';
import React from 'react'; import React from 'react';
import { omit } from 'lodash';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import { isSameDay } from '../../util/timestamp'; import { isSameDay } from '../../util/timestamp';
@ -54,6 +53,7 @@ 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 = {
@ -156,6 +156,7 @@ type PropsLocalType = {
previousItem: undefined | TimelineItemType; previousItem: undefined | TimelineItemType;
nextItem: undefined | TimelineItemType; nextItem: undefined | TimelineItemType;
now: number; now: number;
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
}; };
type PropsActionsType = MessageActionsType & type PropsActionsType = MessageActionsType &
@ -196,6 +197,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
returnToActiveCall, returnToActiveCall,
selectMessage, selectMessage,
startCallingLobby, startCallingLobby,
unreadIndicatorPlacement,
} = this.props; } = this.props;
if (!item) { if (!item) {
@ -212,13 +214,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
if (item.type === 'message') { if (item.type === 'message') {
itemContents = ( itemContents = (
<Message <Message
{...omit(this.props, ['item'])} {...this.props}
{...item.data} {...item.data}
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 {

View File

@ -21,6 +21,7 @@ export type Props = {
renderingContext: string; renderingContext: string;
i18n: LocalizerType; i18n: LocalizerType;
attachment: AttachmentType; attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean; withContentAbove: boolean;
withContentBelow: boolean; withContentBelow: boolean;

View File

@ -46,6 +46,7 @@ import {
invertIdsByTitle, invertIdsByTitle,
} from '../../util/groupMemberNameCollisions'; } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { WidthBreakpoint } from '../../components/_util'; import type { WidthBreakpoint } from '../../components/_util';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
@ -109,6 +110,7 @@ function renderItem({
nextMessageId, nextMessageId,
now, now,
previousMessageId, previousMessageId,
unreadIndicatorPlacement,
}: { }: {
actionProps: TimelineActionsType; actionProps: TimelineActionsType;
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
@ -119,6 +121,7 @@ function renderItem({
nextMessageId: undefined | string; nextMessageId: undefined | string;
now: number; now: number;
previousMessageId: undefined | string; previousMessageId: undefined | string;
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}): JSX.Element { }): JSX.Element {
return ( return (
<SmartTimelineItem <SmartTimelineItem
@ -134,6 +137,7 @@ function renderItem({
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}
unreadIndicatorPlacement={unreadIndicatorPlacement}
/> />
); );
} }

View File

@ -16,6 +16,7 @@ import {
getMessageSelector, getMessageSelector,
getSelectedMessage, getSelectedMessage,
} from '../selectors/conversations'; } from '../selectors/conversations';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import { SmartContactName } from './ContactName'; import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification'; import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
@ -28,6 +29,7 @@ type ExternalProps = {
nextMessageId: undefined | string; nextMessageId: undefined | string;
previousMessageId: undefined | string; previousMessageId: undefined | string;
now: number; now: number;
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}; };
function renderContact(conversationId: string): JSX.Element { function renderContact(conversationId: string): JSX.Element {
@ -47,6 +49,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
nextMessageId, nextMessageId,
previousMessageId, previousMessageId,
now, now,
unreadIndicatorPlacement,
} = props; } = props;
const messageSelector = getMessageSelector(state); const messageSelector = getMessageSelector(state);
@ -82,6 +85,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state), i18n: getIntl(state),
interactionMode: getInteractionMode(state), interactionMode: getInteractionMode(state),
theme: getTheme(state), theme: getTheme(state),
unreadIndicatorPlacement,
}; };
}; };

View File

@ -0,0 +1,116 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { MINUTE, SECOND } from '../../util/durations';
import { areMessagesInSameGroup } from '../../util/timelineUtil';
describe('<Timeline> utilities', () => {
describe('areMessagesInSameGroup', () => {
const defaultNewer = {
type: 'message' as const,
data: {
author: { id: uuid() },
timestamp: new Date(1998, 10, 21, 12, 34, 56, 123).valueOf(),
},
};
const defaultOlder = {
...defaultNewer,
data: {
...defaultNewer.data,
timestamp: defaultNewer.data.timestamp - MINUTE,
},
};
it('returns false if either item is missing', () => {
assert.isFalse(areMessagesInSameGroup(undefined, false, undefined));
assert.isFalse(areMessagesInSameGroup(defaultNewer, false, undefined));
assert.isFalse(areMessagesInSameGroup(undefined, false, defaultNewer));
});
it('returns false if either item is not a message', () => {
const linkNotification = {
type: 'linkNotification' as const,
data: null,
timestamp: Date.now(),
};
assert.isFalse(
areMessagesInSameGroup(defaultNewer, false, linkNotification)
);
assert.isFalse(
areMessagesInSameGroup(linkNotification, false, defaultNewer)
);
assert.isFalse(
areMessagesInSameGroup(linkNotification, false, linkNotification)
);
});
it("returns false if authors don't match", () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, author: { id: uuid() } },
};
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
});
it('returns false if the older item was sent more than 3 minutes before', () => {
const older = {
...defaultNewer,
data: {
...defaultNewer.data,
timestamp: defaultNewer.data.timestamp - 3 * MINUTE - SECOND,
},
};
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
});
it('returns false if the older item was somehow sent in the future', () => {
assert.isFalse(areMessagesInSameGroup(defaultNewer, false, defaultOlder));
});
it("returns false if the older item was sent across a day boundary, even if they're otherwise <3m apart", () => {
const older = {
...defaultOlder,
data: {
...defaultOlder.data,
timestamp: new Date(2000, 2, 2, 23, 59, 0, 0).valueOf(),
},
};
const newer = {
...defaultNewer,
data: {
...defaultNewer.data,
timestamp: new Date(2000, 2, 3, 0, 1, 0, 0).valueOf(),
},
};
assert.isBelow(
newer.data.timestamp - older.data.timestamp,
3 * MINUTE,
'Test was set up incorrectly'
);
assert.isFalse(areMessagesInSameGroup(older, false, newer));
});
it('returns false if the older item has reactions', () => {
const older = {
...defaultOlder,
data: { ...defaultOlder.data, reactions: [{}] },
};
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
});
it("returns false if there's an unread indicator in the middle", () => {
assert.isFalse(areMessagesInSameGroup(defaultOlder, true, defaultNewer));
});
it('returns true if the everything above works out', () => {
assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer));
});
});
});

View File

@ -1,7 +1,64 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { TimelineItemType } from '../components/conversation/TimelineItem';
import { WidthBreakpoint } from '../components/_util'; import { WidthBreakpoint } from '../components/_util';
import { MINUTE } from './durations';
import { isSameDay } from './timestamp';
const COLLAPSE_WITHIN = 3 * MINUTE;
export enum UnreadIndicatorPlacement {
JustAbove,
JustBelow,
}
type MessageTimelineItemDataType = Readonly<{
author: { id: string };
reactions?: ReadonlyArray<unknown>;
timestamp: number;
}>;
// This lets us avoid passing a full `MessageType`. That's useful for tests and for
// documentation.
type MaybeMessageTimelineItemType = Readonly<
| undefined
| TimelineItemType
| { type: 'message'; data: MessageTimelineItemDataType }
>;
const getMessageTimelineItemData = (
timelineItem: MaybeMessageTimelineItemType
): undefined | MessageTimelineItemDataType =>
timelineItem?.type === 'message' ? timelineItem.data : undefined;
export function areMessagesInSameGroup(
olderTimelineItem: MaybeMessageTimelineItemType,
unreadIndicator: boolean,
newerTimelineItem: MaybeMessageTimelineItemType
): boolean {
if (unreadIndicator) {
return false;
}
const olderMessage = getMessageTimelineItemData(olderTimelineItem);
if (!olderMessage) {
return false;
}
const newerMessage = getMessageTimelineItemData(newerTimelineItem);
if (!newerMessage) {
return false;
}
return Boolean(
!olderMessage.reactions?.length &&
olderMessage.author.id === newerMessage.author.id &&
newerMessage.timestamp >= olderMessage.timestamp &&
newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN &&
isSameDay(olderMessage.timestamp, newerMessage.timestamp)
);
}
export function getWidthBreakpoint(width: number): WidthBreakpoint { export function getWidthBreakpoint(width: number): WidthBreakpoint {
if (width > 606) { if (width > 606) {