feat(sidebar-notifs) Create sidebar notifications to replace old bars

Summary:
Move the old bar notifications to the sidebar, and only display one notification
at a time using a priority-rating system. Remove all of the old notification
infrastructure.

Test Plan: Added specs, also reproduced notifications locally

Reviewers: bengotow

Reviewed By: bengotow

Subscribers: juan

Differential Revision: https://phab.nylas.com/D3310
This commit is contained in:
Halla Moore 2016-10-04 08:02:11 -07:00
parent aa7ef91b0b
commit 9e3c3c14cd
43 changed files with 948 additions and 834 deletions

View file

@ -1,48 +0,0 @@
{Actions, LaunchServices} = require 'nylas-exports'
NOTIF_ACTION_YES = 'mailto:set-default-yes'
NOTIF_ACTION_NO = 'mailto:set-default-no'
NOTIF_SETTINGS_KEY = 'nylas.mailto.prompted-about-default'
module.exports =
activate: (@state) ->
@services = new LaunchServices()
return unless @services.available()
# We shouldn't ask if they've already said No
return if NylasEnv.config.get(NOTIF_SETTINGS_KEY) is true
@services.isRegisteredForURLScheme 'mailto', (registered) =>
# Prompt them to make Nylas their default client
unless registered
@_unlisten = Actions.notificationActionTaken.listen(@_onNotificationActionTaken, @)
Actions.postNotification
type: 'info',
sticky: true
message: "Thanks for trying out N1! Would you like to make it your default mail client?",
icon: 'fa-inbox',
actions: [{
label: 'Not Now'
dismisses: true
id: NOTIF_ACTION_NO
},{
label: 'Yes'
dismisses: true
default: true
id: NOTIF_ACTION_YES
}]
deactivate: ->
@_unlisten?()
serialize: -> @state
_onNotificationActionTaken: ({notification, action}) ->
if action.id is NOTIF_ACTION_YES
@services.registerForURLScheme 'mailto', (err) ->
console.log(err) if err
if action.id is NOTIF_ACTION_NO
NylasEnv.config.set(NOTIF_SETTINGS_KEY, true)

View file

@ -1,10 +0,0 @@
{
"name": "notification-mailto",
"main": "./lib/main",
"version": "0.36.0",
"description": "Displays a notification asking the user to make N1 their default mailto handler",
"license": "GPL-3.0",
"engines": {
"nylas": "*"
}
}

View file

@ -1,68 +0,0 @@
{Actions} = require 'nylas-exports'
{ipcRenderer, remote} = require('electron')
module.exports =
activate: (@state) ->
# Populate our initial state directly from the auto update manager.
updater = remote.getGlobal('application').autoUpdateManager
@_unlisten = Actions.notificationActionTaken.listen(@_onNotificationActionTaken, @)
configVersion = NylasEnv.config.get("lastVersion")
currentVersion = NylasEnv.getVersion()
if configVersion and configVersion isnt currentVersion
NylasEnv.config.set("lastVersion", currentVersion)
@displayThanksNotification()
if updater.getState() is 'update-available'
@displayNotification(updater.releaseVersion)
NylasEnv.onUpdateAvailable ({releaseVersion, releaseNotes} = {}) =>
@displayNotification(releaseVersion)
displayThanksNotification: ->
Actions.postNotification
type: 'info'
tag: 'app-update'
sticky: true
message: "You're running the latest version of N1 - view the changelog to see what's new.",
icon: 'fa-magic'
actions: [{
dismisses: true
label: 'Thanks'
id: 'release-bar:no-op'
},{
default: true
dismisses: true
label: 'See What\'s New'
id: 'release-bar:view-changelog'
}]
displayNotification: (version) ->
version = if version then "(#{version})" else ''
Actions.postNotification
type: 'info'
tag: 'app-update'
sticky: true
message: "An update to N1 is available #{version} - click to update now!",
icon: 'fa-flag'
actions: [{
label: 'See What\'s New'
id: 'release-bar:view-changelog'
},{
label: 'Install Now'
dismisses: true
default: true
id: 'release-bar:install-update'
}]
deactivate: ->
@_unlisten()
_onNotificationActionTaken: ({notification, action}) ->
if action.id is 'release-bar:install-update'
ipcRenderer.send 'command', 'application:install-update'
true
if action.id is 'release-bar:view-changelog'
require('electron').shell.openExternal('https://github.com/nylas/N1/blob/master/CHANGELOG.md')
false

View file

@ -1,10 +0,0 @@
{
"name": "notification-update-available",
"main": "./lib/main",
"version": "0.36.0",
"description": "Displays a notification when an update is available",
"license": "GPL-3.0",
"engines": {
"nylas": "*"
}
}

View file

@ -1,71 +0,0 @@
_ = require 'underscore'
proxyquire = require 'proxyquire'
Reflux = require 'reflux'
{Actions} = require 'nylas-exports'
stubUpdaterState = null
stubUpdaterReleaseVersion = null
ipcSendArgs = null
PackageMain = proxyquire "../lib/main",
"electron":
"ipcRenderer":
send: ->
ipcSendArgs = arguments
"remote":
getGlobal: (global) ->
autoUpdateManager:
releaseVersion: stubUpdaterReleaseVersion
getState: -> stubUpdaterState
describe "NotificationUpdateAvailable", ->
beforeEach ->
stubUpdaterState = 'idle'
stubUpdaterReleaseVersion = undefined
ipcSendArgs = null
@package = PackageMain
afterEach ->
@package.deactivate()
describe "activate", ->
it "should display a notification immediately if one is available", ->
spyOn(@package, 'displayNotification')
stubUpdaterState = 'update-available'
@package.activate()
expect(@package.displayNotification).toHaveBeenCalled()
it "should not display a notification if no update is avialable", ->
spyOn(@package, 'displayNotification')
stubUpdaterState = 'no-update-available'
@package.activate()
expect(@package.displayNotification).not.toHaveBeenCalled()
it "should listen for `window:update-available`", ->
spyOn(NylasEnv, 'onUpdateAvailable').andCallThrough()
@package.activate()
expect(NylasEnv.onUpdateAvailable).toHaveBeenCalled()
describe "displayNotification", ->
beforeEach ->
@package.activate()
it "should fire a postNotification Action", ->
spyOn(Actions, 'postNotification')
@package.displayNotification()
expect(Actions.postNotification).toHaveBeenCalled()
it "should include the version if one is provided", ->
spyOn(Actions, 'postNotification')
version = '0.515.0-123123'
@package.displayNotification(version)
expect(Actions.postNotification).toHaveBeenCalled()
notifOptions = Actions.postNotification.mostRecentCall.args[0]
expect(notifOptions.message.indexOf(version) > 0).toBe(true)
describe "when the action is taken", ->
it "should fire the `application:install-update` IPC event", ->
Actions.notificationActionTaken({notification: {}, action: {id: 'release-bar:install-update'}})
expect(Array.prototype.slice.call(ipcSendArgs)).toEqual(['command', 'application:install-update'])

