Message details: group by send status, including viewed state
This commit is contained in:
parent
d91c336e62
commit
1e10286210
11 changed files with 380 additions and 248 deletions
|
@ -5647,6 +5647,30 @@
|
||||||
"message": "Slower, more data",
|
"message": "Slower, more data",
|
||||||
"description": "Description of high quality selector"
|
"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": {
|
"ProfileEditor--about": {
|
||||||
"message": "About",
|
"message": "About",
|
||||||
"description": "Default text for about field"
|
"description": "Default text for about field"
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
.module-media-gallery {
|
.module-media-gallery {
|
||||||
|
|
206
stylesheets/components/MessageDetail.scss
Normal file
206
stylesheets/components/MessageDetail.scss
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -53,6 +53,7 @@
|
||||||
@import './components/Input.scss';
|
@import './components/Input.scss';
|
||||||
@import './components/MediaQualitySelector.scss';
|
@import './components/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
|
@import './components/MessageDetail.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
@import './components/ProfileEditor.scss';
|
@import './components/ProfileEditor.scss';
|
||||||
@import './components/SafetyNumberChangeDialog.scss';
|
@import './components/SafetyNumberChangeDialog.scss';
|
||||||
|
|
|
@ -28,6 +28,7 @@ export enum AvatarBlur {
|
||||||
export enum AvatarSize {
|
export enum AvatarSize {
|
||||||
TWENTY_EIGHT = 28,
|
TWENTY_EIGHT = 28,
|
||||||
THIRTY_TWO = 32,
|
THIRTY_TWO = 32,
|
||||||
|
THIRTY_SIX = 36,
|
||||||
FIFTY_TWO = 52,
|
FIFTY_TWO = 52,
|
||||||
EIGHTY = 80,
|
EIGHTY = 80,
|
||||||
NINETY_SIX = 96,
|
NINETY_SIX = 96,
|
||||||
|
|
|
@ -92,7 +92,19 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Delivered Incoming', () => {
|
story.add('Delivered Incoming', () => {
|
||||||
const props = createProps({});
|
const props = createProps({
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
...getDefaultConversation({
|
||||||
|
color: 'forest',
|
||||||
|
title: 'Max',
|
||||||
|
}),
|
||||||
|
status: undefined,
|
||||||
|
isOutgoingKeyError: false,
|
||||||
|
isUnidentifiedDelivery: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
return <MessageDetail {...props} />;
|
return <MessageDetail {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
// Copyright 2018-2021 Signal Messenger, LLC
|
// Copyright 2018-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { ReactChild, ReactNode } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar, AvatarSize } from '../Avatar';
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
|
@ -16,6 +16,7 @@ import {
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
import { ConversationType } from '../../state/ducks/conversations';
|
import { ConversationType } from '../../state/ducks/conversations';
|
||||||
import { assert } from '../../util/assert';
|
import { assert } from '../../util/assert';
|
||||||
|
import { groupBy } from '../../util/mapUtil';
|
||||||
import { ContactNameColorType } from '../../types/Colors';
|
import { ContactNameColorType } from '../../types/Colors';
|
||||||
import { SendStatus } from '../../messages/MessageSendState';
|
import { SendStatus } from '../../messages/MessageSendState';
|
||||||
|
|
||||||
|
@ -33,7 +34,7 @@ export type Contact = Pick<
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
> & {
|
> & {
|
||||||
status: SendStatus | null;
|
status?: SendStatus;
|
||||||
|
|
||||||
isOutgoingKeyError: boolean;
|
isOutgoingKeyError: boolean;
|
||||||
isUnidentifiedDelivery: boolean;
|
isUnidentifiedDelivery: boolean;
|
||||||
|
@ -42,7 +43,11 @@ export type Contact = Pick<
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
contacts: Array<Contact>;
|
// 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<Contact>;
|
||||||
|
|
||||||
contactNameColor?: ContactNameColorType;
|
contactNameColor?: ContactNameColorType;
|
||||||
errors: Array<Error>;
|
errors: Array<Error>;
|
||||||
message: Omit<MessagePropsDataType, 'renderingContext'>;
|
message: Omit<MessagePropsDataType, 'renderingContext'>;
|
||||||
|
@ -79,6 +84,8 @@ export type Props = {
|
||||||
| 'showVisualAttachment'
|
| 'showVisualAttachment'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const contactSortCollator = new Intl.Collator();
|
||||||
|
|
||||||
const _keyForError = (error: Error): string => {
|
const _keyForError = (error: Error): string => {
|
||||||
return `${error.name}-${error.message}`;
|
return `${error.name}-${error.message}`;
|
||||||
};
|
};
|
||||||
|
@ -124,7 +131,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
profileName={profileName}
|
profileName={profileName}
|
||||||
title={title}
|
title={title}
|
||||||
sharedGroupNames={sharedGroupNames}
|
sharedGroupNames={sharedGroupNames}
|
||||||
size={52}
|
size={AvatarSize.THIRTY_SIX}
|
||||||
unblurredAvatarPath={unblurredAvatarPath}
|
unblurredAvatarPath={unblurredAvatarPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -152,22 +159,12 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
const statusComponent = !contact.isOutgoingKeyError ? (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'module-message-detail__contact__status-icon',
|
|
||||||
contact.status
|
|
||||||
? `module-message-detail__contact__status-icon--${contact.status}`
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
|
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
|
||||||
<div className="module-message-detail__contact__unidentified-delivery-icon" />
|
<div className="module-message-detail__contact__unidentified-delivery-icon" />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={contact.phoneNumber} className="module-message-detail__contact">
|
<div key={contact.id} className="module-message-detail__contact">
|
||||||
{this.renderAvatar(contact)}
|
{this.renderAvatar(contact)}
|
||||||
<div className="module-message-detail__contact__text">
|
<div className="module-message-detail__contact__text">
|
||||||
<div className="module-message-detail__contact__name">
|
<div className="module-message-detail__contact__name">
|
||||||
|
@ -190,21 +187,65 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
</div>
|
</div>
|
||||||
{errorComponent}
|
{errorComponent}
|
||||||
{unidentifiedDeliveryComponent}
|
{unidentifiedDeliveryComponent}
|
||||||
{statusComponent}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderContacts(): JSX.Element | null {
|
private renderContactGroup(
|
||||||
const { contacts } = this.props;
|
sendStatus: undefined | SendStatus,
|
||||||
|
contacts: undefined | ReadonlyArray<Contact>
|
||||||
|
): ReactNode {
|
||||||
|
const { i18n } = this.props;
|
||||||
if (!contacts || !contacts.length) {
|
if (!contacts || !contacts.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const i18nKey =
|
||||||
|
sendStatus === undefined ? 'from' : `MessageDetailsHeader--${sendStatus}`;
|
||||||
|
|
||||||
|
const sortedContacts = [...contacts].sort((a, b) =>
|
||||||
|
contactSortCollator.compare(a.title, b.title)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i18nKey} className="module-message-detail__contact-group">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-message-detail__contact-group__header',
|
||||||
|
sendStatus &&
|
||||||
|
`module-message-detail__contact-group__header--${sendStatus}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i18n(i18nKey)}
|
||||||
|
</div>
|
||||||
|
{sortedContacts.map(contact => this.renderContact(contact))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="module-message-detail__contact-container">
|
<div className="module-message-detail__contact-container">
|
||||||
{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)
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -331,11 +372,6 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
<tr>
|
|
||||||
<td className="module-message-detail__label">
|
|
||||||
{message.direction === 'incoming' ? i18n('from') : i18n('to')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{this.renderContacts()}
|
{this.renderContacts()}
|
||||||
|
|
|
@ -325,17 +325,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
return window.ConversationController.getConversationId(identifier);
|
return window.ConversationController.getConversationId(identifier);
|
||||||
});
|
});
|
||||||
const finalContacts: Array<SmartMessageDetailContact> = conversationIds.map(
|
|
||||||
(id: string): SmartMessageDetailContact => {
|
const contacts: ReadonlyArray<SmartMessageDetailContact> = conversationIds.map(
|
||||||
const errorsForContact = errorsGroupedById[id];
|
id => {
|
||||||
|
const errorsForContact = getOwn(errorsGroupedById, id);
|
||||||
const isOutgoingKeyError = Boolean(
|
const isOutgoingKeyError = Boolean(
|
||||||
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
|
errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
|
||||||
);
|
);
|
||||||
const isUnidentifiedDelivery =
|
const isUnidentifiedDelivery =
|
||||||
window.storage.get('unidentifiedDeliveryIndicators', false) &&
|
window.storage.get('unidentifiedDeliveryIndicators', false) &&
|
||||||
this.isUnidentifiedDelivery(id, unidentifiedDeliveriesSet);
|
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
|
// If a message was only sent to yourself (Note to Self or a lonely group), it
|
||||||
// is shown read.
|
// is shown read.
|
||||||
|
@ -352,27 +353,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// The prefix created here ensures that contacts with errors are listed
|
|
||||||
// first; otherwise it's alphabetical
|
|
||||||
const collator = new Intl.Collator();
|
|
||||||
const sortedContacts: Array<SmartMessageDetailContact> = 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 {
|
return {
|
||||||
sentAt: this.get('sent_at'),
|
sentAt: this.get('sent_at'),
|
||||||
|
@ -394,7 +374,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
errors,
|
errors,
|
||||||
contacts: sortedContacts,
|
contacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,13 +4,9 @@
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import {
|
import { MessageDetail } from '../../components/conversation/MessageDetail';
|
||||||
MessageDetail,
|
|
||||||
Contact,
|
|
||||||
} from '../../components/conversation/MessageDetail';
|
|
||||||
import { PropsData as MessagePropsDataType } from '../../components/conversation/Message';
|
|
||||||
import { mapDispatchToProps } from '../actions';
|
|
||||||
|
|
||||||
|
import { mapDispatchToProps } from '../actions';
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
import { getIntl, getInteractionMode } from '../selectors/user';
|
import { getIntl, getInteractionMode } from '../selectors/user';
|
||||||
import { renderAudioAttachment } from './renderAudioAttachment';
|
import { renderAudioAttachment } from './renderAudioAttachment';
|
||||||
|
@ -21,36 +17,34 @@ type MessageDetailProps = ComponentProps<typeof MessageDetail>;
|
||||||
|
|
||||||
export { Contact } from '../../components/conversation/MessageDetail';
|
export { Contact } from '../../components/conversation/MessageDetail';
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = Pick<
|
||||||
contacts: Array<Contact>;
|
|
||||||
errors: Array<Error>;
|
|
||||||
message: Omit<MessagePropsDataType, 'renderingContext'>;
|
|
||||||
receivedAt: number;
|
|
||||||
sentAt: number;
|
|
||||||
|
|
||||||
sendAnyway: (contactId: string, messageId: string) => unknown;
|
|
||||||
showSafetyNumber: (contactId: string) => void;
|
|
||||||
} & Pick<
|
|
||||||
MessageDetailProps,
|
MessageDetailProps,
|
||||||
| 'clearSelectedMessage'
|
| 'clearSelectedMessage'
|
||||||
| 'checkForAccount'
|
| 'checkForAccount'
|
||||||
|
| 'contacts'
|
||||||
| 'deleteMessage'
|
| 'deleteMessage'
|
||||||
| 'deleteMessageForEveryone'
|
| 'deleteMessageForEveryone'
|
||||||
| 'displayTapToViewMessage'
|
| 'displayTapToViewMessage'
|
||||||
| 'downloadAttachment'
|
| 'downloadAttachment'
|
||||||
| 'doubleCheckMissingQuoteReference'
|
| 'doubleCheckMissingQuoteReference'
|
||||||
|
| 'errors'
|
||||||
| 'kickOffAttachmentDownload'
|
| 'kickOffAttachmentDownload'
|
||||||
| 'markAttachmentAsCorrupted'
|
| 'markAttachmentAsCorrupted'
|
||||||
|
| 'message'
|
||||||
| 'openConversation'
|
| 'openConversation'
|
||||||
| 'openLink'
|
| 'openLink'
|
||||||
| 'reactToMessage'
|
| 'reactToMessage'
|
||||||
|
| 'receivedAt'
|
||||||
| 'replyToMessage'
|
| 'replyToMessage'
|
||||||
| 'retrySend'
|
| 'retrySend'
|
||||||
|
| 'sendAnyway'
|
||||||
|
| 'sentAt'
|
||||||
| 'showContactDetail'
|
| 'showContactDetail'
|
||||||
| 'showContactModal'
|
| 'showContactModal'
|
||||||
| 'showExpiredIncomingTapToViewToast'
|
| 'showExpiredIncomingTapToViewToast'
|
||||||
| 'showExpiredOutgoingTapToViewToast'
|
| 'showExpiredOutgoingTapToViewToast'
|
||||||
| 'showForwardMessageModal'
|
| 'showForwardMessageModal'
|
||||||
|
| 'showSafetyNumber'
|
||||||
| 'showVisualAttachment'
|
| 'showVisualAttachment'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
30
ts/test-both/util/mapUtil_test.ts
Normal file
30
ts/test-both/util/mapUtil_test.ts
Normal file
|
@ -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]],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
26
ts/util/mapUtil.ts
Normal file
26
ts/util/mapUtil.ts
Normal file
|
@ -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 = <T, ResultT>(
|
||||||
|
iterable: Iterable<T>,
|
||||||
|
fn: (value: T) => ResultT
|
||||||
|
): Map<ResultT, Array<T>> =>
|
||||||
|
reduce(
|
||||||
|
iterable,
|
||||||
|
(result: Map<ResultT, Array<T>>, 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<ResultT, Array<T>>()
|
||||||
|
);
|
Loading…
Reference in a new issue