New design for import/install, 'light' import (#2053)

- A new design for the import flow. It features:
  - Icons at the top of every screen
  - Gray background, blue buttons, thinner text
  - Simpler copy
- A new design for the install flow. It features:
  - Immediate entry into the QR code screen
  - Animated dots to show that we're loading the QR code from the server
  - Fewer screens: 1) QR 2) device name 3) sync-in-progress
- When not set up, the app opens directly into the install screen, which has been streamlined. The `--import` command-line argument will cause the app to open directly into the import flow.
- Support for two different flavors of builds - the normal build will open into the standard registration flow, and the import flavor will be exactly the same except during setup it will open directly into the import flow.
- A new design for the (dev-only) standalone registration view
- When these install sequences are active, the OS File menu has entries to allow you to switch the method of setup you'd like to use. These go away as soon as the first step is taken in any of these flows.
- The device name (chosen on initial setup) is now shown in the settings panel
- At the end of a light import, we hand off to the normal device link screen, starting at the QR code. On a full import, we remove the sensitive encryption information in the export to prevent conflicts on multiple imports.
- `Whisper.Backup.exportToDirectory()` takes an options object so you can tell it to do a light export.
- `Whisper.Backup.importFromDirectory()` takes an options object so you can force it to load only the light components found on disk. It also returns an object so you can tell whether a given import was a full import or light import.
- On start of import, we build a list of all the ids present in the messages, conversations, and groups stores in IndexedDB. This can take some time if a lot of data is in the database already, but it makes the subsequent deduplicated import very fast.
- Disappearing messages are now excluded when exporting
- Remove some TODOs in the tests
This commit is contained in:
Scott Nonnenberg 2018-02-22 10:40:32 -08:00 committed by GitHub
parent a1ac810343
commit 426dab85a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1228 additions and 835 deletions

View file