View file

@ -1,25 +0,0 @@
@import "ui-variables";
.release-bar {
order: -1;
}
.release-bar-inner {
height: 25px;
display: block;
padding-left: 10px;
padding-top: 2px;
color: @text-color-inverse;
background-color: @background-color-success;
border-bottom: 1px solid darken(@background-color-success, 10%);
&:hover {
background-color: darken(@background-color-success, 10%);
border-bottom: 1px solid darken(@background-color-success, 15%);
text-decoration: none;
color: white;
}
i {
margin-right:8px;
}
}

View file

@ -1,179 +0,0 @@
/* eslint global-require: 0 */
import {AccountStore, Account, Actions, React, IdentityStore} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import {shell} from 'electron';
export default class AccountErrorHeader extends React.Component {
static displayName = 'AccountErrorHeader';
constructor() {
super();
this.state = this.getStateFromStores();
this.upgradeLabel = ""
}
componentDidMount() {
this.mounted = true;
this.unsubscribers = [
AccountStore.listen(() => this.setState(this.getStateFromStores())),
IdentityStore.listen(() => this.setState(this.getStateFromStores())),
];
}
componentWillUnmount() {
this.mounted = false;
for (const unsub of this.unsubscribers) {
unsub();
}
this.unsubscribers = null;
}
getStateFromStores() {
return {
accounts: AccountStore.accounts(),
subscriptionState: IdentityStore.subscriptionState(),
daysUntilSubscriptionRequired: IdentityStore.daysUntilSubscriptionRequired(),
}
}
_reconnect(existingAccount) {
const ipc = require('electron').ipcRenderer;
ipc.send('command', 'application:add-account', {existingAccount});
}
_openPreferences() {
Actions.switchPreferencesTab('Accounts');
Actions.openPreferences()
}
_contactSupport() {
shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
}
_onCheckAgain = (event) => {
this.setState({refreshing: true});
event.stopPropagation();
IdentityStore.refreshIdentityAndAccounts().finally(() => {
if (!this.mounted) { return; }
this.setState({refreshing: false});
});
}
_onUpgrade = () => {
this.setState({buildingUpgradeURL: true});
const isSubscription = this.state.subscriptionState === IdentityStore.State.Lapsed
const utm = {
source: "UpgradeBanner",
campaign: isSubscription ? "SubscriptionExpired" : "TrialExpired",
}
IdentityStore.fetchSingleSignOnURL('/payment', utm).then((url) => {
this.setState({buildingUpgradeURL: false});
shell.openExternal(url);
});
}
_renderErrorHeader(message, buttonName, actionCallback) {
return (
<div className="account-error-header notifications-sticky">
<div
className={"notifications-sticky-item notification-error has-default-action"}
onClick={actionCallback}
>
<RetinaImg
className="icon"
name="icon-alert-onred.png"
mode={RetinaImg.Mode.ContentPreserve}
/>
<div className="message">
{message}
</div>
<a className="action refresh" onClick={this._onCheckAgain}>
{this.state.refreshing ? "Checking..." : "Check Again"}
</a>
<a className="action default" onClick={actionCallback}>
{buttonName}
</a>
</div>
</div>
)
}
_renderUpgradeHeader() {
return (
<div className="account-error-header notifications-sticky">
<div
className={"notifications-sticky-item notification-upgrade has-default-action"}
onClick={this._onUpgrade}
>
<RetinaImg
className="icon"
name="ic-upgrade.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<div className="message">
{
(this.state.subscriptionState === IdentityStore.State.Lapsed) ? (
"Your subscription has expired, and we've paused your mailboxes. Renew your subscription to continue using N1!"
) : (
"Your trial has expired, and we've paused your mailboxes. Subscribe to continue using N1!"
)
}
</div>
<a className="action refresh" onClick={this._onCheckAgain}>
{this.state.refreshing ? "Checking..." : "Check Again"}
</a>
<a className="action default" onClick={this._onUpgrade}>
{this.state.buildingUpgradeURL ? "Please wait..." : "Upgrade to Nylas Pro..."}
</a>
</div>
</div>
)
}
render() {
const {accounts, subscriptionState, daysUntilSubscriptionRequired} = this.state;
const subscriptionInvalid = (subscriptionState !== IdentityStore.State.Valid);
const subscriptionRequired = (daysUntilSubscriptionRequired !== null) && (daysUntilSubscriptionRequired <= 1);
if (subscriptionInvalid && subscriptionRequired) {
return this._renderUpgradeHeader();
}
const errorAccounts = accounts.filter(a => a.hasSyncStateError());
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 <span />;
}
}

View file

@ -1,40 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -0,0 +1,107 @@
import {shell, ipcRenderer} from 'electron';
import {React, Account, AccountStore, Actions, IdentityStore} from 'nylas-exports';
import Notification from '../notification';
export default class AccountErrorNotification extends React.Component {
static displayName = 'AccountErrorNotification';
static containerRequired = false;
constructor() {
super();
this.state = this.getStateFromStores();
}
componentDidMount() {
this.unlisten = AccountStore.listen(() => this.setState(this.getStateFromStores()));
}
componentWillUnmount() {
this.unlisten();
}
getStateFromStores() {
return {
accounts: AccountStore.accounts(),
}
}
_onContactSupport = () => {
shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
}
_onReconnect = (existingAccount) => {
ipcRenderer.send('command', 'application:add-account', {existingAccount});
}
_onOpenAccountPreferences = () => {
Actions.switchPreferencesTab('Accounts');
Actions.openPreferences()
}
_onCheckAgain = () => {
return IdentityStore.refreshIdentityAndAccounts();
}
render() {
const erroredAccounts = this.state.accounts.filter(a => a.hasSyncStateError());
let title;
let subtitle;
let subtitleAction;
let actions;
if (erroredAccounts.length === 0) {
return <span />
} else if (erroredAccounts.length > 1) {
title = "Several of your accounts are having issues";
actions = [{
label: "Check Again",
fn: this._onCheckAgain,
}, {
label: "Manage",
fn: this._onOpenAccountPreferences,
}];
} else {
const erroredAccount = erroredAccounts[0];
switch (erroredAccount.syncState) {
case Account.SYNC_STATE_AUTH_FAILED:
title = `Cannot authenticate with ${erroredAccount.emailAddress}`;
actions = [{
label: "Check Again",
fn: this._onCheckAgain,
}, {
label: 'Reconnect',
fn: () => this._onReconnect(erroredAccount),
}];
break;
case Account.SYNC_STATE_STOPPED:
title = `Sync has been disabled for ${erroredAccount.emailAddress}`;
subtitle = "Contact support";
subtitleAction = this._onContactSupport;
actions = [{
label: "Check Again",
fn: this._onCheckAgain,
}];
break;
default:
title = `Encountered an error with ${erroredAccount.emailAddress}`;
subtitle = "Contact support";
subtitleAction = this._onContactSupport;
actions = [{
label: "Check Again",
fn: this._onCheckAgain,
}];
}
}
return (
<Notification
priority="3"
isError
title={title}
subtitle={subtitle}
subtitleAction={subtitleAction}
actions={actions}
icon="volstead-error.png"
/>
)
}
}

View file

@ -0,0 +1,75 @@
import {React, LaunchServices} from 'nylas-exports';
import Notification from '../notification';
const SETTINGS_KEY = 'nylas.mailto.prompted-about-default'
export default class DefaultClientNotification extends React.Component {
static displayName = 'DefaultClientNotification';
static containerRequired = false;
constructor() {
super();
this.services = new LaunchServices();
this.state = this.getStateFromStores();
this.state.initializing = true;
this.mounted = false;
}
componentDidMount() {
this.mounted = true;
this.services.isRegisteredForURLScheme('mailto', (registered) => {
if (this.mounted) {
this.setState({
initializing: false,
registered: registered,
})
}
})
this.disposable = NylasEnv.config.onDidChange(SETTINGS_KEY,
() => this.setState(this.getStateFromStores()));
}
componentWillUnmount() {
this.mounted = false;
this.disposable.dispose();
}
getStateFromStores() {
return {
alreadyPrompted: NylasEnv.config.get(SETTINGS_KEY),
}
}
_onAccept = () => {
this.services.registerForURLScheme('mailto', (err) => {
if (err) {
NylasEnv.reportError(err)
}
});
NylasEnv.config.set(SETTINGS_KEY, true)
}
_onDecline = () => {
NylasEnv.config.set(SETTINGS_KEY, true)
}
render() {
if (this.state.initializing || this.state.alreadyPrompted || this.state.registered) {
return <span />
}
return (
<Notification
title="Would you like to make N1 your default mail client?"
priority="1"
icon="volstead-defaultclient.png"
actions={[{
label: "Yes",
fn: this._onAccept,
}, {
label: "No",
fn: this._onDecline,
}]}
/>
)
}
}

View file

@ -0,0 +1,28 @@
import {React} from 'nylas-exports';
import Notification from '../notification';
export default class DevModeNotification extends React.Component {
static displayName = 'DevModeNotification';
static containerRequired = false;
constructor() {
super();
// Don't need listeners to update this, since toggling dev mode reloads
// the entire window anyway
this.state = {
inDevMode: NylasEnv.inDevMode(),
}
}
render() {
if (!this.state.inDevMode) {
return <span />
}
return (
<Notification
priority="0"
title="N1 is running in dev mode!"
/>
)
}
}

View file

@ -0,0 +1,49 @@
import {React, MailRulesStore, Actions} from 'nylas-exports';
import Notification from '../notification';
export default class DisabledMailRulesNotification extends React.Component {
static displayName = 'DisabledMailRulesNotification';
static containerRequired = false;
constructor() {
super();
this.state = this.getStateFromStores();
}
componentDidMount() {
this.unlisten = MailRulesStore.listen(() => this.setState(this.getStateFromStores()));
}
componentWillUnmount() {
this.unlisten();
}
getStateFromStores() {
return {
disabledRules: MailRulesStore.disabledRules(),
}
}
_onOpenMailRulesPreferences = () => {
Actions.switchPreferencesTab('Mail Rules', {accountId: this.state.disabledRules[0].accountId})
Actions.openPreferences()
}
render() {
if (this.state.disabledRules.length === 0) {
return <span />
}
return (
<Notification
priority="2"
title="One or more of your mail rules have been disabled."
icon="volstead-defaultclient.png"
isError
actions={[{
label: 'View Mail Rules',
fn: this._onOpenMailRulesPreferences,
}]}
/>
)
}
}

View file

