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

@ -8,7 +8,7 @@ install:
- yarn install - yarn install
script: script:
- yarn run generate - yarn run generate
- yarn prepare-build - yarn prepare-beta-build
- yarn eslint - yarn eslint
- yarn test-server - yarn test-server
- yarn lint - yarn lint

View file

@ -19,79 +19,65 @@
"message": "&Help", "message": "&Help",
"description": "The label that is used for the Help menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt-<letter> combination." "description": "The label that is used for the Help menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt-<letter> combination."
}, },
"menuSetupWithImport": {
"message": "Set up with import",
"description": "When the application is not yet set up, menu option to start up the import sequence"
},
"menuSetupAsNewDevice": {
"message": "Set up as new device",
"description": "When the application is not yet set up, menu option to start up the set up as fresh device"
},
"menuSetupAsStandalone": {
"message": "Set up as standalone device",
"description": "Only available on development modes, menu option to open up the standalone device setup sequence"
},
"loading": { "loading": {
"message": "Loading...", "message": "Loading...",
"description": "Message shown on the loading screen before we've loaded any messages" "description": "Message shown on the loading screen before we've loaded any messages"
}, },
"exportInstructions": {
"message": "The first step is to choose a directory to store this application's exported data. It will contain your message history and sensitive cryptographic data, so be sure to save it somewhere private.",
"description": "Description of the export process"
},
"chooseDirectory": { "chooseDirectory": {
"message": "Choose directory", "message": "Choose folder",
"description": "Button to allow the user to export all data from app as part of migration process" "description": "Button to allow the user to find a folder on disk"
}, },
"exportButton": { "loadDataHeader": {
"message": "Export", "message": "Load your data",
"desription": "Button shown on the choose directory dialog which starts the export process" "description": "Header shown on the first screen in the data import process"
}, },
"exportChooserTitle": { "loadDataDescription": {
"message": "Choose target directory for data", "message": "You've just gone through the export process, and your contacts and messages are waiting patiently on your computer. Select the folder that contains your saved Signal data.",
"description": "Title of the popup window used to select data storage location" "description": "Introduction to the process of importing messages and contacts from disk"
},
"exportAgain": {
"message": "Export again",
"description": "If user has already exported once, this button allows user to do it again if needed"
},
"exportError": {
"message": "Unfortunately, something went wrong during the export. First, double-check your target empty directory for write access and enough space. Then, please submit a debug log so we can help you get migrated!",
"description": "Helper text if the user went forward on migrating the app, but ran into an error"
},
"exporting": {
"message": "Please wait while we export your data. It may take several minutes. You can still use Signal on your phone and other devices during this time.",
"description": "Message shown on the migration screen while we export data"
},
"exportComplete": {
"message": "Your data has been exported to: <p><b>$location$</b></p> You'll be able to import this data as you set up <a target='_blank' href='https://support.signal.org/hc/en-us/articles/214507138'>the new Signal Desktop</a>.",
"description": "Message shown on the migration screen when we are done exporting data",
"placeholders": {
"location": {
"content": "$1",
"example": "/Users/someone/somewhere"
}
}
},
"installNewSignal": {
"message": "Install new Signal Desktop",
"description": "When export is complete, a button shows which sends user to Signal Desktop install instructions"
},
"importButton": {
"message": "Import",
"desription": "Button shown on the choose directory dialog which starts the import process"
}, },
"importChooserTitle": { "importChooserTitle": {
"message": "Choose directory with exported data", "message": "Choose directory with exported data",
"description": "Title of the popup window used to select data previously exported" "description": "Title of the popup window used to select data previously exported"
}, },
"importErrorHeader": {
"message": "Something went wrong!",
"description": "Header of the error screen after a failed import"
},
"importingHeader": {
"message": "Loading contacts and messages",
"description": "Header of screen shown as data is import"
},
"importError": { "importError": {
"message": "Unfortunately, something went wrong during the import. <p>First, double-check your target directory. It should start with 'Signal Export.'</p><p>Next, try with a new export of your data from the Chrome App.</p>If that still fails, please <a target='_blank' href='https://support.signal.org/hc/en-us/articles/215188737'>submit a debug log</a> so we can help you get migrated!", "message": "Make sure you have chosen the correct directory that contains your saved Signal data. Its name should begin with 'Signal Export.' You can also save a new copy of your data from the Chrome App.<p>If these steps don't work for you, please <a target='_blank' href='https://support.signal.org/hc/en-us/articles/215188737'>submit a debug log</a> so that we can help you get migrated!</p>",
"description": "Message shown if the import went wrong." "description": "Message shown if the import went wrong."
}, },
"tryAgain": { "importAgain": {
"message": "Try again", "message": "Choose folder and try again",
"description": "Button shown if the user runs into an error during import, allowing them to start over" "description": "Button shown if the user runs into an error during import, allowing them to start over"
}, },
"importInstructions": { "importCompleteHeader": {
"message": "The first step is to tell us where you previously <a href='https://support.signal.org/hc/en-us/articles/115002502511'>exported your Signal data</a>. It will be a directory whose name starts with 'Signal Export.'<br><br><b>NOTE</b>: You must only import a set of exported data <b>once</b>. Import makes a copy of the exported client, and duplicate clients interfere with each other.", "message": "Success!",
"description": "Description of the export process" "description": "Header shown on the screen at the end of a successful import process"
}, },
"importing": { "importCompleteStartButton": {
"message": "Please wait while we import your data...", "message": "Start using Signal Desktop",
"description": "Shown as we are loading the user's data from disk" "description": "Button shown at end of successful import process, nothing left but a restart"
}, },
"importComplete": { "importCompleteLinkButton": {
"message": "We've successfully loaded your data. The next step is to restart the application!", "message": "Link this device to your phone",
"description": "Shown when the import is complete." "description": "Button shown at end of successful 'light' import process, so the standard linking process still needs to happen"
}, },
"selectedLocation": { "selectedLocation": {
"message": "your selected location", "message": "your selected location",
@ -526,87 +512,46 @@
"message": "Privacy is possible. Signal makes it easy.", "message": "Privacy is possible. Signal makes it easy.",
"description": "Tagline displayed under 'installWelcome' string on the install page" "description": "Tagline displayed under 'installWelcome' string on the install page"
}, },
"installNew": { "linkYourPhone": {
"message": "Set up as new install", "message": "Link your phone to Signal Desktop",
"description": "One of two choices presented on the screen shown on first launch" "description": "Shown on the front page when the application first starst, above the QR code"
}, },
"installImport": { "signalSettings": {
"message": "Set up with exported data", "message": "Signal Settings",
"description": "One of two choices presented on the screen shown on first launch" "description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app"
}, },
"installGetStartedButton": { "linkedDevices": {
"message": "Get started" "message": "Linked Devices",
"description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app"
}, },
"installSignalLink": { "plusButton": {
"message": "First, install <a $a_params$>Signal</a> on your mobile phone. We'll link your devices and keep your messages in sync.", "message": "'+' Button",
"description": "Prompt the user to install Signal on their phone before linking", "description": "The button used in Signal Android to add a new linked device"
"placeholders": {
"a_params": {
"content": "$1",
"example": "href='http://example.com'"
}
}
}, },
"installSignalLinks": { "linkNewDevice": {
"message": "First, install Signal on your <a $play_store$>Android</a> or <a $app_store$>iPhone</a>.<br /> We'll link your devices and keep your messages in sync.", "message": "Link New Device",
"description": "Prompt the user to install Signal on their phone before linking", "description": "The menu option shown in Signal iOS to add a new linked device"
"placeholders": {
"play_store": {
"content": "$1",
"example": "href='http://example.com'"
},
"app_store": {
"content": "$2",
"example": "href='http://example.com'"
}
}
}, },
"installGotIt": { "deviceName": {
"message": "Got it", "message": "Device name",
"description": "Button for the user to confirm that they have Signal installed." "description": "The label in settings panel shown for the user-provided name for this desktop instance"
}, },
"installIHaveSignalButton": { "chooseDeviceName": {
"message": "I have Signal for Android", "message": "Choose this device's name",
"description": "Button for the user to confirm that they have Signal for Android" "description": "The header shown on the 'choose device name' screen in the device linking process"
}, },
"installFollowUs": { "finishLinkingPhone": {
"message": "<a $a_params$>Follow us</a> for updates about multi-device support for iOS.", "message": "Finish linking phone",
"placeholders": { "description": "The text on the button to finish the linking process, after choosing the device name"
"a_params": {
"content": "$1",
"example": "href='http://example.com'"
}
}
}, },
"installAndroidInstructions": { "initialSync": {
"message": "Open Signal on your phone and navigate to Settings > Linked devices. Tap the button to add a new device, then scan the code above." "message": "Syncing contacts and groups",
}, "description": "Shown during initial link while contacts and groups are being pulled from mobile device"
"installConnecting": {
"message": "Connecting...",
"description": "Displayed when waiting for the QR Code"
}, },
"installConnectionFailed": { "installConnectionFailed": {
"message": "Failed to connect to server.", "message": "Failed to connect to server.",
"description": "Displayed when we can't connect to the server." "description": "Displayed when we can't connect to the server."
}, },
"installGeneratingKeys": {
"message": "Generating Keys"
},
"installSyncingGroupsAndContacts": {
"message": "Syncing groups and contacts"
},
"installComputerName": {
"message": "This computer's name will be",
"description": "Text displayed before the input where the user can enter the name for this device."
},
"installLinkingWithNumber": {
"message": "Linking with",
"description": "Text displayed before the phone number that the user is in the process of linking with"
},
"installFinalButton": {
"message": "Looking good",
"description": "The final button for the install process, after the user has entered a name for their device"
},
"installTooManyDevices": { "installTooManyDevices": {
"message": "Sorry, you have too many devices linked already. Try removing some." "message": "Sorry, you have too many devices linked already. Try removing some."
}, },

