Move all status/alert dialogs into the Left Pane

This commit is contained in:
Josh Perez 2020-02-12 13:30:58 -08:00 committed by GitHub
parent 101070bf42
commit 18fd44f504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1298 additions and 607 deletions

View File

@ -575,6 +575,10 @@
"message": "Connecting",
"description": "Displayed when the desktop client is currently connecting to the server."
},
"connectingHangOn": {
"message": "Shouldn't be long...",
"description": "Subtext description for when the client is connecting to the server."
},
"offline": {
"message": "Offline",
"description": "Displayed when the desktop client has no network connection."
@ -583,15 +587,6 @@
"message": "Check your network connection.",
"description": "Obvious instructions for when a user's computer loses its network connection"
},
"attemptingReconnection": {
"message": "Attempting reconnect in $reconnect_duration_in_seconds$ seconds",
"placeholders": {
"reconnect_duration_in_seconds": {
"content": "$1",
"example": "10"
}
}
},
"submitDebugLog": {
"message": "Debug log",
"description": "Menu item and header text for debug log modal (sentence case)"
@ -839,12 +834,28 @@
"description": "Shown as the title of our update error dialogs on windows"
},
"cannotUpdateDetail": {
"message": "Signal Desktop failed to update, but there is a new version available. Please go to https://signal.org/download and install the new version manually, then either contact support or file a bug about this problem.",
"description": "Shown if a general error happened while trying to install update package"
"message": "Signal Desktop failed to update, but there is a new version available. Please go to $url$ and install the new version manually, then either contact support or file a bug about this problem.",
"description": "Shown if a general error happened while trying to install update package",
"placeholders": {
"url": {
"content": "$1",
"example": "https://signal.org/download"
}
}
},
"readOnlyVolume": {
"message": "Signal Desktop is likely in a macOS quarantine, and will not be able to auto-update. Please try moving Signal.app to /Applications with Finder.",
"description": "Shown on MacOS if running on a read-only volume and we cannot update"
"message": "Signal Desktop is likely in a macOS quarantine, and will not be able to auto-update. Please try moving $app$ to $folder$ with Finder.",
"description": "Shown on MacOS if running on a read-only volume and we cannot update",
"placeholders": {
"app": {
"content": "$1",
"example": "Signal.app"
},
"folder": {
"content": "$2",
"example": "/Applications"
}
}
},
"ok": {
"message": "OK"

View File

@ -54,7 +54,6 @@
<script type='text/x-tmpl-mustache' id='two-column'>
<div class='gutter'>
<div class='network-status-container' aria-live='assertive'></div>
<div class='left-pane-placeholder'></div>
</div>
<div class='conversation-stack'>
@ -72,13 +71,6 @@
<div class='lightbox-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='expired_alert'>
<a target='_blank' href='https://signal.org/download/'>
<button class='upgrade'>{{ upgrade }}</button>
</a>
{{ expiredWarning }}
</script>
<script type='text/x-tmpl-mustache' id='banner'>
<div class='body'>
<span class='icon warning'></span>
@ -227,23 +219,6 @@
{{/isStep2}}
</script>
<script type='text/x-tmpl-mustache' id='networkStatus'>
<div class='network-status-message'>
<h3>{{ message }}</h3>
<span>{{ instructions }}</span>
</div>
{{ #reconnectDurationAsSeconds }}
<div class="network-status-message">
{{ attemptingReconnectionMessage }}
</div>
{{/reconnectDurationAsSeconds }}
{{ #action }}
<div class="action">
<button class='small blue {{ buttonClass }}'>{{ action }}</button>
</div>
{{/action }}
</script>
<script type='text/x-tmpl-mustache' id='import-flow-template'>
{{#isStep2}}
<div id='step2' class='step'>
@ -464,8 +439,6 @@
<script type='text/javascript' src='js/expiring_tap_to_view_messages.js'></script>
<script type='text/javascript' src='js/chromium.js'></script>
<script type='text/javascript' src='js/registration.js'></script>
<script type='text/javascript' src='js/expire.js'></script>
<script type='text/javascript' src='js/conversation_controller.js'></script>
<script type='text/javascript' src='js/message_controller.js'></script>
@ -479,7 +452,6 @@
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>
<script type='text/javascript' src='js/views/inbox_view.js'></script>
<script type='text/javascript' src='js/views/network_status_view.js'></script>
<script type='text/javascript' src='js/views/confirmation_dialog_view.js'></script>
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/install_view.js'></script>

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><circle cx="11" cy="-1041.36" r="8" transform="matrix(1 0 0-1 0-1030.36)" opacity=".98" fill="#da4453"/><path d="m-26.309 18.07c-1.18 0-2.135.968-2.135 2.129v12.82c0 1.176.948 2.129 2.135 2.129 1.183 0 2.135-.968 2.135-2.129v-12.82c0-1.176-.946-2.129-2.135-2.129zm0 21.348c-1.18 0-2.135.954-2.135 2.135 0 1.18.954 2.135 2.135 2.135 1.181 0 2.135-.954 2.135-2.135 0-1.18-.952-2.135-2.135-2.135z" transform="matrix(.30056 0 0 .30056 18.902 1.728)" fill="#fff" stroke="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 539 B

View File

@ -244,7 +244,7 @@
};
Whisper.events.trigger('userChanged', user);
Whisper.Registration.markDone();
window.Signal.Util.Registration.markDone();
window.log.info('dispatching registration event');
Whisper.events.trigger('registration_done');
});
@ -382,7 +382,10 @@
showStickerPack: async (packId, key) => {
// We can get these events even if the user has never linked this instance.
if (Whisper.Import.isIncomplete() || !Whisper.Registration.everDone()) {
if (
Whisper.Import.isIncomplete() ||
!window.Signal.Util.Registration.everDone()
) {
return;
}
@ -559,6 +562,15 @@
} finally {
initializeRedux();
start();
window.Signal.Services.initializeNetworkObserver(
window.reduxActions.network
);
window.Signal.Services.initializeUpdateListener(
window.reduxActions.updates
);
window.reduxActions.expiration.hydrateExpirationStatus(
window.Signal.Util.hasExpired()
);
}
});
@ -609,10 +621,22 @@
Signal.State.Ducks.emojis.actions,
store.dispatch
);
actions.expiration = Signal.State.bindActionCreators(
Signal.State.Ducks.expiration.actions,
store.dispatch
);
actions.items = Signal.State.bindActionCreators(
Signal.State.Ducks.items.actions,
store.dispatch
);
actions.network = Signal.State.bindActionCreators(
Signal.State.Ducks.network.actions,
store.dispatch
);
actions.updates = Signal.State.bindActionCreators(
Signal.State.Ducks.updates.actions,
store.dispatch
);
actions.user = Signal.State.bindActionCreators(
Signal.State.Ducks.user.actions,
store.dispatch
@ -1351,7 +1375,7 @@
if (Whisper.Import.isIncomplete()) {
window.log.info('Import was interrupted, showing import error screen');
appView.openImporter();
} else if (Whisper.Registration.everDone()) {
} else if (window.Signal.Util.Registration.everDone()) {
// listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
window.Signal.RefreshSenderCertificate.initialize({
@ -1377,9 +1401,6 @@
Whisper.events.on('unauthorized', () => {
appView.inboxView.networkStatusView.update();
});
Whisper.events.on('reconnectTimer', () => {
appView.inboxView.networkStatusView.setSocketReconnectInterval(60000);
});
Whisper.events.on('contactsync', () => {
if (appView.installView) {
appView.openInbox();
@ -1479,7 +1500,7 @@
return;
}
if (!Whisper.Registration.everDone()) {
if (!window.Signal.Util.Registration.everDone()) {
return;
}
if (Whisper.Import.isIncomplete()) {
@ -2299,7 +2320,7 @@
window.log.warn(
'Client is no longer authorized; deleting local configuration'
);
Whisper.Registration.remove();
window.Signal.Util.Registration.remove();
const NUMBER_ID_KEY = 'number_id';
const VERSION_KEY = 'version';
@ -2317,7 +2338,7 @@
// These two bits of data are important to ensure that the app loads up
// the conversation list, instead of showing just the QR code screen.
Whisper.Registration.markEverDone();
window.Signal.Util.Registration.markEverDone();
textsecure.storage.put(NUMBER_ID_KEY, previousNumberId);
// These two are important to ensure we don't rip through every message

View File

@ -1,22 +0,0 @@
// eslint-disable-next-line func-names
(function() {
'use strict';
let BUILD_EXPIRATION = 0;
try {
BUILD_EXPIRATION = parseInt(window.getExpiration(), 10);
if (BUILD_EXPIRATION) {
window.log.info(
'Build expires: ',
new Date(BUILD_EXPIRATION).toISOString()
);
}
} catch (e) {
// nothing
}
window.extension = window.extension || {};
window.extension.expired = () =>
BUILD_EXPIRATION && Date.now() > BUILD_EXPIRATION;
})();

View File

@ -64,12 +64,16 @@ const {
const { createStore } = require('../../ts/state/createStore');
const conversationsDuck = require('../../ts/state/ducks/conversations');
const emojisDuck = require('../../ts/state/ducks/emojis');
const expirationDuck = require('../../ts/state/ducks/expiration');
const itemsDuck = require('../../ts/state/ducks/items');
const networkDuck = require('../../ts/state/ducks/network');
const searchDuck = require('../../ts/state/ducks/search');
const stickersDuck = require('../../ts/state/ducks/stickers');
const updatesDuck = require('../../ts/state/ducks/updates');
const userDuck = require('../../ts/state/ducks/user');
const conversationsSelectors = require('../../ts/state/selectors/conversations');
const registrationSelectors = require('../../ts/state/selectors/registration');
const searchSelectors = require('../../ts/state/selectors/search');
// Migrations
@ -98,6 +102,14 @@ const Initialization = require('./views/initialization');
const { IdleDetector } = require('./idle_detector');
const MessageDataMigrator = require('./messages_data_migrator');
// Processes / Services
const {
initializeNetworkObserver,
} = require('../../ts/services/networkObserver');
const {
initializeUpdateListener,
} = require('../../ts/services/updateListener');
function initializeMigrations({
userDataPath,
getRegionCode,
@ -284,19 +296,30 @@ exports.setup = (options = {}) => {
createStickerPreviewModal,
createTimeline,
};
const Ducks = {
conversations: conversationsDuck,
emojis: emojisDuck,
expiration: expirationDuck,
items: itemsDuck,
network: networkDuck,
updates: updatesDuck,
user: userDuck,
search: searchDuck,
stickers: stickersDuck,
};
const Selectors = {
conversations: conversationsSelectors,
registration: registrationSelectors,
search: searchSelectors,
};
const Services = {
initializeNetworkObserver,
initializeUpdateListener,
};
const State = {
bindActionCreators,
createStore,
@ -344,6 +367,7 @@ exports.setup = (options = {}) => {
OS,
RefreshSenderCertificate,
Settings,
Services,
State,
Stickers,
Types,

View File

@ -1,28 +0,0 @@
/* global storage, Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
Whisper.Registration = {
markEverDone() {
storage.put('chromiumRegistrationDoneEver', '');
},
markDone() {
this.markEverDone();
storage.put('chromiumRegistrationDone', '');
},
isDone() {
return storage.get('chromiumRegistrationDone') === '';
},
everDone() {
return (
storage.get('chromiumRegistrationDoneEver') === '' ||
storage.get('chromiumRegistrationDone') === ''
);
},
remove() {
storage.remove('chromiumRegistrationDone');
},
};
})();

View File

@ -83,7 +83,7 @@
}
events.on('timetravel', () => {
if (Whisper.Registration.isDone()) {
if (window.Signal.Util.Registration.isDone()) {
setTimeoutForNextRun();
}
});

View File

@ -29,7 +29,6 @@
});
},
events: {
'click .openInstaller': 'openInstaller', // NetworkStatusView has this button
openInbox: 'openInbox',
},
applyTheme() {

View File

@ -2642,7 +2642,7 @@
this.model.clearTypingTimers();
let ToastView;
if (extension.expired()) {
if (window.reduxStore.getState().expiration.hasExpired) {
ToastView = Whisper.ExpiredToast;
}
if (this.model.isPrivate() && storage.isBlocked(this.model.id)) {

View File

@ -1,7 +1,5 @@
/* global
ConversationController,
extension,
getInboxCollection,
i18n,
Whisper,
Signal
@ -95,25 +93,6 @@
this.setupLeftPane();
}
const inboxCollection = getInboxCollection();
this.listenTo(inboxCollection, 'messageError', () => {
if (this.networkStatusView) {
this.networkStatusView.update();
}
});
this.networkStatusView = new Whisper.NetworkStatusView();
this.$el
.find('.network-status-container')
.append(this.networkStatusView.render().el);
if (extension.expired()) {
const banner = new Whisper.ExpiredAlertBanner().render();
banner.$el.prependTo(this.$el);
this.$el.addClass('expired');
}
Whisper.events.on('pack-install-failed', () => {
const toast = new Whisper.StickerPackInstallFailedToast();
toast.$el.appendTo(this.$el);
@ -225,15 +204,4 @@
this.closeRecording(e);
},
});
Whisper.ExpiredAlertBanner = Whisper.View.extend({
templateName: 'expired_alert',
className: 'expiredAlert clearfix',
render_attributes() {
return {
expiredWarning: i18n('expiredWarning'),
upgrade: i18n('upgrade'),
};
},
});
})();

View File

@ -38,7 +38,7 @@
// Keep data around if it's a re-link, or the middle of a light import
this.shouldRetainData =
Whisper.Registration.everDone() || options.hasExistingData;
window.Signal.Util.Registration.everDone() || options.hasExistingData;
},
render_attributes() {
let errorMessage;

View File

@ -1,122 +0,0 @@
/* global Whisper, extension, Backbone, moment, i18n */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.NetworkStatusView = Whisper.View.extend({
className: 'network-status',
templateName: 'networkStatus',
initialize() {
this.$el.hide();
this.renderIntervalHandle = setInterval(this.update.bind(this), 5000);
extension.windows.onClosed(() => {
clearInterval(this.renderIntervalHandle);
});
setTimeout(this.finishConnectingGracePeriod.bind(this), 5000);
this.withinConnectingGracePeriod = true;
this.setSocketReconnectInterval(null);
window.addEventListener('online', this.update.bind(this));
window.addEventListener('offline', this.update.bind(this));
this.model = new Backbone.Model();
this.listenTo(this.model, 'change', this.onChange);
},
onReconnectTimer() {
this.setSocketReconnectInterval(60000);
},
finishConnectingGracePeriod() {
this.withinConnectingGracePeriod = false;
},
setSocketReconnectInterval(millis) {
this.socketReconnectWaitDuration = moment.duration(millis);
},
navigatorOnLine() {
return navigator.onLine;
},
getSocketStatus() {
return window.getSocketStatus();
},
getNetworkStatus() {
let message = '';
let instructions = '';
let hasInterruption = false;
let action = null;
let buttonClass = null;
const socketStatus = this.getSocketStatus();
switch (socketStatus) {
case WebSocket.CONNECTING:
message = i18n('connecting');
this.setSocketReconnectInterval(null);
break;
case WebSocket.OPEN:
this.setSocketReconnectInterval(null);
break;
case WebSocket.CLOSED:
message = i18n('disconnected');
instructions = i18n('checkNetworkConnection');
hasInterruption = true;
break;
case WebSocket.CLOSING:
default:
message = i18n('disconnected');
instructions = i18n('checkNetworkConnection');
hasInterruption = true;
break;
}
if (
socketStatus === WebSocket.CONNECTING &&
!this.withinConnectingGracePeriod
) {
hasInterruption = true;
}
if (this.socketReconnectWaitDuration.asSeconds() > 0) {
instructions = i18n('attemptingReconnection', [
this.socketReconnectWaitDuration.asSeconds(),
]);
}
if (!this.navigatorOnLine()) {
hasInterruption = true;
message = i18n('offline');
instructions = i18n('checkNetworkConnection');
} else if (!Whisper.Registration.isDone()) {
hasInterruption = true;
message = i18n('unlinked');
instructions = i18n('unlinkedWarning');
action = i18n('relink');
buttonClass = 'openInstaller';
}
return {
message,
instructions,
hasInterruption,
action,
buttonClass,
};
},
update() {
const status = this.getNetworkStatus();
this.model.set(status);
},
render_attributes() {
return this.model.attributes;
},
onChange() {
this.render();
if (this.model.attributes.hasInterruption) {
this.$el.slideDown();
} else {
this.$el.hide();
}
},
});
})();

View File

@ -296,28 +296,6 @@ $loading-height: 16px;
}
}
.expiredAlert {
background: $color-accent-yellow;
padding: 10px;
button {
float: right;
border: none;
border-radius: 5px;
font-weight: bold;
line-height: 36px;
padding: 0 20px;
margin-left: 20px;
color: $color-white;
background: $color-signal-blue;
}
.message {
padding: 10px 0;
}
}
@keyframes loading {
50% {
transform: scale(1);

View File

@ -51,45 +51,6 @@
}
}
.network-status-container {
.network-status {
background: url('../images/error_red.svg') no-repeat left 10px center;
background-size: 25px 25px;
background-color: $color-accent-yellow;
padding: 10px;
padding-left: 48px;
display: none;
.network-status-message {
h3 {
padding: 0px;
margin: 0px;
margin-bottom: 2px;
font-size: 14px;
}
span {
display: inline-block;
font-size: 12px;
padding: 0.5em 0;
}
@include dark-theme {
color: $color-gray-90;
}
}
.action {
button {
border-radius: 5px;
border: solid 1px $color-gray-25;
cursor: pointer;
font-family: inherit;
color: $color-white;
background: $color-signal-blue;
}
}
}
}
.left-pane-placeholder {
height: 100%;
}

View File

@ -4685,8 +4685,10 @@ button.module-image__border-overlay:focus {
.module-search-results__conversations-header {
@include font-body-1-bold;
height: 36px;
height: 52px;
margin-left: 16px;
padding-bottom: 8px;
padding-top: 8px;
@include dark-theme {
color: $color-gray-05;
@ -4716,8 +4718,10 @@ button.module-image__border-overlay:focus {
.module-search-results__messages-header {
@include font-body-1-bold;
height: 36px;
height: 52px;
margin-left: 16px;
padding-bottom: 8px;
padding-top: 8px;
@include dark-theme {
color: $color-gray-05;
@ -6381,6 +6385,79 @@ button.module-image__border-overlay:focus {
}
}
.module-left-pane-dialog {
background: $color-accent-green;
color: $color-white;
padding: 16px;
.module-left-pane-dialog__message {
h3 {
@include font-body-1-bold;
padding: 0px;
margin: 0px;
margin-bottom: 8px;
}
span {
@include font-body-1;
display: inline-block;
}
}
.module-left-pane-dialog__actions {
margin-top: 8px;
text-align: right;
.module-left-pane-dialog__link {
@include keyboard-mode {
display: inline-block;
outline: 0;
}
}
button {
background: inherit;
border-radius: 20px;
border: solid 1px $color-white;
color: $color-white;
cursor: pointer;
font-family: inherit;
margin: 0 4px;
padding: 8px 16px;
outline: 0;
&:focus {
@include keyboard-mode {
box-shadow: 0 0 0 3px $color-signal-blue;
}
}
&:hover {
@include mouse-mode {
box-shadow: 0 0 0 3px $color-signal-blue;
}
}
}
.module-left-pane-dialog__button--no-border {
border: none;
}
}
&.module-left-pane-dialog--error {
background-color: $color-accent-red;
}
&.module-left-pane-dialog--warning {
background-color: $color-accent-yellow;
color: $color-black;
button {
border-color: $color-black;
color: $color-black;
}
}
}
// Module: Emoji Picker
%module-emoji-picker--ribbon {

View File

@ -8,10 +8,6 @@ describe('i18n', () => {
it('returns message for given string', () => {
assert.equal(i18n('reportIssue'), 'Report an issue');
});
it('returns message with single substitution', () => {
const actual = i18n('attemptingReconnection', 5);
assert.equal(actual, 'Attempting reconnect in 5 seconds');
});
it('returns message with multiple substitutions', () => {
const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']);
assert.equal(

View File

@ -35,7 +35,6 @@
<script type='text/x-tmpl-mustache' id='two-column'>
<div class='gutter'>
<div class='network-status-container'></div>
<div class='left-pane-placeholder'></div>
</div>
<div class='conversation-stack'>
@ -66,13 +65,6 @@
</div>
</script>
<script type='text/x-tmpl-mustache' id='expired_alert'>
<a target='_blank' href='https://signal.org/download/'>
<button class='upgrade'>{{ upgrade }}</button>
</a>
{{ expiredWarning }}
</script>
<script type='text/x-tmpl-mustache' id='banner'>
<div class='body'>
<span class='icon warning'></span>
@ -237,23 +229,6 @@
{{/isStep2}}
</script>
<script type='text/x-tmpl-mustache' id='networkStatus'>
<div class='network-status-message'>
<h3>{{ message }}</h3>
<span>{{ instructions }}</span>
</div>
{{ #reconnectDurationAsSeconds }}
<div class="network-status-message">
{{ attemptingReconnectionMessage }}
</div>
{{/reconnectDurationAsSeconds }}
{{ #action }}
<div class="action">
<button class='small blue {{ buttonClass }}'>{{ action }}</button>
</div>
{{/action }}
</script>
<script type='text/x-tmpl-mustache' id='import-flow-template'>
{{#isStep2}}
<div id='step2' class='step'>
@ -459,7 +434,6 @@
<script type="text/javascript" src="test.js"></script>
<script type='text/javascript' src='../js/registration.js' data-cover></script>
<script type="text/javascript" src="../js/expire.js" data-cover></script>
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
<script type="text/javascript" src="../js/database.js" data-cover></script>
<script type="text/javascript" src="../js/storage.js" data-cover></script>
@ -490,7 +464,6 @@
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/inbox_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/network_status_view.js'></script>
<script type='text/javascript' src='../js/views/confirmation_dialog_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
@ -500,7 +473,6 @@
<script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/list_view_test.js"></script>
<script type="text/javascript" src="views/network_status_view_test.js"></script>
<script type="text/javascript" src="models/messages_test.js"></script>

View File

@ -1,180 +0,0 @@
/* global _, $, Whisper */
describe('NetworkStatusView', () => {
describe('getNetworkStatus', () => {
let networkStatusView;
let socketStatus = WebSocket.OPEN;
let oldGetSocketStatus;
/* BEGIN stubbing globals */
before(() => {
oldGetSocketStatus = window.getSocketStatus;
window.getSocketStatus = () => socketStatus;
});
after(() => {
window.getSocketStatus = oldGetSocketStatus;
// It turns out that continued calls to window.getSocketStatus happen
// because we host NetworkStatusView in three mock interfaces, and the view
// checks every N seconds. That results in infinite errors unless there is
// something to call.
window.getSocketStatus = () => WebSocket.OPEN;
});
/* END stubbing globals */
beforeEach(() => {
networkStatusView = new Whisper.NetworkStatusView();
$('.network-status-container').append(networkStatusView.el);
});
afterEach(() => {
// prevents huge number of errors on console after running tests
clearInterval(networkStatusView.renderIntervalHandle);
networkStatusView = null;
});
describe('initialization', () => {
it('should have an empty interval', () => {
assert.equal(
networkStatusView.socketReconnectWaitDuration.asSeconds(),
0
);
});
});
describe('network status with no connection', () => {
beforeEach(() => {
networkStatusView.navigatorOnLine = () => false;
});
it('should be interrupted', () => {
networkStatusView.update();
const status = networkStatusView.getNetworkStatus();
assert(status.hasInterruption);
assert.equal(status.instructions, 'Check your network connection.');
});
it('should display an offline message', () => {
networkStatusView.update();
assert.match(networkStatusView.$el.text(), /Offline/);
});
it('should override socket status', () => {
_([
WebSocket.CONNECTING,
WebSocket.OPEN,
WebSocket.CLOSING,
WebSocket.CLOSED,
]).forEach(socketStatusVal => {
socketStatus = socketStatusVal;
networkStatusView.update();
assert.match(networkStatusView.$el.text(), /Offline/);
});
});
it('should override registration status', () => {
Whisper.Registration.remove();
networkStatusView.update();
assert.match(networkStatusView.$el.text(), /Offline/);
});
});
describe('network status when registration is not done', () => {
beforeEach(() => {
Whisper.Registration.remove();
});
it('should display an unlinked message', () => {
networkStatusView.update();
assert.match(networkStatusView.$el.text(), /Relink/);
});
it('should override socket status', () => {
_([
WebSocket.CONNECTING,
WebSocket.OPEN,
WebSocket.CLOSING,
WebSocket.CLOSED,
]).forEach(socketStatusVal => {
socketStatus = socketStatusVal;
networkStatusView.update();
assert.match(networkStatusView.$el.text(), /Relink/);
});
});
});
describe('network status when registration is done', () => {
beforeEach(() => {
networkStatusView.navigatorOnLine = () => true;
Whisper.Registration.markDone();
networkStatusView.update();
});
it('should not display an unlinked message', () => {
networkStatusView.update();
assert.notMatch(networkStatusView.$el.text(), /Relink/);
});
});
describe('network status when socket is connecting', () => {
beforeEach(() => {
Whisper.Registration.markDone();
socketStatus = WebSocket.CONNECTING;
networkStatusView.update();
});
it('it should display a connecting string if connecting and not in the connecting grace period', () => {
networkStatusView.withinConnectingGracePeriod = false;
networkStatusView.getNetworkStatus();
assert.match(networkStatusView.$el.text(), /Connecting/);
});
it('it should not be interrupted if in connecting grace period', () => {
assert(networkStatusView.withinConnectingGracePeriod);
const status = networkStatusView.getNetworkStatus();
assert.match(networkStatusView.$el.text(), /Connecting/);
assert(!status.hasInterruption);
});
it('it should be interrupted if connecting grace period is over', () => {
networkStatusView.withinConnectingGracePeriod = false;
const status = networkStatusView.getNetworkStatus();
assert(status.hasInterruption);
});
});
describe('network status when socket is open', () => {
before(() => {
socketStatus = WebSocket.OPEN;
});
it('should not be interrupted', () => {
const status = networkStatusView.getNetworkStatus();
assert(!status.hasInterruption);
assert.match(
networkStatusView.$el
.find('.network-status-message')
.text()
.trim(),
/^$/
);
});
});
describe('network status when socket is closed or closing', () => {
_([WebSocket.CLOSED, WebSocket.CLOSING]).forEach(socketStatusVal => {
it('should be interrupted', () => {
socketStatus = socketStatusVal;
networkStatusView.update();
const status = networkStatusView.getNetworkStatus();
assert(status.hasInterruption);
});
});
});
describe('the socket reconnect interval', () => {
beforeEach(() => {
socketStatus = WebSocket.CLOSED;
networkStatusView.setSocketReconnectInterval(61000);
networkStatusView.update();
});
it('should format the message based on the socketReconnectWaitDuration property', () => {
assert.equal(
networkStatusView.socketReconnectWaitDuration.asSeconds(),
61
);
assert.match(
networkStatusView.$('.network-status-message:last').text(),
/Attempting reconnect/
);
});
it('should be reset by changing the socketStatus to CONNECTING', () => {});
});
});
});

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { ExpiredBuildDialog } from './ExpiredBuildDialog';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/ExpiredBuildDialog', module).add(
'ExpiredBuildDialog',
() => {
const hasExpired = boolean('hasExpired', true);
return <ExpiredBuildDialog hasExpired={hasExpired} i18n={i18n} />;
}
);

View File

@ -0,0 +1,34 @@
import React from 'react';
import { LocalizerType } from '../types/Util';
interface PropsType {
hasExpired: boolean;
i18n: LocalizerType;
}
export const ExpiredBuildDialog = ({
hasExpired,
i18n,
}: PropsType): JSX.Element | null => {
if (!hasExpired) {
return null;
}
return (
<div className="module-left-pane-dialog module-left-pane-dialog--error">
{i18n('expiredWarning')}
<div className="module-left-pane-dialog__actions">
<a
className="module-left-pane-dialog__link"
href="https://signal.org/download/"
rel="noreferrer"
tabIndex={-1}
target="_blank"
>
<button className="upgrade">{i18n('upgrade')}</button>
</a>
</div>
</div>
);
};

View File

@ -32,8 +32,11 @@ export interface PropsType {
showInbox: () => void;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: () => JSX.Element;
renderUpdateDialog: () => JSX.Element;
}
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
@ -378,13 +381,22 @@ export class LeftPane extends React.Component<PropsType> {
};
public render(): JSX.Element {
const { renderMainHeader, showArchived } = this.props;
const {
renderExpiredBuildDialog,
renderMainHeader,
renderNetworkStatus,
renderUpdateDialog,
showArchived,
} = this.props;
return (
<div className="module-left-pane">
<div className="module-left-pane__header">
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
</div>
{renderExpiredBuildDialog()}
{renderNetworkStatus()}
{renderUpdateDialog()}
{this.renderList()}
</div>
);

View File

@ -0,0 +1,98 @@
import * as React from 'react';
import { NetworkStatus } from './NetworkStatus';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
hasNetworkDialog: true,
i18n,
isOnline: true,
isRegistrationDone: true,
socketStatus: 0,
relinkDevice: action('relink-device'),
withinConnectingGracePeriod: false,
};
const permutations = [
{
title: 'Connecting',
props: {
socketStatus: 0,
},
},
{
title: 'Closing (online)',
props: {
socketStatus: 2,
},
},
{
title: 'Closed (online)',
props: {
socketStatus: 3,
},
},
{
title: 'Offline',
props: {
isOnline: false,
},
},
{
title: 'Unlinked (online)',
props: {
isRegistrationDone: false,
},
},
{
title: 'Unlinked (offline)',
props: {
isOnline: false,
isRegistrationDone: false,
},
},
];
storiesOf('Components/NetworkStatus', module)
.add('Knobs Playground', () => {
const hasNetworkDialog = boolean('hasNetworkDialog', true);
const isOnline = boolean('isOnline', true);
const isRegistrationDone = boolean('isRegistrationDone', true);
const socketStatus = select(
'socketStatus',
{
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
},
0
);
return (
<NetworkStatus
{...defaultProps}
hasNetworkDialog={hasNetworkDialog}
isOnline={isOnline}
isRegistrationDone={isRegistrationDone}
socketStatus={socketStatus}
/>
);
})
.add('Iterations', () => {
return permutations.map(({ props, title }) => (
<>
<h3>{title}</h3>
<NetworkStatus {...defaultProps} {...props} />
</>
));
});

View File

@ -0,0 +1,83 @@
import React from 'react';
import { LocalizerType } from '../types/Util';
import { NetworkStateType } from '../state/ducks/network';
export interface PropsType extends NetworkStateType {
hasNetworkDialog: boolean;
i18n: LocalizerType;
isRegistrationDone: boolean;
relinkDevice: () => void;
}
type RenderDialogTypes = {
title: string;
subtext: string;
renderActionableButton?: () => JSX.Element;
};
function renderDialog({
title,
subtext,
renderActionableButton,
}: RenderDialogTypes): JSX.Element {
return (
<div className="module-left-pane-dialog module-left-pane-dialog--warning">
<div className="module-left-pane-dialog__message">
<h3>{title}</h3>
<span>{subtext}</span>
</div>
{renderActionableButton && renderActionableButton()}
</div>
);
}
export const NetworkStatus = ({
hasNetworkDialog,
i18n,
isOnline,
isRegistrationDone,
socketStatus,
relinkDevice,
}: PropsType): JSX.Element | null => {
if (!hasNetworkDialog) {
return null;
}
if (!isOnline) {
return renderDialog({
subtext: i18n('checkNetworkConnection'),
title: i18n('offline'),
});
} else if (!isRegistrationDone) {
return renderDialog({
renderActionableButton: (): JSX.Element => (
<div className="module-left-pane-dialog__actions">
<button onClick={relinkDevice}>{i18n('relink')}</button>
</div>
),
subtext: i18n('unlinkedWarning'),
title: i18n('unlinked'),
});
}
let subtext = '';
let title = '';
switch (socketStatus) {
case WebSocket.CONNECTING:
subtext = i18n('connectingHangOn');
title = i18n('connecting');
break;
case WebSocket.CLOSED:
case WebSocket.CLOSING:
default:
title = i18n('disconnected');
subtext = i18n('checkNetworkConnection');
}
return renderDialog({
subtext,
title,
});
};

View File

@ -0,0 +1,73 @@
import * as React from 'react';
import { UpdateDialog } from './UpdateDialog';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
ackRender: action('ack-render'),
dismissDialog: action('dismiss-dialog'),
hasNetworkDialog: false,
i18n,
startUpdate: action('start-update'),
};
const permutations = [
{
title: 'Update',
props: {
dialogType: 1,
},
},
{
title: 'Cannot Update',
props: {
dialogType: 2,
},
},
{
title: 'MacOS Read Only Error',
props: {
dialogType: 3,
},
},
];
storiesOf('Components/UpdateDialog', module)
.add('Knobs Playground', () => {
const dialogType = select(
'dialogType',
{
None: 0,
Update: 1,
Cannot_Update: 2,
MacOS_Read_Only: 3,
},
1
);
const hasNetworkDialog = boolean('hasNetworkDialog', false);
return (
<UpdateDialog
{...defaultProps}
dialogType={dialogType}
hasNetworkDialog={hasNetworkDialog}
/>
);
})
.add('Iterations', () => {
return permutations.map(({ props, title }) => (
<>
<h3>{title}</h3>
<UpdateDialog {...defaultProps} {...props} />
</>
));
});

View File

@ -0,0 +1,135 @@
import React from 'react';
import moment from 'moment';
import { Dialogs } from '../types/Dialogs';
import { Intl } from './Intl';
import { LocalizerType } from '../types/Util';
export interface PropsType {
ackRender: () => void;
dialogType: Dialogs;
dismissDialog: () => void;
hasNetworkDialog: boolean;
i18n: LocalizerType;
startUpdate: () => void;
}
type MaybeMoment = moment.Moment | null;
type ReactSnoozeHook = React.Dispatch<React.SetStateAction<MaybeMoment>>;
const SNOOZE_TIMER = 60 * 1000 * 30;
function handleSnooze(setSnoozeForLater: ReactSnoozeHook) {
setSnoozeForLater(moment().add(SNOOZE_TIMER));
setTimeout(() => {
setSnoozeForLater(moment());
}, SNOOZE_TIMER);
}
function canSnooze(snoozeUntil: MaybeMoment) {
return snoozeUntil === null;
}
function isSnoozed(snoozeUntil: MaybeMoment) {
if (snoozeUntil === null) {
return false;
}
return moment().isBefore(snoozeUntil);
}
export const UpdateDialog = ({
ackRender,
dialogType,
dismissDialog,
hasNetworkDialog,
i18n,
startUpdate,
}: PropsType): JSX.Element | null => {
const [snoozeUntil, setSnoozeForLater] = React.useState<MaybeMoment>(null);
React.useEffect(() => {
ackRender();
});
if (hasNetworkDialog) {
return null;
}
if (dialogType === Dialogs.None || isSnoozed(snoozeUntil)) {
return null;
}
if (dialogType === Dialogs.Cannot_Update) {
return (
<div className="module-left-pane-dialog module-left-pane-dialog--warning">
<div className="module-left-pane-dialog__message">
<h3>{i18n('cannotUpdate')}</h3>
<span>
<Intl
components={[
<a
key="signal-download"
href="https://signal.org/download/"
rel="noreferrer"
target="_blank"
>
https://signal.org/download/
</a>,
]}
i18n={i18n}
id="cannotUpdateDetail"
/>
</span>
</div>
</div>
);
}
if (dialogType === Dialogs.MacOS_Read_Only) {
return (
<div className="module-left-pane-dialog module-left-pane-dialog--warning">
<div className="module-left-pane-dialog__message">
<h3>{i18n('cannotUpdate')}</h3>
<span>
<Intl
components={[
<strong key="app">Signal.app</strong>,
<strong key="folder">/Applications</strong>,
]}
i18n={i18n}
id="readOnlyVolume"
/>
</span>
</div>
<div className="module-left-pane-dialog__actions">
<button onClick={dismissDialog}>{i18n('ok')}</button>
</div>
</div>
);
}
return (
<div className="module-left-pane-dialog">
<div className="module-left-pane-dialog__message">
<h3>{i18n('autoUpdateNewVersionTitle')}</h3>
<span>{i18n('autoUpdateNewVersionMessage')}</span>
</div>
<div className="module-left-pane-dialog__actions">
{canSnooze(snoozeUntil) && (
<button
className="module-left-pane-dialog__button--no-border"
onClick={() => {
handleSnooze(setSnoozeForLater);
}}
>
{i18n('autoUpdateLaterButtonLabel')}
</button>
)}
<button onClick={startUpdate}>
{i18n('autoUpdateRestartButtonLabel')}
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,40 @@
import {
CheckNetworkStatusPayloadType,
NetworkActionType,
} from '../state/ducks/network';
import { getSocketStatus } from '../shims/socketStatus';
type NetworkActions = {
checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType;
closeConnectingGracePeriod: () => NetworkActionType;
};
const REFRESH_INTERVAL = 5000;
interface ShimmedWindow extends Window {
log: {
info: (...args: any) => void;
};
}
const unknownWindow = window as unknown;
const shimmedWindow = unknownWindow as ShimmedWindow;
export function initializeNetworkObserver(networkActions: NetworkActions) {
const { log } = shimmedWindow;
log.info(`Initializing network observer every ${REFRESH_INTERVAL}ms`);
const refresh = () => {
networkActions.checkNetworkStatus({
isOnline: navigator.onLine,
socketStatus: getSocketStatus(),
});
};
window.addEventListener('online', refresh);
window.addEventListener('offline', refresh);
window.setInterval(refresh, REFRESH_INTERVAL);
window.setTimeout(() => {
networkActions.closeConnectingGracePeriod();
}, REFRESH_INTERVAL);
}

View File

@ -0,0 +1,13 @@
import { ipcRenderer } from 'electron';
import { Dialogs } from '../types/Dialogs';
import { ShowUpdateDialogAction } from '../state/ducks/updates';
type UpdatesActions = {
showUpdateDialog: (x: Dialogs) => ShowUpdateDialogAction;
};
export function initializeUpdateListener(updatesActions: UpdatesActions) {
ipcRenderer.on('show-update-dialog', (_, dialogType: Dialogs) => {
updatesActions.showUpdateDialog(dialogType);
});
}

12
ts/shims/socketStatus.ts Normal file
View File

@ -0,0 +1,12 @@
interface ShimmedWindow extends Window {
getSocketStatus: () => number;
}
const unknownWindow = window as unknown;
const shimmedWindow = unknownWindow as ShimmedWindow;
export function getSocketStatus() {
const { getSocketStatus: getMessageReceiverStatus } = shimmedWindow;
return getMessageReceiverStatus();
}

9
ts/shims/updateIpc.ts Normal file
View File

@ -0,0 +1,9 @@
import { ipcRenderer } from 'electron';
export function startUpdate() {
ipcRenderer.send('start-update');
}
export function ackRender() {
ipcRenderer.send('show-update-dialog-ack');
}

View File

@ -1,15 +1,21 @@
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as items } from './ducks/items';
import { actions as network } from './ducks/network';
import { actions as search } from './ducks/search';
import { actions as stickers } from './ducks/stickers';
import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
export const mapDispatchToProps = {
...conversations,
...emojis,
...expiration,
...items,
...network,
...search,
...stickers,
...updates,
...user,
};

View File

@ -0,0 +1,50 @@
// State
export type ExpirationStateType = {
hasExpired: boolean;
};
// Actions
const HYDRATE_EXPIRATION_STATUS = 'expiration/HYDRATE_EXPIRATION_STATUS';
type HyrdateExpirationStatusActionType = {
type: 'expiration/HYDRATE_EXPIRATION_STATUS';
payload: boolean;
};
export type ExpirationActionType = HyrdateExpirationStatusActionType;
// Action Creators
function hydrateExpirationStatus(hasExpired: boolean): ExpirationActionType {
return {
type: HYDRATE_EXPIRATION_STATUS,
payload: hasExpired,
};
}
export const actions = {
hydrateExpirationStatus,
};
// Reducer
function getEmptyState(): ExpirationStateType {
return {
hasExpired: false,
};
}
export function reducer(
state: ExpirationStateType = getEmptyState(),
action: ExpirationActionType
): ExpirationStateType {
if (action.type === HYDRATE_EXPIRATION_STATUS) {
return {
hasExpired: action.payload,
};
}
return state;
}

104
ts/state/ducks/network.ts Normal file
View File

@ -0,0 +1,104 @@
import { SocketStatus } from '../../types/SocketStatus';
import { trigger } from '../../shims/events';
// State
export type NetworkStateType = {
isOnline: boolean;
socketStatus: SocketStatus;
withinConnectingGracePeriod: boolean;
};
// Actions
const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS';
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
const RELINK_DEVICE = 'network/RELINK_DEVICE';
export type CheckNetworkStatusPayloadType = {
isOnline: boolean;
socketStatus: SocketStatus;
};
type CheckNetworkStatusAction = {
type: 'network/CHECK_NETWORK_STATUS';
payload: CheckNetworkStatusPayloadType;
};
type CloseConnectingGracePeriodActionType = {
type: 'network/CLOSE_CONNECTING_GRACE_PERIOD';
};
type RelinkDeviceActionType = {
type: 'network/RELINK_DEVICE';
};
export type NetworkActionType =
| CheckNetworkStatusAction
| CloseConnectingGracePeriodActionType
| RelinkDeviceActionType;
// Action Creators
function checkNetworkStatus(
payload: CheckNetworkStatusPayloadType
): CheckNetworkStatusAction {
return {
type: CHECK_NETWORK_STATUS,
payload,
};
}
function closeConnectingGracePeriod(): CloseConnectingGracePeriodActionType {
return {
type: CLOSE_CONNECTING_GRACE_PERIOD,
};
}
function relinkDevice(): RelinkDeviceActionType {
trigger('setupAsNewDevice');
return {
type: RELINK_DEVICE,
};
}
export const actions = {
checkNetworkStatus,
closeConnectingGracePeriod,
relinkDevice,
};
// Reducer
function getEmptyState(): NetworkStateType {
return {
isOnline: navigator.onLine,
socketStatus: WebSocket.OPEN,
withinConnectingGracePeriod: true,
};
}
export function reducer(
state: NetworkStateType = getEmptyState(),
action: NetworkActionType
): NetworkStateType {
if (action.type === CHECK_NETWORK_STATUS) {
const { isOnline, socketStatus } = action.payload;
return {
...state,
isOnline,
socketStatus,
};
}
if (action.type === CLOSE_CONNECTING_GRACE_PERIOD) {
return {
...state,
withinConnectingGracePeriod: false,
};
}
return state;
}

106
ts/state/ducks/updates.ts Normal file
View File

@ -0,0 +1,106 @@
import { Dialogs } from '../../types/Dialogs';
import * as updateIpc from '../../shims/updateIpc';
// State
export type UpdatesStateType = {
dialogType: Dialogs;
};
// Actions
const ACK_RENDER = 'updates/ACK_RENDER';
const DISMISS_DIALOG = 'updates/DISMISS_DIALOG';
const SHOW_UPDATE_DIALOG = 'updates/SHOW_UPDATE_DIALOG';
const START_UPDATE = 'updates/START_UPDATE';
type AckRenderAction = {
type: 'updates/ACK_RENDER';
};
type DismissDialogAction = {
type: 'updates/DISMISS_DIALOG';
};
export type ShowUpdateDialogAction = {
type: 'updates/SHOW_UPDATE_DIALOG';
payload: Dialogs;
};
type StartUpdateAction = {
type: 'updates/START_UPDATE';
};
export type UpdatesActionType =
| AckRenderAction
| DismissDialogAction
| ShowUpdateDialogAction
| StartUpdateAction;
// Action Creators
function ackRender(): AckRenderAction {
updateIpc.ackRender();
return {
type: ACK_RENDER,
};
}
function dismissDialog(): DismissDialogAction {
return {
type: DISMISS_DIALOG,
};
}
function showUpdateDialog(dialogType: Dialogs): ShowUpdateDialogAction {
return {
type: SHOW_UPDATE_DIALOG,
payload: dialogType,
};
}
function startUpdate(): StartUpdateAction {
updateIpc.startUpdate();
return {
type: START_UPDATE,
};
}
export const actions = {
ackRender,
dismissDialog,
showUpdateDialog,
startUpdate,
};
// Reducer
function getEmptyState(): UpdatesStateType {
return {
dialogType: Dialogs.None,
};
}
export function reducer(
state: UpdatesStateType = getEmptyState(),
action: UpdatesActionType
): UpdatesStateType {
if (action.type === SHOW_UPDATE_DIALOG) {
return {
dialogType: action.payload,
};
}
if (
action.type === DISMISS_DIALOG &&
state.dialogType === Dialogs.MacOS_Read_Only
) {
return {
dialogType: Dialogs.None,
};
}
return state;
}

View File

@ -10,11 +10,21 @@ import {
EmojisStateType,
reducer as emojis,
} from './ducks/emojis';
import {
ExpirationActionType,
ExpirationStateType,
reducer as expiration,
} from './ducks/expiration';
import {
ItemsActionType,
ItemsStateType,
reducer as items,
} from './ducks/items';
import {
NetworkActionType,
NetworkStateType,
reducer as network,
} from './ducks/network';
import {
reducer as search,
SEARCH_TYPES as SearchActionType,
@ -25,30 +35,44 @@ import {
StickersActionType,
StickersStateType,
} from './ducks/stickers';
import {
reducer as updates,
UpdatesActionType,
UpdatesStateType,
} from './ducks/updates';
import { reducer as user, UserStateType } from './ducks/user';
export type StateType = {
conversations: ConversationsStateType;
emojis: EmojisStateType;
expiration: ExpirationStateType;
items: ItemsStateType;
network: NetworkStateType;
search: SearchStateType;
stickers: StickersStateType;
updates: UpdatesStateType;
user: UserStateType;
};
export type ActionsType =
| EmojisActionType
| ExpirationActionType
| ConversationActionType
| ItemsActionType
| NetworkActionType
| StickersActionType
| SearchActionType;
| SearchActionType
| UpdatesActionType;
export const reducers = {
conversations,
emojis,
expiration,
items,
network,
search,
stickers,
updates,
user,
};

View File

@ -0,0 +1,21 @@
import { createSelector } from 'reselect';
import { StateType } from '../reducer';
import { NetworkStateType } from '../ducks/network';
import { isDone } from './registration';
const getNetwork = (state: StateType): NetworkStateType => state.network;
export const hasNetworkDialog = createSelector(
getNetwork,
isDone,
(
{ isOnline, socketStatus, withinConnectingGracePeriod }: NetworkStateType,
isRegistrationDone: boolean
): boolean =>
!isOnline ||
!isRegistrationDone ||
(socketStatus === WebSocket.CONNECTING && !withinConnectingGracePeriod) ||
socketStatus === WebSocket.CLOSED ||
socketStatus === WebSocket.CLOSING
);

View File

@ -0,0 +1,18 @@
import { createSelector } from 'reselect';
import { StateType } from '../reducer';
import { ItemsStateType } from '../ducks/items';
const getItems = (state: StateType): ItemsStateType => state.items;
export const isDone = createSelector(
getItems,
(state: ItemsStateType): boolean => state.chromiumRegistrationDone === ''
);
export const everDone = createSelector(
getItems,
(state: ItemsStateType): boolean =>
state.chromiumRegistrationDoneEver === '' ||
state.chromiumRegistrationDone === ''
);

View File

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ExpiredBuildDialog } from '../../components/ExpiredBuildDialog';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
const mapStateToProps = (state: StateType) => {
return {
hasExpired: state.expiration.hasExpired,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartExpiredBuildDialog = smart(ExpiredBuildDialog);

View File

@ -12,20 +12,35 @@ import {
getShowArchived,
} from '../selectors/conversations';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
import { SmartMainHeader } from './MainHeader';
import { SmartMessageSearchResult } from './MessageSearchResult';
import { SmartNetworkStatus } from './NetworkStatus';
import { SmartUpdateDialog } from './UpdateDialog';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartMainHeader = SmartMainHeader as any;
const FilteredSmartMessageSearchResult = SmartMessageSearchResult as any;
const FilteredSmartNetworkStatus = SmartNetworkStatus as any;
const FilteredSmartUpdateDialog = SmartUpdateDialog as any;
const FilteredSmartExpiredBuildDialog = SmartExpiredBuildDialog as any;
function renderExpiredBuildDialog(): JSX.Element {
return <FilteredSmartExpiredBuildDialog />;
}
function renderMainHeader(): JSX.Element {
return <FilteredSmartMainHeader />;
}
function renderMessageSearchResult(id: string): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} />;
}
function renderUpdateDialog(): JSX.Element {
return <FilteredSmartUpdateDialog />;
}
function renderNetworkStatus(): JSX.Element {
return <FilteredSmartNetworkStatus />;
}
const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state);
@ -40,8 +55,11 @@ const mapStateToProps = (state: StateType) => {
selectedConversationId,
showArchived: getShowArchived(state),
i18n: getIntl(state),
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderUpdateDialog,
};
};

View File

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { NetworkStatus } from '../../components/NetworkStatus';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { hasNetworkDialog } from '../selectors/network';
import { isDone } from '../selectors/registration';
const mapStateToProps = (state: StateType) => {
return {
...state.network,
hasNetworkDialog: hasNetworkDialog(state),
i18n: getIntl(state),
isRegistrationDone: isDone(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartNetworkStatus = smart(NetworkStatus);

View File

@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { UpdateDialog } from '../../components/UpdateDialog';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { hasNetworkDialog } from '../selectors/network';
const mapStateToProps = (state: StateType) => {
return {
...state.updates,
hasNetworkDialog: hasNetworkDialog(state),
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartUpdateDialog = smart(UpdateDialog);

6
ts/types/Dialogs.ts Normal file
View File

@ -0,0 +1,6 @@
export enum Dialogs {
None,
Update,
Cannot_Update,
MacOS_Read_Only,
}

8
ts/types/SocketStatus.ts Normal file
View File

@ -0,0 +1,8 @@
// Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
// which are returned by libtextsecure's MessageReceiver
export enum SocketStatus {
CONNECTING,
OPEN,
CLOSING,
CLOSED,
}

View File

@ -18,9 +18,10 @@ import { v4 as getGuid } from 'uuid';
import pify from 'pify';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { app, BrowserWindow, dialog } from 'electron';
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import { getTempPath } from '../../app/attachments';
import { Dialogs } from '../types/Dialogs';
// @ts-ignore
import * as packageJson from '../../package.json';
@ -49,6 +50,8 @@ const mkdirpPromise = pify(mkdirp);
const rimrafPromise = pify(rimraf);
const { platform } = process;
export const ACK_RENDER_TIMEOUT = 10000;
export async function checkForUpdates(
logger: LoggerType
): Promise<{
@ -141,10 +144,10 @@ export async function downloadUpdate(
}
}
export async function showUpdateDialog(
async function showFallbackUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): Promise<boolean> {
) {
const RESTART_BUTTON = 0;
const LATER_BUTTON = 1;
const options = {
@ -165,10 +168,32 @@ export async function showUpdateDialog(
return response === RESTART_BUTTON;
}
export async function showCannotUpdateDialog(
export function showUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType,
performUpdateCallback: () => void
): void {
let ack = false;
ipcMain.once('start-update', performUpdateCallback);
ipcMain.once('show-update-dialog-ack', () => {
ack = true;
});
mainWindow.webContents.send('show-update-dialog', Dialogs.Update);
setTimeout(async () => {
if (!ack) {
await showFallbackUpdateDialog(mainWindow, messages);
}
}, ACK_RENDER_TIMEOUT);
}
async function showFallbackCannotUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): Promise<any> {
) {
const options = {
type: 'error',
buttons: [messages.ok.message],
@ -179,6 +204,25 @@ export async function showCannotUpdateDialog(
await dialog.showMessageBox(mainWindow, options);
}
export function showCannotUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): void {
let ack = false;
ipcMain.once('show-update-dialog-ack', () => {
ack = true;
});
mainWindow.webContents.send('show-update-dialog', Dialogs.Cannot_Update);
setTimeout(async () => {
if (!ack) {
await showFallbackCannotUpdateDialog(mainWindow, messages);
}
}, ACK_RENDER_TIMEOUT);
}
// Helper functions
export function getUpdateCheckUrl(): string {

View File

@ -4,12 +4,13 @@ import { AddressInfo } from 'net';
import { dirname } from 'path';
import { v4 as getGuid } from 'uuid';
import { app, autoUpdater, BrowserWindow, dialog } from 'electron';
import { app, autoUpdater, BrowserWindow, dialog, ipcMain } from 'electron';
import { get as getFromConfig } from 'config';
import { gt } from 'semver';
import got from 'got';
import {
ACK_RENDER_TIMEOUT,
checkForUpdates,
deleteTempDir,
downloadUpdate,
@ -21,6 +22,7 @@ import {
} from './common';
import { hexToBinary, verifySignature } from './signature';
import { markShouldQuit } from '../../app/window_state';
import { Dialogs } from '../types/Dialogs';
let isChecking = false;
const SECOND = 1000;
@ -96,12 +98,12 @@ async function checkDownloadAndInstall(
const message: string = error.message || '';
if (message.includes(readOnly)) {
logger.info('checkDownloadAndInstall: showing read-only dialog...');
await showReadOnlyDialog(getMainWindow(), messages);
showReadOnlyDialog(getMainWindow(), messages);
} else {
logger.info(
'checkDownloadAndInstall: showing general update failure dialog...'
);
await showCannotUpdateDialog(getMainWindow(), messages);
showCannotUpdateDialog(getMainWindow(), messages);
}
throw error;
@ -111,14 +113,12 @@ async function checkDownloadAndInstall(
// because Squirrel has cached the update file and will do the right thing.
logger.info('checkDownloadAndInstall: showing update dialog...');
const shouldUpdate = await showUpdateDialog(getMainWindow(), messages);
if (!shouldUpdate) {
return;
}
logger.info('checkDownloadAndInstall: calling quitAndInstall...');
markShouldQuit();
autoUpdater.quitAndInstall();
showUpdateDialog(getMainWindow(), messages, () => {
logger.info('checkDownloadAndInstall: calling quitAndInstall...');
markShouldQuit();
autoUpdater.quitAndInstall();
});
} catch (error) {
logger.error('checkDownloadAndInstall: error', getPrintableError(error));
} finally {
@ -339,10 +339,29 @@ function shutdown(
}
}
export async function showReadOnlyDialog(
export function showReadOnlyDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): Promise<void> {
): void {
let ack = false;
ipcMain.once('show-update-dialog-ack', () => {
ack = true;
});
mainWindow.webContents.send('show-update-dialog', Dialogs.MacOS_Read_Only);
setTimeout(async () => {
if (!ack) {
await showFallbackReadOnlyDialog(mainWindow, messages);
}
}, ACK_RENDER_TIMEOUT);
}
async function showFallbackReadOnlyDialog(
mainWindow: BrowserWindow,
messages: MessagesType
) {
const options = {
type: 'warning',
buttons: [messages.ok.message],

View File

@ -93,25 +93,22 @@ async function checkDownloadAndInstall(
}
logger.info('checkDownloadAndInstall: showing dialog...');
const shouldUpdate = await showUpdateDialog(getMainWindow(), messages);
if (!shouldUpdate) {
return;
}
showUpdateDialog(getMainWindow(), messages, async () => {
try {
await verifyAndInstall(updateFilePath, version, logger);
installing = true;
} catch (error) {
logger.info(
'checkDownloadAndInstall: showing general update failure dialog...'
);
showCannotUpdateDialog(getMainWindow(), messages);
try {
await verifyAndInstall(updateFilePath, version, logger);
installing = true;
} catch (error) {
logger.info(
'checkDownloadAndInstall: showing general update failure dialog...'
);
await showCannotUpdateDialog(getMainWindow(), messages);
throw error;
}
throw error;
}
markShouldQuit();
app.quit();
markShouldQuit();
app.quit();
});
} catch (error) {
logger.error('checkDownloadAndInstall: error', getPrintableError(error));
} finally {

47
ts/util/hasExpired.ts Normal file
View File

@ -0,0 +1,47 @@
interface ShimmedWindow extends Window {
getExpiration: () => string;
log: {
info: (...args: any) => void;
error: (...args: any) => void;
};
}
const unknownWindow = window as unknown;
const shimmedWindow = unknownWindow as ShimmedWindow;
// @ts-ignore
const env = window.getEnvironment();
const NINETY_ONE_DAYS = 86400 * 91 * 1000;
export function hasExpired() {
const { getExpiration, log } = shimmedWindow;
let buildExpiration = 0;
try {
buildExpiration = parseInt(getExpiration(), 10);
if (buildExpiration) {
log.info('Build expires: ', new Date(buildExpiration).toISOString());
}
} catch (e) {
log.error('Error retrieving build expiration date', e.stack);
return true;
}
const tooFarIntoFuture = Date.now() + NINETY_ONE_DAYS < buildExpiration;
if (tooFarIntoFuture) {
log.error(
'Build expiration is set too far into the future',
buildExpiration
);
}
if (env === 'production') {
return Date.now() > buildExpiration && tooFarIntoFuture;
}
return buildExpiration && Date.now() > buildExpiration;
}

View File

@ -1,12 +1,14 @@
import * as GoogleChrome from './GoogleChrome';
import * as Registration from './registration';
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
import { combineNames } from './combineNames';
import { createBatcher } from './batcher';
import { createWaitBatcher } from './waitBatcher';
import { hasExpired } from './hasExpired';
import { isFileDangerous } from './isFileDangerous';
import { missingCaseError } from './missingCaseError';
import { migrateColor } from './migrateColor';
import { makeLookup } from './makeLookup';
import { migrateColor } from './migrateColor';
import { missingCaseError } from './missingCaseError';
export {
arrayBufferToObjectURL,
@ -14,8 +16,10 @@ export {
createBatcher,
createWaitBatcher,
GoogleChrome,
hasExpired,
isFileDangerous,
makeLookup,
migrateColor,
missingCaseError,
Registration,
};

View File

@ -309,7 +309,7 @@
"rule": "DOM-innerHTML",
"path": "js/views/app_view.js",
"line": " this.el.innerHTML = '';",
"lineNumber": 55,
"lineNumber": 54,
"reasonCategory": "usageTrusted",
"updated": "2018-09-15T00:38:04.183Z",
"reasonDetail": "Hard-coded string"
@ -318,7 +318,7 @@
"rule": "jQuery-append(",
"path": "js/views/app_view.js",
"line": " this.el.append(view.el);",
"lineNumber": 56,
"lineNumber": 55,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
@ -327,7 +327,7 @@
"rule": "jQuery-appendTo(",
"path": "js/views/app_view.js",
"line": " this.debugLogView.$el.appendTo(this.el);",
"lineNumber": 62,
"lineNumber": 61,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
@ -461,7 +461,7 @@
"rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js",
"line": " view.$el.appendTo(this.el);",
"lineNumber": 35,
"lineNumber": 33,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -470,7 +470,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.message').text(message);",
"lineNumber": 69,
"lineNumber": 67,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -479,7 +479,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " el: this.$('.conversation-stack'),",
"lineNumber": 85,
"lineNumber": 83,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -488,25 +488,7 @@
"rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js",
"line": " this.appLoadingScreen.$el.prependTo(this.el);",
"lineNumber": 92,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " .append(this.networkStatusView.render().el);",
"lineNumber": 109,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js",
"line": " banner.$el.prependTo(this.$el);",
"lineNumber": 113,
"lineNumber": 90,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -515,7 +497,7 @@
"rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 119,
"lineNumber": 98,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -524,7 +506,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 139,
"lineNumber": 118,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -533,7 +515,7 @@
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 139,
"lineNumber": 118,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -542,7 +524,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.placeholder').length) {",
"lineNumber": 189,
"lineNumber": 168,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -551,7 +533,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('#header, .gutter').addClass('inactive');",
"lineNumber": 193,
"lineNumber": 172,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -560,7 +542,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation-stack').addClass('inactive');",
"lineNumber": 197,
"lineNumber": 176,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -569,7 +551,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .menu').trigger('close');",
"lineNumber": 199,
"lineNumber": 178,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -578,7 +560,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 219,
"lineNumber": 198,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Known DOM elements"
@ -587,7 +569,7 @@
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 222,
"lineNumber": 201,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"reasonDetail": "Hardcoded selector"
@ -9323,4 +9305,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-02-07T19:52:28.522Z"
}
]
]

27
ts/util/registration.ts Normal file
View File

@ -0,0 +1,27 @@
import * as RegistrationSelectors from '../state/selectors/registration';
export function markEverDone() {
// @ts-ignore
window.storage.put('chromiumRegistrationDoneEver', '');
}
export function markDone() {
markEverDone();
// @ts-ignore
window.storage.put('chromiumRegistrationDone', '');
}
export function remove() {
// @ts-ignore
window.storage.remove('chromiumRegistrationDone');
}
export function isDone() {
// @ts-ignore
return RegistrationSelectors.isDone(window.reduxStore.getState());
}
export function everDone() {
// @ts-ignore
return RegistrationSelectors.everDone(window.reduxStore.getState());
}