fix(sync-errors): Handle account deltas indicating sync issues
Summary: Changes the delta code to handle new deltas on the Account object, which are triggered by changes in sync state indicating various backend issues. Saves the sync state in a new field on the Account object, which is persisited in `config.cson`. Includes several UI changes to display more information when an account has backend sync issues. Adds better messages and new actions the user can take based on the type of sync issue. Additionally, fixes bug in action bridge that was preventing multi-arg global actions from working. Test Plan: Manual, by testing different sync state values and triggering deltas from the backend Reviewers: juan, evan, bengotow Reviewed By: evan, bengotow Subscribers: khamidou Differential Revision: https://phab.nylas.com/D2696
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 18 KiB |
|
@ -0,0 +1,93 @@
|
|||
import {AccountStore, Account, Actions, React} from 'nylas-exports'
|
||||
import {RetinaImg} from 'nylas-component-kit'
|
||||
|
||||
export default class AccountErrorHeader extends React.Component {
|
||||
static displayName = 'AccountErrorHeader';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = this.getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = AccountStore.listen(() => this._onAccountsChanged());
|
||||
}
|
||||
|
||||
getStateFromStores() {
|
||||
return {accounts: AccountStore.accounts()}
|
||||
}
|
||||
|
||||
_onAccountsChanged() {
|
||||
this.setState(this.getStateFromStores())
|
||||
}
|
||||
|
||||
_reconnect(account) {
|
||||
const ipc = require('electron').ipcRenderer;
|
||||
ipc.send('command', 'application:add-account', account.provider);
|
||||
}
|
||||
|
||||
_openPreferences() {
|
||||
Actions.switchPreferencesTab('Accounts');
|
||||
Actions.openPreferences()
|
||||
}
|
||||
|
||||
_contactSupport() {
|
||||
const {shell} = require("electron");
|
||||
shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
|
||||
}
|
||||
|
||||
renderErrorHeader(message, buttonName, actionCallback) {
|
||||
return (
|
||||
<div className="sync-issue notifications-sticky">
|
||||
<div className={"notifications-sticky-item notification-error has-default-action"}
|
||||
onClick={actionCallback}>
|
||||
<div>
|
||||
<div className="icon">
|
||||
<RetinaImg
|
||||
name="icon-alert-onred.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve} />
|
||||
</div>{message}</div>
|
||||
<a className="action default" onClick={actionCallback}>
|
||||
{buttonName}
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
render() {
|
||||
const errorAccounts = this.state.accounts.filter(a => a.syncState !== "running");
|
||||
if (errorAccounts.length === 1) {
|
||||
const account = errorAccounts[0];
|
||||
|
||||
switch (account.syncState) {
|
||||
|
||||
case Account.SYNC_STATE_AUTH_FAILED:
|
||||
return this.renderErrorHeader(
|
||||
`Nylas N1 can no longer authenticate with ${account.emailAddress}. Click here to reconnect.`,
|
||||
"Reconnect",
|
||||
()=>this._reconnect(account));
|
||||
|
||||
case Account.SYNC_STATE_STOPPED:
|
||||
return this.renderErrorHeader(
|
||||
`The cloud sync for ${account.emailAddress} has been disabled. You will
|
||||
not be able to send or receive mail. Please contact Nylas support.`,
|
||||
"Contact support",
|
||||
()=>this._contactSupport());
|
||||
|
||||
default:
|
||||
return this.renderErrorHeader(
|
||||
`Nylas encountered an error while syncing mail for ${account.emailAddress} - we're
|
||||
looking into it. Contact Nylas support for details.`,
|
||||
"Contact support",
|
||||
()=>this._contactSupport());
|
||||
}
|
||||
}
|
||||
if (errorAccounts.length > 1) {
|
||||
return this.renderErrorHeader("Several of your accounts are having issues. " +
|
||||
"You will not be able to send or receive mail. Click here to manage your accounts.",
|
||||
"Open preferences",
|
||||
()=>this._openPreferences());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
12
internal_packages/account-error-header/lib/main.es6
Normal file
|
@ -0,0 +1,12 @@
|
|||
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
|
||||
import AccountErrorHeader from './account-error-header';
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(AccountErrorHeader, {location: WorkspaceStore.Sheet.Threads.Header});
|
||||
}
|
||||
|
||||
export function serialize() {}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(AccountErrorHeader);
|
||||
}
|
13
internal_packages/account-error-header/package.json
Executable file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "account-error-header",
|
||||
"version": "0.1.0",
|
||||
"main": "./lib/main",
|
||||
"description": "Header to display errors syncing the active account",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"nylas": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
@import "ui-variables";
|
||||
@import "ui-mixins";
|
||||
|
||||
|
||||
.sync-issue.notifications-sticky {
|
||||
.notifications-sticky-item {
|
||||
background-color: initial;
|
||||
background: linear-gradient(to top, #ca2541 0%, #d55268 100%);
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 16px;
|
||||
height: 100%;
|
||||
margin-right: 9px;
|
||||
|
||||
img {
|
||||
vertical-align: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'underscore';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {EditableList, NewsletterSignup} from 'nylas-component-kit';
|
||||
import {RegExpUtils} from 'nylas-exports';
|
||||
import {RegExpUtils, Account} from 'nylas-exports';
|
||||
|
||||
class PreferencesAccountDetails extends Component {
|
||||
|
||||
|
@ -105,6 +105,15 @@ class PreferencesAccountDetails extends Component {
|
|||
this._setStateAndSave({defaultAlias});
|
||||
};
|
||||
|
||||
_reconnect() {
|
||||
const {account} = this.state;
|
||||
const ipc = require('electron').ipcRenderer;
|
||||
ipc.send('command', 'application:add-account', account.provider);
|
||||
}
|
||||
_contactSupport() {
|
||||
const {shell} = require("electron");
|
||||
shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
|
||||
}
|
||||
|
||||
// Renderers
|
||||
|
||||
|
@ -124,6 +133,37 @@ class PreferencesAccountDetails extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
_renderErrorDetail(message, buttonText, buttonAction) {
|
||||
return (<div className="account-error-detail">
|
||||
<div className="message">{message}</div>
|
||||
<a className="action" onClick={buttonAction}>{buttonText}</a>
|
||||
</div>)
|
||||
}
|
||||
_renderSyncErrorDetails() {
|
||||
const {account} = this.state;
|
||||
if (account.syncState !== Account.SYNC_STATE_RUNNING) {
|
||||
switch (account.syncState) {
|
||||
case Account.SYNC_STATE_AUTH_FAILED:
|
||||
return this._renderErrorDetail(
|
||||
`Nylas N1 can no longer authenticate with ${account.emailAddress}. The password or
|
||||
authentication may have changed.`,
|
||||
"Reconnect",
|
||||
()=>this._reconnect());
|
||||
case Account.SYNC_STATE_STOPPED:
|
||||
return this._renderErrorDetail(
|
||||
`The cloud sync for ${account.emailAddress} has been disabled. Please contact Nylas support.`,
|
||||
"Contact support",
|
||||
()=>this._contactSupport());
|
||||
default:
|
||||
return this._renderErrorDetail(
|
||||
`Nylas encountered an error while syncing mail for ${account.emailAddress}. Contact Nylas support for details.`,
|
||||
"Contact support",
|
||||
()=>this._contactSupport());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {account} = this.state;
|
||||
const aliasPlaceholder = this._makeAlias(
|
||||
|
@ -132,6 +172,7 @@ class PreferencesAccountDetails extends Component {
|
|||
|
||||
return (
|
||||
<div className="account-details">
|
||||
{this._renderSyncErrorDetails(account)}
|
||||
<h3>Account Label</h3>
|
||||
<input
|
||||
type="text"
|
||||
|
@ -159,6 +200,8 @@ class PreferencesAccountDetails extends Component {
|
|||
<div className="newsletter">
|
||||
<NewsletterSignup emailAddress={account.emailAddress} name={account.name} />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import React, {Component, PropTypes} from 'react';
|
||||
import {RetinaImg, Flexbox, EditableList} from 'nylas-component-kit';
|
||||
import {Account} from 'nylas-exports';
|
||||
import classnames from 'classnames';
|
||||
|
||||
class PreferencesAccountList extends Component {
|
||||
|
||||
|
@ -12,23 +14,35 @@ class PreferencesAccountList extends Component {
|
|||
onRemoveAccount: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_renderAccountStateIcon(account) {
|
||||
if (account.syncState !== "running") {
|
||||
return (<div className="sync-error-icon"><RetinaImg
|
||||
className="sync-error-icon"
|
||||
name="ic-settings-account-error.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} /></div>)
|
||||
}
|
||||
}
|
||||
|
||||
_renderAccount = (account)=> {
|
||||
const label = account.label;
|
||||
const accountSub = `${account.name || 'No name provided'} <${account.emailAddress}>`;
|
||||
const syncError = account.syncState !== Account.SYNC_STATE_RUNNING;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="account"
|
||||
className={classnames({account: true, "sync-error": syncError})}
|
||||
key={account.id} >
|
||||
<Flexbox direction="row" style={{alignItems: 'middle'}}>
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<RetinaImg
|
||||
name={`ic-settings-account-${account.provider}.png`}
|
||||
name={syncError ? "ic-settings-account-error.png" : `ic-settings-account-${account.provider}.png`}
|
||||
fallback="ic-settings-account-imap.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve} />
|
||||
</div>
|
||||
<div style={{flex: 1, marginLeft: 10}}>
|
||||
<div className="account-name">{label}</div>
|
||||
<div className="account-name">
|
||||
{label}
|
||||
</div>
|
||||
<div className="account-subtext">{accountSub} ({account.displayProvider()})</div>
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
|
|
@ -18,11 +18,16 @@
|
|||
border-bottom: 1px solid @border-color-divider;
|
||||
}
|
||||
|
||||
.list-item:not(.selected) .sync-error {
|
||||
color: @text-color-error;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-size: @font-size-large;
|
||||
cursor:default;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.account-subtext {
|
||||
|
@ -52,6 +57,29 @@
|
|||
height: 140px;
|
||||
}
|
||||
|
||||
.account-error-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(to top, #ca2541 0%, #d55268 100%);
|
||||
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(0,0,0,0.15);
|
||||
text-align: center;
|
||||
padding: 3px @padding-base-horizontal;
|
||||
color: @text-color-inverse
|
||||
}
|
||||
.action:hover {
|
||||
background-color: rgba(255,255,255,0.15);
|
||||
text-decoration:none;
|
||||
}
|
||||
.message {
|
||||
flex-grow: 1;
|
||||
padding: 3px @padding-base-horizontal;
|
||||
color: @text-color-inverse
|
||||
}
|
||||
}
|
||||
|
||||
.newsletter {
|
||||
padding-top: @padding-base-vertical * 2;
|
||||
input[type=checkbox] { margin: 0; position: relative; top: 0; }
|
||||
|
|
|
@ -98,7 +98,7 @@ class NylasLongConnection
|
|||
@withCursor (cursor) =>
|
||||
return if @state is NylasLongConnection.State.Ended
|
||||
console.log("Delta Connection: Starting for account #{@_accountId}, token #{token}, with cursor #{cursor}")
|
||||
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false&exclude_metadata=false")
|
||||
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false&exclude_metadata=false&exclude_account=false")
|
||||
options.auth = "#{token}:"
|
||||
|
||||
if @_api.APIRoot.indexOf('https') is -1
|
||||
|
|
|
@ -84,6 +84,14 @@ class NylasSyncWorkerPool
|
|||
metadata = metadata.concat(_.values(deltas['metadata']))
|
||||
delete deltas['metadata']
|
||||
|
||||
# Remove any account deltas, which are only used to notify broken/fixed sync state
|
||||
# on accounts
|
||||
delete create['account']
|
||||
delete destroy['account']
|
||||
if modify['account']
|
||||
@_handleAccountDeltas(_.values(modify['account']))
|
||||
delete modify['account']
|
||||
|
||||
# Apply all the deltas to create objects. Gets promises for handling
|
||||
# each type of model in the `create` hash, waits for them all to resolve.
|
||||
create[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of create
|
||||
|
@ -139,6 +147,10 @@ class NylasSyncWorkerPool
|
|||
localMetadatum.version = metadatum.version
|
||||
t.persistModel(model)
|
||||
|
||||
_handleAccountDeltas: (deltas) =>
|
||||
for delta in deltas
|
||||
Actions.updateAccount(delta.account_id, {syncState: delta.sync_state})
|
||||
|
||||
_handleDeltaDeletion: (delta) =>
|
||||
klass = NylasAPI._apiObjectToClassMap[delta.object]
|
||||
return unless klass
|
||||
|
|
|
@ -49,7 +49,7 @@ class ActionBridge
|
|||
|
||||
# Observe all global actions and re-broadcast them to other windows
|
||||
Actions.globalActions.forEach (name) =>
|
||||
callback = => @onRebroadcast(TargetWindows.ALL, name, arguments)
|
||||
callback = (args...) => @onRebroadcast(TargetWindows.ALL, name, args)
|
||||
Actions[name].listen(callback, @)
|
||||
|
||||
# Observe the database store (possibly other stores in the future), and
|
||||
|
@ -63,7 +63,7 @@ class ActionBridge
|
|||
# Observe all mainWindow actions fired in this window and re-broadcast
|
||||
# them to other windows so the central application stores can take action
|
||||
Actions.workWindowActions.forEach (name) =>
|
||||
callback = => @onRebroadcast(TargetWindows.WORK, name, arguments)
|
||||
callback = (args...) => @onRebroadcast(TargetWindows.WORK, name, args)
|
||||
Actions[name].listen(callback, @)
|
||||
|
||||
onIPCMessage: (event, initiatorId, name, json) =>
|
||||
|
@ -90,7 +90,7 @@ class ActionBridge
|
|||
else
|
||||
throw new Error("#{@initiatorId} received unknown action-bridge event: #{name}")
|
||||
|
||||
onRebroadcast: (target, name, args...) =>
|
||||
onRebroadcast: (target, name, args) =>
|
||||
if Actions[name]?.firing
|
||||
Actions[name].firing = false
|
||||
return
|
||||
|
@ -99,7 +99,7 @@ class ActionBridge
|
|||
args.forEach (arg) ->
|
||||
if arg instanceof Function
|
||||
throw new Error("ActionBridge cannot forward action argument of type `function` to work window.")
|
||||
params.push(arg[0])
|
||||
params.push(arg)
|
||||
|
||||
json = JSON.stringify(params, Utils.registeredObjectReplacer)
|
||||
|
||||
|
|
|
@ -171,7 +171,7 @@ class Actions
|
|||
Actions.updateAccount(account.id, {accountName: 'new'})
|
||||
```
|
||||
###
|
||||
@updateAccount: ActionScopeWindow
|
||||
@updateAccount: ActionScopeGlobal
|
||||
|
||||
###
|
||||
Public: Re-order the provided account in the account list.
|
||||
|
|
|
@ -29,6 +29,11 @@ Section: Models
|
|||
###
|
||||
class Account extends ModelWithMetadata
|
||||
|
||||
@SYNC_STATE_RUNNING = "running"
|
||||
@SYNC_STATE_STOPPED = "stopped"
|
||||
@SYNC_STATE_AUTH_FAILED = "invalid"
|
||||
@SYNC_STATE_ERROR = "sync_error"
|
||||
|
||||
@attributes: _.extend {}, ModelWithMetadata.attributes,
|
||||
'name': Attributes.String
|
||||
modelKey: 'name'
|
||||
|
@ -58,10 +63,16 @@ class Account extends ModelWithMetadata
|
|||
modelKey: 'defaultAlias'
|
||||
jsonKey: 'default_alias'
|
||||
|
||||
'syncState': Attributes.String
|
||||
queryable: false
|
||||
modelKey: 'syncState'
|
||||
jsonKey: 'sync_state'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@aliases ||= []
|
||||
@label ||= @emailAddress
|
||||
@syncState ||= "running"
|
||||
|
||||
fromJSON: (json) ->
|
||||
json["label"] ||= json[@constructor.attributes['emailAddress'].jsonKey]
|
||||
|
|
|
@ -127,12 +127,6 @@ class NylasAPI
|
|||
NylasEnv.config.onDidChange('env', @_onConfigChanged)
|
||||
@_onConfigChanged()
|
||||
|
||||
if NylasEnv.isMainWindow()
|
||||
Actions.notificationActionTaken.listen ({notification, action}) ->
|
||||
if action.id is '401:reconnect'
|
||||
ipc = require('electron').ipcRenderer
|
||||
ipc.send('command', 'application:add-account', action.provider)
|
||||
|
||||
_onConfigChanged: =>
|
||||
prev = {@AppID, @APIRoot, @APITokens}
|
||||
|
||||
|
@ -245,17 +239,16 @@ class NylasAPI
|
|||
type: 'error'
|
||||
tag: '401'
|
||||
sticky: true
|
||||
message: "Nylas N1 can no longer authenticate with #{email}. You
|
||||
will not be able to send or receive mail. Please click
|
||||
here to reconnect your account.",
|
||||
message: "Action failed: There was an error syncing with #{email}. You
|
||||
may not be able to send or receive mail.",
|
||||
icon: 'fa-sign-out'
|
||||
actions: [{
|
||||
default: true
|
||||
dismisses: true
|
||||
label: 'Reconnect'
|
||||
provider: account?.provider ? ""
|
||||
id: '401:reconnect'
|
||||
}]
|
||||
default: true
|
||||
dismisses: true
|
||||
label: 'Dismiss'
|
||||
provider: account?.provider ? ""
|
||||
id: '401:dismiss'
|
||||
}]
|
||||
|
||||
return Promise.resolve()
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ class AccountStore extends NylasStore
|
|||
# Inbound Events
|
||||
|
||||
_onUpdateAccount: (id, updated) =>
|
||||
return unless NylasEnv.isMainWindow()
|
||||
idx = _.findIndex @_accounts, (a) -> a.id is id
|
||||
account = @_accounts[idx]
|
||||
return if !account
|
||||
|
|
BIN
static/images/notification/icon-alert-onred@1x.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
static/images/notification/icon-alert-onred@2x.png
Normal file
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 602 B |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 5 KiB |