diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4cee99c4f..92b4958b4 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--Pending { +.module-message-detail__contact__status-icon--sending { 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,8 +3243,7 @@ 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--Viewed { +.module-message-detail__contact__status-icon--read { width: 18px; @include light-theme { @@ -3254,7 +3253,7 @@ button.module-conversation-details__action-button { @include color-svg('../images/read.svg', $color-gray-25); } } -.module-message-detail__contact__status-icon--Failed { +.module-message-detail__contact__status-icon--error { @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 d5d30cfc7..ab5dbfccf 100644 --- a/test/keychange_listener_test.js +++ b/test/keychange_listener_test.js @@ -4,39 +4,12 @@ /* 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 85e384afd..846a49335 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,7 +1,7 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { has, isNumber, noop } from 'lodash'; +import { isNumber, noop } from 'lodash'; import { bindActionCreators } from 'redux'; import { render } from 'react-dom'; import { @@ -82,10 +82,6 @@ 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, @@ -3155,39 +3151,15 @@ export async function startApp(): Promise { const now = Date.now(); const timestamp = data.timestamp || now; - const ourId = window.ConversationController.getOurConversationIdOrThrow(); - const { unidentifiedStatus = [] } = data; - - 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 sentTo: Array = []; 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) ); @@ -3202,12 +3174,13 @@ 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', - sendStateByConversationId, + sent: true, unidentifiedDeliveries, expirationStartTimestamp: Math.min( data.expirationStartTimestamp || timestamp, @@ -3586,6 +3559,33 @@ export async function startApp(): Promise { window.log.warn('background onError: Doing nothing with incoming error'); } + function isInList( + conversation: ConversationModel, + list: Array | undefined + ): boolean { + const uuid = conversation.get('uuid'); + const e164 = conversation.get('e164'); + const id = conversation.get('id'); + + if (!list) { + return false; + } + + if (list.includes(id)) { + return true; + } + + if (uuid && list.includes(uuid)) { + return true; + } + + if (e164 && list.includes(e164)) { + return true; + } + + return false; + } + async function archiveSessionOnMatch({ requesterUuid, requesterDevice, @@ -3707,9 +3707,11 @@ export async function startApp(): Promise { return false; } - const sendStateByConversationId = - message.get('sendStateByConversationId') || {}; - return has(sendStateByConversationId, requesterConversation.id); + if (!isInList(requesterConversation, message.get('sent_to'))) { + return false; + } + + return true; }); if (!targetMessage) { diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index bb9a304ad..38b57a218 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -9,7 +9,6 @@ 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'; @@ -49,7 +48,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Delivered, + status: 'delivered', }, ], errors: overrideProps.errors || [], @@ -117,7 +116,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Sent, + status: 'sent', }, { ...getDefaultConversation({ @@ -126,7 +125,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Pending, + status: 'sending', }, { ...getDefaultConversation({ @@ -135,7 +134,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Failed, + status: 'partial-sent', }, { ...getDefaultConversation({ @@ -144,7 +143,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Delivered, + status: 'delivered', }, { ...getDefaultConversation({ @@ -153,7 +152,7 @@ story.add('Message Statuses', () => { }), isOutgoingKeyError: false, isUnidentifiedDelivery: false, - status: SendStatus.Read, + status: 'read', }, ], message: { @@ -210,7 +209,7 @@ story.add('All Errors', () => { }), isOutgoingKeyError: true, isUnidentifiedDelivery: false, - status: SendStatus.Failed, + status: 'error', }, { ...getDefaultConversation({ @@ -225,7 +224,7 @@ story.add('All Errors', () => { ], isOutgoingKeyError: false, isUnidentifiedDelivery: true, - status: SendStatus.Failed, + status: 'error', }, { ...getDefaultConversation({ @@ -234,7 +233,7 @@ story.add('All Errors', () => { }), isOutgoingKeyError: true, isUnidentifiedDelivery: true, - status: SendStatus.Failed, + status: 'error', }, ], }); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 0174b54af..092a4ed1e 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -10,6 +10,7 @@ import { Avatar } from '../Avatar'; import { ContactName } from './ContactName'; import { Message, + MessageStatusType, Props as MessagePropsType, PropsData as MessagePropsDataType, } from './Message'; @@ -17,7 +18,6 @@ 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: SendStatus | null; + status: MessageStatusType | null; isOutgoingKeyError: boolean; isUnidentifiedDelivery: boolean; diff --git a/ts/groups.ts b/ts/groups.ts index bbb57b2d1..4f0236caa 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -2291,8 +2291,8 @@ export async function wrapWithSyncMessageSend({ destination: ourConversation.get('e164'), destinationUuid: ourConversation.get('uuid'), expirationStartTimestamp: null, - conversationIdsSentTo: [], - conversationIdsWithSealedSender: new Set(), + sentTo: [], + unidentifiedDeliveries: [], }); } diff --git a/ts/messageModifiers/DeliveryReceipts.ts b/ts/messageModifiers/DeliveryReceipts.ts index 393b8ceee..a0212f8aa 100644 --- a/ts/messageModifiers/DeliveryReceipts.ts +++ b/ts/messageModifiers/DeliveryReceipts.ts @@ -3,15 +3,13 @@ /* eslint-disable max-classes-per-file */ -import { isEqual } from 'lodash'; +import { union } from 'lodash'; import { Collection, Model } from 'backbone'; import { ConversationModel } from '../models/conversations'; import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isIncoming } from '../state/selectors/message'; -import { getOwn } from '../util/getOwn'; -import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; type DeliveryReceiptAttributesType = { timestamp: number; @@ -84,67 +82,48 @@ export class DeliveryReceipts extends Collection { } async onReceipt(receipt: DeliveryReceiptModel): Promise { - const deliveredTo = receipt.get('deliveredTo'); - const timestamp = receipt.get('timestamp'); - try { - const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { - MessageCollection: window.Whisper.MessageCollection, - }); + const messages = await window.Signal.Data.getMessagesBySentAt( + receipt.get('timestamp'), + { + MessageCollection: window.Whisper.MessageCollection, + } + ); - const message = await getTargetMessage(deliveredTo, messages); + const message = await getTargetMessage( + receipt.get('deliveredTo'), + messages + ); if (!message) { window.log.info( 'No message for delivery receipt', - deliveredTo, - timestamp + receipt.get('deliveredTo'), + receipt.get('timestamp') ); return; } - const oldSendStateByConversationId = - message.get('sendStateByConversationId') || {}; - const oldSendState = getOwn(oldSendStateByConversationId, deliveredTo); - if (oldSendState) { - const newSendState = sendStateReducer(oldSendState, { - type: SendActionType.GotDeliveryReceipt, - updatedAt: timestamp, - }); + const deliveries = message.get('delivered') || 0; + const deliveredTo = message.get('delivered_to') || []; + const expirationStartTimestamp = message.get('expirationStartTimestamp'); + message.set({ + delivered_to: union(deliveredTo, [receipt.get('deliveredTo')]), + delivered: deliveries + 1, + expirationStartTimestamp: expirationStartTimestamp || Date.now(), + sent: true, + }); - // 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, - }); + window.Signal.Util.queueUpdateMessage(message.attributes); - await window.Signal.Data.updateMessageSendState({ - messageId: message.id, - destinationConversationId: deliveredTo, - ...newSendState, - }); - - // 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` - ); + // notify frontend listeners + const conversation = window.ConversationController.get( + message.get('conversationId') + ); + const updateLeftPane = conversation + ? conversation.debouncedUpdateLastMessage + : undefined; + if (updateLeftPane) { + updateLeftPane(); } this.remove(receipt); diff --git a/ts/messageModifiers/ReadReceipts.ts b/ts/messageModifiers/ReadReceipts.ts index 95d363011..75c7a5eaf 100644 --- a/ts/messageModifiers/ReadReceipts.ts +++ b/ts/messageModifiers/ReadReceipts.ts @@ -3,15 +3,12 @@ /* eslint-disable max-classes-per-file */ -import { isEqual } from 'lodash'; import { Collection, Model } from 'backbone'; import { ConversationModel } from '../models/conversations'; import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isOutgoing } from '../state/selectors/message'; -import { getOwn } from '../util/getOwn'; -import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; type ReadReceiptAttributesType = { reader: string; @@ -89,64 +86,46 @@ export class ReadReceipts extends Collection { } async onReceipt(receipt: ReadReceiptModel): Promise { - const reader = receipt.get('reader'); - const timestamp = receipt.get('timestamp'); - try { - const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { - MessageCollection: window.Whisper.MessageCollection, - }); + const messages = await window.Signal.Data.getMessagesBySentAt( + receipt.get('timestamp'), + { + MessageCollection: window.Whisper.MessageCollection, + } + ); const message = await getTargetMessage(receipt.get('reader'), messages); if (!message) { - window.log.info('No message for read receipt', reader, timestamp); + window.log.info( + 'No message for read receipt', + receipt.get('reader'), + receipt.get('timestamp') + ); return; } - const oldSendStateByConversationId = - message.get('sendStateByConversationId') || {}; - const oldSendState = getOwn(oldSendStateByConversationId, reader); - if (oldSendState) { - const newSendState = sendStateReducer(oldSendState, { - type: SendActionType.GotReadReceipt, - updatedAt: timestamp, - }); + const readBy = message.get('read_by') || []; + const expirationStartTimestamp = message.get('expirationStartTimestamp'); - // 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, - }); + readBy.push(receipt.get('reader')); + message.set({ + read_by: readBy, + expirationStartTimestamp: expirationStartTimestamp || Date.now(), + sent: true, + }); - await window.Signal.Data.updateMessageSendState({ - messageId: message.id, - destinationConversationId: reader, - ...newSendState, - }); + 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 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` - ); + // notify frontend listeners + const conversation = window.ConversationController.get( + message.get('conversationId') + ); + const updateLeftPane = conversation + ? conversation.debouncedUpdateLastMessage + : undefined; + if (updateLeftPane) { + updateLeftPane(); } this.remove(receipt); diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts deleted file mode 100644 index 0b1b4e95b..000000000 --- a/ts/messages/MessageSendState.ts +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { isUndefined, zip } from 'lodash'; -import { makeEnumParser } from '../util/enum'; -import { assert } from '../util/assert'; -import { isNormalNumber } from '../util/isNormalNumber'; - -/** - * `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 - ); -}; - -export const serializeSendStateForDatabase = ( - params: Readonly< - { - messageId: string; - destinationConversationId: string; - } & SendState - > -): { - messageId: string; - destinationConversationId: string; - updatedAt: number; - status: string; -} => ({ - messageId: params.messageId, - destinationConversationId: params.destinationConversationId, - updatedAt: params.updatedAt || 0, - status: params.status, -}); - -export function deserializeDatabaseSendStates({ - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined, -}: Readonly<{ - sendConversationIdsJoined?: unknown; - sendStatusesJoined?: unknown; - sendUpdatedAtsJoined?: unknown; -}>): SendStateByConversationId { - const sendConversationIds = splitJoined(sendConversationIdsJoined); - const sendStatuses = splitJoined(sendStatusesJoined); - const sendUpdatedAts = splitJoined(sendUpdatedAtsJoined); - - const result: SendStateByConversationId = Object.create(null); - - // We use `for ... of` here because we want to be able to do an early return. - // eslint-disable-next-line no-restricted-syntax - for (const [destinationConversationId, statusString, updatedAtString] of zip( - sendConversationIds, - sendStatuses, - sendUpdatedAts - )) { - if ( - isUndefined(destinationConversationId) || - isUndefined(statusString) || - isUndefined(updatedAtString) - ) { - assert( - false, - 'Could not parse database message send state: the joined results had different lengths' - ); - return {}; - } - - const status = parseMessageSendStatus(statusString); - - let updatedAt: undefined | number = Math.floor(Number(updatedAtString)); - if (updatedAt === 0) { - updatedAt = undefined; - } else if (!isNormalNumber(updatedAt)) { - assert( - false, - 'Could not parse database message send state: updated timestamp was not a normal number' - ); - updatedAt = undefined; - } - - result[destinationConversationId] = { - status, - updatedAt, - }; - } - - return result; -} - -function splitJoined(value: unknown): ReadonlyArray { - return typeof value === 'string' && value ? value.split(',') : []; -} diff --git a/ts/messages/migrateLegacySendAttributes.ts b/ts/messages/migrateLegacySendAttributes.ts deleted file mode 100644 index 8bcaa13af..000000000 --- a/ts/messages/migrateLegacySendAttributes.ts +++ /dev/null @@ -1,172 +0,0 @@ -// 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 08d4e4b08..560d99e74 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -20,10 +20,6 @@ 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'; @@ -92,6 +88,8 @@ export type MessageAttributesType = { decrypted_at?: number; deletedForEveryone?: boolean; deletedForEveryoneTimestamp?: number; + delivered?: number; + delivered_to?: Array; errors?: Array; expirationStartTimestamp?: number | null; expireTimer?: number; @@ -117,8 +115,10 @@ export type MessageAttributesType = { targetTimestamp: number; timestamp: number; }>; + read_by?: Array; requiredProtocolVersion?: number; retryOptions?: RetryOptions; + sent?: boolean; sourceDevice?: string | number; supportedVersionAtReceive?: unknown; synced?: boolean; @@ -152,10 +152,14 @@ 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; @@ -188,9 +192,6 @@ 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 575e94112..0b5141fa5 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -55,15 +55,7 @@ import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; -import { SendStatus } from '../messages/MessageSendState'; -import { - concat, - filter, - map, - take, - repeat, - zipObject, -} from '../util/iterables'; +import { filter, map, take } from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { @@ -3180,6 +3172,7 @@ 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( @@ -3198,8 +3191,10 @@ 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! @@ -3300,6 +3295,7 @@ 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( @@ -3322,8 +3318,10 @@ 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 @@ -3505,18 +3503,6 @@ 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, @@ -3534,13 +3520,6 @@ export class ConversationModel extends window.Backbone sticker, bodyRanges: mentions, sendHQImages, - sendStateByConversationId: zipObject( - recipientConversationIds, - repeat({ - status: SendStatus.Pending, - updatedAt: now, - }) - ), }); if (isDirectConversation(this.attributes)) { @@ -3584,13 +3563,17 @@ export class ConversationModel extends window.Backbone // We're offline! if (!window.textsecure.messaging) { - const errors = map(recipientConversationIds, conversationId => { + const errors = [ + ...(this.contactCollection && this.contactCollection.length + ? this.contactCollection + : [this]), + ].map(contact => { const error = new Error('Network is not available') as CustomError; error.name = 'SendMessageNetworkError'; - error.identifier = conversationId; + error.identifier = contact.get('id'); return error; }); - await message.saveErrors([...errors]); + await message.saveErrors(errors); return null; } @@ -3769,7 +3752,6 @@ export class ConversationModel extends window.Backbone (previewMessage ? getMessagePropStatus( previewMessage.attributes, - ourConversationId, window.storage.get('read-receipt-setting', false) ) : null) || null, @@ -4050,6 +4032,9 @@ 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, { Message: window.Whisper.Message, }); @@ -4142,6 +4127,9 @@ 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, { Message: window.Whisper.Message, }); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 404579b63..58263036d 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, isEqual, noop, omit, union } from 'lodash'; +import { isEmpty } from 'lodash'; import { CustomError, GroupV1Update, @@ -12,13 +12,12 @@ 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 { SyncMessageClass } from '../textsecure.d'; 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, @@ -39,18 +38,6 @@ 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, @@ -134,6 +121,9 @@ 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, @@ -156,8 +146,6 @@ export function isQuoteAMatch( ); } -const isCustomError = (e: unknown): e is CustomError => e instanceof Error; - export class MessageModel extends window.Backbone.Model { static updateTimers: () => void; @@ -191,17 +179,6 @@ 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(); @@ -263,41 +240,35 @@ export class MessageModel extends window.Backbone.Model { ); } - getPropsForMessageDetail(ourConversationId: string): PropsForMessageDetail { + getPropsForMessageDetail(): PropsForMessageDetail { const newIdentity = window.i18n('newIdentity'); const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; - const sendStateByConversationId = - this.get('sendStateByConversationId') || {}; + 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 unidentifiedDeliveries = this.get('unidentifiedDeliveries') || []; - const unidentifiedDeliveriesSet = new Set( - map( - unidentifiedDeliveries, - identifier => - window.ConversationController.getConversationId(identifier) as string - ) - ); - - let conversationIds: Array; + // 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 /* eslint-disable @typescript-eslint/no-non-null-assertion */ - 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 + 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)! + ) ); - } - } 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 @@ -323,7 +294,9 @@ 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( @@ -331,19 +304,12 @@ export class MessageModel extends window.Backbone.Model { ); const isUnidentifiedDelivery = window.storage.get('unidentifiedDeliveryIndicators', false) && - 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; - } + this.isUnidentifiedDelivery(id, unidentifiedLookup); return { ...findAndFormatContact(id), - status, + + status: this.getStatus(id), errors: errorsForContact, isOutgoingKeyError, isUnidentifiedDelivery, @@ -378,7 +344,7 @@ export class MessageModel extends window.Backbone.Model { message: getPropsForMessage( this.attributes, findAndFormatContact, - ourConversationId, + window.ConversationController.getOurConversationIdOrThrow(), this.OUR_NUMBER, this.OUR_UUID, undefined, @@ -401,6 +367,33 @@ 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; @@ -1063,13 +1056,13 @@ export class MessageModel extends window.Backbone.Model { isUnidentifiedDelivery( contactId: string, - unidentifiedDeliveriesSet: Readonly> + lookup: Record ): boolean { if (isIncoming(this.attributes)) { return Boolean(this.get('unidentifiedDeliveryReceived')); } - return unidentifiedDeliveriesSet.has(contactId); + return Boolean(lookup[contactId]); } getSource(): string | undefined { @@ -1210,64 +1203,44 @@ export class MessageModel extends window.Backbone.Model { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = this.getConversation()!; - const currentRecipients = new Set( - conversation - .getRecipients() - .map(identifier => - window.ConversationController.getConversationId(identifier) - ) - .filter(isNotNil) - ); + 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 profileKey = conversation.get('profileSharing') ? await ourProfileKeyService.get() : undefined; // Determine retry recipients and get their most up-to-date addressing information - 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, { - Message: window.Whisper.Message, - }); + 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); if (!recipients.length) { window.log.warn('retrySend: Nobody to send to!'); - return undefined; + + return window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); } const attachmentsWithData = await Promise.all( @@ -1393,12 +1366,12 @@ export class MessageModel extends window.Backbone.Model { } public hasSuccessfulDelivery(): boolean { - const sendStateByConversationId = this.get('sendStateByConversationId'); - const withoutMe = omit( - sendStateByConversationId, - window.ConversationController.getOurConversationIdOrThrow() - ); - return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent); + const recipients = this.get('recipients') || []; + if (recipients.length === 0) { + return true; + } + + return (this.get('sent_to') || []).length !== 0; } // Called when the user ran into an error with a specific user, wants to send to them @@ -1534,184 +1507,154 @@ export class MessageModel extends window.Backbone.Model { async send( promise: Promise ): Promise> { - 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 }; + const conversation = this.getConversation(); + const updateLeftPane = conversation?.debouncedUpdateLastMessage; + if (updateLeftPane) { + updateLeftPane(); } - updateLeftPane(); + return (promise as Promise) + .then(async result => { + if (updateLeftPane) { + updateLeftPane(); + } - const attributesToUpdate: Partial = {}; + // This is used by sendSyncMessage, then set to null + if (result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } - // This is used by sendSyncMessage, then set to null - if ('dataMessage' in result.value && result.value.dataMessage) { - attributesToUpdate.dataMessage = result.value.dataMessage; - } + 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 + ), + }); - const sendStateByConversationId = { - ...(this.get('sendStateByConversationId') || {}), - }; + if (!this.doNotSave) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } - const successfulIdentifiers: Array = - 'successfulIdentifiers' in result.value && - Array.isArray(result.value.successfulIdentifiers) - ? result.value.successfulIdentifiers - : []; - const sentToAtLeastOneRecipient = - result.success || Boolean(successfulIdentifiers.length); + if (updateLeftPane) { + updateLeftPane(); + } + this.sendSyncMessage(); + }) + .catch((result: CustomError | CallbackResultType) => { + if (updateLeftPane) { + updateLeftPane(); + } - successfulIdentifiers.forEach(identifier => { - const conversation = window.ConversationController.get(identifier); - if (!conversation) { - return; - } + if ('dataMessage' in result && result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } - // If we successfully sent to a user, we can remove our unregistered flag. - if (conversation.isEverUnregistered()) { - conversation.setRegistered(); - } + let promises = []; - const previousSendState = getOwn( - sendStateByConversationId, - conversation.id - ); - if (previousSendState) { - sendStateByConversationId[conversation.id] = sendStateReducer( - previousSendState, - { - type: SendActionType.Sent, - updatedAt: Date.now(), + // 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(); } - ); - } - }); + }); - const previousUnidentifiedDeliveries = - this.get('unidentifiedDeliveries') || []; - const newUnidentifiedDeliveries = - 'unidentifiedDeliveries' in result.value && - Array.isArray(result.value.unidentifiedDeliveries) - ? result.value.unidentifiedDeliveries - : []; + const isError = (e: unknown): e is CustomError => e instanceof Error; - const promises: Array> = []; + 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') || []; - let errors: Array; - if (isCustomError(result.value)) { - errors = [result.value]; - } else if (Array.isArray(result.value.errors)) { - ({ errors } = result.value); - } else { - errors = []; - } + // 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(); + } + }); - // 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 = []; + // 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' + ); - let hadSignedPreKeyRotationError = false; - errors.forEach(error => { - const conversation = - window.ConversationController.get(error.identifier) || - window.ConversationController.get(error.number); + // 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(); - if (conversation) { - const previousSendState = getOwn( - sendStateByConversationId, - conversation.id - ); - if (previousSendState) { - sendStateByConversationId[conversation.id] = sendStateReducer( - previousSendState, - { - type: SendActionType.Failed, - updatedAt: Date.now(), - } + 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()); + } + }) ); } - } - let shouldSaveError = true; - switch (error.name) { - case 'SignedPreKeyRotationError': - hadSignedPreKeyRotationError = true; - break; - case 'OutgoingIdentityKeyError': { - if (conversation) { - promises.push(conversation.getProfiles()); - } - break; + if (updateLeftPane) { + updateLeftPane(); } - 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; - } - 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, { - Message: window.Whisper.Message, + return Promise.all(promises); }); - } - - 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 @@ -1738,6 +1681,12 @@ 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( // TODO: DESKTOP-724 @@ -1769,11 +1718,23 @@ export class MessageModel extends window.Backbone.Model { const updateLeftPane = conv?.debouncedUpdateLastMessage; try { - this.set({ expirationStartTimestamp: Date.now() }); + 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, + expirationStartTimestamp: Date.now(), + }); const result: typeof window.WhatIsThis = await this.sendSyncMessage(); this.set({ // We have to do this afterward, since we didn't have a previous send! unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, + + // 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')]; @@ -1793,8 +1754,6 @@ export class MessageModel extends window.Backbone.Model { async sendSyncMessage(): Promise { const ourNumber = window.textsecure.storage.user.getNumber(); const ourUuid = window.textsecure.storage.user.getUuid(); - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const { wrap, sendOptions, @@ -1815,34 +1774,6 @@ 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 !== ourConversationId - ); - - 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 wrap( window.textsecure.messaging.sendSyncMessage({ encodedDataMessage: dataMessage, @@ -1851,38 +1782,15 @@ export class MessageModel extends window.Backbone.Model { destinationUuid: conv.get('uuid'), expirationStartTimestamp: this.get('expirationStartTimestamp') || null, - conversationIdsSentTo, - conversationIdsWithSealedSender, + sentTo: this.get('sent_to') || [], + unidentifiedDeliveries: this.get('unidentifiedDeliveries') || [], isUpdate, options: sendOptions, }) ).then(async (result: unknown) => { - let newSendStateByConversationId: undefined | SendStateByConversationId; - const sendStateByConversationId = - this.get('sendStateByConversationId') || {}; - const ourOldSendState = getOwn( - sendStateByConversationId, - ourConversationId - ); - if (ourOldSendState) { - const ourNewSendState = sendStateReducer(ourOldSendState, { - type: SendActionType.Sent, - updatedAt: Date.now(), - }); - if (ourNewSendState !== ourOldSendState) { - newSendStateByConversationId = { - ...sendStateByConversationId, - [ourConversationId]: ourNewSendState, - }; - } - } - this.set({ synced: true, dataMessage: null, - ...(newSendStateByConversationId - ? { sendStateByConversationId: newSendStateByConversationId } - : {}), }); // Return early, skip the save @@ -2547,66 +2455,29 @@ 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({ - sendStateByConversationId, - unidentifiedDeliveries: [...unidentifiedDeliveriesSet], + sent_to: _.union(toUpdate.get('sent_to'), sentTo), + unidentifiedDeliveries: _.union( + toUpdate.get('unidentifiedDeliveries'), + unidentifiedDeliveries + ), }); await window.Signal.Data.saveMessage(toUpdate.attributes, { Message: window.Whisper.Message, @@ -3212,62 +3083,19 @@ export class MessageModel extends window.Backbone.Model { let changed = false; if (type === 'outgoing') { - 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'), - }, - })) + const receipts = DeliveryReceipts.getSingleton().forMessage( + conversation, + message ); - - 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); + receipts.forEach(receipt => { + message.set({ + delivered: (message.get('delivered') || 0) + 1, + delivered_to: _.union(message.get('delivered_to') || [], [ + receipt.get('deliveredTo'), + ]), + }); changed = true; - } + }); } if (type === 'incoming') { @@ -3300,6 +3128,34 @@ 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); @@ -3355,7 +3211,6 @@ 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/sql/Client.ts b/ts/sql/Client.ts index bcfdd95fd..86665b518 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -48,6 +48,7 @@ import { ItemKeyType, ItemType, MessageType, + MessageTypeUnhydrated, PreKeyType, SearchResultMessageType, SenderKeyType, @@ -61,10 +62,8 @@ import { UnprocessedUpdateType, } from './Interface'; import Server from './Server'; -import { MessageRowWithJoinedSends, rowToMessage } from './rowToMessage'; import { MessageModel } from '../models/messages'; import { ConversationModel } from '../models/conversations'; -import { SendState } from '../messages/MessageSendState'; // We listen to a lot of events on ipcRenderer, often on the same channel. This prevents // any warnings that might be sent to the console in that case. @@ -202,8 +201,6 @@ const dataInterface: ClientInterface = { hasGroupCallHistoryMessage, migrateConversationMessages, - updateMessageSendState, - getUnprocessedCount, getAllUnprocessed, getUnprocessedById, @@ -251,7 +248,6 @@ const dataInterface: ClientInterface = { // Test-only _getAllMessages, - _getSendStates, // Client-side only @@ -945,17 +941,17 @@ async function searchConversations(query: string) { return conversations; } -function handleSearchMessageRows( - rows: ReadonlyArray +function handleSearchMessageJSON( + messages: Array ): Array { - return rows.map(row => ({ - json: row.json, + return messages.map(message => ({ + json: message.json, // Empty array is a default value. `message.json` has the real field bodyRanges: [], - ...rowToMessage(row), - snippet: row.snippet, + ...JSON.parse(message.json), + snippet: message.snippet, })); } @@ -965,7 +961,7 @@ async function searchMessages( ) { const messages = await channels.searchMessages(query, { limit }); - return handleSearchMessageRows(messages); + return handleSearchMessageJSON(messages); } async function searchMessagesInConversation( @@ -979,7 +975,7 @@ async function searchMessagesInConversation( { limit } ); - return handleSearchMessageRows(messages); + return handleSearchMessageJSON(messages); } // Message @@ -1057,16 +1053,6 @@ async function _getAllMessages({ return new MessageCollection(messages); } -// For testing only -function _getSendStates( - options: Readonly<{ - messageId: string; - destinationConversationId: string; - }> -) { - return channels._getSendStates(options); -} - async function getAllMessageIds() { const ids = await channels.getAllMessageIds(); @@ -1143,10 +1129,8 @@ async function addReaction(reactionObj: ReactionType) { return channels.addReaction(reactionObj); } -function handleMessageRows( - rows: ReadonlyArray -): Array { - return rows.map(row => rowToMessage(row)); +function handleMessageJSON(messages: Array) { + return messages.map(message => JSON.parse(message.json)); } async function getOlderMessagesByConversation( @@ -1175,7 +1159,7 @@ async function getOlderMessagesByConversation( } ); - return new MessageCollection(handleMessageRows(messages)); + return new MessageCollection(handleMessageJSON(messages)); } async function getNewerMessagesByConversation( conversationId: string, @@ -1200,7 +1184,7 @@ async function getNewerMessagesByConversation( } ); - return new MessageCollection(handleMessageRows(messages)); + return new MessageCollection(handleMessageJSON(messages)); } async function getLastConversationActivity({ conversationId, @@ -1258,17 +1242,6 @@ async function migrateConversationMessages( await channels.migrateConversationMessages(obsoleteId, currentId); } -async function updateMessageSendState( - params: Readonly< - { - messageId: string; - destinationConversationId: string; - } & SendState - > -): Promise { - await channels.updateMessageSendState(params); -} - async function removeAllMessagesInConversation( conversationId: string, { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 73c145803..c8398a790 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -15,11 +15,8 @@ import type { ConversationModel } from '../models/conversations'; import type { StoredJob } from '../jobs/types'; import type { ReactionType } from '../types/Reactions'; import type { ConversationColorType, CustomColorType } from '../types/Colors'; -import type { BodyRangesType } from '../types/Util'; import { StorageAccessType } from '../types/Storage.d'; import type { AttachmentType } from '../types/Attachment'; -import type { SendState } from '../messages/MessageSendState'; -import type { MessageRowWithJoinedSends } from './rowToMessage'; export type AttachmentDownloadJobTypeType = | 'long-message' @@ -72,17 +69,21 @@ export type ItemType = { value: StorageAccessType[K]; }; export type MessageType = MessageAttributesType; +export type MessageTypeUnhydrated = { + json: string; +}; export type PreKeyType = { id: number; privateKey: ArrayBuffer; publicKey: ArrayBuffer; }; -export type SearchResultMessageType = MessageRowWithJoinedSends & { +export type SearchResultMessageType = { + json: string; snippet: string; }; export type ClientSearchResultMessageType = MessageType & { json: string; - bodyRanges: BodyRangesType; + bodyRanges: []; snippet: string; }; export type SenderKeyType = { @@ -254,15 +255,6 @@ export type DataInterface = { ) => Promise; getNextTapToViewMessageTimestampToAgeOut: () => Promise; - updateMessageSendState( - params: Readonly< - { - messageId: string; - destinationConversationId: string; - } & SendState - > - ): Promise; - getUnprocessedCount: () => Promise; getAllUnprocessed: () => Promise>; updateUnprocessedAttempts: (id: string, attempts: number) => Promise; @@ -353,20 +345,6 @@ export type DataInterface = { value: CustomColorType; } ) => Promise; - - // For testing only - - _getSendStates: ( - options: Readonly<{ - messageId: string; - destinationConversationId: string; - }> - ) => Promise< - Array<{ - updatedAt: number; - status: string; - }> - >; }; // The reason for client/server divergence is the need to inject Backbone models and @@ -399,11 +377,11 @@ export type ServerInterface = DataInterface & { sentAt?: number; messageId?: string; } - ) => Promise>; + ) => Promise>; getNewerMessagesByConversation: ( conversationId: string, options?: { limit?: number; receivedAt?: number; sentAt?: number } - ) => Promise>; + ) => Promise>; getLastConversationActivity: (options: { conversationId: string; ourConversationId: string; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 89bb3634e..94e2802bf 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -48,6 +48,7 @@ import { ItemKeyType, ItemType, MessageType, + MessageTypeUnhydrated, MessageMetricsType, PreKeyType, SearchResultMessageType, @@ -61,11 +62,6 @@ import { UnprocessedType, UnprocessedUpdateType, } from './Interface'; -import { - SendState, - serializeSendStateForDatabase, -} from '../messages/MessageSendState'; -import { MessageRowWithJoinedSends, rowToMessage } from './rowToMessage'; declare global { // We want to extend `Function`'s properties, so we need to use an interface. @@ -183,7 +179,6 @@ const dataInterface: ServerInterface = { getMessageBySender, getMessageById, _getAllMessages, - _getSendStates, getAllMessageIds, getMessagesBySentAt, getExpiredMessages, @@ -199,8 +194,6 @@ const dataInterface: ServerInterface = { hasGroupCallHistoryMessage, migrateConversationMessages, - updateMessageSendState, - getUnprocessedCount, getAllUnprocessed, updateUnprocessedAttempts, @@ -286,7 +279,6 @@ function objectToJSON(data: any) { function jsonToObject(json: string): any { return JSON.parse(json); } - function rowToConversation(row: ConversationRow): ConversationType { const parsedJson = JSON.parse(row.json); @@ -306,7 +298,6 @@ function rowToConversation(row: ConversationRow): ConversationType { profileLastFetchedAt, }; } - function rowToSticker(row: StickerRow): StickerType { return { ...row, @@ -1946,56 +1937,6 @@ function updateToSchemaVersion35(currentVersion: number, db: Database) { console.log('updateToSchemaVersion35: success!'); } -function updateToSchemaVersion36(currentVersion: number, db: Database) { - if (currentVersion >= 36) { - return; - } - - db.transaction(() => { - db.exec(` - CREATE TABLE sendStates( - messageId STRING NOT NULL, - destinationConversationId STRING NOT NULL, - updatedAt INTEGER NOT NULL, - -- This should match the in-code enum. - status TEXT CHECK( - status IN ( - 'Failed', - 'Pending', - 'Sent', - 'Delivered', - 'Read', - 'Viewed' - ) - ) NOT NULL, - UNIQUE(messageId, destinationConversationId), - FOREIGN KEY (messageId) - REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (destinationConversationId) - REFERENCES conversations(id) ON DELETE CASCADE - ); - - CREATE INDEX message_sends ON sendStates ( - messageId, - destinationConversationId - ); - - CREATE VIEW messagesWithSendStates AS - SELECT - messages.*, - GROUP_CONCAT(sendStates.destinationConversationId) AS sendConversationIdsJoined, - GROUP_CONCAT(sendStates.status) AS sendStatusesJoined, - GROUP_CONCAT(sendStates.updatedAt) AS sendUpdatedAtsJoined - FROM messages - LEFT JOIN sendStates ON messages.id = sendStates.messageId - GROUP BY messages.id; - `); - - db.pragma('user_version = 36'); - })(); - console.log('updateToSchemaVersion36: success!'); -} - const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -2032,7 +1973,6 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion33, updateToSchemaVersion34, updateToSchemaVersion35, - updateToSchemaVersion36, ]; function updateSchema(db: Database): void { @@ -3044,24 +2984,21 @@ async function searchMessages( // give us the right results. We can't call `snippet()` in the query above // because it would bloat the temporary table with text data and we want // to keep its size minimal for `ORDER BY` + `LIMIT` to be fast. - const result: Array = db + const result = db .prepare( ` SELECT - messagesWithSendStates.json, - messagesWithSendStates.sendConversationIdsJoined, - messagesWithSendStates.sendStatusesJoined, - messagesWithSendStates.sendUpdatedAtsJoined, + messages.json, snippet(messages_fts, -1, '<>', '<>', '...', 10) AS snippet FROM tmp_filtered_results INNER JOIN messages_fts ON messages_fts.rowid = tmp_filtered_results.rowid - INNER JOIN messagesWithSendStates - ON messagesWithSendStates.rowid = tmp_filtered_results.rowid + INNER JOIN messages + ON messages.rowid = tmp_filtered_results.rowid WHERE messages_fts.body MATCH $query - ORDER BY messagesWithSendStates.received_at DESC, messagesWithSendStates.sent_at DESC; + ORDER BY messages.received_at DESC, messages.sent_at DESC; ` ) .all({ query }); @@ -3188,11 +3125,9 @@ function saveMessageSync( expirationStartTimestamp, } = data; - const { sendStateByConversationId, ...dataToSaveInJsonField } = data; - const payload = { id, - json: objectToJSON(dataToSaveInJsonField), + json: objectToJSON(data), body: body || null, conversationId, @@ -3214,8 +3149,6 @@ function saveMessageSync( unread: unread ? 1 : 0, }; - let messageId: string; - if (id && !forceSave) { prepare( db, @@ -3246,94 +3179,70 @@ function saveMessageSync( ` ).run(payload); - messageId = id; - } else { - messageId = id || generateUUID(); - - const toCreate = { - ...dataToSaveInJsonField, - id: messageId, - }; - - prepare( - db, - ` - INSERT INTO messages ( - id, - json, - - body, - conversationId, - expirationStartTimestamp, - expireTimer, - hasAttachments, - hasFileAttachments, - hasVisualMediaAttachments, - isErased, - isViewOnce, - received_at, - schemaVersion, - serverGuid, - sent_at, - source, - sourceUuid, - sourceDevice, - type, - unread - ) values ( - $id, - $json, - - $body, - $conversationId, - $expirationStartTimestamp, - $expireTimer, - $hasAttachments, - $hasFileAttachments, - $hasVisualMediaAttachments, - $isErased, - $isViewOnce, - $received_at, - $schemaVersion, - $serverGuid, - $sent_at, - $source, - $sourceUuid, - $sourceDevice, - $type, - $unread - ); - ` - ).run({ - ...payload, - id: messageId, - json: objectToJSON(toCreate), - }); + return id; } - if (sendStateByConversationId) { - const upsertSendStateStmt = prepare( - db, - ` - INSERT OR REPLACE INTO sendStates - (messageId, destinationConversationId, updatedAt, status) VALUES - ($messageId, $destinationConversationId, $updatedAt, $status); - ` - ); - Object.entries(sendStateByConversationId).forEach( - ([destinationConversationId, sendState]) => { - upsertSendStateStmt.run( - serializeSendStateForDatabase({ - messageId, - destinationConversationId, - ...sendState, - }) - ); - } - ); - } + const toCreate = { + ...data, + id: id || generateUUID(), + }; - return messageId; + prepare( + db, + ` + INSERT INTO messages ( + id, + json, + + body, + conversationId, + expirationStartTimestamp, + expireTimer, + hasAttachments, + hasFileAttachments, + hasVisualMediaAttachments, + isErased, + isViewOnce, + received_at, + schemaVersion, + serverGuid, + sent_at, + source, + sourceUuid, + sourceDevice, + type, + unread + ) values ( + $id, + $json, + + $body, + $conversationId, + $expirationStartTimestamp, + $expireTimer, + $hasAttachments, + $hasFileAttachments, + $hasVisualMediaAttachments, + $isErased, + $isViewOnce, + $received_at, + $schemaVersion, + $serverGuid, + $sent_at, + $source, + $sourceUuid, + $sourceDevice, + $type, + $unread + ); + ` + ).run({ + ...payload, + id: toCreate.id, + json: objectToJSON(toCreate), + }); + + return toCreate.id; } async function saveMessage( @@ -3381,18 +3290,8 @@ async function removeMessages(ids: Array): Promise { async function getMessageById(id: string): Promise { const db = getInstance(); - const row: null | MessageRowWithJoinedSends = db - .prepare( - ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates - WHERE id = $id; - ` - ) + const row = db + .prepare('SELECT json FROM messages WHERE id = $id;') .get({ id, }); @@ -3401,45 +3300,16 @@ async function getMessageById(id: string): Promise { return undefined; } - return rowToMessage(row); + return jsonToObject(row.json); } async function _getAllMessages(): Promise> { const db = getInstance(); - const rows: Array = db - .prepare( - ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates - ORDER BY id asc; - ` - ) + const rows: JSONRows = db + .prepare('SELECT json FROM messages ORDER BY id ASC;') .all(); - return rows.map(row => rowToMessage(row)); -} - -async function _getSendStates({ - messageId, - destinationConversationId, -}: Readonly<{ - messageId: string; - destinationConversationId: string; -}>) { - const db = getInstance(); - return db - .prepare( - ` - SELECT status, updatedAt FROM sendStates - WHERE messageId = $messageId - AND destinationConversationId = $destinationConversationId; - ` - ) - .all({ messageId, destinationConversationId }); + return rows.map(row => jsonToObject(row.json)); } async function getAllMessageIds(): Promise> { @@ -3463,16 +3333,10 @@ async function getMessageBySender({ sent_at: number; }): Promise> { const db = getInstance(); - const rows: Array = prepare( + const rows: JSONRows = prepare( db, ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates - WHERE + SELECT json FROM messages WHERE (source = $source OR sourceUuid = $sourceUuid) AND sourceDevice = $sourceDevice AND sent_at = $sent_at; @@ -3484,7 +3348,7 @@ async function getMessageBySender({ sent_at, }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getUnreadCountForConversation( @@ -3750,21 +3614,15 @@ async function getOlderMessagesByConversation( sentAt?: number; messageId?: string; } = {} -): Promise> { +): Promise> { const db = getInstance(); - let rows: Array; + let rows: JSONRows; if (messageId) { rows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates - WHERE + SELECT json FROM messages WHERE conversationId = $conversationId AND id != $messageId AND ( @@ -3786,12 +3644,7 @@ async function getOlderMessagesByConversation( rows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates WHERE + SELECT json FROM messages WHERE conversationId = $conversationId AND ( (received_at = $received_at AND sent_at < $sent_at) OR @@ -3819,17 +3672,12 @@ async function getNewerMessagesByConversation( receivedAt = 0, sentAt = 0, }: { limit?: number; receivedAt?: number; sentAt?: number } = {} -): Promise> { +): Promise> { const db = getInstance(); - return db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates WHERE + SELECT json FROM messages WHERE conversationId = $conversationId AND ( (received_at = $received_at AND sent_at > $sent_at) OR @@ -3845,8 +3693,9 @@ async function getNewerMessagesByConversation( sent_at: sentAt, limit, }); -} + return rows; +} function getOldestMessageForConversation( conversationId: string ): MessageMetricsType | undefined { @@ -3902,15 +3751,10 @@ async function getLastConversationActivity({ ourConversationId: string; }): Promise { const db = getInstance(); - const row: undefined | MessageRowWithJoinedSends = prepare( + const row = prepare( db, ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json FROM messages WHERE conversationId = $conversationId AND (type IS NULL @@ -3948,7 +3792,7 @@ async function getLastConversationActivity({ return undefined; } - return rowToMessage(row); + return jsonToObject(row.json); } async function getLastConversationPreview({ conversationId, @@ -3958,15 +3802,10 @@ async function getLastConversationPreview({ ourConversationId: string; }): Promise { const db = getInstance(); - const row: undefined | MessageRowWithJoinedSends = prepare( + const row = prepare( db, ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json FROM messages WHERE conversationId = $conversationId AND ( @@ -3999,9 +3838,8 @@ async function getLastConversationPreview({ return undefined; } - return rowToMessage(row); + return jsonToObject(row.json); } - function getOldestUnreadMessageForConversation( conversationId: string ): MessageMetricsType | undefined { @@ -4115,38 +3953,14 @@ async function migrateConversationMessages( }); } -async function updateMessageSendState( - params: Readonly< - { - messageId: string; - destinationConversationId: string; - } & SendState - > -): Promise { - const db = getInstance(); - - db.prepare( - ` - INSERT OR REPLACE INTO sendStates - (messageId, destinationConversationId, updatedAt, status) VALUES - ($messageId, $destinationConversationId, $updatedAt, $status); - ` - ).run(serializeSendStateForDatabase(params)); -} - async function getMessagesBySentAt( sentAt: number ): Promise> { const db = getInstance(); - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json FROM messages WHERE sent_at = $sent_at ORDER BY received_at DESC, sent_at DESC; ` @@ -4155,23 +3969,17 @@ async function getMessagesBySentAt( sent_at: sentAt, }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getExpiredMessages(): Promise> { const db = getInstance(); const now = Date.now(); - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates - WHERE + SELECT json FROM messages WHERE expiresAt IS NOT NULL AND expiresAt <= $now ORDER BY expiresAt ASC; @@ -4179,22 +3987,18 @@ async function getExpiredMessages(): Promise> { ) .all({ now }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise< Array > { const db = getInstance(); - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json FROM messages + INDEXED BY messages_unexpectedly_missing_expiration_start_timestamp WHERE expireTimer > 0 AND expirationStartTimestamp IS NULL AND @@ -4209,7 +4013,7 @@ async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise ) .all(); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getSoonestMessageExpiry(): Promise { @@ -4259,15 +4063,11 @@ async function getTapToViewMessagesNeedingErase(): Promise> { const db = getInstance(); const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000; - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json + FROM messages WHERE isViewOnce = 1 AND (isErased IS NULL OR isErased != 1) @@ -4279,7 +4079,7 @@ async function getTapToViewMessagesNeedingErase(): Promise> { THIRTY_DAYS_AGO, }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } function saveUnprocessedSync(data: UnprocessedType): string { @@ -5113,7 +4913,6 @@ async function removeAll(): Promise { DELETE FROM sticker_packs; DELETE FROM sticker_references; DELETE FROM jobs; - DELETE FROM sendStates; `); })(); } @@ -5134,7 +4933,6 @@ async function removeAllConfiguration(): Promise { DELETE FROM signedPreKeys; DELETE FROM unprocessed; DELETE FROM jobs; - DELETE FROM sendStates; ` ); db.prepare('UPDATE conversations SET json = json_patch(json, $patch);').run( @@ -5150,15 +4948,11 @@ async function getMessagesNeedingUpgrade( { maxVersion }: { maxVersion: number } ): Promise> { const db = getInstance(); - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates + SELECT json + FROM messages WHERE schemaVersion IS NULL OR schemaVersion < $maxVersion LIMIT $limit; ` @@ -5168,7 +4962,7 @@ async function getMessagesNeedingUpgrade( limit, }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getMessagesWithVisualMediaAttachments( @@ -5176,15 +4970,10 @@ async function getMessagesWithVisualMediaAttachments( { limit }: { limit: number } ): Promise> { const db = getInstance(); - const rows: Array = db + const rows: JSONRows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates WHERE + SELECT json FROM messages WHERE conversationId = $conversationId AND hasVisualMediaAttachments = 1 ORDER BY received_at DESC, sent_at DESC @@ -5196,7 +4985,7 @@ async function getMessagesWithVisualMediaAttachments( limit, }); - return rows.map(row => rowToMessage(row)); + return rows.map(row => jsonToObject(row.json)); } async function getMessagesWithFileAttachments( @@ -5204,15 +4993,10 @@ async function getMessagesWithFileAttachments( { limit }: { limit: number } ): Promise> { const db = getInstance(); - const rows: Array = db + const rows = db .prepare( ` - SELECT - json, - sendConversationIdsJoined, - sendStatusesJoined, - sendUpdatedAtsJoined - FROM messagesWithSendStates WHERE + SELECT json FROM messages WHERE conversationId = $conversationId AND hasFileAttachments = 1 ORDER BY received_at DESC, sent_at DESC @@ -5224,7 +5008,7 @@ async function getMessagesWithFileAttachments( limit, }); - return rows.map(row => rowToMessage(row)); + return map(rows, row => jsonToObject(row.json)); } async function getMessageServerGuidsForSpam( diff --git a/ts/sql/rowToMessage.ts b/ts/sql/rowToMessage.ts deleted file mode 100644 index a83415eb2..000000000 --- a/ts/sql/rowToMessage.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { MessageType } from './Interface'; -import { deserializeDatabaseSendStates } from '../messages/MessageSendState'; - -export type MessageRowWithJoinedSends = Readonly<{ - json: string; - sendConversationIdsJoined?: string; - sendStatusesJoined?: string; - sendUpdatedAtsJoined?: string; -}>; - -export function rowToMessage( - row: Readonly -): MessageType { - const result = JSON.parse(row.json); - // There should only be sends for outgoing messages, so this check should be redundant, - // but is here as a safety measure. - if (result.type === 'outgoing') { - result.sendStateByConversationId = deserializeDatabaseSendStates(row); - } - return result; -} diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 83fbdac45..b7cd7db8d 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, omit, reduce } from 'lodash'; +import { isNumber, isObject, map, reduce } from 'lodash'; import filesize from 'filesize'; import { @@ -46,15 +46,6 @@ import { GetConversationByIdType, isMissingRequiredProfileSharing, } from './conversations'; -import { - SendStatus, - isDelivered, - isMessageJustForMe, - isRead, - isSent, - maxStatus, - someSendStatus, -} from '../../messages/MessageSendState'; const THREE_HOURS = 3 * 60 * 60 * 1000; @@ -229,9 +220,7 @@ export function isOutgoing( return message.type === 'outgoing'; } -export function hasErrors( - message: Pick -): boolean { +export function hasErrors(message: MessageAttributesType): boolean { return message.errors ? message.errors.length > 0 : false; } @@ -369,7 +358,7 @@ export function getPropsForMessage( bodyRanges: processBodyRanges(message.bodyRanges, conversationSelector), canDeleteForEveryone: canDeleteForEveryone(message), canDownload: canDownload(message, conversationSelector), - canReply: canReply(message, ourConversationId, conversationSelector), + canReply: canReply(message, conversationSelector), contact: getPropsForEmbeddedContact(message, regionCode, accountSelector), conversationColor: conversation?.conversationColor ?? ConversationColors[0], conversationId: message.conversationId, @@ -393,11 +382,7 @@ export function getPropsForMessage( quote: getPropsForQuote(message, conversationSelector, ourConversationId), reactions, selectedReaction, - status: getMessagePropStatus( - message, - ourConversationId, - readReceiptSetting - ), + status: getMessagePropStatus(message, readReceiptSetting), text: createNonBreakingLastSeparator(message.body), textPending: message.bodyPending, timestamp: message.sent_at, @@ -897,54 +882,38 @@ function createNonBreakingLastSeparator(text?: string): string { } export function getMessagePropStatus( - message: Pick< - MessageAttributesType, - 'type' | 'errors' | 'sendStateByConversationId' - >, - ourConversationId: string, + message: MessageAttributesType, 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; } - 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)) { + const readBy = message.read_by || []; + if (readReceiptSetting && readBy.length > 0) { return 'read'; } - if (isDelivered(highestSuccessfulStatus)) { + const { delivered } = message; + const deliveredTo = message.delivered_to || []; + if (delivered || deliveredTo.length > 0) { return 'delivered'; } - if (isSent(highestSuccessfulStatus)) { + if (sent || sentTo.length > 0) { return 'sent'; } + return 'sending'; } @@ -1097,16 +1066,12 @@ function processQuoteAttachment( export function canReply( message: Pick< MessageAttributesType, - | 'conversationId' - | 'deletedForEveryone' - | 'sendStateByConversationId' - | 'type' + 'conversationId' | 'deletedForEveryone' | 'sent_to' | 'type' >, - ourConversationId: string, conversationSelector: GetConversationByIdType ): boolean { const conversation = getConversation(message, conversationSelector); - const { deletedForEveryone, sendStateByConversationId } = message; + const { deletedForEveryone, sent_to: sentTo } = message; if (!conversation) { return false; @@ -1135,10 +1100,7 @@ export function canReply( // We can reply if this is outgoing and sent to at least one recipient if (isOutgoing(message)) { - return ( - isMessageJustForMe(sendStateByConversationId, ourConversationId) || - someSendStatus(omit(sendStateByConversationId, ourConversationId), isSent) - ); + return (sentTo || []).length > 0; } // We can reply to incoming messages @@ -1226,7 +1188,7 @@ export function getAttachmentsForMessage( } export function getLastChallengeError( - message: Pick + message: MessageAttributesType ): 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 deleted file mode 100644 index e3022c53c..000000000 --- a/ts/test-both/messages/MessageSendState_test.ts +++ /dev/null @@ -1,564 +0,0 @@ -// 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, - deserializeDatabaseSendStates, - isDelivered, - isMessageJustForMe, - isRead, - isSent, - maxStatus, - sendStateReducer, - serializeSendStateForDatabase, - 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); - }); - }); - }); - - describe('serializeSendStateForDatabase', () => { - it('serializes legacy states without an update timestamp', () => { - assert.deepEqual( - serializeSendStateForDatabase({ - messageId: 'abc', - destinationConversationId: 'def', - status: SendStatus.Delivered, - }), - { - destinationConversationId: 'def', - messageId: 'abc', - status: 'Delivered', - updatedAt: 0, - } - ); - }); - - it('serializes send states', () => { - assert.deepEqual( - serializeSendStateForDatabase({ - messageId: 'abc', - destinationConversationId: 'def', - status: SendStatus.Read, - updatedAt: 956206800000, - }), - { - destinationConversationId: 'def', - messageId: 'abc', - status: 'Read', - updatedAt: 956206800000, - } - ); - - assert.deepEqual( - serializeSendStateForDatabase({ - messageId: 'abc', - destinationConversationId: 'def', - status: SendStatus.Failed, - updatedAt: 956206800000, - }), - { - destinationConversationId: 'def', - messageId: 'abc', - status: 'Failed', - updatedAt: 956206800000, - } - ); - }); - }); - - describe('deserializeDatabaseSendStates', () => { - it('returns an empty object if passed no send states', () => { - assert.deepEqual(deserializeDatabaseSendStates({}), {}); - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: undefined, - sendStatusesJoined: undefined, - sendUpdatedAtsJoined: undefined, - }), - {} - ); - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: null, - sendStatusesJoined: null, - sendUpdatedAtsJoined: null, - }), - {} - ); - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: '', - sendStatusesJoined: '', - sendUpdatedAtsJoined: '', - }), - {} - ); - }); - - it('deserializes one send state', () => { - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: 'abc', - sendStatusesJoined: 'Delivered', - sendUpdatedAtsJoined: '956206800000', - }), - { - abc: { - status: SendStatus.Delivered, - updatedAt: 956206800000, - }, - } - ); - }); - - it('deserializes multiple send states', () => { - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: 'abc,def', - sendStatusesJoined: 'Delivered,Sent', - sendUpdatedAtsJoined: '956206800000,1271739600000', - }), - { - abc: { - status: SendStatus.Delivered, - updatedAt: 956206800000, - }, - def: { - status: SendStatus.Sent, - updatedAt: 1271739600000, - }, - } - ); - }); - - it('deserializes send states that lack an updated timestamp', () => { - assert.deepEqual( - deserializeDatabaseSendStates({ - sendConversationIdsJoined: 'abc,def', - sendStatusesJoined: 'Delivered,Sent', - sendUpdatedAtsJoined: '956206800000,0', - }).def, - { - status: SendStatus.Sent, - updatedAt: undefined, - } - ); - }); - }); -}); diff --git a/ts/test-both/messages/migrateLegacySendAttributes_test.ts b/ts/test-both/messages/migrateLegacySendAttributes_test.ts deleted file mode 100644 index 07e87b760..000000000 --- a/ts/test-both/messages/migrateLegacySendAttributes_test.ts +++ /dev/null @@ -1,263 +0,0 @@ -// 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 60d2b1572..02b02c719 100644 --- a/ts/test-both/util/iterables_test.ts +++ b/ts/test-both/util/iterables_test.ts @@ -9,14 +9,11 @@ import { filter, find, groupBy, - isEmpty, isIterable, map, reduce, - repeat, size, take, - zipObject, } from '../../util/iterables'; describe('iterable utilities', () => { @@ -64,15 +61,6 @@ 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); @@ -273,28 +261,6 @@ 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(); @@ -386,23 +352,4 @@ 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 758785dd1..d32866eab 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { SendStatus } from '../../messages/MessageSendState'; describe('Conversations', () => { async function resetConversationController(): Promise { @@ -20,9 +19,9 @@ describe('Conversations', () => { // Creating a fake conversation const conversation = new window.Whisper.Conversation({ - id: window.getGuid(), + id: '8c45efca-67a4-4026-b990-9537d5d1a08f', e164: '+15551234567', - uuid: window.getGuid(), + uuid: '2f2734aa-f69d-4c1c-98eb-50eb0fc512d7', type: 'private', inbox_position: 0, isPinned: false, @@ -34,6 +33,7 @@ describe('Conversations', () => { version: 0, }); + const destinationE164 = '+15557654321'; window.textsecure.storage.user.setNumberAndDeviceId( ourNumber, 2, @@ -42,29 +42,27 @@ 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: window.getGuid(), + id: 'd8f2b435-e2ef-46e0-8481-07e68af251c6', 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 @@ -73,7 +71,7 @@ describe('Conversations', () => { Message: window.Whisper.Message, }); message = window.MessageController.register(message.id, message); - await window.Signal.Data.updateConversation(conversation.attributes); + await window.Signal.Data.saveConversation(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 1073c2a45..89162514e 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -5,19 +5,9 @@ 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 { SendStatus } from '../../messages/MessageSendState'; -import MessageSender 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 = { @@ -43,25 +33,16 @@ 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() { @@ -74,92 +55,25 @@ describe('Message', () => { // NOTE: These tests are incomplete. describe('send', () => { - let oldMessageSender: undefined | MessageSender; - - beforeEach(function beforeEach() { - oldMessageSender = window.textsecure.messaging; - - window.textsecure.messaging = - oldMessageSender ?? new MessageSender('username', 'password'); - this.sandbox - .stub(window.textsecure.messaging, 'sendSyncMessage') - .resolves(); - }); - - 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; - } - }); - - 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, - }, - }, - }); + it("saves the result's dataMessage", async () => { + const message = createMessage({ type: 'outgoing', source }); const fakeDataMessage = new ArrayBuffer(0); - const ignoredUuid = window.getGuid(); - - const promise = Promise.resolve({ - successfulIdentifiers: [conversation1.get('uuid'), ignoredUuid], - errors: [ - Object.assign(new Error('failed'), { - identifier: conversation2.get('uuid'), - }), - ], + const result = { dataMessage: fakeDataMessage, - }); + }; + const promise = Promise.resolve(result); await message.send(promise); - 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); + assert.strictEqual(message.get('dataMessage'), fakeDataMessage); + }); + + it('updates the `sent` attribute', async () => { + const message = createMessage({ type: 'outgoing', source, sent: false }); + + await message.send(Promise.resolve({})); + + assert.isTrue(message.get('sent')); }); it('saves errors from promise rejections with errors', async () => { diff --git a/ts/test-electron/sql/saveMessage_test.ts b/ts/test-electron/sql/saveMessage_test.ts deleted file mode 100644 index 7e9286272..000000000 --- a/ts/test-electron/sql/saveMessage_test.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import { omit } from 'lodash'; -import { v4 as uuid } from 'uuid'; -import { MessageModel } from '../../models/messages'; -import { SendStatus } from '../../messages/MessageSendState'; -import type { StorageAccessType } from '../../types/Storage.d'; -import type { MessageAttributesType } from '../../model-types.d'; -import type { WhatIsThis } from '../../window.d'; -import dataInterface from '../../sql/Client'; - -const { - getMessageById, - saveMessage, - saveConversation, - _getSendStates, -} = dataInterface; - -describe('saveMessage', () => { - const STORAGE_KEYS_TO_RESTORE: Array = [ - 'number_id', - 'uuid_id', - ]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const oldStorageValues = new Map(); - - 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', `${uuid()}.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); - } - }); - }); - - // NOTE: These tests are incomplete, and were only added to test new functionality. - it('inserts a new message if passed an object with no ID', async () => { - const messageId = await saveMessage( - ({ - type: 'incoming', - sent_at: Date.now(), - conversationId: uuid(), - received_at: Date.now(), - timestamp: Date.now(), - // TODO: DESKTOP-722 - } as Partial) as WhatIsThis, - { Message: MessageModel } - ); - - assert.exists(await getMessageById(messageId, { Message: MessageModel })); - }); - - it('when inserting a message, saves send states', async () => { - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const conversation1Id = uuid(); - const conversation2Id = uuid(); - - await Promise.all( - [conversation1Id, conversation2Id].map(id => - saveConversation({ - id, - inbox_position: 0, - isPinned: false, - lastMessageDeletedForEveryone: false, - markedUnread: false, - messageCount: 0, - sentMessageCount: 0, - type: 'private', - profileSharing: true, - version: 1, - }) - ) - ); - - const messageId = await saveMessage( - { - id: uuid(), - type: 'outgoing', - sent_at: Date.now(), - conversationId: uuid(), - received_at: Date.now(), - timestamp: Date.now(), - sendStateByConversationId: { - [ourConversationId]: { - status: SendStatus.Sent, - updatedAt: 1, - }, - [conversation1Id]: { - status: SendStatus.Pending, - updatedAt: 2, - }, - [conversation2Id]: { - status: SendStatus.Delivered, - updatedAt: 3, - }, - }, - }, - { forceSave: true, Message: MessageModel } - ); - - const assertSendState = async ( - destinationConversationId: string, - expectedStatusString: string, - expectedUpdatedAt: number - ): Promise => { - assert.deepEqual( - await _getSendStates({ messageId, destinationConversationId }), - [{ status: expectedStatusString, updatedAt: expectedUpdatedAt }] - ); - }; - - await Promise.all([ - assertSendState(ourConversationId, 'Sent', 1), - assertSendState(conversation1Id, 'Pending', 2), - assertSendState(conversation2Id, 'Delivered', 3), - ]); - }); - - it('when updating a message, updates and inserts send states', async () => { - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const conversation1Id = uuid(); - const conversation2Id = uuid(); - const conversation3Id = uuid(); - - await Promise.all( - [conversation1Id, conversation2Id, conversation3Id].map(id => - saveConversation({ - id, - inbox_position: 0, - isPinned: false, - lastMessageDeletedForEveryone: false, - markedUnread: false, - messageCount: 0, - sentMessageCount: 0, - type: 'private', - profileSharing: true, - version: 1, - }) - ) - ); - - const messageAttributes: MessageAttributesType = { - id: 'to be replaced', - type: 'outgoing', - sent_at: Date.now(), - conversationId: uuid(), - received_at: Date.now(), - timestamp: Date.now(), - sendStateByConversationId: { - [ourConversationId]: { - status: SendStatus.Sent, - updatedAt: 1, - }, - [conversation1Id]: { - status: SendStatus.Pending, - updatedAt: 2, - }, - [conversation2Id]: { - status: SendStatus.Delivered, - updatedAt: 3, - }, - }, - }; - - const messageId = await saveMessage( - // TODO: DESKTOP-722 - (omit( - messageAttributes, - 'id' - ) as Partial) as WhatIsThis, - { Message: MessageModel } - ); - - messageAttributes.id = messageId; - messageAttributes.sendStateByConversationId = { - [ourConversationId]: { - status: SendStatus.Delivered, - updatedAt: 4, - }, - [conversation1Id]: { - status: SendStatus.Sent, - updatedAt: 5, - }, - [conversation2Id]: { - status: SendStatus.Read, - updatedAt: 6, - }, - [conversation3Id]: { - status: SendStatus.Pending, - updatedAt: 7, - }, - }; - - await saveMessage(messageAttributes, { Message: MessageModel }); - - const assertSendState = async ( - destinationConversationId: string, - expectedStatusString: string, - expectedUpdatedAt: number - ): Promise => { - assert.deepEqual( - await _getSendStates({ messageId, destinationConversationId }), - [{ status: expectedStatusString, updatedAt: expectedUpdatedAt }] - ); - }; - - await Promise.all([ - assertSendState(ourConversationId, 'Delivered', 4), - assertSendState(conversation1Id, 'Sent', 5), - assertSendState(conversation2Id, 'Read', 6), - assertSendState(conversation3Id, 'Pending', 7), - ]); - }); -}); diff --git a/ts/test-electron/state/selectors/messages_test.ts b/ts/test-electron/state/selectors/messages_test.ts index 4db2ee4dd..e67b9c4d9 100644 --- a/ts/test-electron/state/selectors/messages_test.ts +++ b/ts/test-electron/state/selectors/messages_test.ts @@ -3,16 +3,10 @@ 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, @@ -20,12 +14,6 @@ import { } from '../../../state/selectors/message'; describe('state/selectors/messages', () => { - let ourConversationId: string; - - beforeEach(() => { - ourConversationId = uuid(); - }); - describe('canReply', () => { const defaultConversation: ConversationType = { id: uuid(), @@ -47,7 +35,7 @@ describe('state/selectors/messages', () => { isGroupV1AndDisabled: true, }); - assert.isFalse(canReply(message, ourConversationId, getConversationById)); + assert.isFalse(canReply(message, getConversationById)); }); // NOTE: This is missing a test for mandatory profile sharing. @@ -60,70 +48,33 @@ describe('state/selectors/messages', () => { }; const getConversationById = () => defaultConversation; - assert.isFalse(canReply(message, ourConversationId, getConversationById)); + assert.isFalse(canReply(message, getConversationById)); }); it('returns false for outgoing messages that have not been sent', () => { 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(), - }, - }, + sent_to: [], }; const getConversationById = () => defaultConversation; - assert.isFalse(canReply(message, ourConversationId, getConversationById)); + assert.isFalse(canReply(message, getConversationById)); }); - it('returns true for outgoing messages that are only sent to yourself', () => { + it('returns true for outgoing messages that have been delivered to at least one person', () => { const message = { conversationId: 'fake-conversation-id', type: 'outgoing' as const, - 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(), - }, - }, + receipients: [uuid(), uuid()], + sent_to: [uuid()], }; const getConversationById = () => ({ ...defaultConversation, type: 'group' as const, }); - assert.isTrue(canReply(message, ourConversationId, getConversationById)); + assert.isTrue(canReply(message, getConversationById)); }); it('returns true for incoming messages', () => { @@ -133,247 +84,7 @@ describe('state/selectors/messages', () => { }; const getConversationById = () => defaultConversation; - 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' - ); + assert.isTrue(canReply(message, getConversationById)); }); }); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index bd52c5381..4917c0834 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -47,7 +47,7 @@ import { LinkPreviewImage, LinkPreviewMetadata, } from '../linkPreviews/linkPreviewFetch'; -import { concat, isEmpty, map } from '../util/iterables'; +import { concat } from '../util/iterables'; import { SignalService as Proto } from '../protobuf'; export type SendMetadataType = { @@ -996,8 +996,8 @@ export default class MessageSender { destination, destinationUuid, expirationStartTimestamp, - conversationIdsSentTo = [], - conversationIdsWithSealedSender = new Set(), + sentTo, + unidentifiedDeliveries, isUpdate, options, }: { @@ -1006,8 +1006,8 @@ export default class MessageSender { destination: string | undefined; destinationUuid: string | null | undefined; expirationStartTimestamp: number | null; - conversationIdsSentTo?: Iterable; - conversationIdsWithSealedSender?: Set; + sentTo?: Array; + unidentifiedDeliveries?: Array; isUpdate?: boolean; options?: SendOptionsType; }): Promise { @@ -1035,33 +1035,38 @@ 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 (!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; - } + 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; } - status.unidentified = conversationIdsWithSealedSender.has( - conversationId - ); - return status; - }), - ]; + const uuid = conv.get('uuid'); + if (uuid) { + status.destinationUuid = uuid; + } + } + status.unidentified = Boolean(unidentifiedLookup[identifier]); + return status; + }); } const syncMessage = this.createSyncMessage(); @@ -1682,8 +1687,8 @@ export default class MessageSender { destination: e164, destinationUuid: uuid, expirationStartTimestamp: null, - conversationIdsSentTo: [], - conversationIdsWithSealedSender: new Set(), + sentTo: [], + unidentifiedDeliveries: [], options, }).catch(logError('resetSession/sendSync error:')); diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 2b7c430f5..cfac752fb 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -45,15 +45,21 @@ 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 7b8fe404a..10897814c 100644 --- a/ts/util/iterables.ts +++ b/ts/util/iterables.ts @@ -119,9 +119,6 @@ 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 @@ -170,33 +167,6 @@ 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); } @@ -224,29 +194,3 @@ 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 be2a2341e..62aed043d 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -3350,9 +3350,7 @@ Whisper.ConversationView = Whisper.View.extend({ } const getProps = () => ({ - ...message.getPropsForMessageDetail( - window.ConversationController.getOurConversationIdOrThrow() - ), + ...message.getPropsForMessageDetail(), ...this.getMessageActions(), }); @@ -3748,14 +3746,7 @@ Whisper.ConversationView = Whisper.View.extend({ }) : undefined; - if ( - message && - !canReply( - message.attributes, - window.ConversationController.getOurConversationIdOrThrow(), - findAndFormatContact - ) - ) { + if (message && !canReply(message.attributes, findAndFormatContact)) { return; }