feat(snooze/send-later): Add snooze and send later plugins

Summary:
- Add initial version of snooze and send later plugins
- Tests are missing since this will probably heavily change before we are done with them

Test Plan: - TODO

Reviewers: drew, bengotow, evan

Reviewed By: bengotow, evan

Differential Revision: https://phab.nylas.com/D2578
This commit is contained in:
Juan Tejada 2016-02-18 10:00:11 -08:00
parent 21ce6355a5
commit a841417011
32 changed files with 939 additions and 44 deletions

View file

@ -11,6 +11,7 @@
},
"rules": {
"react/prop-types": [2, {"ignore": ["children"]}],
"react/no-multi-comp": [1],
"eqeqeq": [2, "smart"],
"id-length": [0],
"object-curly-spacing": [0],

View file

@ -0,0 +1,22 @@
/** @babel */
import {ComponentRegistry} from 'nylas-exports'
import SendLaterPopover from './send-later-popover'
import SendLaterStore from './send-later-store'
import SendLaterStatus from './send-later-status'
export function activate() {
SendLaterStore.activate()
ComponentRegistry.register(SendLaterPopover, {role: 'Composer:ActionButton'})
ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'})
}
export function deactivate() {
ComponentRegistry.unregister(SendLaterPopover)
ComponentRegistry.unregister(SendLaterStatus)
SendLaterStore.deactivate()
}
export function serialize() {
}

View file

@ -0,0 +1,13 @@
/** @babel */
import Reflux from 'reflux';
const SendLaterActions = Reflux.createActions([
'sendLater',
'cancelSendLater',
])
for (const key in SendLaterActions) {
SendLaterActions[key].sync = true
}
export default SendLaterActions

View file

@ -0,0 +1,6 @@
/** @babel */
export const PLUGIN_ID = "aqx344zhdh6jyabqokejknkvr"
export const PLUGIN_NAME = "Send Later"
export const DATE_FORMAT_LONG = 'ddd, MMM D, YYYY h:mmA'
export const DATE_FORMAT_SHORT = 'MMM D h:mmA'

View file

@ -0,0 +1,158 @@
/** @babel */
import _ from 'underscore'
import React, {Component, PropTypes} from 'react'
import {DateUtils} from 'nylas-exports'
import {Popover} from 'nylas-component-kit'
import SendLaterActions from './send-later-actions'
import SendLaterStore from './send-later-store'
import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} from './send-later-constants'
const SendLaterOptions = {
'In 1 hour': DateUtils.in1Hour,
'Later Today': DateUtils.laterToday,
'Tomorrow Morning': DateUtils.tomorrow,
'Tomorrow Evening': DateUtils.tomorrowEvening,
'This Weekend': DateUtils.thisWeekend,
'Next Week': DateUtils.nextWeek,
}
class SendLaterPopover extends Component {
static displayName = 'SendLaterPopover';
static propTypes = {
draftClientId: PropTypes.string,
};
constructor(props) {
super(props)
this.state = {
inputSendDate: null,
isScheduled: SendLaterStore.isScheduled(this.props.draftClientId),
}
}
componentDidMount() {
this.unsubscribe = SendLaterStore.listen(this.onScheduledMessagesChanged)
}
componentWillUnmount() {
this.unsubscribe()
}
onSendLater = (momentDate)=> {
const utcDate = momentDate.utc()
const formatted = DateUtils.format(utcDate)
SendLaterActions.sendLater(this.props.draftClientId, formatted)
this.setState({isScheduled: null, inputSendDate: null})
this.refs.popover.close()
};
onCancelSendLater = ()=> {
SendLaterActions.cancelSendLater(this.props.draftClientId)
this.setState({inputSendDate: null})
this.refs.popover.close()
};
onScheduledMessagesChanged = ()=> {
const isScheduled = SendLaterStore.isScheduled(this.props.draftClientId)
if (isScheduled !== this.state.isScheduled) {
this.setState({isScheduled});
}
};
onInputChange = (event)=> {
this.updateInputSendDateValue(event.target.value)
};
getButtonLabel = (isScheduled)=> {
return isScheduled ? '✅ Scheduled' : 'Send Later';
};
updateInputSendDateValue = _.debounce((dateValue)=> {
const inputSendDate = DateUtils.fromString(dateValue)
this.setState({inputSendDate})
}, 250);
renderItems() {
return Object.keys(SendLaterOptions).map((label)=> {
const date = SendLaterOptions[label]()
const formatted = DateUtils.format(date, DATE_FORMAT_SHORT)
return (
<div
key={label}
onMouseDown={this.onSendLater.bind(this, date)}
className="send-later-option">
{label}
<em className="item-date-value">{formatted}</em>
</div>
);
})
}
renderEmptyInput() {
return (
<div className="send-later-section">
<label>At a specific time</label>
<input
type="text"
placeholder="e.g. Next Monday at 1pm"
onChange={this.onInputChange}/>
</div>
)
}
renderLabeledInput(inputSendDate) {
const formatted = DateUtils.format(inputSendDate, DATE_FORMAT_LONG)
return (
<div className="send-later-section">
<label>At a specific time</label>
<input
type="text"
placeholder="e.g. Next Monday at 1pm"
onChange={this.onInputChange}/>
<em className="input-date-value">{formatted}</em>
<button
className="btn btn-send-later"
onClick={this.onSendLater.bind(this, inputSendDate)}>Schedule Email</button>
</div>
)
}
render() {
const {isScheduled, inputSendDate} = this.state
const buttonLabel = isScheduled != null ? this.getButtonLabel(isScheduled) : 'Scheduling...';
const button = (
<button className="btn btn-primary send-later-button">{buttonLabel}</button>
)
const input = inputSendDate ? this.renderLabeledInput(inputSendDate) : this.renderEmptyInput();
return (
<Popover
ref="popover"
style={{order: -103}}
className="send-later"
buttonComponent={button}>
<div className="send-later-container">
{this.renderItems()}
<div className="divider" />
{input}
{isScheduled ?
<div className="divider" />
: void 0}
{isScheduled ?
<div className="send-later-section">
<button className="btn btn-send-later" onClick={this.onCancelSendLater}>
Unschedule Send
</button>
</div>
: void 0}
</div>
</Popover>
);
}
}
export default SendLaterPopover

View file

@ -0,0 +1,40 @@
import React, {Component, PropTypes} from 'react'
import moment from 'moment'
import {DateUtils} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import SendLaterActions from './send-later-actions'
import {PLUGIN_ID, DATE_FORMAT_SHORT} from './send-later-constants'
export default class SendLaterStatus extends Component {
static displayName = 'SendLaterStatus';
static propTypes = {
draft: PropTypes.object,
};
onCancelSendLater = ()=> {
SendLaterActions.cancelSendLater(this.props.draft.clientId)
};
render() {
const {draft} = this.props
const metadata = draft.metadataForPluginId(PLUGIN_ID)
if (metadata && metadata.sendLaterDate) {
const {sendLaterDate} = metadata
const formatted = DateUtils.format(moment(sendLaterDate), DATE_FORMAT_SHORT)
return (
<div className="send-later-status">
<em className="send-later-status">
{`Scheduled for ${formatted}`}
</em>
<RetinaImg
name="image-cancel-button.png"
title="Cancel Send Later"
onClick={this.onCancelSendLater}
mode={RetinaImg.Mode.ContentPreserve} />
</div>
)
}
return <span />
}
}

View file

@ -0,0 +1,84 @@
/** @babel */
import NylasStore from 'nylas-store'
import {NylasAPI, Actions, Message, Rx, DatabaseStore} from 'nylas-exports'
import SendLaterActions from './send-later-actions'
import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
class SendLaterStore extends NylasStore {
constructor(pluginId = PLUGIN_ID) {
super()
this.pluginId = pluginId
this.scheduledMessages = new Map()
}
activate() {
this.setupQuerySubscription()
this.unsubscribers = [
SendLaterActions.sendLater.listen(this.onSendLater),
SendLaterActions.cancelSendLater.listen(this.onCancelSendLater),
]
}
setupQuerySubscription() {
const query = DatabaseStore.findAll(
Message, [Message.attributes.pluginMetadata.contains(this.pluginId)]
)
this.queryDisposable = Rx.Observable.fromQuery(query).subscribe(this.onScheduledMessagesChanged)
}
getScheduledMessage = (messageClientId)=> {
return this.scheduledMessages.get(messageClientId)
};
isScheduled = (messageClientId)=> {
const message = this.getScheduledMessage(messageClientId)
if (message && message.metadataForPluginId(this.pluginId).sendLaterDate) {
return true
}
return false
};
setMetadata = (draftClientId, metadata)=> {
return (
DatabaseStore.modelify(Message, [draftClientId])
.then((messages)=> {
const {accountId} = messages[0]
return NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, accountId)
.then(()=> {
Actions.setMetadata(messages, this.pluginId, metadata)
})
.catch((error)=> {
console.error(error)
NylasEnv.showErrorDialog(error.message)
})
})
)
};
onScheduledMessagesChanged = (messages)=> {
this.scheduledMessages.clear()
messages.forEach((message)=> {
this.scheduledMessages.set(message.clientId, message);
})
this.trigger()
};
onSendLater = (draftClientId, sendLaterDate)=> {
this.setMetadata(draftClientId, {sendLaterDate})
};
onCancelSendLater = (draftClientId)=> {
this.setMetadata(draftClientId, {sendLaterDate: null})
};
deactivate = ()=> {
this.queryDisposable.dispose()
this.unsubscribers.forEach(unsub => unsub())
};
}
export default new SendLaterStore()

View file

@ -0,0 +1,15 @@
{
"name": "n1-send-later",
"version": "1.0.0",
"description": "send email later",
"main": "lib/main",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"windowTypes": {
"default": true,
"composer": true
},
"isOptional": true,
"license": "GPL-3.0"
}

View file

@ -0,0 +1,70 @@
@import "ui-variables";
.send-later {
.send-later-container {
display: flex;
flex-direction: column;
padding: 10px 0;
width: 250px;
.divider {
border-top: 1px solid @border-color-divider;
margin: 10px 0;
width: 90%;
align-self: center;
}
.send-later-section {
padding: 0 10px;
display: flex;
flex-direction: column;
label {
margin-bottom: 3px;
}
input {
border: 1px solid @input-border;
}
.input-date-value {
font-size: 0.9em;
margin: 5px 0;
}
.btn-send-later {
width: 100%;
}
}
.send-later-option {
cursor: default;
width: 100%;
padding: 1px 10px;
.item-date-value {
display: none;
float: right;
font-size: 0.9em;
}
&:hover {
background-color: @background-secondary;
.item-date-value {
display: inline-block;
}
}
}
}
}
.send-later-status {
display: flex;
align-items: center;
em {
font-size: 0.9em;
opacity: 0.62;
}
img {
width: 38px;
margin-left: 15px;
}
}

View file

@ -1,17 +1,10 @@
_ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
{ListTabular,
InjectedComponent,
Flexbox} = require 'nylas-component-kit'
{timestamp,
subject} = require './formatting-utils'
{Actions} = require 'nylas-exports'
SendingProgressBar = require './sending-progress-bar'
SendingCancelButton = require './sending-cancel-button'
{InjectedComponentSet, ListTabular} = require 'nylas-component-kit'
{subject} = require './formatting-utils'
snippet = (html) =>
return "" unless html and typeof(html) is 'string'
@ -51,16 +44,15 @@ ContentsColumn = new ListTabular.Column
{attachments}
</span>
SendStateColumn = new ListTabular.Column
StatusColumn = new ListTabular.Column
name: "State"
resolver: (draft) =>
if draft.uploadTaskId
<Flexbox style={width:150, whiteSpace: 'no-wrap'}>
<SendingProgressBar style={flex: 1, marginRight: 10} progress={draft.uploadProgress * 100} />
<SendingCancelButton taskId={draft.uploadTaskId} />
</Flexbox>
else
<span className="timestamp">{timestamp(draft.date)}</span>
<InjectedComponentSet
inline={true}
containersRequired={false}
matching={role: "DraftList:DraftStatus"}
className="draft-list-injected-state"
exposedProps={{draft}}/>
module.exports =
Wide: [ParticipantsColumn, ContentsColumn, SendStateColumn]
Wide: [ParticipantsColumn, ContentsColumn, StatusColumn]

View file

@ -0,0 +1,28 @@
import React, {Component, PropTypes} from 'react'
import {Flexbox} from 'nylas-component-kit'
import {timestamp} from './formatting-utils'
import SendingProgressBar from './sending-progress-bar'
import SendingCancelButton from './sending-cancel-button'
export default class DraftListSendStatus extends Component {
static displayName = 'DraftListSendStatus';
static propTypes = {
draft: PropTypes.object,
};
static containerRequired = false;
render() {
const {draft} = this.props
if (draft.uploadTaskId) {
return (
<Flexbox style={{width: 150, whiteSpace: 'no-wrap'}}>
<SendingProgressBar style={{flex: 1, marginRight: 10}} progress={draft.uploadProgress * 100} />
<SendingCancelButton taskId={draft.uploadTaskId} />
</Flexbox>
)
}
return <span className="timestamp">{timestamp(draft.date)}</span>
}
}

View file

@ -10,6 +10,7 @@ ThreadList = require './thread-list'
DraftSelectionBar = require './draft-selection-bar'
DraftList = require './draft-list'
DraftListSendStatus = require './draft-list-send-status'
module.exports =
activate: (@state={}) ->
@ -51,6 +52,9 @@ module.exports =
ComponentRegistry.register DraftDeleteButton,
role: 'draft:BulkAction'
ComponentRegistry.register DraftListSendStatus,
role: 'DraftList:DraftStatus'
deactivate: ->
ComponentRegistry.unregister DraftList
ComponentRegistry.unregister DraftSelectionBar
@ -62,3 +66,4 @@ module.exports =
ComponentRegistry.unregister DownButton
ComponentRegistry.unregister UpButton
ComponentRegistry.unregister DraftDeleteButton
ComponentRegistry.unregister DraftListSendStatus

View file