@ -1,13 +1,9 @@
import {
NylasAPI,
NylasSyncStatusStore,
React,
Actions,
} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import {NylasSyncStatusStore, React, Actions} from 'nylas-exports';
import Notification from '../notification';
export default class ConnectionStatusHeader extends React.Component {
static displayName = 'ConnectionStatusHeader';
export default class OfflineNotification extends React.Component {
static displayName = 'OfflineNotification';
static containerRequired = false;
constructor() {
super();
@ -16,7 +12,7 @@ export default class ConnectionStatusHeader extends React.Component {
}
componentDidMount() {
this.unsubscribe = NylasSyncStatusStore.listen(() => {
this.unlisten = NylasSyncStatusStore.listen(() => {
const nextState = this.getStateFromStores();
if ((nextState.connected !== this.state.connected) || (nextState.nextRetryText !== this.state.nextRetryText)) {
this.setState(nextState);
@ -68,6 +64,7 @@ export default class ConnectionStatusHeader extends React.Component {
}
}
}
return {connected, nextRetryText};
}
@ -86,29 +83,18 @@ export default class ConnectionStatusHeader extends React.Component {
render() {
const {connected, nextRetryText} = this.state;
if (connected) {
return (<span />);
return <span />
}
const apiDomain = NylasAPI.APIRoot.split('//').pop();
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 {apiDomain}. Retrying {nextRetryText}.
</div>
<a className="action default" onClick={this.onTryAgain}>
Try Again Now
</a>
</div>
</div>
);
<Notification
title="Nylas N1 is offline"
priority="5"
icon="volstead-offline.png"
subtitle={`Trying again ${nextRetryText}`}
actions={[{label: 'Try now', id: 'try_now', fn: this.onTryAgain}]}
/>
)
}
}

View file

@ -0,0 +1,59 @@
import {React} from 'nylas-exports';
import {ipcRenderer, remote, shell} from 'electron';
import Notification from '../notification';
export default class UpdateNotification extends React.Component {
static displayName = 'UpdateNotification';
static containerRequired = false;
constructor() {
super();
this.updater = remote.getGlobal('application').autoUpdateManager
this.state = this.getStateFromStores();
}
componentDidMount() {
this.disposable = NylasEnv.onUpdateAvailable(() => {
this.setState(this.getStateFromStores())
});
}
componentWillUnmount() {
this.disposable.dispose();
}
getStateFromStores() {
return {
updateAvailable: this.updater.getState() === 'update-available',
version: this.updater.releaseVersion,
}
}
_onUpdate = () => {
ipcRenderer.send('command', 'application:install-update')
}
_onViewChangelog = () => {
shell.openExternal('https://github.com/nylas/N1/blob/master/CHANGELOG.md')
}
render() {
if (!this.state.updateAvailable) {
return <span />
}
const version = this.state.version ? `(${this.state.version})` : '';
return (
<Notification
priority="4"
title={`An update to N1 is available ${version}`}
subtitle="View changelog"
subtitleAction={this._onViewChangelog}
icon="volstead-upgrade.png"
actions={[{
label: "Update",
fn: this._onUpdate,
}]}
/>
)
}
}

View file

@ -2,26 +2,43 @@
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";
import TrialRemainingBlock from "./sidebar/trial-remaining-block";
import NotifWrapper from "./notif-wrapper";
import AccountErrorNotification from "./items/account-error-notif";
import DefaultClientNotification from "./items/default-client-notif";
import DevModeNotification from "./items/dev-mode-notif";
import DisabledMailRulesNotification from "./items/disabled-mail-rules-notif";
import OfflineNotification from "./items/offline-notification";
import UpdateNotification from "./items/update-notification";
const notifications = [
AccountErrorNotification,
DefaultClientNotification,
DevModeNotification,
DisabledMailRulesNotification,
OfflineNotification,
UpdateNotification,
]
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});
ComponentRegistry.register(NotifWrapper, {location: WorkspaceStore.Location.RootSidebar});
ComponentRegistry.register(TrialRemainingBlock, {location: WorkspaceStore.Location.RootSidebar});
for (const notification of notifications) {
ComponentRegistry.register(notification, {role: 'RootSidebar:Notifications'});
}
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(ActivitySidebar);
ComponentRegistry.unregister(NotificationsHeader);
ComponentRegistry.unregister(ConnectionStatusHeader);
ComponentRegistry.unregister(AccountErrorHeader);
ComponentRegistry.unregister(TrialRemainingBlock);
ComponentRegistry.unregister(NotifWrapper);
for (const notification of notifications) {
ComponentRegistry.unregister(notification)
}
}

View file

@ -0,0 +1,49 @@
import _ from 'underscore';
import {React, ReactDOM} from 'nylas-exports'
import {InjectedComponentSet} from 'nylas-component-kit'
const ROLE = "RootSidebar:Notifications";
export default class NotifWrapper extends React.Component {
static displayName = 'NotifWrapper';
componentDidMount() {
this.observer = new MutationObserver(this.update);
this.observer.observe(ReactDOM.findDOMNode(this), {childList: true})
this.update() // Necessary if notifications are already mounted
}
componentWillUnmount() {
this.observer.disconnect();
}
update = () => {
const className = "highest-priority";
const node = ReactDOM.findDOMNode(this);
const oldHighestPriorityElems = node.querySelectorAll(`.${className}`);
for (const oldElem of oldHighestPriorityElems) {
oldElem.classList.remove(className)
}
const elemsWithPriority = node.querySelectorAll("[data-priority]")
if (elemsWithPriority.length === 0) {
return;
}
const highestPriorityElem = _.max(elemsWithPriority,
(elem) => parseInt(elem.dataset.priority, 10))
highestPriorityElem.classList.add(className);
}
render() {
return (
<InjectedComponentSet
className="notifications"
matching={{role: ROLE}}
direction="column"
/>
)
}
}

View file

@ -0,0 +1,89 @@
import {React} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
export default class Notification extends React.Component {
static containerRequired = false;
static propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
subtitleAction: React.PropTypes.func,
actions: React.PropTypes.array,
icon: React.PropTypes.string,
priority: React.PropTypes.string,
isError: React.PropTypes.bool,
}
constructor() {
super()
this.state = {loadingActions: []}
}
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
_onClick(actionId, actionFn) {
const result = actionFn();
if (result instanceof Promise) {
this.setState({
loadingActions: this.state.loadingActions.concat([actionId]),
})
result.finally(() => {
if (this.mounted) {
this.setState({
loadingActions: this.state.loadingActions.filter(f => f !== actionId),
})
}
})
}
}
render() {
const actions = this.props.actions || [];
const actionElems = actions.map((action, idx) => {
const id = `action-${idx}`;
let className = 'action'
if (this.state.loadingActions.includes(id)) {
className += ' loading'
}
return (
<div
key={id}
id={id}
className={className}
onClick={() => this._onClick(id, action.fn)}
>
{action.label}
</div>
);
})
const {isError, priority, icon, title, subtitleAction, subtitle} = this.props;
return (
<div className={`notification${isError ? ' error' : ''}`} data-priority={priority}>
<div className="title">
<RetinaImg
className="icon"
name={icon}
mode={RetinaImg.Mode.ContentPreserve}
/> {title} <br />
<span
className={`subtitle ${subtitleAction ? 'has-action' : ''}`}
onClick={subtitleAction || (() => {})}
>
{subtitle}
</span>
</div>
{actionElems.length > 0 ?
<div className="actions-wrapper">{actionElems}</div> : null
}
</div>
)
}
}

View file

@ -1,101 +0,0 @@
_ = require 'underscore'
{Actions} = require 'nylas-exports'
NylasStore = require 'nylas-store'
VERBOSE = false
DISPLAY_TIME = 3000 # in ms
uuid_count = 0
class Notification
constructor: ({@message, @type, @tag, @sticky, @actions, @icon} = {}) ->
# Check to make sure the provided data is a valid notificaiton, since
# notifications may be constructed by anyone developing on N1
throw new Error "No `new` keyword when constructing Notification" unless @ instanceof Notification
throw new Error "Type must be `info`, `developer`, `error`, or `success`" unless @type in ['info', 'error', 'success', 'developer']
throw new Error "Message must be provided for notification" unless @message
if @actions
for action in @actions
throw new Error "Actions must have an `label`" unless action['label']
throw new Error "Actions must have an `id`" unless action['id']
@tag ?= uuid_count++
@creation = Date.now()
@sticky ?= false
unless @sticky
@expiry = @creation + DISPLAY_TIME
console.log "Created new notif with #{@tag}: #{@message}" if VERBOSE
@
valid: ->
@sticky or @expiry > Date.now()
toString: ->
"Notification.#{@constructor.name}(#{@tag})"
class NotificationStore extends NylasStore
constructor: ->
@_flush()
# The notification store listens for user interaction with notififcations
# and just removes the notifications. To implement notification actions,
# your package should listen to notificationActionTaken and check the
# notification and action objects.
@listenTo Actions.notificationActionTaken, ({notification, action}) =>
@_removeNotification(notification) if action.dismisses
@listenTo Actions.postNotification, (data) =>
@_postNotification(new Notification(data))
@listenTo Actions.dismissNotificationsMatching, (criteria) =>
for tag, notif of @_notifications
if _.isMatch(notif, criteria)
delete @_notifications[tag]
@trigger()
######### PUBLIC #######################################################
notifications: =>
console.log(JSON.stringify(@_notifications)) if VERBOSE
sorted = _.sortBy(_.values(@_notifications), (n) -> -1*(n.creation + n.tag))
_.reject sorted, (n) -> n.sticky
stickyNotifications: =>
console.log(JSON.stringify(@_notifications)) if VERBOSE
sorted = _.sortBy(_.values(@_notifications), (n) -> -1*(n.creation + n.tag))
_.filter sorted, (n) -> n.sticky
Notification: Notification
########### PRIVATE ####################################################
_flush: =>
@_notifications = {}
_postNotification: (notification) =>
console.log "Queue Notification.#{notification}" if VERBOSE
@_notifications[notification.tag] = notification
if notification.expiry?
timeoutVal = Math.max(0, notification.expiry - Date.now())
timeoutId = setTimeout =>
@_removeNotification(notification)
, timeoutVal
notification.timeoutId = timeoutId
@trigger()
# Returns a function for removing a particular notification. See usage
# above in setTimeout()
_removeNotification: (notification) =>
console.log "Removed #{notification}" if VERBOSE
clearTimeout(notification.timeoutId) if notification.timeoutId
delete @_notifications[notification.tag]
@trigger()
# If the window matches the given context then we can show a
# notification.
_inWindowContext: (context={}) =>
return true
module.exports = new NotificationStore()