@ -107,6 +107,27 @@
}
});
Whisper.events.on('setupWithImport', function() {
var appView = window.owsDesktopApp.appView;
if (appView) {
appView.openImporter();
}
});
Whisper.events.on('setupAsNewDevice', function() {
var appView = window.owsDesktopApp.appView;
if (appView) {
appView.openInstaller();
}
});
Whisper.events.on('setupAsStandalone', function() {
var appView = window.owsDesktopApp.appView;
if (appView) {
appView.openStandalone();
}
});
function start() {
var currentVersion = window.config.version;
var lastVersion = storage.get('version');
@ -140,8 +161,10 @@
appView.openInbox({
initialLoadComplete: initialLoadComplete
});
} else if (window.config.importMode) {
appView.openImporter();
} else {
appView.openInstallChoice();
appView.openInstaller();
}
Whisper.events.on('showDebugLog', function() {
@ -158,12 +181,6 @@
appView.openInbox();
}
});
Whisper.events.on('contactsync:begin', function() {
if (appView.installView && appView.installView.showSync) {
appView.installView.showSync();
}
});
Whisper.Notifications.on('click', function(conversation) {
showWindow();
if (conversation) {

View file

@ -75,9 +75,9 @@
};
}
function exportNonMessages(idb_db, parent) {
function exportNonMessages(idb_db, parent, options) {
return createFileAndWriter(parent, 'db.json').then(function(writer) {
return exportToJsonFile(idb_db, writer);
return exportToJsonFile(idb_db, writer, options);
});
}
@ -85,10 +85,27 @@
* Export all data from an IndexedDB database
* @param {IDBDatabase} idb_db
*/
function exportToJsonFile(idb_db, fileWriter) {
function exportToJsonFile(idb_db, fileWriter, options) {
options = options || {};
_.defaults(options, {excludeClientConfig: false});
return new Promise(function(resolve, reject) {
var storeNames = idb_db.objectStoreNames;
storeNames = _.without(storeNames, 'messages');
if (options.excludeClientConfig) {
console.log('exportToJsonFile: excluding client config from export');
storeNames = _.without(
storeNames,
'items',
'signedPreKeys',
'preKeys',
'identityKeys',
'sessions',
'unprocessed' // since we won't be able to decrypt them anyway
);
}
var exportedStoreNames = [];
if (storeNames.length === 0) {
throw new Error('No stores to export');
@ -160,9 +177,10 @@
});
}
function importNonMessages(idb_db, parent) {
return readFileAsText(parent, 'db.json').then(function(string) {
return importFromJsonString(idb_db, string);
function importNonMessages(idb_db, parent, options) {
var file = 'db.json';
return readFileAsText(parent, file).then(function(string) {
return importFromJsonString(idb_db, string, path.join(parent, file), options);
});
}
@ -176,6 +194,16 @@
reject(error || new Error(prefix));
}
function eliminateClientConfigInBackup(data, path) {
var cleaned = _.pick(data, 'conversations', 'groups');
console.log('Writing configuration-free backup file back to disk');
try {
fs.writeFileSync(path, JSON.stringify(cleaned));
} catch (error) {
console.log('Error writing cleaned-up backup to disk: ', error.stack);
}
}
/**
* Import data from JSON into an IndexedDB database. This does not delete any existing data
* from the database, so keys could clash
@ -183,19 +211,50 @@
* @param {IDBDatabase} idb_db
* @param {string} jsonString - data to import, one key per object store
*/
function importFromJsonString(idb_db, jsonString) {
function importFromJsonString(idb_db, jsonString, path, options) {
options = options || {};
_.defaults(options, {
forceLightImport: false,
conversationLookup: {},
groupLookup: {},
});
var conversationLookup = options.conversationLookup;
var groupLookup = options.groupLookup;
var result = {
fullImport: true,
};
return new Promise(function(resolve, reject) {
var importObject = JSON.parse(jsonString);
delete importObject.debug;
var storeNames = _.keys(importObject);
if (!importObject.sessions || options.forceLightImport) {
result.fullImport = false;
delete importObject.items;
delete importObject.signedPreKeys;
delete importObject.preKeys;
delete importObject.identityKeys;
delete importObject.sessions;
delete importObject.unprocessed;
console.log('This is a light import; contacts, groups and messages only');
}
// We mutate the on-disk backup to prevent the user from importing client
// configuration more than once - that causes lots of encryption errors.
// This of course preserves the true data: conversations and groups.
eliminateClientConfigInBackup(importObject, path);
var storeNames = _.keys(importObject);
console.log('Importing to these stores:', storeNames.join(', '));
var finished = false;
var finish = function(via) {
console.log('non-messages import done via', via);
if (finished) {
resolve();
resolve(result);
}
finished = true;
};
@ -219,20 +278,46 @@
}
var count = 0;
var skipCount = 0;
var finishStore = function() {
// added all objects for this store
delete importObject[storeName];
console.log(
'Done importing to store',
storeName,
'Total count:',
count,
'Skipped:',
skipCount
);
if (_.keys(importObject).length === 0) {
// added all object stores
console.log('DB import complete');
finish('puts scheduled');
}
};
_.each(importObject[storeName], function(toAdd) {
toAdd = unstringify(toAdd);
var haveConversationAlready =
storeName === 'conversations'
&& conversationLookup[getConversationKey(toAdd)];
var haveGroupAlready =
storeName === 'groups' && groupLookup[getGroupKey(toAdd)];
if (haveConversationAlready || haveGroupAlready) {
skipCount++;
count++;
return;
}
var request = transaction.objectStore(storeName).put(toAdd, toAdd.id);
request.onsuccess = function(event) {
count++;
if (count == importObject[storeName].length) {
// added all objects for this store
delete importObject[storeName];
console.log('Done importing to store', storeName);
if (_.keys(importObject).length === 0) {
// added all object stores
console.log('DB import complete');
finish('puts scheduled');
}
finishStore();
}
};
request.onerror = function(e) {
@ -243,6 +328,12 @@
);
};
});
// We have to check here, because we may have skipped every item, resulting
// in no onsuccess callback at all.
if (count === importObject[storeName].length) {
finishStore();
}
});
});
}
@ -432,14 +523,20 @@
request.onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
if (count !== 0) {
stream.write(',');
}
var message = cursor.value;
var messageId = message.received_at;
var attachments = message.attachments;
// skip message if it is disappearing, no matter the amount of time left
if (message.expireTimer) {
cursor.continue();
return;
}
if (count !== 0) {
stream.write(',');
}
message.attachments = _.map(attachments, function(attachment) {
return _.omit(attachment, ['data']);
});
@ -598,6 +695,10 @@
}));
}
function saveMessage(idb_db, message) {
return saveAllMessages(idb_db, [message]);
}
function saveAllMessages(idb_db, messages) {
if (!messages.length) {
return Promise.resolve();
@ -658,43 +759,64 @@
// message, save it, and only then do we move on to the next message. Thus, every
// message with attachments needs to be removed from our overall message save with the
// filter() call.
function importConversation(idb_db, dir) {
function importConversation(idb_db, dir, options) {
options = options || {};
_.defaults(options, {messageLookup: {}});
var messageLookup = options.messageLookup;
var conversationId = 'unknown';
var total = 0;
var skipped = 0;
return readFileAsText(dir, 'messages.json').then(function(contents) {
var promiseChain = Promise.resolve();
var json = JSON.parse(contents);
var conversationId;
if (json.messages && json.messages.length) {
conversationId = json.messages[0].conversationId;
conversationId = '[REDACTED]' + (json.messages[0].conversationId || '').slice(-3);
}
total = json.messages.length;
var messages = _.filter(json.messages, function(message) {
message = unstringify(message);
if (messageLookup[getMessageKey(message)]) {
skipped++;
return false;
}
if (message.attachments && message.attachments.length) {
var process = function() {
return loadAttachments(dir, message).then(function() {
return saveAllMessages(idb_db, [message]);
return saveMessage(idb_db, message);
});
};
promiseChain = promiseChain.then(process);
return null;
return false;
}
return message;
return true;
});
return saveAllMessages(idb_db, messages)
var promise = Promise.resolve();
if (messages.length > 0) {
promise = saveAllMessages(idb_db, messages);
}
return promise
.then(function() {
return promiseChain;
})
.then(function() {
console.log(
'Finished importing conversation',
// Don't know if group or private conversation, so we blindly redact
conversationId ? '[REDACTED]' + conversationId.slice(-3) : 'with no messages'
conversationId,
'Total:',
total,
'Skipped:',
skipped
);
});
@ -703,7 +825,7 @@
});
}
function importConversations(idb_db, dir) {
function importConversations(idb_db, dir, options) {
return getDirContents(dir).then(function(contents) {
var promiseChain = Promise.resolve();
@ -713,7 +835,7 @@
}
var process = function() {
return importConversation(idb_db, conversationDir);
return importConversation(idb_db, conversationDir, options);
};
promiseChain = promiseChain.then(process);
@ -723,6 +845,73 @@
});
}
function getMessageKey(message) {
var ourNumber = textsecure.storage.user.getNumber();
var source = message.source || ourNumber;
if (source === ourNumber) {
return source + ' ' + message.timestamp;
}
var sourceDevice = message.sourceDevice || 1;
return source + '.' + sourceDevice + ' ' + message.timestamp;
}
function loadMessagesLookup(idb_db) {
return assembleLookup(idb_db, 'messages', getMessageKey);
}
function getConversationKey(conversation) {
return conversation.id;
}
function loadConversationLookup(idb_db) {
return assembleLookup(idb_db, 'conversations', getConversationKey);
}
function getGroupKey(group) {
return group.id;
}
function loadGroupsLookup(idb_db) {
return assembleLookup(idb_db, 'groups', getGroupKey);
}
function assembleLookup(idb_db, storeName, keyFunction) {
var lookup = Object.create(null);
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction(storeName, 'readwrite');
transaction.onerror = function(e) {
handleDOMException(
'assembleLookup(' + storeName + ') transaction error',
transaction.error,
reject
);
};
transaction.oncomplete = function() {
// not really very useful - fires at unexpected times
};
var promiseChain = Promise.resolve();
var store = transaction.objectStore(storeName);
var request = store.openCursor();
request.onerror = function(e) {
handleDOMException(
'assembleLookup(' + storeName + ') request error',
request.error,
reject
);
};
request.onsuccess = function(event) {
var cursor = event.target.result;
if (cursor && cursor.value) {
lookup[keyFunction(cursor.value)] = true;
cursor.continue();
} else {
console.log('Done creating ' + storeName + ' lookup');
return resolve(lookup);
}
};
});
}
function clearAllStores(idb_db) {
return new Promise(function(resolve, reject) {
console.log('Clearing all indexeddb stores');
@ -791,7 +980,7 @@
};
return getDirectory(options);
},
backupToDirectory: function(directory) {
exportToDirectory: function(directory, options) {
var dir;
var idb;
return openDatabase().then(function(idb_db) {
@ -800,7 +989,7 @@
return createDirectory(directory, name);
}).then(function(created) {
dir = created;
return exportNonMessages(idb, dir);
return exportNonMessages(idb, dir, options);
}).then(function() {
return exportConversations(idb, dir);
}).then(function() {
@ -823,18 +1012,30 @@
};
return getDirectory(options);
},
importFromDirectory: function(directory) {
var idb;
importFromDirectory: function(directory, options) {
options = options || {};
var idb, nonMessageResult;
return openDatabase().then(function(idb_db) {
idb = idb_db;
return importNonMessages(idb_db, directory);
return Promise.all([
loadMessagesLookup(idb_db),
loadConversationLookup(idb_db),
loadGroupsLookup(idb_db),
]);
}).then(function(lookups) {
options.messageLookup = lookups[0];
options.conversationLookup = lookups[1];
options.groupLookup = lookups[2];
}).then(function() {
return importConversations(idb, directory);
return importNonMessages(idb, directory, options);
}).then(function(result) {
nonMessageResult = result;
return importConversations(idb, directory, options);
}).then(function() {
return directory;
}).then(function(path) {
console.log('done restoring from backup!');
return path;
return nonMessageResult;
}, function(error) {
console.log(
'the import went wrong:',

View file

@ -7,12 +7,12 @@
initialize: function(options) {
this.inboxView = null;
this.installView = null;
this.applyTheme();
this.applyHideMenu();
},
events: {
'click .openInstaller': 'openInstaller',
'click .openStandalone': 'openStandalone',
'click .openInstaller': 'openInstaller', // NetworkStatusView has this button
'openInbox': 'openInbox',
'change-theme': 'applyTheme',
'change-hide-menu': 'applyHideMenu',
@ -45,39 +45,29 @@
this.debugLogView = null;
}
},
openInstallChoice: function() {
this.closeInstallChoice();
var installChoice = this.installChoice = new Whisper.InstallChoiceView();
this.listenTo(installChoice, 'install-new', this.openInstaller.bind(this));
this.listenTo(installChoice, 'install-import', this.openImporter.bind(this));
this.openView(this.installChoice);
},
closeInstallChoice: function() {
if (this.installChoice) {
this.installChoice.remove();
this.installChoice = null;
}
},
openImporter: function() {
this.closeImporter();
this.closeInstallChoice();
window.addSetupMenuItems();
this.resetViews();
var importView = this.importView = new Whisper.ImportView();
this.listenTo(importView, 'cancel', this.openInstallChoice.bind(this));
this.listenTo(importView, 'light-import', this.finishLightImport.bind(this));
this.openView(this.importView);
},
finishLightImport: function() {
var options = {
startStep: Whisper.InstallView.Steps.SCAN_QR_CODE,
};
this.openInstaller(options);
},
closeImporter: function() {
if (this.importView) {
this.importView.remove();
this.importView = null;
}
},
openInstaller: function() {
this.closeInstaller();
this.closeInstallChoice();
var installView = this.installView = new Whisper.InstallView();
this.listenTo(installView, 'cancel', this.openInstallChoice.bind(this));
openInstaller: function(options) {
window.addSetupMenuItems();
this.resetViews();
var installView = this.installView = new Whisper.InstallView(options);
this.openView(this.installView);
},
closeInstaller: function() {
@ -88,11 +78,23 @@
},
openStandalone: function() {
if (window.config.environment !== 'production') {
this.closeInstaller();
this.installView = new Whisper.StandaloneRegistrationView();
this.openView(this.installView);
window.addSetupMenuItems();
this.resetViews();
this.standaloneView = new Whisper.StandaloneRegistrationView();
this.openView(this.standaloneView);
}
},
closeStandalone: function() {
if (this.standaloneView) {
this.standaloneView.remove();
this.standaloneView = null;
}
},
resetViews: function() {
this.closeInstaller();
this.closeImporter();
this.closeStandalone();
},
openInbox: function(options) {
options = options || {};
// The inbox can be created before the 'empty' event fires or afterwards. If

View file

@ -7,7 +7,8 @@
var State = {
IMPORTING: 1,
COMPLETE: 2
COMPLETE: 2,
LIGHT_COMPLETE: 3,
};
var IMPORT_STARTED = 'importStarted';
@ -39,12 +40,13 @@
};
Whisper.ImportView = Whisper.View.extend({
templateName: 'app-migration-screen',
className: 'app-loading-screen',
templateName: 'import-flow-template',
className: 'full-screen-flow',
events: {
'click .import': 'onImport',
'click .choose': 'onImport',
'click .restart': 'onRestart',
'click .cancel': 'onCancel',
'click .register': 'onRegister',
},
initialize: function() {
if (Whisper.Import.isIncomplete()) {
@ -55,41 +57,42 @@
this.pending = Promise.resolve();
},
render_attributes: function() {
var message;
var importButton;
var hideProgress = true;
var restartButton;
var cancelButton;
if (this.error) {
return {
message: i18n('importError'),
hideProgress: true,
importButton: i18n('tryAgain'),
isError: true,
errorHeader: i18n('importErrorHeader'),
errorMessage: i18n('importError'),
chooseButton: i18n('importAgain'),
};
}
switch (this.state) {
case State.COMPLETE:
message = i18n('importComplete');
restartButton = i18n('restartSignal');
break;
case State.IMPORTING:
message = i18n('importing');
hideProgress = false;
break;
default:
message = i18n('importInstructions');
importButton = i18n('chooseDirectory');
cancelButton = i18n('cancel');
var restartButton = i18n('importCompleteStartButton');
var registerButton = i18n('importCompleteLinkButton');
var step = 'step2';
if (this.state === State.IMPORTING) {
step = 'step3';
} else if (this.state === State.COMPLETE) {
registerButton = null;
step = 'step4';
} else if (this.state === State.LIGHT_COMPLETE) {
restartButton = null;
step = 'step4';
}
return {
hideProgress: hideProgress,
message: message,
importButton: importButton,
isStep2: step === 'step2',
chooseHeader: i18n('loadDataHeader'),
choose: i18n('loadDataDescription'),
chooseButton: i18n('chooseDirectory'),
isStep3: step === 'step3',
importingHeader: i18n('importingHeader'),
isStep4: step === 'step4',
completeHeader: i18n('importCompleteHeader'),
restartButton: restartButton,
cancelButton: cancelButton,
registerButton: registerButton,
};
},
onRestart: function() {
@ -110,9 +113,16 @@
}
});
},
doImport: function(directory) {
this.error = null;
onRegister: function() {
// AppView listens for this, and opens up InstallView to the QR code step to
// finish setting this device up.
this.trigger('light-import');
},
doImport: function(directory) {
window.removeSetupMenuItems();
this.error = null;
this.state = State.IMPORTING;
this.render();
@ -125,25 +135,17 @@
Whisper.Import.start(),
Whisper.Backup.importFromDirectory(directory)
]);
}).then(function() {
// Catching in-memory cache up with what's in indexeddb now...
// NOTE: this fires storage.onready, listened to across the app. We'll restart
// to complete the install to start up cleanly with everything now in the DB.
return storage.fetch();
}).then(function() {
return Promise.all([
// Clearing any migration-related state inherited from the Chrome App
storage.remove('migrationState'),
storage.remove('migrationEnabled'),
storage.remove('migrationEverCompleted'),
storage.remove('migrationStorageLocation'),
}).then(function(results) {
var importResult = results[1];
Whisper.Import.saveLocation(directory),
Whisper.Import.complete()
]);
}).then(function() {
this.state = State.COMPLETE;
this.render();
// A full import changes so much we need a restart of the app
if (importResult.fullImport) {
return this.finishFullImport(directory);
}
// A light import just brings in contacts, groups, and messages. And we need a
// normal link to finish the process.
return this.finishLightImport(directory);
}.bind(this)).catch(function(error) {
console.log('Error importing:', error && error.stack ? error.stack : error);
@ -153,6 +155,40 @@
return Whisper.Backup.clearDatabase();
}.bind(this));
},
finishLightImport: function(directory) {
ConversationController.reset();
return ConversationController.load().then(function() {
return Promise.all([
Whisper.Import.saveLocation(directory),
Whisper.Import.complete(),
]);
}).then(function() {
this.state = State.LIGHT_COMPLETE;
this.render();
}.bind(this));
},
finishFullImport: function(directory) {
// Catching in-memory cache up with what's in indexeddb now...
// NOTE: this fires storage.onready, listened to across the app. We'll restart
// to complete the install to start up cleanly with everything now in the DB.
return storage.fetch()
.then(function() {
return Promise.all([
// Clearing any migration-related state inherited from the Chrome App
storage.remove('migrationState'),
storage.remove('migrationEnabled'),
storage.remove('migrationEverCompleted'),
storage.remove('migrationStorageLocation'),
Whisper.Import.saveLocation(directory),
Whisper.Import.complete()
]);
}).then(function() {
this.state = State.COMPLETE;
this.render();
}.bind(this));
}
});
})();

View file

@ -1,31 +0,0 @@
/*
* vim: ts=4:sw=4:expandtab
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.InstallChoiceView = Whisper.View.extend({
templateName: 'install-choice',
className: 'install install-choice',
events: {
'click .new': 'onClickNew',
'click .import': 'onClickImport'
},
initialize: function() {
this.render();
},
render_attributes: {
installWelcome: i18n('installWelcome'),
installTagline: i18n('installTagline'),
installNew: i18n('installNew'),
installImport: i18n('installImport')
},
onClickNew: function() {
this.trigger('install-new');
},
onClickImport: function() {
this.trigger('install-import');
}
});
})();

View file

@ -14,145 +14,159 @@
NETWORK_ERROR: 'NetworkError',
};
var DEVICE_NAME_SELECTOR = 'input.device-name';
var CONNECTION_ERROR = -1;
var TOO_MANY_DEVICES = 411;
Whisper.InstallView = Whisper.View.extend({
templateName: 'install_flow_template',
className: 'main install',
render_attributes: function() {
var twitterHref = 'https://twitter.com/signalapp';
var signalHref = 'https://signal.org/install';
return {
installWelcome: i18n('installWelcome'),
installTagline: i18n('installTagline'),
installGetStartedButton: i18n('installGetStartedButton'),
installSignalLink: this.i18n_with_links('installSignalLink', signalHref),
installIHaveSignalButton: i18n('installGotIt'),
installFollowUs: this.i18n_with_links('installFollowUs', twitterHref),
installAndroidInstructions: i18n('installAndroidInstructions'),
installLinkingWithNumber: i18n('installLinkingWithNumber'),
installComputerName: i18n('installComputerName'),
installFinalButton: i18n('installFinalButton'),
installTooManyDevices: i18n('installTooManyDevices'),
installConnectionFailed: i18n('installConnectionFailed'),
ok: i18n('ok'),
tryAgain: i18n('tryAgain'),
development: window.config.environment === 'development'
};
templateName: 'link-flow-template',
className: 'main full-screen-flow',
events: {
'click .try-again': 'connect',
// handler for finish button is in confirmNumber()
},
initialize: function(options) {
this.counter = 0;
options = options || {};
this.render();
var deviceName = textsecure.storage.user.getDeviceName();
if (!deviceName) {
deviceName = window.config.hostname;
}
this.$('#device-name').val(deviceName);
this.selectStep(Steps.INSTALL_SIGNAL);
this.selectStep(Steps.SCAN_QR_CODE);
this.connect();
this.on('disconnected', this.reconnect);
if (Whisper.Registration.everDone()) {
this.selectStep(Steps.SCAN_QR_CODE);
this.hideDots();
if (Whisper.Registration.everDone() || options.startStep) {
this.selectStep(options.startStep || Steps.SCAN_QR_CODE);
}
},
render_attributes: function() {
var errorMessage;
if (this.error) {
if (this.error.name === 'HTTPError'
&& this.error.code == TOO_MANY_DEVICES) {
errorMessage = i18n('installTooManyDevices');
}
else if (this.error.name === 'HTTPError'
&& this.error.code == CONNECTION_ERROR) {
errorMessage = i18n('installConnectionFailed');
}
else if (this.error.message === 'websocket closed') {
// AccountManager.registerSecondDevice uses this specific
// 'websocket closed' error message
errorMessage = i18n('installConnectionFailed');
}
return {
isError: true,
errorHeader: 'Something went wrong!',
errorMessage,
errorButton: 'Try again',
};
}
return {
isStep3: this.step === Steps.SCAN_QR_CODE,
linkYourPhone: i18n('linkYourPhone'),
signalSettings: i18n('signalSettings'),
linkedDevices: i18n('linkedDevices'),
androidFinalStep: i18n('plusButton'),
appleFinalStep: i18n('linkNewDevice'),
isStep4: this.step === Steps.ENTER_NAME,
chooseName: i18n('chooseDeviceName'),
finishLinkingPhoneButton: i18n('finishLinkingPhone'),
isStep5: this.step === Steps.PROGRESS_BAR,
syncing: i18n('initialSync'),
};
},
selectStep: function(step) {
this.step = step;
this.render();
},
connect: function() {
this.error = null;
this.selectStep(Steps.SCAN_QR_CODE);
this.clearQR();
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
var accountManager = getAccountManager();
accountManager.registerSecondDevice(
this.setProvisioningUrl.bind(this),
this.confirmNumber.bind(this),
this.incrementCounter.bind(this)
this.confirmNumber.bind(this)
).catch(this.handleDisconnect.bind(this));
},
handleDisconnect: function(e) {
if (this.canceled) {
return;
}
console.log('provisioning failed', e.stack);
this.error = e;
this.render();
if (e.message === 'websocket closed') {
this.showConnectionError();
this.trigger('disconnected');
} else if (e.name === 'HTTPError' && e.code == -1) {
this.selectStep(Steps.NETWORK_ERROR);
} else if (e.name === 'HTTPError' && e.code == 411) {
this.showTooManyDevices();
} else {
} else if (e.name !== 'HTTPError'
|| (e.code !== CONNECTION_ERROR && e.code !== TOO_MANY_DEVICES)) {
throw e;
}
},
reconnect: function() {
setTimeout(this.connect.bind(this), 10000);
},
events: function() {
return {
'click .error-dialog .ok': 'connect',
'click .step1': 'onCancel',
'click .step2': this.selectStep.bind(this, Steps.INSTALL_SIGNAL),
'click .step3': this.selectStep.bind(this, Steps.SCAN_QR_CODE)
};
},
onCancel: function() {
this.canceled = true;
this.trigger('cancel');
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.timeout = setTimeout(this.connect.bind(this), 10000);
},
clearQR: function() {
this.$('#qr').text(i18n("installConnecting"));
this.$('#qr img').remove();
this.$('#qr canvas').remove();
this.$('#qr .container').show();
this.$('#qr').removeClass('ready');
},
setProvisioningUrl: function(url) {
this.$('#qr').html('');
new QRCode(this.$('#qr')[0]).makeCode(url);
if ($('#qr').length === 0) {
console.log('Did not find #qr element in the DOM!');
return;
}
this.$('#qr .container').hide();
this.qr = new QRCode(this.$('#qr')[0]).makeCode(url);
this.$('#qr').removeAttr('title');
this.$('#qr').addClass('ready');
},
setDeviceNameDefault: function() {
var deviceName = textsecure.storage.user.getDeviceName();
this.$(DEVICE_NAME_SELECTOR).val(deviceName || window.config.hostname);
this.$(DEVICE_NAME_SELECTOR).focus();
},
confirmNumber: function(number) {
var parsed = libphonenumber.parse(number);
var stepId = '#step' + Steps.ENTER_NAME;
this.$(stepId + ' .number').text(libphonenumber.format(
parsed,
libphonenumber.PhoneNumberFormat.INTERNATIONAL
));
window.removeSetupMenuItems();
this.selectStep(Steps.ENTER_NAME);
this.$('#device-name').focus();
this.setDeviceNameDefault();
return new Promise(function(resolve, reject) {
this.$(stepId + ' .cancel').click(function(e) {
reject();
});
this.$(stepId).submit(function(e) {
this.$('.finish').click(function(e) {
e.stopPropagation();
e.preventDefault();
var name = this.$('#device-name').val();
var name = this.$(DEVICE_NAME_SELECTOR).val();
name = name.replace(/\0/g,''); // strip unicode null
if (name.trim().length === 0) {
this.$('#device-name').focus();
this.$(DEVICE_NAME_SELECTOR).focus();
return;
}
this.$('.progress-dialog .status').text(i18n('installGeneratingKeys'));
this.selectStep(Steps.PROGRESS_BAR);
resolve(name);
}.bind(this));
}.bind(this));
},
incrementCounter: function() {
this.$('.progress-dialog .bar').css('width', (++this.counter * 100 / 100) + '%');
},
selectStep: function(step) {
this.$('.step').hide();
this.$('#step' + step).show();
},
showSync: function() {
this.$('.progress-dialog .status').text(i18n('installSyncingGroupsAndContacts'));
this.$('.progress-dialog .bar').addClass('progress-bar-striped active');
},
showTooManyDevices: function() {
this.selectStep(Steps.TOO_MANY_DEVICES);
},
showConnectionError: function() {
this.$('#qr').text(i18n("installConnectionFailed"));
},
hideDots: function() {
this.$('#step' + Steps.SCAN_QR_CODE + ' .nav .dot').hide();
}
});
Whisper.InstallView.Steps = Steps;
})();

View file

@ -55,6 +55,7 @@
className: 'settings modal expand',
templateName: 'settings',
initialize: function() {
this.deviceName = textsecure.storage.user.getDeviceName();
this.render();
new RadioButtonGroupView({
el: this.$('.notification-settings'),
@ -88,6 +89,8 @@
},
render_attributes: function() {
return {
deviceNameLabel: i18n('deviceName'),
deviceName: this.deviceName,
theme: i18n('theme'),
notifications: i18n('notifications'),
notificationSettingsDialog: i18n('notificationSettingsDialog'),

View file

@ -7,7 +7,7 @@
Whisper.StandaloneRegistrationView = Whisper.View.extend({
templateName: 'standalone',
className: 'install main',
className: 'full-screen-flow',
initialize: function() {
this.accountManager = getAccountManager();
@ -21,16 +21,15 @@
this.$('#error').hide();
},
events: {
'submit #form': 'submit',
'validation input.number': 'onValidation',
'change #code': 'onChangeCode',
'click #request-voice': 'requestVoice',
'click #request-sms': 'requestSMSVerification',
'change #code': 'onChangeCode',
'click #verifyCode': 'verifyCode',
},
submit: function(e) {
e.preventDefault();
verifyCode: function(e) {
var number = this.phoneView.validateNumber();
var verificationCode = $('#code').val().replace(/\D+/g, "");
var verificationCode = $('#code').val().replace(/\D+/g, '');
this.accountManager.registerSingleDevice(number, verificationCode).then(function() {
this.$el.trigger('openInbox');
@ -64,6 +63,7 @@
}
},
requestVoice: function() {
window.removeSetupMenuItems();
this.$('#error').hide();
var number = this.phoneView.validateNumber();
if (number) {
@ -74,6 +74,7 @@
}
},
requestSMSVerification: function() {
window.removeSetupMenuItems();
$('#error').hide();
var number = this.phoneView.validateNumber();
if (number) {