diff --git a/internal_packages/account-error-header/lib/main.es6 b/internal_packages/account-error-header/lib/main.es6 deleted file mode 100644 index 51f23443b..000000000 --- a/internal_packages/account-error-header/lib/main.es6 +++ /dev/null @@ -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); -} diff --git a/internal_packages/account-error-header/package.json b/internal_packages/account-error-header/package.json deleted file mode 100755 index 6562099c6..000000000 --- a/internal_packages/account-error-header/package.json +++ /dev/null @@ -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": { - } -} diff --git a/internal_packages/account-error-header/stylesheets/account-error-header.less b/internal_packages/account-error-header/stylesheets/account-error-header.less deleted file mode 100644 index 47a45a88a..000000000 --- a/internal_packages/account-error-header/stylesheets/account-error-header.less +++ /dev/null @@ -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; - } - } - } -} - diff --git a/internal_packages/account-error-header/assets/icon-alert-onred@1x.png b/internal_packages/notifications/assets/icon-alert-onred@1x.png similarity index 100% rename from internal_packages/account-error-header/assets/icon-alert-onred@1x.png rename to internal_packages/notifications/assets/icon-alert-onred@1x.png diff --git a/internal_packages/account-error-header/assets/icon-alert-onred@2x.png b/internal_packages/notifications/assets/icon-alert-onred@2x.png similarity index 100% rename from internal_packages/account-error-header/assets/icon-alert-onred@2x.png rename to internal_packages/notifications/assets/icon-alert-onred@2x.png diff --git a/internal_packages/account-error-header/assets/icon-alert-sourcelist@1x.png b/internal_packages/notifications/assets/icon-alert-sourcelist@1x.png similarity index 100% rename from internal_packages/account-error-header/assets/icon-alert-sourcelist@1x.png rename to internal_packages/notifications/assets/icon-alert-sourcelist@1x.png diff --git a/internal_packages/account-error-header/assets/icon-alert-sourcelist@2x.png b/internal_packages/notifications/assets/icon-alert-sourcelist@2x.png similarity index 100% rename from internal_packages/account-error-header/assets/icon-alert-sourcelist@2x.png rename to internal_packages/notifications/assets/icon-alert-sourcelist@2x.png diff --git a/internal_packages/notifications/lib/activity-sidebar-long-poll-store.coffee b/internal_packages/notifications/lib/activity-sidebar-long-poll-store.coffee deleted file mode 100644 index 034d19871..000000000 --- a/internal_packages/notifications/lib/activity-sidebar-long-poll-store.coffee +++ /dev/null @@ -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() diff --git a/internal_packages/account-error-header/lib/account-error-header.jsx b/internal_packages/notifications/lib/headers/account-error-header.jsx similarity index 90% rename from internal_packages/account-error-header/lib/account-error-header.jsx rename to internal_packages/notifications/lib/headers/account-error-header.jsx index c6ec5d39f..404a83a62 100644 --- a/internal_packages/account-error-header/lib/account-error-header.jsx +++ b/internal_packages/notifications/lib/headers/account-error-header.jsx @@ -38,15 +38,16 @@ export default class AccountErrorHeader extends React.Component { renderErrorHeader(message, buttonName, actionCallback) { return ( -
+
-
-
- -
{message}
+ +
+ {message} +
{buttonName} diff --git a/internal_packages/notifications/lib/headers/connection-status-header.jsx b/internal_packages/notifications/lib/headers/connection-status-header.jsx new file mode 100644 index 000000000..b612036cd --- /dev/null +++ b/internal_packages/notifications/lib/headers/connection-status-header.jsx @@ -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 (); + } + + return ( +
+
+ +
+ Nylas N1 isn't able to reach api.nylas.com. Retrying {nextRetryText}. +
+ + Try Again Now + +
+
+ ); + } +} diff --git a/internal_packages/notifications/lib/headers/notifications-header.cjsx b/internal_packages/notifications/lib/headers/notifications-header.cjsx new file mode 100644 index 000000000..923d9d121 --- /dev/null +++ b/internal_packages/notifications/lib/headers/notifications-header.cjsx @@ -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: => +
+ {@_notificationComponents()} +
+ + _notificationComponents: => + @state.items.map (notif) -> + + + +module.exports = NotificationsHeader diff --git a/internal_packages/notifications/lib/headers/notifications-item.cjsx b/internal_packages/notifications/lib/headers/notifications-item.cjsx new file mode 100644 index 000000000..383d3c359 --- /dev/null +++ b/internal_packages/notifications/lib/headers/notifications-item.cjsx @@ -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() + + + {action.label} + + + if actionDefault +
@_fireItemAction(notif, actionDefault)}> +
{notif.message}
{actionComponents} +
+ else +
+
{notif.message}
{actionComponents} +
+ + _fireItemAction: (notification, action) => + Actions.notificationActionTaken({notification, action}) + +module.exports = NotificationsItem diff --git a/internal_packages/notifications/lib/main.cjsx b/internal_packages/notifications/lib/main.cjsx deleted file mode 100644 index e8e955a1f..000000000 --- a/internal_packages/notifications/lib/main.cjsx +++ /dev/null @@ -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 diff --git a/internal_packages/notifications/lib/main.es6 b/internal_packages/notifications/lib/main.es6 new file mode 100644 index 000000000..4c1e59b9e --- /dev/null +++ b/internal_packages/notifications/lib/main.es6 @@ -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); +} diff --git a/internal_packages/notifications/lib/notifications-sticky-bar.cjsx b/internal_packages/notifications/lib/notifications-sticky-bar.cjsx deleted file mode 100644 index d2399daac..000000000 --- a/internal_packages/notifications/lib/notifications-sticky-bar.cjsx +++ /dev/null @@ -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() - - - {action.label} - - - if actionDefault -
@_fireItemAction(notif, actionDefault)}> -
{notif.message}
{actionComponents} -
- else -
-
{notif.message}
{actionComponents} -
- - _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: => -
- {@_notificationComponents()} -
- - _notificationComponents: => - @state.items.map (notif) -> - - - -module.exports = NotificationStickyBar diff --git a/internal_packages/notifications/lib/activity-sidebar.cjsx b/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx similarity index 73% rename from internal_packages/notifications/lib/activity-sidebar.cjsx rename to internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx index 472c5cc30..61a0629fa 100644 --- a/internal_packages/notifications/lib/activity-sidebar.cjsx +++ b/internal_packages/notifications/lib/sidebar/activity-sidebar.cjsx @@ -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 else items.push - names = classNames "sidebar-activity": true "sidebar-activity-error": error? @@ -87,16 +84,6 @@ class ActivitySidebar extends React.Component
- _renderDeltaSyncActivityItem: => -
-
- -
-
- Syncing your mailbox… -
-
- _renderNotificationActivityItems: => @state.notifications.map (notification) ->
@@ -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 diff --git a/internal_packages/notifications/lib/initial-sync-activity.cjsx b/internal_packages/notifications/lib/sidebar/initial-sync-activity.cjsx similarity index 99% rename from internal_packages/notifications/lib/initial-sync-activity.cjsx rename to internal_packages/notifications/lib/sidebar/initial-sync-activity.cjsx index c8aeb3cf1..b0a95ee61 100644 --- a/internal_packages/notifications/lib/initial-sync-activity.cjsx +++ b/internal_packages/notifications/lib/sidebar/initial-sync-activity.cjsx @@ -108,6 +108,6 @@ class InitialSyncActivity extends React.Component
_onTryAgain: => - Actions.retryInitialSync() + Actions.retrySync() module.exports = InitialSyncActivity diff --git a/internal_packages/notifications/lib/sidebar/streaming-sync-activity.cjsx b/internal_packages/notifications/lib/sidebar/streaming-sync-activity.cjsx new file mode 100644 index 000000000..9c1cf89db --- /dev/null +++ b/internal_packages/notifications/lib/sidebar/streaming-sync-activity.cjsx @@ -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 +
+
+ +
+
+ Syncing your mailbox… +
+
+ + _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 diff --git a/internal_packages/notifications/stylesheets/notifications.less b/internal_packages/notifications/stylesheets/notifications.less index 2dc16f7ca..cccfab416 100644 --- a/internal_packages/notifications/stylesheets/notifications.less +++ b/internal_packages/notifications/stylesheets/notifications.less @@ -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%); + } +} diff --git a/internal_packages/worker-sync/lib/nylas-long-connection.coffee b/internal_packages/worker-sync/lib/nylas-long-connection.coffee index e76333d33..338ff52a9 100644 --- a/internal_packages/worker-sync/lib/nylas-long-connection.coffee +++ b/internal_packages/worker-sync/lib/nylas-long-connection.coffee @@ -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() diff --git a/internal_packages/worker-sync/lib/nylas-sync-worker-pool.coffee b/internal_packages/worker-sync/lib/nylas-sync-worker-pool.coffee index 28b58eef9..c138e3b54 100644 --- a/internal_packages/worker-sync/lib/nylas-sync-worker-pool.coffee +++ b/internal_packages/worker-sync/lib/nylas-sync-worker-pool.coffee @@ -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) diff --git a/internal_packages/worker-sync/lib/nylas-sync-worker.coffee b/internal_packages/worker-sync/lib/nylas-sync-worker.coffee index 696d82b23..cd7b1be8d 100644 --- a/internal_packages/worker-sync/lib/nylas-sync-worker.coffee +++ b/internal_packages/worker-sync/lib/nylas-sync-worker.coffee @@ -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 diff --git a/internal_packages/worker-sync/spec/nylas-sync-worker-spec.coffee b/internal_packages/worker-sync/spec/nylas-sync-worker-spec.coffee index 2fd60267a..ec5a9b1c1 100644 --- a/internal_packages/worker-sync/spec/nylas-sync-worker-spec.coffee +++ b/internal_packages/worker-sync/spec/nylas-sync-worker-spec.coffee @@ -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) diff --git a/internal_packages/worker-ui/lib/developer-bar-store.coffee b/internal_packages/worker-ui/lib/developer-bar-store.coffee index 49f924d6a..dad9279d4 100644 --- a/internal_packages/worker-ui/lib/developer-bar-store.coffee +++ b/internal_packages/worker-ui/lib/developer-bar-store.coffee @@ -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() diff --git a/internal_packages/worker-ui/stylesheets/worker-ui.less b/internal_packages/worker-ui/stylesheets/worker-ui.less index eceab4969..5cbaaed12 100755 --- a/internal_packages/worker-ui/stylesheets/worker-ui.less +++ b/internal_packages/worker-ui/stylesheets/worker-ui.less @@ -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; } } diff --git a/spec/stores/task-queue-spec.coffee b/spec/stores/task-queue-spec.coffee index 23c3fe12d..77b01e092 100644 --- a/spec/stores/task-queue-spec.coffee +++ b/spec/stores/task-queue-spec.coffee @@ -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 diff --git a/spec/tasks/task-spec.coffee b/spec/tasks/task-spec.coffee index aab468276..4fea9ae8a 100644 --- a/spec/tasks/task-spec.coffee +++ b/spec/tasks/task-spec.coffee @@ -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 = -> diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index c50fcbb17..9a1241ec0 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -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. diff --git a/src/flux/errors.coffee b/src/flux/errors.coffee index 7d3e9a6fc..01e74cf90 100644 --- a/src/flux/errors.coffee +++ b/src/flux/errors.coffee @@ -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 diff --git a/src/flux/stores/nylas-sync-status-store.coffee b/src/flux/stores/nylas-sync-status-store.coffee index 3d2f053b6..a8720c39c 100644 --- a/src/flux/stores/nylas-sync-status-store.coffee +++ b/src/flux/stores/nylas-sync-status-store.coffee @@ -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() diff --git a/src/flux/stores/task-queue.coffee b/src/flux/stores/task-queue.coffee index cd0e87d52..9234be60e 100644 --- a/src/flux/stores/task-queue.coffee +++ b/src/flux/stores/task-queue.coffee @@ -87,8 +87,6 @@ class TaskQueue @listenTo Actions.dequeueAllTasks, @dequeueAll @listenTo Actions.dequeueMatchingTask, @dequeueMatching @listenTo Actions.clearDeveloperConsole, @clearCompleted - @listenTo Actions.longPollConnected, => - @_processQueue() queue: => @_queue diff --git a/src/flux/tasks/event-rsvp-task.es6 b/src/flux/tasks/event-rsvp-task.es6 index 3c57cbf85..a7f7b389d 100644 --- a/src/flux/tasks/event-rsvp-task.es6 +++ b/src/flux/tasks/event-rsvp-task.es6 @@ -59,8 +59,4 @@ export default class EventRSVPTask extends Task { onTimeoutError() { return Promise.resolve(); } - - onOfflineError() { - return Promise.resolve(); - } } diff --git a/src/flux/tasks/task.es6 b/src/flux/tasks/task.es6 index 2991f5d8c..4ae07e5fb 100644 --- a/src/flux/tasks/task.es6 +++ b/src/flux/tasks/task.es6 @@ -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. // diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 7dd2ce68c..d74bb2398 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -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