Support for message retry requests
This commit is contained in:
parent
28f016ce48
commit
ee513a1965
37 changed files with 1996 additions and 359 deletions
333
ts/background.ts
333
ts/background.ts
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
27
ts/components/conversation/DeliveryIssueDialog.stories.tsx
Normal file
27
ts/components/conversation/DeliveryIssueDialog.stories.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
57
ts/components/conversation/DeliveryIssueDialog.tsx
Normal file
57
ts/components/conversation/DeliveryIssueDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
);
|
68
ts/components/conversation/DeliveryIssueNotification.tsx
Normal file
68
ts/components/conversation/DeliveryIssueNotification.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -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">
|
||||
|
|
21
ts/groups.ts
21
ts/groups.ts
|
@ -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
15
ts/model-types.d.ts
vendored
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
285
ts/test-both/util/retryPlaceholders_test.ts
Normal file
285
ts/test-both/util/retryPlaceholders_test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
});
|
57
ts/test-electron/background_test.ts
Normal file
57
ts/test-electron/background_test.ts
Normal 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
10
ts/textsecure.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
196
ts/util/retryPlaceholders.ts
Normal file
196
ts/util/retryPlaceholders.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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}`
|
||||
);
|
||||
|
|
|
@ -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
1
ts/window.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue