diff --git a/build/config/eslint.json b/build/config/eslint.json index 905a80e23..672f41f80 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -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], diff --git a/internal_packages/send-later/lib/main.js b/internal_packages/send-later/lib/main.js new file mode 100644 index 000000000..bbed9857c --- /dev/null +++ b/internal_packages/send-later/lib/main.js @@ -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() { + +} + diff --git a/internal_packages/send-later/lib/send-later-actions.js b/internal_packages/send-later/lib/send-later-actions.js new file mode 100644 index 000000000..e96a42b07 --- /dev/null +++ b/internal_packages/send-later/lib/send-later-actions.js @@ -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 diff --git a/internal_packages/send-later/lib/send-later-constants.js b/internal_packages/send-later/lib/send-later-constants.js new file mode 100644 index 000000000..908192aa6 --- /dev/null +++ b/internal_packages/send-later/lib/send-later-constants.js @@ -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' + diff --git a/internal_packages/send-later/lib/send-later-popover.jsx b/internal_packages/send-later/lib/send-later-popover.jsx new file mode 100644 index 000000000..55f583760 --- /dev/null +++ b/internal_packages/send-later/lib/send-later-popover.jsx @@ -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 ( +
+ {label} + {formatted} +
+ ); + }) + } + + renderEmptyInput() { + return ( +
+ + +
+ ) + } + + renderLabeledInput(inputSendDate) { + const formatted = DateUtils.format(inputSendDate, DATE_FORMAT_LONG) + return ( +
+ + + {formatted} + +
+ ) + } + + render() { + const {isScheduled, inputSendDate} = this.state + const buttonLabel = isScheduled != null ? this.getButtonLabel(isScheduled) : 'Scheduling...'; + const button = ( + + ) + const input = inputSendDate ? this.renderLabeledInput(inputSendDate) : this.renderEmptyInput(); + + return ( + +
+ {this.renderItems()} +
+ {input} + {isScheduled ? +
+ : void 0} + {isScheduled ? +
+ +
+ : void 0} +
+ + ); + } + +} + +export default SendLaterPopover diff --git a/internal_packages/send-later/lib/send-later-status.jsx b/internal_packages/send-later/lib/send-later-status.jsx new file mode 100644 index 000000000..2dd9033b3 --- /dev/null +++ b/internal_packages/send-later/lib/send-later-status.jsx @@ -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 ( +
+ + {`Scheduled for ${formatted}`} + + +
+ ) + } + return + } +} diff --git a/internal_packages/send-later/lib/send-later-store.js b/internal_packages/send-later/lib/send-later-store.js new file mode 100644 index 000000000..8f9d8034d --- /dev/null +++ b/internal_packages/send-later/lib/send-later-store.js @@ -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() diff --git a/internal_packages/send-later/package.json b/internal_packages/send-later/package.json new file mode 100644 index 000000000..5eccb1dc8 --- /dev/null +++ b/internal_packages/send-later/package.json @@ -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" +} diff --git a/internal_packages/send-later/stylesheets/send-later.less b/internal_packages/send-later/stylesheets/send-later.less new file mode 100644 index 000000000..4e307fde1 --- /dev/null +++ b/internal_packages/send-later/stylesheets/send-later.less @@ -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; + } +} diff --git a/internal_packages/thread-list/lib/draft-list-columns.cjsx b/internal_packages/thread-list/lib/draft-list-columns.cjsx index 02f242c02..da406b3f4 100644 --- a/internal_packages/thread-list/lib/draft-list-columns.cjsx +++ b/internal_packages/thread-list/lib/draft-list-columns.cjsx @@ -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} -SendStateColumn = new ListTabular.Column +StatusColumn = new ListTabular.Column name: "State" resolver: (draft) => - if draft.uploadTaskId - - - - - else - {timestamp(draft.date)} + module.exports = - Wide: [ParticipantsColumn, ContentsColumn, SendStateColumn] + Wide: [ParticipantsColumn, ContentsColumn, StatusColumn] diff --git a/internal_packages/thread-list/lib/draft-list-send-status.jsx b/internal_packages/thread-list/lib/draft-list-send-status.jsx new file mode 100644 index 000000000..458fd3423 --- /dev/null +++ b/internal_packages/thread-list/lib/draft-list-send-status.jsx @@ -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 ( + + + + + ) + } + return {timestamp(draft.date)} + } +} diff --git a/internal_packages/thread-list/lib/main.cjsx b/internal_packages/thread-list/lib/main.cjsx index a7dce9a23..b376fa41e 100644 --- a/internal_packages/thread-list/lib/main.cjsx +++ b/internal_packages/thread-list/lib/main.cjsx @@ -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 diff --git a/internal_packages/thread-list/stylesheets/thread-list.less b/internal_packages/thread-list/stylesheets/thread-list.less index 061096350..1f73158f7 100644 --- a/internal_packages/thread-list/stylesheets/thread-list.less +++ b/internal_packages/thread-list/stylesheets/thread-list.less @@ -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; } diff --git a/internal_packages/thread-snooze/README.md b/internal_packages/thread-snooze/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/internal_packages/thread-snooze/assets/ic-toolbar-native-snooze@2x.png b/internal_packages/thread-snooze/assets/ic-toolbar-native-snooze@2x.png new file mode 100644 index 000000000..11444b7f3 Binary files /dev/null and b/internal_packages/thread-snooze/assets/ic-toolbar-native-snooze@2x.png differ diff --git a/internal_packages/thread-snooze/lib/components.jsx b/internal_packages/thread-snooze/lib/components.jsx new file mode 100644 index 000000000..18d2ce811 --- /dev/null +++ b/internal_packages/thread-snooze/lib/components.jsx @@ -0,0 +1,57 @@ +/** @babel */ +import React, {Component, PropTypes} from 'react'; +import {RetinaImg} from 'nylas-component-kit'; +import SnoozePopover from './snooze-popover'; + + +const toolbarButton = ( + +) + +const quickActionButton = ( +
+) + + +export class BulkThreadSnooze extends Component { + static displayName = 'BulkThreadSnooze'; + + static propTypes = { + selection: PropTypes.object, + items: PropTypes.array, + }; + + render() { + return ; + } +} + +export class ToolbarSnooze extends Component { + static displayName = 'ToolbarSnooze'; + + static propTypes = { + thread: PropTypes.object, + }; + + render() { + return ; + } +} + +export class QuickActionSnooze extends Component { + static displayName = 'QuickActionSnooze'; + + static propTypes = { + thread: PropTypes.object, + }; + + render() { + return ; + } +} diff --git a/internal_packages/thread-snooze/lib/main.js b/internal_packages/thread-snooze/lib/main.js new file mode 100644 index 000000000..ae16de6db --- /dev/null +++ b/internal_packages/thread-snooze/lib/main.js @@ -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() { + +} diff --git a/internal_packages/thread-snooze/lib/snooze-actions.js b/internal_packages/thread-snooze/lib/snooze-actions.js new file mode 100644 index 000000000..e3b61e802 --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-actions.js @@ -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 diff --git a/internal_packages/thread-snooze/lib/snooze-category-helpers.js b/internal_packages/thread-snooze/lib/snooze-category-helpers.js new file mode 100644 index 000000000..7e70888b6 --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-category-helpers.js @@ -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}) + }) +} diff --git a/internal_packages/thread-snooze/lib/snooze-constants.js b/internal_packages/thread-snooze/lib/snooze-constants.js new file mode 100644 index 000000000..9eeeebfb6 --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-constants.js @@ -0,0 +1,4 @@ +/** @babel */ +export const PLUGIN_ID = "59t1k7y44kf8t450qsdw121ui" +export const PLUGIN_NAME = "Snooze Plugin" +export const SNOOZE_CATEGORY_NAME = "N1-Snoozed" diff --git a/internal_packages/thread-snooze/lib/snooze-popover.jsx b/internal_packages/thread-snooze/lib/snooze-popover.jsx new file mode 100644 index 000000000..76dc71759 --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-popover.jsx @@ -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 ( +
+ {label} +
+ ) + }; + + render() { + const {buttonComponent} = this.props + const items = _.map(SnoozeOptions, (dateGenerator, label)=> this.renderItem(label, dateGenerator)) + + return ( + +
+ {items} +
+
+ ); + } + +} + +export default SnoozePopover; diff --git a/internal_packages/thread-snooze/lib/snooze-store.js b/internal_packages/thread-snooze/lib/snooze-store.js new file mode 100644 index 000000000..87b87aa1d --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-store.js @@ -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; diff --git a/internal_packages/thread-snooze/package.json b/internal_packages/thread-snooze/package.json new file mode 100644 index 000000000..ac38248d6 --- /dev/null +++ b/internal_packages/thread-snooze/package.json @@ -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" +} diff --git a/internal_packages/thread-snooze/stylesheets/snooze-popover.less b/internal_packages/thread-snooze/stylesheets/snooze-popover.less new file mode 100644 index 000000000..09595f9b9 --- /dev/null +++ b/internal_packages/thread-snooze/stylesheets/snooze-popover.less @@ -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; + } + } + } +} diff --git a/package.json b/package.json index 836cffc01..c96ef4f46 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/list-tabular-item.cjsx b/src/components/list-tabular-item.cjsx index 0674091eb..37b34491d 100644 --- a/src/components/list-tabular-item.cjsx +++ b/src/components/list-tabular-item.cjsx @@ -32,7 +32,7 @@ class ListTabularItem extends React.Component # We only do it if the item prop has changed. @_columnCache ?= @_columns() -
+
{@_columnCache}
diff --git a/src/components/popover.cjsx b/src/components/popover.cjsx index 185029f64..8e7222a2f 100644 --- a/src/components/popover.cjsx +++ b/src/components/popover.cjsx @@ -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 diff --git a/src/date-utils.es6 b/src/date-utils.es6 new file mode 100644 index 000000000..d31119ae4 --- /dev/null +++ b/src/date-utils.es6 @@ -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 diff --git a/src/flux/nylas-api.coffee b/src/flux/nylas-api.coffee index 0f7883ddd..809921855 100644 --- a/src/flux/nylas-api.coffee +++ b/src/flux/nylas-api.coffee @@ -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() diff --git a/src/flux/stores/category-store.coffee b/src/flux/stores/category-store.coffee index f5cc91446..595d83376 100644 --- a/src/flux/stores/category-store.coffee +++ b/src/flux/stores/category-store.coffee @@ -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) => diff --git a/src/flux/tasks/task-factory.coffee b/src/flux/tasks/task-factory.coffee index 46659be33..606d9abdd 100644 --- a/src/flux/tasks/task-factory.coffee +++ b/src/flux/tasks/task-factory.coffee @@ -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 diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 88c4ab947..a1862f65c 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -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'