View file

@ -4,7 +4,6 @@ ReactCSSTransitionGroup = require 'react-addons-css-transition-group'
_ = require 'underscore'
classNames = require 'classnames'
NotificationStore = require '../notifications-store'
StreamingSyncActivity = require './streaming-sync-activity'
InitialSyncActivity = require './initial-sync-activity'
@ -28,14 +27,13 @@ class ActivitySidebar extends React.Component
componentDidMount: =>
@_unlisteners = []
@_unlisteners.push TaskQueueStatusStore.listen @_onDataChanged
@_unlisteners.push NotificationStore.listen @_onDataChanged
@_unlisteners.push NylasSyncStatusStore.listen @_onDataChanged
componentWillUnmount: =>
unlisten() for unlisten in @_unlisteners
render: =>
items = [@_renderNotificationActivityItems(), @_renderTaskActivityItems()]
items = @_renderTaskActivityItems()
if @state.isInitialSyncComplete
items.push <StreamingSyncActivity key="streaming-sync" />
@ -84,19 +82,10 @@ class ActivitySidebar extends React.Component
</div>
</div>
_renderNotificationActivityItems: =>
@state.notifications.map (notification) ->
<div className="item" key={notification.id}>
<div className="inner">
{notification.message}
</div>
</div>
_onDataChanged: =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
notifications: NotificationStore.notifications()
tasks: TaskQueueStatusStore.queue()
isInitialSyncComplete: NylasSyncStatusStore.isSyncComplete()

View file

@ -1,9 +1,9 @@
import {mount} from 'enzyme';
import AccountErrorHeader from '../lib/headers/account-error-header';
import {IdentityStore, AccountStore, Account, Actions, React} from 'nylas-exports'
import AccountErrorNotification from '../lib/items/account-error-notif';
import {IdentityStore, AccountStore, Account, Actions, React} from 'nylas-exports';
import {ipcRenderer} from 'electron';
describe("AccountErrorHeader", function AccountErrorHeaderTests() {
describe("AccountErrorNotif", function AccountErrorNotifTests() {
describe("when one account is in the `invalid` state", () => {
beforeEach(() => {
spyOn(AccountStore, 'accounts').andReturn([
@ -13,22 +13,21 @@ describe("AccountErrorHeader", function AccountErrorHeaderTests() {
});
it("renders an error bar that mentions the account email", () => {
const header = mount(<AccountErrorHeader />);
expect(header.find('.notifications-sticky-item')).toBeDefined();
expect(header.find('.message').text().indexOf('123@gmail.com') > 0).toBe(true);
const notif = mount(<AccountErrorNotification />);
expect(notif.find('.title').text().indexOf('123@gmail.com') > 0).toBe(true);
});
it("allows the user to refresh the account", () => {
const header = mount(<AccountErrorHeader />);
const notif = mount(<AccountErrorNotification />);
spyOn(IdentityStore, 'refreshIdentityAndAccounts').andReturn(Promise.resolve());
header.find('.action.refresh').simulate('click');
notif.find('#action-0').simulate('click'); // Expects first action to be the refresh action
expect(IdentityStore.refreshIdentityAndAccounts).toHaveBeenCalled();
});
it("allows the user to reconnect the account", () => {
const header = mount(<AccountErrorHeader />);
const notif = mount(<AccountErrorNotification />);
spyOn(ipcRenderer, 'send');
header.find('.action.default').simulate('click');
notif.find('#action-1').simulate('click'); // Expects second action to be the reconnect action
expect(ipcRenderer.send).toHaveBeenCalledWith('command', 'application:add-account', {
existingAccount: AccountStore.accounts()[0],
});
@ -44,22 +43,22 @@ describe("AccountErrorHeader", function AccountErrorHeaderTests() {
});
it("renders an error bar", () => {
const header = mount(<AccountErrorHeader />);
expect(header.find('.notifications-sticky-item')).toBeDefined();
const notif = mount(<AccountErrorNotification />);
expect(notif.find('.notification').isEmpty()).toEqual(false);
});
it("allows the user to refresh the accounts", () => {
const header = mount(<AccountErrorHeader />);
const notif = mount(<AccountErrorNotification />);
spyOn(IdentityStore, 'refreshIdentityAndAccounts').andReturn(Promise.resolve());
header.find('.action.refresh').simulate('click');
notif.find('#action-0').simulate('click'); // Expects first action to be the refresh action
expect(IdentityStore.refreshIdentityAndAccounts).toHaveBeenCalled();
});
it("allows the user to open preferences", () => {
spyOn(Actions, 'switchPreferencesTab')
spyOn(Actions, 'openPreferences')
const header = mount(<AccountErrorHeader />);
header.find('.action.default').simulate('click');
const notif = mount(<AccountErrorNotification />);
notif.find('#action-1').simulate('click'); // Expects second action to be the preferences action
expect(Actions.openPreferences).toHaveBeenCalled();
expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Accounts');
});
@ -74,8 +73,8 @@ describe("AccountErrorHeader", function AccountErrorHeaderTests() {
});
it("renders nothing", () => {
const header = mount(<AccountErrorHeader />);
expect(header.html()).toEqual('<span></span>');
const notif = mount(<AccountErrorNotification />);
expect(notif.find('.notification').isEmpty()).toEqual(true);
});
});
});

View file

@ -0,0 +1,72 @@
import {mount} from 'enzyme';
import proxyquire from 'proxyquire';
import {React} from 'nylas-exports';
let stubIsRegistered = null;
let stubRegister = () => {};
const patched = proxyquire('../lib/items/default-client-notif',
{
'nylas-exports': {
LaunchServices: class {
constructor() {
this.isRegisteredForURLScheme = (urlScheme, callback) => { callback(stubIsRegistered) };
this.registerForURLScheme = (urlScheme) => { stubRegister(urlScheme) };
}
},
},
}
)
const DefaultClientNotification = patched.default;
const SETTINGS_KEY = 'nylas.mailto.prompted-about-default';
describe("DefaultClientNotif", function DefaultClientNotifTests() {
describe("when N1 isn't the default mail client", () => {
beforeEach(() => {
stubIsRegistered = false;
})
describe("when the user has already responded", () => {
beforeEach(() => {
spyOn(NylasEnv.config, "get").andReturn(true);
this.notif = mount(<DefaultClientNotification />);
expect(NylasEnv.config.get).toHaveBeenCalledWith(SETTINGS_KEY);
});
it("renders nothing", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(true);
});
});
describe("when the user has yet to respond", () => {
beforeEach(() => {
spyOn(NylasEnv.config, "get").andReturn(false);
this.notif = mount(<DefaultClientNotification />);
expect(NylasEnv.config.get).toHaveBeenCalledWith(SETTINGS_KEY);
});
it("renders a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(false);
});
it("allows the user to set N1 as the default client", () => {
let scheme = null;
stubRegister = (urlScheme) => { scheme = urlScheme };
this.notif.find('#action-0').simulate('click'); // Expects first action to set N1 as default
expect(scheme).toEqual('mailto');
});
it("allows the user to decline", () => {
spyOn(NylasEnv.config, "set")
this.notif.find('#action-1').simulate('click'); // Expects second action to decline
expect(NylasEnv.config.set).toHaveBeenCalledWith(SETTINGS_KEY, true);
});
})
});
describe("when N1 is the default mail client", () => {
beforeEach(() => {
stubIsRegistered = true;
this.notif = mount(<DefaultClientNotification />)
})
it("renders nothing", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(true);
});
})
});

View file

@ -0,0 +1,25 @@
import {mount} from 'enzyme';
import {React} from 'nylas-exports';
import DevModeNotification from '../lib/items/dev-mode-notif';
describe("DevModeNotif", function DevModeNotifTests() {
describe("When the window is in dev mode", () => {
beforeEach(() => {
spyOn(NylasEnv, "inDevMode").andReturn(true);
this.notif = mount(<DevModeNotification />);
})
it("displays a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(false);
})
})
describe("When the window is not in dev mode", () => {
beforeEach(() => {
spyOn(NylasEnv, "inDevMode").andReturn(false);
this.notif = mount(<DevModeNotification />);
})
it("doesn't display a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(true);
})
})
});

View file

@ -0,0 +1,57 @@
import {mount} from 'enzyme';
import {React, AccountStore, Account, Actions, MailRulesStore} from 'nylas-exports';
import DisabledMailRulesNotification from '../lib/items/disabled-mail-rules-notif';
describe("DisabledMailRulesNotification", function DisabledMailRulesNotifTests() {
beforeEach(() => {
spyOn(AccountStore, 'accounts').andReturn([
new Account({id: 'A', syncState: 'running', emailAddress: '123@gmail.com'}),
])
})
describe("When there is one disabled mail rule", () => {
beforeEach(() => {
spyOn(MailRulesStore, "disabledRules").andReturn([{accountId: 'A'}])
this.notif = mount(<DisabledMailRulesNotification />)
})
it("displays a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(false);
})
it("allows users to open the preferences", () => {
spyOn(Actions, "switchPreferencesTab")
spyOn(Actions, "openPreferences")
this.notif.find('#action-0').simulate('click');
expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Mail Rules', {accountId: 'A'})
expect(Actions.openPreferences).toHaveBeenCalled();
})
});
describe("When there are multiple disabled mail rules", () => {
beforeEach(() => {
spyOn(MailRulesStore, "disabledRules").andReturn([{accountId: 'A'},
{accountId: 'A'}])
this.notif = mount(<DisabledMailRulesNotification />)
})
it("displays a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(false);
})
it("allows users to open the preferences", () => {
spyOn(Actions, "switchPreferencesTab")
spyOn(Actions, "openPreferences")
this.notif.find('#action-0').simulate('click');
expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Mail Rules', {accountId: 'A'})
expect(Actions.openPreferences).toHaveBeenCalled();
})
});
describe("When there are no disabled mail rules", () => {
beforeEach(() => {
spyOn(MailRulesStore, "disabledRules").andReturn([])
this.notif = mount(<DisabledMailRulesNotification />)
})
it("does not display a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(true);
})
})
})

