Automatic session reset
This commit is contained in:
parent
fe187226bb
commit
98e7e65d25
26 changed files with 803 additions and 225 deletions
117
ts/background.ts
117
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) {
|
||||
|
|
|
@ -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 (
|
||||
<ChatSessionRefreshedDialog
|
||||
contactSupport={action('contactSupport')}
|
||||
onClose={action('onClose')}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
57
ts/components/conversation/ChatSessionRefreshedDialog.tsx
Normal file
57
ts/components/conversation/ChatSessionRefreshedDialog.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 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<ChatSessionRefreshedNotification
|
||||
contactSupport={action('contactSupport')}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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<boolean>(false);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setIsDialogOpen(true);
|
||||
}, [setIsDialogOpen]);
|
||||
const closeDialog = useCallback(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, [setIsDialogOpen]);
|
||||
|
||||
const wrappedContactSupport = useCallback(() => {
|
||||
setIsDialogOpen(false);
|
||||
contactSupport();
|
||||
}, [contactSupport, setIsDialogOpen]);
|
||||
|
||||
return (
|
||||
<div className="module-chat-session-refreshed-notification">
|
||||
<div className="module-chat-session-refreshed-notification__first-line">
|
||||
<span className="module-chat-session-refreshed-notification__icon" />
|
||||
{i18n('ChatRefresh--notification')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="module-chat-session-refreshed-notification__button"
|
||||
>
|
||||
{i18n('ChatRefresh--learnMore')}
|
||||
</button>
|
||||
{isDialogOpen ? (
|
||||
<ModalHost onClose={closeDialog}>
|
||||
<ChatSessionRefreshedDialog
|
||||
onClose={closeDialog}
|
||||
contactSupport={wrappedContactSupport}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</ModalHost>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -252,6 +252,8 @@ const actions = () => ({
|
|||
messageSizeChanged: action('messageSizeChanged'),
|
||||
startCallingLobby: action('startCallingLobby'),
|
||||
returnToActiveCall: action('returnToActiveCall'),
|
||||
|
||||
contactSupport: action('contactSupport'),
|
||||
});
|
||||
|
||||
const renderItem = (id: string) => (
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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<PropsType> {
|
|||
{...item.data}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'linkNotification') {
|
||||
notification = (
|
||||
<div className="module-message-unsynced">
|
||||
|
|
|
@ -2277,6 +2277,35 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
return this.setVerified();
|
||||
}
|
||||
|
||||
async addChatSessionRefreshed(receivedAt: number): Promise<void> {
|
||||
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<void> {
|
||||
window.log.info(
|
||||
'adding key change advisory for',
|
||||
|
|
|
@ -201,6 +201,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
isNormalBubble(): boolean {
|
||||
return (
|
||||
!this.isCallHistory() &&
|
||||
!this.isChatSessionRefreshed() &&
|
||||
!this.isEndSession() &&
|
||||
!this.isExpirationTimerUpdate() &&
|
||||
!this.isGroupUpdate() &&
|
||||
|
@ -282,6 +283,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
}
|
||||
|
||||
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<MessageAttributesType> {
|
|||
|
||||
// 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<MessageAttributesType> {
|
|||
isSticker ||
|
||||
// Rendered sync messages
|
||||
isCallHistory ||
|
||||
isChatSessionRefreshed ||
|
||||
isGroupUpdate ||
|
||||
isGroupV2Change ||
|
||||
isEndSession ||
|
||||
|
|
|
@ -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<string, number>;
|
||||
|
||||
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;
|
||||
|
|
|
@ -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<CallbackResultType> {
|
||||
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;
|
||||
|
|
|
@ -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,
|
||||
|
|
115
ts/util/mapToSupportLocale.ts
Normal file
115
ts/util/mapToSupportLocale.ts
Normal file
|
@ -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';
|
||||
}
|
|
@ -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,
|
||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -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<boolean>;
|
||||
getInteractionMode: () => 'mouse' | 'keyboard';
|
||||
getLocale: () => ElectronLocaleType;
|
||||
getMediaCameraPermissions: () => Promise<boolean>;
|
||||
getMediaPermissions: () => Promise<boolean>;
|
||||
getNodeVersion: () => string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue