Show challenge when requested by server
This commit is contained in:
parent
03c68da17d
commit
986d8a66bc
42 changed files with 1986 additions and 128 deletions
|
@ -1505,6 +1505,10 @@
|
||||||
"message": "Send failed",
|
"message": "Send failed",
|
||||||
"description": "Shown on outgoing message if it fails to send"
|
"description": "Shown on outgoing message if it fails to send"
|
||||||
},
|
},
|
||||||
|
"sendPaused": {
|
||||||
|
"message": "Send paused",
|
||||||
|
"description": "Shown on outgoing message if it cannot be sent immediately"
|
||||||
|
},
|
||||||
"partiallySent": {
|
"partiallySent": {
|
||||||
"message": "Partially sent, click for details",
|
"message": "Partially sent, click for details",
|
||||||
"description": "Shown on outgoing message if it is partially sent"
|
"description": "Shown on outgoing message if it is partially sent"
|
||||||
|
@ -5118,5 +5122,41 @@
|
||||||
"ContactSpoofingReviewDialog__safe-title": {
|
"ContactSpoofingReviewDialog__safe-title": {
|
||||||
"message": "Your contact",
|
"message": "Your contact",
|
||||||
"description": "Header in the contact spoofing review dialog, shown above the \"safe\" user"
|
"description": "Header in the contact spoofing review dialog, shown above the \"safe\" user"
|
||||||
|
},
|
||||||
|
"CaptchaDialog__title": {
|
||||||
|
"message": "Verify to continue messaging",
|
||||||
|
"description": "Header in the captcha dialog"
|
||||||
|
},
|
||||||
|
"CaptchaDialog__first-paragraph": {
|
||||||
|
"message": "To help prevent spam on Signal, please complete verification.",
|
||||||
|
"description": "First paragraph in the captcha dialog"
|
||||||
|
},
|
||||||
|
"CaptchaDialog__second-paragraph": {
|
||||||
|
"message": "After verifying, you can continue messaging. Any paused messages will automatically be sent.",
|
||||||
|
"description": "First paragraph in the captcha dialog"
|
||||||
|
},
|
||||||
|
"CaptchaDialog--can-close__title": {
|
||||||
|
"message": "Continue Without Verifying?",
|
||||||
|
"description": "Header in the captcha dialog that can be closed"
|
||||||
|
},
|
||||||
|
"CaptchaDialog--can-close__body": {
|
||||||
|
"message": "If you choose to skip verification, you may miss messages from other people and your messages may fail to send.",
|
||||||
|
"description": "Body of the captcha dialog that can be closed"
|
||||||
|
},
|
||||||
|
"CaptchaDialog--can_close__skip-verification": {
|
||||||
|
"message": "Skip verification",
|
||||||
|
"description": "Skip button of the captcha dialog that can be closed"
|
||||||
|
},
|
||||||
|
"verificationComplete": {
|
||||||
|
"message": "Verification complete.",
|
||||||
|
"description": "Displayed after successful captcha"
|
||||||
|
},
|
||||||
|
"verificationFailed": {
|
||||||
|
"message": "Verification failed. Please retry later.",
|
||||||
|
"description": "Displayed after unsuccessful captcha"
|
||||||
|
},
|
||||||
|
"deleteForEveryoneFailed": {
|
||||||
|
"message": "Failed to delete message for everyone. Please retry later.",
|
||||||
|
"description": "Displayed when delete-for-everyone has failed to send to all recepients"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
22
main.js
22
main.js
|
@ -108,8 +108,10 @@ const OS = require('./ts/OS');
|
||||||
const { isBeta } = require('./ts/util/version');
|
const { isBeta } = require('./ts/util/version');
|
||||||
const {
|
const {
|
||||||
isSgnlHref,
|
isSgnlHref,
|
||||||
|
isCaptchaHref,
|
||||||
isSignalHttpsLink,
|
isSignalHttpsLink,
|
||||||
parseSgnlHref,
|
parseSgnlHref,
|
||||||
|
parseCaptchaHref,
|
||||||
parseSignalHttpsLink,
|
parseSignalHttpsLink,
|
||||||
} = require('./ts/util/sgnlHref');
|
} = require('./ts/util/sgnlHref');
|
||||||
const {
|
const {
|
||||||
|
@ -120,8 +122,10 @@ const {
|
||||||
TitleBarVisibility,
|
TitleBarVisibility,
|
||||||
} = require('./ts/types/Settings');
|
} = require('./ts/types/Settings');
|
||||||
const { Environment } = require('./ts/environment');
|
const { Environment } = require('./ts/environment');
|
||||||
|
const { ChallengeMainHandler } = require('./ts/main/challengeMain');
|
||||||
|
|
||||||
const sql = new MainSQL();
|
const sql = new MainSQL();
|
||||||
|
const challengeHandler = new ChallengeMainHandler();
|
||||||
|
|
||||||
let sqlInitTimeStart = 0;
|
let sqlInitTimeStart = 0;
|
||||||
let sqlInitTimeEnd = 0;
|
let sqlInitTimeEnd = 0;
|
||||||
|
@ -193,6 +197,12 @@ if (!process.mas) {
|
||||||
|
|
||||||
showWindow();
|
showWindow();
|
||||||
}
|
}
|
||||||
|
const incomingCaptchaHref = getIncomingCaptchaHref(argv);
|
||||||
|
if (incomingCaptchaHref) {
|
||||||
|
const { captcha } = parseCaptchaHref(incomingCaptchaHref, logger);
|
||||||
|
challengeHandler.handleCaptcha(captcha);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Are they trying to open a sgnl:// href?
|
// Are they trying to open a sgnl:// href?
|
||||||
const incomingHref = getIncomingHref(argv);
|
const incomingHref = getIncomingHref(argv);
|
||||||
if (incomingHref) {
|
if (incomingHref) {
|
||||||
|
@ -1391,11 +1401,19 @@ app.on('web-contents-created', (createEvent, contents) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setAsDefaultProtocolClient('sgnl');
|
app.setAsDefaultProtocolClient('sgnl');
|
||||||
|
app.setAsDefaultProtocolClient('signalcaptcha');
|
||||||
app.on('will-finish-launching', () => {
|
app.on('will-finish-launching', () => {
|
||||||
// open-url must be set from within will-finish-launching for macOS
|
// open-url must be set from within will-finish-launching for macOS
|
||||||
// https://stackoverflow.com/a/43949291
|
// https://stackoverflow.com/a/43949291
|
||||||
app.on('open-url', (event, incomingHref) => {
|
app.on('open-url', (event, incomingHref) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isCaptchaHref(incomingHref, logger)) {
|
||||||
|
const { captcha } = parseCaptchaHref(incomingHref, logger);
|
||||||
|
challengeHandler.handleCaptcha(captcha);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handleSgnlHref(incomingHref);
|
handleSgnlHref(incomingHref);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1656,6 +1674,10 @@ function getIncomingHref(argv) {
|
||||||
return argv.find(arg => isSgnlHref(arg, logger));
|
return argv.find(arg => isSgnlHref(arg, logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getIncomingCaptchaHref(argv) {
|
||||||
|
return argv.find(arg => isCaptchaHref(arg, logger));
|
||||||
|
}
|
||||||
|
|
||||||
function handleSgnlHref(incomingHref) {
|
function handleSgnlHref(incomingHref) {
|
||||||
let command;
|
let command;
|
||||||
let args;
|
let args;
|
||||||
|
|
|
@ -367,7 +367,8 @@
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"name": "sgnl-url-scheme",
|
"name": "sgnl-url-scheme",
|
||||||
"schemes": [
|
"schemes": [
|
||||||
"sgnl"
|
"sgnl",
|
||||||
|
"signalcaptcha"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
|
|
|
@ -172,6 +172,12 @@ try {
|
||||||
Whisper.events.trigger('setupAsStandalone');
|
Whisper.events.trigger('setupAsStandalone');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.on('challenge:response', (_event, response) => {
|
||||||
|
Whisper.events.trigger('challengeResponse', response);
|
||||||
|
});
|
||||||
|
window.sendChallengeRequest = request =>
|
||||||
|
ipc.send('challenge:request', request);
|
||||||
|
|
||||||
{
|
{
|
||||||
let isFullScreen = config.isFullScreen === 'true';
|
let isFullScreen = config.isFullScreen === 'true';
|
||||||
|
|
||||||
|
|
|
@ -297,6 +297,17 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.module-message__error--paused {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v2/error-outline-24.svg',
|
||||||
|
$color-gray-60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v2/error-solid-24.svg', $color-gray-45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__error--outgoing {
|
.module-message__error--outgoing {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
@ -1265,6 +1276,7 @@
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__metadata__status-icon--paused,
|
||||||
.module-message__metadata__status-icon--sending {
|
.module-message__metadata__status-icon--sending {
|
||||||
animation: module-message__metadata__status-icon--spinning 4s linear infinite;
|
animation: module-message__metadata__status-icon--spinning 4s linear infinite;
|
||||||
|
|
||||||
|
@ -5254,6 +5266,10 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--on-captcha {
|
||||||
|
background-color: $color-white-alpha-40;
|
||||||
|
}
|
||||||
|
|
||||||
.module-spinner__circle--on-progress-dialog {
|
.module-spinner__circle--on-progress-dialog {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
background-color: $color-white;
|
background-color: $color-white;
|
||||||
|
@ -5268,6 +5284,9 @@ button.module-image__border-overlay:focus {
|
||||||
.module-spinner__arc--on-avatar {
|
.module-spinner__arc--on-avatar {
|
||||||
background-color: $color-white;
|
background-color: $color-white;
|
||||||
}
|
}
|
||||||
|
.module-spinner__arc--on-captcha {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Highlighted Message Body
|
// Module: Highlighted Message Body
|
||||||
|
|
||||||
|
@ -7023,6 +7042,21 @@ button.module-image__border-overlay:focus {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--paused {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v2/error-outline-12.svg',
|
||||||
|
$color-gray-60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v2/error-solid-12.svg',
|
||||||
|
$color-gray-45
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__message-search-result-contents {
|
&__message-search-result-contents {
|
||||||
|
|
|
@ -39,20 +39,27 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|
||||||
@include light-theme {
|
&::before {
|
||||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
content: '';
|
||||||
}
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
@include dark-theme {
|
@include light-theme {
|
||||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
|
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
@include keyboard-mode {
|
|
||||||
background-color: $ultramarine-ui-light;
|
|
||||||
}
|
}
|
||||||
@include dark-keyboard-mode {
|
|
||||||
background-color: $ultramarine-ui-dark;
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
@include keyboard-mode {
|
||||||
|
background-color: $ultramarine-ui-light;
|
||||||
|
}
|
||||||
|
@include dark-keyboard-mode {
|
||||||
|
background-color: $ultramarine-ui-dark;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,4 +100,50 @@
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overrides for a modal with important message
|
||||||
|
&--important {
|
||||||
|
padding: 10px 12px 16px 12px;
|
||||||
|
|
||||||
|
.module-Modal__header {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-Modal__body {
|
||||||
|
padding: 0 12px 4px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-Modal__body p {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-Modal__title {
|
||||||
|
@include font-title-2;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 22px 0;
|
||||||
|
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--with-x-button {
|
||||||
|
margin-top: 31px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-Modal__footer {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 27px;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.module-Button {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 152px;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { DataMessageClass } from './textsecure.d';
|
||||||
import { MessageAttributesType } from './model-types.d';
|
import { MessageAttributesType } from './model-types.d';
|
||||||
import { WhatIsThis } from './window.d';
|
import { WhatIsThis } from './window.d';
|
||||||
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
||||||
|
import { ChallengeHandler } from './challenge';
|
||||||
import { isWindowDragElement } from './util/isWindowDragElement';
|
import { isWindowDragElement } from './util/isWindowDragElement';
|
||||||
import { assert } from './util/assert';
|
import { assert } from './util/assert';
|
||||||
import { senderCertificateService } from './services/senderCertificate';
|
import { senderCertificateService } from './services/senderCertificate';
|
||||||
|
@ -12,6 +13,7 @@ import { routineProfileRefresh } from './routineProfileRefresh';
|
||||||
import { isMoreRecentThan, isOlderThan } from './util/timestamp';
|
import { isMoreRecentThan, isOlderThan } from './util/timestamp';
|
||||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
||||||
import { ConversationModel } from './models/conversations';
|
import { ConversationModel } from './models/conversations';
|
||||||
|
import { getMessageById } from './models/messages';
|
||||||
import { createBatcher } from './util/batcher';
|
import { createBatcher } from './util/batcher';
|
||||||
import { updateConversationsWithUuidLookup } from './updateConversationsWithUuidLookup';
|
import { updateConversationsWithUuidLookup } from './updateConversationsWithUuidLookup';
|
||||||
import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
|
import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
|
||||||
|
@ -1439,7 +1441,62 @@ export async function startApp(): Promise<void> {
|
||||||
window.textsecure.messaging.sendRequestKeySyncMessage();
|
window.textsecure.messaging.sendRequestKeySyncMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let challengeHandler: ChallengeHandler | undefined;
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
challengeHandler = new ChallengeHandler({
|
||||||
|
storage: window.storage,
|
||||||
|
|
||||||
|
getMessageById,
|
||||||
|
|
||||||
|
requestChallenge(request) {
|
||||||
|
window.sendChallengeRequest(request);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendChallengeResponse(data) {
|
||||||
|
await window.textsecure.messaging.sendChallengeResponse(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
onChallengeFailed() {
|
||||||
|
// TODO: DESKTOP-1530
|
||||||
|
// Display humanized `retryAfter`
|
||||||
|
window.Whisper.ToastView.show(
|
||||||
|
window.Whisper.CaptchaFailedToast,
|
||||||
|
document.getElementsByClassName('conversation-stack')[0] ||
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
onChallengeSolved() {
|
||||||
|
window.Whisper.ToastView.show(
|
||||||
|
window.Whisper.CaptchaSolvedToast,
|
||||||
|
document.getElementsByClassName('conversation-stack')[0] ||
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setChallengeStatus(challengeStatus) {
|
||||||
|
window.reduxActions.network.setChallengeStatus(challengeStatus);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.Whisper.events.on('challengeResponse', response => {
|
||||||
|
if (!challengeHandler) {
|
||||||
|
throw new Error('Expected challenge handler to be there');
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeHandler.onResponse(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.storage.onready(async () => {
|
||||||
|
if (!challengeHandler) {
|
||||||
|
throw new Error('Expected challenge handler to be there');
|
||||||
|
}
|
||||||
|
|
||||||
|
await challengeHandler.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.Signal.challengeHandler = challengeHandler;
|
||||||
|
|
||||||
window.dispatchEvent(new Event('storage_ready'));
|
window.dispatchEvent(new Event('storage_ready'));
|
||||||
|
|
||||||
window.log.info('Cleanup: starting...');
|
window.log.info('Cleanup: starting...');
|
||||||
|
@ -1661,6 +1718,10 @@ export async function startApp(): Promise<void> {
|
||||||
// we get an online event. This waits a bit after getting an 'offline' event
|
// we get an online event. This waits a bit after getting an 'offline' event
|
||||||
// before disconnecting the socket manually.
|
// before disconnecting the socket manually.
|
||||||
disconnectTimer = setTimeout(disconnect, 1000);
|
disconnectTimer = setTimeout(disconnect, 1000);
|
||||||
|
|
||||||
|
if (challengeHandler) {
|
||||||
|
challengeHandler.onOffline();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOnline() {
|
function onOnline() {
|
||||||
|
@ -2046,6 +2107,13 @@ export async function startApp(): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!challengeHandler) {
|
||||||
|
throw new Error('Expected challenge handler to be initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentionally not awaiting
|
||||||
|
challengeHandler.onOnline();
|
||||||
} finally {
|
} finally {
|
||||||
connecting = false;
|
connecting = false;
|
||||||
}
|
}
|
||||||
|
|
485
ts/challenge.ts
Normal file
485
ts/challenge.ts
Normal file
|
@ -0,0 +1,485 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
// `ChallengeHandler` is responsible for:
|
||||||
|
// 1. tracking the messages that failed to send with 428 error and could be
|
||||||
|
// retried when user solves the challenge
|
||||||
|
// 2. presenting the challenge to user and sending the challenge response back
|
||||||
|
// to the server
|
||||||
|
//
|
||||||
|
// The tracked messages are persisted in the database, and are imported back
|
||||||
|
// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They
|
||||||
|
// are not immediately retried, however, until `.onOnline()` is called from
|
||||||
|
// when we are actually online.
|
||||||
|
|
||||||
|
import { MessageModel } from './models/messages';
|
||||||
|
import { assert } from './util/assert';
|
||||||
|
import { isNotNil } from './util/isNotNil';
|
||||||
|
import { isOlderThan } from './util/timestamp';
|
||||||
|
import { parseRetryAfter } from './util/parseRetryAfter';
|
||||||
|
import { getEnvironment, Environment } from './environment';
|
||||||
|
|
||||||
|
export type ChallengeResponse = {
|
||||||
|
readonly captcha: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPCRequest = {
|
||||||
|
readonly seq: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPCResponse = {
|
||||||
|
readonly seq: number;
|
||||||
|
readonly data: ChallengeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum RetryMode {
|
||||||
|
Retry = 'Retry',
|
||||||
|
NoImmediateRetry = 'NoImmediateRetry',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = {
|
||||||
|
readonly token: string | undefined;
|
||||||
|
|
||||||
|
resolve(response: ChallengeResponse): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeData = {
|
||||||
|
readonly type: 'recaptcha';
|
||||||
|
readonly token: string;
|
||||||
|
readonly captcha: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinimalMessage = Pick<
|
||||||
|
MessageModel,
|
||||||
|
'id' | 'idForLogging' | 'getLastChallengeError' | 'retrySend'
|
||||||
|
> & {
|
||||||
|
isNormalBubble(): boolean;
|
||||||
|
get(name: 'sent_at'): number;
|
||||||
|
on(event: 'sent', callback: () => void): void;
|
||||||
|
off(event: 'sent', callback: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
readonly storage: {
|
||||||
|
get(key: string): ReadonlyArray<StoredEntity>;
|
||||||
|
put(key: string, value: ReadonlyArray<StoredEntity>): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
requestChallenge(request: IPCRequest): void;
|
||||||
|
|
||||||
|
getMessageById(messageId: string): Promise<MinimalMessage | undefined>;
|
||||||
|
|
||||||
|
sendChallengeResponse(data: ChallengeData): Promise<void>;
|
||||||
|
|
||||||
|
setChallengeStatus(challengeStatus: 'idle' | 'required' | 'pending'): void;
|
||||||
|
|
||||||
|
onChallengeSolved(): void;
|
||||||
|
onChallengeFailed(retryAfter?: number): void;
|
||||||
|
|
||||||
|
expireAfter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoredEntity = {
|
||||||
|
readonly messageId: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrackedEntry = {
|
||||||
|
readonly message: MinimalMessage;
|
||||||
|
readonly createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html';
|
||||||
|
const CAPTCHA_STAGING_URL =
|
||||||
|
'https://signalcaptchas.org/staging/challenge/generate.html';
|
||||||
|
|
||||||
|
function shouldRetrySend(message: MinimalMessage): boolean {
|
||||||
|
const error = message.getLastChallengeError();
|
||||||
|
if (!error || error.retryAfter <= Date.now()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChallengeURL(): string {
|
||||||
|
if (getEnvironment() === Environment.Staging) {
|
||||||
|
return CAPTCHA_STAGING_URL;
|
||||||
|
}
|
||||||
|
return CAPTCHA_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that even though this is a class - only one instance of
|
||||||
|
// `ChallengeHandler` should be in memory at the same time because they could
|
||||||
|
// overwrite each others storage data.
|
||||||
|
export class ChallengeHandler {
|
||||||
|
private isLoaded = false;
|
||||||
|
|
||||||
|
private challengeToken: string | undefined;
|
||||||
|
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
private isOnline = false;
|
||||||
|
|
||||||
|
private readonly responseHandlers = new Map<number, Handler>();
|
||||||
|
|
||||||
|
private readonly trackedMessages = new Map<string, TrackedEntry>();
|
||||||
|
|
||||||
|
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
private readonly pendingRetries = new Set<MinimalMessage>();
|
||||||
|
|
||||||
|
private readonly retryCountById = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(private readonly options: Options) {}
|
||||||
|
|
||||||
|
public async load(): Promise<void> {
|
||||||
|
if (this.isLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoaded = true;
|
||||||
|
const stored: ReadonlyArray<StoredEntity> =
|
||||||
|
this.options.storage.get('challenge:retry-message-ids') || [];
|
||||||
|
|
||||||
|
window.log.info(`challenge: loading ${stored.length} messages`);
|
||||||
|
|
||||||
|
const entityMap = new Map<string, StoredEntity>();
|
||||||
|
for (const entity of stored) {
|
||||||
|
entityMap.set(entity.messageId, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryIds = new Set<string>(stored.map(({ messageId }) => messageId));
|
||||||
|
|
||||||
|
const maybeMessages: ReadonlyArray<
|
||||||
|
MinimalMessage | undefined
|
||||||
|
> = await Promise.all(
|
||||||
|
Array.from(retryIds).map(async messageId =>
|
||||||
|
this.options.getMessageById(messageId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: Array<MinimalMessage> = maybeMessages.filter(isNotNil);
|
||||||
|
|
||||||
|
window.log.info(`challenge: loaded ${messages.length} messages`);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
messages.map(async message => {
|
||||||
|
const entity = entityMap.get(message.id);
|
||||||
|
if (!entity) {
|
||||||
|
window.log.error(
|
||||||
|
'challenge: unexpected missing entity ' +
|
||||||
|
`for ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireAfter = this.options.expireAfter || DEFAULT_EXPIRE_AFTER;
|
||||||
|
if (isOlderThan(entity.createdAt, expireAfter)) {
|
||||||
|
window.log.info(
|
||||||
|
`challenge: expired entity for ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The initialization order is following:
|
||||||
|
//
|
||||||
|
// 1. `.load()` when the `window.storage` is ready
|
||||||
|
// 2. `.onOnline()` when we connected to the server
|
||||||
|
//
|
||||||
|
// Wait for `.onOnline()` to trigger the retries instead of triggering
|
||||||
|
// them here immediately (if the message is ready to be retried).
|
||||||
|
await this.register(message, RetryMode.NoImmediateRetry, entity);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onOffline(): Promise<void> {
|
||||||
|
this.isOnline = false;
|
||||||
|
|
||||||
|
window.log.info('challenge: offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onOnline(): Promise<void> {
|
||||||
|
this.isOnline = true;
|
||||||
|
|
||||||
|
const pending = Array.from(this.pendingRetries.values());
|
||||||
|
this.pendingRetries.clear();
|
||||||
|
|
||||||
|
window.log.info(`challenge: online, retrying ${pending.length} messages`);
|
||||||
|
|
||||||
|
// Retry messages that matured while we were offline
|
||||||
|
await Promise.all(pending.map(message => this.retryOne(message)));
|
||||||
|
|
||||||
|
await this.retrySend();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async register(
|
||||||
|
message: MinimalMessage,
|
||||||
|
retry = RetryMode.Retry,
|
||||||
|
entity?: StoredEntity
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.isRegistered(message)) {
|
||||||
|
window.log.info(
|
||||||
|
`challenge: message already registered ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.trackedMessages.set(message.id, {
|
||||||
|
message,
|
||||||
|
createdAt: entity ? entity.createdAt : Date.now(),
|
||||||
|
});
|
||||||
|
await this.persist();
|
||||||
|
|
||||||
|
// Message is already retryable - initiate new send
|
||||||
|
if (retry === RetryMode.Retry && shouldRetrySend(message)) {
|
||||||
|
window.log.info(
|
||||||
|
`challenge: sending message immediately ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
await this.retryOne(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = message.getLastChallengeError();
|
||||||
|
if (!error) {
|
||||||
|
window.log.error('Unexpected message without challenge error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitTime = Math.max(0, error.retryAfter - Date.now());
|
||||||
|
const oldTimer = this.retryTimers.get(message.id);
|
||||||
|
if (oldTimer) {
|
||||||
|
clearTimeout(oldTimer);
|
||||||
|
}
|
||||||
|
this.retryTimers.set(
|
||||||
|
message.id,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.retryTimers.delete(message.id);
|
||||||
|
|
||||||
|
this.retryOne(message);
|
||||||
|
}, waitTime)
|
||||||
|
);
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
`challenge: tracking ${message.idForLogging()} ` +
|
||||||
|
`with waitTime=${waitTime}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!error.data.options || !error.data.options.includes('recaptcha')) {
|
||||||
|
window.log.error(
|
||||||
|
`challenge: unexpected options ${JSON.stringify(error.data.options)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.data.token) {
|
||||||
|
window.log.error(
|
||||||
|
`challenge: no token in challenge error ${JSON.stringify(error.data)}`
|
||||||
|
);
|
||||||
|
} else if (message.isNormalBubble()) {
|
||||||
|
// Display challenge dialog only for core messages
|
||||||
|
// (e.g. text, attachment, embedded contact, or sticker)
|
||||||
|
//
|
||||||
|
// Note: not waiting on this call intentionally since it waits for
|
||||||
|
// challenge to be fully completed.
|
||||||
|
this.solve(error.data.token);
|
||||||
|
} else {
|
||||||
|
window.log.info(
|
||||||
|
`challenge: not a bubble message ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onResponse(response: IPCResponse): void {
|
||||||
|
const handler = this.responseHandlers.get(response.seq);
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.responseHandlers.delete(response.seq);
|
||||||
|
handler.resolve(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unregister(message: MinimalMessage): Promise<void> {
|
||||||
|
window.log.info(`challenge: unregistered ${message.idForLogging()}`);
|
||||||
|
this.trackedMessages.delete(message.id);
|
||||||
|
this.pendingRetries.delete(message);
|
||||||
|
|
||||||
|
const timer = this.retryTimers.get(message.id);
|
||||||
|
this.retryTimers.delete(message.id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
assert(
|
||||||
|
this.isLoaded,
|
||||||
|
'ChallengeHandler has to be loaded before persisting new data'
|
||||||
|
);
|
||||||
|
await this.options.storage.put(
|
||||||
|
'challenge:retry-message-ids',
|
||||||
|
Array.from(this.trackedMessages.entries()).map(
|
||||||
|
([messageId, { createdAt }]) => {
|
||||||
|
return { messageId, createdAt };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRegistered(message: MinimalMessage): boolean {
|
||||||
|
return this.trackedMessages.has(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retrySend(force = false): Promise<void> {
|
||||||
|
window.log.info(`challenge: retrySend force=${force}`);
|
||||||
|
|
||||||
|
const retries = Array.from(this.trackedMessages.values())
|
||||||
|
.map(({ message }) => message)
|
||||||
|
// Sort messages in `sent_at` order
|
||||||
|
.sort((a, b) => a.get('sent_at') - b.get('sent_at'))
|
||||||
|
.filter(message => force || shouldRetrySend(message))
|
||||||
|
.map(message => this.retryOne(message));
|
||||||
|
|
||||||
|
await Promise.all(retries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryOne(message: MinimalMessage): Promise<void> {
|
||||||
|
// Send is already pending
|
||||||
|
if (!this.isRegistered(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are not online
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.pendingRetries.add(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryCount = this.retryCountById.get(message.id) || 0;
|
||||||
|
window.log.info(
|
||||||
|
`challenge: retrying sending ${message.idForLogging()}, ` +
|
||||||
|
`retry count: ${retryCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retryCount === MAX_RETRIES) {
|
||||||
|
window.log.info(
|
||||||
|
`challenge: dropping message ${message.idForLogging()}, ` +
|
||||||
|
'too many failed retries'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the message registered so that we'll retry sending it on app
|
||||||
|
// restart.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.unregister(message);
|
||||||
|
|
||||||
|
let sent = false;
|
||||||
|
const onSent = () => {
|
||||||
|
sent = true;
|
||||||
|
};
|
||||||
|
message.on('sent', onSent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await message.retrySend();
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
`challenge: failed to send ${message.idForLogging()} due to ` +
|
||||||
|
`error: ${error && error.stack}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
message.off('sent', onSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
window.log.info(`challenge: message ${message.idForLogging()} sent`);
|
||||||
|
this.retryCountById.delete(message.id);
|
||||||
|
if (this.trackedMessages.size === 0) {
|
||||||
|
this.options.setChallengeStatus('idle');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.log.info(`challenge: message ${message.idForLogging()} not sent`);
|
||||||
|
|
||||||
|
this.retryCountById.set(message.id, retryCount + 1);
|
||||||
|
await this.register(message, RetryMode.NoImmediateRetry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async solve(token: string): Promise<void> {
|
||||||
|
const request: IPCRequest = { seq: this.seq };
|
||||||
|
this.seq += 1;
|
||||||
|
|
||||||
|
this.options.setChallengeStatus('required');
|
||||||
|
this.options.requestChallenge(request);
|
||||||
|
|
||||||
|
this.challengeToken = token || '';
|
||||||
|
const response = await new Promise<ChallengeResponse>((resolve, reject) => {
|
||||||
|
this.responseHandlers.set(request.seq, { token, resolve, reject });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another `.solve()` has completed earlier than us
|
||||||
|
if (this.challengeToken === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastToken = this.challengeToken;
|
||||||
|
this.challengeToken = undefined;
|
||||||
|
|
||||||
|
this.options.setChallengeStatus('pending');
|
||||||
|
|
||||||
|
window.log.info('challenge: sending challenge to server');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sendChallengeResponse({
|
||||||
|
type: 'recaptcha',
|
||||||
|
token: lastToken,
|
||||||
|
captcha: response.captcha,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
`challenge: challenge failure, error: ${error && error.stack}`
|
||||||
|
);
|
||||||
|
this.options.setChallengeStatus('required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('challenge: challenge success. force sending');
|
||||||
|
|
||||||
|
this.options.setChallengeStatus('idle');
|
||||||
|
|
||||||
|
this.retrySend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendChallengeResponse(data: ChallengeData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.options.sendChallengeResponse(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!(error instanceof Error) ||
|
||||||
|
error.name !== 'HTTPError' ||
|
||||||
|
error.code !== 413 ||
|
||||||
|
!error.responseHeaders
|
||||||
|
) {
|
||||||
|
this.options.onChallengeFailed();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = parseRetryAfter(
|
||||||
|
error.responseHeaders['retry-after'].toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
window.log.info(`challenge: retry after ${retryAfter}ms`);
|
||||||
|
this.options.onChallengeFailed(retryAfter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.onChallengeSolved();
|
||||||
|
}
|
||||||
|
}
|
33
ts/components/CaptchaDialog.stories.tsx
Normal file
33
ts/components/CaptchaDialog.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { boolean } from '@storybook/addon-knobs';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
|
||||||
|
import { CaptchaDialog } from './CaptchaDialog';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
|
||||||
|
const story = storiesOf('Components/CaptchaDialog', module);
|
||||||
|
|
||||||
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
story.add('CaptchaDialog', () => {
|
||||||
|
const [isSkipped, setIsSkipped] = useState(false);
|
||||||
|
|
||||||
|
if (isSkipped) {
|
||||||
|
return <Button onClick={() => setIsSkipped(false)}>Show again</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CaptchaDialog
|
||||||
|
i18n={i18n}
|
||||||
|
isPending={boolean('isPending', false)}
|
||||||
|
onContinue={action('onContinue')}
|
||||||
|
onSkip={() => setIsSkipped(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
99
ts/components/CaptchaDialog.tsx
Normal file
99
ts/components/CaptchaDialog.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { LocalizerType } from '../types/Util';
|
||||||
|
import { Button, ButtonVariant } from './Button';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
|
type PropsType = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
isPending: boolean;
|
||||||
|
|
||||||
|
onContinue: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
|
||||||
|
const { i18n, isPending, onSkip, onContinue } = props;
|
||||||
|
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const onCancelClick = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsClosing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSkipClick = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSkip();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isClosing && !isPending) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
moduleClassName="module-Modal"
|
||||||
|
i18n={i18n}
|
||||||
|
title={i18n('CaptchaDialog--can-close__title')}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<p>{i18n('CaptchaDialog--can-close__body')}</p>
|
||||||
|
</section>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button onClick={onCancelClick} variant={ButtonVariant.Secondary}>
|
||||||
|
{i18n('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSkipClick} variant={ButtonVariant.Destructive}>
|
||||||
|
{i18n('CaptchaDialog--can_close__skip-verification')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContinueClick = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onContinue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateButtonRef = (button: HTMLButtonElement): void => {
|
||||||
|
buttonRef.current = button;
|
||||||
|
if (button) {
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
moduleClassName="module-Modal--important"
|
||||||
|
i18n={i18n}
|
||||||
|
title={i18n('CaptchaDialog__title')}
|
||||||
|
hasXButton
|
||||||
|
onClose={() => setIsClosing(true)}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<p>{i18n('CaptchaDialog__first-paragraph')}</p>
|
||||||
|
<p>{i18n('CaptchaDialog__second-paragraph')}</p>
|
||||||
|
</section>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onContinueClick}
|
||||||
|
ref={updateButtonRef}
|
||||||
|
variant={ButtonVariant.Primary}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Spinner size="22px" svgSize="small" direction="on-captcha" />
|
||||||
|
) : (
|
||||||
|
'Continue'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -4,9 +4,11 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { select } from '@storybook/addon-knobs';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
|
|
||||||
import { LeftPane, LeftPaneMode, PropsType } from './LeftPane';
|
import { LeftPane, LeftPaneMode, PropsType } from './LeftPane';
|
||||||
|
import { CaptchaDialog } from './CaptchaDialog';
|
||||||
import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
||||||
import { MessageSearchResult } from './conversationList/MessageSearchResult';
|
import { MessageSearchResult } from './conversationList/MessageSearchResult';
|
||||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
|
@ -106,6 +108,12 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
modeSpecificProps: defaultModeSpecificProps,
|
modeSpecificProps: defaultModeSpecificProps,
|
||||||
openConversationInternal: action('openConversationInternal'),
|
openConversationInternal: action('openConversationInternal'),
|
||||||
regionCode: 'US',
|
regionCode: 'US',
|
||||||
|
challengeStatus: select(
|
||||||
|
'challengeStatus',
|
||||||
|
['idle', 'required', 'pending'],
|
||||||
|
'idle'
|
||||||
|
),
|
||||||
|
setChallengeStatus: action('setChallengeStatus'),
|
||||||
renderExpiredBuildDialog: () => <div />,
|
renderExpiredBuildDialog: () => <div />,
|
||||||
renderMainHeader: () => <div />,
|
renderMainHeader: () => <div />,
|
||||||
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
||||||
|
@ -126,6 +134,14 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
renderNetworkStatus: () => <div />,
|
renderNetworkStatus: () => <div />,
|
||||||
renderRelinkDialog: () => <div />,
|
renderRelinkDialog: () => <div />,
|
||||||
renderUpdateDialog: () => <div />,
|
renderUpdateDialog: () => <div />,
|
||||||
|
renderCaptchaDialog: () => (
|
||||||
|
<CaptchaDialog
|
||||||
|
i18n={i18n}
|
||||||
|
isPending={overrideProps.challengeStatus === 'pending'}
|
||||||
|
onContinue={action('onCaptchaContinue')}
|
||||||
|
onSkip={action('onCaptchaSkip')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
selectedConversationId: undefined,
|
selectedConversationId: undefined,
|
||||||
selectedMessageId: undefined,
|
selectedMessageId: undefined,
|
||||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||||
|
@ -468,3 +484,33 @@ story.add('Compose: some contacts, some groups, with a search term', () => (
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Captcha flow
|
||||||
|
|
||||||
|
story.add('Captcha dialog: required', () => (
|
||||||
|
<LeftPane
|
||||||
|
{...createProps({
|
||||||
|
modeSpecificProps: {
|
||||||
|
mode: LeftPaneMode.Inbox,
|
||||||
|
pinnedConversations,
|
||||||
|
conversations: defaultConversations,
|
||||||
|
archivedConversations: [],
|
||||||
|
},
|
||||||
|
challengeStatus: 'required',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
story.add('Captcha dialog: pending', () => (
|
||||||
|
<LeftPane
|
||||||
|
{...createProps({
|
||||||
|
modeSpecificProps: {
|
||||||
|
mode: LeftPaneMode.Inbox,
|
||||||
|
pinnedConversations,
|
||||||
|
conversations: defaultConversations,
|
||||||
|
archivedConversations: [],
|
||||||
|
},
|
||||||
|
challengeStatus: 'pending',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
|
@ -79,6 +79,8 @@ export type PropsType = {
|
||||||
selectedConversationId: undefined | string;
|
selectedConversationId: undefined | string;
|
||||||
selectedMessageId: undefined | string;
|
selectedMessageId: undefined | string;
|
||||||
regionCode: string;
|
regionCode: string;
|
||||||
|
challengeStatus: 'idle' | 'required' | 'pending';
|
||||||
|
setChallengeStatus: (status: 'idle') => void;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
cantAddContactToGroup: (conversationId: string) => void;
|
cantAddContactToGroup: (conversationId: string) => void;
|
||||||
|
@ -110,6 +112,7 @@ export type PropsType = {
|
||||||
renderNetworkStatus: () => JSX.Element;
|
renderNetworkStatus: () => JSX.Element;
|
||||||
renderRelinkDialog: () => JSX.Element;
|
renderRelinkDialog: () => JSX.Element;
|
||||||
renderUpdateDialog: () => JSX.Element;
|
renderUpdateDialog: () => JSX.Element;
|
||||||
|
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeftPane: React.FC<PropsType> = ({
|
export const LeftPane: React.FC<PropsType> = ({
|
||||||
|
@ -121,6 +124,8 @@ export const LeftPane: React.FC<PropsType> = ({
|
||||||
createGroup,
|
createGroup,
|
||||||
i18n,
|
i18n,
|
||||||
modeSpecificProps,
|
modeSpecificProps,
|
||||||
|
challengeStatus,
|
||||||
|
setChallengeStatus,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
renderExpiredBuildDialog,
|
renderExpiredBuildDialog,
|
||||||
renderMainHeader,
|
renderMainHeader,
|
||||||
|
@ -128,6 +133,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
||||||
renderNetworkStatus,
|
renderNetworkStatus,
|
||||||
renderRelinkDialog,
|
renderRelinkDialog,
|
||||||
renderUpdateDialog,
|
renderUpdateDialog,
|
||||||
|
renderCaptchaDialog,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
setComposeSearchTerm,
|
setComposeSearchTerm,
|
||||||
|
@ -464,6 +470,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
||||||
{footerContents && (
|
{footerContents && (
|
||||||
<div className="module-left-pane__footer">{footerContents}</div>
|
<div className="module-left-pane__footer">{footerContents}</div>
|
||||||
)}
|
)}
|
||||||
|
{challengeStatus !== 'idle' &&
|
||||||
|
renderCaptchaDialog({
|
||||||
|
onSkip() {
|
||||||
|
setChallengeStatus('idle');
|
||||||
|
},
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,6 +32,7 @@ export type PropsType = {
|
||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
color?: ColorType;
|
color?: ColorType;
|
||||||
|
disabled?: boolean;
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -339,6 +340,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
const {
|
const {
|
||||||
avatarPath,
|
avatarPath,
|
||||||
color,
|
color,
|
||||||
|
disabled,
|
||||||
i18n,
|
i18n,
|
||||||
name,
|
name,
|
||||||
startComposing,
|
startComposing,
|
||||||
|
@ -437,6 +439,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
ref={this.inputRef}
|
ref={this.inputRef}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
|
@ -48,12 +48,22 @@ export function Modal({
|
||||||
aria-label={i18n('close')}
|
aria-label={i18n('close')}
|
||||||
type="button"
|
type="button"
|
||||||
className="module-Modal__close-button"
|
className="module-Modal__close-button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{title && <h1 className="module-Modal__title">{title}</h1>}
|
{title && (
|
||||||
|
<h1
|
||||||
|
className={classNames(
|
||||||
|
'module-Modal__title',
|
||||||
|
hasXButton ? 'module-Modal__title--with-x-button' : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -19,6 +19,7 @@ const defaultProps = {
|
||||||
socketStatus: 0,
|
socketStatus: 0,
|
||||||
manualReconnect: action('manual-reconnect'),
|
manualReconnect: action('manual-reconnect'),
|
||||||
withinConnectingGracePeriod: false,
|
withinConnectingGracePeriod: false,
|
||||||
|
challengeStatus: 'idle' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const permutations = [
|
const permutations = [
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const SpinnerDirections = [
|
||||||
'outgoing',
|
'outgoing',
|
||||||
'incoming',
|
'incoming',
|
||||||
'on-background',
|
'on-background',
|
||||||
|
'on-captcha',
|
||||||
'on-progress-dialog',
|
'on-progress-dialog',
|
||||||
'on-avatar',
|
'on-avatar',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
@ -489,6 +489,15 @@ story.add('Error', () => {
|
||||||
return renderBothDirections(props);
|
return renderBothDirections(props);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
story.add('Paused', () => {
|
||||||
|
const props = createProps({
|
||||||
|
status: 'paused',
|
||||||
|
text: 'I am up to a challenge',
|
||||||
|
});
|
||||||
|
|
||||||
|
return renderBothDirections(props);
|
||||||
|
});
|
||||||
|
|
||||||
story.add('Partial Send', () => {
|
story.add('Partial Send', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
status: 'partial-sent',
|
status: 'partial-sent',
|
||||||
|
|
|
@ -67,6 +67,7 @@ const THREE_HOURS = 3 * 60 * 60 * 1000;
|
||||||
export const MessageStatuses = [
|
export const MessageStatuses = [
|
||||||
'delivered',
|
'delivered',
|
||||||
'error',
|
'error',
|
||||||
|
'paused',
|
||||||
'partial-sent',
|
'partial-sent',
|
||||||
'read',
|
'read',
|
||||||
'sending',
|
'sending',
|
||||||
|
@ -522,8 +523,31 @@ export class Message extends React.Component<Props, State> {
|
||||||
const isError = status === 'error' && direction === 'outgoing';
|
const isError = status === 'error' && direction === 'outgoing';
|
||||||
const isPartiallySent =
|
const isPartiallySent =
|
||||||
status === 'partial-sent' && direction === 'outgoing';
|
status === 'partial-sent' && direction === 'outgoing';
|
||||||
|
const isPaused = status === 'paused';
|
||||||
|
|
||||||
|
if (isError || isPartiallySent || isPaused) {
|
||||||
|
let statusInfo: React.ReactChild;
|
||||||
|
if (isError) {
|
||||||
|
statusInfo = i18n('sendFailed');
|
||||||
|
} else if (isPaused) {
|
||||||
|
statusInfo = i18n('sendPaused');
|
||||||
|
} else {
|
||||||
|
statusInfo = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="module-message__metadata__tapable"
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
showMessageDetail(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('partiallySent')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isError || isPartiallySent) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={classNames({
|
className={classNames({
|
||||||
|
@ -533,22 +557,7 @@ export class Message extends React.Component<Props, State> {
|
||||||
'module-message__metadata__date--with-image-no-caption': withImageNoCaption,
|
'module-message__metadata__date--with-image-no-caption': withImageNoCaption,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isError ? (
|
{statusInfo}
|
||||||
i18n('sendFailed')
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="module-message__metadata__tapable"
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
showMessageDetail(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('partiallySent')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1232,7 +1241,15 @@ export class Message extends React.Component<Props, State> {
|
||||||
public renderError(isCorrectSide: boolean): JSX.Element | null {
|
public renderError(isCorrectSide: boolean): JSX.Element | null {
|
||||||
const { status, direction } = this.props;
|
const { status, direction } = this.props;
|
||||||
|
|
||||||
if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) {
|
if (!isCorrectSide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
status !== 'paused' &&
|
||||||
|
status !== 'error' &&
|
||||||
|
status !== 'partial-sent'
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1241,7 +1258,8 @@ export class Message extends React.Component<Props, State> {
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-message__error',
|
'module-message__error',
|
||||||
`module-message__error--${direction}`
|
`module-message__error--${direction}`,
|
||||||
|
`module-message__error--${status}`
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1446,7 +1464,9 @@ export class Message extends React.Component<Props, State> {
|
||||||
const { canDeleteForEveryone } = this.state;
|
const { canDeleteForEveryone } = this.state;
|
||||||
|
|
||||||
const showRetry =
|
const showRetry =
|
||||||
(status === 'error' || status === 'partial-sent') &&
|
(status === 'paused' ||
|
||||||
|
status === 'error' ||
|
||||||
|
status === 'partial-sent') &&
|
||||||
direction === 'outgoing';
|
direction === 'outgoing';
|
||||||
const multipleAttachments = attachments && attachments.length > 1;
|
const multipleAttachments = attachments && attachments.length > 1;
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ export const MessageStatuses = [
|
||||||
'sent',
|
'sent',
|
||||||
'delivered',
|
'delivered',
|
||||||
'read',
|
'read',
|
||||||
|
'paused',
|
||||||
'error',
|
'error',
|
||||||
'partial-sent',
|
'partial-sent',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
50
ts/main/challengeMain.ts
Normal file
50
ts/main/challengeMain.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-restricted-syntax, no-console */
|
||||||
|
|
||||||
|
import { ipcMain as ipc, IpcMainEvent } from 'electron';
|
||||||
|
|
||||||
|
import { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge';
|
||||||
|
|
||||||
|
export class ChallengeMainHandler {
|
||||||
|
private handlers: Array<(response: ChallengeResponse) => void> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCaptcha(captcha: string): void {
|
||||||
|
const response: ChallengeResponse = { captcha };
|
||||||
|
|
||||||
|
const { handlers } = this;
|
||||||
|
this.handlers = [];
|
||||||
|
for (const resolve of handlers) {
|
||||||
|
resolve(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onRequest(
|
||||||
|
event: IpcMainEvent,
|
||||||
|
request: IPCRequest
|
||||||
|
): Promise<void> {
|
||||||
|
console.log('Received challenge request, waiting for response');
|
||||||
|
|
||||||
|
const data = await new Promise<ChallengeResponse>(resolve => {
|
||||||
|
this.handlers.push(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending challenge response', data);
|
||||||
|
|
||||||
|
const ipcResponse: IPCResponse = {
|
||||||
|
seq: request.seq,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
event.sender.send('challenge:response', ipcResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize(): void {
|
||||||
|
ipc.on('challenge:request', (event, request) => {
|
||||||
|
this.onRequest(event, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
ts/model-types.d.ts
vendored
16
ts/model-types.d.ts
vendored
|
@ -13,6 +13,7 @@ import {
|
||||||
LastMessageStatus,
|
LastMessageStatus,
|
||||||
} from './state/ducks/conversations';
|
} from './state/ducks/conversations';
|
||||||
import { SendOptionsType } from './textsecure/SendMessage';
|
import { SendOptionsType } from './textsecure/SendMessage';
|
||||||
|
import { SendMessageChallengeData } from './textsecure/Errors';
|
||||||
import {
|
import {
|
||||||
AccessRequiredEnum,
|
AccessRequiredEnum,
|
||||||
MemberRoleEnum,
|
MemberRoleEnum,
|
||||||
|
@ -42,6 +43,8 @@ type TaskResultType = any;
|
||||||
export type CustomError = Error & {
|
export type CustomError = Error & {
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
number?: string;
|
number?: string;
|
||||||
|
data?: object;
|
||||||
|
retryAfter?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupMigrationType = {
|
export type GroupMigrationType = {
|
||||||
|
@ -62,6 +65,13 @@ export type QuotedMessageType = {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RetryOptions = Readonly<{
|
||||||
|
type: 'session-reset';
|
||||||
|
uuid: string;
|
||||||
|
e164: string;
|
||||||
|
now: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type MessageAttributesType = {
|
export type MessageAttributesType = {
|
||||||
bodyPending: boolean;
|
bodyPending: boolean;
|
||||||
bodyRanges: BodyRangesType;
|
bodyRanges: BodyRangesType;
|
||||||
|
@ -113,6 +123,7 @@ export type MessageAttributesType = {
|
||||||
}>;
|
}>;
|
||||||
read_by: Array<string | null>;
|
read_by: Array<string | null>;
|
||||||
requiredProtocolVersion: number;
|
requiredProtocolVersion: number;
|
||||||
|
retryOptions?: RetryOptions;
|
||||||
sent: boolean;
|
sent: boolean;
|
||||||
sourceDevice: string | number;
|
sourceDevice: string | number;
|
||||||
snippet: unknown;
|
snippet: unknown;
|
||||||
|
@ -325,6 +336,11 @@ export type VerificationOptions = {
|
||||||
viaSyncMessage?: boolean;
|
viaSyncMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShallowChallengeError = CustomError & {
|
||||||
|
readonly retryAfter: number;
|
||||||
|
readonly data: SendMessageChallengeData;
|
||||||
|
};
|
||||||
|
|
||||||
export declare class ConversationModelCollectionType extends Backbone.Collection<ConversationModel> {
|
export declare class ConversationModelCollectionType extends Backbone.Collection<ConversationModel> {
|
||||||
resetLookups(): void;
|
resetLookups(): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3028,8 +3028,6 @@ export class ConversationModel extends window.Backbone
|
||||||
fromId: window.ConversationController.getOurConversationId(),
|
fromId: window.ConversationController.getOurConversationId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.Whisper.Deletes.onDelete(deleteModel);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const destination = this.getSendTarget()!;
|
const destination = this.getSendTarget()!;
|
||||||
const recipients = this.getRecipients();
|
const recipients = this.getRecipients();
|
||||||
|
@ -3108,7 +3106,16 @@ export class ConversationModel extends window.Backbone
|
||||||
// anything to the database.
|
// anything to the database.
|
||||||
message.doNotSave = true;
|
message.doNotSave = true;
|
||||||
|
|
||||||
return message.send(this.wrapSend(promise));
|
const result = await message.send(this.wrapSend(promise));
|
||||||
|
|
||||||
|
if (!message.hasSuccessfulDelivery()) {
|
||||||
|
// This is handled by `conversation_view` which displays a toast on
|
||||||
|
// send error.
|
||||||
|
throw new Error('No successful delivery for delete for everyone');
|
||||||
|
}
|
||||||
|
window.Whisper.Deletes.onDelete(deleteModel);
|
||||||
|
|
||||||
|
return result;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'Error sending deleteForEveryone',
|
'Error sending deleteForEveryone',
|
||||||
|
@ -3138,7 +3145,6 @@ export class ConversationModel extends window.Backbone
|
||||||
timestamp,
|
timestamp,
|
||||||
fromSync: true,
|
fromSync: true,
|
||||||
});
|
});
|
||||||
window.Whisper.Reactions.onReaction(reactionModel);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const destination = this.getSendTarget()!;
|
const destination = this.getSendTarget()!;
|
||||||
|
@ -3239,15 +3245,17 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return message.send(this.wrapSend(promise));
|
const result = await message.send(this.wrapSend(promise));
|
||||||
}).catch(error => {
|
|
||||||
window.log.error('Error sending reaction', reaction, target, error);
|
|
||||||
|
|
||||||
const reverseReaction = reactionModel.clone();
|
if (!message.hasSuccessfulDelivery()) {
|
||||||
reverseReaction.set('remove', !reverseReaction.get('remove'));
|
// This is handled by `conversation_view` which displays a toast on
|
||||||
window.Whisper.Reactions.onReaction(reverseReaction);
|
// send error.
|
||||||
|
throw new Error('No successful delivery for reaction');
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
window.Whisper.Reactions.onReaction(reactionModel);
|
||||||
|
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4167,25 +4175,17 @@ export class ConversationModel extends window.Backbone
|
||||||
const message = window.MessageController.register(model.id, model);
|
const message = window.MessageController.register(model.id, model);
|
||||||
this.addSingleMessage(message);
|
this.addSingleMessage(message);
|
||||||
|
|
||||||
const options = await this.getSendOptions();
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
message.send(
|
const uuid = this.get('uuid')!;
|
||||||
this.wrapSend(
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
// TODO: DESKTOP-724
|
const e164 = this.get('e164')!;
|
||||||
// resetSession returns `Array<void>` which is incompatible with the
|
|
||||||
// expected promise return values. `[]` is truthy and wrapSend assumes
|
message.sendUtilityMessageWithRetry({
|
||||||
// it's a valid callback result type
|
type: 'session-reset',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
uuid,
|
||||||
// @ts-ignore
|
e164,
|
||||||
window.textsecure.messaging.resetSession(
|
now,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
});
|
||||||
this.get('uuid')!,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
this.get('e164')!,
|
|
||||||
now,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import {
|
import {
|
||||||
CustomError,
|
CustomError,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
RetryOptions,
|
||||||
|
ShallowChallengeError,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
WhatIsThis,
|
WhatIsThis,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
|
@ -1041,6 +1043,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
const sentTo = this.get('sent_to') || [];
|
const sentTo = this.get('sent_to') || [];
|
||||||
|
|
||||||
if (this.hasErrors()) {
|
if (this.hasErrors()) {
|
||||||
|
if (this.getLastChallengeError()) {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
if (sent || sentTo.length > 0) {
|
if (sent || sentTo.length > 0) {
|
||||||
return 'partial-sent';
|
return 'partial-sent';
|
||||||
}
|
}
|
||||||
|
@ -2000,6 +2005,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
'code',
|
'code',
|
||||||
'number',
|
'number',
|
||||||
'identifier',
|
'identifier',
|
||||||
|
'retryAfter',
|
||||||
|
'data',
|
||||||
'reason'
|
'reason'
|
||||||
) as Required<Error>;
|
) as Required<Error>;
|
||||||
}
|
}
|
||||||
|
@ -2009,6 +2016,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
this.set({ errors });
|
this.set({ errors });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.doNotSave &&
|
||||||
|
errors.some(error => error.name === 'SendMessageChallengeError')
|
||||||
|
) {
|
||||||
|
await window.Signal.challengeHandler.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
if (!skipSave && !this.doNotSave) {
|
if (!skipSave && !this.doNotSave) {
|
||||||
await window.Signal.Data.saveMessage(this.attributes, {
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
Message: window.Whisper.Message,
|
Message: window.Whisper.Message,
|
||||||
|
@ -2109,7 +2123,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set({ errors: undefined });
|
const retryOptions = this.get('retryOptions');
|
||||||
|
|
||||||
|
this.set({ errors: undefined, retryOptions: undefined });
|
||||||
|
|
||||||
|
if (retryOptions) {
|
||||||
|
return this.sendUtilityMessageWithRetry(retryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const conversation = this.getConversation()!;
|
const conversation = this.getConversation()!;
|
||||||
|
@ -2252,11 +2272,35 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
e.name === 'MessageError' ||
|
e.name === 'MessageError' ||
|
||||||
e.name === 'OutgoingMessageError' ||
|
e.name === 'OutgoingMessageError' ||
|
||||||
e.name === 'SendMessageNetworkError' ||
|
e.name === 'SendMessageNetworkError' ||
|
||||||
|
e.name === 'SendMessageChallengeError' ||
|
||||||
e.name === 'SignedPreKeyRotationError' ||
|
e.name === 'SignedPreKeyRotationError' ||
|
||||||
e.name === 'OutgoingIdentityKeyError'
|
e.name === 'OutgoingIdentityKeyError'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLastChallengeError(): ShallowChallengeError | undefined {
|
||||||
|
const errors: ReadonlyArray<CustomError> | undefined = this.get('errors');
|
||||||
|
if (!errors) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeErrors = errors
|
||||||
|
.filter((error): error is ShallowChallengeError => {
|
||||||
|
return (
|
||||||
|
error.name === 'SendMessageChallengeError' &&
|
||||||
|
_.isNumber(error.retryAfter) &&
|
||||||
|
_.isObject(error.data)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.retryAfter - b.retryAfter);
|
||||||
|
|
||||||
|
return challengeErrors.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasSuccessfulDelivery(): boolean {
|
||||||
|
return (this.get('sent_to') || []).length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
canDeleteForEveryone(): boolean {
|
canDeleteForEveryone(): boolean {
|
||||||
// is someone else's message
|
// is someone else's message
|
||||||
if (this.isIncoming()) {
|
if (this.isIncoming()) {
|
||||||
|
@ -2423,6 +2467,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
(e.name === 'MessageError' ||
|
(e.name === 'MessageError' ||
|
||||||
e.name === 'OutgoingMessageError' ||
|
e.name === 'OutgoingMessageError' ||
|
||||||
e.name === 'SendMessageNetworkError' ||
|
e.name === 'SendMessageNetworkError' ||
|
||||||
|
e.name === 'SendMessageChallengeError' ||
|
||||||
e.name === 'SignedPreKeyRotationError' ||
|
e.name === 'SignedPreKeyRotationError' ||
|
||||||
e.name === 'OutgoingIdentityKeyError')
|
e.name === 'OutgoingIdentityKeyError')
|
||||||
);
|
);
|
||||||
|
@ -2564,6 +2609,59 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Currently used only for messages that have to be retried when the server
|
||||||
|
// responds with 428 and we have to retry sending the message on challenge
|
||||||
|
// solution.
|
||||||
|
//
|
||||||
|
// Supported types of messages:
|
||||||
|
// * `session-reset` see `endSession` in `ts/models/conversations.ts`
|
||||||
|
async sendUtilityMessageWithRetry(options: RetryOptions): Promise<void> {
|
||||||
|
if (options.type === 'session-reset') {
|
||||||
|
const conv = this.getConversation();
|
||||||
|
if (!conv) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to find conversation for message: ${this.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!window.textsecure.messaging) {
|
||||||
|
throw new Error('Offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set({
|
||||||
|
retryOptions: options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendOptions = await conv.getSendOptions();
|
||||||
|
|
||||||
|
// We don't have to check `sent_to` here, because:
|
||||||
|
//
|
||||||
|
// 1. This happens only in private conversations
|
||||||
|
// 2. Messages to different device ids for the same identifier are sent
|
||||||
|
// in a single request to the server. So partial success is not
|
||||||
|
// possible.
|
||||||
|
await this.send(
|
||||||
|
conv.wrapSend(
|
||||||
|
// TODO: DESKTOP-724
|
||||||
|
// resetSession returns `Array<void>` which is incompatible with the
|
||||||
|
// expected promise return values. `[]` is truthy and wrapSend assumes
|
||||||
|
// it's a valid callback result type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
window.textsecure.messaging.resetSession(
|
||||||
|
options.uuid,
|
||||||
|
options.e164,
|
||||||
|
options.now,
|
||||||
|
sendOptions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported retriable type: ${options.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
|
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const conv = this.getConversation()!;
|
const conv = this.getConversation()!;
|
||||||
|
@ -2590,7 +2688,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
});
|
});
|
||||||
} catch (result) {
|
} catch (result) {
|
||||||
const errors = (result && result.errors) || [new Error('Unknown error')];
|
const errors = (result && result.errors) || [new Error('Unknown error')];
|
||||||
this.set({ errors });
|
this.saveErrors(errors);
|
||||||
} finally {
|
} finally {
|
||||||
await window.Signal.Data.saveMessage(this.attributes, {
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
Message: window.Whisper.Message,
|
Message: window.Whisper.Message,
|
||||||
|
@ -4094,6 +4192,33 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMessageById(
|
||||||
|
messageId: string
|
||||||
|
): Promise<MessageModel | undefined> {
|
||||||
|
let message = window.MessageController.getById(messageId);
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = await window.Signal.Data.getMessageById(messageId, {
|
||||||
|
Message: window.Whisper.Message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
`failed to load message with id ${messageId} ` +
|
||||||
|
`due to error ${error && error.stack}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = window.MessageController.register(message.id, message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
window.Whisper.Message = MessageModel;
|
window.Whisper.Message = MessageModel;
|
||||||
|
|
||||||
window.Whisper.Message.getLongMessageAttachment = ({
|
window.Whisper.Message.getLongMessageAttachment = ({
|
||||||
|
|
|
@ -42,6 +42,7 @@ export type DBConversationType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LastMessageStatus =
|
export type LastMessageStatus =
|
||||||
|
| 'paused'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'partial-sent'
|
| 'partial-sent'
|
||||||
| 'sending'
|
| 'sending'
|
||||||
|
|
|
@ -11,6 +11,7 @@ export type NetworkStateType = {
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
socketStatus: SocketStatus;
|
socketStatus: SocketStatus;
|
||||||
withinConnectingGracePeriod: boolean;
|
withinConnectingGracePeriod: boolean;
|
||||||
|
challengeStatus: 'required' | 'pending' | 'idle';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
@ -18,6 +19,7 @@ export type NetworkStateType = {
|
||||||
const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS';
|
const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS';
|
||||||
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
|
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
|
||||||
const RELINK_DEVICE = 'network/RELINK_DEVICE';
|
const RELINK_DEVICE = 'network/RELINK_DEVICE';
|
||||||
|
const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS';
|
||||||
|
|
||||||
export type CheckNetworkStatusPayloadType = {
|
export type CheckNetworkStatusPayloadType = {
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
|
@ -37,10 +39,18 @@ type RelinkDeviceActionType = {
|
||||||
type: 'network/RELINK_DEVICE';
|
type: 'network/RELINK_DEVICE';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SetChallengeStatusActionType = {
|
||||||
|
type: 'network/SET_CHALLENGE_STATUS';
|
||||||
|
payload: {
|
||||||
|
challengeStatus: NetworkStateType['challengeStatus'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type NetworkActionType =
|
export type NetworkActionType =
|
||||||
| CheckNetworkStatusAction
|
| CheckNetworkStatusAction
|
||||||
| CloseConnectingGracePeriodActionType
|
| CloseConnectingGracePeriodActionType
|
||||||
| RelinkDeviceActionType;
|
| RelinkDeviceActionType
|
||||||
|
| SetChallengeStatusActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
|
@ -67,19 +77,30 @@ function relinkDevice(): RelinkDeviceActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setChallengeStatus(
|
||||||
|
challengeStatus: NetworkStateType['challengeStatus']
|
||||||
|
): SetChallengeStatusActionType {
|
||||||
|
return {
|
||||||
|
type: SET_CHALLENGE_STATUS,
|
||||||
|
payload: { challengeStatus },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
checkNetworkStatus,
|
checkNetworkStatus,
|
||||||
closeConnectingGracePeriod,
|
closeConnectingGracePeriod,
|
||||||
relinkDevice,
|
relinkDevice,
|
||||||
|
setChallengeStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reducer
|
// Reducer
|
||||||
|
|
||||||
function getEmptyState(): NetworkStateType {
|
export function getEmptyState(): NetworkStateType {
|
||||||
return {
|
return {
|
||||||
isOnline: navigator.onLine,
|
isOnline: navigator.onLine,
|
||||||
socketStatus: WebSocket.OPEN,
|
socketStatus: WebSocket.OPEN,
|
||||||
withinConnectingGracePeriod: true,
|
withinConnectingGracePeriod: true,
|
||||||
|
challengeStatus: 'idle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,5 +126,12 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === SET_CHALLENGE_STATUS) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
challengeStatus: action.payload.challengeStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,3 +22,8 @@ export const hasNetworkDialog = createSelector(
|
||||||
socketStatus === WebSocket.CLOSED ||
|
socketStatus === WebSocket.CLOSED ||
|
||||||
socketStatus === WebSocket.CLOSING)
|
socketStatus === WebSocket.CLOSING)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const isChallengePending = createSelector(
|
||||||
|
getNetwork,
|
||||||
|
({ challengeStatus }) => challengeStatus === 'pending'
|
||||||
|
);
|
||||||
|
|
26
ts/state/smart/CaptchaDialog.tsx
Normal file
26
ts/state/smart/CaptchaDialog.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
import { CaptchaDialog } from '../../components/CaptchaDialog';
|
||||||
|
import { StateType } from '../reducer';
|
||||||
|
import { getIntl } from '../selectors/user';
|
||||||
|
import { isChallengePending } from '../selectors/network';
|
||||||
|
import { getChallengeURL } from '../../challenge';
|
||||||
|
|
||||||
|
const mapStateToProps = (state: StateType) => {
|
||||||
|
return {
|
||||||
|
...state.updates,
|
||||||
|
isPending: isChallengePending(state),
|
||||||
|
i18n: getIntl(state),
|
||||||
|
|
||||||
|
onContinue() {
|
||||||
|
document.location.href = getChallengeURL();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
export const SmartCaptchaDialog = smart(CaptchaDialog);
|
|
@ -41,6 +41,7 @@ import { SmartMessageSearchResult } from './MessageSearchResult';
|
||||||
import { SmartNetworkStatus } from './NetworkStatus';
|
import { SmartNetworkStatus } from './NetworkStatus';
|
||||||
import { SmartRelinkDialog } from './RelinkDialog';
|
import { SmartRelinkDialog } from './RelinkDialog';
|
||||||
import { SmartUpdateDialog } from './UpdateDialog';
|
import { SmartUpdateDialog } from './UpdateDialog';
|
||||||
|
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||||
|
|
||||||
// Workaround: A react component's required properties are filtering up through connect()
|
// Workaround: A react component's required properties are filtering up through connect()
|
||||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||||
|
@ -69,6 +70,9 @@ function renderRelinkDialog(): JSX.Element {
|
||||||
function renderUpdateDialog(): JSX.Element {
|
function renderUpdateDialog(): JSX.Element {
|
||||||
return <SmartUpdateDialog />;
|
return <SmartUpdateDialog />;
|
||||||
}
|
}
|
||||||
|
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
|
||||||
|
return <SmartCaptchaDialog onSkip={onSkip} />;
|
||||||
|
}
|
||||||
|
|
||||||
const getModeSpecificProps = (
|
const getModeSpecificProps = (
|
||||||
state: StateType
|
state: StateType
|
||||||
|
@ -136,12 +140,14 @@ const mapStateToProps = (state: StateType) => {
|
||||||
showArchived: getShowArchived(state),
|
showArchived: getShowArchived(state),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
regionCode: getRegionCode(state),
|
regionCode: getRegionCode(state),
|
||||||
|
challengeStatus: state.network.challengeStatus,
|
||||||
renderExpiredBuildDialog,
|
renderExpiredBuildDialog,
|
||||||
renderMainHeader,
|
renderMainHeader,
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
renderNetworkStatus,
|
renderNetworkStatus,
|
||||||
renderRelinkDialog,
|
renderRelinkDialog,
|
||||||
renderUpdateDialog,
|
renderUpdateDialog,
|
||||||
|
renderCaptchaDialog,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { getMe, getSelectedConversation } from '../selectors/conversations';
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
return {
|
return {
|
||||||
|
disabled: state.network.challengeStatus !== 'idle',
|
||||||
searchTerm: getQuery(state),
|
searchTerm: getQuery(state),
|
||||||
searchConversationId: getSearchConversationId(state),
|
searchConversationId: getSearchConversationId(state),
|
||||||
searchConversationName: getSearchConversationName(state),
|
searchConversationName: getSearchConversationName(state),
|
||||||
|
|
382
ts/test-both/challenge_test.ts
Normal file
382
ts/test-both/challenge_test.ts
Normal file
|
@ -0,0 +1,382 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
|
||||||
|
import { sleep } from '../util/sleep';
|
||||||
|
import { ChallengeHandler, MinimalMessage } from '../challenge';
|
||||||
|
|
||||||
|
type CreateMessageOptions = {
|
||||||
|
readonly sentAt?: number;
|
||||||
|
readonly retryAfter?: number;
|
||||||
|
readonly isNormalBubble?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateHandlerOptions = {
|
||||||
|
readonly challenge?: boolean;
|
||||||
|
readonly challengeError?: Error;
|
||||||
|
readonly expireAfter?: number;
|
||||||
|
readonly onChallengeSolved?: () => void;
|
||||||
|
readonly onChallengeFailed?: (retryAfter?: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ChallengeHandler', () => {
|
||||||
|
const storage = new Map<string, any>();
|
||||||
|
const messageStorage = new Map<string, MinimalMessage>();
|
||||||
|
let challengeStatus = 'idle';
|
||||||
|
let sent: Array<string> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
messageStorage.clear();
|
||||||
|
challengeStatus = 'idle';
|
||||||
|
sent = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMessage = (
|
||||||
|
id: string,
|
||||||
|
options: CreateMessageOptions = {}
|
||||||
|
): MinimalMessage => {
|
||||||
|
const {
|
||||||
|
sentAt = 0,
|
||||||
|
isNormalBubble = true,
|
||||||
|
retryAfter = Date.now() + 25,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const testLocalSent = sent;
|
||||||
|
|
||||||
|
const events = new Map<string, () => void>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
idForLogging: () => id,
|
||||||
|
isNormalBubble() {
|
||||||
|
return isNormalBubble;
|
||||||
|
},
|
||||||
|
getLastChallengeError() {
|
||||||
|
return {
|
||||||
|
name: 'Ignored',
|
||||||
|
message: 'Ignored',
|
||||||
|
retryAfter,
|
||||||
|
data: { token: 'token', options: ['recaptcha'] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
get(name) {
|
||||||
|
assert.equal(name, 'sent_at');
|
||||||
|
return sentAt;
|
||||||
|
},
|
||||||
|
on(name, handler) {
|
||||||
|
if (events.get(name)) {
|
||||||
|
throw new Error('Duplicate event');
|
||||||
|
}
|
||||||
|
events.set(name, handler);
|
||||||
|
},
|
||||||
|
off(name, handler) {
|
||||||
|
assert.equal(events.get(name), handler);
|
||||||
|
events.delete(name);
|
||||||
|
},
|
||||||
|
async retrySend() {
|
||||||
|
await sleep(5);
|
||||||
|
const handler = events.get('sent');
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error('Expected handler');
|
||||||
|
}
|
||||||
|
handler();
|
||||||
|
testLocalSent.push(this.id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createHandler = async ({
|
||||||
|
challenge = false,
|
||||||
|
challengeError,
|
||||||
|
expireAfter,
|
||||||
|
onChallengeSolved = noop,
|
||||||
|
onChallengeFailed = noop,
|
||||||
|
}: CreateHandlerOptions = {}): Promise<ChallengeHandler> => {
|
||||||
|
const handler = new ChallengeHandler({
|
||||||
|
expireAfter,
|
||||||
|
|
||||||
|
storage: {
|
||||||
|
get(key) {
|
||||||
|
return storage.get(key);
|
||||||
|
},
|
||||||
|
async put(key, value) {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onChallengeSolved,
|
||||||
|
onChallengeFailed,
|
||||||
|
|
||||||
|
requestChallenge(request) {
|
||||||
|
if (!challenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
handler.onResponse({
|
||||||
|
seq: request.seq,
|
||||||
|
data: { captcha: 'captcha' },
|
||||||
|
});
|
||||||
|
}, 5);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessageById(messageId) {
|
||||||
|
return messageStorage.get(messageId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendChallengeResponse() {
|
||||||
|
if (challengeError) {
|
||||||
|
throw challengeError;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setChallengeStatus(status) {
|
||||||
|
challengeStatus = status;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await handler.load();
|
||||||
|
await handler.onOnline();
|
||||||
|
return handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInStorage = (messageId: string) => {
|
||||||
|
return (storage.get('challenge:retry-message-ids') || []).some(
|
||||||
|
({ messageId: storageId }: { messageId: string }) => {
|
||||||
|
return storageId === messageId;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should automatically retry after timeout', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const one = createMessage('1');
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
|
||||||
|
await handler.register(one);
|
||||||
|
assert.isTrue(isInStorage(one.id));
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['1']);
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
assert.isFalse(isInStorage(one.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send challenge response', async () => {
|
||||||
|
const handler = await createHandler({ challenge: true });
|
||||||
|
|
||||||
|
const one = createMessage('1', { retryAfter: Date.now() + 100000 });
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
|
||||||
|
await handler.register(one);
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['1']);
|
||||||
|
assert.isFalse(isInStorage(one.id));
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send old messages', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const retryAfter = Date.now() + 50;
|
||||||
|
|
||||||
|
// Put messages in reverse order to validate that the send order is correct
|
||||||
|
const messages = [
|
||||||
|
createMessage('3', { sentAt: 3, retryAfter }),
|
||||||
|
createMessage('2', { sentAt: 2, retryAfter }),
|
||||||
|
createMessage('1', { sentAt: 1, retryAfter }),
|
||||||
|
];
|
||||||
|
for (const message of messages) {
|
||||||
|
messageStorage.set(message.id, message);
|
||||||
|
await handler.register(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
for (const message of messages) {
|
||||||
|
assert.isTrue(
|
||||||
|
isInStorage(message.id),
|
||||||
|
`${message.id} should be in storage`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler.onOffline();
|
||||||
|
|
||||||
|
// Wait for messages to mature
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
// Create new handler to load old messages from storage
|
||||||
|
await createHandler();
|
||||||
|
for (const message of messages) {
|
||||||
|
await handler.unregister(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
assert.isFalse(
|
||||||
|
isInStorage(message.id),
|
||||||
|
`${message.id} should not be in storage`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order has to be correct
|
||||||
|
assert.deepEqual(sent, ['1', '2', '3']);
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send message immediately if it is ready', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const one = createMessage('1', { retryAfter: Date.now() - 100 });
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
assert.deepEqual(sent, ['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change challenge status on non-bubble messages', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const one = createMessage('1', { isNormalBubble: false });
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
|
||||||
|
await sleep(50);
|
||||||
|
assert.deepEqual(sent, ['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not retry expired messages', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const bubble = createMessage('1');
|
||||||
|
messageStorage.set('1', bubble);
|
||||||
|
await handler.register(bubble);
|
||||||
|
assert.isTrue(isInStorage(bubble.id));
|
||||||
|
|
||||||
|
const newHandler = await createHandler({
|
||||||
|
challenge: true,
|
||||||
|
expireAfter: -1,
|
||||||
|
});
|
||||||
|
await handler.unregister(bubble);
|
||||||
|
|
||||||
|
challengeStatus = 'idle';
|
||||||
|
await newHandler.load();
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
|
||||||
|
await sleep(25);
|
||||||
|
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.isFalse(isInStorage(bubble.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send messages that matured while we were offline', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const one = createMessage('1');
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
assert.isTrue(isInStorage(one.id));
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
await handler.onOffline();
|
||||||
|
|
||||||
|
// Let messages mature
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
assert.isTrue(isInStorage(one.id));
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
// Go back online
|
||||||
|
await handler.onOnline();
|
||||||
|
|
||||||
|
assert.isFalse(isInStorage(one.id));
|
||||||
|
assert.deepEqual(sent, [one.id]);
|
||||||
|
assert.equal(challengeStatus, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not retry more than 5 times', async () => {
|
||||||
|
const handler = await createHandler();
|
||||||
|
|
||||||
|
const one = createMessage('1', {
|
||||||
|
retryAfter: Date.now() + 50,
|
||||||
|
});
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
const retrySend = sinon.stub(one, 'retrySend');
|
||||||
|
|
||||||
|
assert.isTrue(isInStorage(one.id));
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
// Let it spam the server
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
assert.isTrue(isInStorage(one.id));
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(challengeStatus, 'required');
|
||||||
|
|
||||||
|
sinon.assert.callCount(retrySend, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onChallengeSolved', async () => {
|
||||||
|
const onChallengeSolved = sinon.stub();
|
||||||
|
|
||||||
|
const handler = await createHandler({
|
||||||
|
challenge: true,
|
||||||
|
onChallengeSolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
const one = createMessage('1', {
|
||||||
|
retryAfter: Date.now() + 1,
|
||||||
|
});
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
// Let the challenge go through
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
sinon.assert.calledOnce(onChallengeSolved);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onChallengeFailed', async () => {
|
||||||
|
const onChallengeFailed = sinon.stub();
|
||||||
|
|
||||||
|
const handler = await createHandler({
|
||||||
|
challenge: true,
|
||||||
|
challengeError: new Error('custom failure'),
|
||||||
|
onChallengeFailed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const one = createMessage('1', {
|
||||||
|
retryAfter: Date.now() + 1,
|
||||||
|
});
|
||||||
|
messageStorage.set('1', one);
|
||||||
|
await handler.register(one);
|
||||||
|
|
||||||
|
// Let the challenge go through
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
sinon.assert.calledOnce(onChallengeFailed);
|
||||||
|
});
|
||||||
|
});
|
26
ts/test-both/state/ducks/network_test.ts
Normal file
26
ts/test-both/state/ducks/network_test.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { actions, getEmptyState, reducer } from '../../../state/ducks/network';
|
||||||
|
|
||||||
|
describe('both/state/ducks/network', () => {
|
||||||
|
describe('setChallengeStatus', () => {
|
||||||
|
const { setChallengeStatus } = actions;
|
||||||
|
|
||||||
|
it('updates whether we need to complete a server challenge', () => {
|
||||||
|
const idleState = reducer(getEmptyState(), setChallengeStatus('idle'));
|
||||||
|
assert.equal(idleState.challengeStatus, 'idle');
|
||||||
|
|
||||||
|
const requiredState = reducer(idleState, setChallengeStatus('required'));
|
||||||
|
assert.equal(requiredState.challengeStatus, 'required');
|
||||||
|
|
||||||
|
const pendingState = reducer(
|
||||||
|
requiredState,
|
||||||
|
setChallengeStatus('pending')
|
||||||
|
);
|
||||||
|
assert.equal(pendingState.challengeStatus, 'pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
22
ts/test-both/util/parseRetryAfter_test.ts
Normal file
22
ts/test-both/util/parseRetryAfter_test.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { parseRetryAfter } from '../../util/parseRetryAfter';
|
||||||
|
|
||||||
|
describe('parseRetryAfter', () => {
|
||||||
|
it('should return 0 on invalid input', () => {
|
||||||
|
assert.equal(parseRetryAfter('nope'), 1000);
|
||||||
|
assert.equal(parseRetryAfter('1ff'), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return milleseconds on valid input', () => {
|
||||||
|
assert.equal(parseRetryAfter('100'), 100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return apply minimum value', () => {
|
||||||
|
assert.equal(parseRetryAfter('0'), 1000);
|
||||||
|
assert.equal(parseRetryAfter('-1'), 1000);
|
||||||
|
});
|
||||||
|
});
|
|
@ -7,8 +7,10 @@ import { LoggerType } from '../../types/Logging';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isSgnlHref,
|
isSgnlHref,
|
||||||
|
isCaptchaHref,
|
||||||
isSignalHttpsLink,
|
isSignalHttpsLink,
|
||||||
parseSgnlHref,
|
parseSgnlHref,
|
||||||
|
parseCaptchaHref,
|
||||||
parseSignalHttpsLink,
|
parseSignalHttpsLink,
|
||||||
} from '../../util/sgnlHref';
|
} from '../../util/sgnlHref';
|
||||||
|
|
||||||
|
@ -26,65 +28,71 @@ const explodingLogger: LoggerType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('sgnlHref', () => {
|
describe('sgnlHref', () => {
|
||||||
describe('isSgnlHref', () => {
|
[
|
||||||
it('returns false for non-strings', () => {
|
{ protocol: 'sgnl', check: isSgnlHref, name: 'isSgnlHref' },
|
||||||
const logger = {
|
{ protocol: 'signalcaptcha', check: isCaptchaHref, name: 'isCaptchaHref' },
|
||||||
...explodingLogger,
|
].forEach(({ protocol, check, name }) => {
|
||||||
warn: Sinon.spy(),
|
describe(name, () => {
|
||||||
};
|
it('returns false for non-strings', () => {
|
||||||
|
const logger = {
|
||||||
|
...explodingLogger,
|
||||||
|
warn: Sinon.spy(),
|
||||||
|
};
|
||||||
|
|
||||||
const castToString = (value: unknown): string => value as string;
|
const castToString = (value: unknown): string => value as string;
|
||||||
|
|
||||||
assert.isFalse(isSgnlHref(castToString(undefined), logger));
|
assert.isFalse(check(castToString(undefined), logger));
|
||||||
assert.isFalse(isSgnlHref(castToString(null), logger));
|
assert.isFalse(check(castToString(null), logger));
|
||||||
assert.isFalse(isSgnlHref(castToString(123), logger));
|
assert.isFalse(check(castToString(123), logger));
|
||||||
|
|
||||||
Sinon.assert.calledThrice(logger.warn);
|
Sinon.assert.calledThrice(logger.warn);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for invalid URLs', () => {
|
it('returns false for invalid URLs', () => {
|
||||||
assert.isFalse(isSgnlHref('', explodingLogger));
|
assert.isFalse(check('', explodingLogger));
|
||||||
assert.isFalse(isSgnlHref('sgnl', explodingLogger));
|
assert.isFalse(check(protocol, explodingLogger));
|
||||||
assert.isFalse(isSgnlHref('sgnl://::', explodingLogger));
|
assert.isFalse(check(`${protocol}://::`, explodingLogger));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false if the protocol is not "sgnl:"', () => {
|
it(`returns false if the protocol is not "${protocol}:"`, () => {
|
||||||
assert.isFalse(isSgnlHref('https://example', explodingLogger));
|
assert.isFalse(check('https://example', explodingLogger));
|
||||||
assert.isFalse(
|
assert.isFalse(
|
||||||
isSgnlHref(
|
check('https://signal.art/addstickers/?pack_id=abc', explodingLogger)
|
||||||
'https://signal.art/addstickers/?pack_id=abc',
|
);
|
||||||
explodingLogger
|
assert.isFalse(check('signal://example', explodingLogger));
|
||||||
)
|
});
|
||||||
);
|
|
||||||
assert.isFalse(isSgnlHref('signal://example', explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true if the protocol is "sgnl:"', () => {
|
it(`returns true if the protocol is "${protocol}:"`, () => {
|
||||||
assert.isTrue(isSgnlHref('sgnl://', explodingLogger));
|
assert.isTrue(check(`${protocol}://`, explodingLogger));
|
||||||
assert.isTrue(isSgnlHref('sgnl://example', explodingLogger));
|
assert.isTrue(check(`${protocol}://example`, explodingLogger));
|
||||||
assert.isTrue(isSgnlHref('sgnl://example.com', explodingLogger));
|
assert.isTrue(check(`${protocol}://example.com`, explodingLogger));
|
||||||
assert.isTrue(isSgnlHref('SGNL://example', explodingLogger));
|
assert.isTrue(
|
||||||
assert.isTrue(isSgnlHref('sgnl://example?foo=bar', explodingLogger));
|
check(`${protocol.toUpperCase()}://example`, explodingLogger)
|
||||||
assert.isTrue(isSgnlHref('sgnl://example/', explodingLogger));
|
);
|
||||||
assert.isTrue(isSgnlHref('sgnl://example#', explodingLogger));
|
assert.isTrue(check(`${protocol}://example?foo=bar`, explodingLogger));
|
||||||
|
assert.isTrue(check(`${protocol}://example/`, explodingLogger));
|
||||||
|
assert.isTrue(check(`${protocol}://example#`, explodingLogger));
|
||||||
|
|
||||||
assert.isTrue(isSgnlHref('sgnl:foo', explodingLogger));
|
assert.isTrue(check(`${protocol}:foo`, explodingLogger));
|
||||||
|
|
||||||
assert.isTrue(isSgnlHref('sgnl://user:pass@example', explodingLogger));
|
assert.isTrue(
|
||||||
assert.isTrue(isSgnlHref('sgnl://example.com:1234', explodingLogger));
|
check(`${protocol}://user:pass@example`, explodingLogger)
|
||||||
assert.isTrue(
|
);
|
||||||
isSgnlHref('sgnl://example.com/extra/path/data', explodingLogger)
|
assert.isTrue(check(`${protocol}://example.com:1234`, explodingLogger));
|
||||||
);
|
assert.isTrue(
|
||||||
assert.isTrue(
|
check(`${protocol}://example.com/extra/path/data`, explodingLogger)
|
||||||
isSgnlHref('sgnl://example/?foo=bar#hash', explodingLogger)
|
);
|
||||||
);
|
assert.isTrue(
|
||||||
});
|
check(`${protocol}://example/?foo=bar#hash`, explodingLogger)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts URL objects', () => {
|
it('accepts URL objects', () => {
|
||||||
const invalid = new URL('https://example.com');
|
const invalid = new URL('https://example.com');
|
||||||
assert.isFalse(isSgnlHref(invalid, explodingLogger));
|
assert.isFalse(check(invalid, explodingLogger));
|
||||||
const valid = new URL('sgnl://example');
|
const valid = new URL(`${protocol}://example`);
|
||||||
assert.isTrue(isSgnlHref(valid, explodingLogger));
|
assert.isTrue(check(valid, explodingLogger));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -255,6 +263,31 @@ describe('sgnlHref', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseCaptchaHref', () => {
|
||||||
|
it('throws on invalid URLs', () => {
|
||||||
|
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseCaptchaHref(href, explodingLogger),
|
||||||
|
'Not a captcha href'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the command for URLs with no arguments', () => {
|
||||||
|
[
|
||||||
|
'signalcaptcha://foo',
|
||||||
|
'signalcaptcha://foo?x=y',
|
||||||
|
'signalcaptcha://a:b@foo?x=y',
|
||||||
|
'signalcaptcha://foo#hash',
|
||||||
|
'signalcaptcha://foo/',
|
||||||
|
].forEach(href => {
|
||||||
|
assert.deepEqual(parseCaptchaHref(href, explodingLogger), {
|
||||||
|
captcha: 'foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseSignalHttpsLink', () => {
|
describe('parseSignalHttpsLink', () => {
|
||||||
it('returns a null command for invalid URLs', () => {
|
it('returns a null command for invalid URLs', () => {
|
||||||
['', 'https', 'https://example/?foo=bar'].forEach(href => {
|
['', 'https', 'https://example/?foo=bar'].forEach(href => {
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
|
import { parseRetryAfter } from '../util/parseRetryAfter';
|
||||||
|
|
||||||
function appendStack(newError: Error, originalError: Error) {
|
function appendStack(newError: Error, originalError: Error) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
newError.stack += `\nOriginal stack:\n${originalError.stack}`;
|
newError.stack += `\nOriginal stack:\n${originalError.stack}`;
|
||||||
|
@ -104,6 +106,37 @@ export class SendMessageNetworkError extends ReplayableError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SendMessageChallengeData = {
|
||||||
|
readonly token?: string;
|
||||||
|
readonly options?: ReadonlyArray<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SendMessageChallengeError extends ReplayableError {
|
||||||
|
public identifier: string;
|
||||||
|
|
||||||
|
public readonly data: SendMessageChallengeData | undefined;
|
||||||
|
|
||||||
|
public readonly retryAfter: number;
|
||||||
|
|
||||||
|
constructor(identifier: string, httpError: Error) {
|
||||||
|
super({
|
||||||
|
name: 'SendMessageChallengeError',
|
||||||
|
message: httpError.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
[this.identifier] = identifier.split('.');
|
||||||
|
this.code = httpError.code;
|
||||||
|
this.data = httpError.response;
|
||||||
|
|
||||||
|
const headers = httpError.responseHeaders || {};
|
||||||
|
|
||||||
|
this.retryAfter =
|
||||||
|
Date.now() + parseRetryAfter(headers['retry-after'].toString());
|
||||||
|
|
||||||
|
appendStack(this, httpError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SignedPreKeyRotationError extends ReplayableError {
|
export class SignedPreKeyRotationError extends ReplayableError {
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
OutgoingIdentityKeyError,
|
OutgoingIdentityKeyError,
|
||||||
OutgoingMessageError,
|
OutgoingMessageError,
|
||||||
SendMessageNetworkError,
|
SendMessageNetworkError,
|
||||||
|
SendMessageChallengeError,
|
||||||
UnregisteredUserError,
|
UnregisteredUserError,
|
||||||
} from './Errors';
|
} from './Errors';
|
||||||
import { isValidNumber } from '../types/PhoneNumber';
|
import { isValidNumber } from '../types/PhoneNumber';
|
||||||
|
@ -163,12 +164,16 @@ export default class OutgoingMessage {
|
||||||
let error = providedError;
|
let error = providedError;
|
||||||
|
|
||||||
if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
|
if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
|
||||||
error = new OutgoingMessageError(
|
if (error && error.code === 428) {
|
||||||
identifier,
|
error = new SendMessageChallengeError(identifier, error);
|
||||||
this.message.toArrayBuffer(),
|
} else {
|
||||||
this.timestamp,
|
error = new OutgoingMessageError(
|
||||||
error
|
identifier,
|
||||||
);
|
this.message.toArrayBuffer(),
|
||||||
|
this.timestamp,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
error.reason = reason;
|
error.reason = reason;
|
||||||
|
@ -370,10 +375,14 @@ export default class OutgoingMessage {
|
||||||
if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
|
if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
|
||||||
// 409 and 410 should bubble and be handled by doSendMessage
|
// 409 and 410 should bubble and be handled by doSendMessage
|
||||||
// 404 should throw UnregisteredUserError
|
// 404 should throw UnregisteredUserError
|
||||||
|
// 428 should throw SendMessageChallengeError
|
||||||
// all other network errors can be retried later.
|
// all other network errors can be retried later.
|
||||||
if (e.code === 404) {
|
if (e.code === 404) {
|
||||||
throw new UnregisteredUserError(identifier, e);
|
throw new UnregisteredUserError(identifier, e);
|
||||||
}
|
}
|
||||||
|
if (e.code === 428) {
|
||||||
|
throw new SendMessageChallengeError(identifier, e);
|
||||||
|
}
|
||||||
throw new SendMessageNetworkError(identifier, jsonData, e);
|
throw new SendMessageNetworkError(identifier, jsonData, e);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
GroupCredentialsType,
|
GroupCredentialsType,
|
||||||
GroupLogResponseType,
|
GroupLogResponseType,
|
||||||
ProxiedRequestOptionsType,
|
ProxiedRequestOptionsType,
|
||||||
|
ChallengeType,
|
||||||
WebAPIType,
|
WebAPIType,
|
||||||
} from './WebAPI';
|
} from './WebAPI';
|
||||||
import createTaskWithTimeout from './TaskWithTimeout';
|
import createTaskWithTimeout from './TaskWithTimeout';
|
||||||
|
@ -1915,4 +1916,10 @@ export default class MessageSender {
|
||||||
): Promise<GroupExternalCredentialClass> {
|
): Promise<GroupExternalCredentialClass> {
|
||||||
return this.server.getGroupExternalCredential(options);
|
return this.server.getGroupExternalCredential(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sendChallengeResponse(
|
||||||
|
challengeResponse: ChallengeType
|
||||||
|
): Promise<void> {
|
||||||
|
return this.server.sendChallengeResponse(challengeResponse);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,7 +306,8 @@ function getContentType(response: Response) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeaderListType = { [name: string]: string };
|
type FetchHeaderListType = { [name: string]: string };
|
||||||
|
type HeaderListType = { [name: string]: string | ReadonlyArray<string> };
|
||||||
type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
type RedactUrl = (url: string) => string;
|
type RedactUrl = (url: string) => string;
|
||||||
|
@ -397,7 +398,7 @@ async function _promiseAjax(
|
||||||
'User-Agent': getUserAgent(options.version),
|
'User-Agent': getUserAgent(options.version),
|
||||||
'X-Signal-Agent': 'OWD',
|
'X-Signal-Agent': 'OWD',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
} as HeaderListType,
|
} as FetchHeaderListType,
|
||||||
redirect: options.redirect,
|
redirect: options.redirect,
|
||||||
agent,
|
agent,
|
||||||
ca: options.certificateAuthority,
|
ca: options.certificateAuthority,
|
||||||
|
@ -500,6 +501,7 @@ async function _promiseAjax(
|
||||||
makeHTTPError(
|
makeHTTPError(
|
||||||
'promiseAjax: invalid response',
|
'promiseAjax: invalid response',
|
||||||
response.status,
|
response.status,
|
||||||
|
response.headers.raw(),
|
||||||
result,
|
result,
|
||||||
options.stack
|
options.stack
|
||||||
)
|
)
|
||||||
|
@ -563,6 +565,7 @@ async function _promiseAjax(
|
||||||
makeHTTPError(
|
makeHTTPError(
|
||||||
'promiseAjax: error response',
|
'promiseAjax: error response',
|
||||||
response.status,
|
response.status,
|
||||||
|
response.headers.raw(),
|
||||||
result,
|
result,
|
||||||
options.stack
|
options.stack
|
||||||
)
|
)
|
||||||
|
@ -576,7 +579,7 @@ async function _promiseAjax(
|
||||||
window.log.error(options.type, url, 0, 'Error');
|
window.log.error(options.type, url, 0, 'Error');
|
||||||
}
|
}
|
||||||
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
||||||
reject(makeHTTPError('promiseAjax catch', 0, e.toString(), stack));
|
reject(makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -614,6 +617,7 @@ declare global {
|
||||||
interface Error {
|
interface Error {
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
response?: any;
|
response?: any;
|
||||||
|
responseHeaders?: HeaderListType;
|
||||||
warn?: boolean;
|
warn?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -621,6 +625,7 @@ declare global {
|
||||||
function makeHTTPError(
|
function makeHTTPError(
|
||||||
message: string,
|
message: string,
|
||||||
providedCode: number,
|
providedCode: number,
|
||||||
|
headers: HeaderListType,
|
||||||
response: any,
|
response: any,
|
||||||
stack?: string
|
stack?: string
|
||||||
) {
|
) {
|
||||||
|
@ -628,6 +633,7 @@ function makeHTTPError(
|
||||||
const e = new Error(`${message}; code: ${code}`);
|
const e = new Error(`${message}; code: ${code}`);
|
||||||
e.name = 'HTTPError';
|
e.name = 'HTTPError';
|
||||||
e.code = code;
|
e.code = code;
|
||||||
|
e.responseHeaders = headers;
|
||||||
if (DEBUG && response) {
|
if (DEBUG && response) {
|
||||||
e.stack += `\nresponse: ${response}`;
|
e.stack += `\nresponse: ${response}`;
|
||||||
}
|
}
|
||||||
|
@ -670,6 +676,7 @@ const URL_CALLS = {
|
||||||
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
||||||
updateDeviceName: 'v1/accounts/name',
|
updateDeviceName: 'v1/accounts/name',
|
||||||
whoami: 'v1/accounts/whoami',
|
whoami: 'v1/accounts/whoami',
|
||||||
|
challenge: 'v1/challenge',
|
||||||
};
|
};
|
||||||
|
|
||||||
type InitializeOptionsType = {
|
type InitializeOptionsType = {
|
||||||
|
@ -875,6 +882,7 @@ export type WebAPIType = {
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
whoami: () => Promise<any>;
|
whoami: () => Promise<any>;
|
||||||
|
sendChallengeResponse: (challengeResponse: ChallengeType) => Promise<any>;
|
||||||
getConfig: () => Promise<
|
getConfig: () => Promise<
|
||||||
Array<{ name: string; enabled: boolean; value: string | null }>
|
Array<{ name: string; enabled: boolean; value: string | null }>
|
||||||
>;
|
>;
|
||||||
|
@ -912,6 +920,12 @@ export type ServerKeysType = {
|
||||||
identityKey: ArrayBuffer;
|
identityKey: ArrayBuffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChallengeType = {
|
||||||
|
readonly type: 'recaptcha';
|
||||||
|
readonly token: string;
|
||||||
|
readonly captcha: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProxiedRequestOptionsType = {
|
export type ProxiedRequestOptionsType = {
|
||||||
returnArrayBuffer?: boolean;
|
returnArrayBuffer?: boolean;
|
||||||
start?: number;
|
start?: number;
|
||||||
|
@ -1035,6 +1049,7 @@ export function initialize({
|
||||||
updateDeviceName,
|
updateDeviceName,
|
||||||
uploadGroupAvatar,
|
uploadGroupAvatar,
|
||||||
whoami,
|
whoami,
|
||||||
|
sendChallengeResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function _ajax(param: AjaxOptionsType): Promise<any> {
|
async function _ajax(param: AjaxOptionsType): Promise<any> {
|
||||||
|
@ -1105,6 +1120,14 @@ export function initialize({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendChallengeResponse(challengeResponse: ChallengeType) {
|
||||||
|
return _ajax({
|
||||||
|
call: 'challenge',
|
||||||
|
httpType: 'PUT',
|
||||||
|
jsonData: challengeResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
type ResType = {
|
type ResType = {
|
||||||
config: Array<{ name: string; enabled: boolean; value: string | null }>;
|
config: Array<{ name: string; enabled: boolean; value: string | null }>;
|
||||||
|
|
|
@ -14140,6 +14140,41 @@
|
||||||
"updated": "2021-01-06T00:47:54.313Z",
|
"updated": "2021-01-06T00:47:54.313Z",
|
||||||
"reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM."
|
"reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/challenge.js",
|
||||||
|
"line": " async load() {",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/challenge.js",
|
||||||
|
"line": " // 1. `.load()` when the `window.storage` is ready",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/challenge.ts",
|
||||||
|
"line": "// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/challenge.ts",
|
||||||
|
"line": " public async load(): Promise<void> {",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/challenge.ts",
|
||||||
|
"line": " // 1. `.load()` when the `window.storage` is ready",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/AvatarInput.js",
|
"path": "ts/components/AvatarInput.js",
|
||||||
|
@ -14212,6 +14247,14 @@
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Used to get the local video element for rendering."
|
"reasonDetail": "Used to get the local video element for rendering."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/CaptchaDialog.js",
|
||||||
|
"line": " const buttonRef = react_1.useRef(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2021-05-05T23:11:22.692Z",
|
||||||
|
"reasonDetail": "Used only to set focus"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/CaptionEditor.js",
|
"path": "ts/components/CaptionEditor.js",
|
||||||
|
|
16
ts/util/parseRetryAfter.ts
Normal file
16
ts/util/parseRetryAfter.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNormalNumber } from './isNormalNumber';
|
||||||
|
|
||||||
|
const ONE_SECOND = 1000;
|
||||||
|
const MINIMAL_RETRY_AFTER = ONE_SECOND;
|
||||||
|
|
||||||
|
export function parseRetryAfter(value: string): number {
|
||||||
|
let retryAfter = parseInt(value, 10);
|
||||||
|
if (!isNormalNumber(retryAfter) || retryAfter.toString() !== value) {
|
||||||
|
retryAfter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(retryAfter * ONE_SECOND, MINIMAL_RETRY_AFTER);
|
||||||
|
}
|
|
@ -23,6 +23,14 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
|
||||||
return url !== null && url.protocol === 'sgnl:';
|
return url !== null && url.protocol === 'sgnl:';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCaptchaHref(
|
||||||
|
value: string | URL,
|
||||||
|
logger: LoggerType
|
||||||
|
): boolean {
|
||||||
|
const url = parseUrl(value, logger);
|
||||||
|
return url !== null && url.protocol === 'signalcaptcha:';
|
||||||
|
}
|
||||||
|
|
||||||
export function isSignalHttpsLink(
|
export function isSignalHttpsLink(
|
||||||
value: string | URL,
|
value: string | URL,
|
||||||
logger: LoggerType
|
logger: LoggerType
|
||||||
|
@ -64,6 +72,23 @@ export function parseSgnlHref(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParsedCaptchaHref = {
|
||||||
|
readonly captcha: string;
|
||||||
|
};
|
||||||
|
export function parseCaptchaHref(
|
||||||
|
href: URL | string,
|
||||||
|
logger: LoggerType
|
||||||
|
): ParsedCaptchaHref {
|
||||||
|
const url = parseUrl(href, logger);
|
||||||
|
if (!url || !isCaptchaHref(url, logger)) {
|
||||||
|
throw new Error('Not a captcha href');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
captcha: url.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSignalHttpsLink(
|
export function parseSignalHttpsLink(
|
||||||
href: string,
|
href: string,
|
||||||
logger: LoggerType
|
logger: LoggerType
|
||||||
|
|
|
@ -85,6 +85,18 @@ Whisper.BlockedGroupToast = Whisper.ToastView.extend({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Whisper.CaptchaSolvedToast = Whisper.ToastView.extend({
|
||||||
|
render_attributes() {
|
||||||
|
return { toastMessage: window.i18n('verificationComplete') };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Whisper.CaptchaFailedToast = Whisper.ToastView.extend({
|
||||||
|
render_attributes() {
|
||||||
|
return { toastMessage: window.i18n('verificationFailed') };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Whisper.LeftGroupToast = Whisper.ToastView.extend({
|
Whisper.LeftGroupToast = Whisper.ToastView.extend({
|
||||||
render_attributes() {
|
render_attributes() {
|
||||||
return { toastMessage: window.i18n('youLeftTheGroup') };
|
return { toastMessage: window.i18n('youLeftTheGroup') };
|
||||||
|
@ -237,6 +249,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Whisper.DeleteForEveryoneFailedToast = Whisper.ToastView.extend({
|
||||||
|
render_attributes() {
|
||||||
|
return { toastMessage: window.i18n('deleteForEveryoneFailed') };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({
|
Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({
|
||||||
render_attributes() {
|
render_attributes() {
|
||||||
return { toastMessage: window.i18n('GroupLinkManagement--clipboard') };
|
return { toastMessage: window.i18n('GroupLinkManagement--clipboard') };
|
||||||
|
@ -2788,7 +2806,16 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
message: window.i18n('deleteForEveryoneWarning'),
|
message: window.i18n('deleteForEveryoneWarning'),
|
||||||
okText: window.i18n('delete'),
|
okText: window.i18n('delete'),
|
||||||
resolve: async () => {
|
resolve: async () => {
|
||||||
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at'));
|
try {
|
||||||
|
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at'));
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
'Error sending delete-for-everyone',
|
||||||
|
error,
|
||||||
|
messageId
|
||||||
|
);
|
||||||
|
this.showToast(Whisper.DeleteForEveryoneFailedToast);
|
||||||
|
}
|
||||||
this.resetPanel();
|
this.resetPanel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
10
ts/window.d.ts
vendored
10
ts/window.d.ts
vendored
|
@ -18,6 +18,10 @@ import {
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
} from './model-types.d';
|
} from './model-types.d';
|
||||||
import { ContactRecordIdentityState, TextSecureType } from './textsecure.d';
|
import { ContactRecordIdentityState, TextSecureType } from './textsecure.d';
|
||||||
|
import {
|
||||||
|
ChallengeHandler,
|
||||||
|
IPCRequest as IPCChallengeRequest,
|
||||||
|
} from './challenge';
|
||||||
import { WebAPIConnectType } from './textsecure/WebAPI';
|
import { WebAPIConnectType } from './textsecure/WebAPI';
|
||||||
import { uploadDebugLogs } from './logging/debuglogs';
|
import { uploadDebugLogs } from './logging/debuglogs';
|
||||||
import { CallingClass } from './services/calling';
|
import { CallingClass } from './services/calling';
|
||||||
|
@ -216,6 +220,7 @@ declare global {
|
||||||
showWindow: () => void;
|
showWindow: () => void;
|
||||||
showSettings: () => void;
|
showSettings: () => void;
|
||||||
shutdown: () => void;
|
shutdown: () => void;
|
||||||
|
sendChallengeRequest: (request: IPCChallengeRequest) => void;
|
||||||
setAutoHideMenuBar: (value: WhatIsThis) => void;
|
setAutoHideMenuBar: (value: WhatIsThis) => void;
|
||||||
setBadgeCount: (count: number) => void;
|
setBadgeCount: (count: number) => void;
|
||||||
setMenuBarVisibility: (value: WhatIsThis) => void;
|
setMenuBarVisibility: (value: WhatIsThis) => void;
|
||||||
|
@ -522,6 +527,7 @@ declare global {
|
||||||
getInitialState: () => WhatIsThis;
|
getInitialState: () => WhatIsThis;
|
||||||
load: () => void;
|
load: () => void;
|
||||||
};
|
};
|
||||||
|
challengeHandler: ChallengeHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
ConversationController: ConversationController;
|
ConversationController: ConversationController;
|
||||||
|
@ -580,6 +586,7 @@ export type DCodeIOType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageControllerType = {
|
type MessageControllerType = {
|
||||||
|
getById: (id: string) => MessageModel | undefined;
|
||||||
findBySender: (sender: string) => MessageModel | null;
|
findBySender: (sender: string) => MessageModel | null;
|
||||||
findBySentAt: (sentAt: number) => MessageModel | null;
|
findBySentAt: (sentAt: number) => MessageModel | null;
|
||||||
register: (id: string, model: MessageModel) => MessageModel;
|
register: (id: string, model: MessageModel) => MessageModel;
|
||||||
|
@ -739,6 +746,8 @@ export type WhisperType = {
|
||||||
BlockedGroupToast: typeof window.Whisper.ToastView;
|
BlockedGroupToast: typeof window.Whisper.ToastView;
|
||||||
BlockedToast: typeof window.Whisper.ToastView;
|
BlockedToast: typeof window.Whisper.ToastView;
|
||||||
CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView;
|
CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView;
|
||||||
|
CaptchaSolvedToast: typeof window.Whisper.ToastView;
|
||||||
|
CaptchaFailedToast: typeof window.Whisper.ToastView;
|
||||||
DangerousFileTypeToast: typeof window.Whisper.ToastView;
|
DangerousFileTypeToast: typeof window.Whisper.ToastView;
|
||||||
ExpiredToast: typeof window.Whisper.ToastView;
|
ExpiredToast: typeof window.Whisper.ToastView;
|
||||||
FileSavedToast: typeof window.Whisper.ToastView;
|
FileSavedToast: typeof window.Whisper.ToastView;
|
||||||
|
@ -753,6 +762,7 @@ export type WhisperType = {
|
||||||
OriginalNotFoundToast: typeof window.Whisper.ToastView;
|
OriginalNotFoundToast: typeof window.Whisper.ToastView;
|
||||||
PinnedConversationsFullToast: typeof window.Whisper.ToastView;
|
PinnedConversationsFullToast: typeof window.Whisper.ToastView;
|
||||||
ReactionFailedToast: typeof window.Whisper.ToastView;
|
ReactionFailedToast: typeof window.Whisper.ToastView;
|
||||||
|
DeleteForEveryoneFailedToast: typeof window.Whisper.ToastView;
|
||||||
TapToViewExpiredIncomingToast: typeof window.Whisper.ToastView;
|
TapToViewExpiredIncomingToast: typeof window.Whisper.ToastView;
|
||||||
TapToViewExpiredOutgoingToast: typeof window.Whisper.ToastView;
|
TapToViewExpiredOutgoingToast: typeof window.Whisper.ToastView;
|
||||||
TimerConflictToast: typeof window.Whisper.ToastView;
|
TimerConflictToast: typeof window.Whisper.ToastView;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue