diff --git a/sticker-creator/elements/MessageBubble.scss b/sticker-creator/elements/MessageBubble.scss
index c4ebc776c6e1..607bba613864 100644
--- a/sticker-creator/elements/MessageBubble.scss
+++ b/sticker-creator/elements/MessageBubble.scss
@@ -1,4 +1,4 @@
-// Copyright 2019-2021 Signal Messenger, LLC
+// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@import '../../stylesheets/variables';
@@ -6,7 +6,7 @@
.base {
background-color: $color-ultramarine;
padding: 6px 12px;
- border-radius: 16px;
+ border-radius: 18px;
color: $color-white-alpha-90;
font: {
size: 12px;
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index 72a0cc1613be..626233473134 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -51,26 +51,6 @@
// 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 {
position: relative;
display: flex;
@@ -79,6 +59,7 @@
outline: none;
padding-left: 16px;
padding-right: 16px;
+ transition: background 0.1s ease-out;
}
.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;
width: 24px;
- cursor: pointer;
+
@include light-theme {
- @include color-svg(
- '../images/icons/v2/save-outline-24.svg',
- $color-gray-45
- );
+ @include color-svg($light-icon, $color-gray-45);
&:hover {
- @include color-svg(
- '../images/icons/v2/save-outline-24.svg',
- $color-gray-90
- );
+ @include color-svg($light-icon, $color-gray-90);
}
}
+
@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 {
- @include color-svg(
- '../images/icons/v2/save-solid-24.svg',
- $color-gray-02
- );
+ @include color-svg($light-icon, $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 {
- height: 24px;
- width: 24px;
- 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
- );
- }
- }
+ @include module-message__buttons__button(
+ '../images/icons/v2/add-emoji-outline-24.svg'
+ );
}
.module-message__buttons__reply {
- height: 24px;
- width: 24px;
- cursor: pointer;
-
- @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
- );
- }
- }
+ @include module-message__buttons__button(
+ '../images/icons/v2/reply-outline-24.svg',
+ '../images/icons/v2/reply-solid-24.svg'
+ );
}
.module-message__buttons__menu {
- height: 24px;
- width: 24px;
- 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
- );
- }
- }
+ @include module-message__buttons__button(
+ '../images/icons/v2/more-horiz-24.svg'
+ );
&--container {
border-radius: 4px;
@@ -256,13 +191,6 @@
// 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.
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 {
+ $collapsed-border-radius: 4px;
+
position: relative;
display: inline-block;
- border-radius: 16px;
+ border-radius: 18px;
margin-bottom: 4px;
margin-top: 4px;
min-width: 0px;
@@ -343,71 +273,47 @@
padding: {
left: 12px;
right: 12px;
- top: 10px;
- bottom: 7px;
+ top: 8px;
+ bottom: 8px;
}
- @include light-theme {
- border: 1px solid $color-white;
+ .module-message--collapsed-above & {
+ margin-top: 1px;
+ }
+ .module-message--collapsed-below & {
+ margin-bottom: 1px;
}
- @include dark-theme {
- border: 1px solid $color-gray-95;
+ .module-message--incoming.module-message--collapsed-above & {
+ 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__container {
+.module-message--selected {
@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 {
- box-shadow: 0 0 0 3px $color-ultramarine;
+ background: $color-selected-message-background-light;
}
-}
-
-@keyframes message--mouse-selected {
- 0% {
- box-shadow: 0 0 0 5px transparent;
+ @include dark-keyboard-mode {
+ background: $color-selected-message-background-dark;
}
- 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 {
@@ -600,8 +506,8 @@
margin: {
left: -12px;
right: -12px;
- top: -10px;
- bottom: -7px;
+ top: -8px;
+ bottom: -8px;
}
line-height: 0;
@@ -1055,13 +961,15 @@
}
.module-message__metadata {
+ align-items: center;
display: flex;
flex-direction: row;
- align-items: center;
+ justify-content: flex-end;
margin-top: 3px;
- &--outgoing {
- justify-content: flex-end;
+ &--inline {
+ float: right;
+ margin-top: -14px;
}
}
@@ -1242,9 +1150,10 @@
display: flex;
justify-content: center;
margin-right: 8px;
+ padding-bottom: 6px;
&--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 {
flex-grow: 1;
padding-left: 8px;
@@ -2037,10 +1954,10 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
&:focus {
@include keyboard-mode {
- background-color: $color-gray-02;
+ background: $color-selected-message-background-light;
}
@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 {
- border-top-left-radius: 16px;
+ border-top-left-radius: 18px;
}
.module-image--curved-top-right {
- border-top-right-radius: 16px;
+ border-top-right-radius: 18px;
}
.module-image--curved-bottom-left {
- border-bottom-left-radius: 16px;
+ border-bottom-left-radius: 18px;
}
.module-image--curved-bottom-right {
- border-bottom-right-radius: 16px;
+ border-bottom-right-radius: 18px;
}
.module-image--small-curved-top-left {
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 {
@include keyboard-mode {
box-shadow: inset 0px 0px 0px 2px $color-ultramarine;
@@ -8303,7 +8188,7 @@ button.module-image__border-overlay:focus {
}
&--with-reactions {
- margin-bottom: -9px;
+ margin-bottom: -6px;
}
&--deleted-for-everyone {
diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss
index cb91e83e05b7..1eb20b7de3cf 100644
--- a/stylesheets/_variables.scss
+++ b/stylesheets/_variables.scss
@@ -230,6 +230,9 @@ $color-white-alpha-40: rgba($color-white, 0.4);
// Used in tap-to-view error states
$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
$header-height: 52px;
diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss
index a4e2fcfe209a..cedc9beb8622 100644
--- a/stylesheets/components/SystemMessage.scss
+++ b/stylesheets/components/SystemMessage.scss
@@ -1,4 +1,4 @@
-// Copyright 2021 Signal Messenger, LLC
+// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@mixin system-message-icon($light, $dark) {
@@ -19,8 +19,8 @@
flex-direction: column;
justify-content: center;
line-height: 16px;
- margin-bottom: 16px;
- margin-top: 16px;
+ padding-bottom: 16px;
+ padding-top: 16px;
@include light-theme {
color: $color-gray-60;
diff --git a/ts/components/AvatarSpacer.tsx b/ts/components/AvatarSpacer.tsx
new file mode 100644
index 000000000000..54f63e77579f
--- /dev/null
+++ b/ts/components/AvatarSpacer.tsx
@@ -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 => (
+
+);
diff --git a/ts/components/conversation/GroupNotification.stories.tsx b/ts/components/conversation/GroupNotification.stories.tsx
index 0ab00d76761c..5ae6bb1331c5 100644
--- a/ts/components/conversation/GroupNotification.stories.tsx
+++ b/ts/components/conversation/GroupNotification.stories.tsx
@@ -1,4 +1,4 @@
-// Copyright 2020-2021 Signal Messenger, LLC
+// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@@ -383,17 +383,11 @@ book.add('GroupNotification', () =>
stories.map(([title, propsArray]) => (
<>
{title}
- {propsArray.map((props, i) => {
- return (
- <>
-
- >
- );
- })}
+ {propsArray.map((props, i) => (
+
+
+
+ ))}
>
))
);
diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx
index 6899950decc4..ed95f81997b5 100644
--- a/ts/components/conversation/Message.stories.tsx
+++ b/ts/components/conversation/Message.stories.tsx
@@ -29,6 +29,7 @@ import enMessages from '../../../_locales/en/messages.json';
import { pngUrl } from '../../storybook/Fixtures';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { WidthBreakpoint } from '../_util';
+import { MINUTE } from '../../util/durations';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
@@ -108,7 +109,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
- collapseMetadata: overrideProps.collapseMetadata,
containerElementRef: React.createRef(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor:
@@ -188,13 +188,33 @@ const createProps = (overrideProps: Partial = {}): Props => ({
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
});
-const renderBothDirections = (props: Props) => (
- <>
-
-
-
- >
-);
+const createTimelineItem = (data: undefined | Props) =>
+ data && {
+ type: 'message' as const,
+ data,
+ timestamp: data.timestamp,
+ };
+
+const renderMany = (propsArray: ReadonlyArray) =>
+ propsArray.map((message, index) => (
+
+ ));
+
+const renderBothDirections = (props: Props) =>
+ renderMany([
+ props,
+ {
+ ...props,
+ author: { ...props.author, id: getDefaultConversation().id },
+ direction: 'outgoing',
+ },
+ ]);
story.add('Plain Message', () => {
const props = createProps({
@@ -350,17 +370,6 @@ story.add('Pending', () => {
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', () => {
const props = createProps({
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',
+ }),
+ ]);
+});
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index 5c4a1bdf17e3..7b19df5b5d69 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -15,14 +15,17 @@ import type {
ConversationTypeType,
InteractionModeType,
} from '../../state/ducks/conversations';
+import type { TimelineItemType } from './TimelineItem';
import { ReadStatus } from '../../messages/MessageReadStatus';
-import { Avatar } from '../Avatar';
+import { Avatar, AvatarSize } from '../Avatar';
+import { AvatarSpacer } from '../AvatarSpacer';
import { Spinner } from '../Spinner';
import {
doesMessageBodyOverflow,
MessageBodyReadMore,
} from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata';
+import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
import { ImageGrid } from './ImageGrid';
import { GIF } from './GIF';
import { Image } from './Image';
@@ -80,15 +83,36 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import { offsetDistanceModifier } from '../../util/popperUtil';
import * as KeyboardLayout from '../../services/keyboardLayout';
import { StopPropagation } from '../StopPropagation';
+import {
+ areMessagesInSameGroup,
+ UnreadIndicatorPlacement,
+} from '../../util/timelineUtil';
type Trigger = {
handleContextClick: (event: React.MouseEvent) => 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 GIF_SIZE = 300;
const SELECTED_TIMEOUT = 1000;
const THREE_HOURS = 3 * 60 * 60 * 1000;
+const SENT_STATUSES = new Set([
+ 'delivered',
+ 'read',
+ 'sent',
+ 'viewed',
+]);
+
+enum MetadataPlacement {
+ NotRendered,
+ RenderedByMessageAudioComponent,
+ InlineWithText,
+ Bottom,
+}
export const MessageStatuses = [
'delivered',
@@ -111,6 +135,7 @@ export type AudioAttachmentProps = {
buttonRef: React.RefObject;
theme: ThemeType | undefined;
attachment: AttachmentType;
+ collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
@@ -211,18 +236,21 @@ export type PropsData = {
export type PropsHousekeeping = {
containerElementRef: RefObject;
containerWidthBreakpoint: WidthBreakpoint;
+ disableMenu?: boolean;
+ disableScroll?: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
now: number;
interactionMode: InteractionModeType;
- theme: ThemeType;
- disableMenu?: boolean;
- disableScroll?: boolean;
- collapseMetadata?: boolean;
+ item?: TimelineItemType;
+ nextItem?: TimelineItemType;
+ previousItem?: TimelineItemType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderReactionPicker: (
props: React.ComponentProps
) => JSX.Element;
+ theme: ThemeType;
+ unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
};
export type PropsActions = {
@@ -287,6 +315,8 @@ export type Props = PropsData &
Pick;
type State = {
+ metadataWidth: number;
+
expiring: boolean;
expired: boolean;
imageBroken: boolean;
@@ -300,9 +330,6 @@ type State = {
hasDeleteForEveryoneTimerExpired: boolean;
};
-const EXPIRATION_CHECK_MINIMUM = 2000;
-const EXPIRED_DELAY = 600;
-
export class Message extends React.PureComponent {
public menuTriggerRef: Trigger | undefined;
@@ -327,6 +354,8 @@ export class Message extends React.PureComponent {
super(props);
this.state = {
+ metadataWidth: DEFAULT_METADATA_WIDTH,
+
expiring: false,
expired: false,
imageBroken: false,
@@ -464,7 +493,7 @@ export class Message extends React.PureComponent {
this.toggleReactionPicker(true);
}
- public override componentDidUpdate(prevProps: Props): void {
+ public override componentDidUpdate(prevProps: Readonly): void {
const { isSelected, status, timestamp } = this.props;
this.startSelectedTimer();
@@ -494,6 +523,37 @@ export class Message extends React.PureComponent {
}
}
+ private getMetadataPlacement(
+ {
+ attachments,
+ expirationLength,
+ expirationTimestamp,
+ status,
+ text,
+ }: Readonly = 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 {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
@@ -569,6 +629,37 @@ export class Message extends React.PureComponent {
return isMessageRequestAccepted && !isBlocked;
}
+ private isCollapsedAbove(
+ { item, previousItem, unreadIndicatorPlacement }: Readonly = this
+ .props
+ ): boolean {
+ return areMessagesInSameGroup(
+ previousItem,
+ unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove,
+ item
+ );
+ }
+
+ private isCollapsedBelow(
+ { item, nextItem, unreadIndicatorPlacement }: Readonly = 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 {
const { text, quote, attachments, previews } = this.props;
@@ -582,10 +673,34 @@ export class Message extends React.PureComponent {
);
}
- 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 {
- attachments,
- collapseMetadata,
deletedForEveryone,
direction,
expirationLength,
@@ -602,16 +717,6 @@ export class Message extends React.PureComponent {
showMessageDetail,
} = 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();
return (
@@ -623,10 +728,12 @@ export class Message extends React.PureComponent {
hasText={Boolean(text)}
i18n={i18n}
id={id}
+ isInline={isInline}
isShowingImage={this.isShowingImage()}
isSticker={isStickerLike}
isTapToViewExpired={isTapToViewExpired}
now={now}
+ onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
showMessageDetail={showMessageDetail}
status={status}
textPending={textPending}
@@ -635,27 +742,16 @@ export class Message extends React.PureComponent {
);
}
- public renderAuthor(): JSX.Element | null {
+ private renderAuthor(): ReactNode {
const {
author,
- collapseMetadata,
contactNameColor,
- conversationType,
- direction,
isSticker,
isTapToView,
isTapToViewExpired,
} = this.props;
- if (collapseMetadata) {
- return null;
- }
-
- if (
- direction !== 'incoming' ||
- conversationType !== 'group' ||
- !author.title
- ) {
+ if (!this.shouldRenderAuthor()) {
return null;
}
@@ -681,8 +777,6 @@ export class Message extends React.PureComponent {
public renderAttachment(): JSX.Element | null {
const {
attachments,
- collapseMetadata,
- conversationType,
direction,
expirationLength,
expirationTimestamp,
@@ -709,6 +803,9 @@ export class Message extends React.PureComponent {
const { imageBroken } = this.state;
+ const collapseMetadata =
+ this.getMetadataPlacement() === MetadataPlacement.NotRendered;
+
if (!attachments || !attachments[0]) {
return null;
}
@@ -716,9 +813,7 @@ export class Message extends React.PureComponent {
// For attachments which aren't full-frame
const withContentBelow = Boolean(text);
- const withContentAbove =
- Boolean(quote) ||
- (conversationType === 'group' && direction === 'incoming');
+ const withContentAbove = Boolean(quote) || this.shouldRenderAuthor();
const displayImage = canDisplayImage(attachments);
if (displayImage && !imageBroken) {
@@ -773,8 +868,12 @@ export class Message extends React.PureComponent {
{
renderingContext,
theme,
attachment: firstAttachment,
+ collapseMetadata,
withContentAbove,
withContentBelow,
@@ -1085,17 +1185,34 @@ export class Message extends React.PureComponent {
});
};
+ 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 (
{
public renderEmbeddedContact(): JSX.Element | null {
const {
- collapseMetadata,
contact,
conversationType,
direction,
@@ -1123,7 +1239,9 @@ export class Message extends React.PureComponent {
const withCaption = Boolean(text);
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
- const withContentBelow = withCaption || !collapseMetadata;
+ const withContentBelow =
+ withCaption ||
+ this.getMetadataPlacement() !== MetadataPlacement.NotRendered;
const otherContent =
(contact && contact.firstNumber && contact.isNumberOnSignal) ||
@@ -1166,22 +1284,19 @@ export class Message extends React.PureComponent {
);
}
- public hasAvatar(): boolean {
- const { collapseMetadata, conversationType, direction } = this.props;
+ private renderAvatar(): ReactNode {
+ const {
+ author,
+ getPreferredBadge,
+ i18n,
+ showContactModal,
+ theme,
+ conversationType,
+ direction,
+ } = this.props;
- return Boolean(
- !collapseMetadata &&
- conversationType === 'group' &&
- direction !== 'outgoing'
- );
- }
-
- public renderAvatar(): JSX.Element | undefined {
- const { author, getPreferredBadge, i18n, showContactModal, theme } =
- this.props;
-
- if (!this.hasAvatar()) {
- return undefined;
+ if (conversationType !== 'group' || direction !== 'incoming') {
+ return null;
}
return (
@@ -1191,29 +1306,33 @@ export class Message extends React.PureComponent {
this.hasReactions(),
})}
>
- {
- event.stopPropagation();
- event.preventDefault();
+ {this.isCollapsedBelow() ? (
+
+ ) : (
+ {
+ event.stopPropagation();
+ event.preventDefault();
- showContactModal(author.id);
- }}
- phoneNumber={author.phoneNumber}
- profileName={author.profileName}
- sharedGroupNames={author.sharedGroupNames}
- size={28}
- theme={theme}
- title={author.title}
- unblurredAvatarPath={author.unblurredAvatarPath}
- />
+ showContactModal(author.id);
+ }}
+ phoneNumber={author.phoneNumber}
+ profileName={author.profileName}
+ sharedGroupNames={author.sharedGroupNames}
+ size={GROUP_AVATAR_SIZE}
+ theme={theme}
+ title={author.title}
+ unblurredAvatarPath={author.unblurredAvatarPath}
+ />
+ )}
);
}
@@ -1232,6 +1351,7 @@ export class Message extends React.PureComponent {
text,
textPending,
} = this.props;
+ const { metadataWidth } = this.state;
// eslint-disable-next-line no-nested-ternary
const contents = deletedForEveryone
@@ -1267,6 +1387,9 @@ export class Message extends React.PureComponent {
text={contents || ''}
textPending={textPending}
/>
+ {this.getMetadataPlacement() === MetadataPlacement.InlineWithText && (
+
+ )}
);
}
@@ -1680,14 +1803,13 @@ export class Message extends React.PureComponent {
}
if (isSticker) {
- // Padding is 8px, on both sides, plus two for 1px border
- return STICKER_SIZE + 8 * 2 + 2;
+ // Padding is 8px, on both sides
+ return STICKER_SIZE + 8 * 2;
}
const dimensions = getGridDimensions(attachments);
if (dimensions) {
- // Add two for 1px border
- return dimensions.width + 2;
+ return dimensions.width;
}
}
@@ -1699,8 +1821,7 @@ export class Message extends React.PureComponent {
) {
const dimensions = getImageDimensions(firstLinkPreview.image);
if (dimensions) {
- // Add two for 1px border
- return dimensions.width + 2;
+ return dimensions.width;
}
}
@@ -1797,13 +1918,14 @@ export class Message extends React.PureComponent {
public renderTapToView(): JSX.Element {
const {
- collapseMetadata,
conversationType,
direction,
isTapToViewExpired,
isTapToViewError,
} = this.props;
+ const collapseMetadata =
+ this.getMetadataPlacement() === MetadataPlacement.NotRendered;
const withContentBelow = !collapseMetadata;
const withContentAbove =
!collapseMetadata &&
@@ -2372,7 +2494,6 @@ export class Message extends React.PureComponent {
isSelected && !isStickerLike
? 'module-message__container--selected'
: null,
- isStickerLike ? 'module-message__container--with-sticker' : null,
!isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
@@ -2440,9 +2561,10 @@ export class Message extends React.PureComponent {
className={classNames(
'module-message',
`module-message--${direction}`,
+ this.isCollapsedAbove() && 'module-message--collapsed-above',
+ this.isCollapsedBelow() && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null,
- expiring ? 'module-message--expired' : null,
- this.hasAvatar() ? 'module-message--with-avatar' : null
+ expiring ? 'module-message--expired' : null
)}
tabIndex={0}
// We pretend to be a button because we sometimes contain buttons and a button
diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx
index a4ec647684ae..5b987765e2df 100644
--- a/ts/components/conversation/MessageAudio.tsx
+++ b/ts/components/conversation/MessageAudio.tsx
@@ -19,6 +19,7 @@ export type Props = {
renderingContext: string;
i18n: LocalizerType;
attachment: AttachmentType;
+ collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
@@ -151,6 +152,7 @@ export const MessageAudio: React.FC = (props: Props) => {
i18n,
renderingContext,
attachment,
+ collapseMetadata,
withContentAbove,
withContentBelow,
@@ -530,7 +532,7 @@ export const MessageAudio: React.FC = (props: Props) => {
const metadata = (
- {!withContentBelow && (
+ {!withContentBelow && !collapseMetadata && (
unknown;
showMessageDetail: (id: string) => void;
status?: MessageStatusType;
textPending?: boolean;
timestamp: number;
};
-export const MessageMetadata: FunctionComponent = props => {
- const {
- deletedForEveryone,
- direction,
- expirationLength,
- expirationTimestamp,
- hasText,
- i18n,
- id,
- isShowingImage,
- isSticker,
- isTapToViewExpired,
- now,
- showMessageDetail,
- status,
- textPending,
- timestamp,
- } = props;
-
+export const MessageMetadata = ({
+ deletedForEveryone,
+ direction,
+ expirationLength,
+ expirationTimestamp,
+ hasText,
+ i18n,
+ id,
+ isInline,
+ isShowingImage,
+ isSticker,
+ isTapToViewExpired,
+ now,
+ onWidthMeasured,
+ showMessageDetail,
+ status,
+ textPending,
+ timestamp,
+}: Readonly): ReactElement => {
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
const metadataDirection = isSticker ? undefined : direction;
@@ -114,16 +117,13 @@ export const MessageMetadata: FunctionComponent = props => {
}
}
- return (
-
+ const className = classNames(
+ 'module-message__metadata',
+ isInline && 'module-message__metadata--inline',
+ withImageNoCaption && 'module-message__metadata--with-image-no-caption'
+ );
+ const children = (
+ <>
{timestampNode}
{expirationLength && expirationTimestamp ? (
= props => {
)}
/>
) : null}
-
+ >
);
+
+ if (onWidthMeasured) {
+ return (
+ {
+ onWidthMeasured(bounds?.width || 0);
+ }}
+ >
+ {({ measureRef }) => (
+
+ {children}
+
+ )}
+
+ );
+ }
+
+ return {children}
;
};
diff --git a/ts/components/conversation/MessageTextMetadataSpacer.tsx b/ts/components/conversation/MessageTextMetadataSpacer.tsx
new file mode 100644
index 000000000000..9c3ec4eae5d2
--- /dev/null
+++ b/ts/components/conversation/MessageTextMetadataSpacer.tsx
@@ -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 => (
+
+);
diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx
index 695f0f102a96..6f7e39bf220f 100644
--- a/ts/components/conversation/Quote.tsx
+++ b/ts/components/conversation/Quote.tsx
@@ -23,6 +23,8 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
export type Props = {
authorTitle: string;
conversationColor: ConversationColorType;
+ curveTopLeft?: boolean;
+ curveTopRight?: boolean;
customColor?: CustomColorType;
bodyRanges?: BodyRangesType;
i18n: LocalizerType;
@@ -422,6 +424,8 @@ export class Quote extends React.Component {
public override render(): JSX.Element | null {
const {
conversationColor,
+ curveTopLeft,
+ curveTopRight,
customColor,
isIncoming,
onClick,
@@ -444,10 +448,10 @@ export class Quote extends React.Component {
isIncoming
? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`,
- !onClick ? 'module-quote--no-click' : null,
- referencedMessageNotFound
- ? 'module-quote--with-reference-warning'
- : null
+ !onClick && 'module-quote--no-click',
+ referencedMessageNotFound && 'module-quote--with-reference-warning',
+ curveTopLeft && 'module-quote--curve-top-left',
+ curveTopRight && 'module-quote--curve-top-right'
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx
index c070f9147578..fa21d64c494d 100644
--- a/ts/components/conversation/Timeline.tsx
+++ b/ts/components/conversation/Timeline.tsx
@@ -32,7 +32,10 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
-import { getWidthBreakpoint } from '../../util/timelineUtil';
+import {
+ getWidthBreakpoint,
+ UnreadIndicatorPlacement,
+} from '../../util/timelineUtil';
import {
getScrollBottom,
scrollToBottom,
@@ -117,6 +120,7 @@ type PropsHousekeepingType = {
nextMessageId: undefined | string;
now: number;
previousMessageId: undefined | string;
+ unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: (
@@ -839,8 +843,11 @@ export class Timeline extends React.Component<
const messageNodes: Array = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
- const previousMessageId: undefined | string = items[itemIndex - 1];
- const nextMessageId: undefined | string = items[itemIndex + 1];
+ const previousItemIndex = itemIndex - 1;
+ const nextItemIndex = itemIndex + 1;
+
+ const previousMessageId: undefined | string = items[previousItemIndex];
+ const nextMessageId: undefined | string = items[nextItemIndex];
const messageId = items[itemIndex];
if (!messageId) {
@@ -851,10 +858,14 @@ export class Timeline extends React.Component<
continue;
}
+ let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnreadIndex === itemIndex) {
+ unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push(
{renderLastSeenIndicator(id)}
);
+ } else if (oldestUnreadIndex === nextItemIndex) {
+ unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
}
messageNodes.push(
@@ -874,6 +885,7 @@ export class Timeline extends React.Component<
nextMessageId,
now: nowThatUpdatesEveryMinute,
previousMessageId,
+ unreadIndicatorPlacement,
})}
diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx
index f8f488bacc1b..29b2faa1017b 100644
--- a/ts/components/conversation/TimelineItem.tsx
+++ b/ts/components/conversation/TimelineItem.tsx
@@ -3,7 +3,6 @@
import type { ReactChild, RefObject } from 'react';
import React from 'react';
-import { omit } from 'lodash';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { isSameDay } from '../../util/timestamp';
@@ -54,6 +53,7 @@ import type { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification';
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
import { ProfileChangeNotification } from './ProfileChangeNotification';
+import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { FullJSXType } from '../Intl';
type CallHistoryType = {
@@ -156,6 +156,7 @@ type PropsLocalType = {
previousItem: undefined | TimelineItemType;
nextItem: undefined | TimelineItemType;
now: number;
+ unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
};
type PropsActionsType = MessageActionsType &
@@ -196,6 +197,7 @@ export class TimelineItem extends React.PureComponent {
returnToActiveCall,
selectMessage,
startCallingLobby,
+ unreadIndicatorPlacement,
} = this.props;
if (!item) {
@@ -212,13 +214,14 @@ export class TimelineItem extends React.PureComponent {
if (item.type === 'message') {
itemContents = (
);
} else {
diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx
index 8de51d56ee4d..ec38d5607bc7 100644
--- a/ts/state/smart/MessageAudio.tsx
+++ b/ts/state/smart/MessageAudio.tsx
@@ -21,6 +21,7 @@ export type Props = {
renderingContext: string;
i18n: LocalizerType;
attachment: AttachmentType;
+ collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx
index 8033f8ec438a..98bcfb9b0088 100644
--- a/ts/state/smart/Timeline.tsx
+++ b/ts/state/smart/Timeline.tsx
@@ -46,6 +46,7 @@ import {
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
+import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { WidthBreakpoint } from '../../components/_util';
import { getPreferredBadgeSelector } from '../selectors/badges';
@@ -109,6 +110,7 @@ function renderItem({
nextMessageId,
now,
previousMessageId,
+ unreadIndicatorPlacement,
}: {
actionProps: TimelineActionsType;
containerElementRef: RefObject;
@@ -119,6 +121,7 @@ function renderItem({
nextMessageId: undefined | string;
now: number;
previousMessageId: undefined | string;
+ unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}): JSX.Element {
return (
);
}
diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx
index 08c8beaaa093..74e512bd882f 100644
--- a/ts/state/smart/TimelineItem.tsx
+++ b/ts/state/smart/TimelineItem.tsx
@@ -16,6 +16,7 @@ import {
getMessageSelector,
getSelectedMessage,
} from '../selectors/conversations';
+import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
@@ -28,6 +29,7 @@ type ExternalProps = {
nextMessageId: undefined | string;
previousMessageId: undefined | string;
now: number;
+ unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
};
function renderContact(conversationId: string): JSX.Element {
@@ -47,6 +49,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
nextMessageId,
previousMessageId,
now,
+ unreadIndicatorPlacement,
} = props;
const messageSelector = getMessageSelector(state);
@@ -82,6 +85,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state),
interactionMode: getInteractionMode(state),
theme: getTheme(state),
+ unreadIndicatorPlacement,
};
};
diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts
new file mode 100644
index 000000000000..762e39e18ba3
--- /dev/null
+++ b/ts/test-both/util/timelineUtil_test.ts
@@ -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(' 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));
+ });
+ });
+});
diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts
index 303c4dcaa8b1..323c7239ce67 100644
--- a/ts/util/timelineUtil.ts
+++ b/ts/util/timelineUtil.ts
@@ -1,7 +1,64 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import type { TimelineItemType } from '../components/conversation/TimelineItem';
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;
+ 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 {
if (width > 606) {