refactor(scheduler): move all event data into metadata

Summary: Moved events into metadata. Removed a lot of code

Test Plan: todo

Reviewers: juan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2866
This commit is contained in:
Evan Morikawa 2016-04-09 21:19:01 -04:00
parent f316c06f7a
commit a1b5a23273
42 changed files with 237 additions and 548 deletions

View file

@ -48,7 +48,7 @@ We're working on building a plugin index that makes it super easy to add them to
#### Bundled Plugins
Great starting points for creating your own plugins!
- [Translate](https://github.com/nylas/N1/tree/master/internal_packages/composer-translate) — Works with 10 languages
- [Scheduler](https://github.com/nylas/N1/tree/master/internal_packages/N1-Scheduler) — Show your availability to schedule a meeting with someone
- [Scheduler](https://github.com/nylas/N1/tree/master/internal_packages/composer-scheduler) — Show your availability to schedule a meeting with someone
- [Quick Replies](https://github.com/nylas/N1/tree/master/internal_packages/composer-templates) — Send emails faster with templates
- [Send Later](https://github.com/nylas/N1/tree/master/internal_packages/send-later) — Schedule your emails to be sent at a later time
- [Open Tracking](https://github.com/nylas/N1/tree/master/internal_packages/open-tracking) — See if your emails have been read

View file

@ -1,90 +0,0 @@
import NylasStore from 'nylas-store'
import SchedulerActions from './scheduler-actions'
import {Event, Message, Actions, DraftStore, DatabaseStore} from 'nylas-exports'
import {PLUGIN_ID} from './scheduler-constants'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
/**
* Maintains the creation of "Proposed Times" when scheduling with people.
*
* The proposed times are displayed in various calendar views.
*
*/
class ProposedTimeMainWindowStore extends NylasStore {
activate() {
this.unsubscribers = [
SchedulerActions.confirmChoices.listen(this._onConfirmChoices),
]
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub())
}
/**
* This removes the metadata on the draft and creates an `Event` on
* `draft.events`
*/
_convertToDraftEvent(draft) {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
return DraftStore.sessionForClientId(draft.clientId).then((session) => {
if (metadata.pendingEvent) {
const event = new Event().fromJSON(metadata.pendingEvent);
session.changes.add({events: [event]});
} else {
session.changes.add({events: []})
}
delete metadata.uid
delete metadata.proposals
delete metadata.pendingEvent
Actions.setMetadata(draft, PLUGIN_ID, metadata);
return session.changes.commit()
});
}
_convertToPendingEvent(draft, proposals) {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
metadata.proposals = proposals;
// This is used to so the backend can reference which draft
// corresponds to which sent message. The backend uses the key `uid`
metadata.uid = draft.clientId;
if (draft.events.length > 0) {
return DraftStore.sessionForClientId(draft.clientId).then((session) => {
metadata.pendingEvent = draft.events[0].toJSON();
session.changes.add({events: []});
return session.changes.commit().then(() => {
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
});
}
Actions.setMetadata(draft, PLUGIN_ID, metadata);
return Promise.resolve()
}
/**
* This will bundle up and attach the choices as metadata on the draft.
*
* Once we attach metadata to the draft, we need to make sure we clear
* the start and end times of the event.
*/
_onConfirmChoices = ({proposals, draftClientId}) => {
this._pendingSave = true;
this.trigger();
DatabaseStore.find(Message, draftClientId).then((draft) => {
if (proposals.length === 0) {
return this._convertToDraftEvent(draft)
}
return this._convertToPendingEvent(draft, proposals);
})
}
}
export default new ProposedTimeMainWindowStore()

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 788 B

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 511 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -2,8 +2,6 @@ import React, {Component, PropTypes} from 'react';
import NewEventCard from './new-event-card'
import {PLUGIN_ID} from '../scheduler-constants'
import {Utils, Event, Actions, DraftStore} from 'nylas-exports';
const MEETING_REQUEST = "MEETING_REQUEST"
const PENDING_EVENT = "PENDING_EVENT"
/**
* When you're creating an event you can either be creating:
@ -11,22 +9,17 @@ const PENDING_EVENT = "PENDING_EVENT"
* 1. A Meeting Request with a specific start and end time
* 2. OR a `pendingEvent` template that has a set of proposed times.
*
* The former (1) is represented by an `Event` object on the `draft.events`
* field of a draft.
* Both are represented by a `pendingEvent` object on the `metadata` that
* holds the JSONified representation of the `Event`
*
* The latter (2) is represented by a `pendingEvent` key on the metadata
* of the `draft`.
*
* These are mutually exclusive and shouldn't exist at the same time on a
* draft.
* #2 adds a set of `proposals` on the metadata object.
*/
export default class NewEventCardContainer extends Component {
static displayName = 'NewEventCardContainer';
static propTypes = {
draftClientId: PropTypes.string,
threadId: PropTypes.string,
};
}
constructor(props) {
super(props);
@ -36,7 +29,7 @@ export default class NewEventCardContainer extends Component {
this._usub = () => {}
}
componentDidMount() {
componentWillMount() {
this._mounted = true;
this._loadDraft(this.props.draftClientId);
}
@ -63,73 +56,30 @@ export default class NewEventCardContainer extends Component {
});
}
_eventType(draft) {
const metadata = draft.metadataForPluginId(PLUGIN_ID);
const hasPendingEvent = metadata && metadata.pendingEvent
if (draft.events && draft.events.length > 0) {
if (hasPendingEvent) {
throw new Error(`Assertion Failure. Can't have both a pendingEvent \
and an event on a draft at the same time!`);
}
return MEETING_REQUEST
} else if (hasPendingEvent) {
return PENDING_EVENT
_onDraftChange = () => {
this.setState({event: this._getEvent()});
}
_getEvent() {
const metadata = this._session.draft().metadataForPluginId(PLUGIN_ID);
if (metadata && metadata.pendingEvent) {
return new Event().fromJSON(metadata.pendingEvent || {})
}
return null
}
_onDraftChange = () => {
const draft = this._session.draft();
let event = null;
const eventType = this._eventType(draft)
if (eventType === MEETING_REQUEST) {
event = draft.events[0]
} else if (eventType === PENDING_EVENT) {
event = this._getPendingEvent(draft.metadataForPluginId(PLUGIN_ID))
}
this.setState({event});
}
_getPendingEvent(metadata) {
return new Event().fromJSON(metadata.pendingEvent || {})
}
_updateDraftEvent(newData) {
const draft = this._session.draft();
const data = newData
const event = Object.assign(draft.events[0].clone(), data);
if (!Utils.isEqual(event, draft.events[0])) {
this._session.changes.add({events: [event]}); // triggers draft change
this._session.changes.commit();
}
}
_updatePendingEvent(newData) {
_updateEvent = (newData) => {
const draft = this._session.draft()
const metadata = draft.metadataForPluginId(PLUGIN_ID)
const pendingEvent = Object.assign(this._getPendingEvent(metadata).clone(), newData)
const pendingEventJSON = pendingEvent.toJSON()
const newEvent = Object.assign(this._getEvent().clone(), newData)
const pendingEventJSON = newEvent.toJSON()
if (!Utils.isEqual(pendingEventJSON, metadata.pendingEvent)) {
metadata.pendingEvent = pendingEventJSON;
Actions.setMetadata(draft, PLUGIN_ID, metadata);
}
}
_onEventChange = (newData) => {
const eventType = this._eventType(this._session.draft());
if (eventType === MEETING_REQUEST) {
this._updateDraftEvent(newData)
} else if (eventType === PENDING_EVENT) {
this._updatePendingEvent(newData)
}
}
_onEventRemove = () => {
this._session.changes.add({events: []});
this._session.changes.commit();
_removeEvent = () => {
const draft = this._session.draft()
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (metadata) {
@ -145,8 +95,8 @@ and an event on a draft at the same time!`);
card = (
<NewEventCard event={this.state.event}
draft={this._session.draft()}
onRemove={this._onEventRemove}
onChange={this._onEventChange}
onRemove={this._removeEvent}
onChange={this._updateEvent}
onParticipantsClick={() => {}}
/>
)

View file

@ -159,7 +159,12 @@ export default class NewEventCard extends React.Component {
_renderTimePicker() {
const metadata = this.props.draft.metadataForPluginId(PLUGIN_ID);
if (metadata && metadata.proposals) {
return <ProposedTimeList event={this.props.event} proposals={metadata.proposals} />
return (
<ProposedTimeList event={this.props.event}
draft={this.props.draft}
proposals={metadata.proposals}
/>
)
}
const startVal = (this.props.event.start) * 1000;

View file

@ -11,6 +11,7 @@ const TZ = moment.tz(Utils.timeZone).format("z");
export default class ProposedTimeList extends React.Component {
static propTypes = {
draft: React.PropTypes.object,
event: React.PropTypes.object,
inEmail: React.PropTypes.bool,
proposals: React.PropTypes.array.isRequired,
}
@ -49,7 +50,7 @@ export default class ProposedTimeList extends React.Component {
<div>
<h2 style={styles}>
{this._renderB64Img("description")}
{((this.props.draft.events || [])[0] || {}).title || this.props.draft.subject}
{this.props.event.title || this.props.draft.subject}
</h2>
<span style={{margin: "0 10px"}}>
{this._renderB64Img("time")}

View file

@ -1,6 +1,7 @@
import React from 'react'
import {
Event,
Actions,
Calendar,
APIError,
NylasAPI,
@ -54,61 +55,59 @@ export default class SchedulerComposerButton extends React.Component {
}
_onDraftChange() {
const draft = this._session.draft();
this.setState({
enabled: draft.events && draft.events.length > 0,
});
this.setState({enabled: this._hasPendingEvent()});
}
_hasPendingEvent() {
const metadata = this._session.draft().metadataForPluginId(PLUGIN_ID);
return metadata && metadata.pendingEvent
}
_onClick = () => {
if (!this._session) { return }
const draft = this._session.draft()
if (draft.events.length === 0) { // API can only handle one event
NylasAPI.authPlugin(PLUGIN_ID, PLUGIN_NAME, draft.accountId)
.then(() => {
DatabaseStore.findAll(Calendar, {accountId: draft.accountId})
.then((allCalendars) => {
if (allCalendars.length === 0) {
throw new Error(`Can't create an event. The Account \
${draft.accountId} has no calendars.`);
}
NylasAPI.authPlugin(PLUGIN_ID, PLUGIN_NAME, draft.accountId)
.then(() => {
DatabaseStore.findAll(Calendar, {accountId: draft.accountId})
.then((allCalendars) => {
if (allCalendars.length === 0) {
throw new Error(`Can't create an event. The Account \
${draft.accountId} has no calendars.`);
}
const cals = allCalendars.filter(c => !c.readOnly);
const cals = allCalendars.filter(c => !c.readOnly);
if (cals.length === 0) {
NylasEnv.showErrorDialog(`This account has no editable \
if (cals.length === 0) {
NylasEnv.showErrorDialog(`This account has no editable \
calendars. We can't create an event for you. Please make sure you have an \
editable calendar with your account provider.`);
return;
}
const start = moment().ceil(30, 'minutes');
const end = moment(start).add(1, 'hour');
// TODO Have a default calendar config
const event = new Event({
end: end.unix(),
start: start.unix(),
calendarId: cals[0].id,
});
this._session.changes.add({events: [event]});
this._session.changes.commit()
})
}).catch((error) => {
let title = "Error"
let msg = `Unfortunately scheduling is not currently available. \
Please try again later.\n\nError: ${error}`
if (!(error instanceof APIError)) {
NylasEnv.reportError(error);
} else if (error.statusCode === 400) {
NylasEnv.reportError(error);
} else if (NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
title = "Offline"
msg = `Scheduling does not work offline. Please try again when you come back online.`
return;
}
NylasEnv.showErrorDialog({title, message: msg});
const start = moment().ceil(30, 'minutes');
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
metadata.uid = draft.clientId;
metadata.pendingEvent = new Event({
calendarId: cals[0].id,
start: start.unix(),
end: moment(start).add(1, 'hour').unix(),
}).toJSON();
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
}
}).catch((error) => {
let title = "Error"
let msg = `Unfortunately scheduling is not currently available. \
Please try again later.\n\nError: ${error}`
if (!(error instanceof APIError)) {
NylasEnv.reportError(error);
} else if (error.statusCode === 400) {
NylasEnv.reportError(error);
} else if (NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
title = "Offline"
msg = `Scheduling does not work offline. Please try again when you come back online.`
}
NylasEnv.showErrorDialog({title, message: msg});
})
}
render() {
@ -117,7 +116,7 @@ Please try again later.\n\nError: ${error}`
onClick={this._onClick}
title="Add an event…"
>
<RetinaImg url="nylas://N1-Scheduler/assets/ic-composer-scheduler@2x.png"
<RetinaImg url="nylas://composer-scheduler/assets/ic-composer-scheduler@2x.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button>)

View file

@ -1,8 +1,7 @@
import _ from 'underscore'
import React from 'react'
import {PLUGIN_ID} from '../scheduler-constants'
import ProposedTimeList from './proposed-time-list'
import {Actions, RegExpUtils, ComposerExtension} from 'nylas-exports'
import {Event, Actions, RegExpUtils, ComposerExtension} from 'nylas-exports'
/**
* Inserts the set of Proposed Times into the body of the HTML email.
@ -45,7 +44,7 @@ export default class SchedulerComposerExtension extends ComposerExtension {
return contentBefore + wrapS + markup + wrapE + contentAfter
}
static _prepareEvent(inEvent, draft) {
static _prepareEvent(inEvent, draft, metadata) {
const event = inEvent
if (!event.title || event.title.length === 0) {
event.title = draft.subject;
@ -58,15 +57,37 @@ export default class SchedulerComposerExtension extends ComposerExtension {
status: "noreply",
}
})
if (metadata.proposals) {
event.end = null
event.start = null
}
return event;
}
// We must set the `preparedEvent` to be exactly what could be posted to
// the /events endpoint of the API.
static _cleanEventJSON(rawJSON) {
const json = rawJSON;
delete json.client_id;
delete json.id;
json.when = {
object: "timespan",
start: json._start,
end: json._end,
}
delete json._start
delete json._end
return json
}
static _insertProposalsIntoBody(draft, metadata) {
const nextDraft = draft;
if (metadata && metadata.proposals) {
const el = React.createElement(ProposedTimeList,
{
draft: nextDraft,
event: metadata.pendingEvent,
inEmail: true,
proposals: metadata.proposals,
});
@ -81,18 +102,11 @@ export default class SchedulerComposerExtension extends ComposerExtension {
const self = SchedulerComposerExtension
let nextDraft = draft.clone();
const metadata = draft.metadataForPluginId(PLUGIN_ID)
if (nextDraft.events.length > 0) {
if (metadata && metadata.pendingEvent) {
throw new Error(`Assertion Failure. Can't have both a pendingEvent \
and an event on a draft at the same time!`);
}
const event = self._prepareEvent(nextDraft.events[0].clone(), draft)
nextDraft.events = [event]
} else if (metadata && metadata.pendingEvent) {
nextDraft = self._insertProposalsIntoBody(nextDraft, metadata)
const event = self._prepareEvent(_.clone(metadata.pendingEvent), draft);
metadata.pendingEvent = event;
if (metadata && metadata.pendingEvent) {
nextDraft = self._insertProposalsIntoBody(nextDraft, metadata);
const nextEvent = new Event().fromJSON(metadata.pendingEvent);
const nextEventPrepared = self._prepareEvent(nextEvent, draft, metadata);
metadata.pendingEvent = self._cleanEventJSON(nextEventPrepared.toJSON());
Actions.setMetadata(nextDraft, PLUGIN_ID, metadata);
}

View file

@ -6,14 +6,10 @@ import ProposedTimeCalendarStore from './proposed-time-calendar-store'
import ProposedTimeMainWindowStore from './proposed-time-main-window-store'
import SchedulerComposerExtension from './composer/scheduler-composer-extension';
import {PLUGIN_ID, PLUGIN_URL} from './scheduler-constants'
import {
Actions,
WorkspaceStore,
ComponentRegistry,
ExtensionRegistry,
RegisterDraftForPluginTask,
} from 'nylas-exports'
export function activate() {
@ -40,22 +36,6 @@ export function activate() {
{role: 'Composer:ActionButton'});
ExtensionRegistry.Composer.register(SchedulerComposerExtension)
const errorMessage = `There was a temporary problem setting up \
these proposed times. Please manually follow up to schedule your event.`
this._usub = Actions.sendDraftSuccess.listen(({message, draftClientId}) => {
if (!NylasEnv.isMainWindow()) return;
if (message.metadataForPluginId(PLUGIN_ID)) {
const task = new RegisterDraftForPluginTask({
errorMessage,
draftClientId,
messageId: message.id,
pluginServerUrl: `${PLUGIN_URL}/plugins/register-message`,
});
Actions.queueTask(task);
}
})
}
}
@ -72,6 +52,5 @@ export function deactivate() {
ComponentRegistry.unregister(NewEventCardContainer);
ComponentRegistry.unregister(SchedulerComposerButton);
ExtensionRegistry.Composer.unregister(SchedulerComposerExtension);
this._usub()
}
}

View file

@ -1,10 +1,10 @@
import _ from 'underscore'
import NylasStore from 'nylas-store'
import moment from 'moment'
import Proposal from './proposal'
import NylasStore from 'nylas-store'
import SchedulerActions from './scheduler-actions'
import {Event, Message, Actions, DraftStore, DatabaseStore} from 'nylas-exports'
import {PLUGIN_ID, CALENDAR_ID} from './scheduler-constants'
import {Event} from 'nylas-exports'
import {CALENDAR_ID} from './scheduler-constants'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
@ -99,71 +99,6 @@ class ProposedTimeCalendarStore extends NylasStore {
})
)
}
/**
* This removes the metadata on the draft and creates an `Event` on
* `draft.events`
*/
_convertToDraftEvent(draft) {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
return DraftStore.sessionForClientId(draft.clientId).then((session) => {
if (metadata.pendingEvent) {
const event = new Event().fromJSON(metadata.pendingEvent);
session.changes.add({events: [event]});
} else {
session.changes.add({events: []})
}
delete metadata.uid
delete metadata.proposals
delete metadata.pendingEvent
Actions.setMetadata(draft, PLUGIN_ID, metadata);
return session.changes.commit()
});
}
_convertToPendingEvent(draft, proposals) {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
metadata.proposals = proposals;
// This is used to so the backend can reference which draft
// corresponds to which sent message. The backend uses the key `uid`
metadata.uid = draft.clientId;
if (draft.events.length > 0) {
return DraftStore.sessionForClientId(draft.clientId).then((session) => {
metadata.pendingEvent = draft.events[0].toJSON();
session.changes.add({events: []});
return session.changes.commit().then(() => {
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
});
}
Actions.setMetadata(draft, PLUGIN_ID, metadata);
return Promise.resolve()
}
/**
* This will bundle up and attach the choices as metadata on the draft.
*
* Once we attach metadata to the draft, we need to make sure we clear
* the start and end times of the event.
*/
_onConfirmChoices = (proposals) => {
this._pendingSave = true;
this.trigger();
const {draftClientId} = NylasEnv.getWindowProps();
DatabaseStore.find(Message, draftClientId).then((draft) => {
if (proposals.length === 0) {
return this._convertToDraftEvent(draft)
}
return this._convertToPendingEvent(draft, proposals);
})
}
}
export default new ProposedTimeCalendarStore()

View file

@ -0,0 +1,46 @@
import NylasStore from 'nylas-store'
import SchedulerActions from './scheduler-actions'
import {Message, Actions, DatabaseStore} from 'nylas-exports'
import {PLUGIN_ID} from './scheduler-constants'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
/**
* Maintains the creation of "Proposed Times" when scheduling with people.
*
* The proposed times are displayed in various calendar views.
*
*/
class ProposedTimeMainWindowStore extends NylasStore {
activate() {
this.unsubscribers = [
SchedulerActions.confirmChoices.listen(this._onConfirmChoices),
]
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub())
}
/**
* This will bundle up and attach the choices as metadata on the draft.
*
* Once we attach metadata to the draft, we need to make sure we clear
* the start and end times of the event.
*/
_onConfirmChoices = ({proposals = [], draftClientId}) => {
this._pendingSave = true;
this.trigger();
DatabaseStore.find(Message, draftClientId).then((draft) => {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
if (proposals.length === 0) {
delete metadata.proposals
}
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
}
}
export default new ProposedTimeMainWindowStore()

View file

@ -1,6 +1,6 @@
{
"name": "N1-Scheduler",
"title":"N1 Scheduler",
"name": "composer-scheduler",
"title":"Scheduler",
"description": "The easiest way to schedule events",
"main": "./lib/main",
"version": "0.1.0",

View file

@ -1,13 +1,10 @@
import {
Actions,
ComponentRegistry,
ExtensionRegistry,
RegisterDraftForPluginTask,
} from 'nylas-exports';
import LinkTrackingButton from './link-tracking-button';
import LinkTrackingComposerExtension from './link-tracking-composer-extension';
import LinkTrackingMessageExtension from './link-tracking-message-extension';
import {PLUGIN_ID, PLUGIN_URL} from './link-tracking-constants'
export function activate() {
@ -17,22 +14,6 @@ export function activate() {
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.register(LinkTrackingMessageExtension);
const errorMessage = `There was a problem saving your link tracking \
settings. This message will not have link tracking.`
this._usub = Actions.sendDraftSuccess.listen(({message, draftClientId}) => {
if (!NylasEnv.isMainWindow()) return;
if (message.metadataForPluginId(PLUGIN_ID)) {
const task = new RegisterDraftForPluginTask({
errorMessage,
draftClientId,
messageId: message.id,
pluginServerUrl: `${PLUGIN_URL}/plugins/register-message`,
});
Actions.queueTask(task);
}
})
}
export function serialize() {}
@ -41,5 +22,4 @@ export function deactivate() {
ComponentRegistry.unregister(LinkTrackingButton);
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.unregister(LinkTrackingMessageExtension);
this._usub()
}

View file

@ -1,14 +1,11 @@
import {
Actions,
ComponentRegistry,
ExtensionRegistry,
RegisterDraftForPluginTask,
} from 'nylas-exports';
import OpenTrackingButton from './open-tracking-button';
import OpenTrackingIcon from './open-tracking-icon';
import OpenTrackingMessageStatus from './open-tracking-message-status';
import OpenTrackingComposerExtension from './open-tracking-composer-extension';
import {PLUGIN_ID, PLUGIN_URL} from './open-tracking-constants'
export function activate() {
ComponentRegistry.register(OpenTrackingButton,
@ -21,22 +18,6 @@ export function activate() {
{role: 'MessageHeaderStatus'});
ExtensionRegistry.Composer.register(OpenTrackingComposerExtension);
const errorMessage = `There was a problem saving your read receipt \
settings. You will not get a read receipt for this message.`
this._usub = Actions.sendDraftSuccess.listen(({message, draftClientId}) => {
if (!NylasEnv.isMainWindow()) return;
if (message.metadataForPluginId(PLUGIN_ID)) {
const task = new RegisterDraftForPluginTask({
errorMessage,
draftClientId,
messageId: message.id,
pluginServerUrl: `${PLUGIN_URL}/plugins/register-message`,
});
Actions.queueTask(task);
}
})
}
export function serialize() {}
@ -46,5 +27,4 @@ export function deactivate() {
ComponentRegistry.unregister(OpenTrackingIcon);
ComponentRegistry.unregister(OpenTrackingMessageStatus);
ExtensionRegistry.Composer.unregister(OpenTrackingComposerExtension);
this._usub()
}

View file

@ -9,8 +9,11 @@ import {
SendDraftTask,
NylasAPI,
SoundRegistry,
SyncbackMetadataTask,
} from 'nylas-exports';
import NotifyPluginsOfSendTask from '../../src/flux/tasks/notify-plugis-of-send-task'
const DBt = DatabaseTransaction.prototype;
const withoutWhitespace = (s) => s.replace(/[\n\r\s]/g, '');
@ -169,7 +172,8 @@ describe("SendDraftTask", () => {
it("should queue tasks to sync back the metadata on the new message", () => {
spyOn(Actions, 'queueTask')
waitsForPromise(() => this.task.performRemote().then(() => {
const metadataTasks = Actions.queueTask.calls.map((call) => call.args[0]);
let metadataTasks = Actions.queueTask.calls.map((call) => call.args[0]);
metadataTasks = metadataTasks.filter((task) => task instanceof SyncbackMetadataTask)
expect(metadataTasks.length).toEqual(this.draft.pluginMetadata.length);
this.draft.pluginMetadata.forEach((pluginMetadatum, idx) => {
expect(metadataTasks[idx].clientId).toEqual(this.draft.clientId);
@ -179,6 +183,27 @@ describe("SendDraftTask", () => {
}));
});
it("should queue a task to register the messageID with the plugin server", () => {
spyOn(Actions, 'queueTask')
waitsForPromise(() => this.task.performRemote().then(() => {
let tasks = Actions.queueTask.calls.map((call) => call.args[0]);
tasks = tasks.filter((task) => task instanceof NotifyPluginsOfSendTask)
expect(tasks.length).toEqual(1);
expect(tasks[0].accountId).toEqual(this.draft.accountId);
expect(tasks[0].messageId).toEqual(this.response.id);
}));
});
it("shouldn't queue a NotifyPluginsOfSendTask if there's no metadata", () => {
spyOn(Actions, 'queueTask');
this.draft.pluginMetadata = []
waitsForPromise(() => this.task.performRemote().then(() => {
let tasks = Actions.queueTask.calls.map((call) => call.args[0]);
tasks = tasks.filter((task) => task instanceof NotifyPluginsOfSendTask)
expect(tasks.length).toEqual(0);
}));
});
it("should play a sound", () => {
spyOn(NylasEnv.config, "get").andReturn(true)
waitsForPromise(() => this.task.performRemote().then(() => {

View file

@ -78,7 +78,7 @@ class Application
if not @config.get('core.disabledPackagesInitialized')
exampleNewNames = {
'N1-Scheduler': 'N1-Scheduler',
'N1-Scheduler': 'composer-scheduler',
'N1-Composer-Templates': 'composer-templates',
'N1-Composer-Translate': 'composer-translate',
'N1-Message-View-on-Github':'message-view-on-github',

View file

@ -84,8 +84,11 @@ export default class TimePicker extends React.Component {
el.setSelectionRange(0, el.value.length)
}
_onBlur = () => {
this.setState({focused: false})
_onBlur = (event) => {
this.setState({focused: false});
if (Array.from(event.relatedTarget.classList).includes("time-options")) {
return
}
this._saveIfValid(this.state.rawText)
}
@ -192,7 +195,7 @@ export default class TimePicker extends React.Component {
})
return (
<div className={className}>{opts}</div>
<div className={className} tabIndex={-1}>{opts}</div>
)
}

View file

@ -13,7 +13,6 @@ FocusedContentStore = require './focused-content-store'
BaseDraftTask = require '../tasks/base-draft-task'
SendDraftTask = require '../tasks/send-draft-task'
SyncbackDraftFilesTask = require '../tasks/syncback-draft-files-task'
SyncbackDraftEventsTask = require '../tasks/syncback-draft-events-task'
SyncbackDraftTask = require '../tasks/syncback-draft-task'
DestroyDraftTask = require '../tasks/destroy-draft-task'
@ -342,8 +341,6 @@ class DraftStore
_queueDraftAssetTasks: (draft) =>
if draft.files.length > 0 or draft.uploads.length > 0
Actions.queueTask(new SyncbackDraftFilesTask(draft.clientId))
if draft.events.length
Actions.queueTask(new SyncbackDraftEventsTask(draft.clientId))
_isPopout: ->
NylasEnv.getWindowType() is "composer"

View file

@ -1,10 +1,8 @@
import Task from './task'
import {APIError} from '../errors'
import NylasAPI from '../nylas-api'
// We use our local `request` so we can track the outgoing calls and
// generate consistent error objects
import nylasRequest from '../../nylas-request'
import EdgehillAPI from '../edgehill-api'
import SyncbackMetadataTask from './syncback-metadata-task'
/**
* If a plugin:
@ -50,45 +48,56 @@ import nylasRequest from '../../nylas-request'
* The task will POST to your backend url the draftClientId and the
* coresponding messageId
*/
export default class RegisterDraftForPluginTask extends Task {
export default class NotifyPluginsOfSendTask extends Task {
constructor(opts = {}) {
super(opts)
this.accountId = opts.accountId
this.messageId = opts.messageId
this.errorMessage = opts.errorMessage
this.draftClientId = opts.draftClientId
this.pluginServerUrl = opts.pluginServerUrl
this.messageClientId = opts.messageClientId
this.errorMessage = `We had trouble connecting to the plugin server. \
Any plugins you used in your sent message will not be available.`
}
isDependentOnTask(other) {
return (other instanceof SyncbackMetadataTask) && (other.clientId === this.messageClientId)
}
performLocal() {
this.validateRequiredFields([
"messageId",
"draftClientId",
"pluginServerUrl",
"accountId",
"messageClientId",
]);
return Promise.resolve()
}
performRemote() {
return new Promise((resolve) => {
nylasRequest.post({url: this.pluginServerUrl, body: {
message_id: this.messageId,
uid: this.draftClientId,
}}, (err) => {
if (err instanceof APIError) {
const msg = `${this.errorMessage}\n\n${err.message}`
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
NylasEnv.showErrorDialog(msg, {showInMainWindow: true})
return resolve([Task.Status.Failed, err])
EdgehillAPI.request({
method: "POST",
path: "/plugins/send-successful",
body: {
message_id: this.messageId,
account_id: this.accountId,
},
success: () => {
return resolve(Task.Status.Success)
},
error: (err) => {
if (err instanceof APIError) {
const msg = `${this.errorMessage}\n\n${err.message}`
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
NylasEnv.showErrorDialog(msg, {showInMainWindow: true})
return resolve([Task.Status.Failed, err])
}
return resolve(Task.Status.Retry)
}
return resolve(Task.Status.Retry)
} else if (err) {
const msg = `${this.errorMessage}\n\n${err.message}`
NylasEnv.reportError(err);
NylasEnv.showErrorDialog(msg, {showInMainWindow: true})
return resolve([Task.Status.Failed, err])
}
return resolve(Task.Status.Success)
},
});
})
});
}
}

View file

@ -8,6 +8,7 @@ import DatabaseStore from '../stores/database-store';
import AccountStore from '../stores/account-store';
import BaseDraftTask from './base-draft-task';
import SyncbackMetadataTask from './syncback-metadata-task';
import NotifyPluginsOfSendTask from './notify-plugins-of-send-task';
export default class SendDraftTask extends BaseDraftTask {
@ -38,6 +39,7 @@ export default class SendDraftTask extends BaseDraftTask {
)
);
})
.then(this.updatePluginMetadata)
.then(this.onSuccess)
.catch(this.onError);
}
@ -89,13 +91,26 @@ export default class SendDraftTask extends BaseDraftTask {
});
}
onSuccess = () => {
// Queue a task to save metadata on the message
updatePluginMetadata = () => {
this.message.pluginMetadata.forEach((m) => {
const task = new SyncbackMetadataTask(this.message.clientId, this.message.constructor.name, m.pluginId);
Actions.queueTask(task);
const t1 = new SyncbackMetadataTask(this.message.clientId,
this.message.constructor.name, m.pluginId);
Actions.queueTask(t1);
});
if (this.message.pluginMetadata.length > 0) {
const t2 = new NotifyPluginsOfSendTask({
accountId: this.message.accountId,
messageId: this.message.id,
messageClientId: this.message.clientId,
});
Actions.queueTask(t2);
}
return Promise.resolve()
}
onSuccess = () => {
Actions.sendDraftSuccess({message: this.message, messageClientId: this.message.clientId, draftClientId: this.draftClientId});
NylasAPI.makeDraftDeletionRequest(this.draft);

View file

@ -1,81 +0,0 @@
import Task from './task';
import {APIError} from '../errors';
import NylasAPI from '../nylas-api';
import BaseDraftTask from './base-draft-task';
import DatabaseStore from '../stores/database-store';
import Event from '../models/event';
export default class SyncbackDraftEventsTask extends BaseDraftTask {
constructor(draftClientId) {
super(draftClientId);
this._appliedEvents = null;
}
label() {
return "Creating meeting request...";
}
performRemote() {
return this.refreshDraftReference()
.then(this.uploadEvents)
.then(this.applyChangesToDraft)
.thenReturn(Task.Status.Success)
.catch((err) => {
if (err instanceof BaseDraftTask.DraftNotFoundError) {
return Promise.resolve(Task.Status.Continue);
}
if (err instanceof APIError && !NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
return Promise.resolve(Task.Status.Retry);
}
return Promise.resolve([Task.Status.Failed, err]);
});
}
uploadEvents = () => {
const events = this.draft.events;
if (events && events.length) {
const event = events[0]; // only upload one
return this.uploadEvent(event).then((savedEvent) => {
if (savedEvent) {
this._appliedEvents = [savedEvent];
}
Promise.resolve();
});
}
return Promise.resolve()
};
uploadEvent = (event) => {
return NylasAPI.makeRequest({
path: "/events?notify_participants=true",
accountId: this.draft.accountId,
method: "POST",
body: this._prepareEventJson(event),
returnsModel: true,
}).then(json =>{
return (new Event()).fromJSON(json);
});
};
_prepareEventJson(inEvent) {
const event = inEvent.fromDraft(this.draft)
const json = event.toJSON();
delete json.id;
json.when = {
start_time: json._start,
end_time: json._end,
};
return json;
}
applyChangesToDraft = () => {
return DatabaseStore.inTransaction((t) => {
return this.refreshDraftReference().then(() => {
this.draft.events = this._appliedEvents;
return t.persistModel(this.draft);
});
});
}
}

View file

@ -50,7 +50,6 @@ class NylasExports
@load "Actions", 'flux/actions'
# API Endpoints
@load "nylasRequest", 'nylas-request' # An extend `request` module
@load "NylasAPI", 'flux/nylas-api'
@load "NylasSyncStatusStore", 'flux/stores/nylas-sync-status-store'
@load "EdgehillAPI", 'flux/edgehill-api'
@ -109,14 +108,12 @@ class NylasExports
@require "DestroyCategoryTask", 'flux/tasks/destroy-category-task'
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftFilesTask", 'flux/tasks/syncback-draft-files-task'
@require "SyncbackDraftEventsTask", 'flux/tasks/syncback-draft-events-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft-task'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
@require "DestroyModelTask", 'flux/tasks/destroy-model-task'
@require "SyncbackModelTask", 'flux/tasks/syncback-model-task'
@require "SyncbackMetadataTask", 'flux/tasks/syncback-metadata-task'
@require "ReprocessMailRulesTask", 'flux/tasks/reprocess-mail-rules-task'
@require "RegisterDraftForPluginTask", 'flux/tasks/register-draft-for-plugin-task'
# Stores
# These need to be required immediately since some Stores are

View file

@ -1,75 +0,0 @@
import _ from 'underscore'
import request from 'request'
import Utils from './flux/models/utils'
import Actions from './flux/actions'
import {APIError} from './flux/errors'
/**
* A light wrapper around the `request` library to make sure we're logging
* requests and retruning standard error types.
*/
const origInit = request.Request.prototype.init
request.Request.prototype.init = function nylasRequestInit(options) {
const opts = options;
const requestId = Utils.generateTempId()
opts.startTime = Date.now();
Actions.willMakeAPIRequest({
request: opts,
requestId,
});
const origCallback = opts.callback || function noop() {}
// It's a super common error to pass an object `body`, but forget to
// pass `json:true`. If you don't pass `json:true` the body won't be
// automatically stringified. We'll take care of doing that for you.
if (!_.isString(opts.body) && !_.isArray(opts.body)) {
if (opts.json === null || opts.json === undefined) {
opts.json = true;
}
}
opts.callback = (error, response, body) => {
let statusCode;
let apiError;
if (error) {
if (error.code) {
statusCode = error.code;
apiError = new APIError({
error, response, body, statusCode,
requestOptions: opts,
});
} else {
throw error
}
} else if (response) {
statusCode = response.statusCode;
if (statusCode > 299) {
apiError = new APIError({
error, response, body, statusCode,
requestOptions: opts,
});
}
} else {
throw new Error("Got a request with no error and no response!")
}
Actions.didMakeAPIRequest({
request: opts,
statusCode,
error,
requestId,
})
if (apiError) {
NylasEnv.errorLogger.apiDebug(apiError)
}
return origCallback(apiError, response, body)
}
this.callback = opts.callback
return origInit.call(this, opts)
}
export default request