@ -116,13 +116,16 @@
}
}
.list-column-State {
display: flex;
align-items: center;
}
.timestamp {
font-size: @font-size-small;
font-weight: @font-weight-normal;
text-align: right;
min-width:70px;
margin-right:@scrollbar-margin;
display:inline-block;
opacity: 0.62;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,57 @@
/** @babel */
import React, {Component, PropTypes} from 'react';
import {RetinaImg} from 'nylas-component-kit';
import SnoozePopover from './snooze-popover';
const toolbarButton = (
<button
className="btn btn-toolbar btn-snooze"
title="Snooze">
<RetinaImg
url="nylas://thread-snooze/assets/ic-toolbar-native-snooze@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
</button>
)
const quickActionButton = (
<div title="Snooze" className="btn action action-snooze" />
)
export class BulkThreadSnooze extends Component {
static displayName = 'BulkThreadSnooze';
static propTypes = {
selection: PropTypes.object,
items: PropTypes.array,
};
render() {
return <SnoozePopover buttonComponent={toolbarButton} threads={this.props.items} />;
}
}
export class ToolbarSnooze extends Component {
static displayName = 'ToolbarSnooze';
static propTypes = {
thread: PropTypes.object,
};
render() {
return <SnoozePopover buttonComponent={toolbarButton} threads={[this.props.thread]} />;
}
}
export class QuickActionSnooze extends Component {
static displayName = 'QuickActionSnooze';
static propTypes = {
thread: PropTypes.object,
};
render() {
return <SnoozePopover buttonComponent={quickActionButton} threads={[this.props.thread]} />;
}
}

View file

@ -0,0 +1,23 @@
/** @babel */
import {ComponentRegistry} from 'nylas-exports';
import {ToolbarSnooze, QuickActionSnooze, BulkThreadSnooze} from './components';
import SnoozeStore from './snooze-store'
export function activate() {
this.snoozeStore = new SnoozeStore()
ComponentRegistry.register(ToolbarSnooze, {role: 'message:Toolbar'});
ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'});
ComponentRegistry.register(BulkThreadSnooze, {role: 'thread:BulkAction'});
}
export function deactivate() {
ComponentRegistry.unregister(ToolbarSnooze);
ComponentRegistry.unregister(QuickActionSnooze);
ComponentRegistry.unregister(BulkThreadSnooze);
this.snoozeStore.deactivate()
}
export function serialize() {
}

View file

@ -0,0 +1,12 @@
/** @babel */
import Reflux from 'reflux';
const SnoozeActions = Reflux.createActions([
'snoozeThreads',
])
for (const key in SnoozeActions) {
SnoozeActions[key].sync = true
}
export default SnoozeActions

View file

@ -0,0 +1,108 @@
/** @babel */
import _ from 'underscore';
import {
Actions,
Thread,
Category,
CategoryStore,
DatabaseStore,
AccountStore,
SyncbackCategoryTask,
TaskQueueStatusStore,
TaskFactory,
} from 'nylas-exports';
import {SNOOZE_CATEGORY_NAME} from './snooze-constants'
export function createSnoozeCategory(accountId, name = SNOOZE_CATEGORY_NAME) {
const category = new Category({
displayName: name,
accountId: accountId,
})
const task = new SyncbackCategoryTask({category})
Actions.queueTask(task)
return TaskQueueStatusStore.waitForPerformRemote(task).then(()=>{
return DatabaseStore.findBy(Category, {clientId: category.clientId})
.then((updatedCat)=> {
if (updatedCat.isSavedRemotely()) {
return Promise.resolve(updatedCat)
}
return Promise.reject(new Error('Could not create Snooze category'))
})
})
}
export function whenCategoriesReady() {
const categoriesReady = ()=> CategoryStore.categories().length > 0
if (!categoriesReady()) {
return new Promise((resolve)=> {
const unsubscribe = CategoryStore.listen(()=> {
if (categoriesReady()) {
unsubscribe()
resolve()
}
})
})
}
return Promise.resolve()
}
export function getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
return whenCategoriesReady()
.then(()=> {
const userCategories = CategoryStore.userCategories(accountId)
const category = _.findWhere(userCategories, {displayName: categoryName})
if (category) {
return Promise.resolve(category);
}
return createSnoozeCategory(accountId, categoryName)
})
}
export function getSnoozeCategoriesByAccount(accounts = AccountStore.accounts()) {
const categoriesByAccountId = {}
accounts.forEach(({id})=> {
if (categoriesByAccountId[id] != null) return;
categoriesByAccountId[id] = getSnoozeCategory(id)
})
return Promise.props(categoriesByAccountId)
}
export function moveThreads(threads, categoriesByAccountId, {snooze} = {}) {
const inbox = CategoryStore.getInboxCategory
const snoozeCat = (accId)=> categoriesByAccountId[accId]
const tasks = TaskFactory.tasksForApplyingCategories({
threads,
categoriesToRemove: snooze ? inbox : snoozeCat,
categoryToAdd: snooze ? snoozeCat : inbox,
})
Actions.queueTasks(tasks)
const promises = tasks.map(task => TaskQueueStatusStore.waitForPerformRemote(task))
// Resolve with the updated threads
return (
Promise.all(promises)
.then(()=> DatabaseStore.modelify(Thread, _.pluck(threads, 'id')))
)
}
export function moveThreadsToSnooze(threads) {
return getSnoozeCategoriesByAccount()
.then((categoriesByAccountId)=> {
return moveThreads(threads, categoriesByAccountId, {snooze: true})
})
}
export function moveThreadsFromSnooze(threads) {
return getSnoozeCategoriesByAccount()
.then((categoriesByAccountId)=> {
return moveThreads(threads, categoriesByAccountId, {snooze: false})
})
}

