Revert "Refactor outbound delivery state"

This reverts commit 9c48a95eb5.
This commit is contained in:
Fedor Indutny 2021-07-12 16:51:45 -07:00 committed by GitHub
parent 77668c3247
commit ad217c808d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 694 additions and 3197 deletions

View File

@ -3206,7 +3206,7 @@ button.module-conversation-details__action-button {
margin-bottom: 2px;
}
.module-message-detail__contact__status-icon--Pending {
.module-message-detail__contact__status-icon--sending {
animation: module-message-detail__contact__status-icon--spinning 4s linear
infinite;
@ -3225,7 +3225,7 @@ button.module-conversation-details__action-button {
}
}
.module-message-detail__contact__status-icon--Sent {
.module-message-detail__contact__status-icon--sent {
@include light-theme {
@include color-svg('../images/check-circle-outline.svg', $color-gray-60);
}
@ -3233,7 +3233,7 @@ button.module-conversation-details__action-button {
@include color-svg('../images/check-circle-outline.svg', $color-gray-25);
}
}
.module-message-detail__contact__status-icon--Delivered {
.module-message-detail__contact__status-icon--delivered {
width: 18px;
@include light-theme {
@ -3243,8 +3243,7 @@ button.module-conversation-details__action-button {
@include color-svg('../images/double-check.svg', $color-gray-25);
}
}
.module-message-detail__contact__status-icon--Read,
.module-message-detail__contact__status-icon--Viewed {
.module-message-detail__contact__status-icon--read {
width: 18px;
@include light-theme {
@ -3254,7 +3253,7 @@ button.module-conversation-details__action-button {
@include color-svg('../images/read.svg', $color-gray-25);
}
}
.module-message-detail__contact__status-icon--Failed {
.module-message-detail__contact__status-icon--error {
@include light-theme {
@include color-svg(
'../images/icons/v2/error-outline-12.svg',

View File

@ -4,39 +4,12 @@
/* global ConversationController, SignalProtocolStore, Whisper */
describe('KeyChangeListener', () => {
const STORAGE_KEYS_TO_RESTORE = ['number_id', 'uuid_id'];
const oldStorageValues = new Map();
const phoneNumberWithKeyChange = '+13016886524'; // nsa
const addressString = `${phoneNumberWithKeyChange}.1`;
const oldKey = window.Signal.Crypto.getRandomBytes(33);
const newKey = window.Signal.Crypto.getRandomBytes(33);
let store;
before(async () => {
window.ConversationController.reset();
await window.ConversationController.load();
STORAGE_KEYS_TO_RESTORE.forEach(key => {
oldStorageValues.set(key, window.textsecure.storage.get(key));
});
window.textsecure.storage.put('number_id', '+14155555556.2');
window.textsecure.storage.put('uuid_id', `${window.getGuid()}.2`);
});
after(async () => {
await window.Signal.Data.removeAll();
await window.storage.fetch();
oldStorageValues.forEach((oldValue, key) => {
if (oldValue) {
window.textsecure.storage.put(key, oldValue);
} else {
window.textsecure.storage.remove(key);
}
});
});
let convo;
beforeEach(async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,237 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isUndefined, zip } from 'lodash';
import { makeEnumParser } from '../util/enum';
import { assert } from '../util/assert';
import { isNormalNumber } from '../util/isNormalNumber';
/**
* `SendStatus` represents the send status of a message to a single recipient. For
* example, if a message is sent to 5 people, there would be 5 `SendStatus`es.
*
* Under normal conditions, the status will go down this list, in order:
*
* 1. `Pending`; the message has not been sent, and we are continuing to try
* 2. `Sent`; the message has been delivered to the server
* 3. `Delivered`; we've received a delivery receipt
* 4. `Read`; we've received a read receipt (not applicable if the recipient has disabled
* sending these receipts)
* 5. `Viewed`; we've received a viewed receipt (not applicable for all message types, or
* if the recipient has disabled sending these receipts)
*
* There's also a `Failed` state, which represents an error we don't want to recover from.
*
* There are some unusual cases where messages don't follow this pattern. For example, if
* we receive a read receipt before we receive a delivery receipt, we might skip the
* Delivered state. However, we should never go "backwards".
*
* Be careful when changing these values, as they are persisted.
*/
export enum SendStatus {
Failed = 'Failed',
Pending = 'Pending',
Sent = 'Sent',
Delivered = 'Delivered',
Read = 'Read',
Viewed = 'Viewed',
}
export const parseMessageSendStatus = makeEnumParser(
SendStatus,
SendStatus.Pending
);
const STATUS_NUMBERS: Record<SendStatus, 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

@ -1,172 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { get, isEmpty } from 'lodash';
import { getOwn } from '../util/getOwn';
import { map, concat, repeat, zipObject } from '../util/iterables';
import { isOutgoing } from '../state/selectors/message';
import type { CustomError, MessageAttributesType } from '../model-types.d';
import {
SendState,
SendActionType,
SendStateByConversationId,
sendStateReducer,
SendStatus,
} from './MessageSendState';
/**
* This converts legacy message fields, such as `sent_to`, into the new
* `sendStateByConversationId` format. These legacy fields aren't typed to prevent their
* usage, so we treat them carefully (i.e., as if they are `unknown`).
*
* Old data isn't dropped, in case we need to revert this change. We should safely be able
* to remove the following attributes once we're confident in this new format:
*
* - delivered
* - delivered_to
* - read_by
* - recipients
* - sent
* - sent_to
*/
export function migrateLegacySendAttributes(
message: Readonly<
Pick<
MessageAttributesType,
'errors' | 'sendStateByConversationId' | 'sent_at' | 'type'
>
>,
getConversation: GetConversationType,
ourConversationId: string
): undefined | SendStateByConversationId {
const shouldMigrate =
isEmpty(message.sendStateByConversationId) && isOutgoing(message);
if (!shouldMigrate) {
return undefined;
}
/* eslint-disable no-restricted-syntax */
const pendingSendState: SendState = {
status: SendStatus.Pending,
updatedAt: message.sent_at,
};
const sendStateByConversationId: SendStateByConversationId = zipObject(
getConversationIdsFromLegacyAttribute(
message,
'recipients',
getConversation
),
repeat(pendingSendState)
);
// We use `get` because `sent` is a legacy, and therefore untyped, attribute.
const wasSentToSelf = Boolean(get(message, 'sent'));
const actions = concat<{
type:
| SendActionType.Failed
| SendActionType.Sent
| SendActionType.GotDeliveryReceipt
| SendActionType.GotReadReceipt;
conversationId: string;
}>(
map(
getConversationIdsFromErrors(message.errors, getConversation),
conversationId => ({
type: SendActionType.Failed,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'sent_to',
getConversation
),
conversationId => ({
type: SendActionType.Sent,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'delivered_to',
getConversation
),
conversationId => ({
type: SendActionType.GotDeliveryReceipt,
conversationId,
})
),
map(
getConversationIdsFromLegacyAttribute(
message,
'read_by',
getConversation
),
conversationId => ({
type: SendActionType.GotReadReceipt,
conversationId,
})
),
[
{
type: wasSentToSelf ? SendActionType.Sent : SendActionType.Failed,
conversationId: ourConversationId,
},
]
);
for (const { conversationId, type } of actions) {
const oldSendState =
getOwn(sendStateByConversationId, conversationId) || pendingSendState;
sendStateByConversationId[conversationId] = sendStateReducer(oldSendState, {
type,
updatedAt: undefined,
});
}
return sendStateByConversationId;
/* eslint-enable no-restricted-syntax */
}
function getConversationIdsFromErrors(
errors: undefined | ReadonlyArray<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,10 +20,6 @@ import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI';
import {
SendState,
SendStateByConversationId,
} from './messages/MessageSendState';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors';
import { AttachmentType, ThumbnailType } from './types/Attachment';
@ -92,6 +88,8 @@ export type MessageAttributesType = {
decrypted_at?: number;
deletedForEveryone?: boolean;
deletedForEveryoneTimestamp?: number;
delivered?: number;
delivered_to?: Array<string | null>;
errors?: Array<CustomError>;
expirationStartTimestamp?: number | null;
expireTimer?: number;
@ -117,8 +115,10 @@ export type MessageAttributesType = {
targetTimestamp: number;
timestamp: number;
}>;
read_by?: Array<string | null>;
requiredProtocolVersion?: number;
retryOptions?: RetryOptions;
sent?: boolean;
sourceDevice?: string | number;
supportedVersionAtReceive?: unknown;
synced?: boolean;
@ -152,10 +152,14 @@ export type MessageAttributesType = {
data?: AttachmentType;
};
sent_at: number;
sent_to?: Array<string>;
unidentifiedDeliveries?: Array<string>;
contact?: Array<ContactType>;
conversationId: string;
recipients?: Array<string>;
reaction?: WhatIsThis;
destination?: WhatIsThis;
destinationUuid?: string;
expirationTimerUpdate?: {
expireTimer: number;
@ -188,9 +192,6 @@ export type MessageAttributesType = {
droppedGV2MemberIds?: Array<string>;
sendHQImages?: boolean;
// Should only be present for outgoing messages
sendStateByConversationId?: SendStateByConversationId;
};
export type ConversationAttributesTypeType = 'private' | 'group';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,564 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { sampleSize, times } from 'lodash';
import { v4 as uuid } from 'uuid';
import {
SendAction,
SendActionType,
SendState,
SendStateByConversationId,
SendStatus,
deserializeDatabaseSendStates,
isDelivered,
isMessageJustForMe,
isRead,
isSent,
maxStatus,
sendStateReducer,
serializeSendStateForDatabase,
someSendStatus,
} from '../../messages/MessageSendState';
describe('message send state utilities', () => {
describe('maxStatus', () => {
const expectedOrder = [
SendStatus.Failed,
SendStatus.Pending,
SendStatus.Sent,
SendStatus.Delivered,
SendStatus.Read,
SendStatus.Viewed,
];
it('returns the input if arguments are equal', () => {
expectedOrder.forEach(status => {
assert.strictEqual(maxStatus(status, status), status);
});
});
it('orders the statuses', () => {
times(100, () => {
const [a, b] = sampleSize(expectedOrder, 2);
const isABigger = expectedOrder.indexOf(a) > expectedOrder.indexOf(b);
const expected = isABigger ? a : b;
const actual = maxStatus(a, b);
assert.strictEqual(actual, expected);
});
});
});
describe('isRead', () => {
it('returns true for read and viewed statuses', () => {
assert.isTrue(isRead(SendStatus.Read));
assert.isTrue(isRead(SendStatus.Viewed));
});
it('returns false for non-read statuses', () => {
assert.isFalse(isRead(SendStatus.Delivered));
assert.isFalse(isRead(SendStatus.Sent));
assert.isFalse(isRead(SendStatus.Pending));
assert.isFalse(isRead(SendStatus.Failed));
});
});
describe('isDelivered', () => {
it('returns true for delivered, read, and viewed statuses', () => {
assert.isTrue(isDelivered(SendStatus.Delivered));
assert.isTrue(isDelivered(SendStatus.Read));
assert.isTrue(isDelivered(SendStatus.Viewed));
});
it('returns false for non-delivered statuses', () => {
assert.isFalse(isDelivered(SendStatus.Sent));
assert.isFalse(isDelivered(SendStatus.Pending));
assert.isFalse(isDelivered(SendStatus.Failed));
});
});
describe('isSent', () => {
it('returns true for all statuses sent and "above"', () => {
assert.isTrue(isSent(SendStatus.Sent));
assert.isTrue(isSent(SendStatus.Delivered));
assert.isTrue(isSent(SendStatus.Read));
assert.isTrue(isSent(SendStatus.Viewed));
});
it('returns false for non-sent statuses', () => {
assert.isFalse(isSent(SendStatus.Pending));
assert.isFalse(isSent(SendStatus.Failed));
});
});
describe('someSendStatus', () => {
it('returns false if there are no send states', () => {
const alwaysTrue = () => true;
assert.isFalse(someSendStatus(undefined, alwaysTrue));
assert.isFalse(someSendStatus({}, alwaysTrue));
});
it('returns false if no send states match', () => {
const sendStateByConversationId: SendStateByConversationId = {
abc: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
def: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
};
assert.isFalse(
someSendStatus(
sendStateByConversationId,
(status: SendStatus) => status === SendStatus.Delivered
)
);
});
it('returns true if at least one send state matches', () => {
const sendStateByConversationId: SendStateByConversationId = {
abc: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
def: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
};
assert.isTrue(
someSendStatus(
sendStateByConversationId,
(status: SendStatus) => status === SendStatus.Read
)
);
});
});
describe('isMessageJustForMe', () => {
const ourConversationId = uuid();
it('returns false if the conversation has an empty send state', () => {
assert.isFalse(isMessageJustForMe(undefined, ourConversationId));
assert.isFalse(isMessageJustForMe({}, ourConversationId));
});
it('returns false if the message is for anyone else', () => {
assert.isFalse(
isMessageJustForMe(
{
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: 123,
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: 123,
},
},
ourConversationId
)
);
// This is an invalid state, but we still want to test the behavior.
assert.isFalse(
isMessageJustForMe(
{
[uuid()]: {
status: SendStatus.Pending,
updatedAt: 123,
},
},
ourConversationId
)
);
});
it('returns true if the message is just for you', () => {
assert.isTrue(
isMessageJustForMe(
{
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: 123,
},
},
ourConversationId
)
);
});
});
describe('sendStateReducer', () => {
const assertTransition = (
startStatus: SendStatus,
actionType: SendActionType,
expectedStatus: SendStatus
): void => {
const startState: SendState = {
status: startStatus,
updatedAt: 1,
};
const action: SendAction = {
type: actionType,
updatedAt: 2,
};
const result = sendStateReducer(startState, action);
assert.strictEqual(result.status, expectedStatus);
assert.strictEqual(
result.updatedAt,
startStatus === expectedStatus ? 1 : 2
);
};
describe('transitions from Pending', () => {
it('goes from Pending → Failed with a failure', () => {
const result = sendStateReducer(
{ status: SendStatus.Pending, updatedAt: 999 },
{ type: SendActionType.Failed, updatedAt: 123 }
);
assert.deepEqual(result, {
status: SendStatus.Failed,
updatedAt: 123,
});
});
it('does nothing when receiving ManuallyRetried', () => {
assertTransition(
SendStatus.Pending,
SendActionType.ManuallyRetried,
SendStatus.Pending
);
});
it('goes from Pending to all other sent states', () => {
assertTransition(
SendStatus.Pending,
SendActionType.Sent,
SendStatus.Sent
);
assertTransition(
SendStatus.Pending,
SendActionType.GotDeliveryReceipt,
SendStatus.Delivered
);
assertTransition(
SendStatus.Pending,
SendActionType.GotReadReceipt,
SendStatus.Read
);
assertTransition(
SendStatus.Pending,
SendActionType.GotViewedReceipt,
SendStatus.Viewed
);
});
});
describe('transitions from Failed', () => {
it('does nothing when receiving a Failed action', () => {
const result = sendStateReducer(
{
status: SendStatus.Failed,
updatedAt: 123,
},
{
type: SendActionType.Failed,
updatedAt: 999,
}
);
assert.deepEqual(result, {
status: SendStatus.Failed,
updatedAt: 123,
});
});
it('goes from Failed to all other states', () => {
assertTransition(
SendStatus.Failed,
SendActionType.ManuallyRetried,
SendStatus.Pending
);
assertTransition(
SendStatus.Failed,
SendActionType.Sent,
SendStatus.Sent
);
assertTransition(
SendStatus.Failed,
SendActionType.GotDeliveryReceipt,
SendStatus.Delivered
);
assertTransition(
SendStatus.Failed,
SendActionType.GotReadReceipt,
SendStatus.Read
);
assertTransition(
SendStatus.Failed,
SendActionType.GotViewedReceipt,
SendStatus.Viewed
);
});
});
describe('transitions from Sent', () => {
it('does nothing when trying to go "backwards"', () => {
[SendActionType.Failed, SendActionType.ManuallyRetried].forEach(
type => {
assertTransition(SendStatus.Sent, type, SendStatus.Sent);
}
);
});
it('does nothing when receiving a Sent action', () => {
assertTransition(SendStatus.Sent, SendActionType.Sent, SendStatus.Sent);
});
it('can go forward to other states', () => {
assertTransition(
SendStatus.Sent,
SendActionType.GotDeliveryReceipt,
SendStatus.Delivered
);
assertTransition(
SendStatus.Sent,
SendActionType.GotReadReceipt,
SendStatus.Read
);
assertTransition(
SendStatus.Sent,
SendActionType.GotViewedReceipt,
SendStatus.Viewed
);
});
});
describe('transitions from Delivered', () => {
it('does nothing when trying to go "backwards"', () => {
[
SendActionType.Failed,
SendActionType.ManuallyRetried,
SendActionType.Sent,
].forEach(type => {
assertTransition(SendStatus.Delivered, type, SendStatus.Delivered);
});
});
it('does nothing when receiving a delivery receipt', () => {
assertTransition(
SendStatus.Delivered,
SendActionType.GotDeliveryReceipt,
SendStatus.Delivered
);
});
it('can go forward to other states', () => {
assertTransition(
SendStatus.Delivered,
SendActionType.GotReadReceipt,
SendStatus.Read
);
assertTransition(
SendStatus.Delivered,
SendActionType.GotViewedReceipt,
SendStatus.Viewed
);
});
});
describe('transitions from Read', () => {
it('does nothing when trying to go "backwards"', () => {
[
SendActionType.Failed,
SendActionType.ManuallyRetried,
SendActionType.Sent,
SendActionType.GotDeliveryReceipt,
].forEach(type => {
assertTransition(SendStatus.Read, type, SendStatus.Read);
});
});
it('does nothing when receiving a read receipt', () => {
assertTransition(
SendStatus.Read,
SendActionType.GotReadReceipt,
SendStatus.Read
);
});
it('can go forward to the "viewed" state', () => {
assertTransition(
SendStatus.Read,
SendActionType.GotViewedReceipt,
SendStatus.Viewed
);
});
});
describe('transitions from Viewed', () => {
it('ignores all actions', () => {
[
SendActionType.Failed,
SendActionType.ManuallyRetried,
SendActionType.Sent,
SendActionType.GotDeliveryReceipt,
SendActionType.GotReadReceipt,
SendActionType.GotViewedReceipt,
].forEach(type => {
assertTransition(SendStatus.Viewed, type, SendStatus.Viewed);
});
});
});
describe('legacy transitions', () => {
it('allows actions without timestamps', () => {
const startState: SendState = {
status: SendStatus.Pending,
updatedAt: Date.now(),
};
const action: SendAction = {
type: SendActionType.Sent,
updatedAt: undefined,
};
const result = sendStateReducer(startState, action);
assert.isUndefined(result.updatedAt);
});
});
});
describe('serializeSendStateForDatabase', () => {
it('serializes legacy states without an update timestamp', () => {
assert.deepEqual(
serializeSendStateForDatabase({
messageId: 'abc',
destinationConversationId: 'def',
status: SendStatus.Delivered,
}),
{
destinationConversationId: 'def',
messageId: 'abc',
status: 'Delivered',
updatedAt: 0,
}
);
});
it('serializes send states', () => {
assert.deepEqual(
serializeSendStateForDatabase({
messageId: 'abc',
destinationConversationId: 'def',
status: SendStatus.Read,
updatedAt: 956206800000,
}),
{
destinationConversationId: 'def',
messageId: 'abc',
status: 'Read',
updatedAt: 956206800000,
}
);
assert.deepEqual(
serializeSendStateForDatabase({
messageId: 'abc',
destinationConversationId: 'def',
status: SendStatus.Failed,
updatedAt: 956206800000,
}),
{
destinationConversationId: 'def',
messageId: 'abc',
status: 'Failed',
updatedAt: 956206800000,
}
);
});
});
describe('deserializeDatabaseSendStates', () => {
it('returns an empty object if passed no send states', () => {
assert.deepEqual(deserializeDatabaseSendStates({}), {});
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: undefined,
sendStatusesJoined: undefined,
sendUpdatedAtsJoined: undefined,
}),
{}
);
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: null,
sendStatusesJoined: null,
sendUpdatedAtsJoined: null,
}),
{}
);
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: '',
sendStatusesJoined: '',
sendUpdatedAtsJoined: '',
}),
{}
);
});
it('deserializes one send state', () => {
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: 'abc',
sendStatusesJoined: 'Delivered',
sendUpdatedAtsJoined: '956206800000',
}),
{
abc: {
status: SendStatus.Delivered,
updatedAt: 956206800000,
},
}
);
});
it('deserializes multiple send states', () => {
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: 'abc,def',
sendStatusesJoined: 'Delivered,Sent',
sendUpdatedAtsJoined: '956206800000,1271739600000',
}),
{
abc: {
status: SendStatus.Delivered,
updatedAt: 956206800000,
},
def: {
status: SendStatus.Sent,
updatedAt: 1271739600000,
},
}
);
});
it('deserializes send states that lack an updated timestamp', () => {
assert.deepEqual(
deserializeDatabaseSendStates({
sendConversationIdsJoined: 'abc,def',
sendStatusesJoined: 'Delivered,Sent',
sendUpdatedAtsJoined: '956206800000,0',
}).def,
{
status: SendStatus.Sent,
updatedAt: undefined,
}
);
});
});
});