View file

@ -6,6 +6,9 @@ function createTemplate(options, messages) {
openNewBugForm, openNewBugForm,
openSupportPage, openSupportPage,
openForums, openForums,
setupWithImport,
setupAsNewDevice,
setupAsStandalone,
} = options; } = options;
const template = [{ const template = [{
@ -123,6 +126,27 @@ function createTemplate(options, messages) {
], ],
}]; }];
if (options.includeSetup) {
const fileMenu = template[0];
// These are in reverse order, since we're prepending them one at a time
if (options.development) {
fileMenu.submenu.unshift({
label: messages.menuSetupAsStandalone.message,
click: setupAsStandalone,
});
}
fileMenu.submenu.unshift({
label: messages.menuSetupAsNewDevice.message,
click: setupAsNewDevice,
});
fileMenu.submenu.unshift({
label: messages.menuSetupWithImport.message,
click: setupWithImport,
});
}
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
return updateForMac(template, messages, options); return updateForMac(template, messages, options);
} }
@ -134,14 +158,46 @@ function updateForMac(template, messages, options) {
const { const {
showWindow, showWindow,
showAbout, showAbout,
setupWithImport,
setupAsNewDevice,
setupAsStandalone,
} = options; } = options;
// Remove About item and separator from Help menu, since it's on the first menu // Remove About item and separator from Help menu, since it's on the first menu
template[4].submenu.pop(); template[4].submenu.pop();
template[4].submenu.pop(); template[4].submenu.pop();
// Replace File menu // Remove File menu
template.shift(); template.shift();
if (options.includeSetup) {
// Add a File menu just for these setup options. Because we're using unshift(), we add
// the file menu first, though it ends up to the right of the Signal Desktop menu.
const fileMenu = {
label: messages.mainMenuFile.message,
submenu: [
{
label: messages.menuSetupWithImport.message,
click: setupWithImport,
},
{
label: messages.menuSetupAsNewDevice.message,
click: setupAsNewDevice,
},
],
};
if (options.development) {
fileMenu.submenu.push({
label: messages.menuSetupAsStandalone.message,
click: setupAsStandalone,
});
}
template.unshift(fileMenu);
}
// Add the OSX-specific Signal Desktop menu at the far left
template.unshift({ template.unshift({
submenu: [ submenu: [
{ {
@ -170,7 +226,8 @@ function updateForMac(template, messages, options) {
}); });
// Add to Edit menu // Add to Edit menu
template[1].submenu.push( const editIndex = options.includeSetup ? 2 : 1;
template[editIndex].submenu.push(
{ {
type: 'separator', type: 'separator',
}, },

View file

@ -19,7 +19,7 @@ build_script:
- node build\grunt.js - node build\grunt.js
- type package.json | findstr /v certificateSubjectName > temp.json - type package.json | findstr /v certificateSubjectName > temp.json
- move temp.json package.json - move temp.json package.json
- yarn prepare-build - yarn prepare-beta-build
- node_modules\.bin\build --em.environment=%SIGNAL_ENV% --publish=never - node_modules\.bin\build --em.environment=%SIGNAL_ENV% --publish=never
test_script: test_script:

View file

@ -571,6 +571,9 @@
<div class='content'> <div class='content'>
<a class='x close' alt='close settings' href='#'></a> <a class='x close' alt='close settings' href='#'></a>
<h2>{{ settings }}</h2> <h2>{{ settings }}</h2>
<div class='device-name-settings'>
<b>{{ deviceNameLabel }}:</b> {{ deviceName }}
</div>
<hr> <hr>
<div class='theme-settings'> <div class='theme-settings'>
<h3>{{ theme }}</h3> <h3>{{ theme }}</h3>
@ -652,151 +655,198 @@
{{/action }} {{/action }}
</script> </script>
<script type='text/x-tmpl-mustache' id='install-choice'> <script type='text/x-tmpl-mustache' id='import-flow-template'>
<div class='step'> {{#isStep2}}
<div id='step2' class='step'>
<div class='inner'> <div class='inner'>
<div class='step-body'> <div class='step-body'>
<img id='signal-icon' src='images/icon_250.png'/> <span class='banner-icon folder-outline'></span>
<h1>{{ installWelcome }}</h1> <div class='header'>{{ chooseHeader }}</div>
<p>{{ installTagline }}</p> <div class='body-text'>{{ choose }}</div>
</div> </div>
<div class='nav'> <div class='nav'>
<div> <a class='button new'>{{ installNew }}</a> </div> <div>
<div> <a class='button import'>{{ installImport }}</a> </div> <a class='button choose'>{{ chooseButton }}</a>
</div>
</div> </div>
</div> </div>
</div> </div>
{{/isStep2}}
{{#isStep3}}
<div id='step3' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon import'></span>
<div class='header'>{{ importingHeader }}</div>
</div>
<div class='progress'>
<div class='bar-container'>
<div class='bar progress-bar progress-bar-striped active'></div>
</div>
</div>
</div>
</div>
{{/isStep3}}
{{#isStep4}}
<div id='step4' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon check-circle-outline'></span>
<div class='header'>{{ completeHeader }}</div>
</div>
<div class='nav'>
{{#restartButton}}
<div>
<a class='button restart'>{{ restartButton }}</a>
</div>
{{/restartButton}}
{{#registerButton}}
<div>
<a class='button register'>{{ registerButton }}</a>
</div>
{{/registerButton}}
</div>
</div>
</div>
{{/isStep4}}
{{#isError}}
<div id='error' class='step'>
<div class='inner error-dialog clearfix'>
<div class='step-body'>
<span class='banner-icon alert-outline'></span>
<div class='header'>{{ errorHeader }}</div>
<div class='body-text-wide'>{{& errorMessage }}</div>
</div>
<div class='nav'>
<div>
<a class='button choose'>{{ chooseButton }}</a>
</div>
</div>
</div>
</div>
{{/isError}}
</script> </script>
<script type='text/x-tmpl-mustache' id='install_flow_template'> <script type='text/x-tmpl-mustache' id='link-flow-template'>
{{#isStep3}}
<div id='step2' class='step hidden'> <div id='step3' class='step'>
<div class='inner'> <div class='inner'>
<div class='step-body'> <div class='step-body'>
<img id='signal-phone' src='images/signal-phone.png'> <div class='header'>{{ linkYourPhone }}</div>
<p>{{{ installSignalLink }}}</p> <div id="qr">
<div class='container'>
<span class='dot'></span>
<span class='dot'></span>
<span class='dot'></span>
</div>
</div>
</div> </div>
<div class='nav'> <div class='nav'>
<div> <a class='button step3'>{{ installIHaveSignalButton }}</a> </div> <div class='instructions'>
<div class='dot-container'> <div class='android'>
<span class='dot step1'></span> <div class='label'>
<span class='dot step2 selected'></span> <span class='os-icon android'></span>
<span class='dot step3'></span> </div>
<div class='body'>
<div>→ {{ signalSettings }}</div>
<div>→ {{ linkedDevices }}</div>
<div>→ {{ androidFinalStep }}</div>
</div>
</div>
<div class='apple'>
<div class='label'>
<span class='os-icon apple'></span>
</div>
<div class='body'>
<div>→ {{ signalSettings }}</div>
<div>→ {{ linkedDevices }}</div>
<div>→ {{ appleFinalStep }}</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{/isStep3}}
<div id='step3' class='step hidden'> {{#isStep4}}
<div id='step4' class='step'>
<div class='inner'> <div class='inner'>
<div class='step-body'> <div class='step-body'>
<div id="qr"></div> <span class='banner-icon lead-pencil'></span>
<p>{{ installAndroidInstructions }}</p> <div class='header'>{{ chooseName }}</div>
</div>
<div class='nav'>
{{ #development }}
<div> <a class='button openStandalone'>Standalone</a> </div>
{{ /development }}
<div class='dot-container'>
<span class='dot step1'></span>
<span class='dot step2'></span>
<span class='dot step3 selected'></span>
</div>
</div>
</div>
</div>
<form id='step4' class='step hidden'>
<div class='inner'>
<div class='step-body'>
<p>{{ installLinkingWithNumber }}</p>
<h2 class='number'></h2>
<img id='signal-computer' src='images/signal-laptop.png'>
<p>{{ installComputerName }}</p>
<div> <div>
<input type='text' id='device-name' spellcheck='false' maxlength='50' /> <input type='text' class='device-name' spellcheck='false' maxlength='50' />
</div> </div>
</div> </div>
<div class='nav'> <div class='nav'>
<div> <div>
<input type='submit' class='button' id='sync' value='{{ installFinalButton }}' /> <a class='button finish'>{{ finishLinkingPhoneButton }}</a>
</div> </div>
</div> </div>
</div> </div>
</form> </div>
{{/isStep4}}
<div id='step5' class='step hidden'> {{#isStep5}}
<div id='step5' class='step'>
<div class='inner'> <div class='inner'>
<div class='step-body'> <div class='step-body'>
<img id='signal-icon' src='images/icon_250.png'/> <span class='banner-icon sync'></span>
<div class='progress-dialog'> <div class='header'>{{ syncing }}</div>
<p class='status'></p> </div>
<div class='bar-container'><div class='bar progress-bar'></div></div> <div class='progress'>
<div class='bar-container'>
<div class='bar progress-bar progress-bar-striped active'></div>
</div> </div>
</div> </div>
<div class='nav'>
</div>
</div> </div>
</div> </div>
{{/isStep5}}
<div id='stepTooManyDevices' class='step hidden'> {{#isError}}
<div class='inner error-dialog clearfix'> <div id='error' class='step'>
<div class='panel step-body'>{{ installTooManyDevices }}</div> <div class='inner'>
<div class='nav'> <div class='step-body'>
<button class='ok step3'>{{ ok }}</button> <span class='banner-icon alert-outline'></span>
</div> <div class='header'>{{ errorHeader }}</div>
</div> <div class='body'>{{ errorMessage }}</div>
</div> </div>
<div id='stepNetworkError' class='step hidden'>
<div class='inner error-dialog clearfix'>
<div class='panel step-body'>{{ installConnectionFailed }}</div>
<button class='ok step3 button'>{{ tryAgain }}</button>
<div class='nav'> <div class='nav'>
<a class='button try-again'>{{ errorButton }}</a>
</div> </div>
</div> </div>
</div> </div>
{{/isError}}
</script> </script>
<script type='text/x-tmpl-mustache' id='standalone'> <script type='text/x-tmpl-mustache' id='standalone'>
<header> <div class='step'>
<div class='container'> <div class='inner'>
<div class='row'> <div class='step-body'>
<div class='col-xs-2 col-md-1'> <img class='banner-image' src='images/icon_128.png' />
<img id='textsecure-icon' src='images/icon_250.png'/> <div class='header'>Create your Signal Account</div>
</div> <div id='phone-number-input'>
<div class='col-xs-10 col-md-11'> <div class='phone-input-form'>
<h1>Create your Signal Account</h1> <div id='number-container' class='number-container'>
<h4 class='tagline'>Private messaging from your web browser.</h4> <input type='tel' class='number' placeholder='Phone Number' />
</div>
</div>
</div>
</header>
<div class='container'>
<div class='col-xs-offset-1 col-md-6'>
<div class='narrow'>
<div id="phone-number-input">
<div class="phone-input-form">
<div id="number-container" class="number-container">
<input type="tel" class="number" placeholder="Phone Number" />
</div> </div>
</div> </div>
</div> </div>
<div class='clearfix'> <div class='clearfix'>
<button id="request-sms" class="btn btn-info">Send SMS</button> <a class='button' id='request-sms'>Send SMS</a>
<button id="request-voice" class="btn btn-info" tabindex=-1>Call</button> <a class='link' id='request-voice' tabindex=-1>Call</a>
</div> </div>
<form id='form'> <input class='form-control' type='text' pattern='\s*[0-9]{3}-?[0-9]{3}\s*' title='Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one' id='code' placeholder='Verification Code' autocomplete='off'>
<h2></h2> <div id='error' class='collapse'></div>
<input class='form-control' type="text" pattern="\s*[0-9]{3}-?[0-9]{3}\s*" title="Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one" id="code" placeholder="Verification Code" autocomplete='off'>
<button id="verifyCode" class="btn btn-info" data-loading-text="Please wait...">Register</button>
<div id='error' class='collapse'></div>
<div> <a class='button openInstaller'>Link to phone</a> </div>
</form>
<div id=status></div> <div id=status></div>
</div> </div>
<div class='nav'>
<a class='button' id='verifyCode' data-loading-text='Please wait...'>Register</a>
</div>
</div> </div>
</div> </div>
</script> </script>
<script type='text/javascript' src='js/components.js'></script> <script type='text/javascript' src='js/components.js'></script>
<script type='text/javascript' src='js/reliable_trigger.js'></script> <script type='text/javascript' src='js/reliable_trigger.js'></script>
<script type='text/javascript' src='js/database.js'></script> <script type='text/javascript' src='js/database.js'></script>
@ -857,7 +907,6 @@
<script type="text/javascript" src="js/views/phone-input-view.js"></script> <script type="text/javascript" src="js/views/phone-input-view.js"></script>
<script type='text/javascript' src='js/views/standalone_registration_view.js'></script> <script type='text/javascript' src='js/views/standalone_registration_view.js'></script>
<script type='text/javascript' src='js/views/app_view.js'></script> <script type='text/javascript' src='js/views/app_view.js'></script>
<script type='text/javascript' src='js/views/install_choice_view.js'></script>
<script type='text/javascript' src='js/views/import_view.js'></script> <script type='text/javascript' src='js/views/import_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script> <script type='text/javascript' src='js/wall_clock_listener.js'></script>

View file

@ -4,5 +4,8 @@
"disableAutoUpdate": false, "disableAutoUpdate": false,
"openDevTools": false, "openDevTools": false,
"buildExpiration": 0, "buildExpiration": 0,
"certificateAuthorities": ["-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n"] "certificateAuthorities": [
"-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n"
],
"import": false
} }

1
images/alert-outline.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" /></svg>

After

Width:  |  Height:  |  Size: 357 B

1
images/android.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M15,5H14V4H15M10,5H9V4H10M15.53,2.16L16.84,0.85C17.03,0.66 17.03,0.34 16.84,0.14C16.64,-0.05 16.32,-0.05 16.13,0.14L14.65,1.62C13.85,1.23 12.95,1 12,1C11.04,1 10.14,1.23 9.34,1.63L7.85,0.14C7.66,-0.05 7.34,-0.05 7.15,0.14C6.95,0.34 6.95,0.66 7.15,0.85L8.46,2.16C6.97,3.26 6,5 6,7H18C18,5 17,3.25 15.53,2.16M20.5,8A1.5,1.5 0 0,0 19,9.5V16.5A1.5,1.5 0 0,0 20.5,18A1.5,1.5 0 0,0 22,16.5V9.5A1.5,1.5 0 0,0 20.5,8M3.5,8A1.5,1.5 0 0,0 2,9.5V16.5A1.5,1.5 0 0,0 3.5,18A1.5,1.5 0 0,0 5,16.5V9.5A1.5,1.5 0 0,0 3.5,8M6,18A1,1 0 0,0 7,19H8V22.5A1.5,1.5 0 0,0 9.5,24A1.5,1.5 0 0,0 11,22.5V19H13V22.5A1.5,1.5 0 0,0 14.5,24A1.5,1.5 0 0,0 16,22.5V19H17A1,1 0 0,0 18,18V8H6V18Z" /></svg>

After

Width:  |  Height:  |  Size: 955 B

1
images/apple.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M18.71,19.5C17.88,20.74 17,21.95 15.66,21.97C14.32,22 13.89,21.18 12.37,21.18C10.84,21.18 10.37,21.95 9.1,22C7.79,22.05 6.8,20.68 5.96,19.47C4.25,17 2.94,12.45 4.7,9.39C5.57,7.87 7.13,6.91 8.82,6.88C10.1,6.86 11.32,7.75 12.11,7.75C12.89,7.75 14.37,6.68 15.92,6.84C16.57,6.87 18.39,7.1 19.56,8.82C19.47,8.88 17.39,10.1 17.41,12.63C17.44,15.65 20.06,16.66 20.09,16.67C20.06,16.74 19.67,18.11 18.71,19.5M13,3.5C13.73,2.67 14.94,2.04 15.94,2C16.07,3.17 15.6,4.35 14.9,5.19C14.21,6.04 13.07,6.7 11.95,6.61C11.8,5.46 12.36,4.26 13,3.5Z" /></svg>

After

Width:  |  Height:  |  Size: 824 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z" /></svg>

After

Width:  |  Height:  |  Size: 499 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z" /></svg>

After

Width:  |  Height:  |  Size: 401 B

1
images/import.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M14,12L10,8V11H2V13H10V16M20,18V6C20,4.89 19.1,4 18,4H6A2,2 0 0,0 4,6V9H6V6H18V18H6V15H4V18A2,2 0 0,0 6,20H18A2,2 0 0,0 20,18Z" /></svg>

After

Width:  |  Height:  |  Size: 421 B

1
images/lead-pencil.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M16.84,2.73C16.45,2.73 16.07,2.88 15.77,3.17L13.65,5.29L18.95,10.6L21.07,8.5C21.67,7.89 21.67,6.94 21.07,6.36L17.9,3.17C17.6,2.88 17.22,2.73 16.84,2.73M12.94,6L4.84,14.11L7.4,14.39L7.58,16.68L9.86,16.85L10.15,19.41L18.25,11.3M4.25,15.04L2.5,21.73L9.2,19.94L8.96,17.78L6.65,17.61L6.47,15.29" /></svg>

After

Width:  |  Height:  |  Size: 584 B

1
images/sync.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" /></svg>

After

Width:  |  Height:  |  Size: 521 B

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() { function start() {
var currentVersion = window.config.version; var currentVersion = window.config.version;
var lastVersion = storage.get('version'); var lastVersion = storage.get('version');
@ -140,8 +161,10 @@
appView.openInbox({ appView.openInbox({
initialLoadComplete: initialLoadComplete initialLoadComplete: initialLoadComplete
}); });
} else if (window.config.importMode) {
appView.openImporter();
} else { } else {
appView.openInstallChoice(); appView.openInstaller();
} }
Whisper.events.on('showDebugLog', function() { Whisper.events.on('showDebugLog', function() {
@ -158,12 +181,6 @@
appView.openInbox(); appView.openInbox();
} }
}); });
Whisper.events.on('contactsync:begin', function() {
if (appView.installView && appView.installView.showSync) {
appView.installView.showSync();
}
});
Whisper.Notifications.on('click', function(conversation) { Whisper.Notifications.on('click', function(conversation) {
showWindow(); showWindow();
if (conversation) { 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 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 * Export all data from an IndexedDB database
* @param {IDBDatabase} idb_db * @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) { return new Promise(function(resolve, reject) {
var storeNames = idb_db.objectStoreNames; var storeNames = idb_db.objectStoreNames;
storeNames = _.without(storeNames, 'messages'); 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 = []; var exportedStoreNames = [];
if (storeNames.length === 0) { if (storeNames.length === 0) {
throw new Error('No stores to export'); throw new Error('No stores to export');
@ -160,9 +177,10 @@
}); });
} }
function importNonMessages(idb_db, parent) { function importNonMessages(idb_db, parent, options) {
return readFileAsText(parent, 'db.json').then(function(string) { var file = 'db.json';
return importFromJsonString(idb_db, string); 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)); 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 * Import data from JSON into an IndexedDB database. This does not delete any existing data
* from the database, so keys could clash * from the database, so keys could clash
@ -183,19 +211,50 @@
* @param {IDBDatabase} idb_db * @param {IDBDatabase} idb_db
* @param {string} jsonString - data to import, one key per object store * @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) { return new Promise(function(resolve, reject) {
var importObject = JSON.parse(jsonString); var importObject = JSON.parse(jsonString);
delete importObject.debug; 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(', ')); console.log('Importing to these stores:', storeNames.join(', '));
var finished = false; var finished = false;
var finish = function(via) { var finish = function(via) {
console.log('non-messages import done via', via); console.log('non-messages import done via', via);
if (finished) { if (finished) {
resolve(); resolve(result);
} }
finished = true; finished = true;
}; };
@ -219,20 +278,46 @@
} }
var count = 0; 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) { _.each(importObject[storeName], function(toAdd) {
toAdd = unstringify(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); var request = transaction.objectStore(storeName).put(toAdd, toAdd.id);
request.onsuccess = function(event) { request.onsuccess = function(event) {
count++; count++;
if (count == importObject[storeName].length) { if (count == importObject[storeName].length) {
// added all objects for this store finishStore();
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');
}
} }
}; };
request.onerror = function(e) { 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) { request.onsuccess = function(event) {
var cursor = event.target.result; var cursor = event.target.result;
if (cursor) { if (cursor) {
if (count !== 0) {
stream.write(',');
}
var message = cursor.value; var message = cursor.value;
var messageId = message.received_at; var messageId = message.received_at;
var attachments = message.attachments; 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) { message.attachments = _.map(attachments, function(attachment) {
return _.omit(attachment, ['data']); return _.omit(attachment, ['data']);
}); });
@ -598,6 +695,10 @@
})); }));
} }
function saveMessage(idb_db, message) {
return saveAllMessages(idb_db, [message]);
}
function saveAllMessages(idb_db, messages) { function saveAllMessages(idb_db, messages) {
if (!messages.length) { if (!messages.length) {
return Promise.resolve(); return Promise.resolve();
@ -658,43 +759,64 @@
// message, save it, and only then do we move on to the next message. Thus, every // 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 // message with attachments needs to be removed from our overall message save with the
// filter() call. // 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) { return readFileAsText(dir, 'messages.json').then(function(contents) {
var promiseChain = Promise.resolve(); var promiseChain = Promise.resolve();
var json = JSON.parse(contents); var json = JSON.parse(contents);
var conversationId;
if (json.messages && json.messages.length) { 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) { var messages = _.filter(json.messages, function(message) {
message = unstringify(message); message = unstringify(message);
if (messageLookup[getMessageKey(message)]) {
skipped++;
return false;
}
if (message.attachments && message.attachments.length) { if (message.attachments && message.attachments.length) {
var process = function() { var process = function() {
return loadAttachments(dir, message).then(function() { return loadAttachments(dir, message).then(function() {
return saveAllMessages(idb_db, [message]); return saveMessage(idb_db, message);
}); });
}; };
promiseChain = promiseChain.then(process); 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() { .then(function() {
return promiseChain; return promiseChain;
}) })
.then(function() { .then(function() {
console.log( console.log(
'Finished importing conversation', 'Finished importing conversation',
// Don't know if group or private conversation, so we blindly redact conversationId,
conversationId ? '[REDACTED]' + conversationId.slice(-3) : 'with no messages' '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) { return getDirContents(dir).then(function(contents) {
var promiseChain = Promise.resolve(); var promiseChain = Promise.resolve();
@ -713,7 +835,7 @@
} }
var process = function() { var process = function() {
return importConversation(idb_db, conversationDir); return importConversation(idb_db, conversationDir, options);
}; };
promiseChain = promiseChain.then(process); 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) { function clearAllStores(idb_db) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
console.log('Clearing all indexeddb stores'); console.log('Clearing all indexeddb stores');
@ -791,7 +980,7 @@
}; };
return getDirectory(options); return getDirectory(options);
}, },
backupToDirectory: function(directory) { exportToDirectory: function(directory, options) {
var dir; var dir;
var idb; var idb;
return openDatabase().then(function(idb_db) { return openDatabase().then(function(idb_db) {
@ -800,7 +989,7 @@
return createDirectory(directory, name); return createDirectory(directory, name);
}).then(function(created) { }).then(function(created) {
dir = created; dir = created;
return exportNonMessages(idb, dir); return exportNonMessages(idb, dir, options);
}).then(function() { }).then(function() {
return exportConversations(idb, dir); return exportConversations(idb, dir);
}).then(function() { }).then(function() {
@ -823,18 +1012,30 @@
}; };
return getDirectory(options); return getDirectory(options);
}, },
importFromDirectory: function(directory) { importFromDirectory: function(directory, options) {
var idb; options = options || {};
var idb, nonMessageResult;
return openDatabase().then(function(idb_db) { return openDatabase().then(function(idb_db) {
idb = 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() { }).then(function() {
return importConversations(idb, directory); return importNonMessages(idb, directory, options);
}).then(function(result) {
nonMessageResult = result;
return importConversations(idb, directory, options);
}).then(function() { }).then(function() {
return directory;
}).then(function(path) {
console.log('done restoring from backup!'); console.log('done restoring from backup!');
return path; return nonMessageResult;
}, function(error) { }, function(error) {
console.log( console.log(
'the import went wrong:', 'the import went wrong:',

View file

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

View file

@ -7,7 +7,8 @@
var State = { var State = {
IMPORTING: 1, IMPORTING: 1,
COMPLETE: 2 COMPLETE: 2,
LIGHT_COMPLETE: 3,
}; };
var IMPORT_STARTED = 'importStarted'; var IMPORT_STARTED = 'importStarted';
@ -39,12 +40,13 @@
}; };
Whisper.ImportView = Whisper.View.extend({ Whisper.ImportView = Whisper.View.extend({
templateName: 'app-migration-screen', templateName: 'import-flow-template',
className: 'app-loading-screen', className: 'full-screen-flow',
events: { events: {
'click .import': 'onImport', 'click .choose': 'onImport',
'click .restart': 'onRestart', 'click .restart': 'onRestart',
'click .cancel': 'onCancel', 'click .cancel': 'onCancel',
'click .register': 'onRegister',
}, },
initialize: function() { initialize: function() {
if (Whisper.Import.isIncomplete()) { if (Whisper.Import.isIncomplete()) {
@ -55,41 +57,42 @@
this.pending = Promise.resolve(); this.pending = Promise.resolve();
}, },
render_attributes: function() { render_attributes: function() {
var message;
var importButton;
var hideProgress = true;
var restartButton;
var cancelButton;
if (this.error) { if (this.error) {
return { return {
message: i18n('importError'), isError: true,
hideProgress: true, errorHeader: i18n('importErrorHeader'),
importButton: i18n('tryAgain'), errorMessage: i18n('importError'),
chooseButton: i18n('importAgain'),
}; };
} }
switch (this.state) { var restartButton = i18n('importCompleteStartButton');
case State.COMPLETE: var registerButton = i18n('importCompleteLinkButton');
message = i18n('importComplete'); var step = 'step2';
restartButton = i18n('restartSignal');
break; if (this.state === State.IMPORTING) {
case State.IMPORTING: step = 'step3';
message = i18n('importing'); } else if (this.state === State.COMPLETE) {
hideProgress = false; registerButton = null;
break; step = 'step4';
default: } else if (this.state === State.LIGHT_COMPLETE) {
message = i18n('importInstructions'); restartButton = null;
importButton = i18n('chooseDirectory'); step = 'step4';
cancelButton = i18n('cancel');
} }
return { return {
hideProgress: hideProgress, isStep2: step === 'step2',
message: message, chooseHeader: i18n('loadDataHeader'),
importButton: importButton, choose: i18n('loadDataDescription'),
chooseButton: i18n('chooseDirectory'),
isStep3: step === 'step3',
importingHeader: i18n('importingHeader'),
isStep4: step === 'step4',
completeHeader: i18n('importCompleteHeader'),
restartButton: restartButton, restartButton: restartButton,
cancelButton: cancelButton, registerButton: registerButton,
}; };
}, },
onRestart: function() { onRestart: function() {
@ -110,9 +113,16 @@
} }
}); });
}, },
doImport: function(directory) { onRegister: function() {
this.error = null; // 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.state = State.IMPORTING;
this.render(); this.render();
@ -125,25 +135,17 @@
Whisper.Import.start(), Whisper.Import.start(),
Whisper.Backup.importFromDirectory(directory) Whisper.Backup.importFromDirectory(directory)
]); ]);
}).then(function() { }).then(function(results) {
// Catching in-memory cache up with what's in indexeddb now... var importResult = results[1];
// 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), // A full import changes so much we need a restart of the app
Whisper.Import.complete() if (importResult.fullImport) {
]); return this.finishFullImport(directory);
}).then(function() { }
this.state = State.COMPLETE;
this.render(); // 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) { }.bind(this)).catch(function(error) {
console.log('Error importing:', error && error.stack ? error.stack : error); console.log('Error importing:', error && error.stack ? error.stack : error);
@ -153,6 +155,40 @@
return Whisper.Backup.clearDatabase(); return Whisper.Backup.clearDatabase();
}.bind(this)); }.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', NETWORK_ERROR: 'NetworkError',
}; };
var DEVICE_NAME_SELECTOR = 'input.device-name';
var CONNECTION_ERROR = -1;
var TOO_MANY_DEVICES = 411;
Whisper.InstallView = Whisper.View.extend({ Whisper.InstallView = Whisper.View.extend({
templateName: 'install_flow_template', templateName: 'link-flow-template',
className: 'main install', className: 'main full-screen-flow',
render_attributes: function() { events: {
var twitterHref = 'https://twitter.com/signalapp'; 'click .try-again': 'connect',
var signalHref = 'https://signal.org/install'; // handler for finish button is in confirmNumber()
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'
};
}, },
initialize: function(options) { initialize: function(options) {
this.counter = 0; options = options || {};
this.render(); this.selectStep(Steps.SCAN_QR_CODE);
var deviceName = textsecure.storage.user.getDeviceName();
if (!deviceName) {
deviceName = window.config.hostname;
}
this.$('#device-name').val(deviceName);
this.selectStep(Steps.INSTALL_SIGNAL);
this.connect(); this.connect();
this.on('disconnected', this.reconnect); this.on('disconnected', this.reconnect);
if (Whisper.Registration.everDone()) { if (Whisper.Registration.everDone() || options.startStep) {
this.selectStep(Steps.SCAN_QR_CODE); this.selectStep(options.startStep || Steps.SCAN_QR_CODE);
this.hideDots();
} }
}, },
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() { connect: function() {
this.error = null;
this.selectStep(Steps.SCAN_QR_CODE);
this.clearQR(); this.clearQR();
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
var accountManager = getAccountManager(); var accountManager = getAccountManager();
accountManager.registerSecondDevice( accountManager.registerSecondDevice(
this.setProvisioningUrl.bind(this), this.setProvisioningUrl.bind(this),
this.confirmNumber.bind(this), this.confirmNumber.bind(this)
this.incrementCounter.bind(this)
).catch(this.handleDisconnect.bind(this)); ).catch(this.handleDisconnect.bind(this));
}, },
handleDisconnect: function(e) { handleDisconnect: function(e) {
if (this.canceled) {
return;
}
console.log('provisioning failed', e.stack); console.log('provisioning failed', e.stack);
this.error = e;
this.render();
if (e.message === 'websocket closed') { if (e.message === 'websocket closed') {
this.showConnectionError();
this.trigger('disconnected'); this.trigger('disconnected');
} else if (e.name === 'HTTPError' && e.code == -1) { } else if (e.name !== 'HTTPError'
this.selectStep(Steps.NETWORK_ERROR); || (e.code !== CONNECTION_ERROR && e.code !== TOO_MANY_DEVICES)) {
} else if (e.name === 'HTTPError' && e.code == 411) {
this.showTooManyDevices();
} else {
throw e; throw e;
} }
}, },
reconnect: function() { reconnect: function() {
setTimeout(this.connect.bind(this), 10000); if (this.timeout) {
}, clearTimeout(this.timeout);
events: function() { this.timeout = null;
return { }
'click .error-dialog .ok': 'connect', this.timeout = setTimeout(this.connect.bind(this), 10000);
'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');
}, },
clearQR: function() { 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) { setProvisioningUrl: function(url) {
this.$('#qr').html(''); if ($('#qr').length === 0) {
new QRCode(this.$('#qr')[0]).makeCode(url); 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) { confirmNumber: function(number) {
var parsed = libphonenumber.parse(number); window.removeSetupMenuItems();
var stepId = '#step' + Steps.ENTER_NAME;
this.$(stepId + ' .number').text(libphonenumber.format(
parsed,
libphonenumber.PhoneNumberFormat.INTERNATIONAL
));
this.selectStep(Steps.ENTER_NAME); this.selectStep(Steps.ENTER_NAME);
this.$('#device-name').focus(); this.setDeviceNameDefault();
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
this.$(stepId + ' .cancel').click(function(e) { this.$('.finish').click(function(e) {
reject();
});
this.$(stepId).submit(function(e) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
var name = this.$('#device-name').val();
var name = this.$(DEVICE_NAME_SELECTOR).val();
name = name.replace(/\0/g,''); // strip unicode null name = name.replace(/\0/g,''); // strip unicode null
if (name.trim().length === 0) { if (name.trim().length === 0) {
this.$('#device-name').focus(); this.$(DEVICE_NAME_SELECTOR).focus();
return; return;
} }
this.$('.progress-dialog .status').text(i18n('installGeneratingKeys'));
this.selectStep(Steps.PROGRESS_BAR); this.selectStep(Steps.PROGRESS_BAR);
resolve(name); resolve(name);
}.bind(this)); }.bind(this));
}.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', className: 'settings modal expand',
templateName: 'settings', templateName: 'settings',
initialize: function() { initialize: function() {
this.deviceName = textsecure.storage.user.getDeviceName();
this.render(); this.render();
new RadioButtonGroupView({ new RadioButtonGroupView({
el: this.$('.notification-settings'), el: this.$('.notification-settings'),
@ -88,6 +89,8 @@
}, },
render_attributes: function() { render_attributes: function() {
return { return {
deviceNameLabel: i18n('deviceName'),
deviceName: this.deviceName,
theme: i18n('theme'), theme: i18n('theme'),
notifications: i18n('notifications'), notifications: i18n('notifications'),
notificationSettingsDialog: i18n('notificationSettingsDialog'), notificationSettingsDialog: i18n('notificationSettingsDialog'),

View file

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

74
main.js
View file

@ -37,11 +37,17 @@ function getMainWindow() {
// Tray icon and related objects // Tray icon and related objects
let tray = null; let tray = null;
const startInTray = process.argv.find(arg => arg === '--start-in-tray'); const startInTray = process.argv.some(arg => arg === '--start-in-tray');
const usingTrayIcon = startInTray || process.argv.find(arg => arg === '--use-tray-icon'); const usingTrayIcon = startInTray || process.argv.some(arg => arg === '--use-tray-icon');
const config = require('./app/config'); const config = require('./app/config');
const importMode = process.argv.some(arg => arg === '--import') || config.get('import');
const development = config.environment === 'development';
// Very important to put before the single instance check, since it is based on the // Very important to put before the single instance check, since it is based on the
// userData directory. // userData directory.
const userConfig = require('./app/user_config'); const userConfig = require('./app/user_config');
@ -119,6 +125,7 @@ function prepareURL(pathSegments) {
appInstance: process.env.NODE_APP_INSTANCE, appInstance: process.env.NODE_APP_INSTANCE,
polyfillNotifications: polyfillNotifications ? true : undefined, // for stringify() polyfillNotifications: polyfillNotifications ? true : undefined, // for stringify()
proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy,
importMode: importMode ? true : undefined, // for stringify()
}, },
}); });
} }
@ -334,6 +341,24 @@ function openForums() {
shell.openExternal('https://whispersystems.discoursehosting.net/'); shell.openExternal('https://whispersystems.discoursehosting.net/');
} }
function setupWithImport() {
if (mainWindow) {
mainWindow.webContents.send('set-up-with-import');
}
}
function setupAsNewDevice() {
if (mainWindow) {
mainWindow.webContents.send('set-up-as-new-device');
}
}
function setupAsStandalone() {
if (mainWindow) {
mainWindow.webContents.send('set-up-as-standalone');
}
}
let aboutWindow; let aboutWindow;
function showAbout() { function showAbout() {
@ -404,23 +429,31 @@ app.on('ready', () => {
tray = createTrayIcon(getMainWindow, locale.messages); tray = createTrayIcon(getMainWindow, locale.messages);
} }
const options = { setupMenu();
showDebugLog,
showWindow,
showAbout,
openReleaseNotes,
openNewBugForm,
openSupportPage,
openForums,
};
const template = createTemplate(options, locale.messages);
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}); });
/* eslint-enable more/no-then */ /* eslint-enable more/no-then */
}); });
function setupMenu(options) {
const menuOptions = Object.assign({}, options, {
development,
showDebugLog,
showWindow,
showAbout,
openReleaseNotes,
openNewBugForm,
openSupportPage,
openForums,
setupWithImport,
setupAsNewDevice,
setupAsStandalone,
});
const template = createTemplate(menuOptions, locale.messages);
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
app.on('before-quit', () => { app.on('before-quit', () => {
windowState.markShouldQuit(); windowState.markShouldQuit();
}); });
@ -454,6 +487,17 @@ ipc.on('set-badge-count', (event, count) => {
app.setBadgeCount(count); app.setBadgeCount(count);
}); });
ipc.on('remove-setup-menu-items', () => {
setupMenu();
});
ipc.on('add-setup-menu-items', () => {
setupMenu({
includeSetup: true,
});
});
ipc.on('draw-attention', () => { ipc.on('draw-attention', () => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.dock.bounce(); app.dock.bounce();

View file

@ -21,7 +21,8 @@
"build": "build --em.environment=$SIGNAL_ENV", "build": "build --em.environment=$SIGNAL_ENV",
"dist": "npm run generate && npm run build", "dist": "npm run generate && npm run build",
"pack": "npm run generate && npm run build -- --dir", "pack": "npm run generate && npm run build -- --dir",
"prepare-build": "node prepare_build.js", "prepare-beta-build": "node prepare_beta_build.js",
"prepare-import-build": "node prepare_import_build.js",
"pack-prod": "SIGNAL_ENV=production npm run pack", "pack-prod": "SIGNAL_ENV=production npm run pack",
"dist-prod": "SIGNAL_ENV=production npm run dist", "dist-prod": "SIGNAL_ENV=production npm run dist",
"dist-prod-all": "SIGNAL_ENV=production npm run dist -- -mwl", "dist-prod-all": "SIGNAL_ENV=production npm run dist -- -mwl",

View file

@ -42,6 +42,26 @@
Whisper.events.trigger('showDebugLog'); Whisper.events.trigger('showDebugLog');
}); });
ipc.on('set-up-with-import', function() {
Whisper.events.trigger('setupWithImport');
});
ipc.on('set-up-as-new-device', function() {
Whisper.events.trigger('setupAsNewDevice');
});
ipc.on('set-up-as-standalone', function() {
Whisper.events.trigger('setupAsStandalone');
});
window.addSetupMenuItems = function() {
ipc.send('add-setup-menu-items');
}
window.removeSetupMenuItems = function() {
ipc.send('remove-setup-menu-items');
}
// We pull these dependencies in now, from here, because they have Node.js dependencies // We pull these dependencies in now, from here, because they have Node.js dependencies
require('./js/logging'); require('./js/logging');

View file

@ -17,7 +17,7 @@ if (!beta.test(version)) {
process.exit(); process.exit();
} }
console.log('prepare_build: updating package.json for beta build'); console.log('prepare_beta_build: updating package.json');
// ------- // -------

60
prepare_import_build.js Normal file
View file

@ -0,0 +1,60 @@
const fs = require('fs');
const _ = require('lodash');
const packageJson = require('./package.json');
const defaultConfig = require('./config/default.json');
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
throw new Error(`${objectPath} was ${actual}; expected ${expected}`);
}
}
// You might be wondering why this file is necessary. We have some very specific
// requirements around our import-flavor builds. They need to look exactly the same as
// normal builds, but they must immediately open into import mode. So they need a
// slight config tweak, and then a change to the .app/.exe name (note: we do NOT want to
// change where data is stored or anything, since that would make these builds
// incompatible with the mainline builds) So we just change the artifact name.
//
// Another key thing to know about these builds is that we should not upload the
// latest.yml (windows) and latest-mac.yml (mac) that go along with the executables.
// This would interrupt the normal install flow for users installing from
// signal.org/download. So any release script will need to upload these files manually
// instead of relying on electron-builder, which will upload everything.
// -------
console.log('prepare_import_build: updating config/default.json');
const IMPORT_PATH = 'import';
const IMPORT_START_VALUE = false;
const IMPORT_END_VALUE = true;
checkValue(defaultConfig, IMPORT_PATH, IMPORT_START_VALUE);
_.set(defaultConfig, IMPORT_PATH, IMPORT_END_VALUE);
// -------
console.log('prepare_import_build: updating package.json');
const MAC_ASSET_PATH = 'build.mac.artifactName';
const MAC_ASSET_START_VALUE = '${name}-mac-${version}.${ext}';
const MAC_ASSET_END_VALUE = '${name}-mac-${version}-import.${ext}';
const WIN_ASSET_PATH = 'build.win.artifactName';
const WIN_ASSET_START_VALUE = '${name}-win-${version}.${ext}';
const WIN_ASSET_END_VALUE = '${name}-win-${version}-import.${ext}';
checkValue(packageJson, MAC_ASSET_PATH, MAC_ASSET_START_VALUE);
checkValue(packageJson, WIN_ASSET_PATH, WIN_ASSET_START_VALUE);
_.set(packageJson, MAC_ASSET_PATH, MAC_ASSET_END_VALUE);
_.set(packageJson, WIN_ASSET_PATH, WIN_ASSET_END_VALUE);
// ---
fs.writeFileSync('./config/default.json', JSON.stringify(defaultConfig, null, ' '));
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' '));

View file

@ -218,7 +218,7 @@ button.hamburger {
} }
.dropoff { .dropoff {
outline: solid 1px #2090ea; outline: solid 1px $blue;
} }
$avatar-size: 44px; $avatar-size: 44px;
@ -609,6 +609,281 @@ input[type=text], input[type=search], textarea {
} }
} }
.full-screen-flow {
z-index: 1000;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
font-family: roboto-light;
color: black;
a {
color: $blue;
}
background: linear-gradient(
to bottom,
rgb(238,238,238) 0%, // (1 - 0.41) * 255 + 0.41 * 213
rgb(243,243,243) 12%, // (1 - 0.19) * 255 + 0.19 * 191
rgb(255,255,255) 27%,
rgb(255,255,255) 60%,
rgb(249,249,249) 85%, // (1 - 0.19) * 255 + 0.19 * 222
rgb(213,213,213) 100% // (1 - 0.27) * 255 + 0.27 * 98
);
display: flex;
align-items: center;
text-align: center;
font-size: 10pt;
input {
margin-top: 1em;
font-size: 12pt;
font-family: roboto-light;
border: 2px solid $blue;
padding: 0.5em;
text-align: center;
width: 20em;
}
@media (min-height: 750px) and (min-width: 700px) {
font-size: 14pt;
input {
font-size: 16pt;
}
}
#qr {
display: inline-block;
&.ready {
border: 5px solid $blue;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
img {
height: 20em;
border: 5px solid white;
}
@media (max-height: 475px) {
img {
width: 8em;
height: 8em;
}
}
.dot {
width: 14px;
height: 14px;
border: 3px solid $blue;
border-radius: 50%;
float: left;
margin: 0 6px;
transform: scale(0);
animation: loading 1500ms ease infinite 0ms;
&:nth-child(2) {
animation: loading 1500ms ease infinite 333ms;
}
&:nth-child(3) {
animation: loading 1500ms ease infinite 666ms;
}
}
canvas {
display: none;
}
}
.os-icon {
height: 3em;
width: 3em;
vertical-align: text-bottom;
display: inline-block;
margin: 0.5em;
&.apple {
@include color-svg('../images/apple.svg', black);
}
&.android {
@include color-svg('../images/android.svg', black);
}
}
.header {
font-weight: normal;
margin-bottom: 1.5em;
font-size: 20pt;
@media (min-height: 750px) and (min-width: 700px) {
font-size: 28pt;
}
}
.body-text {
max-width: 22em;
text-align: left;
margin-left: auto;
margin-right: auto;
}
.body-text-wide {
max-width: 30em;
text-align: left;
margin-left: auto;
margin-right: auto;
}
.step {
height: 100%;
width: 100%;
padding: 70px 0 50px;
}
.step-body {
margin-left: auto;
margin-right: auto;
max-width: 35em;
}
.inner {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 100%;
}
.banner-image {
margin: 1em;
display: none;
@media (min-height: 550px) {
display: inline-block;
height: 10em;
width: 10em;
}
}
.banner-icon {
display: none;
margin: 1em;
// 640px by 338px is the smallest the window can go
@media (min-height: 550px) {
display: inline-block;
height: 10em;
width: 10em;
}
// generic
&.check-circle-outline {
@include color-svg('../images/check-circle-outline.svg', #DEDEDE);
}
&.alert-outline {
@include color-svg('../images/alert-outline.svg', #DEDEDE);
}
// import and export
&.folder-outline {
@include color-svg('../images/folder-outline.svg', #DEDEDE);
}
&.import {
@include color-svg('../images/import.svg', #DEDEDE);
}
&.export {
@include color-svg('../images/export.svg', #DEDEDE);
}
// registration process
&.lead-pencil {
@include color-svg('../images/lead-pencil.svg', #DEDEDE);
}
&.sync {
@include color-svg('../images/sync.svg', #DEDEDE);
}
}
.button {
cursor: pointer;
display: inline-block;
border: none;
min-width: 300px;
padding: 0.75em;
margin-top: 1em;
color: white;
background: $blue;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
font-size: 12pt;
@media (min-height: 750px) and (min-width: 700px) {
font-size: 20pt;
}
}
a.link {
display: block;
cursor: pointer;
text-decoration: underline;
margin: 0.5em;
color: #2090ea;
}
.progress {
text-align: center;
padding: 1em;
width: 80%;
margin: auto;
.bar-container {
height: 1em;
margin: 1em;
background-color: $grey_l;
}
.bar {
width: 100%;
height: 100%;
background-color: $blue_l;
transition: width 0.25s;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
}
.nav {
width: 100%;
bottom: 50px;
margin-top: auto;
padding-bottom: 2em;
padding-left: 20px;
padding-right: 20px;
.instructions {
text-align: left;
margin-left: auto;
margin-right: auto;
margin-bottom: 2em;
margin-top: 2em;
max-width: 30em;
}
.instructions:after {
clear: both;
}
.android {
float: left;
}
.apple {
float: right;
}
.label {
float: left;
}
.body {
float: left;
}
}
}
//yellow border fix //yellow border fix
.inbox:focus { .inbox:focus {
outline: none; outline: none;

View file

@ -11,6 +11,10 @@
hr { hr {
margin: 10px 0; margin: 10px 0;
} }
.device-name-settings {
text-align: center;
margin-bottom: 1em;
}
.syncSettings { .syncSettings {
button { button {
float: right; float: right;

View file

@ -6,335 +6,6 @@
background: url("../images/flags.png"); background: url("../images/flags.png");
} }
.install {
height: 100%;
background: #2090ea;
color: white;
text-align: center;
font-size: 16px;
overflow: auto;
input, button, select, textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.main {
padding: 70px 0 50px;
}
.hidden {
display: none;
}
.step {
height: 100%;
}
.inner {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 100%;
.step-body {
margin-top: auto;
width: 100%;
max-width: 600px;
}
}
#signal-computer,
#signal-phone {
max-width: 50%;
max-height: 250px;
}
p {
max-width: 35em;
margin: 1em auto;
padding: 0 1em;
line-height: 1.5em;
font-size: 1.2em;
font-weight: bold;
}
a {
cursor: pointer;
&, &:visited, &:hover {
text-decoration: none;
}
}
.button {
display: inline-block;
text-transform: uppercase;
border: none;
font-weight: bold;
min-width: 300px;
padding: 0.5em;
margin: 0.5em 0;
background: white;
color: $blue;
}
.nav {
width: 100%;
bottom: 50px;
margin-top: auto;
padding: 20px;
.dot-container {
margin-top: 3em;
}
.dot {
display: inline-block;
cursor: pointer;
margin: 10px;
width: 20px;
height: 20px;
border-radius: 10px;
background: white;
border: solid 5px $blue;
&.selected {
background: $blue_l;
}
}
}
&.install-choice .nav {
top: 20px;
margin-bottom: auto;
}
.link {
&:hover, &:focus {
background: rgba(255,255,255,0.3);
outline: none;
}
&, &:visited, &:hover {
padding: 0 3px;
color: white;
font-weight: bold;
border-bottom: dashed 2px white;
text-decoration: none;
}
}
.container {
min-width: 650px;
}
h1 {
font-size: 30pt;
font-weight: normal;
padding-bottom: 10px;
}
h3.step {
margin-top: 0;
font-weight: bold;
}
.help {
border-top: 2px solid $grey_l;
padding: 1.5em 0.1em;
}
.install {
display: inline-block;
margin-top: 90px;
}
#qr {
display: inline-block;
min-height: 266px;
img {
border: 5px solid white;
}
canvas {
display: none;
}
}
#device-name {
border: none;
border-bottom: 1px solid white;
padding: 8px;
background: transparent;
color: white;
font-weight: bold;
text-align: center;
&::selection, a::selection {
color: $grey_d;
background: white;
}
&::-moz-selection, a::-moz-selection {
color: $grey_d;
background: white;
}
&:focus {
outline: none;
}
&:hover, &:focus {
background: rgba(255,255,255,0.1);
}
}
#verifyCode,
#code,
#number {
box-sizing: border-box;
width: 100%;
display: block;
margin-bottom: 0.5em;
text-align: center;
}
#request-voice,
#request-sms {
box-sizing: border-box;
}
#request-sms {
width: 57%;
float: right;
}
#request-voice {
width: 40%;
float: left;
}
.number-container {
position: relative;
margin-bottom: 0.5em;
}
.number-container .intl-tel-input,
.number-container .number {
width: 100%;
}
.number-container::after {
visibility: hidden;
content: ' ';
display: inline-block;
border-radius: 1.5em;
width: 1.5em;
height: 1.5em;
line-height: 1.5em;
color: #ffffff;
position: absolute;
top: 0;
left: 100%;
margin: 3px 8px;
text-align: center;
}
.number-container.valid::after {
visibility: visible;
content: '';
background-color: #0f9d58;
color: #ffffff;
}
.number-container.invalid::after {
visibility: visible;
content: '!';
background-color: #f44336;
color: #ffffff;
}
#error {
color: white;
font-weight: bold;
padding: 0.5em;
text-align: center;
}
#error { background-color: #f44336; }
#error:before {
content: '\26a0';
padding-right: 0.5em;
}
.narrow {
margin: auto;
box-sizing: border-box;
width: 275px;
max-width: 100%;
}
ul.country-list {
min-width: 197px !important;
}
.confirmation-dialog, .progress-dialog {
padding: 1em;
text-align: left;
}
.number { text-align: center; }
.confirmation-dialog {
button {
float: right;
margin-left: 10px;
}
}
.progress-dialog {
text-align: center;
padding: 1em;
width: 100%;
max-width: 600px;
margin: auto;
.status { padding: 1em; }
.bar-container {
height: 1em;
background-color: $grey_l;
border: solid 1px white;
}
.bar {
width: 0;
height: 100%;
background-color: $blue_l;
transition: width 0.25s;
&.active {
}
}
}
.modal-container {
display: none;
position: absolute;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.1);
top: 0;
padding-top: 10em;
text-align: center;
.modal-main {
display: inline-block;
width: 80%;
max-width: 500px;
border: solid 2px $blue;
background: white;
margin: 10% auto;
box-shadow: 0 0 5px 3px rgba(darken($blue, 30%), 0.2);
h4 {
background-color: $blue;
color: white;
padding: 1em;
margin: 0;
text-align: left;
}
}
}
}
.intl-tel-input .country-list { .intl-tel-input .country-list {
text-align: left; text-align: left;
} }

View file

@ -2,18 +2,33 @@
* vim: ts=4:sw=4:expandtab * vim: ts=4:sw=4:expandtab
*/ */
describe('LastSeenIndicatorView', function() { describe('LastSeenIndicatorView', function() {
// TODO: in electron branch, where we have access to real i18n, test rendered HTML
it('renders provided count', function() { it('renders provided count', function() {
var view = new Whisper.LastSeenIndicatorView({count: 10}); var view = new Whisper.LastSeenIndicatorView({count: 10});
assert.equal(view.count, 10); assert.equal(view.count, 10);
view.render();
assert.match(view.$el.html(), /10 Unread Messages/);
});
it('renders count of 1', function() {
var view = new Whisper.LastSeenIndicatorView({count: 1});
assert.equal(view.count, 1);
view.render();
assert.match(view.$el.html(), /1 Unread Message/);
}); });
it('increments count', function() { it('increments count', function() {
var view = new Whisper.LastSeenIndicatorView({count: 4}); var view = new Whisper.LastSeenIndicatorView({count: 4});
assert.equal(view.count, 4); assert.equal(view.count, 4);
view.render();
assert.match(view.$el.html(), /4 Unread Messages/);
view.increment(3); view.increment(3);
assert.equal(view.count, 7); assert.equal(view.count, 7);
view.render();
assert.match(view.$el.html(), /7 Unread Messages/);
}); });
}); });

View file

@ -2,13 +2,11 @@
* vim: ts=4:sw=4:expandtab * vim: ts=4:sw=4:expandtab
*/ */
describe('ScrollDownButtonView', function() { describe('ScrollDownButtonView', function() {
// TODO: in electron branch, where we have access to real i18n, uncomment assertions against real strings
it('renders with count = 0', function() { it('renders with count = 0', function() {
var view = new Whisper.ScrollDownButtonView(); var view = new Whisper.ScrollDownButtonView();
view.render(); view.render();
assert.equal(view.count, 0); assert.equal(view.count, 0);
// assert.match(view.$el.html(), /Scroll to bottom/); assert.match(view.$el.html(), /Scroll to bottom/);
}); });
it('renders with count = 1', function() { it('renders with count = 1', function() {
@ -16,7 +14,7 @@ describe('ScrollDownButtonView', function() {
view.render(); view.render();
assert.equal(view.count, 1); assert.equal(view.count, 1);
assert.match(view.$el.html(), /new-messages/); assert.match(view.$el.html(), /new-messages/);
// assert.match(view.$el.html(), /New message below/); assert.match(view.$el.html(), /New message below/);
}); });
it('renders with count = 2', function() { it('renders with count = 2', function() {
@ -25,7 +23,7 @@ describe('ScrollDownButtonView', function() {
assert.equal(view.count, 2); assert.equal(view.count, 2);
assert.match(view.$el.html(), /new-messages/); assert.match(view.$el.html(), /new-messages/);
// assert.match(view.$el.html(), /New messages below/); assert.match(view.$el.html(), /New messages below/);
}); });
it('increments count and re-renders', function() { it('increments count and re-renders', function() {