Support for message retry requests

This commit is contained in:
Scott Nonnenberg 2021-05-28 12:11:19 -07:00 committed by GitHub
parent 28f016ce48
commit ee513a1965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1996 additions and 359 deletions

View File

@ -1117,6 +1117,38 @@
"message": "Contact Support",
"description": "Shown on explainer dialog available from chat session refreshed timeline events"
},
"DeliveryIssue--preview": {
"message": "Delivery issue",
"description": "Shown in left pane preview when message delivery issue happens"
},
"DeliveryIssue--notification": {
"message": "A message from $sender$ couldnt be delivered",
"description": "Shown in timeline when message delivery issue happens",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"DeliveryIssue--learnMore": {
"message": "Learn More",
"description": "Shown in timeline when message delivery issue happens, to provide access to a popup info dialog"
},
"DeliveryIssue--title": {
"message": "Delivery Issue",
"description": "Shown on explainer dialog available from delivery issue timeline events"
},
"DeliveryIssue--summary": {
"message": "A message, sticker, reaction, read receipt or media couldnt be delivered to you from $sender$. They may have tried sending it to you directly, or in a group.",
"description": "Shown on explainer dialog available from delivery issue timeline events",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"quoteThumbnailAlt": {
"message": "Thumbnail of image from quoted message",
"description": "Used in alt tag of thumbnail images inside of an embedded message quote"

13
images/delivery-issue.svg Normal file
View File

@ -0,0 +1,13 @@
<svg width="200" height="110" viewBox="0 0 200 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.2999 15.1L12.8999 15C9.0999 15 5.9999 18.1 5.9999 21.9L5.8999 88C5.8999 91.8 8.9999 94.9 12.7999 94.9L39.0999 95C42.8999 95 45.9999 91.9 45.9999 88.1L46.1999 22C46.1999 18.2 43.0999 15.1 39.2999 15.1ZM42.7999 88.1C42.7999 90.1 41.0999 91.8 39.0999 91.8L12.6999 91.7C10.6999 91.7 8.9999 90 8.9999 88L9.0999 21.9C9.0999 19.9 10.7999 18.2 12.7999 18.2L39.1999 18.3C41.1999 18.3 42.8999 20 42.8999 22L42.7999 88.1Z" fill="#C6C6C6"/>
<path d="M187.3 15.1L160.9 15C157.1 15 154 18.1 154 21.9L153.9 88C153.9 91.8 157 94.9 160.8 94.9L187.2 95C191 95 194.1 91.9 194.1 88.1L194.2 22C194.2 18.2 191.1 15.1 187.3 15.1ZM190.8 88.1C190.8 90.1 189.1 91.8 187.1 91.8L160.7 91.7C158.7 91.7 157 90 157 88L157.1 21.9C157.1 19.9 158.8 18.2 160.8 18.2L187.2 18.3C189.2 18.3 190.9 20 190.9 22L190.8 88.1Z" fill="#C6C6C6"/>
<path d="M126 58C127.105 58 128 57.1046 128 56C128 54.8954 127.105 54 126 54C124.895 54 124 54.8954 124 56C124 57.1046 124.895 58 126 58Z" fill="#848484"/>
<path d="M136 58C137.105 58 138 57.1046 138 56C138 54.8954 137.105 54 136 54C134.895 54 134 54.8954 134 56C134 57.1046 134.895 58 136 58Z" fill="#848484"/>
<path d="M146 58C147.105 58 148 57.1046 148 56C148 54.8954 147.105 54 146 54C144.895 54 144 54.8954 144 56C144 57.1046 144.895 58 146 58Z" fill="#848484"/>
<path d="M54 58C55.1046 58 56 57.1046 56 56C56 54.8954 55.1046 54 54 54C52.8954 54 52 54.8954 52 56C52 57.1046 52.8954 58 54 58Z" fill="#848484"/>
<path d="M64 58C65.1046 58 66 57.1046 66 56C66 54.8954 65.1046 54 64 54C62.8954 54 62 54.8954 62 56C62 57.1046 62.8954 58 64 58Z" fill="#848484"/>
<path d="M74 58C75.1046 58 76 57.1046 76 56C76 54.8954 75.1046 54 74 54C72.8954 54 72 54.8954 72 56C72 57.1046 72.8954 58 74 58Z" fill="#848484"/>
<path d="M118 56C118 65.9411 109.941 74 100 74C90.0589 74 82 65.9411 82 56C82 46.0589 90.0589 38 100 38C109.941 38 118 46.0589 118 56Z" fill="#FFC207"/>
<path d="M102.083 47H97.9167L98.75 58.25H101.25L102.083 47Z" fill="white"/>
<path d="M102.079 61.5C102.353 61.87 102.5 62.305 102.5 62.75C102.5 63.3467 102.237 63.919 101.768 64.341C101.299 64.7629 100.663 65 100 65C99.5055 65 99.0222 64.868 98.6111 64.6208C98.2 64.3736 97.8795 64.0222 97.6903 63.611C97.5011 63.1999 97.4516 62.7475 97.548 62.311C97.6445 61.8746 97.8826 61.4737 98.2322 61.159C98.5819 60.8443 99.0273 60.63 99.5123 60.5432C99.9972 60.4564 100.5 60.501 100.957 60.6713C101.414 60.8416 101.804 61.13 102.079 61.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -44,7 +44,7 @@ describe('MessageReceiver', () => {
});
});
it('generates light-session-reset event when it cannot decrypt', done => {
it('generates decryption-error event when it cannot decrypt', done => {
const mockServer = new MockServer('ws://localhost:8081/');
mockServer.on('connection', server => {
@ -63,82 +63,33 @@ describe('MessageReceiver', () => {
}
);
messageReceiver.addEventListener('light-session-reset', done());
messageReceiver.addEventListener('decrytion-error', done());
});
});
describe('methods', () => {
let messageReceiver;
let mockServer;
// For when we start testing individual MessageReceiver methods
beforeEach(() => {
// Necessary to populate the server property inside of MockSocket. Without it, we
// crash when doing any number of things to a MockSocket instance.
mockServer = new MockServer('ws://localhost:8081');
// describe('methods', () => {
// let messageReceiver;
// let mockServer;
messageReceiver = new textsecure.MessageReceiver(
'oldUsername.3',
'username.3',
'password',
'signalingKey',
{
serverTrustRoot: 'AAAAAAAA',
}
);
});
afterEach(() => {
mockServer.close();
});
// beforeEach(() => {
// // Necessary to populate the server property inside of MockSocket. Without it, we
// // crash when doing any number of things to a MockSocket instance.
// mockServer = new MockServer('ws://localhost:8081');
describe('#isOverHourIntoPast', () => {
it('returns false for now', () => {
assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now()));
});
it('returns false for 5 minutes ago', () => {
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo));
});
it('returns true for 65 minutes ago', () => {
const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000;
assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo));
});
});
describe('#cleanupSessionResets', () => {
it('leaves empty object alone', () => {
window.storage.put('sessionResets', {});
messageReceiver.cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = {};
assert.deepEqual(actual, expected);
});
it('filters out any timestamp older than one hour', () => {
const startValue = {
one: Date.now() - 1,
two: Date.now(),
three: Date.now() - 65 * 60 * 1000,
};
window.storage.put('sessionResets', startValue);
messageReceiver.cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = window._.pick(startValue, ['one', 'two']);
assert.deepEqual(actual, expected);
});
it('filters out falsey items', () => {
const startValue = {
one: 0,
two: false,
three: Date.now(),
};
window.storage.put('sessionResets', startValue);
messageReceiver.cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = window._.pick(startValue, ['three']);
assert.deepEqual(actual, expected);
});
});
});
// messageReceiver = new textsecure.MessageReceiver(
// 'oldUsername.3',
// 'username.3',
// 'password',
// 'signalingKey',
// {
// serverTrustRoot: 'AAAAAAAA',
// }
// );
// });
// afterEach(() => {
// mockServer.close();
// });
// });
});

View File

@ -68,7 +68,7 @@
"fs-xattr": "0.3.0"
},
"dependencies": {
"@signalapp/signal-client": "0.6.0",
"@signalapp/signal-client": "0.8.0",
"@sindresorhus/is": "0.8.0",
"@types/pino": "6.3.6",
"@types/pino-multi-stream": "5.1.0",

View File

@ -12,6 +12,11 @@ message Envelope {
PREKEY_BUNDLE = 3;
RECEIPT = 5;
UNIDENTIFIED_SENDER = 6;
// Our parser does not handle reserved in enums: DESKTOP-1569
// reserved 7;
PLAINTEXT_CONTENT = 8;
}
optional Type type = 1;
@ -34,6 +39,7 @@ message Content {
optional ReceiptMessage receiptMessage = 5;
optional TypingMessage typingMessage = 6;
optional bytes senderKeyDistributionMessage = 7;
optional bytes decryptionErrorMessage = 8;
}
// Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node).

View File

@ -39,16 +39,18 @@ message UnidentifiedSenderMessage {
// reserved 3 to 6;
SENDERKEY_MESSAGE = 7;
PLAINTEXT_CONTENT = 8;
}
enum ContentHint {
// Commented out here, even though it is correct syntax. Our parser cannot handle it.
// Our parser does not handle reserved in enums: DESKTOP-1569
// reserved 0; // A content hint of "default" should never be encoded.
SUPPLEMENTARY = 1;
RETRY = 2;
// Do not insert an error.
SUPPLEMENTARY = 1;
// Put an invisible placeholder in the chat (using the groupId from the sealed sender envelope if available) and delay showing an error until later.
RESENDABLE = 2;
}
optional Type type = 1;
@ -61,4 +63,4 @@ message UnidentifiedSenderMessage {
optional bytes ephemeralPublic = 1;
optional bytes encryptedStatic = 2;
optional bytes encryptedMessage = 3;
}
}

View File