View file

@ -0,0 +1,4 @@
/** @babel */
export const PLUGIN_ID = "59t1k7y44kf8t450qsdw121ui"
export const PLUGIN_NAME = "Snooze Plugin"
export const SNOOZE_CATEGORY_NAME = "N1-Snoozed"

View file

@ -0,0 +1,62 @@
/** @babel */
import _ from 'underscore';
import React, {Component, PropTypes} from 'react';
import {DateUtils} from 'nylas-exports'
import {Popover} from 'nylas-component-kit';
import SnoozeActions from './snooze-actions'
const SnoozeOptions = {
'Later Today': DateUtils.laterToday,
'Tonight': DateUtils.tonight,
'Tomorrow': DateUtils.tomorrow,
'This Weekend': DateUtils.thisWeekend,
'Next Week': DateUtils.nextWeek,
'Next Month': DateUtils.nextMonth,
}
class SnoozePopover extends Component {
static displayName = 'SnoozePopover';
static propTypes = {
threads: PropTypes.array.isRequired,
buttonComponent: PropTypes.object.isRequired,
};
onSnooze(dateGenerator) {
const utcDate = dateGenerator().utc()
const formatted = DateUtils.format(utcDate)
SnoozeActions.snoozeThreads(this.props.threads, formatted)
}
renderItem = (label, dateGenerator)=> {
return (
<div
key={label}
className="snooze-item"
onMouseDown={this.onSnooze.bind(this, dateGenerator)}>
{label}
</div>
)
};
render() {
const {buttonComponent} = this.props
const items = _.map(SnoozeOptions, (dateGenerator, label)=> this.renderItem(label, dateGenerator))
return (
<Popover
style={{order: -103}}
className="snooze-popover"
direction="down-align-left"
buttonComponent={buttonComponent}>
<div className="snooze-container">
{items}
</div>
</Popover>
);
}
}
export default SnoozePopover;

View file

@ -0,0 +1,39 @@
/** @babel */
import {Actions, NylasAPI, AccountStore} from 'nylas-exports';
import {moveThreadsToSnooze} from './snooze-category-helpers';
import {PLUGIN_ID, PLUGIN_NAME} from './snooze-constants';
import SnoozeActions from './snooze-actions';
class SnoozeStore {
constructor(pluginId = PLUGIN_ID) {
this.pluginId = pluginId
this.unsubscribe = SnoozeActions.snoozeThreads.listen(this.onSnoozeThreads)
}
onSnoozeThreads = (threads, snoozeDate)=> {
const accounts = AccountStore.accountsForItems(threads)
const promises = accounts.map((acc)=> {
return NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, acc)
})
Promise.all(promises)
.then(()=> {
return moveThreadsToSnooze(threads)
})
.then((updatedThreads)=> {
Actions.setMetadata(updatedThreads, this.pluginId, {snoozeDate})
})
.catch((error)=> {
console.error(error)
NylasEnv.showErrorDialog(error.message)
})
};
deactivate() {
this.unsubscribe()
}
}
export default SnoozeStore;

View file

@ -0,0 +1,18 @@
{
"name": "thread-snooze",
"version": "1.0.0",
"title": "Thread Snooze",
"description": "Snooze mail!",
"main": "lib/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "github.com/nylas/n1"
},
"engines": {
"nylas": ">=0.3.0 <0.5.0"
},
"license": "GPL-3.0"
}

View file

@ -0,0 +1,30 @@
@import "ui-variables";
@snooze-img: "../internal_packages/thread-snooze/assets/ic-toolbar-native-snooze@2x.png";
.thread-list .list-item .list-column-HoverActions .action.action-snooze {
background: url(@snooze-img) center no-repeat, @background-gradient;
background-size: 50%;
}
.snooze-popover {
.snooze-container {
display: flex;
flex-direction: column;
.snooze-item {
padding: 7px 17px;
cursor: default;
min-width: 175px;
background-color: @background-primary;
line-height: initial;
text-align: initial;
&+.snooze-item {
border-top: 1px solid @border-color-divider;
}
&:hover {
background-color: @background-secondary;
}
}
}
}

