diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e3e1f2d153a3..59370ce563f6 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3206,7 +3206,7 @@ button.module-conversation-details__action-button { margin-bottom: 2px; } -.module-message-detail__contact__status-icon--sending { +.module-message-detail__contact__status-icon--Pending { animation: module-message-detail__contact__status-icon--spinning 4s linear infinite; @@ -3225,7 +3225,7 @@ button.module-conversation-details__action-button { } } -.module-message-detail__contact__status-icon--sent { +.module-message-detail__contact__status-icon--Sent { @include light-theme { @include color-svg('../images/check-circle-outline.svg', $color-gray-60); } @@ -3233,7 +3233,7 @@ button.module-conversation-details__action-button { @include color-svg('../images/check-circle-outline.svg', $color-gray-25); } } -.module-message-detail__contact__status-icon--delivered { +.module-message-detail__contact__status-icon--Delivered { width: 18px; @include light-theme { @@ -3243,7 +3243,8 @@ button.module-conversation-details__action-button { @include color-svg('../images/double-check.svg', $color-gray-25); } } -.module-message-detail__contact__status-icon--read { +.module-message-detail__contact__status-icon--Read, +.module-message-detail__contact__status-icon--Viewed { width: 18px; @include light-theme { @@ -3253,7 +3254,7 @@ button.module-conversation-details__action-button { @include color-svg('../images/read.svg', $color-gray-25); } } -.module-message-detail__contact__status-icon--error { +.module-message-detail__contact__status-icon--Failed { @include light-theme { @include color-svg( '../images/icons/v2/error-outline-12.svg', diff --git a/test/keychange_listener_test.js b/test/keychange_listener_test.js index ab5dbfccfa2e..d5d30cfc700f 100644 --- a/test/keychange_listener_test.js +++ b/test/keychange_listener_test.js @@ -4,12 +4,39 @@ /* global ConversationController, SignalProtocolStore, Whisper */ describe('KeyChangeListener', () => { + const STORAGE_KEYS_TO_RESTORE = ['number_id', 'uuid_id']; + const oldStorageValues = new Map(); + const phoneNumberWithKeyChange = '+13016886524'; // nsa const addressString = `${phoneNumberWithKeyChange}.1`; const oldKey = window.Signal.Crypto.getRandomBytes(33); const newKey = window.Signal.Crypto.getRandomBytes(33); let store; + before(async () => { + window.ConversationController.reset(); + await window.ConversationController.load(); + + STORAGE_KEYS_TO_RESTORE.forEach(key => { + oldStorageValues.set(key, window.textsecure.storage.get(key)); + }); + window.textsecure.storage.put('number_id', '+14155555556.2'); + window.textsecure.storage.put('uuid_id', `${window.getGuid()}.2`); + }); + + after(async () => { + await window.Signal.Data.removeAll(); + await window.storage.fetch(); + + oldStorageValues.forEach((oldValue, key) => { + if (oldValue) { + window.textsecure.storage.put(key, oldValue); + } else { + window.textsecure.storage.remove(key); + } + }); + }); + let convo; beforeEach(async () => { diff --git a/ts/background.ts b/ts/background.ts index e29d87cb97de..64a1f182eb7d 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -75,6 +75,10 @@ import { Reactions } from './messageModifiers/Reactions'; import { ReadReceipts } from './messageModifiers/ReadReceipts'; import { ReadSyncs } from './messageModifiers/ReadSyncs'; import { ViewSyncs } from './messageModifiers/ViewSyncs'; +import { + SendStateByConversationId, + SendStatus, +} from './messages/MessageSendState'; import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads'; import { SystemTraySetting, @@ -3199,15 +3203,39 @@ export async function startApp(): Promise { const now = Date.now(); const timestamp = data.timestamp || now; + const ourId = window.ConversationController.getOurConversationIdOrThrow(); + const { unidentifiedStatus = [] } = data; - let sentTo: Array = []; + + const sendStateByConversationId: SendStateByConversationId = unidentifiedStatus.reduce( + (result: SendStateByConversationId, { destinationUuid, destination }) => { + const conversationId = window.ConversationController.ensureContactIds({ + uuid: destinationUuid, + e164: destination, + highTrust: true, + }); + if (!conversationId || conversationId === ourId) { + return result; + } + + return { + ...result, + [conversationId]: { + status: SendStatus.Pending, + updatedAt: timestamp, + }, + }; + }, + { + [ourId]: { + status: SendStatus.Sent, + updatedAt: timestamp, + }, + } + ); let unidentifiedDeliveries: Array = []; if (unidentifiedStatus.length) { - sentTo = unidentifiedStatus - .map(item => item.destinationUuid || item.destination) - .filter(isNotNil); - const unidentified = window._.filter(data.unidentifiedStatus, item => Boolean(item.unidentified) ); @@ -3222,13 +3250,12 @@ export async function startApp(): Promise { sourceDevice: data.device, sent_at: timestamp, serverTimestamp: data.serverTimestamp, - sent_to: sentTo, received_at: data.receivedAtCounter, received_at_ms: data.receivedAtDate, conversationId: descriptor.id, timestamp, type: 'outgoing', - sent: true, + sendStateByConversationId, unidentifiedDeliveries, expirationStartTimestamp: Math.min( data.expirationStartTimestamp || timestamp, diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 38b57a218f4e..bb9a304ad4de 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -9,6 +9,7 @@ import { storiesOf } from '@storybook/react'; import { PropsData as MessageDataPropsType } from './Message'; import { MessageDetail, Props } from './MessageDetail'; +import { SendStatus } from '../../messages/MessageSendState'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; @@ -48,7 +49,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'delivered', + status: SendStatus.Delivered, }, ], errors: overrideProps.errors || [], @@ -116,7 +117,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'sent', + status: SendStatus.Sent, }, { ...getDefaultConversation({ @@ -125,7 +126,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'sending', + status: SendStatus.Pending, }, { ...getDefaultConversation({ @@ -134,7 +135,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'partial-sent', + status: SendStatus.Failed, }, { ...getDefaultConversation({ @@ -143,7 +144,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'delivered', + status: SendStatus.Delivered, }, { ...getDefaultConversation({ @@ -152,7 +153,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: 'read', + status: SendStatus.Read, }, ], message: { @@ -209,7 +210,7 @@ story.add('All Errors', () => { }), isOutgoingKeyError: true, isUnidentifiedDelivery: false, - status: 'error', + status: SendStatus.Failed, }, { ...getDefaultConversation({ @@ -224,7 +225,7 @@ story.add('All Errors', () => { ], isOutgoingKeyError: false, isUnidentifiedDelivery: true, - status: 'error', + status: SendStatus.Failed, }, { ...getDefaultConversation({ @@ -233,7 +234,7 @@ story.add('All Errors', () => { }), isOutgoingKeyError: true, isUnidentifiedDelivery: true, - status: 'error', + status: SendStatus.Failed, }, ], }); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 092a4ed1e47b..0174b54af8fd 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -10,7 +10,6 @@ import { Avatar } from '../Avatar'; import { ContactName } from './ContactName'; import { Message, - MessageStatusType, Props as MessagePropsType, PropsData as MessagePropsDataType, } from './Message'; @@ -18,6 +17,7 @@ import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { assert } from '../../util/assert'; import { ContactNameColorType } from '../../types/Colors'; +import { SendStatus } from '../../messages/MessageSendState'; export type Contact = Pick< ConversationType, @@ -33,7 +33,7 @@ export type Contact = Pick< | 'title' | 'unblurredAvatarPath' > & { - status: MessageStatusType | null; + status: SendStatus | null; isOutgoingKeyError: boolean; isUnidentifiedDelivery: boolean; diff --git a/ts/groups.ts b/ts/groups.ts index b7f81ea21f61..c06d3ce18731 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -2314,9 +2314,7 @@ export async function wrapWithSyncMessageSend({ encodedDataMessage: dataMessage, expirationStartTimestamp: null, options, - sentTo: [], timestamp, - unidentifiedDeliveries: [], }), { messageIds, sendType } ); diff --git a/ts/messageModifiers/DeliveryReceipts.ts b/ts/messageModifiers/DeliveryReceipts.ts index 039b809a4a63..22a5616595f6 100644 --- a/ts/messageModifiers/DeliveryReceipts.ts +++ b/ts/messageModifiers/DeliveryReceipts.ts @@ -3,7 +3,7 @@ /* eslint-disable max-classes-per-file */ -import { union } from 'lodash'; +import { isEqual } from 'lodash'; import { Collection, Model } from 'backbone'; import { ConversationModel } from '../models/conversations'; @@ -11,6 +11,8 @@ import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isIncoming } from '../state/selectors/message'; import { isDirectConversation } from '../util/whatTypeOfConversation'; +import { getOwn } from '../util/getOwn'; +import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; import dataInterface from '../sql/Client'; const { deleteSentProtoRecipient } = dataInterface; @@ -105,27 +107,45 @@ export class DeliveryReceipts extends Collection { return; } - const deliveries = message.get('delivered') || 0; - const originalDeliveredTo = message.get('delivered_to') || []; - const expirationStartTimestamp = message.get('expirationStartTimestamp'); - message.set({ - delivered_to: union(originalDeliveredTo, [deliveredTo]), - delivered: deliveries + 1, - expirationStartTimestamp: expirationStartTimestamp || Date.now(), - sent: true, - }); + const oldSendStateByConversationId = + message.get('sendStateByConversationId') || {}; + const oldSendState = getOwn(oldSendStateByConversationId, deliveredTo); + if (oldSendState) { + const newSendState = sendStateReducer(oldSendState, { + type: SendActionType.GotDeliveryReceipt, + updatedAt: timestamp, + }); - window.Signal.Util.queueUpdateMessage(message.attributes); + // The send state may not change. This can happen if the message was marked read + // before we got the delivery receipt, or if we got double delivery receipts, or + // things like that. + if (!isEqual(oldSendState, newSendState)) { + message.set('sendStateByConversationId', { + ...oldSendStateByConversationId, + [deliveredTo]: newSendState, + }); - // notify frontend listeners - const conversation = window.ConversationController.get( - message.get('conversationId') - ); - const updateLeftPane = conversation - ? conversation.debouncedUpdateLastMessage - : undefined; - if (updateLeftPane) { - updateLeftPane(); + window.Signal.Util.queueUpdateMessage(message.attributes); + + // notify frontend listeners + const conversation = window.ConversationController.get( + message.get('conversationId') + ); + const updateLeftPane = conversation + ? conversation.debouncedUpdateLastMessage + : undefined; + if (updateLeftPane) { + updateLeftPane(); + } + } + } else { + window.log.warn( + `Got a delivery receipt from someone (${deliveredTo}), but the message (sent at ${message.get( + 'sent_at' + )}) wasn't sent to them. It was sent to ${ + Object.keys(oldSendStateByConversationId).length + } recipients` + ); } const unidentifiedLookup = ( diff --git a/ts/messageModifiers/ReadReceipts.ts b/ts/messageModifiers/ReadReceipts.ts index 967251e4885c..5cf5ced61c36 100644 --- a/ts/messageModifiers/ReadReceipts.ts +++ b/ts/messageModifiers/ReadReceipts.ts @@ -3,6 +3,7 @@ /* eslint-disable max-classes-per-file */ +import { isEqual } from 'lodash'; import { Collection, Model } from 'backbone'; import { ConversationModel } from '../models/conversations'; @@ -10,6 +11,8 @@ import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isOutgoing } from '../state/selectors/message'; import { isDirectConversation } from '../util/whatTypeOfConversation'; +import { getOwn } from '../util/getOwn'; +import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; import dataInterface from '../sql/Client'; const { deleteSentProtoRecipient } = dataInterface; @@ -106,27 +109,45 @@ export class ReadReceipts extends Collection { return; } - const readBy = message.get('read_by') || []; - const expirationStartTimestamp = message.get('expirationStartTimestamp'); + const oldSendStateByConversationId = + message.get('sendStateByConversationId') || {}; + const oldSendState = getOwn(oldSendStateByConversationId, reader); + if (oldSendState) { + const newSendState = sendStateReducer(oldSendState, { + type: SendActionType.GotReadReceipt, + updatedAt: timestamp, + }); - readBy.push(reader); - message.set({ - read_by: readBy, - expirationStartTimestamp: expirationStartTimestamp || Date.now(), - sent: true, - }); + // The send state may not change. This can happen if we get read receipts after + // we get viewed receipts, or if we get double read receipts, or things like + // that. + if (!isEqual(oldSendState, newSendState)) { + message.set('sendStateByConversationId', { + ...oldSendStateByConversationId, + [reader]: newSendState, + }); - window.Signal.Util.queueUpdateMessage(message.attributes); + window.Signal.Util.queueUpdateMessage(message.attributes); - // notify frontend listeners - const conversation = window.ConversationController.get( - message.get('conversationId') - ); - const updateLeftPane = conversation - ? conversation.debouncedUpdateLastMessage - : undefined; - if (updateLeftPane) { - updateLeftPane(); + // notify frontend listeners + const conversation = window.ConversationController.get( + message.get('conversationId') + ); + const updateLeftPane = conversation + ? conversation.debouncedUpdateLastMessage + : undefined; + if (updateLeftPane) { + updateLeftPane(); + } + } + } else { + window.log.warn( + `Got a read receipt from someone (${reader}), but the message (sent at ${message.get( + 'sent_at' + )}) wasn't sent to them. It was sent to ${ + Object.keys(oldSendStateByConversationId).length + } recipients` + ); } const deviceId = receipt.get('readerDevice'); diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts new file mode 100644 index 000000000000..391b56de1c10 --- /dev/null +++ b/ts/messages/MessageSendState.ts @@ -0,0 +1,155 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { makeEnumParser } from '../util/enum'; + +/** + * `SendStatus` represents the send status of a message to a single recipient. For + * example, if a message is sent to 5 people, there would be 5 `SendStatus`es. + * + * Under normal conditions, the status will go down this list, in order: + * + * 1. `Pending`; the message has not been sent, and we are continuing to try + * 2. `Sent`; the message has been delivered to the server + * 3. `Delivered`; we've received a delivery receipt + * 4. `Read`; we've received a read receipt (not applicable if the recipient has disabled + * sending these receipts) + * 5. `Viewed`; we've received a viewed receipt (not applicable for all message types, or + * if the recipient has disabled sending these receipts) + * + * There's also a `Failed` state, which represents an error we don't want to recover from. + * + * There are some unusual cases where messages don't follow this pattern. For example, if + * we receive a read receipt before we receive a delivery receipt, we might skip the + * Delivered state. However, we should never go "backwards". + * + * Be careful when changing these values, as they are persisted. + */ +export enum SendStatus { + Failed = 'Failed', + Pending = 'Pending', + Sent = 'Sent', + Delivered = 'Delivered', + Read = 'Read', + Viewed = 'Viewed', +} + +export const parseMessageSendStatus = makeEnumParser( + SendStatus, + SendStatus.Pending +); + +const STATUS_NUMBERS: Record = { + [SendStatus.Failed]: 0, + [SendStatus.Pending]: 1, + [SendStatus.Sent]: 2, + [SendStatus.Delivered]: 3, + [SendStatus.Read]: 4, + [SendStatus.Viewed]: 5, +}; + +export const maxStatus = (a: SendStatus, b: SendStatus): SendStatus => + STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b; + +export const isRead = (status: SendStatus): boolean => + STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Read]; +export const isDelivered = (status: SendStatus): boolean => + STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered]; +export const isSent = (status: SendStatus): boolean => + STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent]; + +/** + * `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the + * user such as "this message was delivered at 6:09pm". + * + * The timestamp may be undefined if reading old data, which did not store a timestamp. + */ +export type SendState = Readonly<{ + status: + | SendStatus.Pending + | SendStatus.Failed + | SendStatus.Sent + | SendStatus.Delivered + | SendStatus.Read + | SendStatus.Viewed; + updatedAt?: number; +}>; + +/** + * The reducer advances the little `SendState` state machine. It mostly follows the steps + * in the `SendStatus` documentation above, but it also handles edge cases. + */ +export function sendStateReducer( + state: Readonly, + action: Readonly +): SendState { + const oldStatus = state.status; + let newStatus: SendStatus; + + if ( + oldStatus === SendStatus.Pending && + action.type === SendActionType.Failed + ) { + newStatus = SendStatus.Failed; + } else { + newStatus = maxStatus(oldStatus, STATE_TRANSITIONS[action.type]); + } + + return newStatus === oldStatus + ? state + : { + status: newStatus, + updatedAt: action.updatedAt, + }; +} + +export enum SendActionType { + Failed, + ManuallyRetried, + Sent, + GotDeliveryReceipt, + GotReadReceipt, + GotViewedReceipt, +} + +export type SendAction = Readonly<{ + type: + | SendActionType.Failed + | SendActionType.ManuallyRetried + | SendActionType.Sent + | SendActionType.GotDeliveryReceipt + | SendActionType.GotReadReceipt + | SendActionType.GotViewedReceipt; + // `updatedAt?: number` makes it easier to forget the property. With this type, you have + // to explicitly say it's missing. + updatedAt: undefined | number; +}>; + +const STATE_TRANSITIONS: Record = { + [SendActionType.Failed]: SendStatus.Failed, + [SendActionType.ManuallyRetried]: SendStatus.Pending, + [SendActionType.Sent]: SendStatus.Sent, + [SendActionType.GotDeliveryReceipt]: SendStatus.Delivered, + [SendActionType.GotReadReceipt]: SendStatus.Read, + [SendActionType.GotViewedReceipt]: SendStatus.Viewed, +}; + +export type SendStateByConversationId = Record; + +export const someSendStatus = ( + sendStateByConversationId: undefined | Readonly, + predicate: (value: SendStatus) => boolean +): boolean => + Object.values(sendStateByConversationId || {}).some(sendState => + predicate(sendState.status) + ); + +export const isMessageJustForMe = ( + sendStateByConversationId: undefined | Readonly, + ourConversationId: string +): boolean => { + const conversationIds = Object.keys(sendStateByConversationId || {}); + return ( + conversationIds.length === 1 && conversationIds[0] === ourConversationId + ); +}; diff --git a/ts/messages/migrateLegacySendAttributes.ts b/ts/messages/migrateLegacySendAttributes.ts new file mode 100644 index 000000000000..8bcaa13af9de --- /dev/null +++ b/ts/messages/migrateLegacySendAttributes.ts @@ -0,0 +1,172 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { get, isEmpty } from 'lodash'; +import { getOwn } from '../util/getOwn'; +import { map, concat, repeat, zipObject } from '../util/iterables'; +import { isOutgoing } from '../state/selectors/message'; +import type { CustomError, MessageAttributesType } from '../model-types.d'; +import { + SendState, + SendActionType, + SendStateByConversationId, + sendStateReducer, + SendStatus, +} from './MessageSendState'; + +/** + * This converts legacy message fields, such as `sent_to`, into the new + * `sendStateByConversationId` format. These legacy fields aren't typed to prevent their + * usage, so we treat them carefully (i.e., as if they are `unknown`). + * + * Old data isn't dropped, in case we need to revert this change. We should safely be able + * to remove the following attributes once we're confident in this new format: + * + * - delivered + * - delivered_to + * - read_by + * - recipients + * - sent + * - sent_to + */ +export function migrateLegacySendAttributes( + message: Readonly< + Pick< + MessageAttributesType, + 'errors' | 'sendStateByConversationId' | 'sent_at' | 'type' + > + >, + getConversation: GetConversationType, + ourConversationId: string +): undefined | SendStateByConversationId { + const shouldMigrate = + isEmpty(message.sendStateByConversationId) && isOutgoing(message); + if (!shouldMigrate) { + return undefined; + } + + /* eslint-disable no-restricted-syntax */ + const pendingSendState: SendState = { + status: SendStatus.Pending, + updatedAt: message.sent_at, + }; + + const sendStateByConversationId: SendStateByConversationId = zipObject( + getConversationIdsFromLegacyAttribute( + message, + 'recipients', + getConversation + ), + repeat(pendingSendState) + ); + + // We use `get` because `sent` is a legacy, and therefore untyped, attribute. + const wasSentToSelf = Boolean(get(message, 'sent')); + + const actions = concat<{ + type: + | SendActionType.Failed + | SendActionType.Sent + | SendActionType.GotDeliveryReceipt + | SendActionType.GotReadReceipt; + conversationId: string; + }>( + map( + getConversationIdsFromErrors(message.errors, getConversation), + conversationId => ({ + type: SendActionType.Failed, + conversationId, + }) + ), + map( + getConversationIdsFromLegacyAttribute( + message, + 'sent_to', + getConversation + ), + conversationId => ({ + type: SendActionType.Sent, + conversationId, + }) + ), + map( + getConversationIdsFromLegacyAttribute( + message, + 'delivered_to', + getConversation + ), + conversationId => ({ + type: SendActionType.GotDeliveryReceipt, + conversationId, + }) + ), + map( + getConversationIdsFromLegacyAttribute( + message, + 'read_by', + getConversation + ), + conversationId => ({ + type: SendActionType.GotReadReceipt, + conversationId, + }) + ), + [ + { + type: wasSentToSelf ? SendActionType.Sent : SendActionType.Failed, + conversationId: ourConversationId, + }, + ] + ); + + for (const { conversationId, type } of actions) { + const oldSendState = + getOwn(sendStateByConversationId, conversationId) || pendingSendState; + sendStateByConversationId[conversationId] = sendStateReducer(oldSendState, { + type, + updatedAt: undefined, + }); + } + + return sendStateByConversationId; + /* eslint-enable no-restricted-syntax */ +} + +function getConversationIdsFromErrors( + errors: undefined | ReadonlyArray, + getConversation: GetConversationType +): Array { + const result: Array = []; + (errors || []).forEach(error => { + const conversation = + getConversation(error.identifier) || getConversation(error.number); + if (conversation) { + result.push(conversation.id); + } + }); + return result; +} + +function getConversationIdsFromLegacyAttribute( + message: Record, + attributeName: string, + getConversation: GetConversationType +): Array { + const rawValue: unknown = + message[attributeName as keyof MessageAttributesType]; + const value: Array = Array.isArray(rawValue) ? rawValue : []; + + const result: Array = []; + value.forEach(identifier => { + if (typeof identifier !== 'string') { + return; + } + const conversation = getConversation(identifier); + if (conversation) { + result.push(conversation.id); + } + }); + return result; +} + +type GetConversationType = (id?: string | null) => { id: string } | undefined; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index fa9580301b8e..ceb6f0b65eb9 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -15,6 +15,10 @@ import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { CapabilitiesType } from './textsecure/WebAPI'; +import { + SendState, + SendStateByConversationId, +} from './messages/MessageSendState'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { ConversationColorType } from './types/Colors'; import { AttachmentType, ThumbnailType } from './types/Attachment'; @@ -87,8 +91,6 @@ export type MessageAttributesType = { decrypted_at?: number; deletedForEveryone?: boolean; deletedForEveryoneTimestamp?: number; - delivered?: number; - delivered_to?: Array; errors?: Array; expirationStartTimestamp?: number | null; expireTimer?: number; @@ -114,10 +116,8 @@ export type MessageAttributesType = { targetTimestamp: number; timestamp: number; }>; - read_by?: Array; requiredProtocolVersion?: number; retryOptions?: RetryOptions; - sent?: boolean; sourceDevice?: string | number; supportedVersionAtReceive?: unknown; synced?: boolean; @@ -151,14 +151,10 @@ export type MessageAttributesType = { data?: AttachmentType; }; sent_at: number; - sent_to?: Array; unidentifiedDeliveries?: Array; contact?: Array; conversationId: string; - recipients?: Array; reaction?: WhatIsThis; - destination?: WhatIsThis; - destinationUuid?: string; expirationTimerUpdate?: { expireTimer: number; @@ -191,6 +187,9 @@ export type MessageAttributesType = { droppedGV2MemberIds?: Array; sendHQImages?: boolean; + + // Should only be present for outgoing messages + sendStateByConversationId?: SendStateByConversationId; }; export type ConversationAttributesTypeType = 'private' | 'group'; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 4e04348bcfb0..fefca0486a5e 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -54,7 +54,15 @@ import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; -import { filter, map, take } from '../util/iterables'; +import { SendStatus } from '../messages/MessageSendState'; +import { + concat, + filter, + map, + take, + repeat, + zipObject, +} from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { @@ -3175,7 +3183,6 @@ export class ConversationModel extends window.Backbone // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const destination = this.getSendTarget()!; - const recipients = this.getRecipients(); return this.queueJob('sendDeleteForEveryone', async () => { window.log.info( @@ -3194,10 +3201,8 @@ export class ConversationModel extends window.Backbone sent_at: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, - recipients, deletedForEveryoneTimestamp: targetTimestamp, timestamp, - ...(isDirectConversation(this.attributes) ? { destination } : {}), }); // We're offline! @@ -3307,7 +3312,6 @@ export class ConversationModel extends window.Backbone // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const destination = this.getSendTarget()!; - const recipients = this.getRecipients(); return this.queueJob('sendReactionMessage', async () => { window.log.info( @@ -3330,10 +3334,8 @@ export class ConversationModel extends window.Backbone sent_at: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, - recipients, reaction: outgoingReaction, timestamp, - ...(isDirectConversation(this.attributes) ? { destination } : {}), }); // This is to ensure that the functions in send() and sendSyncMessage() don't save @@ -3526,6 +3528,18 @@ export class ConversationModel extends window.Backbone now ); + const recipientMaybeConversations = map(recipients, identifier => + window.ConversationController.get(identifier) + ); + const recipientConversations = filter( + recipientMaybeConversations, + isNotNil + ); + const recipientConversationIds = concat( + map(recipientConversations, c => c.id), + [window.ConversationController.getOurConversationIdOrThrow()] + ); + // Here we move attachments to disk const messageWithSchema = await upgradeMessageSchema({ timestamp: now, @@ -3543,6 +3557,13 @@ export class ConversationModel extends window.Backbone sticker, bodyRanges: mentions, sendHQImages, + sendStateByConversationId: zipObject( + recipientConversationIds, + repeat({ + status: SendStatus.Pending, + updatedAt: now, + }) + ), }); if (isDirectConversation(this.attributes)) { @@ -3586,17 +3607,13 @@ export class ConversationModel extends window.Backbone // We're offline! if (!window.textsecure.messaging) { - const errors = [ - ...(this.contactCollection && this.contactCollection.length - ? this.contactCollection - : [this]), - ].map(contact => { + const errors = map(recipientConversationIds, conversationId => { const error = new Error('Network is not available') as CustomError; error.name = 'SendMessageNetworkError'; - error.identifier = contact.get('id'); + error.identifier = conversationId; return error; }); - await message.saveErrors(errors); + await message.saveErrors([...errors]); return null; } @@ -3782,6 +3799,7 @@ export class ConversationModel extends window.Backbone (previewMessage ? getMessagePropStatus( previewMessage.attributes, + ourConversationId, window.storage.get('read-receipt-setting', false) ) : null) || null, @@ -4062,9 +4080,6 @@ export class ConversationModel extends window.Backbone // TODO: DESKTOP-722 } as unknown) as MessageAttributesType); - if (isDirectConversation(this.attributes)) { - model.set({ destination: this.getSendTarget() }); - } const id = await window.Signal.Data.saveMessage(model.attributes); model.set({ id }); @@ -4160,9 +4175,6 @@ export class ConversationModel extends window.Backbone // TODO: DESKTOP-722 } as unknown) as MessageAttributesType); - if (isDirectConversation(this.attributes)) { - model.set({ destination: this.id }); - } const id = await window.Signal.Data.saveMessage(model.attributes); model.set({ id }); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 49d5438654f8..554b851d4537 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1,7 +1,7 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isEmpty } from 'lodash'; +import { isEmpty, isEqual, noop, omit, union } from 'lodash'; import { CustomError, GroupV1Update, @@ -12,19 +12,23 @@ import { QuotedMessageType, WhatIsThis, } from '../model-types.d'; +import { concat, filter, find, map, reduce } from '../util/iterables'; +import { isNotNil } from '../util/isNotNil'; +import { isNormalNumber } from '../util/isNormalNumber'; import { strictAssert } from '../util/assert'; import { dropNull } from '../util/dropNull'; -import { map, filter, find } from '../util/iterables'; -import { isNotNil } from '../util/isNotNil'; import { ConversationModel } from './conversations'; -import { MessageStatusType } from '../components/conversation/Message'; import { OwnProps as SmartMessageDetailPropsType, Contact as SmartMessageDetailContact, } from '../state/smart/MessageDetail'; import { getCallingNotificationText } from '../util/callingNotification'; import { CallbackResultType } from '../textsecure/SendMessage'; -import { ProcessedDataMessage, ProcessedQuote } from '../textsecure/Types.d'; +import { + ProcessedDataMessage, + ProcessedQuote, + ProcessedUnidentifiedDeliveryStatus, +} from '../textsecure/Types.d'; import * as expirationTimer from '../util/expirationTimer'; import { ReactionType } from '../types/Reactions'; @@ -38,6 +42,18 @@ import * as Stickers from '../types/Stickers'; import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; import { ourProfileKeyService } from '../services/ourProfileKey'; +import { + SendAction, + SendActionType, + SendStateByConversationId, + SendStatus, + isMessageJustForMe, + isSent, + sendStateReducer, + someSendStatus, +} from '../messages/MessageSendState'; +import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; +import { getOwn } from '../util/getOwn'; import { markRead } from '../services/MessageUpdater'; import { isDirectConversation, @@ -121,9 +137,6 @@ const { getTextWithMentions, GoogleChrome } = window.Signal.Util; const { addStickerPackReference, getMessageBySender } = window.Signal.Data; const { bytesFromString } = window.Signal.Crypto; -const includesAny = (haystack: Array, ...needles: Array) => - needles.some(needle => haystack.includes(needle)); - export function isQuoteAMatch( message: MessageModel | null | undefined, conversationId: string, @@ -146,6 +159,8 @@ export function isQuoteAMatch( ); } +const isCustomError = (e: unknown): e is CustomError => e instanceof Error; + export class MessageModel extends window.Backbone.Model { static getLongMessageAttachment: ( attachment: typeof window.WhatIsThis @@ -177,6 +192,17 @@ export class MessageModel extends window.Backbone.Model { ); } + const sendStateByConversationId = migrateLegacySendAttributes( + this.attributes, + window.ConversationController.get.bind(window.ConversationController), + window.ConversationController.getOurConversationIdOrThrow() + ); + if (sendStateByConversationId) { + this.set('sendStateByConversationId', sendStateByConversationId, { + silent: true, + }); + } + this.CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; this.OUR_NUMBER = window.textsecure.storage.user.getNumber(); @@ -238,35 +264,41 @@ export class MessageModel extends window.Backbone.Model { ); } - getPropsForMessageDetail(): PropsForMessageDetail { + getPropsForMessageDetail(ourConversationId: string): PropsForMessageDetail { const newIdentity = window.i18n('newIdentity'); const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; - const unidentifiedLookup = ( - this.get('unidentifiedDeliveries') || [] - ).reduce((accumulator: Record, identifier: string) => { - accumulator[ - window.ConversationController.getConversationId(identifier) as string - ] = true; - return accumulator; - }, Object.create(null) as Record); + const sendStateByConversationId = + this.get('sendStateByConversationId') || {}; - // We include numbers we didn't successfully send to so we can display errors. - // Older messages don't have the recipients included on the message, so we fall - // back to the conversation's current recipients + const unidentifiedDeliveries = this.get('unidentifiedDeliveries') || []; + const unidentifiedDeliveriesSet = new Set( + map( + unidentifiedDeliveries, + identifier => + window.ConversationController.getConversationId(identifier) as string + ) + ); + + let conversationIds: Array; /* eslint-disable @typescript-eslint/no-non-null-assertion */ - const conversationIds = isIncoming(this.attributes) - ? [this.getContactId()!] - : _.union( - (this.get('sent_to') || []).map( - (id: string) => window.ConversationController.getConversationId(id)! - ), - ( - this.get('recipients') || this.getConversation()!.getRecipients() - ).map( - (id: string) => window.ConversationController.getConversationId(id)! - ) + if (isIncoming(this.attributes)) { + conversationIds = [this.getContactId()!]; + } else if (!isEmpty(sendStateByConversationId)) { + if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) { + conversationIds = [ourConversationId]; + } else { + conversationIds = Object.keys(sendStateByConversationId).filter( + id => id !== ourConversationId ); + } + } else { + // Older messages don't have the recipients included on the message, so we fall back + // to the conversation's current recipients + conversationIds = (this.getConversation()?.getRecipients() || []).map( + (id: string) => window.ConversationController.getConversationId(id)! + ); + } /* eslint-enable @typescript-eslint/no-non-null-assertion */ // This will make the error message for outgoing key errors a bit nicer @@ -292,9 +324,7 @@ export class MessageModel extends window.Backbone.Model { return window.ConversationController.getConversationId(identifier); }); - const finalContacts: Array = ( - conversationIds || [] - ).map( + const finalContacts: Array = conversationIds.map( (id: string): SmartMessageDetailContact => { const errorsForContact = errorsGroupedById[id]; const isOutgoingKeyError = Boolean( @@ -302,12 +332,19 @@ export class MessageModel extends window.Backbone.Model { ); const isUnidentifiedDelivery = window.storage.get('unidentifiedDeliveryIndicators', false) && - this.isUnidentifiedDelivery(id, unidentifiedLookup); + this.isUnidentifiedDelivery(id, unidentifiedDeliveriesSet); + + let status = getOwn(sendStateByConversationId, id)?.status || null; + + // If a message was only sent to yourself (Note to Self or a lonely group), it + // is shown read. + if (id === ourConversationId && status && isSent(status)) { + status = SendStatus.Read; + } return { ...findAndFormatContact(id), - - status: this.getStatus(id), + status, errors: errorsForContact, isOutgoingKeyError, isUnidentifiedDelivery, @@ -342,7 +379,7 @@ export class MessageModel extends window.Backbone.Model { message: getPropsForMessage( this.attributes, findAndFormatContact, - window.ConversationController.getOurConversationIdOrThrow(), + ourConversationId, this.OUR_NUMBER, this.OUR_UUID, undefined, @@ -365,33 +402,6 @@ export class MessageModel extends window.Backbone.Model { return window.ConversationController.get(this.get('conversationId')); } - private getStatus(identifier: string): MessageStatusType | null { - const conversation = window.ConversationController.get(identifier); - - if (!conversation) { - return null; - } - - const e164 = conversation.get('e164'); - const uuid = conversation.get('uuid'); - const conversationId = conversation.get('id'); - - const readBy = this.get('read_by') || []; - if (includesAny(readBy, conversationId, e164, uuid)) { - return 'read'; - } - const deliveredTo = this.get('delivered_to') || []; - if (includesAny(deliveredTo, conversationId, e164, uuid)) { - return 'delivered'; - } - const sentTo = this.get('sent_to') || []; - if (includesAny(sentTo, conversationId, e164, uuid)) { - return 'sent'; - } - - return null; - } - getNotificationData(): { emoji?: string; text: string } { const { attributes } = this; @@ -1056,13 +1066,13 @@ export class MessageModel extends window.Backbone.Model { isUnidentifiedDelivery( contactId: string, - lookup: Record + unidentifiedDeliveriesSet: Readonly> ): boolean { if (isIncoming(this.attributes)) { return Boolean(this.get('unidentifiedDeliveryReceived')); } - return Boolean(lookup[contactId]); + return unidentifiedDeliveriesSet.has(contactId); } getSource(): string | undefined { @@ -1201,42 +1211,62 @@ export class MessageModel extends window.Backbone.Model { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = this.getConversation()!; - const exists = (v: string | null): v is string => Boolean(v); - const intendedRecipients = (this.get('recipients') || []) - .map(identifier => - window.ConversationController.getConversationId(identifier) - ) - .filter(exists); - const successfulRecipients = (this.get('sent_to') || []) - .map(identifier => - window.ConversationController.getConversationId(identifier) - ) - .filter(exists); - const currentRecipients = conversation - .getRecipients() - .map(identifier => - window.ConversationController.getConversationId(identifier) - ) - .filter(exists); + const currentRecipients = new Set( + conversation + .getRecipients() + .map(identifier => + window.ConversationController.getConversationId(identifier) + ) + .filter(isNotNil) + ); const profileKey = conversation.get('profileSharing') ? await ourProfileKeyService.get() : undefined; // Determine retry recipients and get their most up-to-date addressing information - let recipients = _.intersection(intendedRecipients, currentRecipients); - recipients = _.without(recipients, ...successfulRecipients) - .map(id => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get(id)!; - return c.getSendTarget(); - }) - .filter((recipient): recipient is string => recipient !== undefined); + const oldSendStateByConversationId = + this.get('sendStateByConversationId') || {}; + + const recipients: Array = []; + const newSendStateByConversationId = { ...oldSendStateByConversationId }; + // eslint-disable-next-line no-restricted-syntax + for (const [conversationId, sendState] of Object.entries( + oldSendStateByConversationId + )) { + if (isSent(sendState.status)) { + continue; + } + + const isStillInConversation = currentRecipients.has(conversationId); + if (!isStillInConversation) { + continue; + } + + const recipient = window.ConversationController.get( + conversationId + )?.getSendTarget(); + if (!recipient) { + continue; + } + + newSendStateByConversationId[conversationId] = sendStateReducer( + sendState, + { + type: SendActionType.ManuallyRetried, + updatedAt: Date.now(), + } + ); + recipients.push(recipient); + } + + this.set('sendStateByConversationId', newSendStateByConversationId); + + await window.Signal.Data.saveMessage(this.attributes); if (!recipients.length) { window.log.warn('retrySend: Nobody to send to!'); - - return window.Signal.Data.saveMessage(this.attributes); + return undefined; } const attachmentsWithData = await Promise.all( @@ -1369,12 +1399,12 @@ export class MessageModel extends window.Backbone.Model { } public hasSuccessfulDelivery(): boolean { - const recipients = this.get('recipients') || []; - if (recipients.length === 0) { - return true; - } - - return (this.get('sent_to') || []).length !== 0; + const sendStateByConversationId = this.get('sendStateByConversationId'); + const withoutMe = omit( + sendStateByConversationId, + window.ConversationController.getOurConversationIdOrThrow() + ); + return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent); } // Called when the user ran into an error with a specific user, wants to send to them @@ -1516,152 +1546,186 @@ export class MessageModel extends window.Backbone.Model { async send( promise: Promise ): Promise> { - const conversation = this.getConversation(); - const updateLeftPane = conversation?.debouncedUpdateLastMessage; - if (updateLeftPane) { - updateLeftPane(); + const updateLeftPane = + this.getConversation()?.debouncedUpdateLastMessage || noop; + + updateLeftPane(); + + let result: + | { success: true; value: CallbackResultType } + | { + success: false; + value: CustomError | CallbackResultType; + }; + try { + const value = await (promise as Promise); + result = { success: true, value }; + } catch (err) { + result = { success: false, value: err }; } - return (promise as Promise) - .then(async result => { - if (updateLeftPane) { - updateLeftPane(); - } + updateLeftPane(); - // This is used by sendSyncMessage, then set to null - if (result.dataMessage) { - this.set({ dataMessage: result.dataMessage }); - } + const attributesToUpdate: Partial = {}; - const sentTo = this.get('sent_to') || []; - this.set({ - sent_to: _.union(sentTo, result.successfulIdentifiers), - sent: true, - expirationStartTimestamp: Date.now(), - unidentifiedDeliveries: _.union( - this.get('unidentifiedDeliveries') || [], - result.unidentifiedDeliveries - ), - }); + // This is used by sendSyncMessage, then set to null + if ('dataMessage' in result.value && result.value.dataMessage) { + attributesToUpdate.dataMessage = result.value.dataMessage; + } - if (!this.doNotSave) { - await window.Signal.Data.saveMessage(this.attributes); - } + if (!this.doNotSave) { + await window.Signal.Data.saveMessage(this.attributes); + } - if (updateLeftPane) { - updateLeftPane(); - } - this.sendSyncMessage(); - }) - .catch((result: CustomError | CallbackResultType) => { - if (updateLeftPane) { - updateLeftPane(); - } + const sendStateByConversationId = { + ...(this.get('sendStateByConversationId') || {}), + }; - if ('dataMessage' in result && result.dataMessage) { - this.set({ dataMessage: result.dataMessage }); - } + const successfulIdentifiers: Array = + 'successfulIdentifiers' in result.value && + Array.isArray(result.value.successfulIdentifiers) + ? result.value.successfulIdentifiers + : []; + const sentToAtLeastOneRecipient = + result.success || Boolean(successfulIdentifiers.length); - let promises = []; + successfulIdentifiers.forEach(identifier => { + const conversation = window.ConversationController.get(identifier); + if (!conversation) { + return; + } - // If we successfully sent to a user, we can remove our unregistered flag. - let successfulIdentifiers: Array; - if ('successfulIdentifiers' in result) { - ({ successfulIdentifiers = [] } = result); - } else { - successfulIdentifiers = []; - } - successfulIdentifiers.forEach((identifier: string) => { - const c = window.ConversationController.get(identifier); - if (c && c.isEverUnregistered()) { - c.setRegistered(); + // If we successfully sent to a user, we can remove our unregistered flag. + if (conversation.isEverUnregistered()) { + conversation.setRegistered(); + } + + const previousSendState = getOwn( + sendStateByConversationId, + conversation.id + ); + if (previousSendState) { + sendStateByConversationId[conversation.id] = sendStateReducer( + previousSendState, + { + type: SendActionType.Sent, + updatedAt: Date.now(), } - }); + ); + } + }); - const isError = (e: unknown): e is CustomError => e instanceof Error; + const previousUnidentifiedDeliveries = + this.get('unidentifiedDeliveries') || []; + const newUnidentifiedDeliveries = + 'unidentifiedDeliveries' in result.value && + Array.isArray(result.value.unidentifiedDeliveries) + ? result.value.unidentifiedDeliveries + : []; - if (isError(result)) { - this.saveErrors(result); - if (result.name === 'SignedPreKeyRotationError') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - promises.push(window.getAccountManager()!.rotateSignedPreKey()); - } else if (result.name === 'OutgoingIdentityKeyError') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get(result.number)!; - promises.push(c.getProfiles()); - } - } else { - if (successfulIdentifiers.length > 0) { - const sentTo = this.get('sent_to') || []; + const promises: Array> = []; - // If we just found out that we couldn't send to a user because they are no - // longer registered, we will update our unregistered flag. In groups we - // will not event try to send to them for 6 hours. And we will never try - // to fetch them on startup again. - // The way to discover registration once more is: - // 1) any attempt to send to them in 1:1 conversation - // 2) the six-hour time period has passed and we send in a group again - const unregisteredUserErrors = _.filter( - result.errors, - error => error.name === 'UnregisteredUserError' - ); - unregisteredUserErrors.forEach(error => { - const c = window.ConversationController.get(error.identifier); - if (c) { - c.setUnregistered(); - } - }); + let errors: Array; + if (isCustomError(result.value)) { + errors = [result.value]; + } else if (Array.isArray(result.value.errors)) { + ({ errors } = result.value); + } else { + errors = []; + } - // In groups, we don't treat unregistered users as a user-visible - // error. The message will look successful, but the details - // screen will show that we didn't send to these unregistered users. - const filteredErrors = _.reject( - result.errors, - error => error.name === 'UnregisteredUserError' - ); + // In groups, we don't treat unregistered users as a user-visible + // error. The message will look successful, but the details + // screen will show that we didn't send to these unregistered users. + const errorsToSave: Array = []; - // We don't start the expiration timer if there are real errors - // left after filtering out all of the unregistered user errors. - const expirationStartTimestamp = filteredErrors.length - ? null - : Date.now(); + let hadSignedPreKeyRotationError = false; + errors.forEach(error => { + const conversation = + window.ConversationController.get(error.identifier) || + window.ConversationController.get(error.number); - this.saveErrors(filteredErrors); - - this.set({ - sent_to: _.union(sentTo, result.successfulIdentifiers), - sent: true, - expirationStartTimestamp, - unidentifiedDeliveries: _.union( - this.get('unidentifiedDeliveries') || [], - result.unidentifiedDeliveries - ), - }); - promises.push(this.sendSyncMessage()); - } else if (result.errors) { - this.saveErrors(result.errors); - } - promises = promises.concat( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - _.map(result.errors, error => { - if (error.name === 'OutgoingIdentityKeyError') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get( - error.identifier || error.number - )!; - promises.push(c.getProfiles()); - } - }) + if (conversation) { + const previousSendState = getOwn( + sendStateByConversationId, + conversation.id + ); + if (previousSendState) { + sendStateByConversationId[conversation.id] = sendStateReducer( + previousSendState, + { + type: SendActionType.Failed, + updatedAt: Date.now(), + } ); } + } - if (updateLeftPane) { - updateLeftPane(); + let shouldSaveError = true; + switch (error.name) { + case 'SignedPreKeyRotationError': + hadSignedPreKeyRotationError = true; + break; + case 'OutgoingIdentityKeyError': { + if (conversation) { + promises.push(conversation.getProfiles()); + } + break; } + case 'UnregisteredUserError': + shouldSaveError = false; + // If we just found out that we couldn't send to a user because they are no + // longer registered, we will update our unregistered flag. In groups we + // will not event try to send to them for 6 hours. And we will never try + // to fetch them on startup again. + // + // The way to discover registration once more is: + // 1) any attempt to send to them in 1:1 conversation + // 2) the six-hour time period has passed and we send in a group again + conversation?.setUnregistered(); + break; + default: + break; + } - return Promise.all(promises); - }); + if (shouldSaveError) { + errorsToSave.push(error); + } + }); + + if (hadSignedPreKeyRotationError) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + promises.push(window.getAccountManager()!.rotateSignedPreKey()); + } + + attributesToUpdate.sendStateByConversationId = sendStateByConversationId; + attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient + ? Date.now() + : undefined; + attributesToUpdate.unidentifiedDeliveries = union( + previousUnidentifiedDeliveries, + newUnidentifiedDeliveries + ); + // We may overwrite this in the `saveErrors` call below. + attributesToUpdate.errors = []; + + this.set(attributesToUpdate); + // We skip save because we'll save in the next step. + this.saveErrors(errorsToSave, { skipSave: true }); + + if (!this.doNotSave) { + await window.Signal.Data.saveMessage(this.attributes); + } + + updateLeftPane(); + + if (sentToAtLeastOneRecipient) { + promises.push(this.sendSyncMessage()); + } + + await Promise.all(promises); + + updateLeftPane(); } // Currently used only for messages that have to be retried when the server @@ -1688,12 +1752,6 @@ export class MessageModel extends window.Backbone.Model { const sendOptions = await getSendOptions(conv.attributes); - // We don't have to check `sent_to` here, because: - // - // 1. This happens only in private conversations - // 2. Messages to different device ids for the same identifier are sent - // in a single request to the server. So partial success is not - // possible. await this.send( handleMessageSend( window.textsecure.messaging.resetSession( @@ -1721,10 +1779,7 @@ export class MessageModel extends window.Backbone.Model { try { this.set({ - // These are the same as a normal send() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sent_to: [conv.getSendTarget()!], - sent: true, + // This is the same as a normal send() expirationStartTimestamp: Date.now(), }); const result = await this.sendSyncMessage(); @@ -1734,12 +1789,6 @@ export class MessageModel extends window.Backbone.Model { result && result.unidentifiedDeliveries ? result.unidentifiedDeliveries : undefined, - - // These are unique to a Note to Self message - immediately read/delivered - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - delivered_to: [window.ConversationController.getOurConversationId()!], - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - read_by: [window.ConversationController.getOurConversationId()!], }); } catch (result) { const errors = (result && result.errors) || [new Error('Unknown error')]; @@ -1778,6 +1827,34 @@ export class MessageModel extends window.Backbone.Model { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conv = this.getConversation()!; + const sendEntries = Object.entries( + this.get('sendStateByConversationId') || {} + ); + const sentEntries = filter(sendEntries, ([_conversationId, { status }]) => + isSent(status) + ); + const allConversationIdsSentTo = map( + sentEntries, + ([conversationId]) => conversationId + ); + const conversationIdsSentTo = filter( + allConversationIdsSentTo, + conversationId => conversationId !== ourConversation.id + ); + + const unidentifiedDeliveries = this.get('unidentifiedDeliveries') || []; + const maybeConversationsWithSealedSender = map( + unidentifiedDeliveries, + identifier => window.ConversationController.get(identifier) + ); + const conversationsWithSealedSender = filter( + maybeConversationsWithSealedSender, + isNotNil + ); + const conversationIdsWithSealedSender = new Set( + map(conversationsWithSealedSender, c => c.id) + ); + return handleMessageSend( window.textsecure.messaging.sendSyncMessage({ encodedDataMessage: dataMessage, @@ -1786,16 +1863,39 @@ export class MessageModel extends window.Backbone.Model { destinationUuid: conv.get('uuid'), expirationStartTimestamp: this.get('expirationStartTimestamp') || null, - sentTo: this.get('sent_to') || [], - unidentifiedDeliveries: this.get('unidentifiedDeliveries') || [], + conversationIdsSentTo, + conversationIdsWithSealedSender, isUpdate, options: sendOptions, }), { messageIds: [this.id], sendType: 'sentSync' } ).then(async result => { + let newSendStateByConversationId: undefined | SendStateByConversationId; + const sendStateByConversationId = + this.get('sendStateByConversationId') || {}; + const ourOldSendState = getOwn( + sendStateByConversationId, + ourConversation.id + ); + if (ourOldSendState) { + const ourNewSendState = sendStateReducer(ourOldSendState, { + type: SendActionType.Sent, + updatedAt: Date.now(), + }); + if (ourNewSendState !== ourOldSendState) { + newSendStateByConversationId = { + ...sendStateByConversationId, + [ourConversation.id]: ourNewSendState, + }; + } + } + this.set({ synced: true, dataMessage: null, + ...(newSendStateByConversationId + ? { sendStateByConversationId: newSendStateByConversationId } + : {}), }); // Return early, skip the save @@ -2456,29 +2556,66 @@ export class MessageModel extends window.Backbone.Model { `handleDataMessage: Updating message ${message.idForLogging()} with received transcript` ); - let sentTo = []; - let unidentifiedDeliveries = []; - if (Array.isArray(data.unidentifiedStatus)) { - sentTo = data.unidentifiedStatus.map( - (item: typeof window.WhatIsThis) => item.destination - ); - - const unidentified = _.filter(data.unidentifiedStatus, item => - Boolean(item.unidentified) - ); - unidentifiedDeliveries = unidentified.map(item => item.destination); - } - const toUpdate = window.MessageController.register( existingMessage.id, existingMessage ); + + const unidentifiedDeliveriesSet = new Set( + toUpdate.get('unidentifiedDeliveries') ?? [] + ); + const sendStateByConversationId = { + ...(toUpdate.get('sendStateByConversationId') || {}), + }; + + const unidentifiedStatus: Array = Array.isArray( + data.unidentifiedStatus + ) + ? data.unidentifiedStatus + : []; + + unidentifiedStatus.forEach( + ({ destinationUuid, destination, unidentified }) => { + const identifier = destinationUuid || destination; + if (!identifier) { + return; + } + + const destinationConversationId = window.ConversationController.ensureContactIds( + { + uuid: destinationUuid, + e164: destination, + highTrust: true, + } + ); + if (!destinationConversationId) { + return; + } + + const previousSendState = getOwn( + sendStateByConversationId, + destinationConversationId + ); + if (previousSendState) { + sendStateByConversationId[ + destinationConversationId + ] = sendStateReducer(previousSendState, { + type: SendActionType.Sent, + updatedAt: isNormalNumber(data.timestamp) + ? data.timestamp + : Date.now(), + }); + } + + if (unidentified) { + unidentifiedDeliveriesSet.add(identifier); + } + } + ); + toUpdate.set({ - sent_to: _.union(toUpdate.get('sent_to'), sentTo), - unidentifiedDeliveries: _.union( - toUpdate.get('unidentifiedDeliveries'), - unidentifiedDeliveries - ), + sendStateByConversationId, + unidentifiedDeliveries: [...unidentifiedDeliveriesSet], }); await window.Signal.Data.saveMessage(toUpdate.attributes); @@ -3063,19 +3200,62 @@ export class MessageModel extends window.Backbone.Model { let changed = false; if (type === 'outgoing') { - const receipts = DeliveryReceipts.getSingleton().forMessage( - conversation, - message + const sendActions = concat<{ + destinationConversationId: string; + action: SendAction; + }>( + DeliveryReceipts.getSingleton() + .forMessage(conversation, message) + .map(receipt => ({ + destinationConversationId: receipt.get('deliveredTo'), + action: { + type: SendActionType.GotDeliveryReceipt, + updatedAt: receipt.get('timestamp'), + }, + })), + ReadReceipts.getSingleton() + .forMessage(conversation, message) + .map(receipt => ({ + destinationConversationId: receipt.get('reader'), + action: { + type: SendActionType.GotReadReceipt, + updatedAt: receipt.get('timestamp'), + }, + })) ); - receipts.forEach(receipt => { - message.set({ - delivered: (message.get('delivered') || 0) + 1, - delivered_to: _.union(message.get('delivered_to') || [], [ - receipt.get('deliveredTo'), - ]), - }); + + const oldSendStateByConversationId = + this.get('sendStateByConversationId') || {}; + + const newSendStateByConversationId = reduce( + sendActions, + ( + result: SendStateByConversationId, + { destinationConversationId, action } + ) => { + const oldSendState = getOwn(result, destinationConversationId); + if (!oldSendState) { + window.log.warn( + `Got a receipt for a conversation (${destinationConversationId}), but we have no record of sending to them` + ); + return result; + } + + const newSendState = sendStateReducer(oldSendState, action); + return { + ...result, + [destinationConversationId]: newSendState, + }; + }, + oldSendStateByConversationId + ); + + if ( + !isEqual(oldSendStateByConversationId, newSendStateByConversationId) + ) { + message.set('sendStateByConversationId', newSendStateByConversationId); changed = true; - }); + } } if (type === 'incoming') { @@ -3108,34 +3288,6 @@ export class MessageModel extends window.Backbone.Model { } } - if (type === 'outgoing') { - const reads = ReadReceipts.getSingleton().forMessage( - conversation, - message - ); - if (reads.length) { - const readBy = reads.map(receipt => receipt.get('reader')); - message.set({ - read_by: _.union(message.get('read_by'), readBy), - }); - changed = true; - } - - // A sync'd message to ourself is automatically considered read/delivered - if (isFirstRun && isMe(conversation.attributes)) { - message.set({ - read_by: conversation.getRecipients(), - delivered_to: conversation.getRecipients(), - }); - changed = true; - } - - if (isFirstRun) { - message.set({ recipients: conversation.getRecipients() }); - changed = true; - } - } - // Check for out-of-order view syncs if (type === 'incoming' && isTapToView(message.attributes)) { const viewSync = ViewSyncs.getSingleton().forMessage(message); @@ -3189,6 +3341,7 @@ export class MessageModel extends window.Backbone.Model { (isIncoming(attributes) || getMessagePropStatus( attributes, + window.ConversationController.getOurConversationIdOrThrow(), window.storage.get('read-receipt-setting', false) ) !== 'partial-sent') ) { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index b7cd7db8dd14..83fbdac4555d 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isNumber, isObject, map, reduce } from 'lodash'; +import { isNumber, isObject, map, omit, reduce } from 'lodash'; import filesize from 'filesize'; import { @@ -46,6 +46,15 @@ import { GetConversationByIdType, isMissingRequiredProfileSharing, } from './conversations'; +import { + SendStatus, + isDelivered, + isMessageJustForMe, + isRead, + isSent, + maxStatus, + someSendStatus, +} from '../../messages/MessageSendState'; const THREE_HOURS = 3 * 60 * 60 * 1000; @@ -220,7 +229,9 @@ export function isOutgoing( return message.type === 'outgoing'; } -export function hasErrors(message: MessageAttributesType): boolean { +export function hasErrors( + message: Pick +): boolean { return message.errors ? message.errors.length > 0 : false; } @@ -358,7 +369,7 @@ export function getPropsForMessage( bodyRanges: processBodyRanges(message.bodyRanges, conversationSelector), canDeleteForEveryone: canDeleteForEveryone(message), canDownload: canDownload(message, conversationSelector), - canReply: canReply(message, conversationSelector), + canReply: canReply(message, ourConversationId, conversationSelector), contact: getPropsForEmbeddedContact(message, regionCode, accountSelector), conversationColor: conversation?.conversationColor ?? ConversationColors[0], conversationId: message.conversationId, @@ -382,7 +393,11 @@ export function getPropsForMessage( quote: getPropsForQuote(message, conversationSelector, ourConversationId), reactions, selectedReaction, - status: getMessagePropStatus(message, readReceiptSetting), + status: getMessagePropStatus( + message, + ourConversationId, + readReceiptSetting + ), text: createNonBreakingLastSeparator(message.body), textPending: message.bodyPending, timestamp: message.sent_at, @@ -882,38 +897,54 @@ function createNonBreakingLastSeparator(text?: string): string { } export function getMessagePropStatus( - message: MessageAttributesType, + message: Pick< + MessageAttributesType, + 'type' | 'errors' | 'sendStateByConversationId' + >, + ourConversationId: string, readReceiptSetting: boolean ): LastMessageStatus | undefined { - const { sent } = message; - const sentTo = message.sent_to || []; - - if (hasErrors(message)) { - if (getLastChallengeError(message)) { - return 'paused'; - } - if (sent || sentTo.length > 0) { - return 'partial-sent'; - } - return 'error'; - } if (!isOutgoing(message)) { return undefined; } - const readBy = message.read_by || []; - if (readReceiptSetting && readBy.length > 0) { - return 'read'; - } - const { delivered } = message; - const deliveredTo = message.delivered_to || []; - if (delivered || deliveredTo.length > 0) { - return 'delivered'; - } - if (sent || sentTo.length > 0) { - return 'sent'; + if (getLastChallengeError(message)) { + return 'paused'; } + const { sendStateByConversationId = {} } = message; + + if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) { + const status = + sendStateByConversationId[ourConversationId]?.status ?? + SendStatus.Pending; + const sent = isSent(status); + if (hasErrors(message)) { + return sent ? 'partial-sent' : 'error'; + } + return sent ? 'read' : 'sending'; + } + + const sendStates = Object.values( + omit(sendStateByConversationId, ourConversationId) + ); + const highestSuccessfulStatus = sendStates.reduce( + (result: SendStatus, { status }) => maxStatus(result, status), + SendStatus.Pending + ); + + if (hasErrors(message)) { + return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error'; + } + if (readReceiptSetting && isRead(highestSuccessfulStatus)) { + return 'read'; + } + if (isDelivered(highestSuccessfulStatus)) { + return 'delivered'; + } + if (isSent(highestSuccessfulStatus)) { + return 'sent'; + } return 'sending'; } @@ -1066,12 +1097,16 @@ function processQuoteAttachment( export function canReply( message: Pick< MessageAttributesType, - 'conversationId' | 'deletedForEveryone' | 'sent_to' | 'type' + | 'conversationId' + | 'deletedForEveryone' + | 'sendStateByConversationId' + | 'type' >, + ourConversationId: string, conversationSelector: GetConversationByIdType ): boolean { const conversation = getConversation(message, conversationSelector); - const { deletedForEveryone, sent_to: sentTo } = message; + const { deletedForEveryone, sendStateByConversationId } = message; if (!conversation) { return false; @@ -1100,7 +1135,10 @@ export function canReply( // We can reply if this is outgoing and sent to at least one recipient if (isOutgoing(message)) { - return (sentTo || []).length > 0; + return ( + isMessageJustForMe(sendStateByConversationId, ourConversationId) || + someSendStatus(omit(sendStateByConversationId, ourConversationId), isSent) + ); } // We can reply to incoming messages @@ -1188,7 +1226,7 @@ export function getAttachmentsForMessage( } export function getLastChallengeError( - message: MessageAttributesType + message: Pick ): ShallowChallengeError | undefined { const { errors } = message; if (!errors) { diff --git a/ts/test-both/messages/MessageSendState_test.ts b/ts/test-both/messages/MessageSendState_test.ts new file mode 100644 index 000000000000..afd70d40372d --- /dev/null +++ b/ts/test-both/messages/MessageSendState_test.ts @@ -0,0 +1,432 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { sampleSize, times } from 'lodash'; +import { v4 as uuid } from 'uuid'; + +import { + SendAction, + SendActionType, + SendState, + SendStateByConversationId, + SendStatus, + isDelivered, + isMessageJustForMe, + isRead, + isSent, + maxStatus, + sendStateReducer, + someSendStatus, +} from '../../messages/MessageSendState'; + +describe('message send state utilities', () => { + describe('maxStatus', () => { + const expectedOrder = [ + SendStatus.Failed, + SendStatus.Pending, + SendStatus.Sent, + SendStatus.Delivered, + SendStatus.Read, + SendStatus.Viewed, + ]; + + it('returns the input if arguments are equal', () => { + expectedOrder.forEach(status => { + assert.strictEqual(maxStatus(status, status), status); + }); + }); + + it('orders the statuses', () => { + times(100, () => { + const [a, b] = sampleSize(expectedOrder, 2); + const isABigger = expectedOrder.indexOf(a) > expectedOrder.indexOf(b); + const expected = isABigger ? a : b; + + const actual = maxStatus(a, b); + assert.strictEqual(actual, expected); + }); + }); + }); + + describe('isRead', () => { + it('returns true for read and viewed statuses', () => { + assert.isTrue(isRead(SendStatus.Read)); + assert.isTrue(isRead(SendStatus.Viewed)); + }); + + it('returns false for non-read statuses', () => { + assert.isFalse(isRead(SendStatus.Delivered)); + assert.isFalse(isRead(SendStatus.Sent)); + assert.isFalse(isRead(SendStatus.Pending)); + assert.isFalse(isRead(SendStatus.Failed)); + }); + }); + + describe('isDelivered', () => { + it('returns true for delivered, read, and viewed statuses', () => { + assert.isTrue(isDelivered(SendStatus.Delivered)); + assert.isTrue(isDelivered(SendStatus.Read)); + assert.isTrue(isDelivered(SendStatus.Viewed)); + }); + + it('returns false for non-delivered statuses', () => { + assert.isFalse(isDelivered(SendStatus.Sent)); + assert.isFalse(isDelivered(SendStatus.Pending)); + assert.isFalse(isDelivered(SendStatus.Failed)); + }); + }); + + describe('isSent', () => { + it('returns true for all statuses sent and "above"', () => { + assert.isTrue(isSent(SendStatus.Sent)); + assert.isTrue(isSent(SendStatus.Delivered)); + assert.isTrue(isSent(SendStatus.Read)); + assert.isTrue(isSent(SendStatus.Viewed)); + }); + + it('returns false for non-sent statuses', () => { + assert.isFalse(isSent(SendStatus.Pending)); + assert.isFalse(isSent(SendStatus.Failed)); + }); + }); + + describe('someSendStatus', () => { + it('returns false if there are no send states', () => { + const alwaysTrue = () => true; + assert.isFalse(someSendStatus(undefined, alwaysTrue)); + assert.isFalse(someSendStatus({}, alwaysTrue)); + }); + + it('returns false if no send states match', () => { + const sendStateByConversationId: SendStateByConversationId = { + abc: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + def: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + }; + + assert.isFalse( + someSendStatus( + sendStateByConversationId, + (status: SendStatus) => status === SendStatus.Delivered + ) + ); + }); + + it('returns true if at least one send state matches', () => { + const sendStateByConversationId: SendStateByConversationId = { + abc: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + def: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + }; + + assert.isTrue( + someSendStatus( + sendStateByConversationId, + (status: SendStatus) => status === SendStatus.Read + ) + ); + }); + }); + + describe('isMessageJustForMe', () => { + const ourConversationId = uuid(); + + it('returns false if the conversation has an empty send state', () => { + assert.isFalse(isMessageJustForMe(undefined, ourConversationId)); + assert.isFalse(isMessageJustForMe({}, ourConversationId)); + }); + + it('returns false if the message is for anyone else', () => { + assert.isFalse( + isMessageJustForMe( + { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: 123, + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: 123, + }, + }, + ourConversationId + ) + ); + // This is an invalid state, but we still want to test the behavior. + assert.isFalse( + isMessageJustForMe( + { + [uuid()]: { + status: SendStatus.Pending, + updatedAt: 123, + }, + }, + ourConversationId + ) + ); + }); + + it('returns true if the message is just for you', () => { + assert.isTrue( + isMessageJustForMe( + { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: 123, + }, + }, + ourConversationId + ) + ); + }); + }); + + describe('sendStateReducer', () => { + const assertTransition = ( + startStatus: SendStatus, + actionType: SendActionType, + expectedStatus: SendStatus + ): void => { + const startState: SendState = { + status: startStatus, + updatedAt: 1, + }; + const action: SendAction = { + type: actionType, + updatedAt: 2, + }; + const result = sendStateReducer(startState, action); + assert.strictEqual(result.status, expectedStatus); + assert.strictEqual( + result.updatedAt, + startStatus === expectedStatus ? 1 : 2 + ); + }; + + describe('transitions from Pending', () => { + it('goes from Pending → Failed with a failure', () => { + const result = sendStateReducer( + { status: SendStatus.Pending, updatedAt: 999 }, + { type: SendActionType.Failed, updatedAt: 123 } + ); + assert.deepEqual(result, { + status: SendStatus.Failed, + updatedAt: 123, + }); + }); + + it('does nothing when receiving ManuallyRetried', () => { + assertTransition( + SendStatus.Pending, + SendActionType.ManuallyRetried, + SendStatus.Pending + ); + }); + + it('goes from Pending to all other sent states', () => { + assertTransition( + SendStatus.Pending, + SendActionType.Sent, + SendStatus.Sent + ); + assertTransition( + SendStatus.Pending, + SendActionType.GotDeliveryReceipt, + SendStatus.Delivered + ); + assertTransition( + SendStatus.Pending, + SendActionType.GotReadReceipt, + SendStatus.Read + ); + assertTransition( + SendStatus.Pending, + SendActionType.GotViewedReceipt, + SendStatus.Viewed + ); + }); + }); + + describe('transitions from Failed', () => { + it('does nothing when receiving a Failed action', () => { + const result = sendStateReducer( + { + status: SendStatus.Failed, + updatedAt: 123, + }, + { + type: SendActionType.Failed, + updatedAt: 999, + } + ); + assert.deepEqual(result, { + status: SendStatus.Failed, + updatedAt: 123, + }); + }); + + it('goes from Failed to all other states', () => { + assertTransition( + SendStatus.Failed, + SendActionType.ManuallyRetried, + SendStatus.Pending + ); + assertTransition( + SendStatus.Failed, + SendActionType.Sent, + SendStatus.Sent + ); + assertTransition( + SendStatus.Failed, + SendActionType.GotDeliveryReceipt, + SendStatus.Delivered + ); + assertTransition( + SendStatus.Failed, + SendActionType.GotReadReceipt, + SendStatus.Read + ); + assertTransition( + SendStatus.Failed, + SendActionType.GotViewedReceipt, + SendStatus.Viewed + ); + }); + }); + + describe('transitions from Sent', () => { + it('does nothing when trying to go "backwards"', () => { + [SendActionType.Failed, SendActionType.ManuallyRetried].forEach( + type => { + assertTransition(SendStatus.Sent, type, SendStatus.Sent); + } + ); + }); + + it('does nothing when receiving a Sent action', () => { + assertTransition(SendStatus.Sent, SendActionType.Sent, SendStatus.Sent); + }); + + it('can go forward to other states', () => { + assertTransition( + SendStatus.Sent, + SendActionType.GotDeliveryReceipt, + SendStatus.Delivered + ); + assertTransition( + SendStatus.Sent, + SendActionType.GotReadReceipt, + SendStatus.Read + ); + assertTransition( + SendStatus.Sent, + SendActionType.GotViewedReceipt, + SendStatus.Viewed + ); + }); + }); + + describe('transitions from Delivered', () => { + it('does nothing when trying to go "backwards"', () => { + [ + SendActionType.Failed, + SendActionType.ManuallyRetried, + SendActionType.Sent, + ].forEach(type => { + assertTransition(SendStatus.Delivered, type, SendStatus.Delivered); + }); + }); + + it('does nothing when receiving a delivery receipt', () => { + assertTransition( + SendStatus.Delivered, + SendActionType.GotDeliveryReceipt, + SendStatus.Delivered + ); + }); + + it('can go forward to other states', () => { + assertTransition( + SendStatus.Delivered, + SendActionType.GotReadReceipt, + SendStatus.Read + ); + assertTransition( + SendStatus.Delivered, + SendActionType.GotViewedReceipt, + SendStatus.Viewed + ); + }); + }); + + describe('transitions from Read', () => { + it('does nothing when trying to go "backwards"', () => { + [ + SendActionType.Failed, + SendActionType.ManuallyRetried, + SendActionType.Sent, + SendActionType.GotDeliveryReceipt, + ].forEach(type => { + assertTransition(SendStatus.Read, type, SendStatus.Read); + }); + }); + + it('does nothing when receiving a read receipt', () => { + assertTransition( + SendStatus.Read, + SendActionType.GotReadReceipt, + SendStatus.Read + ); + }); + + it('can go forward to the "viewed" state', () => { + assertTransition( + SendStatus.Read, + SendActionType.GotViewedReceipt, + SendStatus.Viewed + ); + }); + }); + + describe('transitions from Viewed', () => { + it('ignores all actions', () => { + [ + SendActionType.Failed, + SendActionType.ManuallyRetried, + SendActionType.Sent, + SendActionType.GotDeliveryReceipt, + SendActionType.GotReadReceipt, + SendActionType.GotViewedReceipt, + ].forEach(type => { + assertTransition(SendStatus.Viewed, type, SendStatus.Viewed); + }); + }); + }); + + describe('legacy transitions', () => { + it('allows actions without timestamps', () => { + const startState: SendState = { + status: SendStatus.Pending, + updatedAt: Date.now(), + }; + const action: SendAction = { + type: SendActionType.Sent, + updatedAt: undefined, + }; + const result = sendStateReducer(startState, action); + assert.isUndefined(result.updatedAt); + }); + }); + }); +}); diff --git a/ts/test-both/messages/migrateLegacySendAttributes_test.ts b/ts/test-both/messages/migrateLegacySendAttributes_test.ts new file mode 100644 index 000000000000..07e87b760da0 --- /dev/null +++ b/ts/test-both/messages/migrateLegacySendAttributes_test.ts @@ -0,0 +1,263 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { getDefaultConversation } from '../helpers/getDefaultConversation'; +import { ConversationType } from '../../state/ducks/conversations'; +import { SendStatus } from '../../messages/MessageSendState'; + +import { migrateLegacySendAttributes } from '../../messages/migrateLegacySendAttributes'; + +describe('migrateLegacySendAttributes', () => { + const defaultMessage = { + type: 'outgoing' as const, + sent_at: 123, + sent: true, + }; + + const createGetConversation = ( + ...conversations: ReadonlyArray + ) => { + const lookup = new Map(); + conversations.forEach(conversation => { + [conversation.id, conversation.uuid, conversation.e164].forEach( + property => { + if (property) { + lookup.set(property, conversation); + } + } + ); + }); + + return (id?: string | null) => (id ? lookup.get(id) : undefined); + }; + + it("doesn't migrate messages that already have the modern send state", () => { + const ourConversationId = uuid(); + const message = { + ...defaultMessage, + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: 123, + }, + }, + }; + const getConversation = () => undefined; + + assert.isUndefined( + migrateLegacySendAttributes(message, getConversation, ourConversationId) + ); + }); + + it("doesn't migrate messages that aren't outgoing", () => { + const ourConversationId = uuid(); + const message = { + ...defaultMessage, + type: 'incoming' as const, + }; + const getConversation = () => undefined; + + assert.isUndefined( + migrateLegacySendAttributes(message, getConversation, ourConversationId) + ); + }); + + it('advances the send state machine, starting from "pending", for different state types', () => { + let e164Counter = 0; + const getTestConversation = () => { + const last4Digits = e164Counter.toString().padStart(4); + assert.strictEqual( + last4Digits.length, + 4, + 'Test setup failure: E164 is too long' + ); + e164Counter += 1; + return getDefaultConversation({ e164: `+1999555${last4Digits}` }); + }; + + // This is aliased for clarity. + const ignoredUuid = uuid; + + const failedConversationByUuid = getTestConversation(); + const failedConversationByE164 = getTestConversation(); + const pendingConversation = getTestConversation(); + const sentConversation = getTestConversation(); + const deliveredConversation = getTestConversation(); + const readConversation = getTestConversation(); + const conversationNotInRecipientsList = getTestConversation(); + const ourConversation = getTestConversation(); + + const message = { + ...defaultMessage, + recipients: [ + failedConversationByUuid.uuid, + failedConversationByE164.uuid, + pendingConversation.uuid, + sentConversation.uuid, + deliveredConversation.uuid, + readConversation.uuid, + ignoredUuid(), + ourConversation.uuid, + ], + errors: [ + Object.assign(new Error('looked up by UUID'), { + identifier: failedConversationByUuid.uuid, + }), + Object.assign(new Error('looked up by E164'), { + number: failedConversationByE164.e164, + }), + Object.assign(new Error('ignored error'), { + identifier: ignoredUuid(), + }), + new Error('a different error'), + ], + sent_to: [ + sentConversation.e164, + conversationNotInRecipientsList.uuid, + ignoredUuid(), + ourConversation.uuid, + ], + delivered_to: [ + deliveredConversation.uuid, + ignoredUuid(), + ourConversation.uuid, + ], + read_by: [readConversation.uuid, ignoredUuid()], + }; + const getConversation = createGetConversation( + failedConversationByUuid, + failedConversationByE164, + pendingConversation, + sentConversation, + deliveredConversation, + readConversation, + conversationNotInRecipientsList, + ourConversation + ); + + assert.deepEqual( + migrateLegacySendAttributes(message, getConversation, ourConversation.id), + { + [ourConversation.id]: { + status: SendStatus.Delivered, + updatedAt: undefined, + }, + [failedConversationByUuid.id]: { + status: SendStatus.Failed, + updatedAt: undefined, + }, + [failedConversationByE164.id]: { + status: SendStatus.Failed, + updatedAt: undefined, + }, + [pendingConversation.id]: { + status: SendStatus.Pending, + updatedAt: message.sent_at, + }, + [sentConversation.id]: { + status: SendStatus.Sent, + updatedAt: undefined, + }, + [conversationNotInRecipientsList.id]: { + status: SendStatus.Sent, + updatedAt: undefined, + }, + [deliveredConversation.id]: { + status: SendStatus.Delivered, + updatedAt: undefined, + }, + [readConversation.id]: { + status: SendStatus.Read, + updatedAt: undefined, + }, + } + ); + }); + + it('considers our own conversation sent if the "sent" attribute is set', () => { + const ourConversation = getDefaultConversation(); + const conversation1 = getDefaultConversation(); + const conversation2 = getDefaultConversation(); + + const message = { + ...defaultMessage, + recipients: [conversation1.id, conversation2.id], + sent: true, + }; + const getConversation = createGetConversation( + ourConversation, + conversation1, + conversation2 + ); + + assert.deepEqual( + migrateLegacySendAttributes( + message, + getConversation, + ourConversation.id + )?.[ourConversation.id], + { + status: SendStatus.Sent, + updatedAt: undefined, + } + ); + }); + + it("considers our own conversation failed if the message isn't marked sent and we aren't elsewhere in the recipients list", () => { + const ourConversation = getDefaultConversation(); + const conversation1 = getDefaultConversation(); + const conversation2 = getDefaultConversation(); + + const message = { + ...defaultMessage, + recipients: [conversation1.id, conversation2.id], + sent: false, + }; + const getConversation = createGetConversation( + ourConversation, + conversation1, + conversation2 + ); + + assert.deepEqual( + migrateLegacySendAttributes( + message, + getConversation, + ourConversation.id + )?.[ourConversation.id], + { + status: SendStatus.Failed, + updatedAt: undefined, + } + ); + }); + + it('migrates a typical legacy note to self message', () => { + const ourConversation = getDefaultConversation(); + const message = { + ...defaultMessage, + conversationId: ourConversation.id, + recipients: [], + destination: ourConversation.uuid, + sent_to: [ourConversation.uuid], + sent: true, + synced: true, + unidentifiedDeliveries: [], + delivered_to: [ourConversation.id], + read_by: [ourConversation.id], + }; + const getConversation = createGetConversation(ourConversation); + + assert.deepEqual( + migrateLegacySendAttributes(message, getConversation, ourConversation.id), + { + [ourConversation.id]: { + status: SendStatus.Read, + updatedAt: undefined, + }, + } + ); + }); +}); diff --git a/ts/test-both/util/iterables_test.ts b/ts/test-both/util/iterables_test.ts index 02b02c71974f..60d2b1572093 100644 --- a/ts/test-both/util/iterables_test.ts +++ b/ts/test-both/util/iterables_test.ts @@ -9,11 +9,14 @@ import { filter, find, groupBy, + isEmpty, isIterable, map, reduce, + repeat, size, take, + zipObject, } from '../../util/iterables'; describe('iterable utilities', () => { @@ -61,6 +64,15 @@ describe('iterable utilities', () => { }); }); + describe('repeat', () => { + it('repeats the same value forever', () => { + const result = repeat('foo'); + + const truncated = [...take(result, 10)]; + assert.deepEqual(truncated, Array(10).fill('foo')); + }); + }); + describe('size', () => { it('returns the length of a string', () => { assert.strictEqual(size(''), 0); @@ -261,6 +273,28 @@ describe('iterable utilities', () => { }); }); + describe('isEmpty', () => { + it('returns true for empty iterables', () => { + assert.isTrue(isEmpty('')); + assert.isTrue(isEmpty([])); + assert.isTrue(isEmpty(new Set())); + }); + + it('returns false for non-empty iterables', () => { + assert.isFalse(isEmpty(' ')); + assert.isFalse(isEmpty([1, 2])); + assert.isFalse(isEmpty(new Set([3, 4]))); + }); + + it('does not "look past" the first element', () => { + function* numbers() { + yield 1; + throw new Error('this should never happen'); + } + assert.isFalse(isEmpty(numbers())); + }); + }); + describe('map', () => { it('returns an empty iterable when passed an empty iterable', () => { const fn = sinon.fake(); @@ -352,4 +386,23 @@ describe('iterable utilities', () => { assert.deepEqual([...take(set, 10000)], [1, 2, 3]); }); }); + + describe('zipObject', () => { + it('zips up an object', () => { + assert.deepEqual(zipObject(['foo', 'bar'], [1, 2]), { foo: 1, bar: 2 }); + }); + + it('stops if the keys "run out" first', () => { + assert.deepEqual(zipObject(['foo', 'bar'], [1, 2, 3, 4, 5, 6]), { + foo: 1, + bar: 2, + }); + }); + + it('stops if the values "run out" first', () => { + assert.deepEqual(zipObject(['foo', 'bar', 'baz'], [1]), { + foo: 1, + }); + }); + }); }); diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index cd130aa5dcd3..789151b304f1 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { SendStatus } from '../../messages/MessageSendState'; describe('Conversations', () => { async function resetConversationController(): Promise { @@ -19,9 +20,9 @@ describe('Conversations', () => { // Creating a fake conversation const conversation = new window.Whisper.Conversation({ - id: '8c45efca-67a4-4026-b990-9537d5d1a08f', + id: window.getGuid(), e164: '+15551234567', - uuid: '2f2734aa-f69d-4c1c-98eb-50eb0fc512d7', + uuid: window.getGuid(), type: 'private', inbox_position: 0, isPinned: false, @@ -33,7 +34,6 @@ describe('Conversations', () => { version: 0, }); - const destinationE164 = '+15557654321'; window.textsecure.storage.user.setNumberAndDeviceId( ourNumber, 2, @@ -42,27 +42,29 @@ describe('Conversations', () => { window.textsecure.storage.user.setUuidAndDeviceId(ourUuid, 2); await window.ConversationController.loadPromise(); + await window.Signal.Data.saveConversation(conversation.attributes); + // Creating a fake message const now = Date.now(); let message = new window.Whisper.Message({ attachments: [], body: 'bananas', conversationId: conversation.id, - delivered: 1, - delivered_to: [destinationE164], - destination: destinationE164, expirationStartTimestamp: now, hasAttachments: false, hasFileAttachments: false, hasVisualMediaAttachments: false, - id: 'd8f2b435-e2ef-46e0-8481-07e68af251c6', + id: window.getGuid(), received_at: now, - recipients: [destinationE164], - sent: true, sent_at: now, - sent_to: [destinationE164], timestamp: now, type: 'outgoing', + sendStateByConversationId: { + [conversation.id]: { + status: SendStatus.Sent, + updatedAt: now, + }, + }, }); // Saving to db and updating the convo's last message @@ -70,7 +72,7 @@ describe('Conversations', () => { forceSave: true, }); message = window.MessageController.register(message.id, message); - await window.Signal.Data.saveConversation(conversation.attributes); + await window.Signal.Data.updateConversation(conversation.attributes); await conversation.updateLastMessage(); // Should be set to bananas because that's the last message sent. diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index 6aa62988f65d..cebf66f955e3 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -5,10 +5,21 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; -import { CallbackResultType } from '../../textsecure/SendMessage'; +import { SendStatus } from '../../messages/MessageSendState'; +import MessageSender, { + CallbackResultType, +} from '../../textsecure/SendMessage'; +import type { StorageAccessType } from '../../types/Storage.d'; import { SignalService as Proto } from '../../protobuf'; describe('Message', () => { + const STORAGE_KEYS_TO_RESTORE: Array = [ + 'number_id', + 'uuid_id', + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldStorageValues = new Map(); + const i18n = setupI18n('en', enMessages); const attributes = { @@ -34,16 +45,25 @@ describe('Message', () => { before(async () => { window.ConversationController.reset(); await window.ConversationController.load(); + + STORAGE_KEYS_TO_RESTORE.forEach(key => { + oldStorageValues.set(key, window.textsecure.storage.get(key)); + }); window.textsecure.storage.put('number_id', `${me}.2`); window.textsecure.storage.put('uuid_id', `${ourUuid}.2`); }); after(async () => { - window.textsecure.storage.remove('number_id'); - window.textsecure.storage.remove('uuid_id'); - await window.Signal.Data.removeAll(); await window.storage.fetch(); + + oldStorageValues.forEach((oldValue, key) => { + if (oldValue) { + window.textsecure.storage.put(key, oldValue); + } else { + window.textsecure.storage.remove(key); + } + }); }); beforeEach(function beforeEach() { @@ -56,34 +76,98 @@ describe('Message', () => { // NOTE: These tests are incomplete. describe('send', () => { - it("saves the result's dataMessage", async () => { - const message = createMessage({ type: 'outgoing', source }); + let oldMessageSender: undefined | MessageSender; - const fakeDataMessage = new ArrayBuffer(0); - const result = { - dataMessage: fakeDataMessage, - }; - const promise = Promise.resolve(result); - await message.send(promise); + beforeEach(function beforeEach() { + oldMessageSender = window.textsecure.messaging; - assert.strictEqual(message.get('dataMessage'), fakeDataMessage); + window.textsecure.messaging = + oldMessageSender ?? new MessageSender('username', 'password'); + this.sandbox + .stub(window.textsecure.messaging, 'sendSyncMessage') + .resolves({}); }); - it('updates the `sent` attribute', async () => { - const message = createMessage({ type: 'outgoing', source, sent: false }); + afterEach(() => { + if (oldMessageSender) { + window.textsecure.messaging = oldMessageSender; + } else { + // `window.textsecure.messaging` can be undefined in tests. Instead of updating + // the real type, I just ignore it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window.textsecure as any).messaging; + } + }); - const promise: Promise = Promise.resolve({ - successfulIdentifiers: [window.getGuid(), window.getGuid()], + it('updates `sendStateByConversationId`', async function test() { + this.sandbox.useFakeTimers(1234); + + const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const conversation1 = await window.ConversationController.getOrCreateAndWait( + 'a072df1d-7cee-43e2-9e6b-109710a2131c', + 'private' + ); + const conversation2 = await window.ConversationController.getOrCreateAndWait( + '62bd8ef1-68da-4cfd-ac1f-3ea85db7473e', + 'private' + ); + + const message = createMessage({ + type: 'outgoing', + conversationId: ( + await window.ConversationController.getOrCreateAndWait( + '71cc190f-97ba-4c61-9d41-0b9444d721f9', + 'group' + ) + ).id, + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Pending, + updatedAt: 123, + }, + [conversation1.id]: { + status: SendStatus.Pending, + updatedAt: 123, + }, + [conversation2.id]: { + status: SendStatus.Pending, + updatedAt: 456, + }, + }, + }); + + const fakeDataMessage = new ArrayBuffer(0); + const conversation1Uuid = conversation1.get('uuid'); + const ignoredUuid = window.getGuid(); + + if (!conversation1Uuid) { + throw new Error('Test setup failed: conversation1 should have a UUID'); + } + + const promise = Promise.resolve({ + successfulIdentifiers: [conversation1Uuid, ignoredUuid], errors: [ Object.assign(new Error('failed'), { - identifier: window.getGuid(), + identifier: conversation2.get('uuid'), }), ], + dataMessage: fakeDataMessage, }); await message.send(promise); - assert.isTrue(message.get('sent')); + const result = message.get('sendStateByConversationId') || {}; + assert.hasAllKeys(result, [ + ourConversationId, + conversation1.id, + conversation2.id, + ]); + assert.strictEqual(result[ourConversationId]?.status, SendStatus.Sent); + assert.strictEqual(result[ourConversationId]?.updatedAt, 1234); + assert.strictEqual(result[conversation1.id]?.status, SendStatus.Sent); + assert.strictEqual(result[conversation1.id]?.updatedAt, 1234); + assert.strictEqual(result[conversation2.id]?.status, SendStatus.Failed); + assert.strictEqual(result[conversation2.id]?.updatedAt, 1234); }); it('saves errors from promise rejections with errors', async () => { diff --git a/ts/test-electron/state/selectors/messages_test.ts b/ts/test-electron/state/selectors/messages_test.ts index e67b9c4d94ff..4db2ee4ddf9b 100644 --- a/ts/test-electron/state/selectors/messages_test.ts +++ b/ts/test-electron/state/selectors/messages_test.ts @@ -3,10 +3,16 @@ import { assert } from 'chai'; import { v4 as uuid } from 'uuid'; +import { SendStatus } from '../../../messages/MessageSendState'; +import { + MessageAttributesType, + ShallowChallengeError, +} from '../../../model-types.d'; import { ConversationType } from '../../../state/ducks/conversations'; import { canReply, + getMessagePropStatus, isEndSession, isGroupUpdate, isIncoming, @@ -14,6 +20,12 @@ import { } from '../../../state/selectors/message'; describe('state/selectors/messages', () => { + let ourConversationId: string; + + beforeEach(() => { + ourConversationId = uuid(); + }); + describe('canReply', () => { const defaultConversation: ConversationType = { id: uuid(), @@ -35,7 +47,7 @@ describe('state/selectors/messages', () => { isGroupV1AndDisabled: true, }); - assert.isFalse(canReply(message, getConversationById)); + assert.isFalse(canReply(message, ourConversationId, getConversationById)); }); // NOTE: This is missing a test for mandatory profile sharing. @@ -48,33 +60,70 @@ describe('state/selectors/messages', () => { }; const getConversationById = () => defaultConversation; - assert.isFalse(canReply(message, getConversationById)); + assert.isFalse(canReply(message, ourConversationId, getConversationById)); }); it('returns false for outgoing messages that have not been sent', () => { const message = { conversationId: 'fake-conversation-id', type: 'outgoing' as const, - sent_to: [], + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + }, }; const getConversationById = () => defaultConversation; - assert.isFalse(canReply(message, getConversationById)); + assert.isFalse(canReply(message, ourConversationId, getConversationById)); }); - it('returns true for outgoing messages that have been delivered to at least one person', () => { + it('returns true for outgoing messages that are only sent to yourself', () => { const message = { conversationId: 'fake-conversation-id', type: 'outgoing' as const, - receipients: [uuid(), uuid()], - sent_to: [uuid()], + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + }, + }; + const getConversationById = () => defaultConversation; + + assert.isTrue(canReply(message, ourConversationId, getConversationById)); + }); + + it('returns true for outgoing messages that have been sent to at least one person', () => { + const message = { + conversationId: 'fake-conversation-id', + type: 'outgoing' as const, + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + }, }; const getConversationById = () => ({ ...defaultConversation, type: 'group' as const, }); - assert.isTrue(canReply(message, getConversationById)); + assert.isTrue(canReply(message, ourConversationId, getConversationById)); }); it('returns true for incoming messages', () => { @@ -84,7 +133,247 @@ describe('state/selectors/messages', () => { }; const getConversationById = () => defaultConversation; - assert.isTrue(canReply(message, getConversationById)); + assert.isTrue(canReply(message, ourConversationId, getConversationById)); + }); + }); + + describe('getMessagePropStatus', () => { + const createMessage = (overrides: Partial) => ({ + type: 'outgoing' as const, + ...overrides, + }); + + it('returns undefined for incoming messages', () => { + const message = createMessage({ type: 'incoming' }); + + assert.isUndefined( + getMessagePropStatus(message, ourConversationId, true) + ); + }); + + it('returns "paused" for messages with challenges', () => { + const challengeError: ShallowChallengeError = Object.assign( + new Error('a challenge'), + { + name: 'SendMessageChallengeError', + retryAfter: 123, + data: {}, + } + ); + const message = createMessage({ errors: [challengeError] }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'paused' + ); + }); + + it('returns "partial-sent" if the message has errors but was sent to at least one person', () => { + const message = createMessage({ + errors: [new Error('whoopsie')], + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'partial-sent' + ); + }); + + it('returns "error" if the message has errors and has not been sent', () => { + const message = createMessage({ + errors: [new Error('whoopsie')], + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'error' + ); + }); + + it('returns "read" if the message is just for you and has been sent', () => { + const message = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + }, + }); + + [true, false].forEach(readReceiptSetting => { + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, readReceiptSetting), + 'read' + ); + }); + }); + + it('returns "read" if the message was read by at least one person and you have read receipts enabled', () => { + const readMessage = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + }, + }); + assert.strictEqual( + getMessagePropStatus(readMessage, ourConversationId, true), + 'read' + ); + + const viewedMessage = createMessage({ + sendStateByConversationId: { + [uuid()]: { + status: SendStatus.Viewed, + updatedAt: Date.now(), + }, + }, + }); + assert.strictEqual( + getMessagePropStatus(viewedMessage, ourConversationId, true), + 'read' + ); + }); + + it('returns "delivered" if the message was read by at least one person and you have read receipts disabled', () => { + const message = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, false), + 'delivered' + ); + }); + + it('returns "delivered" if the message was delivered to at least one person, but no "higher"', () => { + const message = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'delivered' + ); + }); + + it('returns "sent" if the message was sent to at least one person, but no "higher"', () => { + const message = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'sent' + ); + }); + + it('returns "sending" if the message has not been sent yet, even if it has been synced to yourself', () => { + const message = createMessage({ + sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + [uuid()]: { + status: SendStatus.Pending, + updatedAt: Date.now(), + }, + }, + }); + + assert.strictEqual( + getMessagePropStatus(message, ourConversationId, true), + 'sending' + ); }); }); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index d9941c6e0391..a1573800fd25 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -52,7 +52,7 @@ import { LinkPreviewImage, LinkPreviewMetadata, } from '../linkPreviews/linkPreviewFetch'; -import { concat } from '../util/iterables'; +import { concat, isEmpty, map } from '../util/iterables'; import { handleMessageSend, shouldSaveProto, @@ -1022,8 +1022,8 @@ export default class MessageSender { destination, destinationUuid, expirationStartTimestamp, - sentTo, - unidentifiedDeliveries, + conversationIdsSentTo = [], + conversationIdsWithSealedSender = new Set(), isUpdate, options, }: { @@ -1032,8 +1032,8 @@ export default class MessageSender { destination: string | undefined; destinationUuid: string | null | undefined; expirationStartTimestamp: number | null; - sentTo?: Array; - unidentifiedDeliveries?: Array; + conversationIdsSentTo?: Iterable; + conversationIdsWithSealedSender?: Set; isUpdate?: boolean; options?: SendOptionsType; }): Promise { @@ -1056,38 +1056,33 @@ export default class MessageSender { sentMessage.expirationStartTimestamp = expirationStartTimestamp; } - const unidentifiedLookup = (unidentifiedDeliveries || []).reduce( - (accumulator, item) => { - // eslint-disable-next-line no-param-reassign - accumulator[item] = true; - return accumulator; - }, - Object.create(null) - ); - if (isUpdate) { sentMessage.isRecipientUpdate = true; } // Though this field has 'unidenified' in the name, it should have entries for each // number we sent to. - if (sentTo && sentTo.length) { - sentMessage.unidentifiedStatus = sentTo.map(identifier => { - const status = new Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus(); - const conv = window.ConversationController.get(identifier); - if (conv) { - const e164 = conv.get('e164'); - if (e164) { - status.destination = e164; + if (!isEmpty(conversationIdsSentTo)) { + sentMessage.unidentifiedStatus = [ + ...map(conversationIdsSentTo, conversationId => { + const status = new Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus(); + const conv = window.ConversationController.get(conversationId); + if (conv) { + const e164 = conv.get('e164'); + if (e164) { + status.destination = e164; + } + const uuid = conv.get('uuid'); + if (uuid) { + status.destinationUuid = uuid; + } } - const uuid = conv.get('uuid'); - if (uuid) { - status.destinationUuid = uuid; - } - } - status.unidentified = Boolean(unidentifiedLookup[identifier]); - return status; - }); + status.unidentified = conversationIdsWithSealedSender.has( + conversationId + ); + return status; + }), + ]; } const syncMessage = this.createSyncMessage(); @@ -1673,8 +1668,8 @@ export default class MessageSender { destination: e164, destinationUuid: uuid, expirationStartTimestamp: null, - sentTo: [], - unidentifiedDeliveries: [], + conversationIdsSentTo: [], + conversationIdsWithSealedSender: new Set(), options, }).catch(logError('resetSession/sendSync error:')); diff --git a/ts/types/Message.ts b/ts/types/Message.ts index a7b4bc1ed9fa..4f5d0354b913 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -45,21 +45,15 @@ export type OutgoingMessage = Readonly< // Required attachments: Array; - delivered: number; - delivered_to: Array; - destination: string; // PhoneNumber expirationStartTimestamp: number; id: string; received_at: number; - sent: boolean; - sent_to: Array; // Array // Optional body?: string; expireTimer?: number; messageTimer?: number; // deprecated isViewOnce?: number; - recipients?: Array; // Array synced: boolean; } & SharedMessageProperties & MessageSchemaVersion5 & diff --git a/ts/util/iterables.ts b/ts/util/iterables.ts index 10897814c365..7b8fe404a6d9 100644 --- a/ts/util/iterables.ts +++ b/ts/util/iterables.ts @@ -119,6 +119,9 @@ export function groupBy( return result; } +export const isEmpty = (iterable: Iterable): boolean => + Boolean(iterable[Symbol.iterator]().next().done); + export function map( iterable: Iterable, fn: (value: T) => ResultT @@ -167,6 +170,33 @@ export function reduce( return result; } +export function repeat(value: T): Iterable { + return new RepeatIterable(value); +} + +class RepeatIterable implements Iterable { + constructor(private readonly value: T) {} + + [Symbol.iterator](): Iterator { + return new RepeatIterator(this.value); + } +} + +class RepeatIterator implements Iterator { + private readonly iteratorResult: IteratorResult; + + constructor(value: Readonly) { + this.iteratorResult = { + done: false, + value, + }; + } + + next(): IteratorResult { + return this.iteratorResult; + } +} + export function take(iterable: Iterable, amount: number): Iterable { return new TakeIterable(iterable, amount); } @@ -194,3 +224,29 @@ class TakeIterator implements Iterator { return nextIteration; } } + +// In the future, this could support number and symbol property names. +export function zipObject( + props: Iterable, + values: Iterable +): Record { + const result: Record = {}; + + const propsIterator = props[Symbol.iterator](); + const valuesIterator = values[Symbol.iterator](); + // eslint-disable-next-line no-constant-condition + while (true) { + const propIteration = propsIterator.next(); + if (propIteration.done) { + break; + } + const valueIteration = valuesIterator.next(); + if (valueIteration.done) { + break; + } + + result[propIteration.value] = valueIteration.value; + } + + return result; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 4ebec68f7a1a..9240aa1e35e5 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -3373,7 +3373,9 @@ Whisper.ConversationView = Whisper.View.extend({ } const getProps = () => ({ - ...message.getPropsForMessageDetail(), + ...message.getPropsForMessageDetail( + window.ConversationController.getOurConversationIdOrThrow() + ), ...this.getMessageActions(), }); @@ -3770,7 +3772,14 @@ Whisper.ConversationView = Whisper.View.extend({ }) : undefined; - if (message && !canReply(message.attributes, findAndFormatContact)) { + if ( + message && + !canReply( + message.attributes, + window.ConversationController.getOurConversationIdOrThrow(), + findAndFormatContact + ) + ) { return; }