@ -10385,16 +10385,62 @@ $contact-modal-padding: 18px;
padding: 5px 12px;
}
// Module: Chat Session Refreshed Dialog
// Module: Delivery Issue Notification
.module-delivery-issue-notification {
@include font-body-2;
display: flex;
flex-direction: column;
align-items: center;
}
.module-delivery-issue-notification__first-line {
margin-bottom: 12px;
display: flex;
flex-direction: row;
align-items: center;
.module-chat-session-refreshed-dialog {
width: 360px;
padding: 16px;
padding-top: 28px;
border-radius: 8px;
margin-left: auto;
margin-right: auto;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
.module-delivery-issue-notification__icon {
height: 14px;
width: 14px;
display: inline-block;
margin-right: 8px;
@include light-theme {
@include color-svg(
'../images/icons/v2/error-outline-12.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/error-outline-12.svg',
$color-gray-25
);
}
}
.module-delivery-issue-notification__button {
@include button-reset;
@include button-light-blue-text;
@include button-small;
@include font-body-2;
padding: 5px 12px;
}
// Module: Chat Session Refreshed Dialog
.module-chat-session-refreshed-dialog {
@include light-theme {
background-color: $color-white;
}
@ -10413,6 +10459,7 @@ $contact-modal-padding: 18px;
.module-chat-session-refreshed-dialog__buttons {
text-align: right;
margin-top: 20px;
padding: 3px;
}
.module-chat-session-refreshed-dialog__button {
@include font-body-1-bold;
@ -10427,6 +10474,42 @@ $contact-modal-padding: 18px;
@include button-secondary;
}
// Module: Delivery Issue Dialog
.module-delivery-issue-dialog {
// margin-left: auto;
// margin-right: auto;
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-95;
}
}
.module-delivery-issue-dialog__image {
text-align: center;
}
.module-delivery-issue-dialog__title {
@include font-body-1-bold;
margin-top: 10px;
margin-bottom: 3px;
}
.module-delivery-issue-dialog__buttons {
text-align: right;
margin-top: 20px;
padding: 3px;
}
.module-delivery-issue-dialog__button {
@include font-body-1-bold;
@include button-reset;
@include button-primary;
border-radius: 4px;
padding: 7px 14px;
margin-left: 12px;
}
/* Third-party module: react-contextmenu*/
.react-contextmenu {

View File

@ -3,12 +3,19 @@
/* eslint-disable no-console */
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js');
const Long = require('../components/long/dist/Long.js');
const { setEnvironment, Environment } = require('../ts/environment');
chai.use(chaiAsPromised);
setEnvironment(Environment.Test);
const storageMap = new Map();
// To replicate logic we have on the client side
global.window = {
log: {
@ -21,6 +28,10 @@ global.window = {
ByteBuffer,
Long,
},
storage: {
get: key => storageMap.get(key),
put: async (key, value) => storageMap.set(key, value),
},
};
// For ducks/network.getEmptyState()

View File

@ -1,6 +1,12 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import {
DecryptionErrorMessage,
PlaintextContent,
} from '@signalapp/signal-client';
import { DataMessageClass } from './textsecure.d';
import { MessageAttributesType } from './model-types.d';
import { WhatIsThis } from './window.d';
@ -22,10 +28,38 @@ import { ourProfileKeyService } from './services/ourProfileKey';
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
import { setToExpire } from './services/MessageUpdater';
import { LatestQueue } from './util/LatestQueue';
import { parseIntOrThrow } from './util/parseIntOrThrow';
import {
DecryptionErrorType,
RetryRequestType,
} from './textsecure/MessageReceiver';
import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
export function isOverHourIntoPast(timestamp: number): boolean {
const HOUR = 1000 * 60 * 60;
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
}
type SessionResetsType = Record<string, number>;
export async function cleanupSessionResets(): Promise<void> {
const sessionResets = window.storage.get<SessionResetsType>(
'sessionResets',
{}
);
const keys = Object.keys(sessionResets);
keys.forEach(key => {
const timestamp = sessionResets[key];
if (!timestamp || isOverHourIntoPast(timestamp)) {
delete sessionResets[key];
}
});
await window.storage.put('sessionResets', sessionResets);
}
export async function startApp(): Promise<void> {
window.startupProcessingQueue = new window.Signal.Util.StartupQueue();
window.attachmentDownloadQueue = [];
@ -377,6 +411,27 @@ export async function startApp(): Promise<void> {
}
first = false;
cleanupSessionResets();
const retryPlaceholders = new window.Signal.Util.RetryPlaceholders();
window.Signal.Services.retryPlaceholders = retryPlaceholders;
setInterval(async () => {
const expired = await retryPlaceholders.getExpiredAndRemove();
window.log.info(
`retryPlaceholders/interval: Found ${expired.length} expired items`
);
expired.forEach(item => {
const { conversationId, senderUuid } = item;
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
const now = Date.now();
conversation.queueJob(() =>
conversation.addDeliveryIssue(now, senderUuid)
);
}
});
}, 5 * 60 * 1000);
// These make key operations available to IPC handlers created in preload.js
window.Events = {
getDeviceName: () => window.textsecure.storage.user.getDeviceName(),
@ -1949,7 +2004,8 @@ export async function startApp(): Promise<void> {
addQueuedEventListener('read', onReadReceipt);
addQueuedEventListener('verified', onVerified);
addQueuedEventListener('error', onError);
addQueuedEventListener('light-session-reset', onLightSessionReset);
addQueuedEventListener('decryption-error', onDecryptionError);
addQueuedEventListener('retry-request', onRetryRequest);
addQueuedEventListener('empty', onEmpty);
addQueuedEventListener('reconnect', onReconnect);
addQueuedEventListener('configuration', onConfiguration);
@ -2061,7 +2117,7 @@ export async function startApp(): Promise<void> {
await server.registerCapabilities({
'gv2-3': true,
'gv1-migration': true,
senderKey: false,
senderKey: true,
});
} catch (error) {
window.log.error(
@ -3287,18 +3343,271 @@ export async function startApp(): Promise<void> {
window.log.warn('background onError: Doing nothing with incoming error');
}
type LightSessionResetEventType = Event & {
senderUuid: string;
senderDevice: number;
type RetryRequestEventType = Event & {
retryRequest: RetryRequestType;
};
function onLightSessionReset(event: LightSessionResetEventType) {
const { senderUuid, senderDevice } = event;
function isInList(
conversation: ConversationModel,
list: Array<string | undefined | null> | undefined
): boolean {
const uuid = conversation.get('uuid');
const e164 = conversation.get('e164');
const id = conversation.get('id');
if (event.confirm) {
event.confirm();
if (!list) {
return false;
}
if (list.includes(id)) {
return true;
}
if (uuid && list.includes(uuid)) {
return true;
}
if (e164 && list.includes(e164)) {
return true;
}
return false;
}
async function onRetryRequest(event: RetryRequestEventType) {
const { retryRequest } = event;
const {
requesterUuid,
requesterDevice,
sentAt,
senderDevice,
} = retryRequest;
window.log.info('onRetryRequest:', {
requesterUuid,
requesterDevice,
sentAt,
senderDevice,
});
const requesterConversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
const messages = await window.Signal.Data.getMessagesBySentAt(sentAt, {
MessageCollection: window.Whisper.MessageCollection,
});
const targetMessage = messages.find(message => {
if (message.get('sent_at') !== sentAt) {
return false;
}
if (message.get('type') !== 'outgoing') {
return false;
}
if (!isInList(requesterConversation, message.get('sent_to'))) {
return false;
}
return true;
});
if (!targetMessage) {
window.log.info(
`onRetryRequest: Did not find message sent at ${sentAt}, sent to ${requesterUuid}`
);
return;
}
if (targetMessage.isErased()) {
window.log.info(
`onRetryRequest: Message sent at ${sentAt} is erased, refusing to send again.`
);
return;
}
const HOUR = 60 * 60 * 1000;
const ONE_DAY = 24 * HOUR;
if (isOlderThan(sentAt, ONE_DAY)) {
window.log.info(
`onRetryRequest: Message sent at ${sentAt} is too old, refusing to send again.`
);
return;
}
const sentUnidentified = isInList(
requesterConversation,
targetMessage.get('unidentifiedDeliveries')
);
const wasDelivered = isInList(
requesterConversation,
targetMessage.get('delivered_to')
);
if (sentUnidentified && wasDelivered) {
window.log.info(
`onRetryRequest: Message sent at ${sentAt} was sent sealed sender and was delivered, refusing to send again.`
);
return;
}
window.log.info(
`onRetryRequest: Resending message ${sentAt} to user ${requesterUuid}`
);
const ourDeviceId = parseIntOrThrow(
window.textsecure.storage.user.getDeviceId(),
'onRetryRequest/getDeviceId'
);
if (ourDeviceId === senderDevice) {
const address = `${requesterUuid}.${requesterDevice}`;
window.log.info(
`onRetryRequest: Devices match, archiving session with ${address}`
);
await window.textsecure.storage.protocol.archiveSession(address);
}
targetMessage.resend(requesterUuid);
}
type DecryptionErrorEventType = Event & {
decryptionError: DecryptionErrorType;
};
async function onDecryptionError(event: DecryptionErrorEventType) {
const { decryptionError } = event;
const { senderUuid, senderDevice } = decryptionError;
window.log.info(`onDecryptionError: ${senderUuid}.${senderDevice}`);
const conversation = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const capabilities = conversation.get('capabilities');
if (!capabilities) {
await conversation.getProfiles();
}
if (conversation.get('capabilities')?.senderKey) {
requestResend(decryptionError);
return;
}
await startAutomaticSessionReset(decryptionError);
}
async function requestResend(decryptionError: DecryptionErrorType) {
const {
cipherTextBytes,
cipherTextType,
contentHint,
groupId,
receivedAtCounter,
receivedAtDate,
senderDevice,
senderUuid,
timestamp,
} = decryptionError;
window.log.info(`requestResend: ${senderUuid}.${senderDevice}`, {
cipherTextBytesLength: cipherTextBytes?.byteLength,
cipherTextType,
contentHint,
groupId: groupId ? `groupv2(${groupId})` : undefined,
timestamp,
});
// 1. Find the target conversation
const group = groupId
? window.ConversationController.get(groupId)
: undefined;
const sender = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const conversation = group || sender;
function immediatelyAddError() {
const receivedAt = Date.now();
conversation.queueJob(async () => {
conversation.addDeliveryIssue(receivedAt, senderUuid);
});
}
// 2. Send resend request
if (!cipherTextBytes || !isNumber(cipherTextType)) {
window.log.warn(
'requestResend: Missing cipherText information, failing over to automatic reset'
);
startAutomaticSessionReset(decryptionError);
return;
}
try {
const message = DecryptionErrorMessage.forOriginal(
Buffer.from(cipherTextBytes),
cipherTextType,
timestamp,
senderDevice
);
const plaintext = PlaintextContent.from(message);
const options = await conversation.getSendOptions();
const result = await window.textsecure.messaging.sendRetryRequest({
plaintext,
options,
uuid: senderUuid,
});
if (result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
window.log.error(
'requestResend: Failed to send retry request, failing over to automatic reset',
error && error.stack ? error.stack : error
);
startAutomaticSessionReset(decryptionError);
return;
}
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
// 3. Determine how to represent this to the user. Three different options.
// This is a sync message of some kind that cannot be resent. Don't do anything.
if (contentHint === ContentHint.SUPPLEMENTARY) {
scheduleSessionReset(senderUuid, senderDevice);
return;
}
// If we request a re-send, it might just work out for us!
if (contentHint === ContentHint.RESENDABLE) {
const { retryPlaceholders } = window.Signal.Services;
assert(retryPlaceholders, 'requestResend: adding placeholder');
window.log.warn('requestResend: Adding placeholder');
await retryPlaceholders.add({
conversationId: conversation.get('id'),
receivedAt: receivedAtDate,
receivedAtCounter,
sentAt: timestamp,
senderUuid,
});
return;
}
immediatelyAddError();
}
function scheduleSessionReset(senderUuid: string, senderDevice: number) {
// Postpone sending light session resets until the queue is empty
lightSessionResetQueue.add(() => {
window.textsecure.storage.protocol.lightSessionReset(
@ -3306,6 +3615,12 @@ export async function startApp(): Promise<void> {
senderDevice
);
});
}
function startAutomaticSessionReset(decryptionError: DecryptionErrorType) {
const { senderUuid, senderDevice } = decryptionError;
scheduleSessionReset(senderUuid, senderDevice);
const conversationId = window.ConversationController.ensureContactIds({
uuid: senderUuid,

View File

@ -4,6 +4,8 @@
import * as React from 'react';
import classNames from 'classnames';
import { Modal } from '../Modal';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
@ -12,47 +14,48 @@ export type PropsType = {
onClose: () => unknown;
};
// TODO: This should use <Modal>. See DESKTOP-1038.
export function ChatSessionRefreshedDialog(
props: PropsType
): React.ReactElement {
const { i18n, contactSupport, onClose } = props;
return (
<div className="module-chat-session-refreshed-dialog">
<div className="module-chat-session-refreshed-dialog__image">
<img
src="images/chat-session-refresh.svg"
height="110"
width="200"
alt=""
/>
<Modal hasXButton={false} i18n={i18n}>
<div className="module-chat-session-refreshed-dialog">
<div className="module-chat-session-refreshed-dialog__image">
<img
src="images/chat-session-refresh.svg"
height="110"
width="200"
alt=""
/>
</div>
<div className="module-chat-session-refreshed-dialog__title">
{i18n('ChatRefresh--notification')}
</div>
<div className="module-chat-session-refreshed-dialog__description">
{i18n('ChatRefresh--summary')}
</div>
<div className="module-chat-session-refreshed-dialog__buttons">
<button
type="button"
onClick={contactSupport}
className={classNames(
'module-chat-session-refreshed-dialog__button',
'module-chat-session-refreshed-dialog__button--secondary'
)}
>
{i18n('ChatRefresh--contactSupport')}
</button>
<button
type="button"
onClick={onClose}
className="module-chat-session-refreshed-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
<div className="module-chat-session-refreshed-dialog__title">
{i18n('ChatRefresh--notification')}
</div>
<div className="module-chat-session-refreshed-dialog__description">
{i18n('ChatRefresh--summary')}
</div>
<div className="module-chat-session-refreshed-dialog__buttons">
<button
type="button"
onClick={contactSupport}
className={classNames(
'module-chat-session-refreshed-dialog__button',
'module-chat-session-refreshed-dialog__button--secondary'
)}
>
{i18n('ChatRefresh--contactSupport')}
</button>
<button
type="button"
onClick={onClose}
className="module-chat-session-refreshed-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
</Modal>
);
}

View File

@ -5,7 +5,6 @@ import React, { useCallback, useState, ReactElement } from 'react';
import { LocalizerType } from '../../types/Util';
import { ModalHost } from '../ModalHost';
import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog';
type PropsHousekeepingType = {
@ -50,13 +49,11 @@ export function ChatSessionRefreshedNotification(
{i18n('ChatRefresh--learnMore')}
</button>
{isDialogOpen ? (
<ModalHost onClose={closeDialog}>
<ChatSessionRefreshedDialog
onClose={closeDialog}
contactSupport={wrappedContactSupport}
i18n={i18n}
/>
</ModalHost>
<ChatSessionRefreshedDialog
onClose={closeDialog}
contactSupport={wrappedContactSupport}
i18n={i18n}
/>
) : null}
</div>
);

View File

@ -0,0 +1,27 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { DeliveryIssueDialog } from './DeliveryIssueDialog';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const sender = getDefaultConversation();
storiesOf('Components/Conversation/DeliveryIssueDialog', module).add(
'Default',
() => {
return (
<DeliveryIssueDialog
i18n={i18n}
sender={sender}
onClose={action('onClose')}
/>
);
}
);

View File

@ -0,0 +1,57 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { ConversationType } from '../../state/ducks/conversations';
import { Modal } from '../Modal';
import { Intl } from '../Intl';
import { Emojify } from './Emojify';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
i18n: LocalizerType;
sender: ConversationType;
onClose: () => unknown;
};
export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
const { i18n, sender, onClose } = props;
return (
<Modal hasXButton={false} i18n={i18n}>
<div className="module-delivery-issue-dialog">
<div className="module-delivery-issue-dialog__image">
<img
src="images/delivery-issue.svg"
height="110"
width="200"
alt=""
/>
</div>
<div className="module-delivery-issue-dialog__title">
{i18n('DeliveryIssue--title')}
</div>
<div className="module-delivery-issue-dialog__description">
<Intl
id="DeliveryIssue--summary"
components={{
sender: <Emojify text={sender.title} />,
}}
i18n={i18n}
/>
</div>
<div className="module-delivery-issue-dialog__buttons">
<button
type="button"
onClick={onClose}
className="module-delivery-issue-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { DeliveryIssueNotification } from './DeliveryIssueNotification';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const sender = getDefaultConversation();
storiesOf('Components/Conversation/DeliveryIssueNotification', module).add(
'Default',
() => {
return <DeliveryIssueNotification i18n={i18n} sender={sender} />;
}
);

View File

@ -0,0 +1,68 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, ReactElement } from 'react';
import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { Intl } from '../Intl';
import { Emojify } from './Emojify';
import { DeliveryIssueDialog } from './DeliveryIssueDialog';
export type PropsDataType = {
sender?: ConversationType;
};
type PropsHousekeepingType = {
i18n: LocalizerType;
};
export type PropsType = PropsDataType & PropsHousekeepingType;
export function DeliveryIssueNotification(
props: PropsType
): ReactElement | null {
const { i18n, sender } = props;
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const openDialog = useCallback(() => {
setIsDialogOpen(true);
}, [setIsDialogOpen]);
const closeDialog = useCallback(() => {
setIsDialogOpen(false);
}, [setIsDialogOpen]);
if (!sender) {
return null;
}
return (
<div className="module-delivery-issue-notification">
<div className="module-delivery-issue-notification__first-line">
<span className="module-delivery-issue-notification__icon" />
<Intl
id="DeliveryIssue--notification"
components={{
sender: <Emojify text={sender.firstName || sender.title} />,
}}
i18n={i18n}
/>
</div>
<button
type="button"
onClick={openDialog}
className="module-delivery-issue-notification__button"
>
{i18n('DeliveryIssue--learnMore')}
</button>
{isDialogOpen ? (
<DeliveryIssueDialog
i18n={i18n}
sender={sender}
onClose={closeDialog}
/>
) : null}
</div>
);
}

View File

@ -11,6 +11,7 @@ import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
import { CallMode } from '../../types/Calling';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -99,9 +100,19 @@ storiesOf('Components/Conversation/TimelineItem', module)
{
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
expireTimer: 60,
...getDefaultConversation(),
type: 'fromOther',
},
},
{
type: 'chatSessionRefreshed',
},
{
type: 'deliveryIssue',
data: {
sender: getDefaultConversation(),
},
},
{
@ -367,7 +378,6 @@ storiesOf('Components/Conversation/TimelineItem', module)
item={item as TimelineItemProps['item']}
i18n={i18n}
/>
<hr />
</React.Fragment>
))}
</>

View File

@ -19,6 +19,10 @@ import {
ChatSessionRefreshedNotification,
PropsActionsType as PropsChatSessionRefreshedActionsType,
} from './ChatSessionRefreshedNotification';
import {
DeliveryIssueNotification,
PropsDataType as DeliveryIssueProps,
} from './DeliveryIssueNotification';
import { CallingNotificationType } from '../../util/callingNotification';
import { InlineNotificationWrapper } from './InlineNotificationWrapper';
import {
@ -66,6 +70,10 @@ type ChatSessionRefreshedType = {
type: 'chatSessionRefreshed';
data: null;
};
type DeliveryIssueType = {
type: 'deliveryIssue';
data: DeliveryIssueProps;
};
type LinkNotificationType = {
type: 'linkNotification';
data: null;
@ -114,6 +122,7 @@ type ProfileChangeNotificationType = {
export type TimelineItemType =
| CallHistoryType
| ChatSessionRefreshedType
| DeliveryIssueType
| GroupNotificationType
| GroupV1MigrationType
| GroupV2ChangeType
@ -203,6 +212,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n={i18n}
/>
);
} else if (item.type === 'deliveryIssue') {
notification = <DeliveryIssueNotification {...item.data} i18n={i18n} />;
} else if (item.type === 'linkNotification') {
notification = (
<div className="module-message-unsynced">

View File

@ -1259,6 +1259,9 @@ export async function modifyGroupV2({
const sendOptions = await conversation.getSendOptions();
const timestamp = Date.now();
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const promise = conversation.wrapSend(
window.Signal.Util.sendToGroup(
@ -1272,6 +1275,7 @@ export async function modifyGroupV2({
profileKey,
},
conversation,
ContentHint.SUPPLEMENTARY,
sendOptions
)
);
@ -1629,6 +1633,10 @@ export async function createGroupV2({
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const sendOptions = await conversation.getSendOptions();
await wrapWithSyncMessageSend({
conversation,
@ -1640,7 +1648,9 @@ export async function createGroupV2({
timestamp,
profileKey,
},
conversation
conversation,
ContentHint.SUPPLEMENTARY,
sendOptions
),
timestamp,
});
@ -2145,6 +2155,11 @@ export async function initiateMigrationToGroupV2(
| ArrayBuffer
| undefined = await ourProfileKeyService.get();
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const sendOptions = await conversation.getSendOptions();
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
@ -2158,7 +2173,9 @@ export async function initiateMigrationToGroupV2(
timestamp,
profileKey: ourProfileKey,
},
conversation
conversation,
ContentHint.SUPPLEMENTARY,
sendOptions
),
timestamp,
});

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

@ -129,18 +129,19 @@ export type MessageAttributesType = {
id: string;
type?:
| 'incoming'
| 'outgoing'
| 'group'
| 'keychange'
| 'verified-change'
| 'message-history-unsynced'
| 'call-history'
| 'chat-session-refreshed'
| 'delivery-issue'
| 'group'
| 'group-v1-migration'
| 'group-v2-change'
| 'incoming'
| 'keychange'
| 'message-history-unsynced'
| 'outgoing'
| 'profile-change'
| 'timer-notification';
| 'timer-notification'
| 'verified-change';
body: string;
attachments: Array<WhatIsThis>;
preview: Array<WhatIsThis>;

View File

@ -139,6 +139,8 @@ export class ConversationModel extends window.Backbone
throttledFetchSMSOnlyUUID?: () => Promise<void> | void;
throttledMaybeMigrateV1Group?: () => Promise<void> | void;
typingRefreshTimer?: NodeJS.Timer | null;
typingPauseTimer?: NodeJS.Timer | null;
@ -304,7 +306,11 @@ export class ConversationModel extends window.Backbone
this.isFetchingUUID = this.isSMSOnly();
this.throttledFetchSMSOnlyUUID = window._.throttle(
this.fetchSMSOnlyUUID,
this.fetchSMSOnlyUUID.bind(this),
FIVE_MINUTES
);
this.throttledMaybeMigrateV1Group = window._.throttle(
this.maybeMigrateV1Group.bind(this),
FIVE_MINUTES
);
@ -811,6 +817,10 @@ export class ConversationModel extends window.Backbone
}
setRegistered(): void {
if (this.get('discoveredUnregisteredAt') === undefined) {
return;
}
window.log.info(
`Conversation ${this.idForLogging()} is registered once again`
);
@ -1193,15 +1203,18 @@ export class ConversationModel extends window.Backbone
}
);
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const sendOptions = await this.getSendOptions();
if (this.isPrivate()) {
const silent = true;
this.wrapSend(
window.textsecure.messaging.sendMessageProtoAndWait(
timestamp,
groupMembers,
contentMessage,
silent,
ContentHint.SUPPLEMENTARY,
undefined,
{
...sendOptions,
online: true,
@ -1211,6 +1224,7 @@ export class ConversationModel extends window.Backbone
} else {
this.wrapSend(
window.Signal.Util.sendContentMessageToGroup({
contentHint: ContentHint.SUPPLEMENTARY,
contentMessage,
conversation: this,
online: true,
@ -2438,7 +2452,8 @@ export class ConversationModel extends window.Backbone
async addChatSessionRefreshed(receivedAt: number): Promise<void> {
window.log.info(
`addChatSessionRefreshed: adding for ${this.idForLogging()}`
`addChatSessionRefreshed: adding for ${this.idForLogging()}`,
{ receivedAt }
);
const message = ({
@ -2466,6 +2481,43 @@ export class ConversationModel extends window.Backbone
this.trigger('newmessage', model);
}
async addDeliveryIssue(
receivedAt: number,
senderUuid: string
): Promise<void> {
window.log.info(`addDeliveryIssue: adding for ${this.idForLogging()}`, {
receivedAt,
senderUuid,
});
const message = ({
conversationId: this.id,
type: 'delivery-issue',
sourceUuid: senderUuid,
sent_at: receivedAt,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: receivedAt,
unread: 1,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown) as typeof window.Whisper.MessageAttributesType;
const id = await window.Signal.Data.saveMessage(message, {
Message: window.Whisper.Message,
});
const model = window.MessageController.register(
id,
new window.Whisper.Message({
...message,
id,
})
);
this.trigger('newmessage', model);
await this.notify(model);
}
async addKeyChange(keyChangedId: string): Promise<void> {
window.log.info(
'adding key change advisory for',
@ -3108,6 +3160,10 @@ export class ConversationModel extends window.Backbone
profileKey = await ourProfileKeyService.get();
}
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
if (this.isPrivate()) {
return window.textsecure.messaging.sendMessageToIdentifier(
destination,
@ -3120,6 +3176,8 @@ export class ConversationModel extends window.Backbone
targetTimestamp,
timestamp,
undefined, // expireTimer
ContentHint.SUPPLEMENTARY,
undefined, // groupId
profileKey,
options
);
@ -3134,6 +3192,7 @@ export class ConversationModel extends window.Backbone
profileKey,
},
this,
ContentHint.SUPPLEMENTARY,
options
);
})();
@ -3254,6 +3313,9 @@ export class ConversationModel extends window.Backbone
}
const options = await this.getSendOptions();
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const promise = (() => {
if (this.isPrivate()) {
@ -3268,6 +3330,8 @@ export class ConversationModel extends window.Backbone
undefined, // deletedForEveryoneTimestamp
timestamp,
expireTimer,
ContentHint.SUPPLEMENTARY,
undefined, // groupId
profileKey,
options
);
@ -3285,6 +3349,7 @@ export class ConversationModel extends window.Backbone
profileKey,
},
this,
ContentHint.SUPPLEMENTARY,
options
);
})();
@ -3492,6 +3557,9 @@ export class ConversationModel extends window.Backbone
const conversationType = this.get('type');
const options = await this.getSendOptions();
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
let promise;
if (conversationType === Message.GROUP) {
@ -3510,6 +3578,7 @@ export class ConversationModel extends window.Backbone
mentions,
},
this,
ContentHint.RESENDABLE,
options
);
} else {
@ -3524,6 +3593,8 @@ export class ConversationModel extends window.Backbone
undefined, // deletedForEveryoneTimestamp
now,
expireTimer,
ContentHint.RESENDABLE,
undefined, // groupId
profileKey,
options
);

View File

@ -41,6 +41,7 @@ import {
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration';
import { PropsDataType as DeliveryIssuePropsType } from '../components/conversation/DeliveryIssueNotification';
import {
PropsData as GroupNotificationProps,
ChangeType,
@ -132,6 +133,10 @@ type MessageBubbleProps =
type: 'chatSessionRefreshed';
data: null;
}
| {
type: 'deliveryIssue';
data: DeliveryIssuePropsType;
}
| {
type: 'message';
data: PropsForMessage;
@ -407,6 +412,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
data: null,
};
}
if (this.isDeliveryIssue()) {
return {
type: 'deliveryIssue',
data: this.getPropsForDeliveryIssue(),
};
}
return {
type: 'message',
@ -581,6 +592,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return this.get('type') === 'chat-session-refreshed';
}
isDeliveryIssue(): boolean {
return this.get('type') === 'delivery-issue';
}
isProfileChange(): boolean {
return this.get('type') === 'profile-change';
}
@ -874,6 +889,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
getPropsForDeliveryIssue(): DeliveryIssuePropsType {
const sender = this.getContact()?.format();
return {
sender,
};
}
getPropsForProfileChange(): ProfileChangeNotificationPropsType {
const change = this.get('profileChange');
const changedId = this.get('changedId');
@ -1359,6 +1382,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
getNotificationData(): { emoji?: string; text: string } {
if (this.isDeliveryIssue()) {
return {
emoji: '⚠️',
text: window.i18n('DeliveryIssue--preview'),
};
}
if (this.isChatSessionRefreshed()) {
return {
emoji: '🔁',
@ -1893,6 +1923,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Rendered sync messages
const isCallHistory = this.isCallHistory();
const isChatSessionRefreshed = this.isChatSessionRefreshed();
const isDeliveryIssue = this.isDeliveryIssue();
const isGroupUpdate = this.isGroupUpdate();
const isGroupV2Change = this.isGroupV2Change();
const isEndSession = this.isEndSession();
@ -1922,6 +1953,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Rendered sync messages
isCallHistory ||
isChatSessionRefreshed ||
isDeliveryIssue ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||
@ -2216,6 +2248,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let promise;
const options = await conversation.getSendOptions();
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
if (conversation.isPrivate()) {
const [identifier] = recipients;
promise = window.textsecure.messaging.sendMessageToIdentifier(
@ -2229,6 +2265,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
ContentHint.RESENDABLE,
undefined, // groupId
profileKey,
options
);
@ -2271,6 +2309,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
groupV1,
},
conversation,
ContentHint.RESENDABLE,
options,
partialSend
);
@ -2403,7 +2442,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async resend(identifier: string): Promise<void | null | Array<void>> {
const error = this.removeOutgoingErrors(identifier);
if (!error) {
window.log.warn('resend: requested number was not present in errors');
window.log.warn(
'resend: requested number was not present in errors. continuing.'
);
}
if (this.isErased()) {
window.log.warn('resend: message is erased; refusing to resend');
return null;
}
@ -2431,7 +2476,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
// flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
@ -2444,22 +2488,59 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return this.sendSyncMessageOnly(dataMessage);
}
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const parentConversation = this.getConversation();
const groupId = parentConversation?.get('groupId');
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(identifier);
const promise = window.textsecure.messaging.sendMessageToIdentifier(
identifier,
body,
const group =
groupId && parentConversation?.isGroupV1()
? {
id: groupId,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
}
: undefined;
const timestamp = this.get('sent_at');
const contentMessage = await window.textsecure.messaging.getContentMessage({
attachments,
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
profileKey,
body,
expireTimer: this.get('expireTimer'),
group,
groupV2: parentConversation?.getGroupV2Info(),
preview: previewWithData,
quote: quoteWithData,
mentions: this.get('bodyRanges'),
recipients: [identifier],
sticker: stickerWithData,
timestamp,
});
if (parentConversation) {
const senderKeyInfo = parentConversation.get('senderKeyInfo');
if (senderKeyInfo && senderKeyInfo.distributionId) {
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
senderKeyInfo.distributionId
);
window.dcodeIO.ByteBuffer.wrap(
window.Signal.Crypto.typedArrayToArrayBuffer(
senderKeyDistributionMessage.serialize()
)
);
}
}
const promise = window.textsecure.messaging.sendMessageProtoAndWait(
timestamp,
[identifier],
contentMessage,
ContentHint.RESENDABLE,
groupId && parentConversation?.isGroupV2() ? groupId : undefined,
sendOptions
);
@ -2506,7 +2587,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
sent_to: _.union(sentTo, result.successfulIdentifiers),
sent: true,
expirationStartTimestamp: Date.now(),
unidentifiedDeliveries: result.unidentifiedDeliveries,
unidentifiedDeliveries: _.union(
this.get('unidentifiedDeliveries') || [],
result.unidentifiedDeliveries
),
});
if (!this.doNotSave) {
@ -2595,7 +2679,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
sent_to: _.union(sentTo, result.successfulIdentifiers),
sent: true,
expirationStartTimestamp,
unidentifiedDeliveries: result.unidentifiedDeliveries,
unidentifiedDeliveries: _.union(
this.get('unidentifiedDeliveries') || [],
result.unidentifiedDeliveries
),
});
promises.push(this.sendSyncMessage());
} else if (result.errors) {
@ -3452,6 +3539,24 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
// Now check for decryption error placeholders
const { retryPlaceholders } = window.Signal.Services;
if (retryPlaceholders) {
const item = await retryPlaceholders.findByMessageAndRemove(
conversationId,
message.get('sent_at')
);
if (item) {
window.log.info(
`handleDataMessage: found retry placeholder. Updating ${message.idForLogging()} received_at/received_at_ms`
);
message.set({
received_at: item.receivedAtCounter,
received_at_ms: item.receivedAt,
});
}
}
// GroupV2
if (initialMessage.groupV2) {

View File

@ -766,6 +766,9 @@ export class CallingClass {
const timestamp = Date.now();
// We "fire and forget" because sending this message is non-essential.
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
@ -773,6 +776,7 @@ export class CallingClass {
window.Signal.Util.sendToGroup(
{ groupCallUpdate: { eraId }, groupV2, timestamp },
conversation,
ContentHint.SUPPLEMENTARY,
sendOptions
),
timestamp,

View File

@ -159,18 +159,19 @@ export type MessageType = {
source?: string;
sourceUuid?: string;
type?:
| 'incoming'
| 'outgoing'
| 'group'
| 'keychange'
| 'verified-change'
| 'message-history-unsynced'
| 'call-history'
| 'chat-session-refreshed'
| 'delivery-issue'
| 'group'
| 'group-v1-migration'
| 'group-v2-change'
| 'incoming'
| 'keychange'
| 'message-history-unsynced'
| 'outgoing'
| 'profile-change'
| 'timer-notification';
| 'timer-notification'
| 'verified-change';
quote?: { author?: string; authorUuid?: string };
received_at: number;
sent_at?: number;

View File

@ -0,0 +1,285 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
getOneHourAgo,
RetryItemType,
RetryPlaceholders,
STORAGE_KEY,
} from '../../util/retryPlaceholders';
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('RetryPlaceholders', () => {
beforeEach(() => {
window.storage.put(STORAGE_KEY, null);
});
function getDefaultItem(): RetryItemType {
return {
conversationId: 'conversation-id',
sentAt: Date.now() - 10,
receivedAt: Date.now() - 5,
receivedAtCounter: 4,
senderUuid: 'sender-uuid',
};
}
describe('constructor', () => {
it('loads previously-saved data on creation', () => {
const items: Array<RetryItemType> = [
getDefaultItem(),
{ ...getDefaultItem(), conversationId: 'conversation-id-2' },
];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
});
it('starts with no data if provided data fails to parse', () => {
window.storage.put(STORAGE_KEY, [
{ item: 'is wrong shape!' },
{ bad: 'is not good!' },
]);
const placeholders = new RetryPlaceholders();
assert.strictEqual(0, placeholders.getCount());
});
});
describe('#add', () => {
it('adds one item', async () => {
const placeholders = new RetryPlaceholders();
await placeholders.add(getDefaultItem());
assert.strictEqual(1, placeholders.getCount());
});
it('throws if provided data fails to parse', () => {
const placeholders = new RetryPlaceholders();
assert.isRejected(
placeholders.add({
item: 'is wrong shape!',
} as any),
'Item did not match schema'
);
});
});
describe('#getNextToExpire', () => {
it('returns nothing if no items', () => {
const placeholders = new RetryPlaceholders();
assert.strictEqual(0, placeholders.getCount());
assert.isUndefined(placeholders.getNextToExpire());
});
it('returns only item if just one item', () => {
const item = getDefaultItem();
const items: Array<RetryItemType> = [item];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(1, placeholders.getCount());
assert.deepEqual(item, placeholders.getNextToExpire());
});
it('returns soonest expiration given a list, and after add', async () => {
const older = {
...getDefaultItem(),
receivedAt: Date.now(),
};
const newer = {
...getDefaultItem(),
receivedAt: Date.now() + 10,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual(older, placeholders.getNextToExpire());
const oldest = {
...getDefaultItem(),
receivedAt: Date.now() - 5,
};
await placeholders.add(oldest);
assert.strictEqual(3, placeholders.getCount());
assert.deepEqual(oldest, placeholders.getNextToExpire());
});
});
describe('#getExpiredAndRemove', () => {
it('does nothing if no item expired', async () => {
const older = {
...getDefaultItem(),
receivedAt: Date.now() + 10,
};
const newer = {
...getDefaultItem(),
receivedAt: Date.now() + 15,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual([], await placeholders.getExpiredAndRemove());
assert.strictEqual(2, placeholders.getCount());
});
it('removes just one if expired', async () => {
const older = {
...getDefaultItem(),
receivedAt: getOneHourAgo() - 1000,
};
const newer = {
...getDefaultItem(),
receivedAt: Date.now() + 15,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual([older], await placeholders.getExpiredAndRemove());
assert.strictEqual(1, placeholders.getCount());
assert.deepEqual(newer, placeholders.getNextToExpire());
});
it('removes all if expired', async () => {
const older = {
...getDefaultItem(),
receivedAt: getOneHourAgo() - 1000,
};
const newer = {
...getDefaultItem(),
receivedAt: getOneHourAgo() - 900,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual(
[older, newer],
await placeholders.getExpiredAndRemove()
);
assert.strictEqual(0, placeholders.getCount());
});
});
describe('#findByConversationAndRemove', () => {
it('does nothing if no items found matching conversation', async () => {
const older = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
};
const newer = {
...getDefaultItem(),
conversationId: 'conversation-id-2',
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual(
[],
await placeholders.findByConversationAndRemove('conversation-id-3')
);
assert.strictEqual(2, placeholders.getCount());
});
it('removes all items matching conversation', async () => {
const convo1a = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
receivedAt: Date.now() - 5,
};
const convo1b = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
receivedAt: Date.now() - 4,
};
const convo2a = {
...getDefaultItem(),
conversationId: 'conversation-id-2',
receivedAt: Date.now() + 15,
};
const items: Array<RetryItemType> = [convo1a, convo1b, convo2a];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(3, placeholders.getCount());
assert.deepEqual(
[convo1a, convo1b],
await placeholders.findByConversationAndRemove('conversation-id-1')
);
assert.strictEqual(1, placeholders.getCount());
const convo2b = {
...getDefaultItem(),
conversationId: 'conversation-id-2',
receivedAt: Date.now() + 16,
};
await placeholders.add(convo2b);
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual(
[convo2a, convo2b],
await placeholders.findByConversationAndRemove('conversation-id-2')
);
assert.strictEqual(0, placeholders.getCount());
});
});
describe('#findByMessageAndRemove', () => {
it('does nothing if no item matching message found', async () => {
const sentAt = Date.now() - 20;
const older = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
sentAt: Date.now() - 10,
};
const newer = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
sentAt: Date.now() - 11,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.isUndefined(
await placeholders.findByMessageAndRemove('conversation-id-1', sentAt)
);
assert.strictEqual(2, placeholders.getCount());
});
it('removes the item matching message', async () => {
const sentAt = Date.now() - 20;
const older = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
sentAt: Date.now() - 10,
};
const newer = {
...getDefaultItem(),
conversationId: 'conversation-id-1',
sentAt,
};
const items: Array<RetryItemType> = [older, newer];
window.storage.put(STORAGE_KEY, items);
const placeholders = new RetryPlaceholders();
assert.strictEqual(2, placeholders.getCount());
assert.deepEqual(
newer,
await placeholders.findByMessageAndRemove('conversation-id-1', sentAt)
);
assert.strictEqual(1, placeholders.getCount());
});
});
});

View File

@ -0,0 +1,57 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { isOverHourIntoPast, cleanupSessionResets } from '../background';
describe('#isOverHourIntoPast', () => {
it('returns false for now', () => {
assert.isFalse(isOverHourIntoPast(Date.now()));
});
it('returns false for 5 minutes ago', () => {
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
assert.isFalse(isOverHourIntoPast(fiveMinutesAgo));
});
it('returns true for 65 minutes ago', () => {
const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000;
assert.isTrue(isOverHourIntoPast(sixtyFiveMinutesAgo));
});
});
describe('#cleanupSessionResets', () => {
it('leaves empty object alone', () => {
window.storage.put('sessionResets', {});
cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = {};
assert.deepEqual(actual, expected);
});
it('filters out any timestamp older than one hour', () => {
const startValue = {
one: Date.now() - 1,
two: Date.now(),
three: Date.now() - 65 * 60 * 1000,
};
window.storage.put('sessionResets', startValue);
cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = window._.pick(startValue, ['one', 'two']);
assert.deepEqual(actual, expected);
});
it('filters out falsey items', () => {
const startValue = {
one: 0,
two: false,
three: Date.now(),
};
window.storage.put('sessionResets', startValue);
cleanupSessionResets();
const actual = window.storage.get('sessionResets');
const expected = window._.pick(startValue, ['three']);
assert.deepEqual(actual, expected);
});
});

10
ts/textsecure.d.ts vendored
View File

@ -1,6 +1,8 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client';
import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import MessageSender from './textsecure/SendMessage';
@ -571,6 +573,7 @@ export declare class ContentClass {
receiptMessage?: ReceiptMessageClass;
typingMessage?: TypingMessageClass;
senderKeyDistributionMessage?: ByteBufferClass;
decryptionErrorMessage?: ByteBufferClass;
}
export declare class DataMessageClass {
@ -722,6 +725,9 @@ export declare class EnvelopeClass {
receivedAtDate: number;
unidentifiedDeliveryReceived?: boolean;
messageAgeSec?: number;
contentHint?: number;
groupId?: string;
usmc?: UnidentifiedSenderMessageContent;
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -731,6 +737,7 @@ export declare namespace EnvelopeClass {
static PREKEY_BUNDLE: number;
static RECEIPT: number;
static UNIDENTIFIED_SENDER: number;
static PLAINTEXT_CONTENT: number;
}
}
@ -1386,10 +1393,11 @@ export declare namespace UnidentifiedSenderMessageClass.Message {
static PREKEY_MESSAGE: number;
static MESSAGE: number;
static SENDERKEY_MESSAGE: number;
static PLAINTEXT_CONTENT: number;
}
class ContentHint {
static SUPPLEMENTARY: number;
static RETRY: number;
static RESENDABLE: number;
}
}

View File

@ -70,8 +70,8 @@ export class OutgoingMessageError extends ReplayableError {
// Note: Data to resend message is no longer captured
constructor(
incomingIdentifier: string,
_m: ArrayBuffer,
_t: number,
_m: unknown,
_t: unknown,
httpError?: Error
) {
const identifier = incomingIdentifier.split('.')[0];

View File

@ -13,9 +13,12 @@
import { isNumber, map, omit, noop } from 'lodash';
import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid';
import { z } from 'zod';
import {
DecryptionErrorMessage,
groupDecrypt,
PlaintextContent,
PreKeySignalMessage,
processSenderKeyDistributionMessage,
ProtocolAddress,
@ -73,7 +76,30 @@ const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
type SessionResetsType = Record<string, number>;
const decryptionErrorTypeSchema = z
.object({
cipherTextBytes: z.instanceof(ArrayBuffer).optional(),
cipherTextType: z.number().optional(),
contentHint: z.number().optional(),
groupId: z.string().optional(),
receivedAtCounter: z.number(),
receivedAtDate: z.number(),
senderDevice: z.number(),
senderUuid: z.string(),
timestamp: z.number(),
})
.passthrough();
export type DecryptionErrorType = z.infer<typeof decryptionErrorTypeSchema>;
const retryRequestTypeSchema = z
.object({
requesterUuid: z.string(),
requesterDevice: z.number(),
senderDevice: z.number(),
sentAt: z.number(),
})
.passthrough();
export type RetryRequestType = z.infer<typeof retryRequestTypeSchema>;
declare global {
// We want to extend `Event`, so we need an interface.
@ -107,6 +133,8 @@ declare global {
timestamp?: any;
typing?: any;
verified?: any;
retryRequest?: RetryRequestType;
decryptionError?: DecryptionErrorType;
}
// We want to extend `Error`, so we need an interface.
// eslint-disable-next-line no-restricted-syntax
@ -261,8 +289,6 @@ class MessageReceiverInner extends EventTarget {
maxSize: 30,
processBatch: this.cacheRemoveBatch.bind(this),
});
this.cleanupSessionResets();
}
static stringToArrayBuffer = (string: string): ArrayBuffer =>
@ -1122,7 +1148,14 @@ class MessageReceiverInner extends EventTarget {
ArrayBuffer | { isMe: boolean } | { isBlocked: boolean } | undefined
>;
if (envelope.type === envelopeTypeEnum.CIPHERTEXT) {
if (envelope.type === envelopeTypeEnum.PLAINTEXT_CONTENT) {
const buffer = Buffer.from(ciphertext.toArrayBuffer());
const plaintextContent = PlaintextContent.deserialize(buffer);
promise = Promise.resolve(
this.unpad(typedArrayToArrayBuffer(plaintextContent.body()))
);
} else if (envelope.type === envelopeTypeEnum.CIPHERTEXT) {
window.log.info('message from', this.getEnvelopeId(envelope));
if (!identifier) {
throw new Error(
@ -1215,6 +1248,13 @@ class MessageReceiverInner extends EventTarget {
originalSource || originalSourceUuid
);
// eslint-disable-next-line no-param-reassign
envelope.contentHint = messageContent.contentHint();
// eslint-disable-next-line no-param-reassign
envelope.groupId = messageContent.groupId()?.toString('base64');
// eslint-disable-next-line no-param-reassign
envelope.usmc = messageContent;
if (
(envelope.source && this.isBlocked(envelope.source)) ||
(envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid))
@ -1231,6 +1271,17 @@ class MessageReceiverInner extends EventTarget {
);
}
if (
messageContent.msgType() ===
unidentifiedSenderTypeEnum.PLAINTEXT_CONTENT
) {
const plaintextContent = PlaintextContent.deserialize(
messageContent.contents()
);
return plaintextContent.body();
}
if (
messageContent.msgType() ===
unidentifiedSenderTypeEnum.SENDERKEY_MESSAGE
@ -1345,10 +1396,26 @@ class MessageReceiverInner extends EventTarget {
}
if (uuid && deviceId) {
// It is safe (from deadlocks) to await this call because the session
// reset is going to be scheduled on a separate p-queue in
// ts/background.ts
await this.lightSessionReset(uuid, deviceId);
const event = new Event('decryption-error');
event.decryptionError = {
cipherTextBytes: envelope.usmc
? typedArrayToArrayBuffer(envelope.usmc.contents())
: undefined,
cipherTextType: envelope.usmc ? envelope.usmc.msgType() : undefined,
contentHint: envelope.contentHint,
groupId: envelope.groupId,
receivedAtCounter: envelope.receivedAtCounter,
receivedAtDate: envelope.receivedAtDate,
senderDevice: deviceId,
senderUuid: uuid,
timestamp: envelope.timestamp.toNumber(),
};
// Avoid deadlocks by scheduling processing on decrypted queue
this.addToQueue(
() => this.dispatchAndWait(event),
TaskType.Decrypted
);
} else {
const envelopeId = this.getEnvelopeId(envelope);
window.log.error(
@ -1360,40 +1427,6 @@ class MessageReceiverInner extends EventTarget {
});
}
isOverHourIntoPast(timestamp: number): boolean {
const HOUR = 1000 * 60 * 60;
const now = Date.now();
const oneHourIntoPast = now - HOUR;
return isNumber(timestamp) && timestamp <= oneHourIntoPast;
}
// We don't lose anything if we delete keys over an hour into the past, because we only
// change our behavior if the timestamps stored are less than an hour ago.
cleanupSessionResets(): void {
const sessionResets = window.storage.get(
'sessionResets',
{}
) as SessionResetsType;
const keys = Object.keys(sessionResets);
keys.forEach(key => {
const timestamp = sessionResets[key];
if (!timestamp || this.isOverHourIntoPast(timestamp)) {
delete sessionResets[key];
}
});
window.storage.put('sessionResets', sessionResets);
}
async lightSessionReset(uuid: string, deviceId: number): Promise<void> {
const event = new Event('light-session-reset');
event.senderUuid = uuid;
event.senderDevice = deviceId;
await this.dispatchAndWait(event);
}
async handleSentMessage(
envelope: EnvelopeClass,
sentContainer: SyncMessageClass.Sent
@ -1630,7 +1663,10 @@ class MessageReceiverInner extends EventTarget {
// make sure to process it first. If that fails, we still try to process
// the rest of the message.
try {
if (content.senderKeyDistributionMessage) {
if (
content.senderKeyDistributionMessage &&
!isByteBufferEmpty(content.senderKeyDistributionMessage)
) {
await this.handleSenderKeyDistributionMessage(
envelope,
content.senderKeyDistributionMessage
@ -1643,6 +1679,16 @@ class MessageReceiverInner extends EventTarget {
);
}
if (
content.decryptionErrorMessage &&
!isByteBufferEmpty(content.decryptionErrorMessage)
) {
await this.handleDecryptionError(
envelope,
content.decryptionErrorMessage
);
return;
}
if (content.syncMessage) {
await this.handleSyncMessage(envelope, content.syncMessage);
return;
@ -1675,6 +1721,34 @@ class MessageReceiverInner extends EventTarget {
}
}
async handleDecryptionError(
envelope: EnvelopeClass,
decryptionError: ByteBufferClass
) {
const envelopeId = this.getEnvelopeId(envelope);
window.log.info(`handleDecryptionError: ${envelopeId}`);
const buffer = Buffer.from(decryptionError.toArrayBuffer());
const request = DecryptionErrorMessage.deserialize(buffer);
this.removeFromCache(envelope);
const { sourceUuid, sourceDevice } = envelope;
if (!sourceUuid || !sourceDevice) {
window.log.error('handleDecryptionError: Missing uuid or device!');
return;
}
const event = new Event('retry-request');
event.retryRequest = {
sentAt: request.timestamp(),
requesterUuid: sourceUuid,
requesterDevice: sourceDevice,
senderDevice: request.deviceId(),
};
await this.dispatchAndWait(event);
}
async handleSenderKeyDistributionMessage(
envelope: EnvelopeClass,
distributionMessage: ByteBufferClass
@ -2603,10 +2677,6 @@ export default class MessageReceiver {
this.stopProcessing = inner.stopProcessing.bind(inner);
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
// For tests
this.isOverHourIntoPast = inner.isOverHourIntoPast.bind(inner);
this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner);
inner.connect();
this.getProcessedCount = () => inner.processedCount;
}
@ -2629,10 +2699,6 @@ export default class MessageReceiver {
unregisterBatchers: () => void;
isOverHourIntoPast: (timestamp: number) => boolean;
cleanupSessionResets: () => void;
getProcessedCount: () => number;
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;

View File

@ -13,10 +13,13 @@ import { reject } from 'lodash';
import { z } from 'zod';
import {
CiphertextMessageType,
CiphertextMessage,
PlaintextContent,
ProtocolAddress,
sealedSenderEncryptMessage,
sealedSenderEncrypt,
SenderCertificate,
signalEncrypt,
UnidentifiedSenderMessageContent,
} from '@signalapp/signal-client';
import { WebAPIType } from './WebAPI';
@ -73,6 +76,9 @@ function ciphertextMessageTypeToEnvelopeType(type: number) {
if (type === CiphertextMessageType.Whisper) {
return window.textsecure.protobuf.Envelope.Type.CIPHERTEXT;
}
if (type === CiphertextMessageType.Plaintext) {
return window.textsecure.protobuf.Envelope.Type.PLAINTEXT_CONTENT;
}
throw new Error(
`ciphertextMessageTypeToEnvelopeType: Unrecognized type ${type}`
);
@ -106,12 +112,10 @@ export default class OutgoingMessage {
identifiers: Array<string>;
message: ContentClass;
message: ContentClass | PlaintextContent;
callback: (result: CallbackResultType) => void;
silent?: boolean;
plaintext?: Uint8Array;
identifiersCompleted: number;
@ -128,12 +132,17 @@ export default class OutgoingMessage {
online?: boolean;
groupId?: string;
contentHint: number;
constructor(
server: WebAPIType,
timestamp: number,
identifiers: Array<string>,
message: ContentClass | DataMessageClass,
silent: boolean | undefined,
message: ContentClass | DataMessageClass | PlaintextContent,
contentHint: number,
groupId: string | undefined,
callback: (result: CallbackResultType) => void,
options: OutgoingMessageOptionsType = {}
) {
@ -149,8 +158,9 @@ export default class OutgoingMessage {
this.server = server;
this.timestamp = timestamp;
this.identifiers = identifiers;
this.contentHint = contentHint;
this.groupId = groupId;
this.callback = callback;
this.silent = silent;
this.identifiersCompleted = 0;
this.errors = [];
@ -186,12 +196,7 @@ export default class OutgoingMessage {
if (error && error.code === 428) {
error = new SendMessageChallengeError(identifier, error);
} else {
error = new OutgoingMessageError(
identifier,
this.message.toArrayBuffer(),
this.timestamp,
error
);
error = new OutgoingMessageError(identifier, null, null, error);
}
}
@ -246,7 +251,6 @@ export default class OutgoingMessage {
} catch (error) {
if (error?.message?.includes('untrusted identity for address')) {
error.timestamp = this.timestamp;
error.originalMessage = this.message.toArrayBuffer();
}
throw error;
}
@ -265,7 +269,6 @@ export default class OutgoingMessage {
identifier,
jsonData,
timestamp,
this.silent,
this.online,
{ accessKey }
);
@ -274,7 +277,6 @@ export default class OutgoingMessage {
identifier,
jsonData,
timestamp,
this.silent,
this.online
);
}
@ -299,18 +301,45 @@ export default class OutgoingMessage {
getPlaintext(): ArrayBuffer {
if (!this.plaintext) {
this.plaintext = padMessage(this.message.toArrayBuffer());
const { message } = this;
if (message instanceof window.textsecure.protobuf.Content) {
this.plaintext = padMessage(message.toArrayBuffer());
} else {
this.plaintext = message.serialize();
}
}
return this.plaintext;
}
async getCiphertextMessage({
identityKeyStore,
protocolAddress,
sessionStore,
}: {
identityKeyStore: IdentityKeys;
protocolAddress: ProtocolAddress;
sessionStore: Sessions;
}): Promise<CiphertextMessage> {
const { message } = this;
if (message instanceof window.textsecure.protobuf.Content) {
return signalEncrypt(
Buffer.from(this.getPlaintext()),
protocolAddress,
sessionStore,
identityKeyStore
);
}
return message.asCiphertextMessage();
}
async doSendMessage(
identifier: string,
deviceIds: Array<number>,
recurse?: boolean
): Promise<void> {
const plaintext = this.getPlaintext();
const { sendMetadata } = this;
const { accessKey, senderCertificate } = sendMetadata?.[identifier] || {};
@ -364,15 +393,29 @@ export default class OutgoingMessage {
const destinationRegistrationId = activeSession.remoteRegistrationId();
if (sealedSender && senderCertificate) {
const ciphertextMessage = await this.getCiphertextMessage({
identityKeyStore,
protocolAddress,
sessionStore,
});
const certificate = SenderCertificate.deserialize(
Buffer.from(senderCertificate.serialized)
);
const groupIdBuffer = this.groupId
? Buffer.from(this.groupId, 'base64')
: null;
const buffer = await sealedSenderEncryptMessage(
Buffer.from(plaintext),
protocolAddress,
const content = UnidentifiedSenderMessageContent.new(
ciphertextMessage,
certificate,
sessionStore,
this.contentHint,
groupIdBuffer
);
const buffer = await sealedSenderEncrypt(
content,
protocolAddress,
identityKeyStore
);
@ -385,12 +428,11 @@ export default class OutgoingMessage {
};
}
const ciphertextMessage = await signalEncrypt(
Buffer.from(plaintext),
const ciphertextMessage = await this.getCiphertextMessage({
identityKeyStore,
protocolAddress,
sessionStore,
identityKeyStore
);
});
const type = ciphertextMessageTypeToEnvelopeType(
ciphertextMessage.type()
);
@ -487,8 +529,6 @@ export default class OutgoingMessage {
if (error?.message?.includes('untrusted identity for address')) {
// eslint-disable-next-line no-param-reassign
error.timestamp = this.timestamp;
// eslint-disable-next-line no-param-reassign
error.originalMessage = this.message.toArrayBuffer();
window.log.error(
'Got "key changed" error from encrypt - no identityKey for application layer',
identifier,

View File

@ -12,6 +12,7 @@ import { Dictionary } from 'lodash';
import PQueue from 'p-queue';
import { AbortSignal } from 'abort-controller';
import {
PlaintextContent,
ProtocolAddress,
SenderKeyDistributionMessage,
} from '@signalapp/signal-client';
@ -795,10 +796,11 @@ export default class MessageSender {
async sendMessage(
attrs: MessageOptionsType,
contentHint: number,
groupId: string | undefined,
options?: SendOptionsType
): Promise<CallbackResultType> {
const message = new Message(attrs);
const silent = false;
return Promise.all([
this.uploadAttachments(message),
@ -812,6 +814,8 @@ export default class MessageSender {
message.timestamp,
message.recipients || [],
message.toProto(),
contentHint,
groupId,
(res: CallbackResultType) => {
res.dataMessage = message.toArrayBuffer();
if (res.errors && res.errors.length > 0) {
@ -820,7 +824,6 @@ export default class MessageSender {
resolve(res);
}
},
silent,
options
);
})
@ -830,9 +833,10 @@ export default class MessageSender {
sendMessageProto(
timestamp: number,
recipients: Array<string>,
messageProto: ContentClass | DataMessageClass,
messageProto: ContentClass | DataMessageClass | PlaintextContent,
contentHint: number,
groupId: string | undefined,
callback: (result: CallbackResultType) => void,
silent?: boolean,
options?: SendOptionsType
): void {
const rejections = window.textsecure.storage.get(
@ -848,7 +852,8 @@ export default class MessageSender {
timestamp,
recipients,
messageProto,
silent,
contentHint,
groupId,
callback,
options
);
@ -863,8 +868,9 @@ export default class MessageSender {
async sendMessageProtoAndWait(
timestamp: number,
identifiers: Array<string>,
messageProto: DataMessageClass,
silent?: boolean,
messageProto: ContentClass | DataMessageClass | PlaintextContent,
contentHint: number,
groupId: string | undefined,
options?: SendOptionsType
): Promise<CallbackResultType> {
return new Promise((resolve, reject) => {
@ -881,8 +887,9 @@ export default class MessageSender {
timestamp,
identifiers,
messageProto,
contentHint,
groupId,
callback,
silent,
options
);
});
@ -890,9 +897,9 @@ export default class MessageSender {
async sendIndividualProto(
identifier: string,
proto: DataMessageClass | ContentClass,
proto: DataMessageClass | ContentClass | PlaintextContent,
timestamp: number,
silent?: boolean,
contentHint: number,
options?: SendOptionsType
): Promise<CallbackResultType> {
return new Promise((resolve, reject) => {
@ -907,13 +914,16 @@ export default class MessageSender {
timestamp,
[identifier],
proto,
contentHint,
undefined, // groupId
callback,
silent,
options
);
});
}
// You might wonder why this takes a groupId. models/messages.resend() can send a group
// message to just one person.
async sendMessageToIdentifier(
identifier: string,
messageText: string | undefined,
@ -925,6 +935,8 @@ export default class MessageSender {
deletedForEveryoneTimestamp: number | undefined,
timestamp: number,
expireTimer: number | undefined,
contentHint: number,
groupId: string | undefined,
profileKey?: ArrayBuffer,
options?: SendOptionsType
): Promise<CallbackResultType> {
@ -942,6 +954,8 @@ export default class MessageSender {
expireTimer,
profileKey,
},
contentHint,
groupId,
options
);
}
@ -1018,12 +1032,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
timestamp,
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1043,12 +1060,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1071,12 +1091,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1098,12 +1121,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1127,12 +1153,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1160,12 +1189,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
await this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1189,12 +1221,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
await this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1224,12 +1259,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1261,12 +1299,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1299,12 +1340,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
sendOptions
);
}
@ -1344,12 +1388,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1397,12 +1444,15 @@ export default class MessageSender {
const secondMessage = new window.textsecure.protobuf.Content();
secondMessage.syncMessage = syncMessage;
const innerSilent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
myUuid || myNumber,
secondMessage,
now,
innerSilent,
ContentHint.SUPPLEMENTARY,
options
);
});
@ -1416,6 +1466,10 @@ export default class MessageSender {
sendOptions: SendOptionsType,
groupId?: string
): Promise<CallbackResultType> {
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendMessage(
{
recipients,
@ -1431,6 +1485,8 @@ export default class MessageSender {
}
: {}),
},
ContentHint.SUPPLEMENTARY,
undefined, // groupId
sendOptions
);
}
@ -1446,13 +1502,16 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.callingMessage = callingMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
await this.sendMessageProtoAndWait(
finalTimestamp,
recipients,
contentMessage,
silent,
ContentHint.SUPPLEMENTARY,
undefined, // groupId
sendOptions
);
}
@ -1481,12 +1540,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.receiptMessage = receiptMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
recipientUuid || recipientE164,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1504,12 +1566,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.receiptMessage = receiptMessage;
const silent = true;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto(
senderUuid || senderE164,
contentMessage,
Date.now(),
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1534,14 +1599,17 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.nullMessage = nullMessage;
// We want the NullMessage to look like a normal outgoing message; not silent
const silent = false;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
// We want the NullMessage to look like a normal outgoing message
const timestamp = Date.now();
return this.sendIndividualProto(
identifier,
contentMessage,
timestamp,
silent,
ContentHint.SUPPLEMENTARY,
options
);
}
@ -1555,7 +1623,6 @@ export default class MessageSender {
CallbackResultType | void | Array<CallbackResultType | void | Array<void>>
> {
window.log.info('resetSession: start');
const silent = false;
const proto = new window.textsecure.protobuf.DataMessage();
proto.body = 'TERMINATE';
proto.flags = window.textsecure.protobuf.DataMessage.Flags.END_SESSION;
@ -1568,6 +1635,10 @@ export default class MessageSender {
throw error;
};
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const sendToContactPromise = window.textsecure.storage.protocol
.archiveAllSessions(identifier)
.catch(logError('resetSession/archiveAllSessions1 error:'))
@ -1579,7 +1650,7 @@ export default class MessageSender {
identifier,
proto,
timestamp,
silent,
ContentHint.SUPPLEMENTARY,
options
).catch(logError('resetSession/sendToContact error:'));
})
@ -1619,6 +1690,10 @@ export default class MessageSender {
profileKey?: ArrayBuffer,
options?: SendOptionsType
): Promise<CallbackResultType> {
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendMessage(
{
recipients: [identifier],
@ -1628,6 +1703,31 @@ export default class MessageSender {
flags:
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
},
ContentHint.SUPPLEMENTARY,
undefined, // groupId
options
);
}
async sendRetryRequest({
options,
plaintext,
uuid,
}: {
options?: SendOptionsType;
plaintext: PlaintextContent;
uuid: string;
}): Promise<CallbackResultType> {
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendMessageProtoAndWait(
Date.now(),
[uuid],
plaintext,
ContentHint.SUPPLEMENTARY,
undefined, // groupId
options
);
}
@ -1639,6 +1739,8 @@ export default class MessageSender {
providedIdentifiers: Array<string>,
proto: ContentClass,
timestamp = Date.now(),
contentHint: number,
groupId: string | undefined,
options?: SendOptionsType
): Promise<CallbackResultType> {
const myE164 = window.textsecure.storage.user.getNumber();
@ -1658,7 +1760,6 @@ export default class MessageSender {
}
return new Promise((resolve, reject) => {
const silent = true;
const callback = (res: CallbackResultType) => {
res.dataMessage = proto.dataMessage?.toArrayBuffer();
if (res.errors && res.errors.length > 0) {
@ -1672,21 +1773,17 @@ export default class MessageSender {
timestamp,
providedIdentifiers,
proto,
contentHint,
groupId,
callback,
silent,
options
);
});
}
// The one group send exception - a message that should never be sent via sender key
async sendSenderKeyDistributionMessage(
{
distributionId,
identifiers,
}: { distributionId: string; identifiers: Array<string> },
options?: SendOptionsType
): Promise<CallbackResultType> {
async getSenderKeyDistributionMessage(
distributionId: string
): Promise<SenderKeyDistributionMessage> {
const ourUuid = window.textsecure.storage.user.getUuid();
if (!ourUuid) {
throw new Error(
@ -1702,7 +1799,7 @@ export default class MessageSender {
const address = `${ourUuid}.${ourDeviceId}`;
const senderKeyStore = new SenderKeys();
const message = await window.textsecure.storage.protocol.enqueueSenderKeyJob(
return window.textsecure.storage.protocol.enqueueSenderKeyJob(
address,
async () =>
SenderKeyDistributionMessage.create(
@ -1711,13 +1808,40 @@ export default class MessageSender {
senderKeyStore
)
);
}
const proto = new window.textsecure.protobuf.Content();
proto.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap(
typedArrayToArrayBuffer(message.serialize())
// The one group send exception - a message that should never be sent via sender key
async sendSenderKeyDistributionMessage(
{
contentHint,
distributionId,
groupId,
identifiers,
}: {
contentHint: number;
distributionId: string;
groupId: string | undefined;
identifiers: Array<string>;
},
options?: SendOptionsType
): Promise<CallbackResultType> {
const contentMessage = new window.textsecure.protobuf.Content();
const senderKeyDistributionMessage = await this.getSenderKeyDistributionMessage(
distributionId
);
contentMessage.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap(
typedArrayToArrayBuffer(senderKeyDistributionMessage.serialize())
);
return this.sendGroupProto(identifiers, proto, Date.now(), options);
return this.sendGroupProto(
identifiers,
contentMessage,
Date.now(),
contentHint,
groupId,
options
);
}
// GroupV1-only functions; not to be used in the future
@ -1731,7 +1855,18 @@ export default class MessageSender {
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.QUIT;
return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options);
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendGroupProto(
groupIdentifiers,
proto,
Date.now(),
ContentHint.SUPPLEMENTARY,
undefined, // only for GV2 ids
options
);
}
async sendExpirationTimerUpdateToGroup(
@ -1770,7 +1905,15 @@ export default class MessageSender {
});
}
return this.sendMessage(attrs, options);
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
return this.sendMessage(
attrs,
ContentHint.SUPPLEMENTARY,
undefined, // only for GV2 ids
options
);
}
// Simple pass-throughs

View File

@ -934,14 +934,12 @@ export type WebAPIType = {
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean
) => Promise<void>;
sendMessagesUnauth: (
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean,
options?: { accessKey?: string }
) => Promise<void>;
@ -1446,7 +1444,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = {
'gv2-3': true,
'gv1-migration': true,
senderKey: false,
senderKey: true,
};
const { accessKey } = options;
@ -1684,15 +1682,11 @@ export function initialize({
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean,
{ accessKey }: { accessKey?: string } = {}
) {
const jsonData: any = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}
@ -1712,14 +1706,10 @@ export function initialize({
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean
) {
const jsonData: any = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}

View File

@ -36,6 +36,7 @@ import * as zkgroup from './zkgroup';
import { StartupQueue } from './StartupQueue';
import { postLinkExperience } from './postLinkExperience';
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
import { RetryPlaceholders } from './retryPlaceholders';
export {
GoogleChrome,
@ -62,6 +63,7 @@ export {
parseRemoteClientExpiration,
postLinkExperience,
queueUpdateMessage,
RetryPlaceholders,
saveNewMessageBatcher,
sendContentMessageToGroup,
sendToGroup,

View File

@ -0,0 +1,196 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import { groupBy } from 'lodash';
const retryItemSchema = z
.object({
conversationId: z.string(),
sentAt: z.number(),
receivedAt: z.number(),
receivedAtCounter: z.number(),
senderUuid: z.string(),
})
.passthrough();
export type RetryItemType = z.infer<typeof retryItemSchema>;
const retryItemListSchema = z.array(retryItemSchema);
export type RetryItemListType = z.infer<typeof retryItemListSchema>;
export type ByConversationLookupType = {
[key: string]: Array<RetryItemType>;
};
export type ByMessageLookupType = Map<string, RetryItemType>;
export function getItemId(conversationId: string, sentAt: number): string {
return `${conversationId}--${sentAt}`;
}
const HOUR = 60 * 60 * 1000;
export const STORAGE_KEY = 'retryPlaceholders';
export function getOneHourAgo(): number {
return Date.now() - HOUR;
}
export class RetryPlaceholders {
private items: Array<RetryItemType>;
private byConversation: ByConversationLookupType;
private byMessage: ByMessageLookupType;
constructor() {
if (!window.storage) {
throw new Error(
'RetryPlaceholders.constructor: window.storage not available!'
);
}
const parsed = retryItemListSchema.safeParse(
window.storage.get(STORAGE_KEY) || []
);
if (!parsed.success) {
window.log.warn(
`RetryPlaceholders.constructor: Data fetched from storage did not match schema: ${JSON.stringify(
parsed.error.flatten()
)}`
);
}
this.items = parsed.success ? parsed.data : [];
window.log.info(
`RetryPlaceholders.constructor: Started with ${this.items.length} items`
);
this.sortByExpiresAtAsc();
this.byConversation = this.makeByConversationLookup();
this.byMessage = this.makeByMessageLookup();
}
// Arranging local data for efficiency
sortByExpiresAtAsc(): void {
this.items.sort(
(left: RetryItemType, right: RetryItemType) =>
left.receivedAt - right.receivedAt
);
}
makeByConversationLookup(): ByConversationLookupType {
return groupBy(this.items, item => item.conversationId);
}
makeByMessageLookup(): ByMessageLookupType {
const lookup = new Map<string, RetryItemType>();
this.items.forEach(item => {
lookup.set(getItemId(item.conversationId, item.sentAt), item);
});
return lookup;
}
makeLookups(): void {
this.byConversation = this.makeByConversationLookup();
this.byMessage = this.makeByMessageLookup();
}
// Basic data management
async add(item: RetryItemType): Promise<void> {
const parsed = retryItemSchema.safeParse(item);
if (!parsed.success) {
throw new Error(
`RetryPlaceholders.add: Item did not match schema ${JSON.stringify(
parsed.error.flatten()
)}`
);
}
this.items.push(item);
this.sortByExpiresAtAsc();
this.makeLookups();
await this.save();
}
async save(): Promise<void> {
await window.storage.put(STORAGE_KEY, this.items);
}
// Finding items in different ways
getCount(): number {
return this.items.length;
}
getNextToExpire(): RetryItemType | undefined {
return this.items[0];
}
async getExpiredAndRemove(): Promise<Array<RetryItemType>> {
const expiration = getOneHourAgo();
const max = this.items.length;
const result: Array<RetryItemType> = [];
for (let i = 0; i < max; i += 1) {
const item = this.items[i];
if (item.receivedAt <= expiration) {
result.push(item);
} else {
break;
}
}
window.log.info(
`RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items`
);
this.items.splice(0, result.length);
this.makeLookups();
await this.save();
return result;
}
async findByConversationAndRemove(
conversationId: string
): Promise<Array<RetryItemType>> {
const result = this.byConversation[conversationId];
if (!result) {
return [];
}
const items = this.items.filter(
item => item.conversationId !== conversationId
);
window.log.info(
`RetryPlaceholders.findByConversationAndRemove: Found ${result.length} expired items`
);
this.items = items;
this.sortByExpiresAtAsc();
this.makeLookups();
await this.save();
return result;
}
async findByMessageAndRemove(
conversationId: string,
sentAt: number
): Promise<RetryItemType | undefined> {
const result = this.byMessage.get(getItemId(conversationId, sentAt));
if (!result) {
return undefined;
}
const index = this.items.findIndex(item => item === result);
this.items.splice(index, 1);
this.makeLookups();
await this.save();
return result;
}
}

View File

@ -55,6 +55,7 @@ const MAX_RECURSION = 5;
export async function sendToGroup(
groupSendOptions: GroupSendOptionsType,
conversation: ConversationModel,
contentHint: number,
sendOptions?: SendOptionsType,
isPartialSend?: boolean
): Promise<CallbackResultType> {
@ -75,6 +76,7 @@ export async function sendToGroup(
);
return sendContentMessageToGroup({
contentHint,
contentMessage,
conversation,
isPartialSend,
@ -85,6 +87,7 @@ export async function sendToGroup(
}
export async function sendContentMessageToGroup({
contentHint,
contentMessage,
conversation,
isPartialSend,
@ -93,6 +96,7 @@ export async function sendContentMessageToGroup({
sendOptions,
timestamp,
}: {
contentHint: number;
contentMessage: ContentClass;
conversation: ConversationModel;
isPartialSend?: boolean;
@ -110,6 +114,7 @@ export async function sendContentMessageToGroup({
if (conversation.isGroupV2()) {
try {
return await sendToGroupViaSenderKey({
contentHint,
contentMessage,
conversation,
isPartialSend,
@ -127,10 +132,15 @@ export async function sendContentMessageToGroup({
}
}
const groupId = conversation.isGroupV2()
? conversation.get('groupId')
: undefined;
return window.textsecure.messaging.sendGroupProto(
recipients,
contentMessage,
timestamp,
contentHint,
groupId,
sendOptions
);
}
@ -138,6 +148,7 @@ export async function sendContentMessageToGroup({
// The Primary Sender Key workflow
export async function sendToGroupViaSenderKey(options: {
contentHint: number;
contentMessage: ContentClass;
conversation: ConversationModel;
isPartialSend?: boolean;
@ -148,6 +159,7 @@ export async function sendToGroupViaSenderKey(options: {
timestamp: number;
}): Promise<CallbackResultType> {
const {
contentHint,
contentMessage,
conversation,
isPartialSend,
@ -157,6 +169,9 @@ export async function sendToGroupViaSenderKey(options: {
sendOptions,
timestamp,
} = options;
const {
ContentHint,
} = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const logId = conversation.idForLogging();
window.log.info(
@ -176,6 +191,15 @@ export async function sendToGroupViaSenderKey(options: {
);
}
if (
contentHint !== ContentHint.RESENDABLE &&
contentHint !== ContentHint.SUPPLEMENTARY
) {
throw new Error(
`sendToGroupViaSenderKey/${logId}: Invalid contentHint ${contentHint}`
);
}
assert(
window.textsecure.messaging,
'sendToGroupViaSenderKey: textsecure.messaging not available!'
@ -293,10 +317,15 @@ export async function sendToGroupViaSenderKey(options: {
newToMemberUuids.length
} members: ${JSON.stringify(newToMemberUuids)}`
);
await window.textsecure.messaging.sendSenderKeyDistributionMessage({
distributionId,
identifiers: newToMemberUuids,
});
await window.textsecure.messaging.sendSenderKeyDistributionMessage(
{
contentHint: ContentHint.SUPPLEMENTARY,
distributionId,
groupId,
identifiers: newToMemberUuids,
},
sendOptions
);
}
// 9. Update memberDevices with both adds and the removals which didn't require a reset.
@ -323,6 +352,7 @@ export async function sendToGroupViaSenderKey(options: {
// 10. Send the Sender Key message!
try {
const messageBuffer = await encryptForSenderKey({
contentHint,
devices: devicesForSenderKey,
distributionId,
contentMessage: contentMessage.toArrayBuffer(),
@ -396,6 +426,8 @@ export async function sendToGroupViaSenderKey(options: {
normalRecipients,
contentMessage,
timestamp,
contentHint,
groupId,
sendOptions
);
@ -594,14 +626,16 @@ function getXorOfAccessKeys(devices: Array<DeviceType>): Buffer {
}
async function encryptForSenderKey({
contentHint,
contentMessage,
devices,
distributionId,
contentMessage,
groupId,
}: {
contentHint: number;
contentMessage: ArrayBuffer;
devices: Array<DeviceType>;
distributionId: string;
contentMessage: ArrayBuffer;
groupId: string;
}): Promise<Buffer> {
const ourUuid = window.textsecure.storage.user.getUuid();
@ -625,7 +659,6 @@ async function encryptForSenderKey({
() => groupEncrypt(sender, distributionId, senderKeyStore, message)
);
const contentHint = 1;
const groupIdBuffer = Buffer.from(groupId, 'base64');
const senderCertificateObject = await senderCertificateService.get(
SenderCertificateMode.WithoutE164
@ -676,8 +709,8 @@ function isValidSenderKeyRecipient(
return false;
}
const { capabilities } = memberConversation.attributes;
if (!capabilities.senderKey) {
const capabilities = memberConversation.get('capabilities');
if (!capabilities?.senderKey) {
window.log.info(
`isValidSenderKeyRecipient: Missing senderKey capability for member ${uuid}`
);

View File

@ -394,12 +394,6 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.throttledGetProfiles =
this.model.throttledGetProfiles ||
window._.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES);
this.model.throttledMaybeMigrateV1Group =
this.model.throttledMaybeMigrateV1Group ||
window._.throttle(
this.model.maybeMigrateV1Group.bind(this.model),
FIVE_MINUTES
);
this.debouncedMaybeGrabLinkPreview = window._.debounce(
this.maybeGrabLinkPreview.bind(this),
@ -2171,6 +2165,8 @@ Whisper.ConversationView = Whisper.View.extend({
},
async onOpened(messageId: any) {
const { model }: { model: ConversationModel } = this;
if (messageId) {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
@ -2184,29 +2180,41 @@ Whisper.ConversationView = Whisper.View.extend({
window.log.warn(`onOpened: Did not find message ${messageId}`);
}
const { retryPlaceholders } = window.Signal.Services;
if (retryPlaceholders) {
const placeholders = await retryPlaceholders.findByConversationAndRemove(
model.id
);
window.log.info(`onOpened: Found ${placeholders.length} placeholders`);
}
this.loadNewestMessages();
this.model.updateLastMessage();
model.updateLastMessage();
this.focusMessageField();
const quotedMessageId = this.model.get('quotedMessageId');
const quotedMessageId = model.get('quotedMessageId');
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
}
this.model.fetchLatestGroupV2Data();
this.model.throttledMaybeMigrateV1Group();
model.fetchLatestGroupV2Data();
assert(
this.model.throttledFetchSMSOnlyUUID !== undefined,
model.throttledMaybeMigrateV1Group !== undefined,
'Conversation model should be initialized'
);
this.model.throttledFetchSMSOnlyUUID();
model.throttledMaybeMigrateV1Group();
assert(
model.throttledFetchSMSOnlyUUID !== undefined,
'Conversation model should be initialized'
);
model.throttledFetchSMSOnlyUUID();
const statusPromise = this.model.throttledGetProfiles();
// eslint-disable-next-line more/no-then
this.statusFetch = statusPromise.then(() =>
// eslint-disable-next-line more/no-then
this.model.updateVerified().then(() => {
model.updateVerified().then(() => {
this.onVerifiedChange();
this.statusFetch = null;
})

1
ts/window.d.ts vendored
View File

@ -315,6 +315,7 @@ declare global {
) => void;
onTimeout: (timestamp: number, cb: () => void, id?: string) => string;
removeTimeout: (uuid: string) => void;
retryPlaceholders?: Util.RetryPlaceholders;
runStorageServiceSyncJob: () => Promise<void>;
storageServiceUploadJob: () => void;
};

View File

@ -1634,10 +1634,10 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
"@signalapp/signal-client@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a"
integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg==
"@signalapp/signal-client@0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.8.0.tgz#30c3bfafbd32680c8dd7e5417e53b928b1ccdd65"
integrity sha512-pchM+cwWdJZSCIceUvq/2lNZr6qJO7qGpQMfxbm9CGrcQaU7t7vtrkR5F0AsHnYO+lfL/3mMOVbBb0Rgl5/IVw==
dependencies:
node-gyp-build "^4.2.3"
uuid "^8.3.0"