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