View file

@ -1,67 +0,0 @@
NotificationsStore = require '../lib/notifications-store'
Notification = NotificationsStore.Notification
{Actions} = require 'nylas-exports'
describe 'Notification', ->
it 'should assert that a message has been provided', ->
expect( -> new Notification({})).toThrow()
it 'should assert that a valid type has been provided', ->
for type in ['info', 'success', 'error']
expect( -> new Notification({type: type, message: 'bla'})).not.toThrow()
expect( -> new Notification({type: 'extreme'})).toThrow()
it 'should assert that any actions have ids and labels', ->
expect( -> new Notification({type: 'info', message: '1', actions:[{id: 'a'}]})).toThrow()
expect( -> new Notification({type: 'info', message: '2', actions:[{label: 'b'}]})).toThrow()
expect( -> new Notification({type: 'info', message: '3', actions:[{id: 'a', label: 'b'}]})).not.toThrow()
it 'should assign a tag and creation time', ->
@n = new Notification({type: 'info', message: 'A', actions:[{id: 'a', label: 'b'}]})
expect(@n.tag).toBeDefined()
expect(@n.creation).toBeDefined()
it 'should use a provided tag if the notification is meant to replace an existing tag', ->
@n = new Notification({tag: 'update', type: 'info', message: 'A', actions:[{id: 'a', label: 'b'}]})
expect(@n.tag).toBe('update')
it 'should be valid at creation', ->
@n = new Notification({type: 'info', message: 'A', actions:[{id: 'a', label: 'b'}]})
expect(@n.valid()).toBe true
describe 'NotificationStore', ->
beforeEach ->
NotificationsStore._flush()
it 'should have no notifications by default', ->
expect(NotificationsStore.notifications().length).toEqual 0
it 'should register a notification', ->
message = "Hello"
Actions.postNotification({type: 'info', message: message})
n = NotificationsStore.notifications()[0]
expect(n.message).toEqual(message)
it 'should unregister on removeNotification', ->
Actions.postNotification({type: 'info', message: 'hi'})
n = NotificationsStore.notifications()[0]
NotificationsStore._removeNotification(n)
expect(NotificationsStore.notifications().length).toEqual 0
describe "with a few notifications", ->
beforeEach ->
Actions.postNotification({type: 'info', message: 'A', sticky: true})
Actions.postNotification({type: 'info', message: 'B', sticky: false})
Actions.postNotification({type: 'info', message: 'C'})
Actions.postNotification({type: 'info', message: 'D', sticky: true})
describe "stickyNotifications", ->
it 'should return all of the notifications with the sticky flag, ordered by date DESC', ->
messages = NotificationsStore.stickyNotifications().map (n) -> n.message
expect(messages).toEqual(['D','A'])
describe "notifications", ->
it 'should return all of the notifications without the sticky flag, ordered by date DESC', ->
messages = NotificationsStore.notifications().map (n) -> n.message
expect(messages).toEqual(['C','B'])

View file

@ -0,0 +1,30 @@
import {mount} from 'enzyme';
import {React, NylasSyncStatusStore} from 'nylas-exports';
import OfflineNotification from '../lib/items/offline-notification';
describe("OfflineNotif", function offlineNotifTests() {
describe("When N1 is offline", () => {
beforeEach(() => {
spyOn(NylasSyncStatusStore, "connected").andReturn(false);
spyOn(NylasSyncStatusStore, "nextRetryDelay").andReturn(10000);
this.notif = mount(<OfflineNotification />);
})
it("displays a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(false);
})
it("allows the user to try connecting now", () => {
})
})
describe("When N1 is online", () => {
beforeEach(() => {
spyOn(NylasSyncStatusStore, "connected").andReturn(true);
this.notif = mount(<OfflineNotification />);
})
it("doesn't display a notification", () => {
expect(this.notif.find('.notification').isEmpty()).toEqual(true);
})
})
});

View file

@ -0,0 +1,65 @@
import {mount} from 'enzyme';
import {ComponentRegistry, React} from 'nylas-exports';
import NotifWrapper from '../lib/notif-wrapper.jsx';
import Notification from '../lib/notification.jsx';
const stubNotif = (priority) => {
return class extends React.Component {
static displayName = `NotifPriority${priority}`;
static containerRequired = false;
render() { return <Notification priority={`${priority}`} title={`Priority ${priority}`} /> }
}
};
const checkHighestPriority = (expectedPriority, wrapper) => {
const visibleElems = wrapper.find(".highest-priority")
expect(visibleElems.isEmpty()).toEqual(false);
const titleElem = visibleElems.first().find('.title');
expect(titleElem.isEmpty()).toEqual(false);
expect(titleElem.text().trim()).toEqual(`Priority ${expectedPriority}`);
// Make sure there's only one highest-priority elem
expect(visibleElems.get(1)).toEqual(undefined);
}
describe("NotifPriority", function notifPriorityTests() {
beforeEach(() => {
this.wrapper = mount(<NotifWrapper />)
this.trigger = () => {
ComponentRegistry.trigger();
this.wrapper.get(0).update();
}
})
describe("When there is only one notification", () => {
beforeEach(() => {
ComponentRegistry._clear();
ComponentRegistry.register(stubNotif(5), {role: 'RootSidebar:Notifications'})
this.trigger();
})
it("should mark it as highest-priority", () => {
checkHighestPriority(5, this.wrapper);
})
})
describe("when there are multiple notifications", () => {
beforeEach(() => {
this.components = [stubNotif(5), stubNotif(7), stubNotif(3), stubNotif(2)]
ComponentRegistry._clear();
this.components.forEach((item) => {
ComponentRegistry.register(item, {role: 'RootSidebar:Notifications'})
})
this.trigger();
})
it("should mark the proper one as highest-priority", () => {
checkHighestPriority(7, this.wrapper);
})
it("properly updates when a highest-priority notification is removed", () => {
ComponentRegistry.unregister(this.components[1])
this.trigger();
checkHighestPriority(5, this.wrapper);
})
it("properly updates when a higher priority notifcation is added", () => {
ComponentRegistry.register(stubNotif(10), {role: 'RootSidebar:Notifications'});
this.trigger();
checkHighestPriority(10, this.wrapper);
})
})
});

View file

@ -0,0 +1,77 @@
import {mount} from 'enzyme';
import proxyquire from 'proxyquire';
import {React} from 'nylas-exports';
let stubUpdaterState = null
let stubUpdaterReleaseVersion = null
let ipcSendArgs = null
const patched = proxyquire("../lib/items/update-notification",
{
electron: {
ipcRenderer: {
send: (...args) => {
ipcSendArgs = args
},
},
remote: {
getGlobal: () => {
return {
autoUpdateManager: {
releaseVersion: stubUpdaterReleaseVersion,
getState: () => stubUpdaterState,
},
}
},
},
},
}
)
const UpdateNotification = patched.default;
describe("UpdateNotification", () => {
beforeEach(() => {
stubUpdaterState = 'idle'
stubUpdaterReleaseVersion = undefined
ipcSendArgs = null
})
describe("mounting", () => {
it("should display a notification immediately if one is available", () => {
stubUpdaterState = 'update-available'
const notif = mount(<UpdateNotification />);
expect(notif.find('.notification').isEmpty()).toEqual(false);
})
it("should not display a notification if no update is avialable", () => {
stubUpdaterState = 'no-update-available'
const notif = mount(<UpdateNotification />);
expect(notif.find('.notification').isEmpty()).toEqual(true);
})
it("should listen for `window:update-available`", () => {
spyOn(NylasEnv, 'onUpdateAvailable').andCallThrough()
mount(<UpdateNotification />);
expect(NylasEnv.onUpdateAvailable).toHaveBeenCalled()
})
})
describe("displayNotification", () => {
it("should include the version if one is provided", () => {
stubUpdaterState = 'update-available'
stubUpdaterReleaseVersion = '0.515.0-123123'
const notif = mount(<UpdateNotification />);
expect(notif.find('.title').text().indexOf('0.515.0-123123') >= 0).toBe(true);
})
describe("when the action is taken", () => {
it("should fire the `application:install-update` IPC event", () => {
stubUpdaterState = 'update-available'
const notif = mount(<UpdateNotification />);
notif.find('#action-0').simulate('click'); // Expects the first action to be the install action
expect(ipcSendArgs).toEqual(['command', 'application:install-update'])
})
})
})
})

View file

@ -0,0 +1,91 @@
@import 'ui-variables';
.notifications {
background-color: @panel-background-color;
}
.notification {
background: @background-color-info;
display: none;
color: @text-color-inverse;
margin: 10px;
border-radius: @border-radius-large;
}
.notification.error {
background: @background-color-error;
}
.notification.highest-priority {
display: block;
}
.notif-top {
display: flex;
align-items: flex-start;
padding: 10px;
}
.notification .icon {
margin-right: 20px;
}
.notification .title {
padding: 10px;
padding-left: 40px;
text-indent: -30px;
}
.notification .subtitle {
font-size: @font-size-smaller;
position: relative;
opacity: 0.8;
}
.notification .subtitle.has-action {
cursor: pointer;
}
.notification .subtitle.has-action::after {
content:'';
background: url(nylas://notifications/assets/minichevron@2x.png) top left no-repeat;
background-size: 4.5px 7px;
margin-left:3px;
display: inline-block;
width:4.5px;
height:7px;
vertical-align: baseline;
}
.notification .actions-wrapper {
display: flex;
}
.notification .action {
text-align: center;
flex: 1;
border-top: solid rgba(255, 255, 255, 0.5) 1px;
border-left: solid rgba(255, 255, 255, 0.5) 1px;
padding: 10px;
cursor: pointer;
/* The semi-transparent backgrounds that can be layered on top
of this class shouldn't have sharp corners on the bottom */
border-bottom-left-radius: @border-radius-large;
border-bottom-right-radius: @border-radius-large;
}
.notification .action:first-child {
border-left: none;
}
.notification .action:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: @standard-shadow inset;
}
.notification .action.loading {
cursor: progress;
background-color: rgba(0, 0, 0, 0.2);
box-shadow: @standard-shadow inset;
}

View file