View File

@ -1,263 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { getDefaultConversation } from '../helpers/getDefaultConversation';
import { ConversationType } from '../../state/ducks/conversations';
import { SendStatus } from '../../messages/MessageSendState';
import { migrateLegacySendAttributes } from '../../messages/migrateLegacySendAttributes';
describe('migrateLegacySendAttributes', () => {
const defaultMessage = {
type: 'outgoing' as const,
sent_at: 123,
sent: true,
};
const createGetConversation = (
...conversations: ReadonlyArray<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,14 +9,11 @@ import {
filter,
find,
groupBy,
isEmpty,
isIterable,
map,
reduce,
repeat,
size,
take,
zipObject,
} from '../../util/iterables';
describe('iterable utilities', () => {
@ -64,15 +61,6 @@ describe('iterable utilities', () => {
});
});
describe('repeat', () => {
it('repeats the same value forever', () => {
const result = repeat('foo');
const truncated = [...take(result, 10)];
assert.deepEqual(truncated, Array(10).fill('foo'));
});
});
describe('size', () => {
it('returns the length of a string', () => {
assert.strictEqual(size(''), 0);
@ -273,28 +261,6 @@ describe('iterable utilities', () => {
});
});
describe('isEmpty', () => {
it('returns true for empty iterables', () => {
assert.isTrue(isEmpty(''));
assert.isTrue(isEmpty([]));
assert.isTrue(isEmpty(new Set()));
});
it('returns false for non-empty iterables', () => {
assert.isFalse(isEmpty(' '));
assert.isFalse(isEmpty([1, 2]));
assert.isFalse(isEmpty(new Set([3, 4])));
});
it('does not "look past" the first element', () => {
function* numbers() {
yield 1;
throw new Error('this should never happen');
}
assert.isFalse(isEmpty(numbers()));
});
});
describe('map', () => {
it('returns an empty iterable when passed an empty iterable', () => {
const fn = sinon.fake();
@ -386,23 +352,4 @@ describe('iterable utilities', () => {
assert.deepEqual([...take(set, 10000)], [1, 2, 3]);
});
});
describe('zipObject', () => {
it('zips up an object', () => {
assert.deepEqual(zipObject(['foo', 'bar'], [1, 2]), { foo: 1, bar: 2 });
});
it('stops if the keys "run out" first', () => {
assert.deepEqual(zipObject(['foo', 'bar'], [1, 2, 3, 4, 5, 6]), {
foo: 1,
bar: 2,
});
});
it('stops if the values "run out" first', () => {
assert.deepEqual(zipObject(['foo', 'bar', 'baz'], [1]), {
foo: 1,
});
});
});
});

View File

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

View File

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

View File

@ -1,231 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { omit } from 'lodash';
import { v4 as uuid } from 'uuid';
import { MessageModel } from '../../models/messages';
import { SendStatus } from '../../messages/MessageSendState';
import type { StorageAccessType } from '../../types/Storage.d';
import type { MessageAttributesType } from '../../model-types.d';
import type { WhatIsThis } from '../../window.d';
import dataInterface from '../../sql/Client';
const {
getMessageById,
saveMessage,
saveConversation,
_getSendStates,
} = dataInterface;
describe('saveMessage', () => {
const STORAGE_KEYS_TO_RESTORE: Array<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,16 +3,10 @@
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { SendStatus } from '../../../messages/MessageSendState';
import {
MessageAttributesType,
ShallowChallengeError,
} from '../../../model-types.d';
import { ConversationType } from '../../../state/ducks/conversations';
import {
canReply,
getMessagePropStatus,
isEndSession,
isGroupUpdate,
isIncoming,
@ -20,12 +14,6 @@ import {
} from '../../../state/selectors/message';
describe('state/selectors/messages', () => {
let ourConversationId: string;
beforeEach(() => {
ourConversationId = uuid();
});
describe('canReply', () => {
const defaultConversation: ConversationType = {
id: uuid(),
@ -47,7 +35,7 @@ describe('state/selectors/messages', () => {
isGroupV1AndDisabled: true,
});
assert.isFalse(canReply(message, ourConversationId, getConversationById));
assert.isFalse(canReply(message, getConversationById));
});
// NOTE: This is missing a test for mandatory profile sharing.
@ -60,70 +48,33 @@ describe('state/selectors/messages', () => {
};
const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, ourConversationId, getConversationById));
assert.isFalse(canReply(message, getConversationById));
});
it('returns false for outgoing messages that have not been sent', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
sent_to: [],
};
const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, ourConversationId, getConversationById));
assert.isFalse(canReply(message, getConversationById));
});
it('returns true for outgoing messages that are only sent to yourself', () => {
it('returns true for outgoing messages that have been delivered to at least one person', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
};
const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
it('returns true for outgoing messages that have been sent to at least one person', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
},
receipients: [uuid(), uuid()],
sent_to: [uuid()],
};
const getConversationById = () => ({
...defaultConversation,
type: 'group' as const,
});
assert.isTrue(canReply(message, ourConversationId, getConversationById));
assert.isTrue(canReply(message, getConversationById));
});
it('returns true for incoming messages', () => {
@ -133,247 +84,7 @@ describe('state/selectors/messages', () => {
};
const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
});
describe('getMessagePropStatus', () => {
const createMessage = (overrides: Partial<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'
);
assert.isTrue(canReply(message, getConversationById));
});
});

View File

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

View File

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

View File

@ -119,9 +119,6 @@ export function groupBy<T>(
return result;
}
export const isEmpty = (iterable: Iterable<unknown>): boolean =>
Boolean(iterable[Symbol.iterator]().next().done);
export function map<T, ResultT>(
iterable: Iterable<T>,
fn: (value: T) => ResultT
@ -170,33 +167,6 @@ export function reduce<T, TResult>(
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> {
return new TakeIterable(iterable, amount);
}
@ -224,29 +194,3 @@ class TakeIterator<T> implements Iterator<T> {
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,9 +3350,7 @@ Whisper.ConversationView = Whisper.View.extend({
}
const getProps = () => ({
...message.getPropsForMessageDetail(
window.ConversationController.getOurConversationIdOrThrow()
),
...message.getPropsForMessageDetail(),
...this.getMessageActions(),
});
@ -3748,14 +3746,7 @@ Whisper.ConversationView = Whisper.View.extend({
})
: undefined;
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
if (message && !canReply(message.attributes, findAndFormatContact)) {
return;
}