View file

@ -17,6 +17,7 @@
"atom-keymap": "^6.1.1",
"babel-core": "^5.8.21",
"bluebird": "^2.9",
"chrono-node": "^1.1.2",
"classnames": "1.2.1",
"clear-cut": "^2.0.1",
"coffee-react": "^2.0.0",

View file

@ -32,7 +32,7 @@ class ListTabularItem extends React.Component
# We only do it if the item prop has changed.
@_columnCache ?= @_columns()
<div {...props} className={className} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height, overflow: 'hidden'}>
<div {...props} className={className} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
{@_columnCache}
</div>

View file

@ -190,7 +190,8 @@ class Popover extends React.Component
if event.key is "Escape"
@close()
_onClick: =>
_onClick: (e) =>
e.stopPropagation()
if not @state.showing
@open()
else

94
src/date-utils.es6 Normal file
View file

@ -0,0 +1,94 @@
/** @babel */
import moment from 'moment'
import chrono from 'chrono-node'
const Hours = {
Morning: 9,
Evening: 19,
}
const Days = {
NextMonday: 8,
ThisWeekend: 6,
}
moment.prototype.oclock = function oclock() {
return this.minute(0).second(0)
}
moment.prototype.morning = function morning(morningHour = Hours.Morning) {
return this.hour(morningHour).oclock()
}
moment.prototype.evening = function evening(eveningHour = Hours.Evening) {
return this.hour(eveningHour).oclock()
}
const DateUtils = {
format(momentDate, formatString) {
if (!momentDate) return null;
return momentDate.format(formatString);
},
utc(momentDate) {
if (!momentDate) return null;
return momentDate.utc();
},
minutesFromNow(minutes, now = moment()) {
return now.add(minutes, 'minutes');
},
in1Hour() {
return DateUtils.minutesFromNow(60);
},
laterToday(now = moment()) {
return now.add(3, 'hours').oclock();
},
tonight(now = moment()) {
if (now.hour() >= Hours.Evening) {
return DateUtils.tomorrowEvening();
}
return now.evening();
},
tomorrow(now = moment()) {
return now.add(1, 'day').morning();
},
tomorrowEvening(now = moment()) {
return now.add(1, 'day').evening()
},
thisWeekend(now = moment()) {
return now.day(Days.ThisWeekend).morning()
},
nextWeek(now = moment()) {
return now.day(Days.NextMonday).morning()
},
nextMonth(now = moment()) {
return now.add(1, 'month').date(1).morning()
},
/**
* Can take almost any string.
* e.g. "Next monday at 2pm"
* @param {string} dateLikeString - a string representing a date.
* @return {moment} - moment object representing date
*/
fromString(dateLikeString) {
const date = chrono.parseDate(dateLikeString)
if (!date) {
return null
}
return moment(date)
},
}
export default DateUtils

View file

