mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 07:46:06 +08:00
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:
parent
21ce6355a5
commit
a841417011
|
@ -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],
|
||||
|
|
22
internal_packages/send-later/lib/main.js
Normal file
22
internal_packages/send-later/lib/main.js
Normal 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() {
|
||||
|
||||
}
|
||||
|
13
internal_packages/send-later/lib/send-later-actions.js
Normal file
13
internal_packages/send-later/lib/send-later-actions.js
Normal 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
|
6
internal_packages/send-later/lib/send-later-constants.js
Normal file
6
internal_packages/send-later/lib/send-later-constants.js
Normal 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'
|
||||
|
158
internal_packages/send-later/lib/send-later-popover.jsx
Normal file
158
internal_packages/send-later/lib/send-later-popover.jsx
Normal 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
|
40
internal_packages/send-later/lib/send-later-status.jsx
Normal file
40
internal_packages/send-later/lib/send-later-status.jsx
Normal 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 />
|
||||
}
|
||||
}
|
84
internal_packages/send-later/lib/send-later-store.js
Normal file
84
internal_packages/send-later/lib/send-later-store.js
Normal 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()
|
15
internal_packages/send-later/package.json
Normal file
15
internal_packages/send-later/package.json
Normal 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"
|
||||
}
|
70
internal_packages/send-later/stylesheets/send-later.less
Normal file
70
internal_packages/send-later/stylesheets/send-later.less
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
28
internal_packages/thread-list/lib/draft-list-send-status.jsx
Normal file
28
internal_packages/thread-list/lib/draft-list-send-status.jsx
Normal 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>
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
0
internal_packages/thread-snooze/README.md
Normal file
0
internal_packages/thread-snooze/README.md
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
57
internal_packages/thread-snooze/lib/components.jsx
Normal file
57
internal_packages/thread-snooze/lib/components.jsx
Normal 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]} />;
|
||||
}
|
||||
}
|
23
internal_packages/thread-snooze/lib/main.js
Normal file
23
internal_packages/thread-snooze/lib/main.js
Normal 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() {
|
||||
|
||||
}
|
12
internal_packages/thread-snooze/lib/snooze-actions.js
Normal file
12
internal_packages/thread-snooze/lib/snooze-actions.js
Normal 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
|
108
internal_packages/thread-snooze/lib/snooze-category-helpers.js
Normal file
108
internal_packages/thread-snooze/lib/snooze-category-helpers.js
Normal 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})
|
||||
})
|
||||
}
|
4
internal_packages/thread-snooze/lib/snooze-constants.js
Normal file
4
internal_packages/thread-snooze/lib/snooze-constants.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @babel */
|
||||
export const PLUGIN_ID = "59t1k7y44kf8t450qsdw121ui"
|
||||
export const PLUGIN_NAME = "Snooze Plugin"
|
||||
export const SNOOZE_CATEGORY_NAME = "N1-Snoozed"
|
62
internal_packages/thread-snooze/lib/snooze-popover.jsx
Normal file
62
internal_packages/thread-snooze/lib/snooze-popover.jsx
Normal 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;
|
39
internal_packages/thread-snooze/lib/snooze-store.js
Normal file
39
internal_packages/thread-snooze/lib/snooze-store.js
Normal 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;
|
18
internal_packages/thread-snooze/package.json
Normal file
18
internal_packages/thread-snooze/package.json
Normal 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"
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
94
src/date-utils.es6
Normal 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
|
|
@ -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()
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue