feat(offline-status): Show a bar when not connected to the API

Summary:
The TaskQueue does it's own throttling and has it's own processQueue retry timeout, no need for longPollConnected

Remove dead code (OfflineError)

Rename long connection state to status so we don't ask for `state.state`

Remove long poll actions related to online/offline in favor of exposing connection state through NylasSyncStatusStore

Consoliate notifications and account-error-heaer into a single package and organize files into sidebar vs. header.

Update the DeveloperBarStore to query the sync status store for long poll statuses

Test Plan: All existing tests pass

Reviewers: juan, evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2835
This commit is contained in:
Ben Gotow 2016-04-04 17:11:09 -07:00
parent 2ea0c3b078
commit a3ede94423
34 changed files with 432 additions and 336 deletions

View file

@ -1,12 +0,0 @@
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);
}

View file

@ -1,13 +0,0 @@
{
"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": {
}
}

View file

@ -1,22 +0,0 @@
@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;
}
}
}
}

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,8 +0,0 @@
{Actions} = require 'nylas-exports'
NylasStore = require 'nylas-store'
class AccountSidebarLongPollStore extends NylasStore
constructor: ->
@listenTo Actions.longPollReceivedRawDeltasPing, (n) => @trigger(n)
module.exports = new AccountSidebarLongPollStore()

View file

@ -38,15 +38,16 @@ export default class AccountErrorHeader extends React.Component {
renderErrorHeader(message, buttonName, actionCallback) {
return (
<div className="sync-issue notifications-sticky">
<div className="account-error-header 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>
<RetinaImg
className="icon"
name="icon-alert-onred.png"
mode={RetinaImg.Mode.ContentPreserve} />
<div className="message">
{message}
</div>
<a className="action default" onClick={actionCallback}>
{buttonName}
</a>

View file

@ -0,0 +1,101 @@
import {NylasSyncStatusStore, React, Actions} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
export default class ConnectionStatusHeader extends React.Component {
static displayName = 'ConnectionStatusHeader';
constructor() {
super();
this._updateInterval = null;
this.state = this.getStateFromStores();
}
componentDidMount() {
this.unsubscribe = NylasSyncStatusStore.listen(()=> {
const nextState = this.getStateFromStores();
if ((nextState.connected !== this.state.connected) || (nextState.nextRetryText !== this.state.nextRetryText)) {
this.setState(nextState);
}
});
window.addEventListener('browser-window-focus', this.onWindowFocusChanged);
window.addEventListener('browser-window-blur', this.onWindowFocusChanged);
this.ensureCountdownInterval();
}
componentDidUpdate() {
this.ensureCountdownInterval();
}
componentWillUnmount() {
window.removeEventListener('browser-window-focus', this.onWindowFocusChanged);
window.removeEventListener('browser-window-blur', this.onWindowFocusChanged);
}
onTryAgain = () => {
Actions.retrySync();
}
onWindowFocusChanged = () => {
this.setState(this.getStateFromStores());
this.ensureCountdownInterval();
}
getStateFromStores() {
const nextRetryTimestamp = NylasSyncStatusStore.nextRetryTimestamp();
const connected = NylasSyncStatusStore.connected();
let nextRetryText = null;
if (!connected) {
if (document.body.classList.contains('is-blurred')) {
nextRetryText = 'soon';
} else {
const seconds = Math.ceil((nextRetryTimestamp - Date.now()) / 1000.0);
if (seconds > 1) {
nextRetryText = `in ${seconds} seconds`;
} else {
nextRetryText = `now`;
}
}
}
return {connected, nextRetryText};
}
ensureCountdownInterval = () => {
if (this._updateInterval) {
clearInterval(this._updateInterval);
}
// only count down the "Reconnecting in..." label if the window is in the
// foreground to avoid the battery hit.
if (!this.state.connected && !document.body.classList.contains('is-blurred')) {
this._updateInterval = setInterval(() => {
this.setState(this.getStateFromStores());
}, 1000);
}
}
render() {
const {connected, nextRetryText} = this.state;
if (connected) {
return (<span/>);
}
return (
<div className="connection-status-header notifications-sticky">
<div className={"notifications-sticky-item notification-offline"}>
<RetinaImg
className="icon"
name="icon-alert-onred.png"
mode={RetinaImg.Mode.ContentPreserve} />
<div className="message">
Nylas N1 isn't able to reach api.nylas.com. Retrying {nextRetryText}.
</div>
<a className="action default" onClick={this.onTryAgain}>
Try Again Now
</a>
</div>
</div>
);
}
}

View file

@ -0,0 +1,40 @@
React = require 'react'
NotificationStore = require '../notifications-store'
NotificationsItem = require './notifications-item'
class NotificationsHeader extends React.Component
@displayName: "NotificationsHeader"
@containerRequired: false
constructor: (@props) ->
@state = @_getStateFromStores()
_getStateFromStores: =>
items: NotificationStore.stickyNotifications()
_onDataChanged: =>
@setState @_getStateFromStores()
componentDidMount: =>
@_unlistener = NotificationStore.listen(@_onDataChanged, @)
@
# It's important that every React class explicitly stops listening to
# N1 events before it unmounts. Thank you event-kit
# This can be fixed via a Reflux mixin
componentWillUnmount: =>
@_unlistener() if @_unlistener
@
render: =>
<div className="notifications-sticky">
{@_notificationComponents()}
</div>
_notificationComponents: =>
@state.items.map (notif) ->
<NotificationsItem notification={notif} key={notif.message} />
module.exports = NotificationsHeader

View file

@ -0,0 +1,39 @@
React = require 'react'
{Actions} = require 'nylas-exports'
class NotificationsItem extends React.Component
@displayName: "NotificationsItem"
render: =>
notif = @props.notification
iconClass = if notif.icon then "fa #{notif.icon}" else ""
actionDefault = null
actionComponents = notif.actions?.map (action) =>
classname = "action "
if action.default
actionDefault = action
classname += "default"
actionClick = (event) =>
@_fireItemAction(notif, action)
event.stopPropagation()
event.preventDefault()
<a className={classname} key={action.label} onClick={actionClick}>
{action.label}
</a>
if actionDefault
<div className={"notifications-sticky-item notification-#{notif.type} has-default-action"}
onClick={=> @_fireItemAction(notif, actionDefault)}>
<i className={iconClass}></i><div className="message">{notif.message}</div>{actionComponents}
</div>
else
<div className={"notifications-sticky-item notification-#{notif.type}"}>
<i className={iconClass}></i><div className="message">{notif.message}</div>{actionComponents}
</div>
_fireItemAction: (notification, action) =>
Actions.notificationActionTaken({notification, action})
module.exports = NotificationsItem

View file

@ -1,21 +0,0 @@
React = require "react"
ActivitySidebar = require "./activity-sidebar"
NotificationStore = require './notifications-store'
NotificationsStickyBar = require "./notifications-sticky-bar"
{ComponentRegistry, WorkspaceStore} = require("nylas-exports")
module.exports =
item: null # The DOM item the main React component renders into
activate: (@state={}) ->
ComponentRegistry.register ActivitySidebar,
location: WorkspaceStore.Location.RootSidebar
ComponentRegistry.register NotificationsStickyBar,
location: WorkspaceStore.Sheet.Global.Header
deactivate: ->
ComponentRegistry.unregister(ActivitySidebar)
ComponentRegistry.unregister(NotificationsStickyBar)
serialize: -> @state

View file

@ -0,0 +1,24 @@
/* eslint no-unused-vars:0 */
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
import ActivitySidebar from "./sidebar/activity-sidebar";
import NotificationStore from './notifications-store';
import ConnectionStatusHeader from './headers/connection-status-header';
import AccountErrorHeader from './headers/account-error-header';
import NotificationsHeader from "./headers/notifications-header";
export function activate() {
ComponentRegistry.register(ActivitySidebar, {location: WorkspaceStore.Location.RootSidebar});
ComponentRegistry.register(NotificationsHeader, {location: WorkspaceStore.Sheet.Global.Header});
ComponentRegistry.register(ConnectionStatusHeader, {location: WorkspaceStore.Sheet.Global.Header});
ComponentRegistry.register(AccountErrorHeader, {location: WorkspaceStore.Sheet.Threads.Header});
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(ActivitySidebar);
ComponentRegistry.unregister(NotificationsHeader);
ComponentRegistry.unregister(ConnectionStatusHeader);
ComponentRegistry.unregister(AccountErrorHeader);
}

View file

@ -1,76 +0,0 @@
React = require 'react'
{Actions} = require 'nylas-exports'
NotificationStore = require './notifications-store'
class NotificationStickyItem extends React.Component
@displayName: "NotificationStickyItem"
render: =>
notif = @props.notification
iconClass = if notif.icon then "fa #{notif.icon}" else ""
actionDefault = null
actionComponents = notif.actions?.map (action) =>
classname = "action "
if action.default
actionDefault = action
classname += "default"
actionClick = (event) =>
@_fireItemAction(notif, action)
event.stopPropagation()
event.preventDefault()
<a className={classname} key={action.label} onClick={actionClick}>
{action.label}
</a>
if actionDefault
<div className={"notifications-sticky-item notification-#{notif.type} has-default-action"}
onClick={=> @_fireItemAction(notif, actionDefault)}>
<i className={iconClass}></i><div>{notif.message}</div>{actionComponents}
</div>
else
<div className={"notifications-sticky-item notification-#{notif.type}"}>
<i className={iconClass}></i><div>{notif.message}</div>{actionComponents}
</div>
_fireItemAction: (notification, action) =>
Actions.notificationActionTaken({notification, action})
class NotificationStickyBar extends React.Component
@displayName: "NotificationsStickyBar"
@containerRequired: false
constructor: (@props) ->
@state = @_getStateFromStores()
_getStateFromStores: =>
items: NotificationStore.stickyNotifications()
_onDataChanged: =>
@setState @_getStateFromStores()
componentDidMount: =>
@_unlistener = NotificationStore.listen(@_onDataChanged, @)
@
# It's important that every React class explicitly stops listening to
# N1 events before it unmounts. Thank you event-kit
# This can be fixed via a Reflux mixin
componentWillUnmount: =>
@_unlistener() if @_unlistener
@
render: =>
<div className="notifications-sticky">
{@_notificationComponents()}
</div>
_notificationComponents: =>
@state.items.map (notif) ->
<NotificationStickyItem notification={notif} key={notif.message} />
module.exports = NotificationStickyBar

View file

@ -4,15 +4,15 @@ ReactCSSTransitionGroup = require 'react-addons-css-transition-group'
_ = require 'underscore'
classNames = require 'classnames'
NotificationStore = require './notifications-store'
NotificationStore = require '../notifications-store'
StreamingSyncActivity = require './streaming-sync-activity'
InitialSyncActivity = require './initial-sync-activity'
{Actions,
TaskQueue,
AccountStore,
NylasSyncStatusStore,
TaskQueueStatusStore} = require 'nylas-exports'
ActivitySidebarLongPollStore = require './activity-sidebar-long-poll-store'
{RetinaImg} = require 'nylas-component-kit'
class ActivitySidebar extends React.Component
@displayName: 'ActivitySidebar'
@ -30,7 +30,6 @@ class ActivitySidebar extends React.Component
@_unlisteners.push TaskQueueStatusStore.listen @_onDataChanged
@_unlisteners.push NotificationStore.listen @_onDataChanged
@_unlisteners.push NylasSyncStatusStore.listen @_onDataChanged
@_unlisteners.push ActivitySidebarLongPollStore.listen @_onDeltaReceived
componentWillUnmount: =>
unlisten() for unlisten in @_unlisteners
@ -39,12 +38,10 @@ class ActivitySidebar extends React.Component
items = [@_renderNotificationActivityItems(), @_renderTaskActivityItems()]
if @state.isInitialSyncComplete
if @state.receivingDelta
items.push @_renderDeltaSyncActivityItem()
items.push <StreamingSyncActivity key="streaming-sync" />
else
items.push <InitialSyncActivity key="initial-sync" />
names = classNames
"sidebar-activity": true
"sidebar-activity-error": error?
@ -87,16 +84,6 @@ class ActivitySidebar extends React.Component
</div>
</div>
_renderDeltaSyncActivityItem: =>
<div className="item" key="delta-sync-item">
<div style={padding: "9px 9px 0 12px", float: "left"}>
<RetinaImg name="sending-spinner.gif" width={18} mode={RetinaImg.Mode.ContentPreserve} />
</div>
<div className="inner">
Syncing your mailbox&hellip;
</div>
</div>
_renderNotificationActivityItems: =>
@state.notifications.map (notification) ->
<div className="item" key={notification.id}>
@ -113,19 +100,4 @@ class ActivitySidebar extends React.Component
tasks: TaskQueueStatusStore.queue()
isInitialSyncComplete: NylasSyncStatusStore.isSyncComplete()
_onDeltaReceived: (countDeltas) =>
tooSmallForNotification = countDeltas <= 10
return if tooSmallForNotification
if @_timeoutId
clearTimeout @_timeoutId
@_timeoutId = setTimeout(( =>
delete @_timeoutId
@setState receivingDelta: false
), 30000)
@setState receivingDelta: true
module.exports = ActivitySidebar

View file

@ -108,6 +108,6 @@ class InitialSyncActivity extends React.Component
</div>
_onTryAgain: =>
Actions.retryInitialSync()
Actions.retrySync()
module.exports = InitialSyncActivity

View file

@ -0,0 +1,44 @@
{Actions, React} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
class StreamingSyncActivity extends React.Component
constructor: (@props) ->
@_timeoutId = null
@state =
receivingDelta: false
componentDidMount: =>
@_unlistener = Actions.longPollReceivedRawDeltasPing.listen(@_onDeltaReceived)
componentWillUnmount: =>
@_unlistener() if @_unlistener
clearTimeout(@_timeoutId) if @_timeoutId
render: =>
return false unless @state.receivingDelta
<div className="item" key="delta-sync-item">
<div style={padding: "9px 9px 0 12px", float: "left"}>
<RetinaImg name="sending-spinner.gif" width={18} mode={RetinaImg.Mode.ContentPreserve} />
</div>
<div className="inner">
Syncing your mailbox&hellip;
</div>
</div>
_onDeltaReceived: (countDeltas) =>
tooSmallForNotification = countDeltas <= 10
return if tooSmallForNotification
if @_timeoutId
clearTimeout(@_timeoutId)
@_timeoutId = setTimeout(( =>
delete(@_timeoutId)
@setState(receivingDelta: false)
), 20000)
@setState(receivingDelta: true)
module.exports = StreamingSyncActivity

View file

@ -126,7 +126,6 @@
opacity:0;
}
.notifications-sticky {
width:100%;
@ -144,6 +143,10 @@
.notification-success {
border-color: @background-color-success;
}
.notification-offline {
background-color: #CC9900;
border-color: darken(#CC9900, 5%);
}
.notifications-sticky-item {
display:flex;
@ -173,11 +176,23 @@
i {
margin-right:@padding-base-horizontal;
}
div {
.icon {
display: inline-block;
align-self: center;
line-height: 16px;
margin-right:@padding-base-horizontal;
img {
vertical-align: initial;
}
}
div.message {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
line-height: @line-height-base * 1.1;
padding: @padding-small-vertical 0;
}
&.has-default-action:hover {
@ -186,6 +201,9 @@
}
}
}
// Windows Changes
body.platform-win32 {
.notifications-sticky {
.notifications-sticky-item {
@ -195,3 +213,12 @@ body.platform-win32 {
}
}
}
// Activity Error Header
.account-error-header.notifications-sticky {
.notifications-sticky-item {
background-color: initial;
background: linear-gradient(to top, #ca2541 0%, #d55268 100%);
}
}

View file

@ -4,21 +4,21 @@ _ = require 'underscore'
class NylasLongConnection
@State =
Idle: 'idle'
Ended: 'ended'
@Status =
None: 'none'
Connecting: 'connecting'
Connected: 'connected'
Retrying: 'retrying'
Closed: 'closed' # Socket has been closed for any reason
Ended: 'ended' # We have received 'end()' and will never open again.
constructor: (api, accountId, config) ->
@_api = api
@_accountId = accountId
@_config = config
@_emitter = new Emitter
@_state = 'idle'
@_status = NylasLongConnection.Status.None
@_req = null
@_reqForceReconnectInterval = null
@_pingTimeout = null
@_buffer = null
@_deltas = []
@ -54,15 +54,13 @@ class NylasLongConnection
@_config.setCursor(cursor)
callback(cursor)
state: ->
@state
status: ->
@status
setState: (state) ->
@_state = state
@_emitter.emit('state-change', state)
onStateChange: (callback) ->
@_emitter.on('state-change', callback)
setStatus: (status) ->
return if @_status is status
@_status = status
@_config.setStatus(status)
onDeltas: (callback) ->
@_emitter.on('deltas-stopped-arriving', callback)
@ -89,15 +87,15 @@ class NylasLongConnection
start: ->
return unless @_config.ready()
return unless @_status in [NylasLongConnection.Status.None, NylasLongConnection.Status.Closed]
token = @_api.accessTokenForAccountId(@_accountId)
return if not token?
return if @_state is NylasLongConnection.State.Ended
return if @_req
@withCursor (cursor) =>
return if @state is NylasLongConnection.State.Ended
console.log("Delta Connection: Starting for account #{@_accountId}, token #{token}, with cursor #{cursor}")
return if @status is NylasLongConnection.Status.Ended
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false&exclude_metadata=false&exclude_account=false")
options.auth = "#{token}:"
@ -107,62 +105,58 @@ class NylasLongConnection
options.port = 443
lib = require 'https'
req = lib.request options, (res) =>
@_req = lib.request options, (res) =>
if res.statusCode isnt 200
res.on 'data', (chunk) =>
if chunk.toString().indexOf('Invalid cursor') > 0
console.log('Delta Connection: Cursor is invalid. Need to blow away local cache.')
# TODO THIS!
else
@retry()
@close()
return
@_buffer = ''
processBufferThrottled = _.throttle(@onProcessBuffer, 400, {leading: false})
res.setEncoding('utf8')
res.on 'close', => @retry()
res.on 'close', => @close()
res.on 'data', (chunk) =>
@closeIfDataStops()
# Ignore redundant newlines sent as pings. Want to avoid
# calls to @onProcessBuffer that contain no actual updates
return if chunk is '\n' and (@_buffer.length is 0 or @_buffer[-1] is '\n')
@_buffer += chunk
processBufferThrottled()
req.setTimeout(60*60*1000)
req.setSocketKeepAlive(true)
req.on 'error', => @retry()
req.on 'socket', (socket) =>
@setState(NylasLongConnection.State.Connecting)
@_req.setTimeout(60*60*1000)
@_req.setSocketKeepAlive(true)
@_req.on 'error', => @close()
@_req.on 'socket', (socket) =>
@setStatus(NylasLongConnection.Status.Connecting)
socket.on 'connect', =>
@setState(NylasLongConnection.State.Connected)
req.write("1")
@setStatus(NylasLongConnection.Status.Connected)
@closeIfDataStops()
@_req.write("1")
@_req = req
# Currently we have trouble identifying when the connection has closed.
# Instead of trying to fix that, just reconnect every 120 seconds.
@_reqForceReconnectInterval = setInterval =>
@retry(true)
,30000
retry: (immediate = false) ->
return if @_state is NylasLongConnection.State.Ended
@setState(NylasLongConnection.State.Retrying)
close: ->
return if @_status is NylasLongConnection.Status.Closed
@setStatus(NylasLongConnection.Status.Closed)
@cleanup()
startDelay = if immediate then 0 else 10000
setTimeout =>
@start()
, startDelay
closeIfDataStops: =>
clearTimeout(@_pingTimeout) if @_pingTimeout
@_pingTimeout = setTimeout =>
@_pingTimeout = null
@close()
, 15 * 1000
end: ->
console.log("Delta Connection: Closed.")
@setState(NylasLongConnection.State.Ended)
@setStatus(NylasLongConnection.Status.Ended)
@cleanup()
cleanup: ->
clearInterval(@_reqForceReconnectInterval) if @_reqForceReconnectInterval
@_reqForceReconnectInterval = null
clearInterval(@_pingTimeout) if @_pingTimeout
@_pingTimeout = null
@_buffer = ''
if @_req
@_req.end()

View file

@ -41,16 +41,6 @@ class NylasSyncWorkerPool
worker = new NylasSyncWorker(NylasAPI, account)
connection = worker.connection()
connection.onStateChange (state) ->
Actions.longPollStateChanged({accountId: account.id, state: state})
if state == NylasLongConnection.State.Connected
## TODO use OfflineStatusStore
Actions.longPollConnected()
else
## TODO use OfflineStatusStore
Actions.longPollOffline()
connection.onDeltas (deltas) =>
@_handleDeltas(deltas)

View file

@ -11,16 +11,12 @@ MAX_PAGE_SIZE = 200
#
class BackoffTimer
constructor: (@fn) ->
@reset()
@resetDelay()
cancel: =>
clearTimeout(@_timeout) if @_timeout
@_timeout = null
reset: =>
@cancel()
@_delay = 20 * 1000
backoff: =>
@_delay = Math.min(@_delay * 1.4, 5 * 1000 * 60) # Cap at 5 minutes
if not NylasEnv.inSpecMode()
@ -33,6 +29,12 @@ class BackoffTimer
@fn()
, @_delay
resetDelay: =>
@_delay = 20 * 1000
getCurrentDelay: =>
@_delay
module.exports =
class NylasSyncWorker
@ -41,31 +43,37 @@ class NylasSyncWorker
@_api = api
@_account = account
# indirection needed so resumeFetches can be spied on
@_resumeTimer = new BackoffTimer => @resume()
@_refreshingCaches = [new ContactRankingsCache(account.id)]
@_terminated = false
@_connection = new NylasLongConnection(api, account.id, {
ready: => @_state isnt null
getCursor: =>
return null if @_state is null
@_state['cursor'] || NylasEnv.config.get("nylas.#{@_account.id}.cursor")
@_state.cursor || NylasEnv.config.get("nylas.#{@_account.id}.cursor")
setCursor: (val) =>
@_state['cursor'] = val
@_state.cursor = val
@writeState()
setStatus: (status) =>
@_state.longConnectionStatus = status
if status is NylasLongConnection.Status.Closed
@_backoff()
if status is NylasLongConnection.Status.Connected
@_resumeTimer.resetDelay()
@writeState()
})
@_refreshingCaches = [new ContactRankingsCache(account.id)]
@_resumeTimer = new BackoffTimer =>
# indirection needed so resumeFetches can be spied on
@resumeFetches()
@_unlisten = Actions.retryInitialSync.listen(@_onRetryInitialSync, @)
@_unlisten = Actions.retrySync.listen(@_onRetrySync, @)
@_state = null
DatabaseStore.findJSONBlob("NylasSyncWorker:#{@_account.id}").then (json) =>
@_state = json ? {}
@_state.longConnectionStatus = NylasLongConnection.Status.Idle
for key in ['threads', 'labels', 'folders', 'drafts', 'contacts', 'calendars', 'events']
@_state[key].busy = false if @_state[key]
@resumeFetches()
@_connection.start()
@resume()
@
@ -89,7 +97,7 @@ class NylasSyncWorker
@_resumeTimer.start()
@_connection.start()
@_refreshingCaches.map (c) -> c.start()
@resumeFetches()
@resume()
cleanup: ->
@_unlisten?()
@ -99,9 +107,11 @@ class NylasSyncWorker
@_terminated = true
@
resumeFetches: =>
resume: =>
return unless @_state
@_connection.start()
# Stop the timer. If one or more network requests fails during the fetch process
# we'll backoff and restart the timer.
@_resumeTimer.cancel()
@ -239,13 +249,11 @@ class NylasSyncWorker
success(response) if success
error: (err) =>
return if @_terminated
@_resumeTimer.backoff()
@_resumeTimer.start()
@_backoff()
error(err) if error
_fetchCollectionPageError: (model, params, err) ->
@_resumeTimer.backoff()
@_resumeTimer.start()
@_backoff()
@updateTransferState(model, {
busy: false,
complete: false,
@ -253,6 +261,11 @@ class NylasSyncWorker
errorRequestRange: {offset: params.offset, limit: params.limit}
})
_backoff: =>
@_resumeTimer.backoff()
@_resumeTimer.start()
@_state.nextRetryTimestamp = Date.now() + @_resumeTimer.getCurrentDelay()
updateTransferState: (model, updatedKeys) ->
@_state[model] = _.extend(@_state[model], updatedKeys)
@writeState()
@ -264,8 +277,9 @@ class NylasSyncWorker
,100
@_writeState()
_onRetryInitialSync: =>
@resumeFetches()
_onRetrySync: =>
@_resumeTimer.resetDelay()
@resume()
NylasSyncWorker.BackoffTimer = BackoffTimer
NylasSyncWorker.INITIAL_PAGE_SIZE = INITIAL_PAGE_SIZE

View file

@ -7,6 +7,7 @@ describe "NylasSyncWorker", ->
beforeEach ->
@apiRequests = []
@api =
APIRoot: 'https://api.nylas.com'
pluginsSupported: true
accessTokenForAccountId: =>
'123'
@ -46,7 +47,7 @@ describe "NylasSyncWorker", ->
it "should reset `busy` to false when reading state from disk", ->
@worker = new NylasSyncWorker(@api, @account)
spyOn(@worker, 'resumeFetches')
spyOn(@worker, 'resume')
advanceClock()
expect(@worker.state().contacts.busy).toEqual(false)
@ -96,42 +97,42 @@ describe "NylasSyncWorker", ->
@apiRequests[1].requestOptions.error({statusCode: 400})
@apiRequests = []
spyOn(@worker, 'resumeFetches').andCallThrough()
spyOn(@worker, 'resume').andCallThrough()
@worker.start()
expect(@worker.resumeFetches.callCount).toBe(1)
simulateNetworkFailure(); expect(@worker.resumeFetches.callCount).toBe(1)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(2)
simulateNetworkFailure(); expect(@worker.resumeFetches.callCount).toBe(2)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(2)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(3)
simulateNetworkFailure(); expect(@worker.resumeFetches.callCount).toBe(3)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(3)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(4)
simulateNetworkFailure(); expect(@worker.resumeFetches.callCount).toBe(4)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(4)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(4)
advanceClock(30000); expect(@worker.resumeFetches.callCount).toBe(5)
expect(@worker.resume.callCount).toBe(1)
simulateNetworkFailure(); expect(@worker.resume.callCount).toBe(1)
advanceClock(30000); expect(@worker.resume.callCount).toBe(2)
simulateNetworkFailure(); expect(@worker.resume.callCount).toBe(2)
advanceClock(30000); expect(@worker.resume.callCount).toBe(2)
advanceClock(30000); expect(@worker.resume.callCount).toBe(3)
simulateNetworkFailure(); expect(@worker.resume.callCount).toBe(3)
advanceClock(30000); expect(@worker.resume.callCount).toBe(3)
advanceClock(30000); expect(@worker.resume.callCount).toBe(4)
simulateNetworkFailure(); expect(@worker.resume.callCount).toBe(4)
advanceClock(30000); expect(@worker.resume.callCount).toBe(4)
advanceClock(30000); expect(@worker.resume.callCount).toBe(4)
advanceClock(30000); expect(@worker.resume.callCount).toBe(5)
it "handles the request as a failure if we try and grab labels or folders without an 'inbox'", ->
spyOn(@worker, 'resumeFetches').andCallThrough()
spyOn(@worker, 'resume').andCallThrough()
@worker.start()
expect(@worker.resumeFetches.callCount).toBe(1)
expect(@worker.resume.callCount).toBe(1)
request = _.findWhere(@apiRequests, model: 'labels')
request.requestOptions.success([])
expect(@worker.resumeFetches.callCount).toBe(1)
expect(@worker.resume.callCount).toBe(1)
advanceClock(30000)
expect(@worker.resumeFetches.callCount).toBe(2)
expect(@worker.resume.callCount).toBe(2)
it "handles the request as a success if we try and grab labels or folders and it includes the 'inbox'", ->
spyOn(@worker, 'resumeFetches').andCallThrough()
spyOn(@worker, 'resume').andCallThrough()
@worker.start()
expect(@worker.resumeFetches.callCount).toBe(1)
expect(@worker.resume.callCount).toBe(1)
request = _.findWhere(@apiRequests, model: 'labels')
request.requestOptions.success([{name: "inbox"}, {name: "archive"}])
expect(@worker.resumeFetches.callCount).toBe(1)
expect(@worker.resume.callCount).toBe(1)
advanceClock(30000)
expect(@worker.resumeFetches.callCount).toBe(1)
expect(@worker.resume.callCount).toBe(1)
describe "delta streaming cursor", ->
it "should read the cursor from the database, and the old config format", ->
@ -179,7 +180,7 @@ describe "NylasSyncWorker", ->
nextState = @worker.state()
expect(nextState.threads.count).toEqual(1001)
describe "resumeFetches", ->
describe "resume", ->
it "should fetch metadata first and fetch other collections when metadata is ready", ->
fetchAllMetadataCallback = null
jasmine.unspy(NylasSyncWorker.prototype, 'fetchAllMetadata')
@ -187,7 +188,7 @@ describe "NylasSyncWorker", ->
fetchAllMetadataCallback = cb
spyOn(@worker, 'fetchCollection')
@worker._state = {}
@worker.resumeFetches()
@worker.resume()
expect(@worker.fetchAllMetadata).toHaveBeenCalled()
expect(@worker.fetchCollection.calls.length).toBe(0)
fetchAllMetadataCallback()
@ -198,7 +199,7 @@ describe "NylasSyncWorker", ->
spyOn(NylasSyncWorker.prototype, '_fetchWithErrorHandling')
spyOn(@worker, 'fetchCollection')
@worker._state = {}
@worker.resumeFetches()
@worker.resume()
expect(@worker._fetchWithErrorHandling).not.toHaveBeenCalled()
expect(@worker.fetchCollection.calls.length).not.toBe(0)
@ -206,13 +207,13 @@ describe "NylasSyncWorker", ->
spyOn(@worker, 'fetchCollection')
spyOn(@worker, 'shouldFetchCollection').andCallFake (collection) =>
return collection in ['threads', 'labels', 'drafts']
@worker.resumeFetches()
@worker.resume()
expect(@worker.fetchCollection.calls.map (call) -> call.args[0]).toEqual(['threads', 'labels', 'drafts'])
it "should be called when Actions.retryInitialSync is received", ->
spyOn(@worker, 'resumeFetches').andCallThrough()
Actions.retryInitialSync()
expect(@worker.resumeFetches).toHaveBeenCalled()
it "should be called when Actions.retrySync is received", ->
spyOn(@worker, 'resume').andCallThrough()
Actions.retrySync()
expect(@worker.resume).toHaveBeenCalled()
describe "shouldFetchCollection", ->
it "should return false if the collection sync is already in progress", ->
@ -398,7 +399,7 @@ describe "NylasSyncWorker", ->
it "should stop trying to restart failed collection syncs", ->
spyOn(console, 'log')
spyOn(@worker, 'resumeFetches').andCallThrough()
spyOn(@worker, 'resume').andCallThrough()
@worker.cleanup()
advanceClock(50000)
expect(@worker.resumeFetches.callCount).toBe(0)
expect(@worker.resume.callCount).toBe(0)

View file

@ -1,5 +1,5 @@
NylasStore = require 'nylas-store'
{Actions} = require 'nylas-exports'
{Actions, NylasSyncStatusStore} = require 'nylas-exports'
qs = require 'querystring'
_ = require 'underscore'
moment = require 'moment'
@ -55,11 +55,11 @@ class DeveloperBarStore extends NylasStore
@_longPollState = {}
_registerListeners: ->
@listenTo NylasSyncStatusStore, @_onSyncStatusChanged
@listenTo Actions.willMakeAPIRequest, @_onWillMakeAPIRequest
@listenTo Actions.didMakeAPIRequest, @_onDidMakeAPIRequest
@listenTo Actions.longPollReceivedRawDeltas, @_onLongPollDeltas
@listenTo Actions.longPollProcessedDeltas, @_onLongPollProcessedDeltas
@listenTo Actions.longPollStateChanged, @_onLongPollStateChange
@listenTo Actions.clearDeveloperConsole, @_onClear
_onClear: ->
@ -68,6 +68,12 @@ class DeveloperBarStore extends NylasStore
@_longPollHistory = []
@trigger(@)
_onSyncStatusChanged: ->
@_longPollState = {}
_.forEach NylasSyncStatusStore.state(), (state, accountId) =>
@_longPollState[accountId] = state.longConnectionStatus
@trigger()
_onLongPollDeltas: (deltas) ->
# Add a local timestamp to deltas so we can display it
now = new Date()

View file

@ -79,13 +79,12 @@
&.state-connecting {
background-color:#aff2a7;
}
&.state-connected,
&.state-running {
&.state-connected {
background-color:#94E864;
}
&.state-paused,
&.state-idle,
&.state-retrying, {
&.state-none,
&.state-closed,
&.state-ended, {
background-color:gray;
}
}

View file

@ -4,7 +4,6 @@ TaskQueue = require '../../src/flux/stores/task-queue'
Task = require '../../src/flux/tasks/task'
{APIError,
OfflineError,
TimeoutError} = require '../../src/flux/errors'
class TaskSubclassA extends Task

View file

@ -3,7 +3,6 @@ TaskQueue = require '../../src/flux/stores/task-queue'
Task = require '../../src/flux/tasks/task'
{APIError,
OfflineError,
TimeoutError} = require '../../src/flux/errors'
noop = ->

View file

@ -118,12 +118,9 @@ class Actions
###
@dequeueMatchingTask: ActionScopeWorkWindow
@longPollStateChanged: ActionScopeWorkWindow
@longPollReceivedRawDeltas: ActionScopeWorkWindow
@longPollReceivedRawDeltasPing: ActionScopeGlobal
@longPollProcessedDeltas: ActionScopeWorkWindow
@longPollConnected: ActionScopeWorkWindow
@longPollOffline: ActionScopeWorkWindow
@willMakeAPIRequest: ActionScopeWorkWindow
@didMakeAPIRequest: ActionScopeWorkWindow
@ -132,7 +129,7 @@ class Actions
*Scope: Work Window*
###
@retryInitialSync: ActionScopeWorkWindow
@retrySync: ActionScopeWorkWindow
###
Public: Open the preferences view.

View file

@ -14,13 +14,9 @@ class APIError extends Error
@name = "APIError"
@message = @body?.message ? @body ? @error?.toString?()
class OfflineError extends Error
constructor: ->
class TimeoutError extends Error
constructor: ->
module.exports =
"APIError": APIError
"OfflineError": OfflineError
"TimeoutError": TimeoutError

View file

@ -45,4 +45,21 @@ class NylasSyncStatusStore extends NylasStore
return true
false
connected: =>
# Return true if any account is in a state other than `retrying`.
# When data isn't received, NylasLongConnection closes the socket and
# goes into `retrying` state.
statuses = _.values(@_statesByAccount).map (state) ->
state.longConnectionStatus
if statuses.length is 0
return true
return _.any statuses, (status) -> status isnt 'closed'
nextRetryTimestamp: =>
retryDates = _.values(@_statesByAccount).map (state) ->
state.nextRetryTimestamp
_.compact(retryDates).sort((a, b) => a < b).pop()
module.exports = new NylasSyncStatusStore()

View file

@ -87,8 +87,6 @@ class TaskQueue
@listenTo Actions.dequeueAllTasks, @dequeueAll
@listenTo Actions.dequeueMatchingTask, @dequeueMatching
@listenTo Actions.clearDeveloperConsole, @clearCompleted
@listenTo Actions.longPollConnected, =>
@_processQueue()
queue: =>
@_queue

View file

@ -59,8 +59,4 @@ export default class EventRSVPTask extends Task {
onTimeoutError() {
return Promise.resolve();
}
onOfflineError() {
return Promise.resolve();
}
}

View file

@ -70,13 +70,12 @@ const TaskDebugStatus = {
// All tasks should gracefully handle the case when there is no network
// connection.
//
// if (we're offline the common behavior is for a task to:)
// if we're offline the common behavior is for a task to:
//
// 1. Perform its local change
// 2. Attempt the remote request and get a timeout or offline code
// 2. Attempt the remote request, which will fail
// 3. Have `performRemote` resolve a `Task.Status.Retry`
// 3. Sit queued up waiting to be retried
// 4. Wait for {Actions::longPollConnected} to restart the {TaskQueue}
//
// Remember that a user may be offline for hours and perform thousands of
// tasks in the meantime. It's important that your tasks implement
@ -445,10 +444,6 @@ export default class Task {
// and tried again later. Any other task dependent on the current one
// will also continue waiting.
//
// The queue is re-processed whenever a new task is enqueued, dequeued,
// or the internet connection comes back online via
// {Actions::longPollConnected}.
//
// `Task.Status.Retry` is useful if (it looks like we're offline, or you)
// get an API error code that indicates temporary failure.
//

View file

@ -197,7 +197,6 @@ class NylasExports
# Errors
@get "APIError", -> require('../flux/errors').APIError
@get "OfflineError", -> require('../flux/errors').OfflineError
@get "TimeoutError", -> require('../flux/errors').TimeoutError
# Process Internals