diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3bdf1a28c08c..6f2723a7d148 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5647,6 +5647,30 @@ "message": "Slower, more data", "description": "Description of high quality selector" }, + "MessageDetailsHeader--Failed": { + "message": "Not sent", + "description": "In the message details screen, shown above contacts where the message failed to deliver" + }, + "MessageDetailsHeader--Pending": { + "message": "Pending", + "description": "In the message details screen, shown above contacts where the message is still sending" + }, + "MessageDetailsHeader--Sent": { + "message": "Sent to", + "description": "In the message details screen, shown above contacts where the message has been sent (but not delivered, read, or viewed)" + }, + "MessageDetailsHeader--Delivered": { + "message": "Delivered to", + "description": "In the message details screen, shown above contacts who have received your message" + }, + "MessageDetailsHeader--Read": { + "message": "Read by", + "description": "In the message details screen, shown above contacts who have read this message" + }, + "MessageDetailsHeader--Viewed": { + "message": "Viewed by", + "description": "In the message details screen, shown above contacts who have viewed this message" + }, "ProfileEditor--about": { "message": "About", "description": "Default text for about field" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index d38187db2d95..911f2053b5d2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3142,184 +3142,6 @@ button.module-conversation-details__action-button { } } -// Module: Message Detail - -.module-message-detail { - max-width: 650px; - margin-left: auto; - margin-right: auto; - padding: 20px; - outline: none; -} - -.module-message-detail__message-container { - padding-top: 20px; - padding-bottom: 20px; - - &::after { - content: '.'; - visibility: hidden; - display: block; - height: 0; - clear: both; - } -} - -.module-message-detail__label { - @include font-body-1-bold; -} - -.module-message-detail__unix-timestamp { - @include light-theme { - color: $color-gray-05; - } - @include dark-theme { - color: $color-gray-45; - } -} - -.module-message-detail__contact-container { - margin: 20px; -} - -.module-message-detail__contact { - margin-bottom: 8px; - display: flex; - flex-direction: row; - align-items: center; -} - -.module-message-detail__contact__text { - margin-left: 10px; - flex-grow: 1; -} - -.module-message-detail__contact__error { - color: $color-accent-red; - font-weight: bold; -} - -.module-message-detail__contact__status-icon { - width: 12px; - height: 12px; - display: inline-block; - margin-bottom: 2px; -} - -.module-message-detail__contact__status-icon--Pending { - animation: module-message-detail__contact__status-icon--spinning 4s linear - infinite; - - @include light-theme { - @include color-svg('../images/sending.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/sending.svg', $color-gray-25); - } -} - -@keyframes module-message-detail__contact__status-icon--spinning { - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -.module-message-detail__contact__status-icon--Sent { - @include light-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-25); - } -} -.module-message-detail__contact__status-icon--Delivered { - width: 18px; - - @include light-theme { - @include color-svg('../images/double-check.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/double-check.svg', $color-gray-25); - } -} -.module-message-detail__contact__status-icon--Read, -.module-message-detail__contact__status-icon--Viewed { - width: 18px; - - @include light-theme { - @include color-svg('../images/read.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/read.svg', $color-gray-25); - } -} -.module-message-detail__contact__status-icon--Failed { - @include light-theme { - @include color-svg( - '../images/icons/v2/error-outline-12.svg', - $color-accent-red - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/error-solid-12.svg', - $color-accent-red - ); - } -} - -.module-message-detail__contact__unidentified-delivery-icon { - margin-left: 6px; - margin-right: 10px; - - width: 20px; - height: 20px; - display: inline-block; - - @include light-theme { - @include color-svg( - '../images/icons/v2/unidentified-delivery-solid-20.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/unidentified-delivery-solid-20.svg', - $color-gray-25 - ); - } -} - -.module-message-detail__contact__error-buttons { - text-align: right; -} - -.module-message-detail__contact__show-safety-number { - @include button-reset; - padding: 4px; - border-radius: 4px; - - color: $color-white; - - @include light-theme { - background-color: $color-gray-45; - } - @include dark-theme { - background-color: $color-gray-25; - } -} -.module-message-detail__contact__send-anyway { - @include button-reset; - margin-left: 5px; - margin-top: 5px; - padding: 4px; - border-radius: 4px; - - color: $color-white; - background-color: $color-accent-red; -} - // Module: Media Gallery .module-media-gallery { diff --git a/stylesheets/components/MessageDetail.scss b/stylesheets/components/MessageDetail.scss new file mode 100644 index 000000000000..9fed6e54aa9f --- /dev/null +++ b/stylesheets/components/MessageDetail.scss @@ -0,0 +1,206 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-message-detail { + max-width: 650px; + margin-left: auto; + margin-right: auto; + padding: 20px; + outline: none; +} + +.module-message-detail__message-container { + padding-top: 20px; + padding-bottom: 20px; + + &::after { + content: '.'; + visibility: hidden; + display: block; + height: 0; + clear: both; + } +} + +.module-message-detail__label { + @include font-body-1-bold; +} + +.module-message-detail__unix-timestamp { + @include light-theme { + color: $color-gray-05; + } + @include dark-theme { + color: $color-gray-45; + } +} + +.module-message-detail__contact-container { + border-top: 1px solid $color-gray-15; + margin-top: 36px; + + @include light-theme { + border-top-color: $color-gray-15; + } + @include dark-theme { + border-top-color: $color-gray-75; + } +} + +.module-message-detail__contact-group__header { + @include font-body-1-bold; + align-items: center; + display: flex; + justify-content: space-between; + margin-top: 24px; + padding: 10px 0; + user-select: none; + + &:first-child { + margin-top: 36px; + } + + &--Failed, + &--Viewed, + &--Read, + &--Delivered, + &--Sent, + &--Pending { + &:after { + content: ''; + display: block; + flex-shrink: 0; + height: 12px; + margin-left: 10px; + } + } + + &--Failed:after { + width: 12px; + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-accent-red + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-solid-12.svg', + $color-accent-red + ); + } + } + + @mixin normal-icon($icon) { + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + + &--Viewed:after, + &--Read:after { + // Viewed and read deliberately have the same icon. + width: 18px; + @include normal-icon('../images/read.svg'); + } + + &--Delivered:after { + width: 18px; + @include normal-icon('../images/double-check.svg'); + } + + &--Sent:after { + width: 12px; + @include normal-icon('../images/check-circle-outline.svg'); + } + + &--Pending:after { + width: 12px; + animation: module-message-detail__contact-group__header--Pending 4s linear + infinite; + @include normal-icon('../images/sending.svg'); + } +} + +@keyframes module-message-detail__contact-group__header--Pending { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.module-message-detail__contact { + margin-bottom: 8px; + padding: 8px 0; + display: flex; + flex-direction: row; + align-items: center; + + &:last-child { + margin-bottom: 0; + } +} + +.module-message-detail__contact__text { + @include font-body-1; + flex-grow: 1; + margin-left: 10px; +} + +.module-message-detail__contact__error { + color: $color-accent-red; + font-weight: bold; +} + +.module-message-detail__contact__unidentified-delivery-icon { + margin-left: 6px; + + width: 18px; + height: 18px; + display: inline-block; + + @include light-theme { + @include color-svg( + '../images/icons/v2/unidentified-delivery-solid-20.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/unidentified-delivery-solid-20.svg', + $color-gray-25 + ); + } +} + +.module-message-detail__contact__error-buttons { + text-align: right; +} + +.module-message-detail__contact__show-safety-number { + @include button-reset; + padding: 4px; + border-radius: 4px; + + color: $color-white; + + @include light-theme { + background-color: $color-gray-45; + } + @include dark-theme { + background-color: $color-gray-25; + } +} +.module-message-detail__contact__send-anyway { + @include button-reset; + margin-left: 5px; + margin-top: 5px; + padding: 4px; + border-radius: 4px; + + color: $color-white; + background-color: $color-accent-red; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index ba031475657d..34de63b0c2b8 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -53,6 +53,7 @@ @import './components/Input.scss'; @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; +@import './components/MessageDetail.scss'; @import './components/Modal.scss'; @import './components/ProfileEditor.scss'; @import './components/SafetyNumberChangeDialog.scss'; diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index fa5011e5b4e0..64700e991a0a 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -28,6 +28,7 @@ export enum AvatarBlur { export enum AvatarSize { TWENTY_EIGHT = 28, THIRTY_TWO = 32, + THIRTY_SIX = 36, FIFTY_TWO = 52, EIGHTY = 80, NINETY_SIX = 96, diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index bb9a304ad4de..5fc5d38477f8 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -92,7 +92,19 @@ const createProps = (overrideProps: Partial = {}): Props => ({ }); story.add('Delivered Incoming', () => { - const props = createProps({}); + const props = createProps({ + contacts: [ + { + ...getDefaultConversation({ + color: 'forest', + title: 'Max', + }), + status: undefined, + isOutgoingKeyError: false, + isUnidentifiedDelivery: false, + }, + ], + }); return ; }); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 0174b54af8fd..9f5451a5a5ec 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -1,12 +1,12 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { ReactChild, ReactNode } from 'react'; import classNames from 'classnames'; import moment from 'moment'; import { noop } from 'lodash'; -import { Avatar } from '../Avatar'; +import { Avatar, AvatarSize } from '../Avatar'; import { ContactName } from './ContactName'; import { Message, @@ -16,6 +16,7 @@ import { import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { assert } from '../../util/assert'; +import { groupBy } from '../../util/mapUtil'; import { ContactNameColorType } from '../../types/Colors'; import { SendStatus } from '../../messages/MessageSendState'; @@ -33,7 +34,7 @@ export type Contact = Pick< | 'title' | 'unblurredAvatarPath' > & { - status: SendStatus | null; + status?: SendStatus; isOutgoingKeyError: boolean; isUnidentifiedDelivery: boolean; @@ -42,7 +43,11 @@ export type Contact = Pick< }; export type Props = { - contacts: Array; + // An undefined status means they were the sender and it's an incoming message. If + // `undefined` is a status, there should be no other items in the array; if there are + // any defined statuses, `undefined` shouldn't be present. + contacts: ReadonlyArray; + contactNameColor?: ContactNameColorType; errors: Array; message: Omit; @@ -79,6 +84,8 @@ export type Props = { | 'showVisualAttachment' >; +const contactSortCollator = new Intl.Collator(); + const _keyForError = (error: Error): string => { return `${error.name}-${error.message}`; }; @@ -124,7 +131,7 @@ export class MessageDetail extends React.Component { profileName={profileName} title={title} sharedGroupNames={sharedGroupNames} - size={52} + size={AvatarSize.THIRTY_SIX} unblurredAvatarPath={unblurredAvatarPath} /> ); @@ -152,22 +159,12 @@ export class MessageDetail extends React.Component { ) : null; - const statusComponent = !contact.isOutgoingKeyError ? ( -
- ) : null; const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
) : null; return ( -
+
{this.renderAvatar(contact)}
@@ -190,21 +187,65 @@ export class MessageDetail extends React.Component {
{errorComponent} {unidentifiedDeliveryComponent} - {statusComponent}
); } - public renderContacts(): JSX.Element | null { - const { contacts } = this.props; - + private renderContactGroup( + sendStatus: undefined | SendStatus, + contacts: undefined | ReadonlyArray + ): ReactNode { + const { i18n } = this.props; if (!contacts || !contacts.length) { return null; } + const i18nKey = + sendStatus === undefined ? 'from' : `MessageDetailsHeader--${sendStatus}`; + + const sortedContacts = [...contacts].sort((a, b) => + contactSortCollator.compare(a.title, b.title) + ); + + return ( +
+
+ {i18n(i18nKey)} +
+ {sortedContacts.map(contact => this.renderContact(contact))} +
+ ); + } + + private renderContacts(): ReactChild { + // This assumes that the list either contains one sender (a status of `undefined`) or + // 1+ contacts with `SendStatus`es, but it doesn't check that assumption. + const { contacts } = this.props; + + const contactsBySendStatus = groupBy(contacts, contact => contact.status); + return (
- {contacts.map(contact => this.renderContact(contact))} + {[ + undefined, + SendStatus.Failed, + SendStatus.Viewed, + SendStatus.Read, + SendStatus.Delivered, + SendStatus.Sent, + SendStatus.Pending, + ].map(sendStatus => + this.renderContactGroup( + sendStatus, + contactsBySendStatus.get(sendStatus) + ) + )}
); } @@ -331,11 +372,6 @@ export class MessageDetail extends React.Component { ) : null} - - - {message.direction === 'incoming' ? i18n('from') : i18n('to')} - - {this.renderContacts()} diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4a4a8c2406fe..ef3904165c13 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -325,17 +325,18 @@ export class MessageModel extends window.Backbone.Model { return window.ConversationController.getConversationId(identifier); }); - const finalContacts: Array = conversationIds.map( - (id: string): SmartMessageDetailContact => { - const errorsForContact = errorsGroupedById[id]; + + const contacts: ReadonlyArray = conversationIds.map( + id => { + const errorsForContact = getOwn(errorsGroupedById, id); const isOutgoingKeyError = Boolean( - _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) + errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR) ); const isUnidentifiedDelivery = window.storage.get('unidentifiedDeliveryIndicators', false) && this.isUnidentifiedDelivery(id, unidentifiedDeliveriesSet); - let status = getOwn(sendStateByConversationId, id)?.status || null; + let status = getOwn(sendStateByConversationId, id)?.status; // If a message was only sent to yourself (Note to Self or a lonely group), it // is shown read. @@ -352,27 +353,6 @@ export class MessageModel extends window.Backbone.Model { }; } ); - // The prefix created here ensures that contacts with errors are listed - // first; otherwise it's alphabetical - const collator = new Intl.Collator(); - const sortedContacts: Array = finalContacts.sort( - ( - left: SmartMessageDetailContact, - right: SmartMessageDetailContact - ): number => { - const leftErrors = Boolean(left.errors && left.errors.length); - const rightErrors = Boolean(right.errors && right.errors.length); - - if (leftErrors && !rightErrors) { - return -1; - } - if (!leftErrors && rightErrors) { - return 1; - } - - return collator.compare(left.title, right.title); - } - ); return { sentAt: this.get('sent_at'), @@ -394,7 +374,7 @@ export class MessageModel extends window.Backbone.Model { } ), errors, - contacts: sortedContacts, + contacts, }; } diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index d7429209d293..71cbebc37cbc 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -4,13 +4,9 @@ import { ComponentProps } from 'react'; import { connect } from 'react-redux'; -import { - MessageDetail, - Contact, -} from '../../components/conversation/MessageDetail'; -import { PropsData as MessagePropsDataType } from '../../components/conversation/Message'; -import { mapDispatchToProps } from '../actions'; +import { MessageDetail } from '../../components/conversation/MessageDetail'; +import { mapDispatchToProps } from '../actions'; import { StateType } from '../reducer'; import { getIntl, getInteractionMode } from '../selectors/user'; import { renderAudioAttachment } from './renderAudioAttachment'; @@ -21,36 +17,34 @@ type MessageDetailProps = ComponentProps; export { Contact } from '../../components/conversation/MessageDetail'; -export type OwnProps = { - contacts: Array; - errors: Array; - message: Omit; - receivedAt: number; - sentAt: number; - - sendAnyway: (contactId: string, messageId: string) => unknown; - showSafetyNumber: (contactId: string) => void; -} & Pick< +export type OwnProps = Pick< MessageDetailProps, | 'clearSelectedMessage' | 'checkForAccount' + | 'contacts' | 'deleteMessage' | 'deleteMessageForEveryone' | 'displayTapToViewMessage' | 'downloadAttachment' | 'doubleCheckMissingQuoteReference' + | 'errors' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' + | 'message' | 'openConversation' | 'openLink' | 'reactToMessage' + | 'receivedAt' | 'replyToMessage' | 'retrySend' + | 'sendAnyway' + | 'sentAt' | 'showContactDetail' | 'showContactModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showForwardMessageModal' + | 'showSafetyNumber' | 'showVisualAttachment' >; diff --git a/ts/test-both/util/mapUtil_test.ts b/ts/test-both/util/mapUtil_test.ts new file mode 100644 index 000000000000..bad6d44a5eb1 --- /dev/null +++ b/ts/test-both/util/mapUtil_test.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { groupBy } from '../../util/mapUtil'; + +describe('map utilities', () => { + describe('groupBy', () => { + it('returns an empty map when passed an empty iterable', () => { + const fn = sinon.fake(); + + assert.isEmpty(groupBy([], fn)); + + sinon.assert.notCalled(fn); + }); + + it('groups the iterable', () => { + assert.deepEqual( + groupBy([2.3, 1.3, 2.9, 1.1, 3.4], Math.floor), + new Map([ + [1, [1.3, 1.1]], + [2, [2.3, 2.9]], + [3, [3.4]], + ]) + ); + }); + }); +}); diff --git a/ts/util/mapUtil.ts b/ts/util/mapUtil.ts new file mode 100644 index 000000000000..38dc0019e275 --- /dev/null +++ b/ts/util/mapUtil.ts @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { reduce } from './iterables'; + +/** + * Like Lodash's `groupBy`, but returns a `Map`. + */ +export const groupBy = ( + iterable: Iterable, + fn: (value: T) => ResultT +): Map> => + reduce( + iterable, + (result: Map>, value: T) => { + const key = fn(value); + const existingGroup = result.get(key); + if (existingGroup) { + existingGroup.push(value); + } else { + result.set(key, [value]); + } + return result; + }, + new Map>() + );