@ -43,8 +43,6 @@ class ThreadListStore extends NylasStore
# Inbound Events
_onPerspectiveChanged: =>
if FocusedPerspectiveStore.current().searchQuery is undefined
Actions.dismissNotificationsMatching({tag: 'search-error'})
@createListDataSource()
_onDataChanged: ({previous, next} = {}) =>

View file

@ -1,8 +1,11 @@
@import "darkside-variables";
.notifications-sticky .notifications-sticky-item {
.notifications {
background-color: @sidebar;
}
.notifications .notification{
background-color: @accent;
line-height: 50px;
border: none;
}

View file

@ -1,11 +1,11 @@
@import "variables";
.notifications-sticky, .notification-developer, .notifications-sticky-item, .notification-info {
.notification {
color: @white !important;
background: @taiga-accent !important;
.action {
color: @white !important;
background: darken(@taiga-accent, 20%) !important;
background: darken(@taiga-accent, 20%);
}
}

View file

@ -11,7 +11,7 @@ import {ipcRenderer} from 'electron'
* erased!).
*/
function onNotificationActionTaken(numAsks) {
function onDialogActionTaken(numAsks) {
return (buttonIndex) => {
if (buttonIndex === 0) {
ipcRenderer.send("move-to-applications")
@ -74,7 +74,7 @@ export function activate() {
detail: msg,
defaultId: 0,
cancelId: CANCEL_ID,
}, onNotificationActionTaken(numAsks))
}, onDialogActionTaken(numAsks))
}
export function deactivate() {

View file

@ -92,7 +92,6 @@ describe('SendDraftTask', function sendDraftTask() {
spyOn(DBt, 'unpersistModel').andReturn(Promise.resolve());
spyOn(DBt, 'persistModel').andReturn(Promise.resolve());
spyOn(SoundRegistry, "playSound");
spyOn(Actions, "postNotification");
spyOn(Actions, "sendDraftSuccess");
});

View file

@ -32,8 +32,6 @@ how it propogates between windows.
## Firing Actions
```coffee
Actions.postNotification({message: "Removed Thread", type: 'success'})
Actions.queueTask(new ChangeStarredTask(thread: @_thread, starred: true))
```
@ -413,58 +411,6 @@ class Actions
###
@RSVPEvent: ActionScopeWindow
###
Public: Fire to display an in-window notification to the user in the app's standard
notification interface.
*Scope: Global*
```
# A simple notification
Actions.postNotification({message: "Removed Thread", type: 'success'})
# A sticky notification with actions
NOTIF_ACTION_YES = 'YES'
NOTIF_ACTION_NO = 'NO'
Actions.postNotification
type: 'info',
sticky: true
message: "Thanks for trying out N1! Would you like to make it your default mail client?",
icon: 'fa-inbox',
actions: [{
label: 'Yes'
default: true
dismisses: true
id: NOTIF_ACTION_YES
},{
label: 'More Info'
dismisses: false
id: NOTIF_ACTION_MORE_INFO
}]
```
###
@postNotification: ActionScopeGlobal
@dismissNotificationsMatching: ActionScopeGlobal
###
Public: Listen to this action to handle user interaction with notifications you
published via `postNotification`.
*Scope: Global*
```
@_unlisten = Actions.notificationActionTaken.listen(@_onActionTaken, @)
_onActionTaken: ({notification, action}) ->
if action.id is NOTIF_ACTION_YES
# perform action
```
###
@notificationActionTaken: ActionScopeGlobal
# FullContact Sidebar
@getFullContactDetails: ActionScopeWindow
@focusContact: ActionScopeWindow

View file

@ -26,7 +26,6 @@ class MailRulesStore extends NylasStore
@listenTo Actions.reorderMailRule, @_onReorderMailRule
@listenTo Actions.updateMailRule, @_onUpdateMailRule
@listenTo Actions.disableMailRule, @_onDisableMailRule
@listenTo Actions.notificationActionTaken, @_onNotificationActionTaken
rules: =>
@_rules
@ -34,6 +33,9 @@ class MailRulesStore extends NylasStore
rulesForAccountId: (accountId) =>
@_rules.filter (f) => f.accountId is accountId
disabledRules: (accountId) =>
@_rules.filter (f) => f.disabled
_onDeleteMailRule: (id) =>
@_rules = @_rules.filter (f) -> f.id isnt id
@_saveMailRules()
@ -74,22 +76,6 @@ class MailRulesStore extends NylasStore
existing = _.find @_rules, (f) -> id is f.id
return if not existing or existing.disabled is true
Actions.postNotification
message: "We were unable to run your mail rules - one or more rules have been disabled."
type: "error"
tag: 'mail-rule-failure'
sticky: true
actions: [{
label: 'Hide'
dismisses: true
id: 'hide'
},{
label: 'View Rules'
dismisses: true
default: true
id: 'mail-rule-failure:view-rules'
}]
# Disable the task
existing.disabled = true
existing.disabledReason = reason
@ -101,20 +87,10 @@ class MailRulesStore extends NylasStore
@trigger()
_onNotificationActionTaken: ({notification, action}) =>
return unless NylasEnv.isMainWindow()
accountId = AccountStore.accounts()[0].accountId
if action.id is 'mail-rule-failure:view-rules'
Actions.switchPreferencesTab('Mail Rules', {accountId})
Actions.openPreferences()
_saveMailRules: =>
@_saveMailRulesDebounced ?= _.debounce =>
DatabaseStore.inTransaction (t) =>
t.persistJSONBlob(RulesJSONBlobKey, @_rules)
if not _.findWhere(@_rules, {disabled: true})
Actions.dismissNotificationsMatching({tag: 'mail-rule-failure'})
,1000
@_saveMailRulesDebounced()

View file

@ -2,7 +2,6 @@ import Task from './task';
import {APIError} from '../errors';
import Message from '../models/message';
import DatabaseStore from '../stores/database-store';
import Actions from '../actions';
import NylasAPI from '../nylas-api';
import BaseDraftTask from './base-draft-task';
@ -65,10 +64,7 @@ export default class DestroyDraftTask extends BaseDraftTask {
return Promise.resolve(Task.Status.Retry);
}
Actions.postNotification({
message: "Unable to delete this draft. Restoring...",
type: "error",
});
NylasEnv.showErrorDialog("Unable to delete this draft. Restoring...");
return DatabaseStore.inTransaction((t) =>
t.persistModel(this.draft)

View file

@ -219,17 +219,7 @@ class WindowEventHandler
showDevModeMessages: ->
return unless NylasEnv.isMainWindow()
if NylasEnv.inDevMode()
Actions = require './flux/actions'
Actions.postNotification
icon: 'fa-flask'
type: 'developer'
tag: 'developer'
sticky: true
actions: [{label: 'Thanks', id: 'ok', dismisses: true, default: true}]
message: "N1 is running with debug flags enabled (slower). Packages in
~/.nylas/dev/packages will be loaded. Have fun!"
else
if !NylasEnv.inDevMode()
console.log("%c Welcome to N1! If you're exploring the source or building a
plugin, you should enable debug flags. It's slower, but
gives you better exceptions, the debug version of React,

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB