Automatic session reset

This commit is contained in:
Scott Nonnenberg 2021-02-18 08:40:26 -08:00 committed by Josh Perez
parent fe187226bb
commit 98e7e65d25
26 changed files with 803 additions and 225 deletions

View file

@ -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) {

View file

@ -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}
/>
);
}
);

View 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>
);
}

View file

@ -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}
/>
);
});

View file

@ -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>
);
}

View file

@ -252,6 +252,8 @@ const actions = () => ({
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
contactSupport: action('contactSupport'),
});
const renderItem = (id: string) => (

View file

@ -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'),

View file

@ -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">

View file

@ -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',

View file

@ -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 ||

View file

@ -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;

View file

@ -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;

View file

@ -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,

View 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';
}

View file

@ -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
View file

@ -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;