@ -1,6 +1,7 @@
_ = require 'underscore'
request = require 'request'
Utils = require './models/utils'
Account = require './models/account'
Actions = require './actions'
{APIError} = require './errors'
PriorityUICoordinator = require '../priority-ui-coordinator'
@ -382,30 +383,35 @@ class NylasAPI
# 3. The API request to auth this account to the plugin failed. This may mean that
# the plugin server couldn't be reached or failed to respond properly when authing
# the account, or that the Nylas API couldn't be reached.
authPlugin: (pluginId, pluginName, accountId) ->
AccountStore = AccountStore || require './stores/account-store'
account = AccountStore.accountForId(accountId)
authPlugin: (pluginId, pluginName, accountOrId) ->
account = if accountOrId instanceof Account
accountOrId
else
AccountStore ?= require './stores/account-store'
AccountStore.accountForId(accountOrId)
Promise.reject(new Error('Invalid account')) unless account
return @makeRequest({
returnsModel: false,
method: "GET",
accountId: account.id,
path: "/auth/plugin?client_id=#{pluginId}"
}).then( (result) =>
})
.then (result) =>
if result.authed
return Promise.resolve()
else
return @_requestPluginAuth(pluginName, account).then( => @makeRequest({
returnsModel: false,
method: "POST",
accountId: account.id,
path: "/auth/plugin",
body: {client_id: pluginId},
json: true
}))
)
return @_requestPluginAuth(pluginName, account).then =>
@makeRequest({
returnsModel: false,
method: "POST",
accountId: account.id,
path: "/auth/plugin",
body: {client_id: pluginId},
json: true
})
_requestPluginAuth: (pluginName, account) ->
dialog = require('remote').require('dialog')
{dialog} = require('electron').remote
return new Promise( (resolve, reject) =>
dialog.showMessageBox({
title: "Plugin Offline Email Access",
@ -429,6 +435,6 @@ You can review and revoke Offline Access for plugins at any time from Preference
method: "DELETE",
accountId: accountId,
path: "/auth/plugin?client_id=#{pluginId}"
});
})
module.exports = new NylasAPI()

View file

@ -66,7 +66,7 @@ class CategoryStore extends NylasStore
# ('inbox', 'drafts', etc.) It's possible for this to return `null`.
# For example, Gmail likely doesn't have an `archive` label.
#
getStandardCategory: (accountOrId, name) ->
getStandardCategory: (accountOrId, name) =>
return null unless accountOrId
unless name in StandardCategoryNames
@ -76,7 +76,7 @@ class CategoryStore extends NylasStore
# Public: Returns the set of all standard categories that match the given
# names for each of the provided accounts
getStandardCategories: (accountsOrIds, names...) ->
getStandardCategories: (accountsOrIds, names...) =>
if Array.isArray(accountsOrIds)
res = []
for accOrId in accountsOrIds
@ -90,7 +90,7 @@ class CategoryStore extends NylasStore
# actions. On Gmail, this is the "all" label. On providers using folders, it
# returns any available "Archive" folder, or null if no such folder exists.
#
getArchiveCategory: (accountOrId) ->
getArchiveCategory: (accountOrId) =>
return null unless accountOrId
account = asAccount(accountOrId)
return null unless account
@ -103,13 +103,13 @@ class CategoryStore extends NylasStore
# Public: Returns the Folder or Label object that should be used for
# the inbox or null if it doesn't exist
#
getInboxCategory: (accountOrId) ->
getInboxCategory: (accountOrId) =>
@getStandardCategory(accountOrId, "inbox")
# Public: Returns the Folder or Label object that should be used for
# "Move to Trash", or null if no trash folder exists.
#
getTrashCategory: (accountOrId) ->
getTrashCategory: (accountOrId) =>
@getStandardCategory(accountOrId, "trash")
_onCategoriesChanged: (categories) =>

View file

@ -34,9 +34,11 @@ class TaskFactory
threads: threads
else
labelsToAdd = if categoryToAdd then [categoryToAdd] else []
labelsToRemove = categoriesToRemove ? []
labelsToRemove = if labelsToRemove instanceof Array then labelsToRemove else [labelsToRemove]
tasks.push new ChangeLabelsTask
threads: threads
labelsToRemove: categoriesToRemove
labelsToRemove: labelsToRemove
labelsToAdd: labelsToAdd
return tasks

View file

@ -161,6 +161,7 @@ class NylasExports
@load "DOMUtils", 'dom-utils'
@load "CanvasUtils", 'canvas-utils'
@load "RegExpUtils", 'regexp-utils'
@load "DateUtils", 'date-utils'
@load "MenuHelpers", 'menu-helpers'
@load "MessageUtils", 'flux/models/message-utils'
@load "NylasSpellchecker", 'nylas-spellchecker'