Revert "Refactor outbound delivery state"
This reverts commit 9c48a95eb5
.
This commit is contained in:
parent
77668c3247
commit
ad217c808d
29 changed files with 694 additions and 3197 deletions
|
@ -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',
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -2291,8 +2291,8 @@ export async function wrapWithSyncMessageSend({
|
|||
destination: ourConversation.get('e164'),
|
||||
destinationUuid: ourConversation.get('uuid'),
|
||||
expirationStartTimestamp: null,
|
||||
conversationIdsSentTo: [],
|
||||
conversationIdsWithSealedSender: new Set(),
|
||||
sentTo: [],
|
||||
unidentifiedDeliveries: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(',') : [];
|
||||
}
|
|
@ -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
15
ts/model-types.d.ts
vendored
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
) {
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
448
ts/sql/Server.ts
448
ts/sql/Server.ts
|
@ -48,6 +48,7 @@ import {
|
|||
ItemKeyType,
|
||||
ItemType,
|
||||
MessageType,
|
||||
MessageTypeUnhydrated,
|
||||
MessageMetricsType,
|
||||
PreKeyType,
|
||||
SearchResultMessageType,
|
||||
|
@ -61,11 +62,6 @@ import {
|
|||
UnprocessedType,
|
||||
UnprocessedUpdateType,
|
||||
} from './Interface';
|
||||
import {
|
||||
SendState,
|
||||
serializeSendStateForDatabase,
|
||||
} from '../messages/MessageSendState';
|
||||
import { MessageRowWithJoinedSends, rowToMessage } from './rowToMessage';
|
||||
|
||||
declare global {
|
||||
// We want to extend `Function`'s properties, so we need to use an interface.
|
||||
|
@ -183,7 +179,6 @@ const dataInterface: ServerInterface = {
|
|||
getMessageBySender,
|
||||
getMessageById,
|
||||
_getAllMessages,
|
||||
_getSendStates,
|
||||
getAllMessageIds,
|
||||
getMessagesBySentAt,
|
||||
getExpiredMessages,
|
||||
|
@ -199,8 +194,6 @@ const dataInterface: ServerInterface = {
|
|||
hasGroupCallHistoryMessage,
|
||||
migrateConversationMessages,
|
||||
|
||||
updateMessageSendState,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
updateUnprocessedAttempts,
|
||||
|
@ -286,7 +279,6 @@ function objectToJSON(data: any) {
|
|||
function jsonToObject(json: string): any {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function rowToConversation(row: ConversationRow): ConversationType {
|
||||
const parsedJson = JSON.parse(row.json);
|
||||
|
||||
|
@ -306,7 +298,6 @@ function rowToConversation(row: ConversationRow): ConversationType {
|
|||
profileLastFetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToSticker(row: StickerRow): StickerType {
|
||||
return {
|
||||
...row,
|
||||
|
@ -1946,56 +1937,6 @@ function updateToSchemaVersion35(currentVersion: number, db: Database) {
|
|||
console.log('updateToSchemaVersion35: success!');
|
||||
}
|
||||
|
||||
function updateToSchemaVersion36(currentVersion: number, db: Database) {
|
||||
if (currentVersion >= 36) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE sendStates(
|
||||
messageId STRING NOT NULL,
|
||||
destinationConversationId STRING NOT NULL,
|
||||
updatedAt INTEGER NOT NULL,
|
||||
-- This should match the in-code enum.
|
||||
status TEXT CHECK(
|
||||
status IN (
|
||||
'Failed',
|
||||
'Pending',
|
||||
'Sent',
|
||||
'Delivered',
|
||||
'Read',
|
||||
'Viewed'
|
||||
)
|
||||
) NOT NULL,
|
||||
UNIQUE(messageId, destinationConversationId),
|
||||
FOREIGN KEY (messageId)
|
||||
REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (destinationConversationId)
|
||||
REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX message_sends ON sendStates (
|
||||
messageId,
|
||||
destinationConversationId
|
||||
);
|
||||
|
||||
CREATE VIEW messagesWithSendStates AS
|
||||
SELECT
|
||||
messages.*,
|
||||
GROUP_CONCAT(sendStates.destinationConversationId) AS sendConversationIdsJoined,
|
||||
GROUP_CONCAT(sendStates.status) AS sendStatusesJoined,
|
||||
GROUP_CONCAT(sendStates.updatedAt) AS sendUpdatedAtsJoined
|
||||
FROM messages
|
||||
LEFT JOIN sendStates ON messages.id = sendStates.messageId
|
||||
GROUP BY messages.id;
|
||||
`);
|
||||
|
||||
db.pragma('user_version = 36');
|
||||
})();
|
||||
console.log('updateToSchemaVersion36: success!');
|
||||
}
|
||||
|
||||
const SCHEMA_VERSIONS = [
|
||||
updateToSchemaVersion1,
|
||||
updateToSchemaVersion2,
|
||||
|
@ -2032,7 +1973,6 @@ const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion33,
|
||||
updateToSchemaVersion34,
|
||||
updateToSchemaVersion35,
|
||||
updateToSchemaVersion36,
|
||||
];
|
||||
|
||||
function updateSchema(db: Database): void {
|
||||
|
@ -3044,24 +2984,21 @@ async function searchMessages(
|
|||
// give us the right results. We can't call `snippet()` in the query above
|
||||
// because it would bloat the temporary table with text data and we want
|
||||
// to keep its size minimal for `ORDER BY` + `LIMIT` to be fast.
|
||||
const result: Array<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(
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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:'));
|
||||
|
||||
|
|
|
@ -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 &
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue