diff --git a/sticker-creator/elements/MessageBubble.scss b/sticker-creator/elements/MessageBubble.scss index c4ebc776c..607bba613 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 72a0cc161..626233473 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 cb91e83e0..1eb20b7de 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 a4e2fcfe2..cedc9beb8 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 000000000..54f63e775 --- /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 0ab00d767..5ae6bb133 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 6899950de..ed95f8199 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 5c4a1bdf1..7b19df5b5 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 a4ec64768..5b987765e 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 000000000..9c3ec4eae --- /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 695f0f102..6f7e39bf2 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 c070f9147..fa21d64c4 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 f8f488bac..29b2faa10 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 8de51d56e..ec38d5607 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 8033f8ec4..98bcfb9b0 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 08c8beaaa..74e512bd8 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 000000000..762e39e18 --- /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 303c4dcaa..323c7239c 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) {