diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 3fe43aaff..fdd9ac3ad 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1077,6 +1077,22 @@
"message": "Secure session reset",
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
},
+ "ChatRefresh--notification": {
+ "message": "Chat session refreshed",
+ "description": "Shown in timeline when a error happened, and the session was automatically reset."
+ },
+ "ChatRefresh--learnMore": {
+ "message": "Learn More",
+ "description": "Shown in timeline when session is automatically reset, to provide access to a popup info dialog"
+ },
+ "ChatRefresh--summary": {
+ "message": "Signal uses end-to-end encryption and it may need to refresh your chat session sometimes. This doesn’t affect your chat’s security but you may have missed a message from this contact and you can ask them to resend it.",
+ "description": "Shown on explainer dialog available from chat session refreshed timeline events"
+ },
+ "ChatRefresh--contactSupport": {
+ "message": "Contact Support",
+ "description": "Shown on explainer dialog available from chat session refreshed timeline events"
+ },
"quoteThumbnailAlt": {
"message": "Thumbnail of image from quoted message",
"description": "Used in alt tag of thumbnail images inside of an embedded message quote"
diff --git a/images/chat-session-refresh.svg b/images/chat-session-refresh.svg
new file mode 100644
index 000000000..be9410200
--- /dev/null
+++ b/images/chat-session-refresh.svg
@@ -0,0 +1,12 @@
+
diff --git a/images/icons/v2/refresh-16.svg b/images/icons/v2/refresh-16.svg
new file mode 100644
index 000000000..9d417e1a6
--- /dev/null
+++ b/images/icons/v2/refresh-16.svg
@@ -0,0 +1,3 @@
+
diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js
index e906c3baa..3c9864ca8 100644
--- a/libtextsecure/test/fake_web_api.js
+++ b/libtextsecure/test/fake_web_api.js
@@ -14,7 +14,7 @@ const fakeAPI = {
getAvatar: fakeCall,
getDevices: fakeCall,
// getKeysForIdentifier : fakeCall,
- getMessageSocket: fakeCall,
+ getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'),
getMyKeys: fakeCall,
getProfile: fakeCall,
getProvisioningSocket: fakeCall,
diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js
index f2eb6427a..801f726f1 100644
--- a/libtextsecure/test/in_memory_signal_protocol_store.js
+++ b/libtextsecure/test/in_memory_signal_protocol_store.js
@@ -166,4 +166,15 @@ SignalProtocolStore.prototype = {
resolve(deviceIds);
});
},
+
+ getUnprocessedCount: () => Promise.resolve(0),
+ getAllUnprocessed: () => Promise.resolve([]),
+ getUnprocessedById: () => Promise.resolve(null),
+ addUnprocessed: () => Promise.resolve(),
+ addMultipleUnprocessed: () => Promise.resolve(),
+ updateUnprocessedAttempts: () => Promise.resolve(),
+ updateUnprocessedWithData: () => Promise.resolve(),
+ updateUnprocessedsWithData: () => Promise.resolve(),
+ removeUnprocessed: () => Promise.resolve(),
+ removeAllUnprocessed: () => Promise.resolve(),
};
diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html
index 0f6b58901..5fe7f3dcf 100644
--- a/libtextsecure/test/index.html
+++ b/libtextsecure/test/index.html
@@ -24,6 +24,7 @@
+
@@ -31,10 +32,7 @@
-
-
-
@@ -44,6 +42,7 @@
+
diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js
index 73b606deb..0ded6a0c2 100644
--- a/libtextsecure/test/message_receiver_test.js
+++ b/libtextsecure/test/message_receiver_test.js
@@ -1,10 +1,9 @@
// Copyright 2015-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-/* global libsignal, textsecure, SignalProtocolStore */
+/* global libsignal, textsecure */
describe('MessageReceiver', () => {
- textsecure.storage.impl = new SignalProtocolStore();
const { WebSocket } = window;
const number = '+19999999999';
const uuid = 'AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE';
@@ -12,6 +11,7 @@ describe('MessageReceiver', () => {
const signalingKey = libsignal.crypto.getRandomBytes(32 + 20);
before(() => {
+ localStorage.clear();
window.WebSocket = MockSocket;
textsecure.storage.user.setNumberAndDeviceId(number, deviceId, 'name');
textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId);
@@ -19,94 +19,126 @@ describe('MessageReceiver', () => {
textsecure.storage.put('signaling_key', signalingKey);
});
after(() => {
+ localStorage.clear();
window.WebSocket = WebSocket;
});
describe('connecting', () => {
- const attrs = {
- type: textsecure.protobuf.Envelope.Type.CIPHERTEXT,
- source: number,
- sourceUuid: uuid,
- sourceDevice: deviceId,
- timestamp: Date.now(),
- };
- const websocketmessage = new textsecure.protobuf.WebSocketMessage({
- type: textsecure.protobuf.WebSocketMessage.Type.REQUEST,
- request: { verb: 'PUT', path: '/messages' },
+ let attrs;
+ let websocketmessage;
+
+ before(() => {
+ attrs = {
+ type: textsecure.protobuf.Envelope.Type.CIPHERTEXT,
+ source: number,
+ sourceUuid: uuid,
+ sourceDevice: deviceId,
+ timestamp: Date.now(),
+ content: libsignal.crypto.getRandomBytes(200),
+ };
+ const body = new textsecure.protobuf.Envelope(attrs).toArrayBuffer();
+
+ websocketmessage = new textsecure.protobuf.WebSocketMessage({
+ type: textsecure.protobuf.WebSocketMessage.Type.REQUEST,
+ request: { verb: 'PUT', path: '/api/v1/message', body },
+ });
});
- before(done => {
- const signal = new textsecure.protobuf.Envelope(attrs).toArrayBuffer();
-
- const aesKey = signalingKey.slice(0, 32);
- const macKey = signalingKey.slice(32, 32 + 20);
-
- window.crypto.subtle
- .importKey('raw', aesKey, { name: 'AES-CBC' }, false, ['encrypt'])
- .then(key => {
- const iv = libsignal.crypto.getRandomBytes(16);
- window.crypto.subtle
- .encrypt({ name: 'AES-CBC', iv: new Uint8Array(iv) }, key, signal)
- .then(ciphertext => {
- window.crypto.subtle
- .importKey(
- 'raw',
- macKey,
- { name: 'HMAC', hash: { name: 'SHA-256' } },
- false,
- ['sign']
- )
- .then(innerKey => {
- window.crypto.subtle
- .sign({ name: 'HMAC', hash: 'SHA-256' }, innerKey, signal)
- .then(mac => {
- const version = new Uint8Array([1]);
- const message = dcodeIO.ByteBuffer.concat([
- version,
- iv,
- ciphertext,
- mac,
- ]);
- websocketmessage.request.body = message.toArrayBuffer();
- done();
- });
- });
- });
- });
- });
-
- it('connects', done => {
- const mockServer = new MockServer(
- `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent(
- uuid
- )}.1&password=password`
- );
+ it('generates light-session-reset event when it cannot decrypt', done => {
+ const mockServer = new MockServer('ws://localhost:8081/');
mockServer.on('connection', server => {
- server.send(new Blob([websocketmessage.toArrayBuffer()]));
+ setTimeout(() => {
+ server.send(new Blob([websocketmessage.toArrayBuffer()]));
+ }, 1);
});
- window.addEventListener('textsecure:message', ev => {
- const signal = ev.proto;
- const keys = Object.keys(attrs);
-
- for (let i = 0, max = keys.length; i < max; i += 1) {
- const key = keys[i];
- assert.strictEqual(attrs[key], signal[key]);
- }
- assert.strictEqual(signal.message.body, 'hello');
- mockServer.close();
-
- done();
- });
-
- window.messageReceiver = new textsecure.MessageReceiver(
+ const messageReceiver = new textsecure.MessageReceiver(
+ 'oldUsername',
'username',
'password',
- 'signalingKey'
- // 'ws://localhost:8080',
- // window,
+ 'signalingKey',
+ {
+ serverTrustRoot: 'AAAAAAAA',
+ }
);
+
+ messageReceiver.addEventListener('light-session-reset', done());
+ });
+ });
+
+ describe('methods', () => {
+ let messageReceiver;
+ let mockServer;
+
+ beforeEach(() => {
+ // Necessary to populate the server property inside of MockSocket. Without it, we
+ // crash when doing any number of things to a MockSocket instance.
+ mockServer = new MockServer('ws://localhost:8081');
+
+ messageReceiver = new textsecure.MessageReceiver(
+ 'oldUsername',
+ 'username',
+ 'password',
+ 'signalingKey',
+ {
+ serverTrustRoot: 'AAAAAAAA',
+ }
+ );
+ });
+ afterEach(() => {
+ mockServer.close();
+ });
+
+ describe('#isOverHourIntoPast', () => {
+ it('returns false for now', () => {
+ assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now()));
+ });
+ it('returns false for 5 minutes ago', () => {
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
+ assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo));
+ });
+ it('returns true for 65 minutes ago', () => {
+ const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000;
+ assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo));
+ });
+ });
+
+ describe('#cleanupSessionResets', () => {
+ it('leaves empty object alone', () => {
+ window.storage.put('sessionResets', {});
+ messageReceiver.cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = {};
+ assert.deepEqual(actual, expected);
+ });
+ it('filters out any timestamp older than one hour', () => {
+ const startValue = {
+ one: Date.now() - 1,
+ two: Date.now(),
+ three: Date.now() - 65 * 60 * 1000,
+ };
+ window.storage.put('sessionResets', startValue);
+ messageReceiver.cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = window._.pick(startValue, ['one', 'two']);
+ assert.deepEqual(actual, expected);
+ });
+ it('filters out falsey items', () => {
+ const startValue = {
+ one: 0,
+ two: false,
+ three: Date.now(),
+ };
+ window.storage.put('sessionResets', startValue);
+ messageReceiver.cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = window._.pick(startValue, ['three']);
+ assert.deepEqual(actual, expected);
+ });
});
});
});
diff --git a/preload.js b/preload.js
index 581f47e12..dadfe1d4f 100644
--- a/preload.js
+++ b/preload.js
@@ -45,6 +45,7 @@ try {
window.platform = process.platform;
window.getTitle = () => title;
+ window.getLocale = () => config.locale;
window.getEnvironment = getEnvironment;
window.getAppInstance = () => config.appInstance;
window.getVersion = () => config.version;
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index b0b0e733b..97dc97c29 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -11453,6 +11453,88 @@ $contact-modal-padding: 18px;
}
}
+// Module: Chat Session Refreshed Notification
+
+.module-chat-session-refreshed-notification {
+ @include font-body-2;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.module-chat-session-refreshed-notification__first-line {
+ margin-bottom: 12px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ margin-left: auto;
+ margin-right: auto;
+}
+.module-chat-session-refreshed-notification__icon {
+ height: 16px;
+ width: 16px;
+ display: inline-block;
+ margin-right: 8px;
+
+ @include light-theme {
+ @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-60);
+ }
+ @include dark-theme {
+ @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-25);
+ }
+}
+.module-chat-session-refreshed-notification__button {
+ @include button-reset;
+ @include button-light-blue-text;
+ @include button-small;
+
+ @include font-body-2;
+ padding: 5px 12px;
+}
+
+// Module: Chat Session Refreshed Dialog
+
+.module-chat-session-refreshed-dialog {
+ width: 360px;
+ padding: 16px;
+ padding-top: 28px;
+ border-radius: 8px;
+ margin-left: auto;
+ margin-right: auto;
+
+ @include light-theme {
+ background-color: $color-white;
+ }
+ @include dark-theme {
+ background-color: $color-gray-95;
+ }
+}
+.module-chat-session-refreshed-dialog__image {
+ text-align: center;
+}
+.module-chat-session-refreshed-dialog__title {
+ @include font-body-1-bold;
+ margin-top: 10px;
+ margin-bottom: 3px;
+}
+.module-chat-session-refreshed-dialog__buttons {
+ text-align: right;
+ margin-top: 20px;
+}
+.module-chat-session-refreshed-dialog__button {
+ @include font-body-1-bold;
+ @include button-reset;
+ @include button-primary;
+
+ border-radius: 4px;
+ padding: 7px 14px;
+ margin-left: 12px;
+}
+.module-chat-session-refreshed-dialog__button--secondary {
+ @include button-secondary;
+}
+
/* Third-party module: react-contextmenu*/
.react-contextmenu {
diff --git a/test/index.html b/test/index.html
index a95268288..540786f11 100644
--- a/test/index.html
+++ b/test/index.html
@@ -337,15 +337,12 @@
-
-
-
@@ -353,20 +350,15 @@
-
-
-
-
-
diff --git a/ts/background.ts b/ts/background.ts
index 1469ad78d..5fd76106c 100644
--- a/ts/background.ts
+++ b/ts/background.ts
@@ -1937,6 +1937,7 @@ type WhatIsThis = import('./window.d').WhatIsThis;
addQueuedEventListener('read', onReadReceipt);
addQueuedEventListener('verified', onVerified);
addQueuedEventListener('error', onError);
+ addQueuedEventListener('light-session-reset', onLightSessionReset);
addQueuedEventListener('empty', onEmpty);
addQueuedEventListener('reconnect', onReconnect);
addQueuedEventListener('configuration', onConfiguration);
@@ -3121,7 +3122,8 @@ type WhatIsThis = import('./window.d').WhatIsThis;
error.name === 'HTTPError' &&
(error.code === 401 || error.code === 403)
) {
- return unlinkAndDisconnect();
+ unlinkAndDisconnect();
+ return;
}
if (
@@ -3136,101 +3138,40 @@ type WhatIsThis = import('./window.d').WhatIsThis;
window.Whisper.events.trigger('reconnectTimer');
}
- return Promise.resolve();
+ return;
}
- if (ev.proto) {
- if (error && error.name === 'MessageCounterError') {
- if (ev.confirm) {
- ev.confirm();
- }
- // Ignore this message. It is likely a duplicate delivery
- // because the server lost our ack the first time.
- return Promise.resolve();
- }
- const envelope = ev.proto;
- const id = window.ConversationController.ensureContactIds({
- e164: envelope.source,
- uuid: envelope.sourceUuid,
- });
- if (!id) {
- throw new Error('onError: ensureContactIds returned falsey id!');
- }
- const message = initIncomingMessage(envelope, {
- type: Message.PRIVATE,
- id,
- });
+ window.log.warn('background onError: Doing nothing with incoming error');
+ }
- const conversationId = message.get('conversationId');
- const conversation = window.ConversationController.get(conversationId);
+ type LightSessionResetEventType = {
+ senderUuid: string;
+ };
- if (!conversation) {
- window.log.warn(
- 'onError: No conversation id, cannot save error bubble'
- );
- ev.confirm();
- return Promise.resolve();
- }
+ function onLightSessionReset(event: LightSessionResetEventType) {
+ const conversationId = window.ConversationController.ensureContactIds({
+ uuid: event.senderUuid,
+ });
- // This matches the queueing behavior used in Message.handleDataMessage
- conversation.queueJob(async () => {
- const existingMessage = await window.Signal.Data.getMessageBySender(
- message.attributes,
- {
- Message: window.Whisper.Message,
- }
- );
- if (existingMessage) {
- ev.confirm();
- window.log.warn(
- `Got duplicate error for message ${message.idForLogging()}`
- );
- return;
- }
+ if (!conversationId) {
+ window.log.warn(
+ 'onLightSessionReset: No conversation id, cannot add message to timeline'
+ );
+ return;
+ }
+ const conversation = window.ConversationController.get(conversationId);
- const model = new window.Whisper.Message({
- ...message.attributes,
- id: window.getGuid(),
- });
- await model.saveErrors(error || new Error('Error was null'), {
- skipSave: true,
- });
-
- window.MessageController.register(model.id, model);
- await window.Signal.Data.saveMessage(model.attributes, {
- Message: window.Whisper.Message,
- forceSave: true,
- });
-
- conversation.set({
- active_at: Date.now(),
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- unreadCount: conversation.get('unreadCount')! + 1,
- });
-
- const conversationTimestamp = conversation.get('timestamp');
- const messageTimestamp = model.get('timestamp');
- if (
- !conversationTimestamp ||
- messageTimestamp > conversationTimestamp
- ) {
- conversation.set({ timestamp: model.get('sent_at') });
- }
-
- conversation.trigger('newmessage', model);
- conversation.notify(model);
-
- window.Whisper.events.trigger('incrementProgress');
-
- if (ev.confirm) {
- ev.confirm();
- }
-
- window.Signal.Data.updateConversation(conversation.attributes);
- });
+ if (!conversation) {
+ window.log.warn(
+ 'onLightSessionReset: No conversation, cannot add message to timeline'
+ );
+ return;
}
- throw error;
+ const receivedAt = Date.now();
+ conversation.queueJob(async () => {
+ conversation.addChatSessionRefreshed(receivedAt);
+ });
}
async function onViewSync(ev: WhatIsThis) {
diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx
new file mode 100644
index 000000000..b08bad13b
--- /dev/null
+++ b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx
@@ -0,0 +1,25 @@
+// 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 { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog';
+
+const i18n = setupI18n('en', enMessages);
+
+storiesOf('Components/Conversation/ChatSessionRefreshedDialog', module).add(
+ 'Default',
+ () => {
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.tsx
new file mode 100644
index 000000000..cb73ab4cd
--- /dev/null
+++ b/ts/components/conversation/ChatSessionRefreshedDialog.tsx
@@ -0,0 +1,57 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as React from 'react';
+import classNames from 'classnames';
+
+import { LocalizerType } from '../../types/Util';
+
+export type PropsType = {
+ i18n: LocalizerType;
+ contactSupport: () => unknown;
+ onClose: () => unknown;
+};
+
+export function ChatSessionRefreshedDialog(
+ props: PropsType
+): React.ReactElement {
+ const { i18n, contactSupport, onClose } = props;
+
+ return (
+
+
+

+
+
+ {i18n('ChatRefresh--notification')}
+
+
+ {i18n('ChatRefresh--summary')}
+
+
+
+
+
+
+ );
+}
diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx
new file mode 100644
index 000000000..80c14cb5e
--- /dev/null
+++ b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx
@@ -0,0 +1,24 @@
+// 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 { ChatSessionRefreshedNotification } from './ChatSessionRefreshedNotification';
+
+const i18n = setupI18n('en', enMessages);
+
+storiesOf(
+ 'Components/Conversation/ChatSessionRefreshedNotification',
+ module
+).add('Default', () => {
+ return (
+
+ );
+});
diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.tsx
new file mode 100644
index 000000000..cf5e31674
--- /dev/null
+++ b/ts/components/conversation/ChatSessionRefreshedNotification.tsx
@@ -0,0 +1,63 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { useCallback, useState, ReactElement } from 'react';
+
+import { LocalizerType } from '../../types/Util';
+
+import { ModalHost } from '../ModalHost';
+import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog';
+
+type PropsHousekeepingType = {
+ i18n: LocalizerType;
+};
+
+export type PropsActionsType = {
+ contactSupport: () => unknown;
+};
+
+export type PropsType = PropsHousekeepingType & PropsActionsType;
+
+export function ChatSessionRefreshedNotification(
+ props: PropsType
+): ReactElement {
+ const { contactSupport, i18n } = props;
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const openDialog = useCallback(() => {
+ setIsDialogOpen(true);
+ }, [setIsDialogOpen]);
+ const closeDialog = useCallback(() => {
+ setIsDialogOpen(false);
+ }, [setIsDialogOpen]);
+
+ const wrappedContactSupport = useCallback(() => {
+ setIsDialogOpen(false);
+ contactSupport();
+ }, [contactSupport, setIsDialogOpen]);
+
+ return (
+
+
+
+ {i18n('ChatRefresh--notification')}
+
+
+ {isDialogOpen ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx
index b62f4707f..f0eb3fa82 100644
--- a/ts/components/conversation/Timeline.stories.tsx
+++ b/ts/components/conversation/Timeline.stories.tsx
@@ -252,6 +252,8 @@ const actions = () => ({
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
+
+ contactSupport: action('contactSupport'),
});
const renderItem = (id: string) => (
diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx
index 03b2fe5aa..d9800da50 100644
--- a/ts/components/conversation/TimelineItem.stories.tsx
+++ b/ts/components/conversation/TimelineItem.stories.tsx
@@ -41,6 +41,7 @@ const getDefaultProps = () => ({
selectMessage: action('selectMessage'),
reactToMessage: action('reactToMessage'),
clearSelectedMessage: action('clearSelectedMessage'),
+ contactSupport: action('contactSupport'),
replyToMessage: action('replyToMessage'),
retrySend: action('retrySend'),
deleteMessage: action('deleteMessage'),
diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx
index 409914b0a..ec6959d3e 100644
--- a/ts/components/conversation/TimelineItem.tsx
+++ b/ts/components/conversation/TimelineItem.tsx
@@ -10,11 +10,14 @@ import {
PropsActions as MessageActionsType,
PropsData as MessageProps,
} from './Message';
-
import {
CallingNotification,
PropsActionsType as CallingNotificationActionsType,
} from './CallingNotification';
+import {
+ ChatSessionRefreshedNotification,
+ PropsActionsType as PropsChatSessionRefreshedActionsType,
+} from './ChatSessionRefreshedNotification';
import { CallingNotificationType } from '../../util/callingNotification';
import { InlineNotificationWrapper } from './InlineNotificationWrapper';
import {
@@ -58,6 +61,10 @@ type CallHistoryType = {
type: 'callHistory';
data: CallingNotificationType;
};
+type ChatSessionRefreshedType = {
+ type: 'chatSessionRefreshed';
+ data: null;
+};
type LinkNotificationType = {
type: 'linkNotification';
data: null;
@@ -105,6 +112,7 @@ type ProfileChangeNotificationType = {
export type TimelineItemType =
| CallHistoryType
+ | ChatSessionRefreshedType
| GroupNotificationType
| GroupV1MigrationType
| GroupV2ChangeType
@@ -131,6 +139,7 @@ type PropsLocalType = {
type PropsActionsType = MessageActionsType &
CallingNotificationActionsType &
+ PropsChatSessionRefreshedActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
@@ -184,6 +193,14 @@ export class TimelineItem extends React.PureComponent {
{...item.data}
/>
);
+ } else if (item.type === 'chatSessionRefreshed') {
+ notification = (
+
+ );
} else if (item.type === 'linkNotification') {
notification = (
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index d24da65d7..129ec242c 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -2277,6 +2277,35 @@ export class ConversationModel extends window.Backbone.Model<
return this.setVerified();
}
+ async addChatSessionRefreshed(receivedAt: number): Promise {
+ window.log.info(
+ `addChatSessionRefreshed: adding for ${this.idForLogging()}`
+ );
+
+ const message = ({
+ conversationId: this.id,
+ type: 'chat-session-refreshed',
+ sent_at: receivedAt,
+ received_at: 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);
+ }
+
async addKeyChange(keyChangedId: string): Promise {
window.log.info(
'adding key change advisory for',
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index 9c54d33fd..8f340a1f4 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -201,6 +201,7 @@ export class MessageModel extends window.Backbone.Model {
isNormalBubble(): boolean {
return (
!this.isCallHistory() &&
+ !this.isChatSessionRefreshed() &&
!this.isEndSession() &&
!this.isExpirationTimerUpdate() &&
!this.isGroupUpdate() &&
@@ -282,6 +283,12 @@ export class MessageModel extends window.Backbone.Model {
data: this.getPropsForProfileChange(),
};
}
+ if (this.isChatSessionRefreshed()) {
+ return {
+ type: 'chatSessionRefreshed',
+ data: null,
+ };
+ }
return {
type: 'message',
@@ -461,6 +468,10 @@ export class MessageModel extends window.Backbone.Model {
return this.get('type') === 'call-history';
}
+ isChatSessionRefreshed(): boolean {
+ return this.get('type') === 'chat-session-refreshed';
+ }
+
isProfileChange(): boolean {
return this.get('type') === 'profile-change';
}
@@ -1178,6 +1189,13 @@ export class MessageModel extends window.Backbone.Model {
}
getNotificationData(): { emoji?: string; text: string } {
+ if (this.isChatSessionRefreshed()) {
+ return {
+ emoji: '🔁',
+ text: window.i18n('ChatRefresh--notification'),
+ };
+ }
+
if (this.isUnsupportedMessage()) {
return {
text: window.i18n('message--getDescription--unsupported-message'),
@@ -1695,6 +1713,7 @@ export class MessageModel extends window.Backbone.Model {
// Rendered sync messages
const isCallHistory = this.isCallHistory();
+ const isChatSessionRefreshed = this.isChatSessionRefreshed();
const isGroupUpdate = this.isGroupUpdate();
const isGroupV2Change = this.isGroupV2Change();
const isEndSession = this.isEndSession();
@@ -1723,6 +1742,7 @@ export class MessageModel extends window.Backbone.Model {
isSticker ||
// Rendered sync messages
isCallHistory ||
+ isChatSessionRefreshed ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||
diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts
index 52e9a7892..1eeea3a05 100644
--- a/ts/textsecure/MessageReceiver.ts
+++ b/ts/textsecure/MessageReceiver.ts
@@ -15,6 +15,7 @@ import { v4 as getGuid } from 'uuid';
import { SessionCipherClass, SignalProtocolAddressClass } from '../libsignal.d';
import { BatcherType, createBatcher } from '../util/batcher';
+import { assert } from '../util/assert';
import EventTarget from './EventTarget';
import { WebAPIType } from './WebAPI';
@@ -48,6 +49,8 @@ const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
+type SessionResetsType = Record;
+
declare global {
// We want to extend `Event`, so we need an interface.
// eslint-disable-next-line no-restricted-syntax
@@ -208,6 +211,8 @@ class MessageReceiverInner extends EventTarget {
maxSize: 30,
processBatch: this.cacheRemoveBatch.bind(this),
});
+
+ this.cleanupSessionResets();
}
static stringToArrayBuffer = (string: string): ArrayBuffer =>
@@ -237,6 +242,7 @@ class MessageReceiverInner extends EventTarget {
}
this.isEmptied = false;
+
this.hasConnected = true;
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
@@ -1089,34 +1095,120 @@ class MessageReceiverInner extends EventTarget {
return plaintext;
})
.catch(async error => {
- let errorToThrow = error;
+ this.removeFromCache(envelope);
- if (error && error.message === 'Unknown identity key') {
- // create an error that the UI will pick up and ask the
- // user if they want to re-negotiate
- const buffer = window.dcodeIO.ByteBuffer.wrap(ciphertext);
- errorToThrow = new IncomingIdentityKeyError(
- address.toString(),
- buffer.toArrayBuffer(),
- error.identityKey
+ const uuid = envelope.sourceUuid;
+ const deviceId = envelope.sourceDevice;
+
+ // We don't do a light session reset if it's just a duplicated message
+ if (error && error.name === 'MessageCounterError') {
+ throw error;
+ }
+
+ if (uuid && deviceId) {
+ await this.lightSessionReset(uuid, deviceId);
+ } else {
+ const envelopeId = this.getEnvelopeId(envelope);
+ window.log.error(
+ `MessageReceiver.decrypt: Envelope ${envelopeId} missing uuid or deviceId`
);
}
- if (envelope.timestamp && envelope.timestamp.toNumber) {
- // eslint-disable-next-line no-param-reassign
- envelope.timestamp = envelope.timestamp.toNumber();
- }
-
- const ev = new Event('error');
- ev.error = errorToThrow;
- ev.proto = envelope;
- ev.confirm = this.removeFromCache.bind(this, envelope);
-
- const returnError = async () => Promise.reject(errorToThrow);
- return this.dispatchAndWait(ev).then(returnError, returnError);
+ throw error;
});
}
+ 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) {
+ const id = `${uuid}.${deviceId}`;
+
+ try {
+ const sessionResets = window.storage.get(
+ 'sessionResets',
+ {}
+ ) as SessionResetsType;
+ const lastReset = sessionResets[id];
+
+ if (lastReset && !this.isOverHourIntoPast(lastReset)) {
+ window.log.warn(
+ `lightSessionReset: Skipping session reset for ${id}, last reset at ${lastReset}`
+ );
+ return;
+ }
+ sessionResets[id] = Date.now();
+ window.storage.put('sessionResets', sessionResets);
+
+ // First, fetch this conversation
+ const conversationId = window.ConversationController.ensureContactIds({
+ uuid,
+ });
+ assert(conversationId, 'lightSessionReset: missing conversationId');
+
+ const conversation = window.ConversationController.get(conversationId);
+ assert(conversation, 'lightSessionReset: missing conversation');
+
+ window.log.warn(`lightSessionReset: Resetting session for ${id}`);
+
+ // Archive open session with this device
+ const address = new window.libsignal.SignalProtocolAddress(
+ uuid,
+ deviceId
+ );
+ const sessionCipher = new window.libsignal.SessionCipher(
+ window.textsecure.storage.protocol,
+ address
+ );
+
+ await sessionCipher.closeOpenSessionForDevice();
+
+ // Send a null message with newly-created session
+ const sendOptions = conversation.getSendOptions();
+ await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions);
+
+ // Emit event for app to put item into conversation timeline
+ const event = new Event('light-session-reset');
+ event.senderUuid = uuid;
+ await this.dispatchAndWait(event);
+ } catch (error) {
+ // If we failed to do the session reset, then we'll allow another attempt
+ const sessionResets = window.storage.get(
+ 'sessionResets',
+ {}
+ ) as SessionResetsType;
+ delete sessionResets[id];
+ window.storage.put('sessionResets', sessionResets);
+
+ const errorString = error && error.stack ? error.stack : error;
+ window.log.error('lightSessionReset: Enountered error', errorString);
+ }
+ }
+
async decryptPreKeyWhisperMessage(
ciphertext: ArrayBuffer,
sessionCipher: SessionCipherClass,
@@ -2266,6 +2358,10 @@ 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();
}
@@ -2287,6 +2383,10 @@ export default class MessageReceiver {
unregisterBatchers: () => void;
+ isOverHourIntoPast: (timestamp: number) => boolean;
+
+ cleanupSessionResets: () => void;
+
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;
static arrayBufferToString = MessageReceiverInner.arrayBufferToString;
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index cb276ff45..9b5c7e48d 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -742,12 +742,7 @@ export default class MessageSender {
createSyncMessage(): SyncMessageClass {
const syncMessage = new window.textsecure.protobuf.SyncMessage();
- // Generate a random int from 1 and 512
- const buffer = window.libsignal.crypto.getRandomBytes(1);
- const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1;
-
- // Generate a random padding buffer of the chosen size
- syncMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength);
+ syncMessage.padding = this.getRandomPadding();
return syncMessage;
}
@@ -1374,6 +1369,47 @@ export default class MessageSender {
);
}
+ getRandomPadding(): ArrayBuffer {
+ // Generate a random int from 1 and 512
+ const buffer = window.libsignal.crypto.getRandomBytes(2);
+ const paddingLength = (new Uint16Array(buffer)[0] & 0x1ff) + 1;
+
+ // Generate a random padding buffer of the chosen size
+ return window.libsignal.crypto.getRandomBytes(paddingLength);
+ }
+
+ async sendNullMessage(
+ {
+ uuid,
+ e164,
+ padding,
+ }: { uuid?: string; e164?: string; padding?: ArrayBuffer },
+ options?: SendOptionsType
+ ): Promise {
+ const nullMessage = new window.textsecure.protobuf.NullMessage();
+
+ const identifier = uuid || e164;
+ if (!identifier) {
+ throw new Error('sendNullMessage: Got neither uuid nor e164!');
+ }
+
+ nullMessage.padding = padding || this.getRandomPadding();
+
+ 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 timestamp = Date.now();
+ return this.sendIndividualProto(
+ identifier,
+ contentMessage,
+ timestamp,
+ silent,
+ options
+ );
+ }
+
async syncVerification(
destinationE164: string,
destinationUuid: string,
@@ -1390,26 +1426,12 @@ export default class MessageSender {
return Promise.resolve();
}
+ // Get padding which we can share between null message and verified sync
+ const padding = this.getRandomPadding();
+
// First send a null message to mask the sync message.
- const nullMessage = new window.textsecure.protobuf.NullMessage();
-
- // Generate a random int from 1 and 512
- const buffer = window.libsignal.crypto.getRandomBytes(1);
- const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1;
-
- // Generate a random padding buffer of the chosen size
- nullMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength);
-
- 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 promise = this.sendIndividualProto(
- destinationUuid || destinationE164,
- contentMessage,
- now,
- silent,
+ const promise = this.sendNullMessage(
+ { uuid: destinationUuid, e164: destinationE164, padding },
options
);
@@ -1423,7 +1445,7 @@ export default class MessageSender {
verified.destinationUuid = destinationUuid;
}
verified.identityKey = identityKey;
- verified.nullMessage = nullMessage.padding;
+ verified.nullMessage = padding;
const syncMessage = this.createSyncMessage();
syncMessage.verified = verified;
diff --git a/ts/util/index.ts b/ts/util/index.ts
index 380327b74..b8c53db37 100644
--- a/ts/util/index.ts
+++ b/ts/util/index.ts
@@ -24,6 +24,7 @@ import { parseRemoteClientExpiration } from './parseRemoteClientExpiration';
import { sleep } from './sleep';
import { longRunningTaskWrapper } from './longRunningTaskWrapper';
import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64';
+import { mapToSupportLocale } from './mapToSupportLocale';
import * as zkgroup from './zkgroup';
export {
@@ -44,6 +45,7 @@ export {
isFileDangerous,
longRunningTaskWrapper,
makeLookup,
+ mapToSupportLocale,
missingCaseError,
parseRemoteClientExpiration,
Registration,
diff --git a/ts/util/mapToSupportLocale.ts b/ts/util/mapToSupportLocale.ts
new file mode 100644
index 000000000..5a00a7d26
--- /dev/null
+++ b/ts/util/mapToSupportLocale.ts
@@ -0,0 +1,115 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export type SupportLocaleType =
+ | 'ar'
+ | 'de'
+ | 'en-us'
+ | 'es'
+ | 'fr'
+ | 'it'
+ | 'ja'
+ | 'pl'
+ | 'pt-br'
+ | 'ru'
+ | 'sq'
+ | 'zh-tw';
+
+export type ElectronLocaleType =
+ | 'af'
+ | 'ar'
+ | 'bg'
+ | 'bn'
+ | 'ca'
+ | 'cs'
+ | 'cy'
+ | 'da'
+ | 'de'
+ | 'el'
+ | 'en'
+ | 'eo'
+ | 'es'
+ | 'es_419'
+ | 'et'
+ | 'eu'
+ | 'fa'
+ | 'fi'
+ | 'fr'
+ | 'he'
+ | 'hi'
+ | 'hr'
+ | 'hu'
+ | 'id'
+ | 'it'
+ | 'ja'
+ | 'km'
+ | 'kn'
+ | 'ko'
+ | 'lt'
+ | 'mk'
+ | 'mr'
+ | 'ms'
+ | 'nb'
+ | 'nl'
+ | 'nn'
+ | 'no'
+ | 'pl'
+ | 'pt_BR'
+ | 'pt_PT'
+ | 'ro'
+ | 'ru'
+ | 'sk'
+ | 'sl'
+ | 'sq'
+ | 'sr'
+ | 'sv'
+ | 'sw'
+ | 'ta'
+ | 'te'
+ | 'th'
+ | 'tr'
+ | 'uk'
+ | 'ur'
+ | 'vi'
+ | 'zh_CN'
+ | 'zh_TW';
+
+export function mapToSupportLocale(
+ ourLocale: ElectronLocaleType
+): SupportLocaleType {
+ if (ourLocale === 'ar') {
+ return ourLocale;
+ }
+ if (ourLocale === 'de') {
+ return ourLocale;
+ }
+ if (ourLocale === 'es') {
+ return ourLocale;
+ }
+ if (ourLocale === 'fr') {
+ return ourLocale;
+ }
+ if (ourLocale === 'it') {
+ return ourLocale;
+ }
+ if (ourLocale === 'ja') {
+ return ourLocale;
+ }
+ if (ourLocale === 'pl') {
+ return ourLocale;
+ }
+ if (ourLocale === 'pt_BR') {
+ return 'pt-br';
+ }
+ if (ourLocale === 'ru') {
+ return ourLocale;
+ }
+ if (ourLocale === 'sq') {
+ return ourLocale;
+ }
+ if (ourLocale === 'zh_TW') {
+ return 'zh-tw';
+ }
+
+ return 'en-us';
+}
diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts
index 1b2877a8c..046b5eeac 100644
--- a/ts/views/conversation_view.ts
+++ b/ts/views/conversation_view.ts
@@ -774,6 +774,15 @@ Whisper.ConversationView = Whisper.View.extend({
const showExpiredOutgoingTapToViewToast = () => {
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
};
+ const contactSupport = () => {
+ const baseUrl =
+ 'https://support.signal.org/hc/LOCALE/requests/new?desktop&chat_refreshed';
+ const locale = window.getLocale();
+ const supportLocale = window.Signal.Util.mapToSupportLocale(locale);
+ const url = baseUrl.replace('LOCALE', supportLocale);
+
+ this.navigateTo(url);
+ };
const scrollToQuotedMessage = async (options: any) => {
const { authorId, sentAt } = options;
@@ -928,6 +937,7 @@ Whisper.ConversationView = Whisper.View.extend({
JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, {
id,
+ contactSupport,
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 36993f772..414fdbea5 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -92,6 +92,7 @@ import { ProgressModal } from './components/ProgressModal';
import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { MIMEType } from './types/MIME';
+import { ElectronLocaleType } from './util/mapToSupportLocale';
export { Long } from 'long';
@@ -148,6 +149,7 @@ declare global {
getInboxCollection: () => ConversationModelCollectionType;
getIncomingCallNotification: () => Promise;
getInteractionMode: () => 'mouse' | 'keyboard';
+ getLocale: () => ElectronLocaleType;
getMediaCameraPermissions: () => Promise;
getMediaPermissions: () => Promise;
getNodeVersion: () => string;