Refactor outbound delivery state

This commit is contained in:
Evan Hahn 2021-07-09 16:38:51 -05:00 committed by GitHub
parent 831ec98418
commit 9c48a95eb5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 3200 additions and 697 deletions

View file

@ -3230,7 +3230,7 @@ button.module-conversation-details__action-button {
margin-bottom: 2px; margin-bottom: 2px;
} }
.module-message-detail__contact__status-icon--sending { .module-message-detail__contact__status-icon--Pending {
animation: module-message-detail__contact__status-icon--spinning 4s linear animation: module-message-detail__contact__status-icon--spinning 4s linear
infinite; infinite;
@ -3249,7 +3249,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 light-theme {
@include color-svg('../images/check-circle-outline.svg', $color-gray-60); @include color-svg('../images/check-circle-outline.svg', $color-gray-60);
} }
@ -3257,7 +3257,7 @@ button.module-conversation-details__action-button {
@include color-svg('../images/check-circle-outline.svg', $color-gray-25); @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; width: 18px;
@include light-theme { @include light-theme {
@ -3267,7 +3267,8 @@ button.module-conversation-details__action-button {
@include color-svg('../images/double-check.svg', $color-gray-25); @include color-svg('../images/double-check.svg', $color-gray-25);
} }
} }
.module-message-detail__contact__status-icon--read { .module-message-detail__contact__status-icon--Read,
.module-message-detail__contact__status-icon--Viewed {
width: 18px; width: 18px;
@include light-theme { @include light-theme {
@ -3277,7 +3278,7 @@ button.module-conversation-details__action-button {
@include color-svg('../images/read.svg', $color-gray-25); @include color-svg('../images/read.svg', $color-gray-25);
} }
} }
.module-message-detail__contact__status-icon--error { .module-message-detail__contact__status-icon--Failed {
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v2/error-outline-12.svg', '../images/icons/v2/error-outline-12.svg',

View file

@ -4,12 +4,39 @@
/* global ConversationController, SignalProtocolStore, Whisper */ /* global ConversationController, SignalProtocolStore, Whisper */
describe('KeyChangeListener', () => { describe('KeyChangeListener', () => {
const STORAGE_KEYS_TO_RESTORE = ['number_id', 'uuid_id'];
const oldStorageValues = new Map();
const phoneNumberWithKeyChange = '+13016886524'; // nsa const phoneNumberWithKeyChange = '+13016886524'; // nsa
const addressString = `${phoneNumberWithKeyChange}.1`; const addressString = `${phoneNumberWithKeyChange}.1`;
const oldKey = window.Signal.Crypto.getRandomBytes(33); const oldKey = window.Signal.Crypto.getRandomBytes(33);
const newKey = window.Signal.Crypto.getRandomBytes(33); const newKey = window.Signal.Crypto.getRandomBytes(33);
let store; 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; let convo;
beforeEach(async () => { beforeEach(async () => {

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, noop } from 'lodash'; import { has, isNumber, noop } from 'lodash';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { import {
@ -82,6 +82,10 @@ import { Reactions } from './messageModifiers/Reactions';
import { ReadReceipts } from './messageModifiers/ReadReceipts'; import { ReadReceipts } from './messageModifiers/ReadReceipts';
import { ReadSyncs } from './messageModifiers/ReadSyncs'; import { ReadSyncs } from './messageModifiers/ReadSyncs';
import { ViewSyncs } from './messageModifiers/ViewSyncs'; import { ViewSyncs } from './messageModifiers/ViewSyncs';
import {
SendStateByConversationId,
SendStatus,
} from './messages/MessageSendState';
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads'; import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
import { import {
SystemTraySetting, SystemTraySetting,
@ -3151,15 +3155,39 @@ export async function startApp(): Promise<void> {
const now = Date.now(); const now = Date.now();
const timestamp = data.timestamp || now; const timestamp = data.timestamp || now;
const ourId = window.ConversationController.getOurConversationIdOrThrow();
const { unidentifiedStatus = [] } = data; const { unidentifiedStatus = [] } = data;
let sentTo: Array<string> = [];
const sendStateByConversationId: SendStateByConversationId = unidentifiedStatus.reduce(
(result: SendStateByConversationId, { destinationUuid, destination }) => {
const conversationId = window.ConversationController.ensureContactIds({
uuid: destinationUuid,
e164: destination,
highTrust: true,
});
if (!conversationId || conversationId === ourId) {
return result;
}
return {
...result,
[conversationId]: {
status: SendStatus.Pending,
updatedAt: timestamp,
},
};
},
{
[ourId]: {
status: SendStatus.Sent,
updatedAt: timestamp,
},
}
);
let unidentifiedDeliveries: Array<string> = []; let unidentifiedDeliveries: Array<string> = [];
if (unidentifiedStatus.length) { if (unidentifiedStatus.length) {
sentTo = unidentifiedStatus
.map(item => item.destinationUuid || item.destination)
.filter(isNotNil);
const unidentified = window._.filter(data.unidentifiedStatus, item => const unidentified = window._.filter(data.unidentifiedStatus, item =>
Boolean(item.unidentified) Boolean(item.unidentified)
); );
@ -3174,13 +3202,12 @@ export async function startApp(): Promise<void> {
sourceDevice: data.device, sourceDevice: data.device,
sent_at: timestamp, sent_at: timestamp,
serverTimestamp: data.serverTimestamp, serverTimestamp: data.serverTimestamp,
sent_to: sentTo,
received_at: data.receivedAtCounter, received_at: data.receivedAtCounter,
received_at_ms: data.receivedAtDate, received_at_ms: data.receivedAtDate,
conversationId: descriptor.id, conversationId: descriptor.id,
timestamp, timestamp,
type: 'outgoing', type: 'outgoing',
sent: true, sendStateByConversationId,
unidentifiedDeliveries, unidentifiedDeliveries,
expirationStartTimestamp: Math.min( expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || timestamp, data.expirationStartTimestamp || timestamp,
@ -3559,33 +3586,6 @@ export async function startApp(): Promise<void> {
window.log.warn('background onError: Doing nothing with incoming error'); window.log.warn('background onError: Doing nothing with incoming error');
} }
function isInList(
conversation: ConversationModel,
list: Array<string | undefined | null> | 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({ async function archiveSessionOnMatch({
requesterUuid, requesterUuid,
requesterDevice, requesterDevice,
@ -3707,11 +3707,9 @@ export async function startApp(): Promise<void> {
return false; return false;
} }
if (!isInList(requesterConversation, message.get('sent_to'))) { const sendStateByConversationId =
return false; message.get('sendStateByConversationId') || {};
} return has(sendStateByConversationId, requesterConversation.id);
return true;
}); });
if (!targetMessage) { if (!targetMessage) {

View file

@ -9,6 +9,7 @@ import { storiesOf } from '@storybook/react';
import { PropsData as MessageDataPropsType } from './Message'; import { PropsData as MessageDataPropsType } from './Message';
import { MessageDetail, Props } from './MessageDetail'; import { MessageDetail, Props } from './MessageDetail';
import { SendStatus } from '../../messages/MessageSendState';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
@ -48,7 +49,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'delivered', status: SendStatus.Delivered,
}, },
], ],
errors: overrideProps.errors || [], errors: overrideProps.errors || [],
@ -116,7 +117,7 @@ story.add('Message Statuses', () => {
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'sent', status: SendStatus.Sent,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -125,7 +126,7 @@ story.add('Message Statuses', () => {
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'sending', status: SendStatus.Pending,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -134,7 +135,7 @@ story.add('Message Statuses', () => {
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'partial-sent', status: SendStatus.Failed,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -143,7 +144,7 @@ story.add('Message Statuses', () => {
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'delivered', status: SendStatus.Delivered,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -152,7 +153,7 @@ story.add('Message Statuses', () => {
}), }),
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'read', status: SendStatus.Read,
}, },
], ],
message: { message: {
@ -209,7 +210,7 @@ story.add('All Errors', () => {
}), }),
isOutgoingKeyError: true, isOutgoingKeyError: true,
isUnidentifiedDelivery: false, isUnidentifiedDelivery: false,
status: 'error', status: SendStatus.Failed,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -224,7 +225,7 @@ story.add('All Errors', () => {
], ],
isOutgoingKeyError: false, isOutgoingKeyError: false,
isUnidentifiedDelivery: true, isUnidentifiedDelivery: true,
status: 'error', status: SendStatus.Failed,
}, },
{ {
...getDefaultConversation({ ...getDefaultConversation({
@ -233,7 +234,7 @@ story.add('All Errors', () => {
}), }),
isOutgoingKeyError: true, isOutgoingKeyError: true,
isUnidentifiedDelivery: true, isUnidentifiedDelivery: true,
status: 'error', status: SendStatus.Failed,
}, },
], ],
}); });

View file

@ -10,7 +10,6 @@ import { Avatar } from '../Avatar';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { import {
Message, Message,
MessageStatusType,
Props as MessagePropsType, Props as MessagePropsType,
PropsData as MessagePropsDataType, PropsData as MessagePropsDataType,
} from './Message'; } from './Message';
@ -18,6 +17,7 @@ import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { ContactNameColorType } from '../../types/Colors'; import { ContactNameColorType } from '../../types/Colors';
import { SendStatus } from '../../messages/MessageSendState';
export type Contact = Pick< export type Contact = Pick<
ConversationType, ConversationType,
@ -33,7 +33,7 @@ export type Contact = Pick<
| 'title' | 'title'
| 'unblurredAvatarPath' | 'unblurredAvatarPath'
> & { > & {
status: MessageStatusType | null; status: SendStatus | null;
isOutgoingKeyError: boolean; isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean; isUnidentifiedDelivery: boolean;

View file

@ -2291,8 +2291,8 @@ export async function wrapWithSyncMessageSend({
destination: ourConversation.get('e164'), destination: ourConversation.get('e164'),
destinationUuid: ourConversation.get('uuid'), destinationUuid: ourConversation.get('uuid'),
expirationStartTimestamp: null, expirationStartTimestamp: null,
sentTo: [], conversationIdsSentTo: [],
unidentifiedDeliveries: [], conversationIdsWithSealedSender: new Set(),
}); });
} }

View file

@ -3,13 +3,15 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { union } from 'lodash'; import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone'; import { Collection, Model } from 'backbone';
import { ConversationModel } from '../models/conversations'; import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d'; import { MessageModelCollectionType } from '../model-types.d';
import { isIncoming } from '../state/selectors/message'; import { isIncoming } from '../state/selectors/message';
import { getOwn } from '../util/getOwn';
import { SendActionType, sendStateReducer } from '../messages/MessageSendState';
type DeliveryReceiptAttributesType = { type DeliveryReceiptAttributesType = {
timestamp: number; timestamp: number;
@ -82,48 +84,67 @@ export class DeliveryReceipts extends Collection<DeliveryReceiptModel> {
} }
async onReceipt(receipt: DeliveryReceiptModel): Promise<void> { async onReceipt(receipt: DeliveryReceiptModel): Promise<void> {
try { const deliveredTo = receipt.get('deliveredTo');
const messages = await window.Signal.Data.getMessagesBySentAt( const timestamp = receipt.get('timestamp');
receipt.get('timestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const message = await getTargetMessage( try {
receipt.get('deliveredTo'), const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, {
messages MessageCollection: window.Whisper.MessageCollection,
); });
const message = await getTargetMessage(deliveredTo, messages);
if (!message) { if (!message) {
window.log.info( window.log.info(
'No message for delivery receipt', 'No message for delivery receipt',
receipt.get('deliveredTo'), deliveredTo,
receipt.get('timestamp') timestamp
); );
return; return;
} }
const deliveries = message.get('delivered') || 0; const oldSendStateByConversationId =
const deliveredTo = message.get('delivered_to') || []; message.get('sendStateByConversationId') || {};
const expirationStartTimestamp = message.get('expirationStartTimestamp'); const oldSendState = getOwn(oldSendStateByConversationId, deliveredTo);
message.set({ if (oldSendState) {
delivered_to: union(deliveredTo, [receipt.get('deliveredTo')]), const newSendState = sendStateReducer(oldSendState, {
delivered: deliveries + 1, type: SendActionType.GotDeliveryReceipt,
expirationStartTimestamp: expirationStartTimestamp || Date.now(), updatedAt: timestamp,
sent: true, });
});
window.Signal.Util.queueUpdateMessage(message.attributes); // The send state may not change. This can happen if the message was marked read
// before we got the delivery receipt, or if we got double delivery receipts, or
// things like that.
if (!isEqual(oldSendState, newSendState)) {
message.set('sendStateByConversationId', {
...oldSendStateByConversationId,
[deliveredTo]: newSendState,
});
// notify frontend listeners await window.Signal.Data.updateMessageSendState({
const conversation = window.ConversationController.get( messageId: message.id,
message.get('conversationId') destinationConversationId: deliveredTo,
); ...newSendState,
const updateLeftPane = conversation });
? conversation.debouncedUpdateLastMessage
: undefined; // notify frontend listeners
if (updateLeftPane) { const conversation = window.ConversationController.get(
updateLeftPane(); 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`
);
} }
this.remove(receipt); this.remove(receipt);

View file

@ -3,12 +3,15 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone'; import { Collection, Model } from 'backbone';
import { ConversationModel } from '../models/conversations'; import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d'; import { MessageModelCollectionType } from '../model-types.d';
import { isOutgoing } from '../state/selectors/message'; import { isOutgoing } from '../state/selectors/message';
import { getOwn } from '../util/getOwn';
import { SendActionType, sendStateReducer } from '../messages/MessageSendState';
type ReadReceiptAttributesType = { type ReadReceiptAttributesType = {
reader: string; reader: string;
@ -86,46 +89,64 @@ export class ReadReceipts extends Collection<ReadReceiptModel> {
} }
async onReceipt(receipt: ReadReceiptModel): Promise<void> { async onReceipt(receipt: ReadReceiptModel): Promise<void> {
const reader = receipt.get('reader');
const timestamp = receipt.get('timestamp');
try { try {
const messages = await window.Signal.Data.getMessagesBySentAt( const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, {
receipt.get('timestamp'), MessageCollection: window.Whisper.MessageCollection,
{ });
MessageCollection: window.Whisper.MessageCollection,
}
);
const message = await getTargetMessage(receipt.get('reader'), messages); const message = await getTargetMessage(receipt.get('reader'), messages);
if (!message) { if (!message) {
window.log.info( window.log.info('No message for read receipt', reader, timestamp);
'No message for read receipt',
receipt.get('reader'),
receipt.get('timestamp')
);
return; return;
} }
const readBy = message.get('read_by') || []; const oldSendStateByConversationId =
const expirationStartTimestamp = message.get('expirationStartTimestamp'); message.get('sendStateByConversationId') || {};
const oldSendState = getOwn(oldSendStateByConversationId, reader);
if (oldSendState) {
const newSendState = sendStateReducer(oldSendState, {
type: SendActionType.GotReadReceipt,
updatedAt: timestamp,
});
readBy.push(receipt.get('reader')); // The send state may not change. This can happen if we get read receipts after
message.set({ // we get viewed receipts, or if we get double read receipts, or things like
read_by: readBy, // that.
expirationStartTimestamp: expirationStartTimestamp || Date.now(), if (!isEqual(oldSendState, newSendState)) {
sent: true, message.set('sendStateByConversationId', {
}); ...oldSendStateByConversationId,
[reader]: newSendState,
});
window.Signal.Util.queueUpdateMessage(message.attributes); await window.Signal.Data.updateMessageSendState({
messageId: message.id,
destinationConversationId: reader,
...newSendState,
});
// notify frontend listeners // notify frontend listeners
const conversation = window.ConversationController.get( const conversation = window.ConversationController.get(
message.get('conversationId') message.get('conversationId')
); );
const updateLeftPane = conversation const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage ? conversation.debouncedUpdateLastMessage
: undefined; : undefined;
if (updateLeftPane) { if (updateLeftPane) {
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`
);
} }
this.remove(receipt); this.remove(receipt);

View file

@ -0,0 +1,237 @@
// 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, number> = {
[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<SendState>,
action: Readonly<SendAction>
): 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, SendStatus> = {
[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<string, SendState>;
export const someSendStatus = (
sendStateByConversationId: undefined | Readonly<SendStateByConversationId>,
predicate: (value: SendStatus) => boolean
): boolean =>
Object.values(sendStateByConversationId || {}).some(sendState =>
predicate(sendState.status)
);
export const isMessageJustForMe = (
sendStateByConversationId: undefined | Readonly<SendStateByConversationId>,
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<string> {
return typeof value === 'string' && value ? value.split(',') : [];
}

View file

@ -0,0 +1,172 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { get, isEmpty } from 'lodash';
import { getOwn } from '../util/getOwn';
import { map, concat, repeat, zipObject } from '../util/iterables';
import { isOutgoing } from '../state/selectors/message';
import type { CustomError, MessageAttributesType } from '../model-types.d';
import {
SendState,
SendActionType,
SendStateByConversationId,
sendStateReducer,
SendStatus,
} from './MessageSendState';
/**
* This converts legacy message fields, such as `sent_to`, into the new
* `sendStateByConversationId` format. These legacy fields aren't typed to prevent their
* usage, so we treat them carefully (i.e., as if they are `unknown`).
*
* Old data isn't dropped, in case we need to revert this change. We should safely be able
* to remove the following attributes once we're confident in this new format:
*
* - delivered
* - delivered_to
* - read_by
* - recipients
* - sent
* - sent_to
*/
export function migrateLegacySendAttributes(
message: Readonly<
Pick<
MessageAttributesType,
'errors' | 'sendStateByConversationId' | 'sent_at' | 'type'
>
>,
getConversation: GetConversationType,
ourConversationId: string
): undefined | SendStateByConversationId {
const shouldMigrate =
isEmpty(message.sendStateByConversationId) && isOutgoing(message);
if (!shouldMigrate) {
return undefined;
}
/* eslint-disable no-restricted-syntax */
const pendingSendState: SendState = {
status: SendStatus.Pending,
updatedAt: message.sent_at,
};
const sendStateByConversationId: SendStateByConversationId = zipObject(
getConversationIdsFromLegacyAttribute(
message,
'recipients',
getConversation
),
repeat(pendingSendState)
);
// We use `get` because `sent` is a legacy, and therefore untyped, attribute.
const wasSentToSelf = Boolean(get(message, 'sent'));
const actions = concat<{
type:
| SendActionType.Failed
| SendActionType.Sent
| SendActionType.GotDeliveryReceipt
| SendActionType.GotReadReceipt;
conversationId: string;
}>(
map(
getConversationIdsFromErrors(message.errors, getConversation),
conversationId => ({
type: SendActionType.Failed,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'sent_to',
getConversation
),
conversationId => ({
type: SendActionType.Sent,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'delivered_to',
getConversation
),
conversationId => ({
type: SendActionType.GotDeliveryReceipt,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'read_by',
getConversation
),
conversationId => ({
type: SendActionType.GotReadReceipt,
conversationId,
})
),
[
{
type: wasSentToSelf ? SendActionType.Sent : SendActionType.Failed,
conversationId: ourConversationId,
},
]
);
for (const { conversationId, type } of actions) {
const oldSendState =
getOwn(sendStateByConversationId, conversationId) || pendingSendState;
sendStateByConversationId[conversationId] = sendStateReducer(oldSendState, {
type,
updatedAt: undefined,
});
}
return sendStateByConversationId;
/* eslint-enable no-restricted-syntax */
}
function getConversationIdsFromErrors(
errors: undefined | ReadonlyArray<CustomError>,
getConversation: GetConversationType
): Array<string> {
const result: Array<string> = [];
(errors || []).forEach(error => {
const conversation =
getConversation(error.identifier) || getConversation(error.number);
if (conversation) {
result.push(conversation.id);
}
});
return result;
}
function getConversationIdsFromLegacyAttribute(
message: Record<string, unknown>,
attributeName: string,
getConversation: GetConversationType
): Array<string> {
const rawValue: unknown =
message[attributeName as keyof MessageAttributesType];
const value: Array<unknown> = Array.isArray(rawValue) ? rawValue : [];
const result: Array<string> = [];
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;

15
ts/model-types.d.ts vendored
View file

@ -20,6 +20,10 @@ import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI'; import { CapabilitiesType } from './textsecure/WebAPI';
import {
SendState,
SendStateByConversationId,
} from './messages/MessageSendState';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors'; import { ConversationColorType } from './types/Colors';
import { AttachmentType, ThumbnailType } from './types/Attachment'; import { AttachmentType, ThumbnailType } from './types/Attachment';
@ -88,8 +92,6 @@ export type MessageAttributesType = {
decrypted_at?: number; decrypted_at?: number;
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
delivered?: number;
delivered_to?: Array<string | null>;
errors?: Array<CustomError>; errors?: Array<CustomError>;
expirationStartTimestamp?: number | null; expirationStartTimestamp?: number | null;
expireTimer?: number; expireTimer?: number;
@ -115,10 +117,8 @@ export type MessageAttributesType = {
targetTimestamp: number; targetTimestamp: number;
timestamp: number; timestamp: number;
}>; }>;
read_by?: Array<string | null>;
requiredProtocolVersion?: number; requiredProtocolVersion?: number;
retryOptions?: RetryOptions; retryOptions?: RetryOptions;
sent?: boolean;
sourceDevice?: string | number; sourceDevice?: string | number;
supportedVersionAtReceive?: unknown; supportedVersionAtReceive?: unknown;
synced?: boolean; synced?: boolean;
@ -152,14 +152,10 @@ export type MessageAttributesType = {
data?: AttachmentType; data?: AttachmentType;
}; };
sent_at: number; sent_at: number;
sent_to?: Array<string>;
unidentifiedDeliveries?: Array<string>; unidentifiedDeliveries?: Array<string>;
contact?: Array<ContactType>; contact?: Array<ContactType>;
conversationId: string; conversationId: string;
recipients?: Array<string>;
reaction?: WhatIsThis; reaction?: WhatIsThis;
destination?: WhatIsThis;
destinationUuid?: string;
expirationTimerUpdate?: { expirationTimerUpdate?: {
expireTimer: number; expireTimer: number;
@ -192,6 +188,9 @@ export type MessageAttributesType = {
droppedGV2MemberIds?: Array<string>; droppedGV2MemberIds?: Array<string>;
sendHQImages?: boolean; sendHQImages?: boolean;
// Should only be present for outgoing messages
sendStateByConversationId?: SendStateByConversationId;
}; };
export type ConversationAttributesTypeType = 'private' | 'group'; export type ConversationAttributesTypeType = 'private' | 'group';

View file

@ -55,7 +55,15 @@ import { handleMessageSend } from '../util/handleMessageSend';
import { getConversationMembers } from '../util/getConversationMembers'; import { getConversationMembers } from '../util/getConversationMembers';
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { filter, map, take } from '../util/iterables'; import { SendStatus } from '../messages/MessageSendState';
import {
concat,
filter,
map,
take,
repeat,
zipObject,
} from '../util/iterables';
import * as universalExpireTimer from '../util/universalExpireTimer'; import * as universalExpireTimer from '../util/universalExpireTimer';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
import { import {
@ -3172,7 +3180,6 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!; const destination = this.getSendTarget()!;
const recipients = this.getRecipients();
return this.queueJob('sendDeleteForEveryone', async () => { return this.queueJob('sendDeleteForEveryone', async () => {
window.log.info( window.log.info(
@ -3191,10 +3198,8 @@ export class ConversationModel extends window.Backbone
sent_at: timestamp, sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
recipients,
deletedForEveryoneTimestamp: targetTimestamp, deletedForEveryoneTimestamp: targetTimestamp,
timestamp, timestamp,
...(isDirectConversation(this.attributes) ? { destination } : {}),
}); });
// We're offline! // We're offline!
@ -3295,7 +3300,6 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!; const destination = this.getSendTarget()!;
const recipients = this.getRecipients();
return this.queueJob('sendReactionMessage', async () => { return this.queueJob('sendReactionMessage', async () => {
window.log.info( window.log.info(
@ -3318,10 +3322,8 @@ export class ConversationModel extends window.Backbone
sent_at: timestamp, sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
recipients,
reaction: outgoingReaction, reaction: outgoingReaction,
timestamp, timestamp,
...(isDirectConversation(this.attributes) ? { destination } : {}),
}); });
// This is to ensure that the functions in send() and sendSyncMessage() don't save // This is to ensure that the functions in send() and sendSyncMessage() don't save
@ -3503,6 +3505,18 @@ export class ConversationModel extends window.Backbone
now 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 // Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({ const messageWithSchema = await upgradeMessageSchema({
timestamp: now, timestamp: now,
@ -3520,6 +3534,13 @@ export class ConversationModel extends window.Backbone
sticker, sticker,
bodyRanges: mentions, bodyRanges: mentions,
sendHQImages, sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: now,
})
),
}); });
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
@ -3563,17 +3584,13 @@ export class ConversationModel extends window.Backbone
// We're offline! // We're offline!
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
const errors = [ const errors = map(recipientConversationIds, conversationId => {
...(this.contactCollection && this.contactCollection.length
? this.contactCollection
: [this]),
].map(contact => {
const error = new Error('Network is not available') as CustomError; const error = new Error('Network is not available') as CustomError;
error.name = 'SendMessageNetworkError'; error.name = 'SendMessageNetworkError';
error.identifier = contact.get('id'); error.identifier = conversationId;
return error; return error;
}); });
await message.saveErrors(errors); await message.saveErrors([...errors]);
return null; return null;
} }
@ -3752,6 +3769,7 @@ export class ConversationModel extends window.Backbone
(previewMessage (previewMessage
? getMessagePropStatus( ? getMessagePropStatus(
previewMessage.attributes, previewMessage.attributes,
ourConversationId,
window.storage.get('read-receipt-setting', false) window.storage.get('read-receipt-setting', false)
) )
: null) || null, : null) || null,
@ -4032,9 +4050,6 @@ export class ConversationModel extends window.Backbone
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown) as MessageAttributesType); } as unknown) as MessageAttributesType);
if (isDirectConversation(this.attributes)) {
model.set({ destination: this.getSendTarget() });
}
const id = await window.Signal.Data.saveMessage(model.attributes, { const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}); });
@ -4127,9 +4142,6 @@ export class ConversationModel extends window.Backbone
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown) as MessageAttributesType); } as unknown) as MessageAttributesType);
if (isDirectConversation(this.attributes)) {
model.set({ destination: this.id });
}
const id = await window.Signal.Data.saveMessage(model.attributes, { const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}); });

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty } from 'lodash'; import { isEmpty, isEqual, noop, omit, union } from 'lodash';
import { import {
CustomError, CustomError,
GroupV1Update, GroupV1Update,
@ -12,12 +12,13 @@ import {
QuotedMessageType, QuotedMessageType,
WhatIsThis, WhatIsThis,
} from '../model-types.d'; } 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 { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { map, filter, find } from '../util/iterables';
import { isNotNil } from '../util/isNotNil';
import { ConversationModel } from './conversations'; import { ConversationModel } from './conversations';
import { MessageStatusType } from '../components/conversation/Message';
import { import {
OwnProps as SmartMessageDetailPropsType, OwnProps as SmartMessageDetailPropsType,
Contact as SmartMessageDetailContact, Contact as SmartMessageDetailContact,
@ -38,6 +39,18 @@ import * as Stickers from '../types/Stickers';
import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { MIMEType, IMAGE_WEBP } from '../types/MIME'; import { MIMEType, IMAGE_WEBP } from '../types/MIME';
import { ourProfileKeyService } from '../services/ourProfileKey'; 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 { markRead } from '../services/MessageUpdater';
import { import {
isDirectConversation, isDirectConversation,
@ -121,9 +134,6 @@ const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
const { addStickerPackReference, getMessageBySender } = window.Signal.Data; const { addStickerPackReference, getMessageBySender } = window.Signal.Data;
const { bytesFromString } = window.Signal.Crypto; const { bytesFromString } = window.Signal.Crypto;
const includesAny = <T>(haystack: Array<T>, ...needles: Array<T>) =>
needles.some(needle => haystack.includes(needle));
export function isQuoteAMatch( export function isQuoteAMatch(
message: MessageModel | null | undefined, message: MessageModel | null | undefined,
conversationId: string, conversationId: string,
@ -146,6 +156,8 @@ export function isQuoteAMatch(
); );
} }
const isCustomError = (e: unknown): e is CustomError => e instanceof Error;
export class MessageModel extends window.Backbone.Model<MessageAttributesType> { export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
static updateTimers: () => void; static updateTimers: () => void;
@ -179,6 +191,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
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.CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT;
this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL;
this.OUR_NUMBER = window.textsecure.storage.user.getNumber(); this.OUR_NUMBER = window.textsecure.storage.user.getNumber();
@ -240,35 +263,41 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
getPropsForMessageDetail(): PropsForMessageDetail { getPropsForMessageDetail(ourConversationId: string): PropsForMessageDetail {
const newIdentity = window.i18n('newIdentity'); const newIdentity = window.i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
const unidentifiedLookup = ( const sendStateByConversationId =
this.get('unidentifiedDeliveries') || [] this.get('sendStateByConversationId') || {};
).reduce((accumulator: Record<string, boolean>, identifier: string) => {
accumulator[
window.ConversationController.getConversationId(identifier) as string
] = true;
return accumulator;
}, Object.create(null) as Record<string, boolean>);
// We include numbers we didn't successfully send to so we can display errors. const unidentifiedDeliveries = this.get('unidentifiedDeliveries') || [];
// Older messages don't have the recipients included on the message, so we fall const unidentifiedDeliveriesSet = new Set(
// back to the conversation's current recipients map(
unidentifiedDeliveries,
identifier =>
window.ConversationController.getConversationId(identifier) as string
)
);
let conversationIds: Array<string>;
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
const conversationIds = isIncoming(this.attributes) if (isIncoming(this.attributes)) {
? [this.getContactId()!] conversationIds = [this.getContactId()!];
: _.union( } else if (!isEmpty(sendStateByConversationId)) {
(this.get('sent_to') || []).map( if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
(id: string) => window.ConversationController.getConversationId(id)! conversationIds = [ourConversationId];
), } else {
( conversationIds = Object.keys(sendStateByConversationId).filter(
this.get('recipients') || this.getConversation()!.getRecipients() id => id !== ourConversationId
).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 */ /* eslint-enable @typescript-eslint/no-non-null-assertion */
// This will make the error message for outgoing key errors a bit nicer // This will make the error message for outgoing key errors a bit nicer
@ -294,9 +323,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.getConversationId(identifier); return window.ConversationController.getConversationId(identifier);
}); });
const finalContacts: Array<SmartMessageDetailContact> = ( const finalContacts: Array<SmartMessageDetailContact> = conversationIds.map(
conversationIds || []
).map(
(id: string): SmartMessageDetailContact => { (id: string): SmartMessageDetailContact => {
const errorsForContact = errorsGroupedById[id]; const errorsForContact = errorsGroupedById[id];
const isOutgoingKeyError = Boolean( const isOutgoingKeyError = Boolean(
@ -304,12 +331,19 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
const isUnidentifiedDelivery = const isUnidentifiedDelivery =
window.storage.get('unidentifiedDeliveryIndicators', false) && window.storage.get('unidentifiedDeliveryIndicators', false) &&
this.isUnidentifiedDelivery(id, unidentifiedLookup); this.isUnidentifiedDelivery(id, unidentifiedDeliveriesSet);
let status = getOwn(sendStateByConversationId, id)?.status || null;
// If a message was only sent to yourself (Note to Self or a lonely group), it
// is shown read.
if (id === ourConversationId && status && isSent(status)) {
status = SendStatus.Read;
}
return { return {
...findAndFormatContact(id), ...findAndFormatContact(id),
status,
status: this.getStatus(id),
errors: errorsForContact, errors: errorsForContact,
isOutgoingKeyError, isOutgoingKeyError,
isUnidentifiedDelivery, isUnidentifiedDelivery,
@ -344,7 +378,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
message: getPropsForMessage( message: getPropsForMessage(
this.attributes, this.attributes,
findAndFormatContact, findAndFormatContact,
window.ConversationController.getOurConversationIdOrThrow(), ourConversationId,
this.OUR_NUMBER, this.OUR_NUMBER,
this.OUR_UUID, this.OUR_UUID,
undefined, undefined,
@ -367,33 +401,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.get(this.get('conversationId')); 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 } { getNotificationData(): { emoji?: string; text: string } {
const { attributes } = this; const { attributes } = this;
@ -1056,13 +1063,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
isUnidentifiedDelivery( isUnidentifiedDelivery(
contactId: string, contactId: string,
lookup: Record<string, unknown> unidentifiedDeliveriesSet: Readonly<Set<string>>
): boolean { ): boolean {
if (isIncoming(this.attributes)) { if (isIncoming(this.attributes)) {
return Boolean(this.get('unidentifiedDeliveryReceived')); return Boolean(this.get('unidentifiedDeliveryReceived'));
} }
return Boolean(lookup[contactId]); return unidentifiedDeliveriesSet.has(contactId);
} }
getSource(): string | undefined { getSource(): string | undefined {
@ -1203,44 +1210,64 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!; const conversation = this.getConversation()!;
const exists = (v: string | null): v is string => Boolean(v); const currentRecipients = new Set<string>(
const intendedRecipients = (this.get('recipients') || []) conversation
.map(identifier => .getRecipients()
window.ConversationController.getConversationId(identifier) .map(identifier =>
) window.ConversationController.getConversationId(identifier)
.filter(exists); )
const successfulRecipients = (this.get('sent_to') || []) .filter(isNotNil)
.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') const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get() ? await ourProfileKeyService.get()
: undefined; : undefined;
// Determine retry recipients and get their most up-to-date addressing information // Determine retry recipients and get their most up-to-date addressing information
let recipients = _.intersection(intendedRecipients, currentRecipients); const oldSendStateByConversationId =
recipients = _.without(recipients, ...successfulRecipients) this.get('sendStateByConversationId') || {};
.map(id => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const recipients: Array<string> = [];
const c = window.ConversationController.get(id)!; const newSendStateByConversationId = { ...oldSendStateByConversationId };
return c.getSendTarget(); // eslint-disable-next-line no-restricted-syntax
}) for (const [conversationId, sendState] of Object.entries(
.filter((recipient): recipient is string => recipient !== undefined); 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,
});
if (!recipients.length) { if (!recipients.length) {
window.log.warn('retrySend: Nobody to send to!'); 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( const attachmentsWithData = await Promise.all(
@ -1366,12 +1393,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
public hasSuccessfulDelivery(): boolean { public hasSuccessfulDelivery(): boolean {
const recipients = this.get('recipients') || []; const sendStateByConversationId = this.get('sendStateByConversationId');
if (recipients.length === 0) { const withoutMe = omit(
return true; sendStateByConversationId,
} window.ConversationController.getOurConversationIdOrThrow()
);
return (this.get('sent_to') || []).length !== 0; return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent);
} }
// Called when the user ran into an error with a specific user, wants to send to them // Called when the user ran into an error with a specific user, wants to send to them
@ -1507,154 +1534,184 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async send( async send(
promise: Promise<CallbackResultType | void | null> promise: Promise<CallbackResultType | void | null>
): Promise<void | Array<void>> { ): Promise<void | Array<void>> {
const conversation = this.getConversation(); const updateLeftPane =
const updateLeftPane = conversation?.debouncedUpdateLastMessage; this.getConversation()?.debouncedUpdateLastMessage || noop;
if (updateLeftPane) {
updateLeftPane(); updateLeftPane();
let result:
| { success: true; value: CallbackResultType }
| {
success: false;
value: CustomError | CallbackResultType;
};
try {
const value = await (promise as Promise<CallbackResultType>);
result = { success: true, value };
} catch (err) {
result = { success: false, value: err };
} }
return (promise as Promise<CallbackResultType>) updateLeftPane();
.then(async result => {
if (updateLeftPane) {
updateLeftPane();
}
// This is used by sendSyncMessage, then set to null const attributesToUpdate: Partial<MessageAttributesType> = {};
if (result.dataMessage) {
this.set({ dataMessage: result.dataMessage });
}
const sentTo = this.get('sent_to') || []; // This is used by sendSyncMessage, then set to null
this.set({ if ('dataMessage' in result.value && result.value.dataMessage) {
sent_to: _.union(sentTo, result.successfulIdentifiers), attributesToUpdate.dataMessage = result.value.dataMessage;
sent: true, }
expirationStartTimestamp: Date.now(),
unidentifiedDeliveries: _.union(
this.get('unidentifiedDeliveries') || [],
result.unidentifiedDeliveries
),
});
if (!this.doNotSave) { const sendStateByConversationId = {
await window.Signal.Data.saveMessage(this.attributes, { ...(this.get('sendStateByConversationId') || {}),
Message: window.Whisper.Message, };
});
}
if (updateLeftPane) { const successfulIdentifiers: Array<string> =
updateLeftPane(); 'successfulIdentifiers' in result.value &&
} Array.isArray(result.value.successfulIdentifiers)
this.sendSyncMessage(); ? result.value.successfulIdentifiers
}) : [];
.catch((result: CustomError | CallbackResultType) => { const sentToAtLeastOneRecipient =
if (updateLeftPane) { result.success || Boolean(successfulIdentifiers.length);
updateLeftPane();
}
if ('dataMessage' in result && result.dataMessage) { successfulIdentifiers.forEach(identifier => {
this.set({ dataMessage: result.dataMessage }); const conversation = window.ConversationController.get(identifier);
} if (!conversation) {
return;
}
let promises = []; // If we successfully sent to a user, we can remove our unregistered flag.
if (conversation.isEverUnregistered()) {
conversation.setRegistered();
}
// If we successfully sent to a user, we can remove our unregistered flag. const previousSendState = getOwn(
let successfulIdentifiers: Array<string>; sendStateByConversationId,
if ('successfulIdentifiers' in result) { conversation.id
({ successfulIdentifiers = [] } = result); );
} else { if (previousSendState) {
successfulIdentifiers = []; sendStateByConversationId[conversation.id] = sendStateReducer(
} previousSendState,
successfulIdentifiers.forEach((identifier: string) => { {
const c = window.ConversationController.get(identifier); type: SendActionType.Sent,
if (c && c.isEverUnregistered()) { updatedAt: Date.now(),
c.setRegistered();
} }
}); );
}
});
const isError = (e: unknown): e is CustomError => e instanceof Error; const previousUnidentifiedDeliveries =
this.get('unidentifiedDeliveries') || [];
const newUnidentifiedDeliveries =
'unidentifiedDeliveries' in result.value &&
Array.isArray(result.value.unidentifiedDeliveries)
? result.value.unidentifiedDeliveries
: [];
if (isError(result)) { const promises: Array<Promise<unknown>> = [];
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') || [];
// If we just found out that we couldn't send to a user because they are no let errors: Array<CustomError>;
// longer registered, we will update our unregistered flag. In groups we if (isCustomError(result.value)) {
// will not event try to send to them for 6 hours. And we will never try errors = [result.value];
// to fetch them on startup again. } else if (Array.isArray(result.value.errors)) {
// The way to discover registration once more is: ({ errors } = result.value);
// 1) any attempt to send to them in 1:1 conversation } else {
// 2) the six-hour time period has passed and we send in a group again errors = [];
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 // In groups, we don't treat unregistered users as a user-visible
// error. The message will look successful, but the details // error. The message will look successful, but the details
// screen will show that we didn't send to these unregistered users. // screen will show that we didn't send to these unregistered users.
const filteredErrors = _.reject( const errorsToSave: Array<CustomError> = [];
result.errors,
error => error.name === 'UnregisteredUserError'
);
// We don't start the expiration timer if there are real errors let hadSignedPreKeyRotationError = false;
// left after filtering out all of the unregistered user errors. errors.forEach(error => {
const expirationStartTimestamp = filteredErrors.length const conversation =
? null window.ConversationController.get(error.identifier) ||
: Date.now(); window.ConversationController.get(error.number);
this.saveErrors(filteredErrors); if (conversation) {
const previousSendState = getOwn(
this.set({ sendStateByConversationId,
sent_to: _.union(sentTo, result.successfulIdentifiers), conversation.id
sent: true, );
expirationStartTimestamp, if (previousSendState) {
unidentifiedDeliveries: _.union( sendStateByConversationId[conversation.id] = sendStateReducer(
this.get('unidentifiedDeliveries') || [], previousSendState,
result.unidentifiedDeliveries {
), type: SendActionType.Failed,
}); updatedAt: Date.now(),
promises.push(this.sendSyncMessage()); }
} else if (result.errors) {
this.saveErrors(result.errors);
}
promises = promises.concat(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
_.map(result.errors, error => {
if (error.name === 'OutgoingIdentityKeyError') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const c = window.ConversationController.get(
error.identifier || error.number
)!;
promises.push(c.getProfiles());
}
})
); );
} }
}
if (updateLeftPane) { let shouldSaveError = true;
updateLeftPane(); switch (error.name) {
case 'SignedPreKeyRotationError':
hadSignedPreKeyRotationError = true;
break;
case 'OutgoingIdentityKeyError': {
if (conversation) {
promises.push(conversation.getProfiles());
}
break;
} }
case 'UnregisteredUserError':
shouldSaveError = false;
// If we just found out that we couldn't send to a user because they are no
// longer registered, we will update our unregistered flag. In groups we
// will not event try to send to them for 6 hours. And we will never try
// to fetch them on startup again.
//
// The way to discover registration once more is:
// 1) any attempt to send to them in 1:1 conversation
// 2) the six-hour time period has passed and we send in a group again
conversation?.setUnregistered();
break;
default:
break;
}
return Promise.all(promises); if (shouldSaveError) {
errorsToSave.push(error);
}
});
if (hadSignedPreKeyRotationError) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
promises.push(window.getAccountManager()!.rotateSignedPreKey());
}
attributesToUpdate.sendStateByConversationId = sendStateByConversationId;
attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient
? Date.now()
: undefined;
attributesToUpdate.unidentifiedDeliveries = union(
previousUnidentifiedDeliveries,
newUnidentifiedDeliveries
);
// We may overwrite this in the `saveErrors` call below.
attributesToUpdate.errors = [];
this.set(attributesToUpdate);
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
if (!this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes, {
Message: window.Whisper.Message,
}); });
}
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 // Currently used only for messages that have to be retried when the server
@ -1681,12 +1738,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const sendOptions = await getSendOptions(conv.attributes); 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( await this.send(
handleMessageSend( handleMessageSend(
// TODO: DESKTOP-724 // TODO: DESKTOP-724
@ -1718,23 +1769,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const updateLeftPane = conv?.debouncedUpdateLastMessage; const updateLeftPane = conv?.debouncedUpdateLastMessage;
try { try {
this.set({ this.set({ expirationStartTimestamp: Date.now() });
// 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(); const result: typeof window.WhatIsThis = await this.sendSyncMessage();
this.set({ this.set({
// We have to do this afterward, since we didn't have a previous send! // We have to do this afterward, since we didn't have a previous send!
unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, 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) { } catch (result) {
const errors = (result && result.errors) || [new Error('Unknown error')]; const errors = (result && result.errors) || [new Error('Unknown error')];
@ -1754,6 +1793,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async sendSyncMessage(): Promise<WhatIsThis> { async sendSyncMessage(): Promise<WhatIsThis> {
const ourNumber = window.textsecure.storage.user.getNumber(); const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid(); const ourUuid = window.textsecure.storage.user.getUuid();
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const { const {
wrap, wrap,
sendOptions, sendOptions,
@ -1774,6 +1815,34 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = this.getConversation()!; 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( return wrap(
window.textsecure.messaging.sendSyncMessage({ window.textsecure.messaging.sendSyncMessage({
encodedDataMessage: dataMessage, encodedDataMessage: dataMessage,
@ -1782,15 +1851,38 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
destinationUuid: conv.get('uuid'), destinationUuid: conv.get('uuid'),
expirationStartTimestamp: expirationStartTimestamp:
this.get('expirationStartTimestamp') || null, this.get('expirationStartTimestamp') || null,
sentTo: this.get('sent_to') || [], conversationIdsSentTo,
unidentifiedDeliveries: this.get('unidentifiedDeliveries') || [], conversationIdsWithSealedSender,
isUpdate, isUpdate,
options: sendOptions, options: sendOptions,
}) })
).then(async (result: unknown) => { ).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({ this.set({
synced: true, synced: true,
dataMessage: null, dataMessage: null,
...(newSendStateByConversationId
? { sendStateByConversationId: newSendStateByConversationId }
: {}),
}); });
// Return early, skip the save // Return early, skip the save
@ -2455,29 +2547,66 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`handleDataMessage: Updating message ${message.idForLogging()} with received transcript` `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( const toUpdate = window.MessageController.register(
existingMessage.id, existingMessage.id,
existingMessage existingMessage
); );
const unidentifiedDeliveriesSet = new Set<string>(
toUpdate.get('unidentifiedDeliveries') ?? []
);
const sendStateByConversationId = {
...(toUpdate.get('sendStateByConversationId') || {}),
};
const unidentifiedStatus: Array<SyncMessageClass.Sent.UnidentifiedDeliveryStatus> = 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({ toUpdate.set({
sent_to: _.union(toUpdate.get('sent_to'), sentTo), sendStateByConversationId,
unidentifiedDeliveries: _.union( unidentifiedDeliveries: [...unidentifiedDeliveriesSet],
toUpdate.get('unidentifiedDeliveries'),
unidentifiedDeliveries
),
}); });
await window.Signal.Data.saveMessage(toUpdate.attributes, { await window.Signal.Data.saveMessage(toUpdate.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
@ -3083,19 +3212,62 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let changed = false; let changed = false;
if (type === 'outgoing') { if (type === 'outgoing') {
const receipts = DeliveryReceipts.getSingleton().forMessage( const sendActions = concat<{
conversation, destinationConversationId: string;
message action: SendAction;
}>(
DeliveryReceipts.getSingleton()
.forMessage(conversation, message)
.map(receipt => ({
destinationConversationId: receipt.get('deliveredTo'),
action: {
type: SendActionType.GotDeliveryReceipt,
updatedAt: receipt.get('timestamp'),
},
})),
ReadReceipts.getSingleton()
.forMessage(conversation, message)
.map(receipt => ({
destinationConversationId: receipt.get('reader'),
action: {
type: SendActionType.GotReadReceipt,
updatedAt: receipt.get('timestamp'),
},
}))
); );
receipts.forEach(receipt => {
message.set({ const oldSendStateByConversationId =
delivered: (message.get('delivered') || 0) + 1, this.get('sendStateByConversationId') || {};
delivered_to: _.union(message.get('delivered_to') || [], [
receipt.get('deliveredTo'), const newSendStateByConversationId = reduce(
]), sendActions,
}); (
result: SendStateByConversationId,
{ destinationConversationId, action }
) => {
const oldSendState = getOwn(result, destinationConversationId);
if (!oldSendState) {
window.log.warn(
`Got a receipt for a conversation (${destinationConversationId}), but we have no record of sending to them`
);
return result;
}
const newSendState = sendStateReducer(oldSendState, action);
return {
...result,
[destinationConversationId]: newSendState,
};
},
oldSendStateByConversationId
);
if (
!isEqual(oldSendStateByConversationId, newSendStateByConversationId)
) {
message.set('sendStateByConversationId', newSendStateByConversationId);
changed = true; changed = true;
}); }
} }
if (type === 'incoming') { if (type === 'incoming') {
@ -3128,34 +3300,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
} }
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 // Check for out-of-order view syncs
if (type === 'incoming' && isTapToView(message.attributes)) { if (type === 'incoming' && isTapToView(message.attributes)) {
const viewSync = ViewSyncs.getSingleton().forMessage(message); const viewSync = ViewSyncs.getSingleton().forMessage(message);
@ -3211,6 +3355,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
(isIncoming(attributes) || (isIncoming(attributes) ||
getMessagePropStatus( getMessagePropStatus(
attributes, attributes,
window.ConversationController.getOurConversationIdOrThrow(),
window.storage.get('read-receipt-setting', false) window.storage.get('read-receipt-setting', false)
) !== 'partial-sent') ) !== 'partial-sent')
) { ) {

View file

@ -48,7 +48,6 @@ import {
ItemKeyType, ItemKeyType,
ItemType, ItemType,
MessageType, MessageType,
MessageTypeUnhydrated,
PreKeyType, PreKeyType,
SearchResultMessageType, SearchResultMessageType,
SenderKeyType, SenderKeyType,
@ -62,8 +61,10 @@ import {
UnprocessedUpdateType, UnprocessedUpdateType,
} from './Interface'; } from './Interface';
import Server from './Server'; import Server from './Server';
import { MessageRowWithJoinedSends, rowToMessage } from './rowToMessage';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { ConversationModel } from '../models/conversations'; 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 // 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. // any warnings that might be sent to the console in that case.
@ -201,6 +202,8 @@ const dataInterface: ClientInterface = {
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
updateMessageSendState,
getUnprocessedCount, getUnprocessedCount,
getAllUnprocessed, getAllUnprocessed,
getUnprocessedById, getUnprocessedById,
@ -248,6 +251,7 @@ const dataInterface: ClientInterface = {
// Test-only // Test-only
_getAllMessages, _getAllMessages,
_getSendStates,
// Client-side only // Client-side only
@ -941,17 +945,17 @@ async function searchConversations(query: string) {
return conversations; return conversations;
} }
function handleSearchMessageJSON( function handleSearchMessageRows(
messages: Array<SearchResultMessageType> rows: ReadonlyArray<SearchResultMessageType>
): Array<ClientSearchResultMessageType> { ): Array<ClientSearchResultMessageType> {
return messages.map(message => ({ return rows.map(row => ({
json: message.json, json: row.json,
// Empty array is a default value. `message.json` has the real field // Empty array is a default value. `message.json` has the real field
bodyRanges: [], bodyRanges: [],
...JSON.parse(message.json), ...rowToMessage(row),
snippet: message.snippet, snippet: row.snippet,
})); }));
} }
@ -961,7 +965,7 @@ async function searchMessages(
) { ) {
const messages = await channels.searchMessages(query, { limit }); const messages = await channels.searchMessages(query, { limit });
return handleSearchMessageJSON(messages); return handleSearchMessageRows(messages);
} }
async function searchMessagesInConversation( async function searchMessagesInConversation(
@ -975,7 +979,7 @@ async function searchMessagesInConversation(
{ limit } { limit }
); );
return handleSearchMessageJSON(messages); return handleSearchMessageRows(messages);
} }
// Message // Message
@ -1053,6 +1057,16 @@ async function _getAllMessages({
return new MessageCollection(messages); return new MessageCollection(messages);
} }
// For testing only
function _getSendStates(
options: Readonly<{
messageId: string;
destinationConversationId: string;
}>
) {
return channels._getSendStates(options);
}
async function getAllMessageIds() { async function getAllMessageIds() {
const ids = await channels.getAllMessageIds(); const ids = await channels.getAllMessageIds();
@ -1129,8 +1143,10 @@ async function addReaction(reactionObj: ReactionType) {
return channels.addReaction(reactionObj); return channels.addReaction(reactionObj);
} }
function handleMessageJSON(messages: Array<MessageTypeUnhydrated>) { function handleMessageRows(
return messages.map(message => JSON.parse(message.json)); rows: ReadonlyArray<MessageRowWithJoinedSends>
): Array<MessageType> {
return rows.map(row => rowToMessage(row));
} }
async function getOlderMessagesByConversation( async function getOlderMessagesByConversation(
@ -1159,7 +1175,7 @@ async function getOlderMessagesByConversation(
} }
); );
return new MessageCollection(handleMessageJSON(messages)); return new MessageCollection(handleMessageRows(messages));
} }
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(
conversationId: string, conversationId: string,
@ -1184,7 +1200,7 @@ async function getNewerMessagesByConversation(
} }
); );
return new MessageCollection(handleMessageJSON(messages)); return new MessageCollection(handleMessageRows(messages));
} }
async function getLastConversationActivity({ async function getLastConversationActivity({
conversationId, conversationId,
@ -1242,6 +1258,17 @@ async function migrateConversationMessages(
await channels.migrateConversationMessages(obsoleteId, currentId); await channels.migrateConversationMessages(obsoleteId, currentId);
} }
async function updateMessageSendState(
params: Readonly<
{
messageId: string;
destinationConversationId: string;
} & SendState
>
): Promise<void> {
await channels.updateMessageSendState(params);
}
async function removeAllMessagesInConversation( async function removeAllMessagesInConversation(
conversationId: string, conversationId: string,
{ {

View file

@ -15,8 +15,11 @@ import type { ConversationModel } from '../models/conversations';
import type { StoredJob } from '../jobs/types'; import type { StoredJob } from '../jobs/types';
import type { ReactionType } from '../types/Reactions'; import type { ReactionType } from '../types/Reactions';
import type { ConversationColorType, CustomColorType } from '../types/Colors'; import type { ConversationColorType, CustomColorType } from '../types/Colors';
import type { BodyRangesType } from '../types/Util';
import { StorageAccessType } from '../types/Storage.d'; import { StorageAccessType } from '../types/Storage.d';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { SendState } from '../messages/MessageSendState';
import type { MessageRowWithJoinedSends } from './rowToMessage';
export type AttachmentDownloadJobTypeType = export type AttachmentDownloadJobTypeType =
| 'long-message' | 'long-message'
@ -69,21 +72,17 @@ export type ItemType<K extends ItemKeyType> = {
value: StorageAccessType[K]; value: StorageAccessType[K];
}; };
export type MessageType = MessageAttributesType; export type MessageType = MessageAttributesType;
export type MessageTypeUnhydrated = {
json: string;
};
export type PreKeyType = { export type PreKeyType = {
id: number; id: number;
privateKey: ArrayBuffer; privateKey: ArrayBuffer;
publicKey: ArrayBuffer; publicKey: ArrayBuffer;
}; };
export type SearchResultMessageType = { export type SearchResultMessageType = MessageRowWithJoinedSends & {
json: string;
snippet: string; snippet: string;
}; };
export type ClientSearchResultMessageType = MessageType & { export type ClientSearchResultMessageType = MessageType & {
json: string; json: string;
bodyRanges: []; bodyRanges: BodyRangesType;
snippet: string; snippet: string;
}; };
export type SenderKeyType = { export type SenderKeyType = {
@ -255,6 +254,15 @@ export type DataInterface = {
) => Promise<void>; ) => Promise<void>;
getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>; getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>;
updateMessageSendState(
params: Readonly<
{
messageId: string;
destinationConversationId: string;
} & SendState
>
): Promise<void>;
getUnprocessedCount: () => Promise<number>; getUnprocessedCount: () => Promise<number>;
getAllUnprocessed: () => Promise<Array<UnprocessedType>>; getAllUnprocessed: () => Promise<Array<UnprocessedType>>;
updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>; updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>;
@ -345,6 +353,20 @@ export type DataInterface = {
value: CustomColorType; value: CustomColorType;
} }
) => Promise<void>; ) => Promise<void>;
// 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 // The reason for client/server divergence is the need to inject Backbone models and
@ -377,11 +399,11 @@ export type ServerInterface = DataInterface & {
sentAt?: number; sentAt?: number;
messageId?: string; messageId?: string;
} }
) => Promise<Array<MessageTypeUnhydrated>>; ) => Promise<Array<MessageRowWithJoinedSends>>;
getNewerMessagesByConversation: ( getNewerMessagesByConversation: (
conversationId: string, conversationId: string,
options?: { limit?: number; receivedAt?: number; sentAt?: number } options?: { limit?: number; receivedAt?: number; sentAt?: number }
) => Promise<Array<MessageTypeUnhydrated>>; ) => Promise<Array<MessageRowWithJoinedSends>>;
getLastConversationActivity: (options: { getLastConversationActivity: (options: {
conversationId: string; conversationId: string;
ourConversationId: string; ourConversationId: string;

View file

@ -48,7 +48,6 @@ import {
ItemKeyType, ItemKeyType,
ItemType, ItemType,
MessageType, MessageType,
MessageTypeUnhydrated,
MessageMetricsType, MessageMetricsType,
PreKeyType, PreKeyType,
SearchResultMessageType, SearchResultMessageType,
@ -62,6 +61,11 @@ import {
UnprocessedType, UnprocessedType,
UnprocessedUpdateType, UnprocessedUpdateType,
} from './Interface'; } from './Interface';
import {
SendState,
serializeSendStateForDatabase,
} from '../messages/MessageSendState';
import { MessageRowWithJoinedSends, rowToMessage } from './rowToMessage';
declare global { declare global {
// We want to extend `Function`'s properties, so we need to use an interface. // We want to extend `Function`'s properties, so we need to use an interface.
@ -179,6 +183,7 @@ const dataInterface: ServerInterface = {
getMessageBySender, getMessageBySender,
getMessageById, getMessageById,
_getAllMessages, _getAllMessages,
_getSendStates,
getAllMessageIds, getAllMessageIds,
getMessagesBySentAt, getMessagesBySentAt,
getExpiredMessages, getExpiredMessages,
@ -194,6 +199,8 @@ const dataInterface: ServerInterface = {
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
updateMessageSendState,
getUnprocessedCount, getUnprocessedCount,
getAllUnprocessed, getAllUnprocessed,
updateUnprocessedAttempts, updateUnprocessedAttempts,
@ -279,6 +286,7 @@ function objectToJSON(data: any) {
function jsonToObject(json: string): any { function jsonToObject(json: string): any {
return JSON.parse(json); return JSON.parse(json);
} }
function rowToConversation(row: ConversationRow): ConversationType { function rowToConversation(row: ConversationRow): ConversationType {
const parsedJson = JSON.parse(row.json); const parsedJson = JSON.parse(row.json);
@ -298,6 +306,7 @@ function rowToConversation(row: ConversationRow): ConversationType {
profileLastFetchedAt, profileLastFetchedAt,
}; };
} }
function rowToSticker(row: StickerRow): StickerType { function rowToSticker(row: StickerRow): StickerType {
return { return {
...row, ...row,
@ -1937,6 +1946,56 @@ function updateToSchemaVersion35(currentVersion: number, db: Database) {
console.log('updateToSchemaVersion35: success!'); 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 = [ const SCHEMA_VERSIONS = [
updateToSchemaVersion1, updateToSchemaVersion1,
updateToSchemaVersion2, updateToSchemaVersion2,
@ -1973,6 +2032,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion33, updateToSchemaVersion33,
updateToSchemaVersion34, updateToSchemaVersion34,
updateToSchemaVersion35, updateToSchemaVersion35,
updateToSchemaVersion36,
]; ];
function updateSchema(db: Database): void { function updateSchema(db: Database): void {
@ -2984,21 +3044,24 @@ async function searchMessages(
// give us the right results. We can't call `snippet()` in the query above // 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 // 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. // to keep its size minimal for `ORDER BY` + `LIMIT` to be fast.
const result = db const result: Array<SearchResultMessageType> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT SELECT
messages.json, messagesWithSendStates.json,
messagesWithSendStates.sendConversationIdsJoined,
messagesWithSendStates.sendStatusesJoined,
messagesWithSendStates.sendUpdatedAtsJoined,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 10) snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 10)
AS snippet AS snippet
FROM tmp_filtered_results FROM tmp_filtered_results
INNER JOIN messages_fts INNER JOIN messages_fts
ON messages_fts.rowid = tmp_filtered_results.rowid ON messages_fts.rowid = tmp_filtered_results.rowid
INNER JOIN messages INNER JOIN messagesWithSendStates
ON messages.rowid = tmp_filtered_results.rowid ON messagesWithSendStates.rowid = tmp_filtered_results.rowid
WHERE WHERE
messages_fts.body MATCH $query messages_fts.body MATCH $query
ORDER BY messages.received_at DESC, messages.sent_at DESC; ORDER BY messagesWithSendStates.received_at DESC, messagesWithSendStates.sent_at DESC;
` `
) )
.all({ query }); .all({ query });
@ -3125,9 +3188,11 @@ function saveMessageSync(
expirationStartTimestamp, expirationStartTimestamp,
} = data; } = data;
const { sendStateByConversationId, ...dataToSaveInJsonField } = data;
const payload = { const payload = {
id, id,
json: objectToJSON(data), json: objectToJSON(dataToSaveInJsonField),
body: body || null, body: body || null,
conversationId, conversationId,
@ -3149,6 +3214,8 @@ function saveMessageSync(
unread: unread ? 1 : 0, unread: unread ? 1 : 0,
}; };
let messageId: string;
if (id && !forceSave) { if (id && !forceSave) {
prepare( prepare(
db, db,
@ -3179,70 +3246,94 @@ function saveMessageSync(
` `
).run(payload); ).run(payload);
return id; 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),
});
} }
const toCreate = { if (sendStateByConversationId) {
...data, const upsertSendStateStmt = prepare(
id: id || generateUUID(), db,
}; `
INSERT OR REPLACE INTO sendStates
prepare( (messageId, destinationConversationId, updatedAt, status) VALUES
db, ($messageId, $destinationConversationId, $updatedAt, $status);
` `
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
); );
` Object.entries(sendStateByConversationId).forEach(
).run({ ([destinationConversationId, sendState]) => {
...payload, upsertSendStateStmt.run(
id: toCreate.id, serializeSendStateForDatabase({
json: objectToJSON(toCreate), messageId,
}); destinationConversationId,
...sendState,
})
);
}
);
}
return toCreate.id; return messageId;
} }
async function saveMessage( async function saveMessage(
@ -3290,8 +3381,18 @@ async function removeMessages(ids: Array<string>): Promise<void> {
async function getMessageById(id: string): Promise<MessageType | undefined> { async function getMessageById(id: string): Promise<MessageType | undefined> {
const db = getInstance(); const db = getInstance();
const row = db const row: null | MessageRowWithJoinedSends = db
.prepare<Query>('SELECT json FROM messages WHERE id = $id;') .prepare<Query>(
`
SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE id = $id;
`
)
.get({ .get({
id, id,
}); });
@ -3300,16 +3401,45 @@ async function getMessageById(id: string): Promise<MessageType | undefined> {
return undefined; return undefined;
} }
return jsonToObject(row.json); return rowToMessage(row);
} }
async function _getAllMessages(): Promise<Array<MessageType>> { async function _getAllMessages(): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<EmptyQuery>('SELECT json FROM messages ORDER BY id ASC;') .prepare<EmptyQuery>(
`
SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
ORDER BY id asc;
`
)
.all(); .all();
return rows.map(row => jsonToObject(row.json)); 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 });
} }
async function getAllMessageIds(): Promise<Array<string>> { async function getAllMessageIds(): Promise<Array<string>> {
@ -3333,10 +3463,16 @@ async function getMessageBySender({
sent_at: number; sent_at: number;
}): Promise<Array<MessageType>> { }): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = prepare( const rows: Array<MessageRowWithJoinedSends> = prepare(
db, db,
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE
(source = $source OR sourceUuid = $sourceUuid) AND (source = $source OR sourceUuid = $sourceUuid) AND
sourceDevice = $sourceDevice AND sourceDevice = $sourceDevice AND
sent_at = $sent_at; sent_at = $sent_at;
@ -3348,7 +3484,7 @@ async function getMessageBySender({
sent_at, sent_at,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getUnreadCountForConversation( async function getUnreadCountForConversation(
@ -3614,15 +3750,21 @@ async function getOlderMessagesByConversation(
sentAt?: number; sentAt?: number;
messageId?: string; messageId?: string;
} = {} } = {}
): Promise<Array<MessageTypeUnhydrated>> { ): Promise<Array<MessageRowWithJoinedSends>> {
const db = getInstance(); const db = getInstance();
let rows: JSONRows; let rows: Array<MessageRowWithJoinedSends>;
if (messageId) { if (messageId) {
rows = db rows = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
id != $messageId AND id != $messageId AND
( (
@ -3644,7 +3786,12 @@ async function getOlderMessagesByConversation(
rows = db rows = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
( (
(received_at = $received_at AND sent_at < $sent_at) OR (received_at = $received_at AND sent_at < $sent_at) OR
@ -3672,12 +3819,17 @@ async function getNewerMessagesByConversation(
receivedAt = 0, receivedAt = 0,
sentAt = 0, sentAt = 0,
}: { limit?: number; receivedAt?: number; sentAt?: number } = {} }: { limit?: number; receivedAt?: number; sentAt?: number } = {}
): Promise<Array<MessageTypeUnhydrated>> { ): Promise<Array<MessageRowWithJoinedSends>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db return db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
( (
(received_at = $received_at AND sent_at > $sent_at) OR (received_at = $received_at AND sent_at > $sent_at) OR
@ -3693,9 +3845,8 @@ async function getNewerMessagesByConversation(
sent_at: sentAt, sent_at: sentAt,
limit, limit,
}); });
return rows;
} }
function getOldestMessageForConversation( function getOldestMessageForConversation(
conversationId: string conversationId: string
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
@ -3751,10 +3902,15 @@ async function getLastConversationActivity({
ourConversationId: string; ourConversationId: string;
}): Promise<MessageType | undefined> { }): Promise<MessageType | undefined> {
const db = getInstance(); const db = getInstance();
const row = prepare( const row: undefined | MessageRowWithJoinedSends = prepare(
db, db,
` `
SELECT json FROM messages SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
(type IS NULL (type IS NULL
@ -3792,7 +3948,7 @@ async function getLastConversationActivity({
return undefined; return undefined;
} }
return jsonToObject(row.json); return rowToMessage(row);
} }
async function getLastConversationPreview({ async function getLastConversationPreview({
conversationId, conversationId,
@ -3802,10 +3958,15 @@ async function getLastConversationPreview({
ourConversationId: string; ourConversationId: string;
}): Promise<MessageType | undefined> { }): Promise<MessageType | undefined> {
const db = getInstance(); const db = getInstance();
const row = prepare( const row: undefined | MessageRowWithJoinedSends = prepare(
db, db,
` `
SELECT json FROM messages SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
( (
@ -3838,8 +3999,9 @@ async function getLastConversationPreview({
return undefined; return undefined;
} }
return jsonToObject(row.json); return rowToMessage(row);
} }
function getOldestUnreadMessageForConversation( function getOldestUnreadMessageForConversation(
conversationId: string conversationId: string
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
@ -3953,14 +4115,38 @@ async function migrateConversationMessages(
}); });
} }
async function updateMessageSendState(
params: Readonly<
{
messageId: string;
destinationConversationId: string;
} & SendState
>
): Promise<void> {
const db = getInstance();
db.prepare<Query>(
`
INSERT OR REPLACE INTO sendStates
(messageId, destinationConversationId, updatedAt, status) VALUES
($messageId, $destinationConversationId, $updatedAt, $status);
`
).run(serializeSendStateForDatabase(params));
}
async function getMessagesBySentAt( async function getMessagesBySentAt(
sentAt: number sentAt: number
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE sent_at = $sent_at WHERE sent_at = $sent_at
ORDER BY received_at DESC, sent_at DESC; ORDER BY received_at DESC, sent_at DESC;
` `
@ -3969,17 +4155,23 @@ async function getMessagesBySentAt(
sent_at: sentAt, sent_at: sentAt,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getExpiredMessages(): Promise<Array<MessageType>> { async function getExpiredMessages(): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const now = Date.now(); const now = Date.now();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE
expiresAt IS NOT NULL AND expiresAt IS NOT NULL AND
expiresAt <= $now expiresAt <= $now
ORDER BY expiresAt ASC; ORDER BY expiresAt ASC;
@ -3987,18 +4179,22 @@ async function getExpiredMessages(): Promise<Array<MessageType>> {
) )
.all({ now }); .all({ now });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise< async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise<
Array<MessageType> Array<MessageType>
> { > {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<EmptyQuery>( .prepare<EmptyQuery>(
` `
SELECT json FROM messages SELECT
INDEXED BY messages_unexpectedly_missing_expiration_start_timestamp json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE WHERE
expireTimer > 0 AND expireTimer > 0 AND
expirationStartTimestamp IS NULL AND expirationStartTimestamp IS NULL AND
@ -4013,7 +4209,7 @@ async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise
) )
.all(); .all();
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getSoonestMessageExpiry(): Promise<undefined | number> { async function getSoonestMessageExpiry(): Promise<undefined | number> {
@ -4063,11 +4259,15 @@ async function getTapToViewMessagesNeedingErase(): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000; const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000;
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json SELECT
FROM messages json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE WHERE
isViewOnce = 1 isViewOnce = 1
AND (isErased IS NULL OR isErased != 1) AND (isErased IS NULL OR isErased != 1)
@ -4079,7 +4279,7 @@ async function getTapToViewMessagesNeedingErase(): Promise<Array<MessageType>> {
THIRTY_DAYS_AGO, THIRTY_DAYS_AGO,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
function saveUnprocessedSync(data: UnprocessedType): string { function saveUnprocessedSync(data: UnprocessedType): string {
@ -4913,6 +5113,7 @@ async function removeAll(): Promise<void> {
DELETE FROM sticker_packs; DELETE FROM sticker_packs;
DELETE FROM sticker_references; DELETE FROM sticker_references;
DELETE FROM jobs; DELETE FROM jobs;
DELETE FROM sendStates;
`); `);
})(); })();
} }
@ -4933,6 +5134,7 @@ async function removeAllConfiguration(): Promise<void> {
DELETE FROM signedPreKeys; DELETE FROM signedPreKeys;
DELETE FROM unprocessed; DELETE FROM unprocessed;
DELETE FROM jobs; DELETE FROM jobs;
DELETE FROM sendStates;
` `
); );
db.prepare('UPDATE conversations SET json = json_patch(json, $patch);').run( db.prepare('UPDATE conversations SET json = json_patch(json, $patch);').run(
@ -4948,11 +5150,15 @@ async function getMessagesNeedingUpgrade(
{ maxVersion }: { maxVersion: number } { maxVersion }: { maxVersion: number }
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json SELECT
FROM messages json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates
WHERE schemaVersion IS NULL OR schemaVersion < $maxVersion WHERE schemaVersion IS NULL OR schemaVersion < $maxVersion
LIMIT $limit; LIMIT $limit;
` `
@ -4962,7 +5168,7 @@ async function getMessagesNeedingUpgrade(
limit, limit,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getMessagesWithVisualMediaAttachments( async function getMessagesWithVisualMediaAttachments(
@ -4970,10 +5176,15 @@ async function getMessagesWithVisualMediaAttachments(
{ limit }: { limit: number } { limit }: { limit: number }
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
hasVisualMediaAttachments = 1 hasVisualMediaAttachments = 1
ORDER BY received_at DESC, sent_at DESC ORDER BY received_at DESC, sent_at DESC
@ -4985,7 +5196,7 @@ async function getMessagesWithVisualMediaAttachments(
limit, limit,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getMessagesWithFileAttachments( async function getMessagesWithFileAttachments(
@ -4993,10 +5204,15 @@ async function getMessagesWithFileAttachments(
{ limit }: { limit: number } { limit }: { limit: number }
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows = db const rows: Array<MessageRowWithJoinedSends> = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT
json,
sendConversationIdsJoined,
sendStatusesJoined,
sendUpdatedAtsJoined
FROM messagesWithSendStates WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
hasFileAttachments = 1 hasFileAttachments = 1
ORDER BY received_at DESC, sent_at DESC ORDER BY received_at DESC, sent_at DESC
@ -5008,7 +5224,7 @@ async function getMessagesWithFileAttachments(
limit, limit,
}); });
return map(rows, row => jsonToObject(row.json)); return rows.map(row => rowToMessage(row));
} }
async function getMessageServerGuidsForSpam( async function getMessageServerGuidsForSpam(

24
ts/sql/rowToMessage.ts Normal file
View file

@ -0,0 +1,24 @@
// 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<MessageRowWithJoinedSends>
): 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;
}

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, isObject, map, reduce } from 'lodash'; import { isNumber, isObject, map, omit, reduce } from 'lodash';
import filesize from 'filesize'; import filesize from 'filesize';
import { import {
@ -46,6 +46,15 @@ import {
GetConversationByIdType, GetConversationByIdType,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from './conversations'; } from './conversations';
import {
SendStatus,
isDelivered,
isMessageJustForMe,
isRead,
isSent,
maxStatus,
someSendStatus,
} from '../../messages/MessageSendState';
const THREE_HOURS = 3 * 60 * 60 * 1000; const THREE_HOURS = 3 * 60 * 60 * 1000;
@ -220,7 +229,9 @@ export function isOutgoing(
return message.type === 'outgoing'; return message.type === 'outgoing';
} }
export function hasErrors(message: MessageAttributesType): boolean { export function hasErrors(
message: Pick<MessageAttributesType, 'errors'>
): boolean {
return message.errors ? message.errors.length > 0 : false; return message.errors ? message.errors.length > 0 : false;
} }
@ -358,7 +369,7 @@ export function getPropsForMessage(
bodyRanges: processBodyRanges(message.bodyRanges, conversationSelector), bodyRanges: processBodyRanges(message.bodyRanges, conversationSelector),
canDeleteForEveryone: canDeleteForEveryone(message), canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector), canDownload: canDownload(message, conversationSelector),
canReply: canReply(message, conversationSelector), canReply: canReply(message, ourConversationId, conversationSelector),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector), contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
conversationColor: conversation?.conversationColor ?? ConversationColors[0], conversationColor: conversation?.conversationColor ?? ConversationColors[0],
conversationId: message.conversationId, conversationId: message.conversationId,
@ -382,7 +393,11 @@ export function getPropsForMessage(
quote: getPropsForQuote(message, conversationSelector, ourConversationId), quote: getPropsForQuote(message, conversationSelector, ourConversationId),
reactions, reactions,
selectedReaction, selectedReaction,
status: getMessagePropStatus(message, readReceiptSetting), status: getMessagePropStatus(
message,
ourConversationId,
readReceiptSetting
),
text: createNonBreakingLastSeparator(message.body), text: createNonBreakingLastSeparator(message.body),
textPending: message.bodyPending, textPending: message.bodyPending,
timestamp: message.sent_at, timestamp: message.sent_at,
@ -882,38 +897,54 @@ function createNonBreakingLastSeparator(text?: string): string {
} }
export function getMessagePropStatus( export function getMessagePropStatus(
message: MessageAttributesType, message: Pick<
MessageAttributesType,
'type' | 'errors' | 'sendStateByConversationId'
>,
ourConversationId: string,
readReceiptSetting: boolean readReceiptSetting: boolean
): LastMessageStatus | undefined { ): 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)) { if (!isOutgoing(message)) {
return undefined; return undefined;
} }
const readBy = message.read_by || []; if (getLastChallengeError(message)) {
if (readReceiptSetting && readBy.length > 0) { return 'paused';
return 'read';
}
const { delivered } = message;
const deliveredTo = message.delivered_to || [];
if (delivered || deliveredTo.length > 0) {
return 'delivered';
}
if (sent || sentTo.length > 0) {
return 'sent';
} }
const { sendStateByConversationId = {} } = message;
if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
const status =
sendStateByConversationId[ourConversationId]?.status ??
SendStatus.Pending;
const sent = isSent(status);
if (hasErrors(message)) {
return sent ? 'partial-sent' : 'error';
}
return sent ? 'read' : 'sending';
}
const sendStates = Object.values(
omit(sendStateByConversationId, ourConversationId)
);
const highestSuccessfulStatus = sendStates.reduce(
(result: SendStatus, { status }) => maxStatus(result, status),
SendStatus.Pending
);
if (hasErrors(message)) {
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
}
if (readReceiptSetting && isRead(highestSuccessfulStatus)) {
return 'read';
}
if (isDelivered(highestSuccessfulStatus)) {
return 'delivered';
}
if (isSent(highestSuccessfulStatus)) {
return 'sent';
}
return 'sending'; return 'sending';
} }
@ -1066,12 +1097,16 @@ function processQuoteAttachment(
export function canReply( export function canReply(
message: Pick< message: Pick<
MessageAttributesType, MessageAttributesType,
'conversationId' | 'deletedForEveryone' | 'sent_to' | 'type' | 'conversationId'
| 'deletedForEveryone'
| 'sendStateByConversationId'
| 'type'
>, >,
ourConversationId: string,
conversationSelector: GetConversationByIdType conversationSelector: GetConversationByIdType
): boolean { ): boolean {
const conversation = getConversation(message, conversationSelector); const conversation = getConversation(message, conversationSelector);
const { deletedForEveryone, sent_to: sentTo } = message; const { deletedForEveryone, sendStateByConversationId } = message;
if (!conversation) { if (!conversation) {
return false; return false;
@ -1100,7 +1135,10 @@ export function canReply(
// We can reply if this is outgoing and sent to at least one recipient // We can reply if this is outgoing and sent to at least one recipient
if (isOutgoing(message)) { if (isOutgoing(message)) {
return (sentTo || []).length > 0; return (
isMessageJustForMe(sendStateByConversationId, ourConversationId) ||
someSendStatus(omit(sendStateByConversationId, ourConversationId), isSent)
);
} }
// We can reply to incoming messages // We can reply to incoming messages
@ -1188,7 +1226,7 @@ export function getAttachmentsForMessage(
} }
export function getLastChallengeError( export function getLastChallengeError(
message: MessageAttributesType message: Pick<MessageAttributesType, 'errors'>
): ShallowChallengeError | undefined { ): ShallowChallengeError | undefined {
const { errors } = message; const { errors } = message;
if (!errors) { if (!errors) {

View file

@ -0,0 +1,564 @@
// 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,
}
);
});
});
});

View file

@ -0,0 +1,263 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { getDefaultConversation } from '../helpers/getDefaultConversation';
import { ConversationType } from '../../state/ducks/conversations';
import { SendStatus } from '../../messages/MessageSendState';
import { migrateLegacySendAttributes } from '../../messages/migrateLegacySendAttributes';
describe('migrateLegacySendAttributes', () => {
const defaultMessage = {
type: 'outgoing' as const,
sent_at: 123,
sent: true,
};
const createGetConversation = (
...conversations: ReadonlyArray<ConversationType>
) => {
const lookup = new Map<string, ConversationType>();
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,
},
}
);
});
});

View file

@ -9,11 +9,14 @@ import {
filter, filter,
find, find,
groupBy, groupBy,
isEmpty,
isIterable, isIterable,
map, map,
reduce, reduce,
repeat,
size, size,
take, take,
zipObject,
} from '../../util/iterables'; } from '../../util/iterables';
describe('iterable utilities', () => { describe('iterable utilities', () => {
@ -61,6 +64,15 @@ describe('iterable utilities', () => {
}); });
}); });
describe('repeat', () => {
it('repeats the same value forever', () => {
const result = repeat('foo');
const truncated = [...take(result, 10)];
assert.deepEqual(truncated, Array(10).fill('foo'));
});
});
describe('size', () => { describe('size', () => {
it('returns the length of a string', () => { it('returns the length of a string', () => {
assert.strictEqual(size(''), 0); assert.strictEqual(size(''), 0);
@ -261,6 +273,28 @@ describe('iterable utilities', () => {
}); });
}); });
describe('isEmpty', () => {
it('returns true for empty iterables', () => {
assert.isTrue(isEmpty(''));
assert.isTrue(isEmpty([]));
assert.isTrue(isEmpty(new Set()));
});
it('returns false for non-empty iterables', () => {
assert.isFalse(isEmpty(' '));
assert.isFalse(isEmpty([1, 2]));
assert.isFalse(isEmpty(new Set([3, 4])));
});
it('does not "look past" the first element', () => {
function* numbers() {
yield 1;
throw new Error('this should never happen');
}
assert.isFalse(isEmpty(numbers()));
});
});
describe('map', () => { describe('map', () => {
it('returns an empty iterable when passed an empty iterable', () => { it('returns an empty iterable when passed an empty iterable', () => {
const fn = sinon.fake(); const fn = sinon.fake();
@ -352,4 +386,23 @@ describe('iterable utilities', () => {
assert.deepEqual([...take(set, 10000)], [1, 2, 3]); 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,
});
});
});
}); });

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { SendStatus } from '../../messages/MessageSendState';
describe('Conversations', () => { describe('Conversations', () => {
async function resetConversationController(): Promise<void> { async function resetConversationController(): Promise<void> {
@ -19,9 +20,9 @@ describe('Conversations', () => {
// Creating a fake conversation // Creating a fake conversation
const conversation = new window.Whisper.Conversation({ const conversation = new window.Whisper.Conversation({
id: '8c45efca-67a4-4026-b990-9537d5d1a08f', id: window.getGuid(),
e164: '+15551234567', e164: '+15551234567',
uuid: '2f2734aa-f69d-4c1c-98eb-50eb0fc512d7', uuid: window.getGuid(),
type: 'private', type: 'private',
inbox_position: 0, inbox_position: 0,
isPinned: false, isPinned: false,
@ -33,7 +34,6 @@ describe('Conversations', () => {
version: 0, version: 0,
}); });
const destinationE164 = '+15557654321';
window.textsecure.storage.user.setNumberAndDeviceId( window.textsecure.storage.user.setNumberAndDeviceId(
ourNumber, ourNumber,
2, 2,
@ -42,27 +42,29 @@ describe('Conversations', () => {
window.textsecure.storage.user.setUuidAndDeviceId(ourUuid, 2); window.textsecure.storage.user.setUuidAndDeviceId(ourUuid, 2);
await window.ConversationController.loadPromise(); await window.ConversationController.loadPromise();
await window.Signal.Data.saveConversation(conversation.attributes);
// Creating a fake message // Creating a fake message
const now = Date.now(); const now = Date.now();
let message = new window.Whisper.Message({ let message = new window.Whisper.Message({
attachments: [], attachments: [],
body: 'bananas', body: 'bananas',
conversationId: conversation.id, conversationId: conversation.id,
delivered: 1,
delivered_to: [destinationE164],
destination: destinationE164,
expirationStartTimestamp: now, expirationStartTimestamp: now,
hasAttachments: false, hasAttachments: false,
hasFileAttachments: false, hasFileAttachments: false,
hasVisualMediaAttachments: false, hasVisualMediaAttachments: false,
id: 'd8f2b435-e2ef-46e0-8481-07e68af251c6', id: window.getGuid(),
received_at: now, received_at: now,
recipients: [destinationE164],
sent: true,
sent_at: now, sent_at: now,
sent_to: [destinationE164],
timestamp: now, timestamp: now,
type: 'outgoing', type: 'outgoing',
sendStateByConversationId: {
[conversation.id]: {
status: SendStatus.Sent,
updatedAt: now,
},
},
}); });
// Saving to db and updating the convo's last message // Saving to db and updating the convo's last message
@ -71,7 +73,7 @@ describe('Conversations', () => {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}); });
message = window.MessageController.register(message.id, message); message = window.MessageController.register(message.id, message);
await window.Signal.Data.saveConversation(conversation.attributes); await window.Signal.Data.updateConversation(conversation.attributes);
await conversation.updateLastMessage(); await conversation.updateLastMessage();
// Should be set to bananas because that's the last message sent. // Should be set to bananas because that's the last message sent.

View file

@ -5,9 +5,19 @@ import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; 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'; import { SignalService as Proto } from '../../protobuf';
describe('Message', () => { describe('Message', () => {
const STORAGE_KEYS_TO_RESTORE: Array<keyof StorageAccessType> = [
'number_id',
'uuid_id',
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const oldStorageValues = new Map<keyof StorageAccessType, any>();
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const attributes = { const attributes = {
@ -33,16 +43,25 @@ describe('Message', () => {
before(async () => { before(async () => {
window.ConversationController.reset(); window.ConversationController.reset();
await window.ConversationController.load(); 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('number_id', `${me}.2`);
window.textsecure.storage.put('uuid_id', `${ourUuid}.2`); window.textsecure.storage.put('uuid_id', `${ourUuid}.2`);
}); });
after(async () => { after(async () => {
window.textsecure.storage.remove('number_id');
window.textsecure.storage.remove('uuid_id');
await window.Signal.Data.removeAll(); await window.Signal.Data.removeAll();
await window.storage.fetch(); 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() { beforeEach(function beforeEach() {
@ -55,25 +74,92 @@ describe('Message', () => {
// NOTE: These tests are incomplete. // NOTE: These tests are incomplete.
describe('send', () => { describe('send', () => {
it("saves the result's dataMessage", async () => { let oldMessageSender: undefined | MessageSender;
const message = createMessage({ type: 'outgoing', source });
const fakeDataMessage = new ArrayBuffer(0); beforeEach(function beforeEach() {
const result = { oldMessageSender = window.textsecure.messaging;
dataMessage: fakeDataMessage,
};
const promise = Promise.resolve(result);
await message.send(promise);
assert.strictEqual(message.get('dataMessage'), fakeDataMessage); window.textsecure.messaging =
oldMessageSender ?? new MessageSender('username', 'password');
this.sandbox
.stub(window.textsecure.messaging, 'sendSyncMessage')
.resolves();
}); });
it('updates the `sent` attribute', async () => { afterEach(() => {
const message = createMessage({ type: 'outgoing', source, sent: false }); 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;
}
});
await message.send(Promise.resolve({})); it('updates `sendStateByConversationId`', async function test() {
this.sandbox.useFakeTimers(1234);
assert.isTrue(message.get('sent')); const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const conversation1 = await window.ConversationController.getOrCreateAndWait(
'a072df1d-7cee-43e2-9e6b-109710a2131c',
'private'
);
const conversation2 = await window.ConversationController.getOrCreateAndWait(
'62bd8ef1-68da-4cfd-ac1f-3ea85db7473e',
'private'
);
const message = createMessage({
type: 'outgoing',
conversationId: (
await window.ConversationController.getOrCreateAndWait(
'71cc190f-97ba-4c61-9d41-0b9444d721f9',
'group'
)
).id,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Pending,
updatedAt: 123,
},
[conversation1.id]: {
status: SendStatus.Pending,
updatedAt: 123,
},
[conversation2.id]: {
status: SendStatus.Pending,
updatedAt: 456,
},
},
});
const fakeDataMessage = new ArrayBuffer(0);
const ignoredUuid = window.getGuid();
const promise = Promise.resolve({
successfulIdentifiers: [conversation1.get('uuid'), ignoredUuid],
errors: [
Object.assign(new Error('failed'), {
identifier: conversation2.get('uuid'),
}),
],
dataMessage: fakeDataMessage,
});
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);
}); });
it('saves errors from promise rejections with errors', async () => { it('saves errors from promise rejections with errors', async () => {

View file

@ -0,0 +1,231 @@
// 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<keyof StorageAccessType> = [
'number_id',
'uuid_id',
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const oldStorageValues = new Map<keyof StorageAccessType, any>();
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<MessageAttributesType>) 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<void> => {
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<MessageAttributesType>) 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<void> => {
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),
]);
});
});

View file

@ -3,10 +3,16 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { v4 as uuid } from 'uuid'; 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 { ConversationType } from '../../../state/ducks/conversations';
import { import {
canReply, canReply,
getMessagePropStatus,
isEndSession, isEndSession,
isGroupUpdate, isGroupUpdate,
isIncoming, isIncoming,
@ -14,6 +20,12 @@ import {
} from '../../../state/selectors/message'; } from '../../../state/selectors/message';
describe('state/selectors/messages', () => { describe('state/selectors/messages', () => {
let ourConversationId: string;
beforeEach(() => {
ourConversationId = uuid();
});
describe('canReply', () => { describe('canReply', () => {
const defaultConversation: ConversationType = { const defaultConversation: ConversationType = {
id: uuid(), id: uuid(),
@ -35,7 +47,7 @@ describe('state/selectors/messages', () => {
isGroupV1AndDisabled: true, isGroupV1AndDisabled: true,
}); });
assert.isFalse(canReply(message, getConversationById)); assert.isFalse(canReply(message, ourConversationId, getConversationById));
}); });
// NOTE: This is missing a test for mandatory profile sharing. // NOTE: This is missing a test for mandatory profile sharing.
@ -48,33 +60,70 @@ describe('state/selectors/messages', () => {
}; };
const getConversationById = () => defaultConversation; const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, getConversationById)); assert.isFalse(canReply(message, ourConversationId, getConversationById));
}); });
it('returns false for outgoing messages that have not been sent', () => { it('returns false for outgoing messages that have not been sent', () => {
const message = { const message = {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
type: 'outgoing' as const, type: 'outgoing' as const,
sent_to: [], sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
}; };
const getConversationById = () => defaultConversation; const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, getConversationById)); assert.isFalse(canReply(message, ourConversationId, getConversationById));
}); });
it('returns true for outgoing messages that have been delivered to at least one person', () => { it('returns true for outgoing messages that are only sent to yourself', () => {
const message = { const message = {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
type: 'outgoing' as const, type: 'outgoing' as const,
receipients: [uuid(), uuid()], sendStateByConversationId: {
sent_to: [uuid()], [ourConversationId]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
};
const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
it('returns true for outgoing messages that have been sent to at least one person', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
},
}; };
const getConversationById = () => ({ const getConversationById = () => ({
...defaultConversation, ...defaultConversation,
type: 'group' as const, type: 'group' as const,
}); });
assert.isTrue(canReply(message, getConversationById)); assert.isTrue(canReply(message, ourConversationId, getConversationById));
}); });
it('returns true for incoming messages', () => { it('returns true for incoming messages', () => {
@ -84,7 +133,247 @@ describe('state/selectors/messages', () => {
}; };
const getConversationById = () => defaultConversation; const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, getConversationById)); assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
});
describe('getMessagePropStatus', () => {
const createMessage = (overrides: Partial<MessageAttributesType>) => ({
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'
);
}); });
}); });

View file

@ -47,7 +47,7 @@ import {
LinkPreviewImage, LinkPreviewImage,
LinkPreviewMetadata, LinkPreviewMetadata,
} from '../linkPreviews/linkPreviewFetch'; } from '../linkPreviews/linkPreviewFetch';
import { concat } from '../util/iterables'; import { concat, isEmpty, map } from '../util/iterables';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
export type SendMetadataType = { export type SendMetadataType = {
@ -996,8 +996,8 @@ export default class MessageSender {
destination, destination,
destinationUuid, destinationUuid,
expirationStartTimestamp, expirationStartTimestamp,
sentTo, conversationIdsSentTo = [],
unidentifiedDeliveries, conversationIdsWithSealedSender = new Set(),
isUpdate, isUpdate,
options, options,
}: { }: {
@ -1006,8 +1006,8 @@ export default class MessageSender {
destination: string | undefined; destination: string | undefined;
destinationUuid: string | null | undefined; destinationUuid: string | null | undefined;
expirationStartTimestamp: number | null; expirationStartTimestamp: number | null;
sentTo?: Array<string>; conversationIdsSentTo?: Iterable<string>;
unidentifiedDeliveries?: Array<string>; conversationIdsWithSealedSender?: Set<string>;
isUpdate?: boolean; isUpdate?: boolean;
options?: SendOptionsType; options?: SendOptionsType;
}): Promise<CallbackResultType | void> { }): Promise<CallbackResultType | void> {
@ -1035,38 +1035,33 @@ export default class MessageSender {
sentMessage.expirationStartTimestamp = expirationStartTimestamp; 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) { if (isUpdate) {
sentMessage.isRecipientUpdate = true; sentMessage.isRecipientUpdate = true;
} }
// Though this field has 'unidenified' in the name, it should have entries for each // Though this field has 'unidenified' in the name, it should have entries for each
// number we sent to. // number we sent to.
if (sentTo && sentTo.length) { if (!isEmpty(conversationIdsSentTo)) {
sentMessage.unidentifiedStatus = sentTo.map(identifier => { sentMessage.unidentifiedStatus = [
const status = new Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus(); ...map(conversationIdsSentTo, conversationId => {
const conv = window.ConversationController.get(identifier); const status = new Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus();
if (conv) { const conv = window.ConversationController.get(conversationId);
const e164 = conv.get('e164'); if (conv) {
if (e164) { const e164 = conv.get('e164');
status.destination = e164; if (e164) {
status.destination = e164;
}
const uuid = conv.get('uuid');
if (uuid) {
status.destinationUuid = uuid;
}
} }
const uuid = conv.get('uuid'); status.unidentified = conversationIdsWithSealedSender.has(
if (uuid) { conversationId
status.destinationUuid = uuid; );
} return status;
} }),
status.unidentified = Boolean(unidentifiedLookup[identifier]); ];
return status;
});
} }
const syncMessage = this.createSyncMessage(); const syncMessage = this.createSyncMessage();
@ -1687,8 +1682,8 @@ export default class MessageSender {
destination: e164, destination: e164,
destinationUuid: uuid, destinationUuid: uuid,
expirationStartTimestamp: null, expirationStartTimestamp: null,
sentTo: [], conversationIdsSentTo: [],
unidentifiedDeliveries: [], conversationIdsWithSealedSender: new Set(),
options, options,
}).catch(logError('resetSession/sendSync error:')); }).catch(logError('resetSession/sendSync error:'));

View file

@ -45,21 +45,15 @@ export type OutgoingMessage = Readonly<
// Required // Required
attachments: Array<Attachment>; attachments: Array<Attachment>;
delivered: number;
delivered_to: Array<string>;
destination: string; // PhoneNumber
expirationStartTimestamp: number; expirationStartTimestamp: number;
id: string; id: string;
received_at: number; received_at: number;
sent: boolean;
sent_to: Array<string>; // Array<PhoneNumber>
// Optional // Optional
body?: string; body?: string;
expireTimer?: number; expireTimer?: number;
messageTimer?: number; // deprecated messageTimer?: number; // deprecated
isViewOnce?: number; isViewOnce?: number;
recipients?: Array<string>; // Array<PhoneNumber>
synced: boolean; synced: boolean;
} & SharedMessageProperties & } & SharedMessageProperties &
MessageSchemaVersion5 & MessageSchemaVersion5 &

View file

@ -119,6 +119,9 @@ export function groupBy<T>(
return result; return result;
} }
export const isEmpty = (iterable: Iterable<unknown>): boolean =>
Boolean(iterable[Symbol.iterator]().next().done);
export function map<T, ResultT>( export function map<T, ResultT>(
iterable: Iterable<T>, iterable: Iterable<T>,
fn: (value: T) => ResultT fn: (value: T) => ResultT
@ -167,6 +170,33 @@ export function reduce<T, TResult>(
return result; return result;
} }
export function repeat<T>(value: T): Iterable<T> {
return new RepeatIterable(value);
}
class RepeatIterable<T> implements Iterable<T> {
constructor(private readonly value: T) {}
[Symbol.iterator](): Iterator<T> {
return new RepeatIterator(this.value);
}
}
class RepeatIterator<T> implements Iterator<T> {
private readonly iteratorResult: IteratorResult<T>;
constructor(value: Readonly<T>) {
this.iteratorResult = {
done: false,
value,
};
}
next(): IteratorResult<T> {
return this.iteratorResult;
}
}
export function take<T>(iterable: Iterable<T>, amount: number): Iterable<T> { export function take<T>(iterable: Iterable<T>, amount: number): Iterable<T> {
return new TakeIterable(iterable, amount); return new TakeIterable(iterable, amount);
} }
@ -194,3 +224,29 @@ class TakeIterator<T> implements Iterator<T> {
return nextIteration; return nextIteration;
} }
} }
// In the future, this could support number and symbol property names.
export function zipObject<ValueT>(
props: Iterable<string>,
values: Iterable<ValueT>
): Record<string, ValueT> {
const result: Record<string, ValueT> = {};
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;
}

View file

@ -3350,7 +3350,9 @@ Whisper.ConversationView = Whisper.View.extend({
} }
const getProps = () => ({ const getProps = () => ({
...message.getPropsForMessageDetail(), ...message.getPropsForMessageDetail(
window.ConversationController.getOurConversationIdOrThrow()
),
...this.getMessageActions(), ...this.getMessageActions(),
}); });
@ -3746,7 +3748,14 @@ Whisper.ConversationView = Whisper.View.extend({
}) })
: undefined; : undefined;
if (message && !canReply(message.attributes, findAndFormatContact)) { if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
return; return;
} }