diff --git a/app/internal_packages/activity-list/assets/icon.png b/app/internal_packages/activity-list/assets/icon.png
new file mode 100644
index 000000000..a56f492aa
Binary files /dev/null and b/app/internal_packages/activity-list/assets/icon.png differ
diff --git a/app/internal_packages/activity-list/lib/activity-data-source.es6 b/app/internal_packages/activity-list/lib/activity-data-source.es6
new file mode 100644
index 000000000..0f05b5766
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-data-source.es6
@@ -0,0 +1,17 @@
+import {Rx, Message, DatabaseStore} from 'nylas-exports';
+
+export default class ActivityDataSource {
+ buildObservable({openTrackingId, linkTrackingId, messageLimit}) {
+ const query = DatabaseStore
+ .findAll(Message)
+ .order(Message.attributes.date.descending())
+ .where(Message.attributes.pluginMetadata.contains(openTrackingId, linkTrackingId))
+ .limit(messageLimit);
+ this.observable = Rx.Observable.fromQuery(query);
+ return this.observable;
+ }
+
+ subscribe(callback) {
+ return this.observable.subscribe(callback);
+ }
+}
diff --git a/app/internal_packages/activity-list/lib/activity-list-actions.es6 b/app/internal_packages/activity-list/lib/activity-list-actions.es6
new file mode 100644
index 000000000..04821ea83
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list-actions.es6
@@ -0,0 +1,11 @@
+import Reflux from 'reflux';
+
+const ActivityListActions = Reflux.createActions([
+ "resetSeen",
+]);
+
+for (const key of Object.keys(ActivityListActions)) {
+ ActivityListActions[key].sync = true;
+}
+
+export default ActivityListActions;
diff --git a/app/internal_packages/activity-list/lib/activity-list-button.jsx b/app/internal_packages/activity-list/lib/activity-list-button.jsx
new file mode 100644
index 000000000..f0e16e337
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list-button.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import {Actions, ReactDOM} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+
+import ActivityList from './activity-list';
+import ActivityListStore from './activity-list-store';
+
+
+class ActivityListButton extends React.Component {
+ static displayName = 'ActivityListButton';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsub = ActivityListStore.listen(this._onDataChanged);
+ }
+
+ componentWillUnmount() {
+ this._unsub();
+ }
+
+ onClick = () => {
+ const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
+ Actions.openPopover(
+ ,
+ {originRect: buttonRect, direction: 'down'}
+ );
+ }
+
+ _onDataChanged = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ _getStateFromStores() {
+ return {
+ unreadCount: ActivityListStore.unreadCount(),
+ }
+ }
+
+ render() {
+ let unreadCountClass = "unread-count";
+ let iconClass = "activity-toolbar-icon";
+ if (this.state.unreadCount) {
+ unreadCountClass += " active";
+ iconClass += " unread";
+ }
+ return (
+
+
+ {this.state.unreadCount}
+
+
+
+ );
+ }
+}
+
+export default ActivityListButton;
diff --git a/app/internal_packages/activity-list/lib/activity-list-empty-state.jsx b/app/internal_packages/activity-list/lib/activity-list-empty-state.jsx
new file mode 100644
index 000000000..55bf4afee
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list-empty-state.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import {RetinaImg} from 'nylas-component-kit';
+
+const ActivityListEmptyState = function ActivityListEmptyState() {
+ return (
+
+
+
+ Enable read receipts or
+ link tracking to
+ see notifications here.
+
+
+ );
+}
+
+export default ActivityListEmptyState;
diff --git a/app/internal_packages/activity-list/lib/activity-list-item-container.jsx b/app/internal_packages/activity-list/lib/activity-list-item-container.jsx
new file mode 100644
index 000000000..ceb0131ed
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list-item-container.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+
+import {DisclosureTriangle,
+ Flexbox,
+ RetinaImg} from 'nylas-component-kit';
+import {DateUtils} from 'nylas-exports';
+import ActivityListStore from './activity-list-store';
+import {pluginFor} from './plugin-helpers';
+
+
+class ActivityListItemContainer extends React.Component {
+
+ static displayName = 'ActivityListItemContainer';
+
+ static propTypes = {
+ group: React.PropTypes.array,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ collapsed: true,
+ };
+ }
+
+ _onClick(threadId) {
+ ActivityListStore.focusThread(threadId);
+ }
+
+ _onCollapseToggled = (event) => {
+ event.stopPropagation();
+ this.setState({collapsed: !this.state.collapsed});
+ }
+
+ _getText() {
+ const text = {
+ recipient: "Someone",
+ title: "(No Subject)",
+ date: new Date(0),
+ };
+ const lastAction = this.props.group[0];
+ if (this.props.group.length === 1 && lastAction.recipient) {
+ text.recipient = lastAction.recipient.displayName();
+ } else if (this.props.group.length > 1 && lastAction.recipient) {
+ const people = [];
+ for (const action of this.props.group) {
+ if (!people.includes(action.recipient)) {
+ people.push(action.recipient);
+ }
+ }
+ if (people.length === 1) text.recipient = people[0].displayName();
+ else if (people.length === 2) text.recipient = `${people[0].displayName()} and 1 other`;
+ else text.recipient = `${people[0].displayName()} and ${people.length - 1} others`;
+ }
+ if (lastAction.title) text.title = lastAction.title;
+ text.date.setUTCSeconds(lastAction.timestamp);
+ return text;
+ }
+
+ renderActivityContainer() {
+ if (this.props.group.length === 1) return null;
+ const actions = [];
+ for (const action of this.props.group) {
+ const date = new Date(0);
+ date.setUTCSeconds(action.timestamp);
+ actions.push(
+
+
+
+ {action.recipient ? action.recipient.displayName() : "Someone"}
+
+
+
+ {DateUtils.shortTimeString(date)}
+
+
+
+ );
+ }
+ return (
+
+ {actions}
+
+ );
+ }
+
+ render() {
+ const lastAction = this.props.group[0];
+ let className = "activity-list-item";
+ if (!ActivityListStore.hasBeenViewed(lastAction)) className += " unread";
+ const text = this._getText();
+ let disclosureTriangle = ();
+ if (this.props.group.length > 1) {
+ disclosureTriangle = (
+
+ );
+ }
+ return (
+ { this._onClick(lastAction.threadId) }}>
+
+
+
+
+
+ {disclosureTriangle}
+
+ {text.recipient} {pluginFor(lastAction.pluginId).predicate}:
+
+
+
+ {DateUtils.shortTimeString(text.date)}
+
+
+
+ {text.title}
+
+
+ {this.renderActivityContainer()}
+
+ );
+ }
+
+}
+
+export default ActivityListItemContainer;
diff --git a/app/internal_packages/activity-list/lib/activity-list-store.jsx b/app/internal_packages/activity-list/lib/activity-list-store.jsx
new file mode 100644
index 000000000..c7fa3339a
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list-store.jsx
@@ -0,0 +1,218 @@
+import NylasStore from 'nylas-store';
+import {
+ Actions,
+ Thread,
+ DatabaseStore,
+ NativeNotifications,
+ FocusedPerspectiveStore,
+} from 'nylas-exports';
+import ActivityListActions from './activity-list-actions';
+import ActivityDataSource from './activity-data-source';
+import {pluginFor} from './plugin-helpers';
+
+
+class ActivityListStore extends NylasStore {
+ activate() {
+ this.listenTo(ActivityListActions.resetSeen, this._onResetSeen);
+ this.listenTo(FocusedPerspectiveStore, this._updateActivity);
+
+ const start = () => this._getActivity();
+ if (NylasEnv.inSpecMode()) {
+ start();
+ } else {
+ setTimeout(start, 2000);
+ }
+ }
+
+ deactivate() {
+ // todo
+ }
+
+ actions() {
+ return this._actions;
+ }
+
+ unreadCount() {
+ if (this._unreadCount < 1000) {
+ return this._unreadCount;
+ } else if (!this._unreadCount) {
+ return null;
+ }
+ return "999+";
+ }
+
+ hasBeenViewed(action) {
+ if (!NylasEnv.savedState.activityListViewed) return false;
+ return action.timestamp < NylasEnv.savedState.activityListViewed;
+ }
+
+ focusThread(threadId) {
+ NylasEnv.displayWindow()
+ Actions.closePopover()
+ DatabaseStore.find(Thread, threadId).then((thread) => {
+ if (!thread) {
+ NylasEnv.reportError(new Error(`ActivityListStore::focusThread: Can't find thread`, {threadId}))
+ NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`)
+ return;
+ }
+ Actions.ensureCategoryIsFocused('sent', thread.accountId);
+ Actions.setFocus({collection: 'thread', item: thread});
+ });
+ }
+
+ getRecipient(recipientEmail, recipients) {
+ if (recipientEmail) {
+ for (const recipient of recipients) {
+ if (recipientEmail === recipient.email) {
+ return recipient;
+ }
+ }
+ } else if (recipients.length === 1) {
+ return recipients[0];
+ }
+ return null;
+ }
+
+ _dataSource() {
+ return new ActivityDataSource();
+ }
+
+ _onResetSeen() {
+ NylasEnv.savedState.activityListViewed = Date.now() / 1000;
+ this._unreadCount = 0;
+ this.trigger();
+ }
+
+ _getActivity() {
+ const dataSource = this._dataSource();
+ this._subscription = dataSource.buildObservable({
+ openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'),
+ linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'),
+ messageLimit: 500,
+ }).subscribe((messages) => {
+ this._messages = messages;
+ this._updateActivity();
+ });
+ }
+
+ _updateActivity() {
+ this._actions = this._messages ? this._getActions(this._messages) : [];
+ this.trigger();
+ }
+
+ _getActions(messages) {
+ let actions = [];
+ this._notifications = [];
+ this._unreadCount = 0;
+ const sidebarAccountIds = FocusedPerspectiveStore.sidebarAccountIds();
+ for (const message of messages) {
+ if (sidebarAccountIds.length > 1 || message.accountId === sidebarAccountIds[0]) {
+ const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
+ const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
+ if (message.metadataForPluginId(openTrackingId) ||
+ message.metadataForPluginId(linkTrackingId)) {
+ actions = actions.concat(this._openActionsForMessage(message));
+ actions = actions.concat(this._linkActionsForMessage(message));
+ }
+ }
+ }
+ if (!this._lastNotified) this._lastNotified = {};
+ for (const notification of this._notifications) {
+ const lastNotified = this._lastNotified[notification.threadId];
+ const {notificationInterval} = pluginFor(notification.pluginId);
+ if (!lastNotified || lastNotified < Date.now() - notificationInterval) {
+ NativeNotifications.displayNotification(notification.data);
+ this._lastNotified[notification.threadId] = Date.now();
+ }
+ }
+ const d = new Date();
+ this._lastChecked = d.getTime() / 1000;
+
+ actions = actions.sort((a, b) => b.timestamp - a.timestamp);
+ // For performance reasons, only display the last 100 actions
+ if (actions.length > 100) {
+ actions.length = 100;
+ }
+ return actions;
+ }
+
+ _openActionsForMessage(message) {
+ const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
+ const openMetadata = message.metadataForPluginId(openTrackingId);
+ const recipients = message.to.concat(message.cc, message.bcc);
+ const actions = [];
+ if (openMetadata) {
+ if (openMetadata.open_count > 0) {
+ for (const open of openMetadata.open_data) {
+ const recipient = this.getRecipient(open.recipient, recipients);
+ if (open.timestamp > this._lastChecked) {
+ this._notifications.push({
+ pluginId: openTrackingId,
+ threadId: message.threadId,
+ data: {
+ title: "New open",
+ subtitle: `${recipient ? recipient.displayName() : "Someone"} just opened ${message.subject}`,
+ canReply: false,
+ tag: "message-open",
+ onActivate: () => {
+ this.focusThread(message.threadId);
+ },
+ },
+ });
+ }
+ if (!this.hasBeenViewed(open)) this._unreadCount += 1;
+ actions.push({
+ messageId: message.id,
+ threadId: message.threadId,
+ title: message.subject,
+ recipient: recipient,
+ pluginId: openTrackingId,
+ timestamp: open.timestamp,
+ });
+ }
+ }
+ }
+ return actions;
+ }
+
+ _linkActionsForMessage(message) {
+ const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
+ const linkMetadata = message.metadataForPluginId(linkTrackingId)
+ const recipients = message.to.concat(message.cc, message.bcc);
+ const actions = [];
+ if (linkMetadata && linkMetadata.links) {
+ for (const link of linkMetadata.links) {
+ for (const click of link.click_data) {
+ const recipient = this.getRecipient(click.recipient, recipients);
+ if (click.timestamp > this._lastChecked) {
+ this._notifications.push({
+ pluginId: linkTrackingId,
+ threadId: message.threadId,
+ data: {
+ title: "New click",
+ subtitle: `${recipient ? recipient.displayName() : "Someone"} just clicked ${link.url}.`,
+ canReply: false,
+ tag: "link-open",
+ onActivate: () => {
+ this.focusThread(message.threadId);
+ },
+ },
+ });
+ }
+ if (!this.hasBeenViewed(click)) this._unreadCount += 1;
+ actions.push({
+ messageId: message.id,
+ threadId: message.threadId,
+ title: link.url,
+ recipient: recipient,
+ pluginId: linkTrackingId,
+ timestamp: click.timestamp,
+ });
+ }
+ }
+ }
+ return actions;
+ }
+}
+
+export default new ActivityListStore();
diff --git a/app/internal_packages/activity-list/lib/activity-list.jsx b/app/internal_packages/activity-list/lib/activity-list.jsx
new file mode 100644
index 000000000..e60473e3c
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/activity-list.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import classnames from 'classnames';
+
+import {Flexbox,
+ ScrollRegion} from 'nylas-component-kit';
+import ActivityListStore from './activity-list-store';
+import ActivityListActions from './activity-list-actions';
+import ActivityListItemContainer from './activity-list-item-container';
+import ActivityListEmptyState from './activity-list-empty-state';
+
+class ActivityList extends React.Component {
+
+ static displayName = 'ActivityList';
+
+ constructor() {
+ super();
+ this.state = this._getStateFromStores();
+ }
+
+ componentDidMount() {
+ this._unsub = ActivityListStore.listen(this._onDataChanged);
+ }
+
+ componentWillUnmount() {
+ ActivityListActions.resetSeen();
+ this._unsub();
+ }
+
+ _onDataChanged = () => {
+ this.setState(this._getStateFromStores());
+ }
+
+ _getStateFromStores() {
+ const actions = ActivityListStore.actions();
+ return {
+ actions: actions,
+ empty: actions instanceof Array && actions.length === 0,
+ collapsedToggles: this.state ? this.state.collapsedToggles : {},
+ }
+ }
+
+ _groupActions(actions) {
+ const groupedActions = [];
+ for (const action of actions) {
+ if (groupedActions.length > 0) {
+ const currentGroup = groupedActions[groupedActions.length - 1];
+ if (action.messageId === currentGroup[0].messageId &&
+ action.pluginId === currentGroup[0].pluginId) {
+ groupedActions[groupedActions.length - 1].push(action);
+ } else {
+ groupedActions.push([action]);
+ }
+ } else {
+ groupedActions.push([action]);
+ }
+ }
+ return groupedActions;
+ }
+
+ renderActions() {
+ if (this.state.empty) {
+ return (
+
+ )
+ }
+
+ const groupedActions = this._groupActions(this.state.actions);
+ return groupedActions.map((group) => {
+ return (
+
+ );
+ });
+ }
+
+ render() {
+ if (!this.state.actions) return null;
+
+ const classes = classnames({
+ "activity-list-container": true,
+ "empty": this.state.empty,
+ })
+ return (
+
+
+ {this.renderActions()}
+
+
+ );
+ }
+}
+
+export default ActivityList;
diff --git a/app/internal_packages/activity-list/lib/main.es6 b/app/internal_packages/activity-list/lib/main.es6
new file mode 100644
index 000000000..6ac9bd2b6
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/main.es6
@@ -0,0 +1,21 @@
+import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+import ActivityListButton from './activity-list-button';
+import ActivityListStore from './activity-list-store';
+
+const ActivityListButtonWithTutorialTip = HasTutorialTip(ActivityListButton, {
+ title: "Open and link tracking",
+ instructions: "If you've enabled link tracking or read receipts, those events will appear here!",
+});
+
+export function activate() {
+ ComponentRegistry.register(ActivityListButtonWithTutorialTip, {
+ location: WorkspaceStore.Location.RootSidebar.Toolbar,
+ });
+ ActivityListStore.activate();
+}
+
+
+export function deactivate() {
+ ComponentRegistry.unregister(ActivityListButtonWithTutorialTip);
+}
diff --git a/app/internal_packages/activity-list/lib/plugin-helpers.es6 b/app/internal_packages/activity-list/lib/plugin-helpers.es6
new file mode 100644
index 000000000..8e9d5a6a4
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/plugin-helpers.es6
@@ -0,0 +1,22 @@
+
+export function pluginFor(id) {
+ const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
+ const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
+ if (id === openTrackingId) {
+ return {
+ name: "open",
+ predicate: "opened",
+ iconName: "icon-activity-mailopen.png",
+ notificationInterval: 600000, // 10 minutes in ms
+ }
+ }
+ if (id === linkTrackingId) {
+ return {
+ name: "link",
+ predicate: "clicked",
+ iconName: "icon-activity-linkopen.png",
+ notificationInterval: 10000, // 10 seconds in ms
+ }
+ }
+ return undefined
+}
diff --git a/app/internal_packages/activity-list/lib/test-data-source.es6 b/app/internal_packages/activity-list/lib/test-data-source.es6
new file mode 100644
index 000000000..40022ee02
--- /dev/null
+++ b/app/internal_packages/activity-list/lib/test-data-source.es6
@@ -0,0 +1,18 @@
+export default class TestDataSource {
+ buildObservable() {
+ return this;
+ }
+
+ manuallyTrigger = (messages = []) => {
+ this.onNext(messages);
+ }
+
+ subscribe(onNext) {
+ this.onNext = onNext;
+ this.manuallyTrigger();
+ const dispose = () => {
+ this._unsub();
+ }
+ return {dispose};
+ }
+}
diff --git a/app/internal_packages/activity-list/package.json b/app/internal_packages/activity-list/package.json
new file mode 100644
index 000000000..37be921b2
--- /dev/null
+++ b/app/internal_packages/activity-list/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "activity-list",
+ "main": "./lib/main",
+ "version": "0.1.0",
+ "repository": {
+ "type": "git",
+ "url": ""
+ },
+ "engines": {
+ "mailspring": "*"
+ },
+
+ "isOptional": true,
+
+ "title":"Activity List",
+ "icon":"./assets/icon.png",
+ "description": "Get notifications for open and link tracking activity.",
+ "supportedEnvs": ["development", "staging", "production"],
+
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages/activity-list/specs/activity-list-spec.jsx b/app/internal_packages/activity-list/specs/activity-list-spec.jsx
new file mode 100644
index 000000000..4790eed20
--- /dev/null
+++ b/app/internal_packages/activity-list/specs/activity-list-spec.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import ReactTestUtils from 'react-addons-test-utils';
+import {
+ Thread,
+ Actions,
+ Contact,
+ Message,
+ DatabaseStore,
+ FocusedPerspectiveStore,
+} from 'nylas-exports';
+import ActivityList from '../lib/activity-list';
+import ActivityListStore from '../lib/activity-list-store';
+import TestDataSource from '../lib/test-data-source';
+
+const OPEN_TRACKING_ID = 'open-tracking-id'
+const LINK_TRACKING_ID = 'link-tracking-id'
+
+const messages = [
+ new Message({
+ id: 'a',
+ accountId: "0000000000000000000000000",
+ bcc: [],
+ cc: [],
+ snippet: "Testing.",
+ subject: "Open me!",
+ threadId: "0000000000000000000000000",
+ to: [new Contact({
+ name: "Jackie Luo",
+ email: "jackie@nylas.com",
+ })],
+ }),
+ new Message({
+ id: 'b',
+ accountId: "0000000000000000000000000",
+ bcc: [new Contact({
+ name: "Ben Gotow",
+ email: "ben@nylas.com",
+ })],
+ cc: [],
+ snippet: "Hey! I am in town for the week...",
+ subject: "Coffee?",
+ threadId: "0000000000000000000000000",
+ to: [new Contact({
+ name: "Jackie Luo",
+ email: "jackie@nylas.com",
+ })],
+ }),
+ new Message({
+ id: 'c',
+ accountId: "0000000000000000000000000",
+ bcc: [],
+ cc: [new Contact({
+ name: "Evan Morikawa",
+ email: "evan@nylas.com",
+ })],
+ snippet: "Here's the latest deals!",
+ subject: "Newsletter",
+ threadId: "0000000000000000000000000",
+ to: [new Contact({
+ name: "Juan Tejada",
+ email: "juan@nylas.com",
+ })],
+ }),
+];
+
+let pluginValue = {
+ open_count: 1,
+ open_data: [{
+ timestamp: 1461361759.351055,
+ }],
+};
+messages[0].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
+pluginValue = {
+ links: [{
+ click_count: 1,
+ click_data: [{
+ timestamp: 1461349232.495837,
+ }],
+ }],
+ tracked: true,
+};
+messages[0].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
+pluginValue = {
+ open_count: 1,
+ open_data: [{
+ timestamp: 1461361763.283720,
+ }],
+};
+messages[1].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
+pluginValue = {
+ links: [],
+ tracked: false,
+};
+messages[1].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
+pluginValue = {
+ open_count: 0,
+ open_data: [],
+};
+messages[2].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
+pluginValue = {
+ links: [{
+ click_count: 0,
+ click_data: [],
+ }],
+ tracked: true,
+};
+messages[2].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
+
+
+describe('ActivityList', function activityList() {
+ beforeEach(() => {
+ this.testSource = new TestDataSource();
+ spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake((pluginName) => {
+ if (pluginName === 'open-tracking') {
+ return OPEN_TRACKING_ID
+ }
+ if (pluginName === 'link-tracking') {
+ return LINK_TRACKING_ID
+ }
+ return null
+ })
+ spyOn(ActivityListStore, "_dataSource").andReturn(this.testSource);
+ spyOn(FocusedPerspectiveStore, "sidebarAccountIds").andReturn(["0000000000000000000000000"]);
+ spyOn(DatabaseStore, "run").andCallFake((query) => {
+ if (query._klass === Thread) {
+ const thread = new Thread({
+ id: "0000000000000000000000000",
+ accountId: TEST_ACCOUNT_ID,
+ });
+ return Promise.resolve(thread);
+ }
+ return null;
+ });
+ spyOn(ActivityListStore, "focusThread").andCallThrough();
+ spyOn(NylasEnv, "displayWindow");
+ spyOn(Actions, "closePopover");
+ spyOn(Actions, "setFocus");
+ spyOn(Actions, "ensureCategoryIsFocused");
+ ActivityListStore.activate();
+ this.component = ReactTestUtils.renderIntoDocument();
+ });
+
+ describe('when no actions are found', () => {
+ it('should show empty state', () => {
+ const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
+ expect(items.length).toBe(0);
+ });
+ });
+
+ describe('when actions are found', () => {
+ it('should show activity list items', () => {
+ this.testSource.manuallyTrigger(messages);
+ waitsFor(() => {
+ const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
+ return items.length > 0;
+ });
+ runs(() => {
+ expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item").length).toBe(3);
+ });
+ });
+
+ it('should show the correct items', () => {
+ this.testSource.manuallyTrigger(messages);
+ waitsFor(() => {
+ const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
+ return items.length > 0;
+ });
+ runs(() => {
+ expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0].textContent).toBe("Someone opened:Apr 22 2016Coffee?");
+ expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[1].textContent).toBe("Jackie Luo opened:Apr 22 2016Open me!");
+ expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[2].textContent).toBe("Jackie Luo clicked:Apr 22 2016(No Subject)");
+ });
+ });
+
+ xit('should focus the thread', () => {
+ runs(() => {
+ return this.testSource.manuallyTrigger(messages);
+ })
+ waitsFor(() => {
+ const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
+ return items.length > 0;
+ });
+ runs(() => {
+ const item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0];
+ ReactTestUtils.Simulate.click(item);
+ });
+ waitsFor(() => {
+ return ActivityListStore.focusThread.calls.length > 0;
+ });
+ runs(() => {
+ expect(NylasEnv.displayWindow.calls.length).toBe(1);
+ expect(Actions.closePopover.calls.length).toBe(1);
+ expect(Actions.setFocus.calls.length).toBe(1);
+ expect(Actions.ensureCategoryIsFocused.calls.length).toBe(1);
+ });
+ });
+ });
+});
diff --git a/app/internal_packages/activity-list/styles/index.less b/app/internal_packages/activity-list/styles/index.less
new file mode 100644
index 000000000..13a0d0754
--- /dev/null
+++ b/app/internal_packages/activity-list/styles/index.less
@@ -0,0 +1,142 @@
+@import "ui-variables";
+
+.toolbar-activity {
+ order: 100;
+ position: relative;
+
+ .unread-count {
+ display: none;
+ &.active {
+ display: inline-block;
+ background: @component-active-color;
+ text-align: center;
+ color: @white;
+ border-radius: @border-radius-base;
+ font-size: 8px;
+ padding: 0 4px;
+ position: absolute;
+ right: -7px;
+ top: 5px;
+ line-height: 11px;
+ }
+ }
+ .activity-toolbar-icon {
+ margin-top: 20px;
+ background: @gray;
+ &.unread {
+ background: @component-active-color;
+ }
+ }
+}
+
+.activity-list-container {
+ width: 260px;
+ overflow: hidden;
+ font-size: @font-size-small;
+ color: @text-color-subtle;
+ .spacer {
+ flex: 1 1 0;
+ }
+
+ height: 282px;
+ &.empty {
+ height: 182px;
+ }
+
+ .empty {
+ text-align: center;
+ padding: @padding-base-horizontal * 2;
+ padding-top: @padding-base-vertical * 8;
+ img.logo {
+ background-color: @text-color-very-subtle;
+ }
+ .text {
+ margin-top: @padding-base-vertical * 6;
+ color: @text-color-very-subtle;
+ }
+ }
+
+ .activity-list-item {
+ padding: @padding-small-vertical @padding-small-horizontal;
+ white-space: nowrap;
+ border-bottom: 1px solid @border-color-primary;
+ cursor: default;
+ &.unread {
+ color: @text-color;
+ background: @background-primary;
+ &:hover {
+ background: darken(@background-primary, 2%);
+ }
+ .action-message {
+ font-weight: 600;
+ }
+ }
+
+ &:hover {
+ background: darken(@background-secondary, 2%);
+ }
+
+ .disclosure-triangle {
+ padding-top: 5px;
+ padding-bottom: 0;
+ }
+ .activity-icon-container {
+ flex-shrink: 0;
+ }
+ .activity-icon {
+ vertical-align: text-bottom;
+ }
+ .action-message, .title {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ .timestamp {
+ color: @text-color-very-subtle;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex-shrink: 0;
+ padding-left: 5px;
+ }
+ }
+ .activity-list-toggle-item {
+ height: 30px;
+ white-space: nowrap;
+ background: @background-secondary;
+ cursor: default;
+ overflow-y: hidden;
+ transition-property: all;
+ transition-duration: .5s;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ &:last-child {
+ border-bottom: 1px solid @border-color-primary;
+ }
+ .action-message {
+ padding: @padding-small-vertical @padding-small-horizontal;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ .timestamp {
+ padding: @padding-small-vertical @padding-small-horizontal;
+ color: @text-color-very-subtle;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ .activity-toggle-container {
+ &.hidden {
+ .activity-list-toggle-item {
+ height: 0;
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+ }
+ }
+}
+
+body.platform-win32,
+body.platform-linux {
+ .toolbar-activity {
+ margin-right: @padding-base-horizontal;
+ }
+}
\ No newline at end of file
diff --git a/app/internal_packages/link-tracking/README.md b/app/internal_packages/link-tracking/README.md
new file mode 100644
index 000000000..97f2d061f
--- /dev/null
+++ b/app/internal_packages/link-tracking/README.md
@@ -0,0 +1,4 @@
+
+## Open Tracking
+
+Adds tracking pixels to messages and tracks whether they have been opened.
diff --git a/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@1x.png b/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@1x.png
new file mode 100644
index 000000000..99e32111d
Binary files /dev/null and b/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@1x.png differ
diff --git a/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@2x.png b/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@2x.png
new file mode 100644
index 000000000..1e697c7d4
Binary files /dev/null and b/app/internal_packages/link-tracking/assets/ic-tracking-unvisited@2x.png differ
diff --git a/app/internal_packages/link-tracking/assets/ic-tracking-visited@1x.png b/app/internal_packages/link-tracking/assets/ic-tracking-visited@1x.png
new file mode 100644
index 000000000..db8c7b309
Binary files /dev/null and b/app/internal_packages/link-tracking/assets/ic-tracking-visited@1x.png differ
diff --git a/app/internal_packages/link-tracking/assets/ic-tracking-visited@2x.png b/app/internal_packages/link-tracking/assets/ic-tracking-visited@2x.png
new file mode 100644
index 000000000..c6022439a
Binary files /dev/null and b/app/internal_packages/link-tracking/assets/ic-tracking-visited@2x.png differ
diff --git a/app/internal_packages/link-tracking/assets/linktracking-icon@2x.png b/app/internal_packages/link-tracking/assets/linktracking-icon@2x.png
new file mode 100644
index 000000000..f03e0172e
Binary files /dev/null and b/app/internal_packages/link-tracking/assets/linktracking-icon@2x.png differ
diff --git a/app/internal_packages/link-tracking/icon.png b/app/internal_packages/link-tracking/icon.png
new file mode 100644
index 000000000..67cbc47ce
Binary files /dev/null and b/app/internal_packages/link-tracking/icon.png differ
diff --git a/app/internal_packages/link-tracking/lib/link-tracking-button.jsx b/app/internal_packages/link-tracking/lib/link-tracking-button.jsx
new file mode 100644
index 000000000..7fa429c93
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/link-tracking-button.jsx
@@ -0,0 +1,46 @@
+import {React, APIError, NylasAPIRequest} from 'nylas-exports'
+import {MetadataComposerToggleButton} from 'nylas-component-kit'
+import {PLUGIN_ID, PLUGIN_NAME} from './link-tracking-constants'
+
+export default class LinkTrackingButton extends React.Component {
+ static displayName = 'LinkTrackingButton';
+
+ static propTypes = {
+ draft: React.PropTypes.object.isRequired,
+ session: React.PropTypes.object.isRequired,
+ };
+
+ shouldComponentUpdate(nextProps) {
+ return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
+ }
+
+ _title(enabled) {
+ const dir = enabled ? "Disable" : "Enable";
+ return `${dir} link tracking`
+ }
+
+ _errorMessage(error) {
+ if (error instanceof APIError && NylasAPIRequest.TimeoutErrorCodes.includes(error.statusCode)) {
+ return `Link tracking does not work offline. Please re-enable when you come back online.`
+ }
+ return `Unfortunately, link tracking servers are currently not available. Please try again later. Error: ${error.message}`
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+LinkTrackingButton.containerRequired = false;
diff --git a/app/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6 b/app/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6
new file mode 100644
index 000000000..3e6213235
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6
@@ -0,0 +1,77 @@
+import {ComposerExtension, RegExpUtils} from 'nylas-exports';
+import {PLUGIN_ID, PLUGIN_URL} from './link-tracking-constants'
+
+function forEachATagInBody(draftBodyRootNode, callback) {
+ const treeWalker = document.createTreeWalker(draftBodyRootNode, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) => {
+ if (node.classList.contains('gmail_quote')) {
+ return NodeFilter.FILTER_REJECT; // skips the entire subtree
+ }
+ return (node.hasAttribute('href')) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+ },
+ })
+
+ while (treeWalker.nextNode()) {
+ callback(treeWalker.currentNode);
+ }
+}
+
+/**
+ * This replaces all links with a new url that redirects through our
+ * cloud-api servers (see cloud-api/routes/link-tracking)
+ *
+ * This redirect link href is NOT complete at this stage. It requires
+ * substantial post processing just before send. This happens in iso-core
+ * since sending can happen immediately or later in cloud-workers.
+ *
+ * See isomorphic-core tracking-utils.es6
+ *
+ * We also need to add individualized recipients to each tracking pixel
+ * for each message sent to each person.
+ *
+ * We finally need to put the original url back for the message that ends
+ * up in the users's sent folder. This ensures the sender doesn't trip
+ * their own link tracks.
+ */
+export default class LinkTrackingComposerExtension extends ComposerExtension {
+ static applyTransformsForSending({draftBodyRootNode, draft}) {
+ const metadata = draft.metadataForPluginId(PLUGIN_ID);
+ if (metadata) {
+ const messageUid = draft.clientId;
+ const links = [];
+
+ forEachATagInBody(draftBodyRootNode, (el) => {
+ const url = el.getAttribute('href');
+ if (!RegExpUtils.urlRegex().test(url)) {
+ return;
+ }
+ const encoded = encodeURIComponent(url);
+ const redirectUrl = `${PLUGIN_URL}/link/${draft.headerMessageId}/${links.length}?redirect=${encoded}`;
+
+ links.push({
+ url,
+ click_count: 0,
+ click_data: [],
+ redirect_url: redirectUrl,
+ });
+
+ el.setAttribute('href', redirectUrl);
+ });
+
+ // save the link info to draft metadata
+ metadata.uid = messageUid;
+ metadata.links = links;
+ draft.applyPluginMetadata(PLUGIN_ID, metadata);
+ }
+ }
+
+ static unapplyTransformsForSending({draftBodyRootNode}) {
+ forEachATagInBody(draftBodyRootNode, (el) => {
+ const url = el.getAttribute('href');
+ if (url.indexOf(PLUGIN_URL) !== -1) {
+ const userURLEncoded = url.split('?redirect=')[1];
+ el.setAttribute('href', decodeURIComponent(userURLEncoded));
+ }
+ });
+ }
+}
diff --git a/app/internal_packages/link-tracking/lib/link-tracking-constants.es6 b/app/internal_packages/link-tracking/lib/link-tracking-constants.es6
new file mode 100644
index 000000000..fd9c0ecee
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/link-tracking-constants.es6
@@ -0,0 +1,5 @@
+import plugin from '../package.json'
+
+export const PLUGIN_NAME = plugin.title
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
diff --git a/app/internal_packages/link-tracking/lib/link-tracking-message-extension.jsx b/app/internal_packages/link-tracking/lib/link-tracking-message-extension.jsx
new file mode 100644
index 000000000..b126df2b4
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/link-tracking-message-extension.jsx
@@ -0,0 +1,69 @@
+import {React, MessageViewExtension, Actions} from 'nylas-exports'
+import LinkTrackingMessagePopover from './link-tracking-message-popover'
+import {PLUGIN_ID} from './link-tracking-constants'
+
+export default class LinkTrackingMessageExtension extends MessageViewExtension {
+
+ static renderedMessageBodyIntoDocument({document, message, iframe}) {
+ const metadata = message.metadataForPluginId(PLUGIN_ID) || {};
+ if ((metadata.links || []).length === 0) { return }
+
+ const links = {}
+ for (const link of metadata.links) {
+ links[link.url] = link
+ links[link.redirect_url] = link
+ }
+
+ const trackedLinksWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) => {
+ if ((node.nodeName === 'A') && links[node.getAttribute('href')]) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ },
+ });
+
+ while (trackedLinksWalker.nextNode()) {
+ const node = trackedLinksWalker.currentNode;
+ const nodeHref = node.getAttribute('href');
+ const originalHref = links[nodeHref].url;
+
+ const dotNode = document.createElement('img');
+ dotNode.className = 'link-tracking-dot';
+ dotNode.style = 'margin-bottom: 0.75em; margin-left: 1px; margin-right: 1px; vertical-align: text-bottom; width: 6px;';
+ if (links[nodeHref].click_count > 0) {
+ dotNode.title = `${links[nodeHref].click_count} click${links[nodeHref].click_count === 1 ? "" : "s"} (${originalHref})`;
+ dotNode.src = 'mailspring://link-tracking/assets/ic-tracking-visited@2x.png';
+ dotNode.style = 'margin-bottom: 0.75em; margin-left: 1px; margin-right: 1px; vertical-align: text-bottom; width: 6px; cursor: pointer;'
+ dotNode.onmousedown = () => {
+ const dotRect = dotNode.getBoundingClientRect();
+ const iframeRect = iframe.getBoundingClientRect();
+ const rect = {
+ top: dotRect.top + iframeRect.top,
+ bottom: dotRect.bottom + iframeRect.top,
+ left: dotRect.left + iframeRect.left,
+ right: dotRect.right + iframeRect.left,
+ width: dotRect.width,
+ height: dotRect.height,
+ };
+ Actions.openPopover(
+ ,
+ {
+ originRect: rect,
+ direction: 'down',
+ }
+ );
+ }
+ } else {
+ dotNode.title = `This link has not been clicked (${originalHref})`;
+ dotNode.src = 'mailspring://link-tracking/assets/ic-tracking-unvisited@2x.png';
+ }
+ node.href = originalHref;
+ node.title = originalHref;
+ node.parentNode.insertBefore(dotNode, node.nextSibling);
+ }
+ }
+}
diff --git a/app/internal_packages/link-tracking/lib/link-tracking-message-popover.jsx b/app/internal_packages/link-tracking/lib/link-tracking-message-popover.jsx
new file mode 100644
index 000000000..1e7eb267c
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/link-tracking-message-popover.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import {DateUtils} from 'nylas-exports';
+import {Flexbox} from 'nylas-component-kit';
+import ActivityListStore from '../../activity-list/lib/activity-list-store';
+
+
+class LinkTrackingMessagePopover extends React.Component {
+ static displayName = 'LinkTrackingMessagePopover';
+
+ static propTypes = {
+ message: React.PropTypes.object,
+ linkMetadata: React.PropTypes.object,
+ };
+
+ renderClickActions() {
+ const clicks = this.props.linkMetadata.click_data;
+ return clicks.map((click) => {
+ const recipients = this.props.message.to.concat(this.props.message.cc, this.props.message.bcc);
+ const recipient = ActivityListStore.getRecipient(click.recipient, recipients);
+ const date = new Date(0);
+ date.setUTCSeconds(click.timestamp);
+ return (
+
+
+ {recipient ? recipient.displayName() : "Someone"}
+
+
+
+ {DateUtils.shortTimeString(date)}
+
+
+ );
+ });
+ }
+
+ render() {
+ return (
+
+
Clicked by:
+
+ {this.renderClickActions()}
+
+
+ );
+ }
+}
+
+export default LinkTrackingMessagePopover;
diff --git a/app/internal_packages/link-tracking/lib/main.es6 b/app/internal_packages/link-tracking/lib/main.es6
new file mode 100644
index 000000000..3923a3d97
--- /dev/null
+++ b/app/internal_packages/link-tracking/lib/main.es6
@@ -0,0 +1,32 @@
+import {
+ ComponentRegistry,
+ ExtensionRegistry,
+} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+
+import LinkTrackingButton from './link-tracking-button';
+import LinkTrackingComposerExtension from './link-tracking-composer-extension';
+import LinkTrackingMessageExtension from './link-tracking-message-extension';
+
+const LinkTrackingButtonWithTutorialTip = HasTutorialTip(LinkTrackingButton, {
+ title: "Track links in this email",
+ instructions: "When link tracking is turned on, Mailspring will notify you when recipients click links in this email.",
+});
+
+export function activate() {
+ ComponentRegistry.register(LinkTrackingButtonWithTutorialTip, {
+ role: 'Composer:ActionButton',
+ });
+
+ ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
+
+ ExtensionRegistry.MessageView.register(LinkTrackingMessageExtension);
+}
+
+export function serialize() {}
+
+export function deactivate() {
+ ComponentRegistry.unregister(LinkTrackingButtonWithTutorialTip);
+ ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
+ ExtensionRegistry.MessageView.unregister(LinkTrackingMessageExtension);
+}
diff --git a/app/internal_packages/link-tracking/package.json b/app/internal_packages/link-tracking/package.json
new file mode 100644
index 000000000..4073d7848
--- /dev/null
+++ b/app/internal_packages/link-tracking/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "link-tracking",
+ "main": "./lib/main",
+ "version": "0.1.0",
+ "serverUrl": {
+ "development": "http://localhost:5100",
+ "staging": "https://link-staging.getmailspring.com",
+ "production": "https://link.getmailspring.com"
+ },
+
+ "title": "Link Tracking",
+ "description": "Track when links in an email have been clicked by recipients.",
+ "icon": "./icon.png",
+ "isOptional": true,
+ "supportedEnvs": ["development", "staging", "production"],
+
+ "repository": {
+ "type": "git",
+ "url": ""
+ },
+ "engines": {
+ "mailspring": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true
+ },
+ "dependencies": {},
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages/link-tracking/specs/link-tracking-composer-extension-spec.es6 b/app/internal_packages/link-tracking/specs/link-tracking-composer-extension-spec.es6
new file mode 100644
index 000000000..6bd36673b
--- /dev/null
+++ b/app/internal_packages/link-tracking/specs/link-tracking-composer-extension-spec.es6
@@ -0,0 +1,114 @@
+import {Message} from 'nylas-exports';
+
+import LinkTrackingComposerExtension from '../lib/link-tracking-composer-extension'
+import {PLUGIN_ID, PLUGIN_URL} from '../lib/link-tracking-constants';
+
+const beforeBody = `TEST_BODY
+test
+asdad
+adsasd
+stillhere
+
+http://www.stillhere.com
+twstasdad
`;
+
+const afterBodyFactory = (accountId, messageUid) => `TEST_BODY
+test
+asdad
+adsasd
+stillhere
+
+http://www.stillhere.com
+twstasdad
`;
+
+const nodeForHTML = (html) => {
+ const fragment = document.createDocumentFragment();
+ const node = document.createElement('root');
+ fragment.appendChild(node);
+ node.innerHTML = html;
+ return node;
+}
+
+xdescribe('Link tracking composer extension', function linkTrackingComposerExtension() {
+ describe("applyTransformsForSending", () => {
+ beforeEach(() => {
+ this.draft = new Message({accountId: "test"});
+ this.draft.body = beforeBody;
+ this.draftBodyRootNode = nodeForHTML(this.draft.body);
+ });
+
+ it("takes no action if there is no metadata", () => {
+ LinkTrackingComposerExtension.applyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const afterBody = this.draftBodyRootNode.innerHTML;
+ expect(afterBody).toEqual(beforeBody);
+ });
+
+ describe("With properly formatted metadata and correct params", () => {
+ beforeEach(() => {
+ this.metadata = {tracked: true};
+ this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
+ });
+
+ it("replaces links in the unquoted portion of the body", () => {
+ LinkTrackingComposerExtension.applyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+
+ const metadata = this.draft.metadataForPluginId(PLUGIN_ID);
+ const afterBody = this.draftBodyRootNode.innerHTML;
+ expect(afterBody).toEqual(afterBodyFactory(this.draft.accountId, metadata.uid));
+ });
+
+ it("sets a uid and list of links on the metadata", () => {
+ LinkTrackingComposerExtension.applyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const metadata = this.draft.metadataForPluginId(PLUGIN_ID);
+ expect(metadata.uid).not.toBeUndefined();
+ expect(metadata.links).not.toBeUndefined();
+ expect(metadata.links.length).toEqual(2);
+
+ for (const link of metadata.links) {
+ expect(link.click_count).toEqual(0);
+ }
+ });
+ });
+ });
+
+ describe("unapplyTransformsForSending", () => {
+ beforeEach(() => {
+ this.metadata = {tracked: true, uid: '123'};
+ this.draft = new Message({accountId: "test"});
+ this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
+ });
+
+ it("takes no action if there are no tracked links in the body", () => {
+ this.draft.body = beforeBody;
+ this.draftBodyRootNode = nodeForHTML(this.draft.body);
+
+ LinkTrackingComposerExtension.unapplyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const afterBody = this.draftBodyRootNode.innerHTML;
+ expect(afterBody).toEqual(beforeBody);
+ });
+
+ it("replaces tracked links with the original links, restoring the body exactly", () => {
+ this.draft.body = afterBodyFactory(this.draft.accountId, this.metadata.uid);
+ this.draftBodyRootNode = nodeForHTML(this.draft.body);
+
+ LinkTrackingComposerExtension.unapplyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const afterBody = this.draftBodyRootNode.innerHTML;
+ expect(afterBody).toEqual(beforeBody);
+ });
+ });
+});
diff --git a/app/internal_packages/link-tracking/styles/main.less b/app/internal_packages/link-tracking/styles/main.less
new file mode 100644
index 000000000..9669150ba
--- /dev/null
+++ b/app/internal_packages/link-tracking/styles/main.less
@@ -0,0 +1,77 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+
+.link-tracking-icon img.content-mask {
+ background-color: #AAA;
+ vertical-align: text-bottom;
+}
+.link-tracking-icon img.content-mask.clicked {
+ background-color: #CCC;
+}
+.link-tracking-icon .link-click-count {
+ display: inline-block;
+ position: relative;
+ left: -16px;
+ text-align: center;
+
+ color: #3187e1;
+ font-size: 12px;
+ font-weight: bold;
+}
+.link-tracking-icon {
+ width: 16px;
+ margin-right: 4px;
+}
+
+
+.link-tracking-panel {
+ background: #DDF6FF;
+ border: 1px solid #ACD;
+ padding: 5px;
+ border-radius: 5px;
+}
+
+.link-tracking-panel h4{
+ text-align: center;
+ margin-top: 0;
+}
+.link-tracking-panel table{
+ width: 100%;
+}
+.link-tracking-panel td {
+ border-bottom: 1px solid #D5EAF5;
+ border-top: 1px solid #D5EAF5;
+ padding: 0 10px;
+ text-align: left;
+}
+
+.link-tracking-message-popover {
+ width: 200px;
+ max-height: 134px;
+ .link-tracking-header {
+ padding: @padding-base-vertical @padding-base-horizontal 0 @padding-base-horizontal;
+ text-align: center;
+ color: @text-color-subtle;
+ font-weight: 600;
+ }
+ .click-history-container {
+ max-height: 112px;
+ padding: 0 @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
+ overflow: auto;
+ .click-action {
+ color: @text-color-subtle;
+ .recipient {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ .spacer {
+ flex: 1 1 0;
+ }
+ .timestamp {
+ color: @text-color-very-subtle;
+ flex-shrink: 0;
+ }
+ }
+ }
+}
diff --git a/app/internal_packages/open-tracking/README.md b/app/internal_packages/open-tracking/README.md
new file mode 100644
index 000000000..97f2d061f
--- /dev/null
+++ b/app/internal_packages/open-tracking/README.md
@@ -0,0 +1,4 @@
+
+## Open Tracking
+
+Adds tracking pixels to messages and tracks whether they have been opened.
diff --git a/app/internal_packages/open-tracking/assets/InMessage-opened@1x.png b/app/internal_packages/open-tracking/assets/InMessage-opened@1x.png
new file mode 100644
index 000000000..0ed393ff1
Binary files /dev/null and b/app/internal_packages/open-tracking/assets/InMessage-opened@1x.png differ
diff --git a/app/internal_packages/open-tracking/assets/InMessage-opened@2x.png b/app/internal_packages/open-tracking/assets/InMessage-opened@2x.png
new file mode 100644
index 000000000..db241f088
Binary files /dev/null and b/app/internal_packages/open-tracking/assets/InMessage-opened@2x.png differ
diff --git a/app/internal_packages/open-tracking/assets/icon-composer-eye@1x.png b/app/internal_packages/open-tracking/assets/icon-composer-eye@1x.png
new file mode 100644
index 000000000..2d7a1a840
Binary files /dev/null and b/app/internal_packages/open-tracking/assets/icon-composer-eye@1x.png differ
diff --git a/app/internal_packages/open-tracking/assets/icon-composer-eye@2x.png b/app/internal_packages/open-tracking/assets/icon-composer-eye@2x.png
new file mode 100644
index 000000000..5a87f3f6e
Binary files /dev/null and b/app/internal_packages/open-tracking/assets/icon-composer-eye@2x.png differ
diff --git a/app/internal_packages/open-tracking/assets/icon-tracking-opened@2x.png b/app/internal_packages/open-tracking/assets/icon-tracking-opened@2x.png
new file mode 100644
index 000000000..4408e13b7
Binary files /dev/null and b/app/internal_packages/open-tracking/assets/icon-tracking-opened@2x.png differ
diff --git a/app/internal_packages/open-tracking/icon.png b/app/internal_packages/open-tracking/icon.png
new file mode 100644
index 000000000..94e058a45
Binary files /dev/null and b/app/internal_packages/open-tracking/icon.png differ
diff --git a/app/internal_packages/open-tracking/lib/main.es6 b/app/internal_packages/open-tracking/lib/main.es6
new file mode 100644
index 000000000..65ac373e7
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/main.es6
@@ -0,0 +1,36 @@
+import {
+ ComponentRegistry,
+ ExtensionRegistry,
+} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+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';
+
+const OpenTrackingButtonWithTutorialTip = HasTutorialTip(OpenTrackingButton, {
+ title: "See when recipients open this email",
+ instructions: "When enabled, Mailspring will notify you as soon as someone reads this message. Sending to a group? Mailspring shows you which recipients opened your email so you can follow up with precision.",
+});
+
+export function activate() {
+ ComponentRegistry.register(OpenTrackingButtonWithTutorialTip,
+ {role: 'Composer:ActionButton'});
+
+ ComponentRegistry.register(OpenTrackingIcon,
+ {role: 'ThreadListIcon'});
+
+ ComponentRegistry.register(OpenTrackingMessageStatus,
+ {role: 'MessageHeaderStatus'});
+
+ ExtensionRegistry.Composer.register(OpenTrackingComposerExtension);
+}
+
+export function serialize() {}
+
+export function deactivate() {
+ ComponentRegistry.unregister(OpenTrackingButtonWithTutorialTip);
+ ComponentRegistry.unregister(OpenTrackingIcon);
+ ComponentRegistry.unregister(OpenTrackingMessageStatus);
+ ExtensionRegistry.Composer.unregister(OpenTrackingComposerExtension);
+}
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-button.jsx b/app/internal_packages/open-tracking/lib/open-tracking-button.jsx
new file mode 100644
index 000000000..640417cb0
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-button.jsx
@@ -0,0 +1,51 @@
+import {React, APIError, NylasAPIRequest} from 'nylas-exports'
+import {MetadataComposerToggleButton} from 'nylas-component-kit'
+import {PLUGIN_ID, PLUGIN_NAME} from './open-tracking-constants'
+
+export default class OpenTrackingButton extends React.Component {
+ static displayName = 'OpenTrackingButton';
+
+ static propTypes = {
+ draft: React.PropTypes.object.isRequired,
+ session: React.PropTypes.object.isRequired,
+ };
+
+ shouldComponentUpdate(nextProps) {
+ return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
+ }
+
+ _title(enabled) {
+ const dir = enabled ? "Disable" : "Enable";
+ return `${dir} open tracking`
+ }
+
+ _errorMessage(error) {
+ if (error instanceof APIError && NylasAPIRequest.TimeoutErrorCodes.includes(error.statusCode)) {
+ return `Open tracking does not work offline. Please re-enable when you come back online.`
+ }
+ return `Unfortunately, open tracking is currently not available. Please try again later. Error: ${error.message}`
+ }
+
+ render() {
+ const enabledValue = {
+ open_count: 0,
+ open_data: [],
+ };
+
+ return (
+
+ )
+ }
+}
+
+OpenTrackingButton.containerRequired = false;
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6 b/app/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6
new file mode 100644
index 000000000..87b4c27e9
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6
@@ -0,0 +1,57 @@
+import {ComposerExtension} from 'nylas-exports';
+import {PLUGIN_ID, PLUGIN_URL} from './open-tracking-constants';
+
+export default class OpenTrackingComposerExtension extends ComposerExtension {
+
+ /**
+ * This inserts a placeholder image tag to serve as our open tracking
+ * pixel.
+ *
+ * See cloud-api/routes/open-tracking
+ *
+ * This image tag is NOT complete at this stage. It requires substantial
+ * post processing just before send. This happens in iso-core since
+ * sending can happen immediately or later in cloud-workers.
+ *
+ * See isomorphic-core tracking-utils.es6
+ *
+ * We don't add a `src` parameter here since we don't want the tracking
+ * pixel to prematurely load with an incorrect url.
+ *
+ * We also need to add individualized recipients to each tracking pixel
+ * for each message sent to each person.
+ *
+ * We finally need to remove the tracking pixel from the message that
+ * ends up in the users's sent folder. This ensures the sender doesn't
+ * trip their own open track.
+ */
+ static applyTransformsForSending({draftBodyRootNode, draft}) {
+ // grab message metadata, if any
+ const messageUid = draft.clientId;
+ const metadata = draft.metadataForPluginId(PLUGIN_ID);
+ if (!metadata) {
+ return;
+ }
+
+ // insert a tracking pixel into the message
+ const serverUrl = `${PLUGIN_URL}/open/${draft.headerMessageId}`
+ const imgFragment = document.createRange().createContextualFragment(``);
+ const beforeEl = draftBodyRootNode.querySelector('.gmail_quote');
+ if (beforeEl) {
+ beforeEl.parentNode.insertBefore(imgFragment, beforeEl);
+ } else {
+ draftBodyRootNode.appendChild(imgFragment);
+ }
+
+ // save the uid info to draft metadata
+ metadata.uid = messageUid;
+ draft.applyPluginMetadata(PLUGIN_ID, metadata);
+ }
+
+ static unapplyTransformsForSending({draftBodyRootNode}) {
+ const imgEl = draftBodyRootNode.querySelector('.n1-open');
+ if (imgEl) {
+ imgEl.parentNode.removeChild(imgEl);
+ }
+ }
+}
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-constants.es6 b/app/internal_packages/open-tracking/lib/open-tracking-constants.es6
new file mode 100644
index 000000000..fd9c0ecee
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-constants.es6
@@ -0,0 +1,5 @@
+import plugin from '../package.json'
+
+export const PLUGIN_NAME = plugin.title
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-icon.jsx b/app/internal_packages/open-tracking/lib/open-tracking-icon.jsx
new file mode 100644
index 000000000..a0cfc5af6
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-icon.jsx
@@ -0,0 +1,89 @@
+import {React, ReactDOM, Actions} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+import OpenTrackingMessagePopover from './open-tracking-message-popover';
+import {PLUGIN_ID} from './open-tracking-constants';
+
+
+export default class OpenTrackingIcon extends React.Component {
+ static displayName = 'OpenTrackingIcon';
+
+ static propTypes = {
+ thread: React.PropTypes.object.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = this._getStateFromThread(props.thread)
+ }
+
+ componentWillReceiveProps(newProps) {
+ this.setState(this._getStateFromThread(newProps.thread));
+ }
+
+ onMouseDown = () => {
+ const rect = ReactDOM.findDOMNode(this).getBoundingClientRect();
+ Actions.openPopover(
+ ,
+ {originRect: rect, direction: 'down'}
+ )
+ }
+
+ _getStateFromThread(thread) {
+ const messages = thread.__messages || []
+
+ let lastMessage = null;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (!messages[i].draft) {
+ lastMessage = messages[i];
+ break;
+ }
+ }
+
+ if (!lastMessage) {
+ return {
+ message: null,
+ opened: false,
+ openCount: null,
+ hasMetadata: false,
+ };
+ }
+
+ const lastMessageMeta = lastMessage.metadataForPluginId(PLUGIN_ID);
+ const hasMetadata = lastMessageMeta != null && lastMessageMeta.open_count != null;
+
+ return {
+ message: lastMessage,
+ opened: hasMetadata && lastMessageMeta.open_count > 0,
+ openCount: hasMetadata ? lastMessageMeta.open_count : null,
+ hasMetadata: hasMetadata,
+ };
+ }
+
+ _renderImage() {
+ return (
+
+ );
+ }
+
+ render() {
+ if (!this.state.hasMetadata) return ;
+ const openedTitle = `${this.state.openCount} open${this.state.openCount === 1 ? "" : "s"}`;
+ const title = this.state.opened ? openedTitle : "This message has not been opened";
+ return (
+
+ {this._renderImage()}
+
+ );
+ }
+}
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-message-popover.jsx b/app/internal_packages/open-tracking/lib/open-tracking-message-popover.jsx
new file mode 100644
index 000000000..f592c114c
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-message-popover.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import {DateUtils} from 'nylas-exports';
+import {Flexbox} from 'nylas-component-kit';
+import ActivityListStore from '../../activity-list/lib/activity-list-store';
+
+
+class OpenTrackingMessagePopover extends React.Component {
+ static displayName = 'OpenTrackingMessagePopover';
+
+ static propTypes = {
+ message: React.PropTypes.object,
+ openMetadata: React.PropTypes.object,
+ };
+
+ renderOpenActions() {
+ const opens = this.props.openMetadata.open_data;
+ return opens.map((open) => {
+ const recipients = this.props.message.to.concat(this.props.message.cc, this.props.message.bcc);
+ const recipient = ActivityListStore.getRecipient(open.recipient, recipients);
+ const date = new Date(0);
+ date.setUTCSeconds(open.timestamp);
+ return (
+
+
+ {recipient ? recipient.displayName() : "Someone"}
+
+
+
+ {DateUtils.shortTimeString(date)}
+
+
+ );
+ });
+ }
+
+ render() {
+ return (
+
+
Opened by:
+
+ {this.renderOpenActions()}
+
+
+ );
+ }
+}
+
+export default OpenTrackingMessagePopover;
diff --git a/app/internal_packages/open-tracking/lib/open-tracking-message-status.jsx b/app/internal_packages/open-tracking/lib/open-tracking-message-status.jsx
new file mode 100644
index 000000000..490884db8
--- /dev/null
+++ b/app/internal_packages/open-tracking/lib/open-tracking-message-status.jsx
@@ -0,0 +1,79 @@
+import {React, ReactDOM, Actions} from 'nylas-exports'
+import {RetinaImg} from 'nylas-component-kit'
+import OpenTrackingMessagePopover from './open-tracking-message-popover'
+import {PLUGIN_ID} from './open-tracking-constants'
+
+
+export default class OpenTrackingMessageStatus extends React.Component {
+ static displayName = "OpenTrackingMessageStatus";
+
+ static propTypes = {
+ message: React.PropTypes.object.isRequired,
+ };
+
+ static containerStyles = {
+ paddingTop: 4,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = this._getStateFromMessage(props.message)
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState(this._getStateFromMessage(nextProps.message))
+ }
+
+ onMouseDown = () => {
+ const rect = ReactDOM.findDOMNode(this).getBoundingClientRect();
+ Actions.openPopover(
+ ,
+ {originRect: rect, direction: 'down'}
+ )
+ }
+
+ _getStateFromMessage(message) {
+ const metadata = message.metadataForPluginId(PLUGIN_ID);
+ if (!metadata || metadata.open_count == null) {
+ return {
+ hasMetadata: false,
+ openCount: null,
+ opened: false,
+ };
+ }
+ return {
+ hasMetadata: true,
+ openCount: metadata.open_count,
+ opened: metadata.open_count > 0,
+ };
+ }
+
+ renderImage() {
+ return (
+
+ );
+ }
+
+ render() {
+ if (!this.state.hasMetadata) return false;
+ let openedCount = `${this.state.openCount} open${this.state.openCount === 1 ? "" : "s"}`;
+ if (this.state.openCount > 999) openedCount = "999+ opens";
+ const text = this.state.opened ? openedCount : "No opens";
+ return (
+
+ {this.renderImage()} {text}
+
+ )
+ }
+}
diff --git a/app/internal_packages/open-tracking/package.json b/app/internal_packages/open-tracking/package.json
new file mode 100644
index 000000000..19ed5aeac
--- /dev/null
+++ b/app/internal_packages/open-tracking/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "open-tracking",
+ "main": "./lib/main",
+ "version": "0.1.0",
+ "serverUrl": {
+ "development": "http://localhost:5100",
+ "staging": "https://link-staging.getmailspring.com",
+ "production": "https://link.getmailspring.com"
+ },
+
+ "title": "Open Tracking",
+ "description": "Track when email messages have been opened by recipients.",
+ "icon": "./icon.png",
+ "isOptional": true,
+ "supportedEnvs": ["development", "staging", "production"],
+
+ "repository": {
+ "type": "git",
+ "url": ""
+ },
+ "engines": {
+ "mailspring": "*"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true
+ },
+ "dependencies": {},
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages/open-tracking/specs/open-tracking-composer-extension-spec.es6 b/app/internal_packages/open-tracking/specs/open-tracking-composer-extension-spec.es6
new file mode 100644
index 000000000..401687e30
--- /dev/null
+++ b/app/internal_packages/open-tracking/specs/open-tracking-composer-extension-spec.es6
@@ -0,0 +1,91 @@
+import {Message} from 'nylas-exports';
+import OpenTrackingComposerExtension from '../lib/open-tracking-composer-extension'
+import {PLUGIN_ID, PLUGIN_URL} from '../lib/open-tracking-constants';
+
+const accountId = 'fake-accountId';
+const clientId = 'local-31d8df57-1442';
+const beforeBody = `TEST_BODY On Feb 25 2016, at 3:38 pm, Drew <drew@nylas.com> wrote:
twst
`;
+const afterBody = `TEST_BODY On Feb 25 2016, at 3:38 pm, Drew <drew@nylas.com> wrote:
twst
`;
+
+const nodeForHTML = (html) => {
+ const fragment = document.createDocumentFragment();
+ const node = document.createElement('root');
+ fragment.appendChild(node);
+ node.innerHTML = html;
+ return node;
+}
+
+xdescribe('Open tracking composer extension', function openTrackingComposerExtension() {
+ describe("applyTransformsForSending", () => {
+ beforeEach(() => {
+ this.draftBodyRootNode = nodeForHTML(beforeBody);
+ this.draft = new Message({
+ clientId: clientId,
+ accountId: accountId,
+ body: beforeBody,
+ });
+ });
+
+ it("takes no action if there is no metadata", () => {
+ OpenTrackingComposerExtension.applyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const actualAfterBody = this.draftBodyRootNode.innerHTML;
+ expect(actualAfterBody).toEqual(beforeBody);
+ });
+
+ describe("With properly formatted metadata and correct params", () => {
+ beforeEach(() => {
+ this.metadata = {open_count: 0};
+ this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
+
+ OpenTrackingComposerExtension.applyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ this.metadata = this.draft.metadataForPluginId(PLUGIN_ID);
+ });
+
+ it("appends an image with the correct server URL to the unquoted body", () => {
+ const actualAfterBody = this.draftBodyRootNode.innerHTML;
+ expect(actualAfterBody).toEqual(afterBody);
+ });
+ });
+ });
+
+ describe("unapplyTransformsForSending", () => {
+ it("takes no action if the img tag is missing", () => {
+ this.draftBodyRootNode = nodeForHTML(beforeBody);
+ this.draft = new Message({
+ clientId: clientId,
+ accountId: accountId,
+ body: beforeBody,
+ });
+ OpenTrackingComposerExtension.unapplyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const actualAfterBody = this.draftBodyRootNode.innerHTML;
+ expect(actualAfterBody).toEqual(beforeBody);
+ });
+
+ it("removes the image from the body and restore the body to it's exact original content", () => {
+ this.metadata = {open_count: 0};
+ this.draft.applyPluginMetadata(PLUGIN_ID, this.metadata);
+
+ this.draftBodyRootNode = nodeForHTML(afterBody);
+ this.draft = new Message({
+ clientId: clientId,
+ accountId: accountId,
+ body: afterBody,
+ });
+ OpenTrackingComposerExtension.unapplyTransformsForSending({
+ draftBodyRootNode: this.draftBodyRootNode,
+ draft: this.draft,
+ });
+ const actualAfterBody = this.draftBodyRootNode.innerHTML;
+ expect(actualAfterBody).toEqual(beforeBody);
+ });
+ });
+});
diff --git a/app/internal_packages/open-tracking/specs/open-tracking-icon-spec.jsx b/app/internal_packages/open-tracking/specs/open-tracking-icon-spec.jsx
new file mode 100644
index 000000000..58f52c575
--- /dev/null
+++ b/app/internal_packages/open-tracking/specs/open-tracking-icon-spec.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {findRenderedDOMComponentWithClass} from 'react-addons-test-utils';
+
+import {Message, NylasTestUtils} from 'nylas-exports'
+import OpenTrackingIcon from '../lib/open-tracking-icon'
+import {PLUGIN_ID} from '../lib/open-tracking-constants'
+
+const {renderIntoDocument} = NylasTestUtils;
+
+function makeIcon(thread, props = {}) {
+ return renderIntoDocument();
+}
+
+function find(component, className) {
+ return ReactDOM.findDOMNode(findRenderedDOMComponentWithClass(component, className))
+}
+
+function addOpenMetadata(obj, openCount) {
+ obj.applyPluginMetadata(PLUGIN_ID, {open_count: openCount});
+}
+
+describe('Open tracking icon', function openTrackingIcon() {
+ beforeEach(() => {
+ this.thread = {__messages: []};
+ });
+
+
+ it("shows no icon if the thread has no messages", () => {
+ const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
+ expect(icon.children.length).toEqual(0);
+ });
+
+ it("shows no icon if the thread messages have no metadata", () => {
+ this.thread.__messages.push(new Message());
+ this.thread.__messages.push(new Message());
+ const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
+ expect(icon.children.length).toEqual(0);
+ });
+
+ describe("With messages and metadata", () => {
+ beforeEach(() => {
+ this.messages = [new Message(), new Message(), new Message({draft: true})];
+ this.thread.__messages.push(...this.messages);
+ });
+
+ it("shows no icon if metadata is malformed", () => {
+ this.messages[0].applyPluginMetadata(PLUGIN_ID, {gar: "bage"});
+ const icon = ReactDOM.findDOMNode(makeIcon(this.thread));
+ expect(icon.children.length).toEqual(0);
+ });
+
+ it("shows an unopened icon if last non draft message has metadata and is unopened", () => {
+ addOpenMetadata(this.messages[0], 1);
+ addOpenMetadata(this.messages[1], 0);
+ const icon = find(makeIcon(this.thread), "open-tracking-icon");
+ expect(icon.children.length).toEqual(1);
+ expect(icon.querySelector("img.unopened")).not.toBeNull();
+ expect(icon.querySelector("img.opened")).toBeNull();
+ });
+
+ it("shows an opened icon if last non draft message with metadata is opened", () => {
+ addOpenMetadata(this.messages[0], 0);
+ addOpenMetadata(this.messages[1], 1);
+ const icon = find(makeIcon(this.thread), "open-tracking-icon");
+ expect(icon.children.length).toEqual(1);
+ expect(icon.querySelector("img.unopened")).toBeNull();
+ expect(icon.querySelector("img.opened")).not.toBeNull();
+ });
+ });
+});
diff --git a/app/internal_packages/open-tracking/specs/open-tracking-message-status-spec.jsx b/app/internal_packages/open-tracking/specs/open-tracking-message-status-spec.jsx
new file mode 100644
index 000000000..b8186f756
--- /dev/null
+++ b/app/internal_packages/open-tracking/specs/open-tracking-message-status-spec.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import {Message, NylasTestUtils} from 'nylas-exports'
+import OpenTrackingMessageStatus from '../lib/open-tracking-message-status'
+import {PLUGIN_ID} from '../lib/open-tracking-constants'
+
+const {renderIntoDocument} = NylasTestUtils;
+
+function makeIcon(message, props = {}) {
+ return renderIntoDocument(
);
+}
+
+function addOpenMetadata(obj, openCount) {
+ obj.applyPluginMetadata(PLUGIN_ID, {open_count: openCount});
+}
+
+describe('Open tracking message status', function openTrackingMessageStatus() {
+ beforeEach(() => {
+ this.message = new Message();
+ });
+
+
+ it("shows nothing if the message has no metadata", () => {
+ const icon = ReactDOM.findDOMNode(makeIcon(this.message));
+ expect(icon.querySelector(".open-tracking-message-status")).toBeNull();
+ });
+
+
+ it("shows nothing if metadata is malformed", () => {
+ this.message.applyPluginMetadata(PLUGIN_ID, {gar: "bage"});
+ const icon = ReactDOM.findDOMNode(makeIcon(this.message));
+ expect(icon.querySelector(".open-tracking-message-status")).toBeNull();
+ });
+
+ it("shows an unopened icon if the message has metadata and is unopened", () => {
+ addOpenMetadata(this.message, 0);
+ const icon = ReactDOM.findDOMNode(makeIcon(this.message));
+ expect(icon.querySelector("img.unopened")).not.toBeNull();
+ expect(icon.querySelector("img.opened")).toBeNull();
+ });
+
+ it("shows an opened icon if the message has metadata and is opened", () => {
+ addOpenMetadata(this.message, 1);
+ const icon = ReactDOM.findDOMNode(makeIcon(this.message));
+ expect(icon.querySelector("img.unopened")).toBeNull();
+ expect(icon.querySelector("img.opened")).not.toBeNull();
+ });
+});
diff --git a/app/internal_packages/open-tracking/styles/main.less b/app/internal_packages/open-tracking/styles/main.less
new file mode 100644
index 000000000..2ae6cae1e
--- /dev/null
+++ b/app/internal_packages/open-tracking/styles/main.less
@@ -0,0 +1,88 @@
+@import "ui-variables";
+@import "ui-mixins";
+
+@open-tracking-color: #7C19CC;
+
+.open-tracking-icon img {
+ vertical-align: initial;
+}
+
+.open-tracking-icon img.content-mask.unopened {
+ background-color: fadeout(@open-tracking-color, 80%);
+ cursor: default;
+}
+.open-tracking-icon img.content-mask.opened {
+ background-color: @open-tracking-color;
+ cursor: pointer;
+}
+
+.list-item.focused, .list-item.selected {
+ .open-tracking-icon img.content-mask.unopened {
+ background-color: fadeout(@text-color-inverse, 70%);
+ }
+ .open-tracking-icon img.content-mask.opened {
+ background-color: @text-color-inverse;
+ }
+}
+
+.open-tracking-icon .open-count {
+ display: inline-block;
+ position: relative;
+ left: -16px;
+ text-align: center;
+
+ background-color: @text-color-link;
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.open-tracking-icon {
+ width: 15px;
+ margin: 0 2px;
+}
+
+.open-tracking-message-status {
+ color: @text-color-very-subtle;
+ margin-left: 10px;
+ &.unopened {
+ img.content-mask {
+ background-color: @text-color-very-subtle;
+ }
+ }
+ &.opened {
+ cursor: pointer;
+ img.content-mask {
+ background-color: @open-tracking-color;
+ }
+ }
+}
+
+.open-tracking-message-popover {
+ width: 200px;
+ max-height: 240px;
+ .open-tracking-header {
+ padding: @padding-base-vertical @padding-base-horizontal 0 @padding-base-horizontal;
+ text-align: center;
+ color: @text-color-subtle;
+ font-weight: 600;
+ }
+ .open-history-container {
+ max-height: 216px;
+ padding: 0 @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
+ overflow: auto;
+ .open-action {
+ color: @text-color-subtle;
+ .recipient {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ .spacer {
+ flex: 1 1 0;
+ }
+ .timestamp {
+ color: @text-color-very-subtle;
+ flex-shrink: 0;
+ }
+ }
+ }
+}
diff --git a/app/internal_packages/send-later/assets/ic-send-later-modal@2x.png b/app/internal_packages/send-later/assets/ic-send-later-modal@2x.png
new file mode 100644
index 000000000..b800245eb
Binary files /dev/null and b/app/internal_packages/send-later/assets/ic-send-later-modal@2x.png differ
diff --git a/app/internal_packages/send-later/icon.png b/app/internal_packages/send-later/icon.png
new file mode 100644
index 000000000..febd859b5
Binary files /dev/null and b/app/internal_packages/send-later/icon.png differ
diff --git a/app/internal_packages/send-later/lib/main.es6 b/app/internal_packages/send-later/lib/main.es6
new file mode 100644
index 000000000..72d8969dc
--- /dev/null
+++ b/app/internal_packages/send-later/lib/main.es6
@@ -0,0 +1,23 @@
+import {ComponentRegistry} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+import SendLaterButton from './send-later-button';
+import SendLaterStatus from './send-later-status';
+
+const SendLaterButtonWithTip = HasTutorialTip(SendLaterButton, {
+ title: "Send on your own schedule",
+ instructions: "Schedule this message to send at the ideal time. N1 makes it easy to control the fabric of spacetime!",
+});
+
+export function activate() {
+ ComponentRegistry.register(SendLaterButtonWithTip, {role: 'Composer:ActionButton'})
+ ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'})
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(SendLaterButtonWithTip)
+ ComponentRegistry.unregister(SendLaterStatus)
+}
+
+export function serialize() {
+
+}
diff --git a/app/internal_packages/send-later/lib/send-later-button.jsx b/app/internal_packages/send-later/lib/send-later-button.jsx
new file mode 100644
index 000000000..f9f39c0ec
--- /dev/null
+++ b/app/internal_packages/send-later/lib/send-later-button.jsx
@@ -0,0 +1,231 @@
+import fs from 'fs';
+import React, {Component} from 'react'
+import PropTypes from 'prop-types'
+import ReactDOM from 'react-dom'
+import {Actions, DateUtils, NylasAPIHelpers, DraftHelpers, FeatureUsageStore} from 'nylas-exports'
+import {RetinaImg} from 'nylas-component-kit'
+import SendLaterPopover from './send-later-popover'
+import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
+const {NylasAPIRequest, NylasAPI, N1CloudAPI} = require('nylas-exports')
+
+Promise.promisifyAll(fs);
+
+class SendLaterButton extends Component {
+ static displayName = 'SendLaterButton';
+
+ static containerRequired = false;
+
+ static propTypes = {
+ draft: PropTypes.object.isRequired,
+ session: PropTypes.object.isRequired,
+ isValidDraft: PropTypes.func,
+ };
+
+ constructor() {
+ super();
+ this.state = {
+ saving: false,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ if (nextState.saving !== this.state.saving) {
+ return true;
+ }
+ if (this._sendLaterDateForDraft(nextProps.draft) !== this._sendLaterDateForDraft(this.props.draft)) {
+ return true;
+ }
+ return false;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ onAssignSendLaterDate = async (sendLaterDate, dateLabel) => {
+ if (!this.props.isValidDraft()) { return }
+ Actions.closePopover();
+
+ const currentSendLaterDate = this._sendLaterDateForDraft(this.props.draft)
+ if (currentSendLaterDate === sendLaterDate) { return }
+
+ // Only check for feature usage and record metrics if this draft is not
+ // already set to send later.
+ if (!currentSendLaterDate) {
+ const lexicon = {
+ displayName: "Send Later",
+ usedUpHeader: "All delayed sends used",
+ iconUrl: "mailspring://send-later/assets/ic-send-later-modal@2x.png",
+ }
+
+ try {
+ await FeatureUsageStore.asyncUseFeature('send-later', {lexicon})
+ } catch (error) {
+ if (error instanceof FeatureUsageStore.NoProAccessError) {
+ return
+ }
+ }
+
+ this.setState({saving: true});
+ const sendInSec = Math.round(((new Date(sendLaterDate)).valueOf() - Date.now()) / 1000)
+ Actions.recordUserEvent("Draft Send Later", {
+ timeInSec: sendInSec,
+ timeInLog10Sec: Math.log10(sendInSec),
+ label: dateLabel,
+ });
+ }
+ this.onSetMetadata({expiration: sendLaterDate});
+ };
+
+ onCancelSendLater = () => {
+ Actions.closePopover();
+ this.onSetMetadata({expiration: null, cancelled: true});
+ };
+
+ onSetMetadata = async (metadatum = {}) => {
+ if (!this.mounted) { return; }
+ const {draft, session} = this.props;
+ const {expiration, ...extra} = metadatum
+ this.setState({saving: true});
+
+ try {
+ await NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, draft.accountId);
+ if (!this.mounted) { return; }
+
+ if (!expiration) {
+ session.changes.addPluginMetadata(PLUGIN_ID, {
+ ...extra,
+ expiration: null,
+ });
+ } else {
+ session.changes.add({pristine: false})
+ const draftContents = await DraftHelpers.finalizeDraft(session);
+ const req = new NylasAPIRequest({
+ api: NylasAPI,
+ options: {
+ path: `/drafts/build`,
+ method: 'POST',
+ body: draftContents,
+ accountId: draft.accountId,
+ returnsModel: false,
+ },
+ });
+
+ const draftMessage = await req.run();
+ const uploads = [];
+
+ // Now, upload attachments to our blob service.
+ for (const attachment of draftContents.uploads) {
+ const uploadReq = new NylasAPIRequest({
+ api: N1CloudAPI,
+ options: {
+ path: `/blobs`,
+ method: 'PUT',
+ blob: true,
+ accountId: draft.accountId,
+ returnsModel: false,
+ formData: {
+ id: attachment.id,
+ file: fs.createReadStream(attachment.originPath),
+ },
+ },
+ });
+ await uploadReq.run();
+ attachment.serverId = `${draftContents.accountId}-${attachment.id}`;
+ uploads.push(attachment);
+ }
+
+ const OPEN_TRACKING_ID = NylasEnv.packages.pluginIdFor('open-tracking')
+ const LINK_TRACKING_ID = NylasEnv.packages.pluginIdFor('link-tracking')
+
+ draftMessage.usesOpenTracking = draft.metadataForPluginId(OPEN_TRACKING_ID) != null;
+ draftMessage.usesLinkTracking = draft.metadataForPluginId(LINK_TRACKING_ID) != null;
+ session.changes.add({serverId: draftMessage.id})
+ session.changes.addPluginMetadata(PLUGIN_ID, {
+ ...draftMessage,
+ ...extra,
+ expiration,
+ uploads,
+ });
+ }
+
+ // TODO: This currently is only useful for syncing the draft metadata,
+ // even though we don't actually syncback drafts
+ Actions.finalizeDraftAndSyncbackMetadata(draft.clientId);
+
+ if (expiration && NylasEnv.isComposerWindow()) {
+ NylasEnv.close();
+ }
+ } catch (error) {
+ NylasEnv.reportError(error);
+ NylasEnv.showErrorDialog(`Sorry, we were unable to schedule this message. ${error.message}`);
+ }
+
+ if (!this.mounted) { return }
+ this.setState({saving: false})
+ }
+
+ onClick = () => {
+ const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
+ Actions.openPopover(
+ ,
+ {originRect: buttonRect, direction: 'up'}
+ )
+ };
+
+ _sendLaterDateForDraft(draft) {
+ if (!draft) {
+ return null;
+ }
+ const messageMetadata = draft.metadataForPluginId(PLUGIN_ID) || {};
+ return messageMetadata.expiration;
+ }
+
+
+ render() {
+ let className = 'btn btn-toolbar btn-send-later';
+
+ if (this.state.saving) {
+ return (
+
+ );
+ }
+
+ let sendLaterLabel = false;
+ const sendLaterDate = this._sendLaterDateForDraft(this.props.draft);
+
+ if (sendLaterDate) {
+ className += ' btn-enabled';
+ const momentDate = DateUtils.futureDateFromString(sendLaterDate);
+ if (momentDate) {
+ sendLaterLabel = Sending in {momentDate.fromNow(true)};
+ } else {
+ sendLaterLabel = Sending now;
+ }
+ }
+ return (
+
+ );
+ }
+}
+
+export default SendLaterButton
diff --git a/app/internal_packages/send-later/lib/send-later-constants.es6 b/app/internal_packages/send-later/lib/send-later-constants.es6
new file mode 100644
index 000000000..f9f639ec5
--- /dev/null
+++ b/app/internal_packages/send-later/lib/send-later-constants.es6
@@ -0,0 +1,4 @@
+import plugin from '../package.json'
+
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_NAME = "Send Later"
diff --git a/app/internal_packages/send-later/lib/send-later-popover.jsx b/app/internal_packages/send-later/lib/send-later-popover.jsx
new file mode 100644
index 000000000..ae35aea61
--- /dev/null
+++ b/app/internal_packages/send-later/lib/send-later-popover.jsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {DateUtils} from 'nylas-exports'
+import {DatePickerPopover} from 'nylas-component-kit'
+
+
+const SendLaterOptions = {
+ 'In 1 hour': DateUtils.in1Hour,
+ 'In 2 hours': DateUtils.in2Hours,
+ 'Later today': DateUtils.laterToday,
+ 'Tomorrow morning': DateUtils.tomorrow,
+ 'Tomorrow evening': DateUtils.tomorrowEvening,
+ 'This weekend': DateUtils.thisWeekend,
+ 'Next week': DateUtils.nextWeek,
+}
+
+function SendLaterPopover(props) {
+ let footer;
+ const {onAssignSendLaterDate, onCancelSendLater, sendLaterDate} = props
+ const header = Send later:
+ if (sendLaterDate) {
+ footer = [
+ ,
+
+
+
,
+ ]
+ }
+
+ return (
+
+ );
+}
+SendLaterPopover.displayName = 'SendLaterPopover';
+SendLaterPopover.propTypes = {
+ sendLaterDate: PropTypes.string,
+ onAssignSendLaterDate: PropTypes.func.isRequired,
+ onCancelSendLater: PropTypes.func.isRequired,
+};
+
+export default SendLaterPopover
diff --git a/app/internal_packages/send-later/lib/send-later-status.jsx b/app/internal_packages/send-later/lib/send-later-status.jsx
new file mode 100644
index 000000000..5b63c4448
--- /dev/null
+++ b/app/internal_packages/send-later/lib/send-later-status.jsx
@@ -0,0 +1,49 @@
+import React, {Component} from 'react'
+import PropTypes from 'prop-types'
+import moment from 'moment'
+import {DateUtils, Actions, SyncbackMetadataTask} from 'nylas-exports'
+import {RetinaImg} from 'nylas-component-kit'
+import {PLUGIN_ID} from './send-later-constants'
+
+const {DATE_FORMAT_SHORT} = DateUtils
+
+
+export default class SendLaterStatus extends Component {
+ static displayName = 'SendLaterStatus';
+
+ static propTypes = {
+ draft: PropTypes.object,
+ };
+
+ onCancelSendLater = () => {
+ Actions.queueTask(new SyncbackMetadataTask({
+ model: this.props.draft,
+ accountId: this.props.draft.accountId,
+ pluginId: PLUGIN_ID,
+ value: {expiration: null, cancelled: true},
+ }))
+ };
+
+ render() {
+ const {draft} = this.props
+ const metadata = draft.metadataForPluginId(PLUGIN_ID)
+ if (metadata && metadata.expiration) {
+ const {expiration} = metadata
+ const formatted = DateUtils.format(moment(expiration), DATE_FORMAT_SHORT)
+ return (
+
+
+ {`Scheduled for ${formatted}`}
+
+
+
+ )
+ }
+ return
+ }
+}
diff --git a/app/internal_packages/send-later/package.json b/app/internal_packages/send-later/package.json
new file mode 100644
index 000000000..a57ca9f3b
--- /dev/null
+++ b/app/internal_packages/send-later/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "send-later",
+ "version": "1.0.0",
+ "title": "Send Later",
+ "description": "Choose to send emails at a specified time in the future.",
+ "isHiddenOnPluginsPage": true,
+ "icon": "./icon.png",
+ "main": "lib/main",
+ "supportedEnvs": ["development", "staging", "production"],
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true,
+ "thread-popout": true
+ },
+ "isOptional": true,
+ "engines": {
+ "mailspring": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages/send-later/specs/send-later-button-spec.jsx b/app/internal_packages/send-later/specs/send-later-button-spec.jsx
new file mode 100644
index 000000000..ed06dcd5b
--- /dev/null
+++ b/app/internal_packages/send-later/specs/send-later-button-spec.jsx
@@ -0,0 +1,97 @@
+/* eslint react/no-render-return-value: 0 */
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {findRenderedDOMComponentWithClass} from 'react-addons-test-utils';
+
+import {DateUtils, NylasAPIHelpers, Actions} from 'nylas-exports'
+import SendLaterButton from '../lib/send-later-button';
+import {PLUGIN_ID, PLUGIN_NAME} from '../lib/send-later-constants'
+
+const node = document.createElement('div');
+
+const makeButton = (initialState, metadataValue) => {
+ const draft = {
+ accountId: 'accountId',
+ metadataForPluginId: () => metadataValue,
+ }
+ const session = {
+ changes: {
+ add: jasmine.createSpy('add'),
+ addPluginMetadata: jasmine.createSpy('addPluginMetadata'),
+ },
+ }
+ const button = ReactDOM.render( true} />, node);
+ if (initialState) {
+ button.setState(initialState)
+ }
+ return button
+};
+
+xdescribe('SendLaterButton', function sendLaterButton() {
+ beforeEach(() => {
+ spyOn(DateUtils, 'format').andReturn('formatted')
+ });
+
+ describe('onSendLater', () => {
+ it('sets scheduled date to "saving" and adds plugin metadata to the session', () => {
+ const button = makeButton(null, {sendLaterDate: 'date'})
+ spyOn(button, 'setState')
+ spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.resolve());
+ spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
+
+ const sendLaterDate = {utc: () => 'utc'}
+ button.onSendLater(sendLaterDate)
+ advanceClock()
+
+ expect(button.setState).toHaveBeenCalledWith({saving: true})
+ expect(NylasAPIHelpers.authPlugin).toHaveBeenCalledWith(PLUGIN_ID, PLUGIN_NAME, button.props.draft.accountId)
+ expect(button.props.session.changes.addPluginMetadata).toHaveBeenCalledWith(PLUGIN_ID, {sendLaterDate})
+ });
+
+ it('displays dialog if an auth error occurs', () => {
+ const button = makeButton(null, {sendLaterDate: 'date'})
+ spyOn(button, 'setState')
+ spyOn(NylasEnv, 'reportError')
+ spyOn(NylasEnv, 'showErrorDialog')
+ spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.reject(new Error('Oh no!')))
+ spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
+ button.onSendLater({utc: () => 'utc'})
+ advanceClock()
+ expect(NylasEnv.reportError).toHaveBeenCalled()
+ expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
+ });
+
+ it('closes the composer window if a sendLaterDate has been set', () => {
+ const button = makeButton(null, {sendLaterDate: 'date'})
+ spyOn(button, 'setState')
+ spyOn(NylasEnv, 'close')
+ spyOn(NylasAPIHelpers, 'authPlugin').andReturn(Promise.resolve());
+ spyOn(NylasEnv, 'isComposerWindow').andReturn(true)
+ spyOn(Actions, 'finalizeDraftAndSyncbackMetadata')
+ button.onSendLater({utc: () => 'utc'})
+ advanceClock()
+ expect(NylasEnv.close).toHaveBeenCalled()
+ });
+ });
+
+ describe('render', () => {
+ it('renders spinner if saving', () => {
+ const button = ReactDOM.findDOMNode(makeButton({saving: true}, null))
+ expect(button.title).toEqual('Saving send date...')
+ });
+
+ it('renders date if message is scheduled', () => {
+ spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: () => '5 minutes'})
+ const button = makeButton({saving: false}, {sendLaterDate: 'date'})
+ const span = ReactDOM.findDOMNode(findRenderedDOMComponentWithClass(button, 'at'))
+ expect(span.textContent).toEqual('Sending in 5 minutes')
+ });
+
+ it('does not render date if message is not scheduled', () => {
+ const button = makeButton(null, null)
+ expect(() => {
+ findRenderedDOMComponentWithClass(button, 'at')
+ }).toThrow()
+ });
+ });
+});
diff --git a/app/internal_packages/send-later/specs/send-later-popover-spec.jsx b/app/internal_packages/send-later/specs/send-later-popover-spec.jsx
new file mode 100644
index 000000000..657f306f9
--- /dev/null
+++ b/app/internal_packages/send-later/specs/send-later-popover-spec.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import {mount} from 'enzyme'
+import SendLaterPopover from '../lib/send-later-popover';
+
+
+const makePopover = (props = {}) => {
+ return mount(
+ {}}
+ onAssignSendLaterDate={() => {}}
+ onCancelSendLater={() => {}}
+ {...props}
+ />
+ );
+};
+
+describe('SendLaterPopover', function sendLaterPopover() {
+ describe('render', () => {
+ it('renders cancel button if scheduled', () => {
+ const onCancelSendLater = jasmine.createSpy('onCancelSendLater')
+ const popover = makePopover({onCancelSendLater, sendLaterDate: 'date'})
+ const button = popover.find('.btn-cancel')
+ button.simulate('click')
+ expect(onCancelSendLater).toHaveBeenCalled()
+ });
+ });
+});
diff --git a/app/internal_packages/send-later/styles/send-later-used-modal.less b/app/internal_packages/send-later/styles/send-later-used-modal.less
new file mode 100644
index 000000000..bc36226c1
--- /dev/null
+++ b/app/internal_packages/send-later/styles/send-later-used-modal.less
@@ -0,0 +1,20 @@
+@import "ui-variables";
+
+.feature-usage-modal.send-later {
+ @send-later-color: #777ff0;
+ .feature-header {
+ @from: @send-later-color;
+ @to: lighten(@send-later-color, 10%);
+ background: linear-gradient(to top, @from, @to);
+ }
+ .feature-name {
+ color: @send-later-color;
+ }
+ .pro-description {
+ li {
+ &:before {
+ color: @send-later-color;
+ }
+ }
+ }
+}
diff --git a/app/internal_packages/send-later/styles/send-later.less b/app/internal_packages/send-later/styles/send-later.less
new file mode 100644
index 000000000..c81b84b7d
--- /dev/null
+++ b/app/internal_packages/send-later/styles/send-later.less
@@ -0,0 +1,29 @@
+@import "ui-variables";
+
+.send-later-popover {
+ .btn-cancel {
+ width: 100%;
+ }
+}
+
+.btn-send-later {
+ .at {
+ margin-left: 3px;
+ }
+}
+
+.send-later-status {
+ display: flex;
+ align-items: center;
+
+ .time {
+ font-size: 0.9em;
+ opacity: 0.62;
+ color: @component-active-color;
+ font-weight: @font-weight-normal;
+ }
+ img {
+ width: 38px;
+ margin-left: 15px;
+ }
+}
diff --git a/app/internal_packages/send-reminders/assets/ic-send-reminders-modal@2x.png b/app/internal_packages/send-reminders/assets/ic-send-reminders-modal@2x.png
new file mode 100644
index 000000000..bedf273a3
Binary files /dev/null and b/app/internal_packages/send-reminders/assets/ic-send-reminders-modal@2x.png differ
diff --git a/app/internal_packages/send-reminders/icon.png b/app/internal_packages/send-reminders/icon.png
new file mode 100644
index 000000000..91ba40be0
Binary files /dev/null and b/app/internal_packages/send-reminders/icon.png differ
diff --git a/app/internal_packages/send-reminders/lib/main.es6 b/app/internal_packages/send-reminders/lib/main.es6
new file mode 100644
index 000000000..384848910
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/main.es6
@@ -0,0 +1,37 @@
+import {ComponentRegistry, ExtensionRegistry} from 'nylas-exports';
+import {HasTutorialTip} from 'nylas-component-kit';
+import SendRemindersThreadTimestamp from './send-reminders-thread-timestamp';
+import SendRemindersComposerButton from './send-reminders-composer-button';
+import SendRemindersToolbarButton from './send-reminders-toolbar-button';
+import {ThreadHeader, MessageHeader} from './send-reminders-headers';
+import SendRemindersStore from './send-reminders-store';
+import * as ThreadListExtension from './send-reminders-thread-list-extension';
+import * as AccountSidebarExtension from './send-reminders-account-sidebar-extension';
+
+
+const ComposerButtonWithTip = HasTutorialTip(SendRemindersComposerButton, {
+ title: "Get reminded!",
+ instructions: "Get reminded if you don't receive a reply for this message within a specified time.",
+});
+
+export function activate() {
+ ComponentRegistry.register(ComposerButtonWithTip, {role: 'Composer:ActionButton'})
+ ComponentRegistry.register(SendRemindersToolbarButton, {role: 'ThreadActionsToolbarButton'});
+ ComponentRegistry.register(SendRemindersThreadTimestamp, {role: 'ThreadListTimestamp'});
+ ComponentRegistry.register(MessageHeader, {role: 'MessageHeader'});
+ ComponentRegistry.register(ThreadHeader, {role: 'MessageListHeaders'});
+ ExtensionRegistry.ThreadList.register(ThreadListExtension)
+ ExtensionRegistry.AccountSidebar.register(AccountSidebarExtension)
+ SendRemindersStore.activate()
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ComposerButtonWithTip)
+ ComponentRegistry.unregister(SendRemindersToolbarButton)
+ ComponentRegistry.unregister(SendRemindersThreadTimestamp);
+ ComponentRegistry.unregister(MessageHeader);
+ ComponentRegistry.unregister(ThreadHeader);
+ ExtensionRegistry.ThreadList.unregister(ThreadListExtension)
+ ExtensionRegistry.AccountSidebar.unregister(AccountSidebarExtension)
+ SendRemindersStore.deactivate()
+}
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-account-sidebar-extension.es6 b/app/internal_packages/send-reminders/lib/send-reminders-account-sidebar-extension.es6
new file mode 100644
index 000000000..f01685054
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-account-sidebar-extension.es6
@@ -0,0 +1,13 @@
+import SendRemindersMailboxPerspective from './send-reminders-mailbox-perspective'
+
+
+export const name = 'SendRemindersAccountSidebarExtension'
+
+export function sidebarItem(accountIds) {
+ return {
+ id: 'Reminders',
+ name: 'Reminders',
+ iconName: 'reminders.png',
+ perspective: new SendRemindersMailboxPerspective(accountIds),
+ }
+}
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-composer-button.jsx b/app/internal_packages/send-reminders/lib/send-reminders-composer-button.jsx
new file mode 100644
index 000000000..69e2374ee
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-composer-button.jsx
@@ -0,0 +1,98 @@
+import React, {Component} from 'react'
+import PropTypes from 'prop-types'
+import ReactDOM from 'react-dom'
+import {Actions} from 'nylas-exports'
+import {RetinaImg} from 'nylas-component-kit'
+import SendRemindersPopover from './send-reminders-popover'
+import {setDraftReminder, reminderDateForMessage, getReminderLabel} from './send-reminders-utils'
+
+
+class SendRemindersComposerButton extends Component {
+ static displayName = 'SendRemindersComposerButton';
+
+ static containerRequired = false;
+
+ static propTypes = {
+ draft: PropTypes.object.isRequired,
+ session: PropTypes.object.isRequired,
+ };
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ saving: false,
+ }
+ }
+
+ componentWillReceiveProps() {
+ if (this.state.saving) {
+ this.setState({saving: false})
+ }
+ }
+
+ shouldComponentUpdate(nextProps) {
+ if (reminderDateForMessage(nextProps.draft) !== reminderDateForMessage(this.props.draft)) {
+ return true;
+ }
+ return false;
+ }
+
+ onSetReminder = (reminderDate, dateLabel) => {
+ const {draft, session} = this.props
+ this.setState({saving: true})
+ setDraftReminder(draft.accountId, session, reminderDate, dateLabel)
+ }
+
+ onClick = () => {
+ const {draft} = this.props
+ const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
+ Actions.openPopover(
+ this.onSetReminder(null)}
+ />,
+ {originRect: buttonRect, direction: 'up'}
+ )
+ };
+
+ render() {
+ const {saving} = this.state
+ let className = 'btn btn-toolbar btn-send-reminder';
+
+ if (saving) {
+ return (
+
+ );
+ }
+
+ const {draft} = this.props
+ const reminderDate = reminderDateForMessage(draft);
+ let reminderLabel = 'Set reminder';
+ if (reminderDate) {
+ className += ' btn-enabled';
+ reminderLabel = getReminderLabel(reminderDate, {fromNow: true})
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default SendRemindersComposerButton
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-constants.es6 b/app/internal_packages/send-reminders/lib/send-reminders-constants.es6
new file mode 100644
index 000000000..487c51ed3
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-constants.es6
@@ -0,0 +1,4 @@
+import plugin from '../package.json'
+
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_NAME = "Send Reminders"
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-headers.jsx b/app/internal_packages/send-reminders/lib/send-reminders-headers.jsx
new file mode 100644
index 000000000..bb8ed6b91
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-headers.jsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {RetinaImg} from 'nylas-component-kit'
+import {FocusedPerspectiveStore} from 'nylas-exports'
+import {getReminderLabel, getLatestMessage, getLatestMessageWithReminder, setMessageReminder} from './send-reminders-utils'
+import {PLUGIN_ID} from './send-reminders-constants'
+
+
+export function MessageHeader(props) {
+ const {thread, messages, message} = props
+ const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
+ if (!shouldNotify) {
+ return
+ }
+ const latestMessage = getLatestMessage(thread, messages)
+ if (message.id !== latestMessage.id) {
+ return
+ }
+ return (
+
+
+
+ Reminder
+
+
+ )
+}
+MessageHeader.displayName = 'MessageHeader'
+MessageHeader.containerRequired = false
+MessageHeader.propTypes = {
+ messages: PropTypes.array,
+ message: PropTypes.object,
+ thread: PropTypes.object,
+}
+
+export function ThreadHeader(props) {
+ const {thread, messages} = props
+ const message = getLatestMessageWithReminder(thread, messages)
+ if (!message) {
+ return
+ }
+ const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
+ const clearReminder = () => {
+ setMessageReminder(message.accountId, message, null)
+ }
+ return (
+
+
+
+ {` ${getReminderLabel(expiration)}`}
+
+ Cancel
+
+ )
+}
+ThreadHeader.displayName = 'ThreadHeader'
+ThreadHeader.containerRequired = false
+ThreadHeader.propTypes = {
+ thread: PropTypes.object,
+ messages: PropTypes.array,
+}
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-mailbox-perspective.es6 b/app/internal_packages/send-reminders/lib/send-reminders-mailbox-perspective.es6
new file mode 100644
index 000000000..70cce57cd
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-mailbox-perspective.es6
@@ -0,0 +1,46 @@
+import {
+ MailboxPerspective,
+} from 'nylas-exports'
+import SendRemindersQuerySubscription from './send-reminders-query-subscription'
+
+
+class SendRemindersMailboxPerspective extends MailboxPerspective {
+
+ constructor(accountIds) {
+ super(accountIds)
+ this.accountIds = accountIds
+ this.name = 'Reminders'
+ this.iconName = 'reminders.png'
+ }
+
+ get isReminders() {
+ return true
+ }
+
+ emptyMessage() {
+ return "No reminders set"
+ }
+
+ threads() {
+ return new SendRemindersQuerySubscription(this.accountIds)
+ }
+
+ canReceiveThreadsFromAccountIds() {
+ return false
+ }
+
+ canArchiveThreads() {
+ return false
+ }
+
+ canTrashThreads() {
+ return false
+ }
+
+ canMoveThreadsTo() {
+ return false
+ }
+
+}
+
+export default SendRemindersMailboxPerspective
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-popover-button.jsx b/app/internal_packages/send-reminders/lib/send-reminders-popover-button.jsx
new file mode 100644
index 000000000..568963ed5
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-popover-button.jsx
@@ -0,0 +1,84 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import ReactDOM from 'react-dom';
+import {Rx, Actions, Message, DatabaseStore} from 'nylas-exports';
+import {RetinaImg, ListensToObservable} from 'nylas-component-kit';
+import SendRemindersPopover from './send-reminders-popover';
+import {getLatestMessage, setMessageReminder, reminderDateForMessage} from './send-reminders-utils'
+
+
+function getMessageObservable({thread} = {}) {
+ if (!thread) { return Rx.Observable.empty() }
+ const latestMessage = getLatestMessage(thread) || {}
+ const query = DatabaseStore.find(Message, latestMessage.id)
+ return Rx.Observable.fromQuery(query)
+}
+
+function getStateFromObservable(message, {props}) {
+ const {thread} = props
+ const latestMessage = message || getLatestMessage(thread)
+ return {latestMessage}
+}
+
+
+class SendRemindersPopoverButton extends Component {
+ static displayName = 'SendRemindersPopoverButton';
+
+ static propTypes = {
+ className: PropTypes.string,
+ thread: PropTypes.object,
+ latestMessage: PropTypes.object,
+ direction: PropTypes.string,
+ getBoundingClientRect: PropTypes.func,
+ };
+
+ static defaultProps = {
+ className: 'btn btn-toolbar',
+ direction: 'down',
+ getBoundingClientRect: (inst) => ReactDOM.findDOMNode(inst).getBoundingClientRect(),
+ };
+
+ onSetReminder = (reminderDate, dateLabel) => {
+ const {latestMessage, thread} = this.props
+ setMessageReminder(latestMessage.accountId, latestMessage, reminderDate, dateLabel, thread)
+ }
+
+ onClick = (event) => {
+ event.stopPropagation()
+ const {direction, latestMessage, getBoundingClientRect} = this.props
+ const reminderDate = reminderDateForMessage(latestMessage)
+ const buttonRect = getBoundingClientRect(this)
+ Actions.openPopover(
+ this.onSetReminder(null)}
+ />,
+ {originRect: buttonRect, direction}
+ )
+ };
+
+ render() {
+ const {className, latestMessage} = this.props
+ const reminderDate = reminderDateForMessage(latestMessage)
+ const title = reminderDate ? 'Edit reminder' : 'Set reminder';
+ return (
+
+ );
+ }
+}
+
+export default ListensToObservable(SendRemindersPopoverButton, {
+ getObservable: getMessageObservable,
+ getStateFromObservable,
+})
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-popover.jsx b/app/internal_packages/send-reminders/lib/send-reminders-popover.jsx
new file mode 100644
index 000000000..df42d82c5
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-popover.jsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {DateUtils} from 'nylas-exports'
+import {DatePickerPopover} from 'nylas-component-kit'
+import {getReminderLabel} from './send-reminders-utils'
+
+
+const SendRemindersOptions = {
+ 'In 1 hour': DateUtils.in1Hour,
+ 'In 2 hours': DateUtils.in2Hours,
+ 'In 4 hours': () => DateUtils.minutesFromNow(240),
+ 'Tomorrow morning': DateUtils.tomorrow,
+ 'Tomorrow evening': DateUtils.tomorrowEvening,
+ 'In 2 days': () => DateUtils.hoursFromNow(48),
+ 'In 4 days': () => DateUtils.hoursFromNow(96),
+ 'In 1 week': () => DateUtils.weeksFromNow(1),
+ 'In 2 weeks': () => DateUtils.weeksFromNow(2),
+ 'In 1 month': () => DateUtils.monthsFromNow(1),
+}
+
+function SendRemindersPopover(props) {
+ const {reminderDate, onRemind, onCancelReminder} = props
+ const header = Remind me if no one replies:
+ const footer = [
+ reminderDate ? : null,
+ reminderDate ?
+
+
+
+ This thread will come back to the top of your inbox if nobody replies by:
+
+ {` ${getReminderLabel(reminderDate)}`}
+
+
+
+
+
:
+ null,
+ ]
+
+ return (
+
+ );
+}
+SendRemindersPopover.displayName = 'SendRemindersPopover';
+
+SendRemindersPopover.propTypes = {
+ reminderDate: PropTypes.string,
+ onRemind: PropTypes.func,
+ onCancelReminder: PropTypes.func,
+};
+
+
+export default SendRemindersPopover
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-query-subscription.es6 b/app/internal_packages/send-reminders/lib/send-reminders-query-subscription.es6
new file mode 100644
index 000000000..464a185ad
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-query-subscription.es6
@@ -0,0 +1,42 @@
+import {
+ Thread,
+ DatabaseStore,
+ MutableQuerySubscription,
+} from 'nylas-exports'
+import {observableForThreadsWithReminders} from './send-reminders-utils'
+
+
+class SendRemindersQuerySubscription extends MutableQuerySubscription {
+
+ constructor(accountIds) {
+ super(null, {emitResultSet: true})
+ this._disposable = null
+ this._accountIds = accountIds
+ setImmediate(() => this.fetchThreadsWithReminders())
+ }
+
+ replaceRange = () => {
+ // TODO
+ }
+
+ fetchThreadsWithReminders() {
+ this._disposable = observableForThreadsWithReminders(this._accountIds, {emitIds: true})
+ .subscribe((threadIds) => {
+ const threadQuery = (
+ DatabaseStore.findAll(Thread)
+ .where({id: threadIds})
+ .order(Thread.attributes.lastMessageReceivedTimestamp.descending())
+ )
+ this.replaceQuery(threadQuery)
+ })
+ }
+
+ onLastCallbackRemoved() {
+ if (this._disposable) {
+ this._disposable.dispose()
+ }
+ }
+}
+
+export default SendRemindersQuerySubscription
+
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-store.es6 b/app/internal_packages/send-reminders/lib/send-reminders-store.es6
new file mode 100644
index 000000000..925a8b6e5
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-store.es6
@@ -0,0 +1,77 @@
+import {Actions, FocusedContentStore, SyncbackMetadataTask} from 'nylas-exports'
+import NylasStore from 'nylas-store';
+import {PLUGIN_ID} from './send-reminders-constants'
+import {
+ getLatestMessage,
+ setMessageReminder,
+ getLatestMessageWithReminder,
+ asyncUpdateFromSentMessage,
+ observableForThreadsWithReminders,
+} from './send-reminders-utils'
+
+
+class SendRemindersStore extends NylasStore {
+ constructor() {
+ super();
+ this._lastFocusedThread = null;
+ }
+
+ activate() {
+ this._unsubscribers = [
+ FocusedContentStore.listen(this._onFocusedContentChanged),
+ Actions.draftDeliverySucceeded.listen(this._onDraftDeliverySucceeded),
+ ]
+ this._disposables = [
+ observableForThreadsWithReminders().subscribe(this._onThreadsWithRemindersChanged),
+ ]
+ }
+
+ deactivate() {
+ this._unsubscribers.forEach((unsub) => unsub())
+ this._disposables.forEach((disp) => disp.dispose())
+ }
+
+ _onDraftDeliverySucceeded = ({headerMessageId}) => {
+ asyncUpdateFromSentMessage({headerMessageId})
+ }
+
+ _onFocusedContentChanged = () => {
+ const thread = FocusedContentStore.focused('thread') || null
+ const didUnfocusLastThread = (
+ (!thread && this._lastFocusedThread) ||
+ (thread && this._lastFocusedThread && thread.id !== this._lastFocusedThread.id)
+ )
+ // When we unfocus a thread that had `shouldNotify == true`, it means that
+ // we have acknowledged the notification, or in this case, the reminder. If
+ // that's the case, set `shouldNotify` to false.
+ if (didUnfocusLastThread) {
+ const {shouldNotify} = this._lastFocusedThread.metadataForPluginId(PLUGIN_ID) || {}
+ if (shouldNotify) {
+ Actions.queueTask(new SyncbackMetadataTask({
+ model: this._lastFocusedThread,
+ accountId: this._lastFocusedThread.accountId,
+ pluginId: PLUGIN_ID,
+ value: {shouldNotify: false},
+ }));
+ }
+ }
+ this._lastFocusedThread = thread
+ }
+
+ _onThreadsWithRemindersChanged = (threads) => {
+ // If a new message was received on the thread, clear the reminder
+ threads.forEach((thread) => {
+ const {accountId} = thread
+ thread.messages().then((messages) => {
+ const latestMessage = getLatestMessage(thread, messages)
+ const latestMessageWithReminder = getLatestMessageWithReminder(thread, messages)
+ if (!latestMessageWithReminder) { return }
+ if (latestMessage.id !== latestMessageWithReminder.id) {
+ setMessageReminder(accountId, latestMessageWithReminder, null)
+ }
+ })
+ })
+ }
+}
+
+export default new SendRemindersStore()
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-thread-list-extension.es6 b/app/internal_packages/send-reminders/lib/send-reminders-thread-list-extension.es6
new file mode 100644
index 000000000..a9f6f6aaf
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-thread-list-extension.es6
@@ -0,0 +1,23 @@
+import {PLUGIN_ID} from './send-reminders-constants'
+import {getLatestMessageWithReminder} from './send-reminders-utils'
+
+export const name = 'SendRemindersThreadListExtension'
+
+export function cssClassNamesForThreadListItem(thread) {
+ const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
+ if (shouldNotify) {
+ return 'thread-list-reminder-item'
+ }
+ return ''
+}
+
+export function cssClassNamesForThreadListIcon(thread) {
+ const {shouldNotify} = thread.metadataForPluginId(PLUGIN_ID) || {}
+ if (shouldNotify) {
+ return 'thread-icon-reminder-triggered'
+ }
+ if (getLatestMessageWithReminder(thread)) {
+ return 'thread-icon-reminder-pending'
+ }
+ return ''
+}
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-thread-timestamp.jsx b/app/internal_packages/send-reminders/lib/send-reminders-thread-timestamp.jsx
new file mode 100644
index 000000000..c632ae0b4
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-thread-timestamp.jsx
@@ -0,0 +1,105 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types'
+import {RetinaImg} from 'nylas-component-kit';
+import {Rx, Message, DatabaseStore, FocusedPerspectiveStore} from 'nylas-exports';
+import {getReminderLabel, getLatestMessageWithReminder, setMessageReminder} from './send-reminders-utils'
+import {PLUGIN_ID} from './send-reminders-constants';
+
+
+function canRenderTimestamp(message) {
+ const current = FocusedPerspectiveStore.current()
+ if (!current.isReminders) {
+ return false
+ }
+ if (!message) {
+ return false
+ }
+ return true
+}
+
+class SendRemindersThreadTimestamp extends Component {
+ static displayName = 'SendRemindersThreadTimestamp';
+
+ static propTypes = {
+ thread: PropTypes.object,
+ messages: PropTypes.array,
+ fallback: PropTypes.func,
+ };
+
+ static containerRequired = false;
+
+ constructor(props) {
+ super(props)
+ this._disposable = null
+ this.state = {
+ message: getLatestMessageWithReminder(props.thread, props.messages),
+ }
+ }
+
+ componentDidMount() {
+ const {message} = this.state
+ this.setupMessageObservable(message)
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const {thread, messages} = nextProps
+ const message = getLatestMessageWithReminder(thread, messages)
+ this.disposeMessageObservable()
+ if (!message) {
+ this.setState({message})
+ } else {
+ this.setupMessageObservable(message)
+ }
+ }
+
+ componentWillUnmount() {
+ this.disposeMessageObservable()
+ }
+
+ onRemoveReminder(message) {
+ setMessageReminder(message.accountId, message, null)
+ }
+
+ setupMessageObservable(message) {
+ if (!canRenderTimestamp(message)) { return }
+ const message$ = Rx.Observable.fromQuery(DatabaseStore.find(Message, message.id))
+ this._disposable = message$.subscribe((msg) => {
+ const {expiration} = msg.metadataForPluginId(PLUGIN_ID) || {};
+ if (!expiration) {
+ this.setState({message: null})
+ } else {
+ this.setState({message: msg})
+ }
+ })
+ }
+
+ disposeMessageObservable() {
+ if (this._disposable) {
+ this._disposable.dispose()
+ }
+ }
+
+ render() {
+ const {message} = this.state;
+ const Fallback = this.props.fallback;
+ if (!canRenderTimestamp(message)) {
+ return
+ }
+ const {expiration} = message.metadataForPluginId(PLUGIN_ID);
+ const title = getReminderLabel(expiration, {fromNow: true})
+ const shortLabel = getReminderLabel(expiration, {shortFormat: true})
+ return (
+
+
+
+ {shortLabel}
+
+
+ )
+ }
+}
+
+export default SendRemindersThreadTimestamp
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-toolbar-button.jsx b/app/internal_packages/send-reminders/lib/send-reminders-toolbar-button.jsx
new file mode 100644
index 000000000..dee1e84ba
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-toolbar-button.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types'
+import {HasTutorialTip} from 'nylas-component-kit';
+import {getLatestMessage} from './send-reminders-utils'
+import SendRemindersPopoverButton from './send-reminders-popover-button';
+
+const SendRemindersPopoverButtonWithTip = HasTutorialTip(SendRemindersPopoverButton, {
+ title: "Get reminded!",
+ instructions: "Get reminded if you don't receive a reply for this message within a specified time.",
+});
+
+function canSetReminderOnThread(thread) {
+ const {from} = getLatestMessage(thread) || {}
+ return (
+ from && from.length > 0 && from[0].isMe()
+ )
+}
+
+export default function SendRemindersToolbarButton(props) {
+ const threads = props.items
+ if (threads.length > 1) {
+ return ;
+ }
+ const thread = threads[0]
+ if (!canSetReminderOnThread(thread)) {
+ return ;
+ }
+ return (
+
+ );
+}
+
+SendRemindersToolbarButton.containerRequired = false;
+SendRemindersToolbarButton.displayName = 'SendRemindersToolbarButton';
+SendRemindersToolbarButton.propTypes = {
+ items: PropTypes.array,
+};
diff --git a/app/internal_packages/send-reminders/lib/send-reminders-utils.jsx b/app/internal_packages/send-reminders/lib/send-reminders-utils.jsx
new file mode 100644
index 000000000..8b0c8a7d8
--- /dev/null
+++ b/app/internal_packages/send-reminders/lib/send-reminders-utils.jsx
@@ -0,0 +1,205 @@
+import moment from 'moment'
+import {
+ Rx,
+ Thread,
+ Message,
+ Actions,
+ Folder,
+ NylasAPIHelpers,
+ DateUtils,
+ DatabaseStore,
+ FeatureUsageStore,
+ SyncbackMetadataTask,
+} from 'nylas-exports'
+import {PLUGIN_ID, PLUGIN_NAME} from './send-reminders-constants'
+
+
+const {DATE_FORMAT_LONG_NO_YEAR} = DateUtils
+
+export function reminderDateForMessage(message) {
+ if (!message) {
+ return null;
+ }
+ const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {};
+ return messageMetadata.expiration;
+}
+
+async function asyncBuildMetadata({message, thread, expiration} = {}) {
+ if (!message) {
+ // this is a draft. We can't finalize the metadata because the message
+ // may not be attached to the thread sync will place it on.
+ return {
+ expiration,
+ };
+ }
+
+ let headerMessageIds = [message.headerMessageId];
+ let folderPaths = [];
+
+ // There won't be a thread if this is a newly sent draft that wasn't a reply.
+ if (thread) {
+ // We need to include the hidden messages so the cloud-worker doesn't think
+ // that previously hidden messages are new replies to the thread.
+ const messages = await thread.messages({includeHidden: true})
+ headerMessageIds = messages.map(msg => msg.headerMessageId)
+ folderPaths = thread.folders.map(f => f.path)
+ }
+
+ let primary = await DatabaseStore.findBy(Folder, {role: 'all', accountId: message.accountId})
+ primary = primary || await DatabaseStore.findBy(Folder, {role: 'inbox', accountId: message.accountId})
+ if (primary) {
+ folderPaths.unshift(primary.path); // Put it at the front so we check it first
+ }
+
+ return {
+ expiration,
+ folderPaths,
+ headerMessageIds,
+ replyTo: message.headerMessageId,
+ subject: message.subject,
+ }
+}
+
+export async function asyncUpdateFromSentMessage({headerMessageId}) {
+ const message = await DatabaseStore.findBy(Message, {headerMessageId})
+ if (!message) {
+ throw new Error("SendReminders: Could not find message to update")
+ }
+ const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
+ if (!expiration) {
+ // This message doesn't have a reminder
+ return;
+ }
+
+ const thread = message.threadId && await DatabaseStore.find(Thread, message.threadId);
+ // thread may not exist if this message wasn't a reply and doesn't have a thread yet
+
+ Actions.queueTask(new SyncbackMetadataTask({
+ value: await asyncBuildMetadata({message, thread, expiration}),
+ model: message,
+ accountId: message.accountId,
+ pluginId: PLUGIN_ID,
+ }));
+}
+
+async function asyncSetReminder(accountId, reminderDate, dateLabel, {message, thread, isDraft, draftSession} = {}) {
+ // Only check for feature usage and record metrics if this message doesn't
+ // already have a reminder set
+ if (!reminderDateForMessage(message)) {
+ const lexicon = {
+ displayName: "be Reminded",
+ usedUpHeader: "All reminders used",
+ iconUrl: "mailspring://send-reminders/assets/ic-send-reminders-modal@2x.png",
+ }
+
+ try {
+ await FeatureUsageStore.asyncUseFeature('send-reminders', {lexicon})
+ } catch (error) {
+ if (error instanceof FeatureUsageStore.NoProAccessError) {
+ return
+ }
+ }
+
+ if (reminderDate && dateLabel) {
+ const remindInSec = Math.round(((new Date(reminderDate)).valueOf() - Date.now()) / 1000)
+ Actions.recordUserEvent("Set Reminder", {
+ timeInSec: remindInSec,
+ timeInLog10Sec: Math.log10(remindInSec),
+ label: dateLabel,
+ });
+ }
+ }
+
+ let metadata = {}
+ if (reminderDate) {
+ metadata = await asyncBuildMetadata({message, thread, expiration: reminderDate})
+ } // else: we're clearing the reminder and the metadata should remain empty
+
+ await NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, accountId);
+
+ try {
+ if (isDraft) {
+ if (!draftSession) { throw new Error('setDraftReminder: Must provide draftSession') }
+ draftSession.changes.add({pristine: false})
+ draftSession.changes.addPluginMetadata(PLUGIN_ID, metadata);
+ } else {
+ if (!message) { throw new Error('setMessageReminder: Must provide message') }
+ Actions.queueTask(new SyncbackMetadataTask({
+ model: message,
+ accountId: message.accountId,
+ pluginId: PLUGIN_ID,
+ value: metadata,
+ }));
+ }
+ } catch (error) {
+ NylasEnv.reportError(error);
+ NylasEnv.showErrorDialog(`Sorry, we were unable to save the reminder for this message. ${error.message}`);
+ } finally {
+ Actions.closePopover()
+ }
+}
+
+export function setMessageReminder(accountId, message, reminderDate, dateLabel, thread) {
+ return asyncSetReminder(accountId, reminderDate, dateLabel, {isDraft: false, message, thread})
+}
+
+export function setDraftReminder(accountId, draftSession, reminderDate, dateLabel) {
+ return asyncSetReminder(accountId, reminderDate, dateLabel, {isDraft: true, draftSession})
+}
+
+
+function reminderThreadIdsFromMessages(messages) {
+ return Array.from(new Set(
+ messages
+ .filter((message) => (message.metadataForPluginId(PLUGIN_ID) || {}).expiration != null)
+ .map(({threadId}) => threadId)
+ .filter((threadId) => threadId != null)
+ ))
+}
+
+export function observableForThreadsWithReminders(accountIds = [], {emitIds = false} = {}) {
+ let messagesQuery = (
+ DatabaseStore.findAll(Message)
+ .where(Message.attributes.pluginMetadata.contains(PLUGIN_ID))
+ )
+ if (accountIds.length === 1) {
+ messagesQuery = messagesQuery.where({accountId: accountIds[0]})
+ }
+ const messages$ = Rx.Observable.fromQuery(messagesQuery)
+ if (emitIds) {
+ return messages$.map((messages) => reminderThreadIdsFromMessages(messages))
+ }
+ return messages$.flatMapLatest((messages) => {
+ const threadIds = reminderThreadIdsFromMessages(messages)
+ const threadsQuery = (
+ DatabaseStore.findAll(Thread)
+ .where({id: threadIds})
+ .order(Thread.attributes.lastMessageReceivedTimestamp.descending())
+ )
+ return Rx.Observable.fromQuery(threadsQuery)
+ })
+}
+
+export function getLatestMessage(thread, messages) {
+ const msgs = messages || thread.__messages || [];
+ return msgs[msgs.length - 1]
+}
+
+export function getLatestMessageWithReminder(thread, messages) {
+ const msgs = (messages || thread.__messages || []).slice().reverse();
+ return msgs.find((message) => {
+ const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
+ return expiration != null
+ })
+}
+
+export function getReminderLabel(reminderDate, {fromNow = false, shortFormat = false} = {}) {
+ const momentDate = DateUtils.futureDateFromString(reminderDate);
+ if (shortFormat) {
+ return momentDate ? `in ${momentDate.fromNow(true)}` : 'now'
+ }
+ if (fromNow) {
+ return momentDate ? `Reminder set for ${momentDate.fromNow(true)} from now` : `Reminder set`;
+ }
+ return moment(reminderDate).format(DATE_FORMAT_LONG_NO_YEAR)
+}
diff --git a/app/internal_packages/send-reminders/package.json b/app/internal_packages/send-reminders/package.json
new file mode 100644
index 000000000..f0477af41
--- /dev/null
+++ b/app/internal_packages/send-reminders/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "send-reminders",
+ "version": "1.0.0",
+ "title": "Send Reminders",
+ "description": "Get reminded if you don't receive a reply for a message within a specified time in the future",
+ "isHiddenOnPluginsPage": true,
+ "icon": "./icon.png",
+ "main": "lib/main",
+ "supportedEnvs": ["development", "staging", "production"],
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "windowTypes": {
+ "default": true,
+ "composer": true
+ },
+ "isOptional": true,
+ "engines": {
+ "mailspring": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages/send-reminders/styles/reminders-used-modal.less b/app/internal_packages/send-reminders/styles/reminders-used-modal.less
new file mode 100644
index 000000000..97260f2a3
--- /dev/null
+++ b/app/internal_packages/send-reminders/styles/reminders-used-modal.less
@@ -0,0 +1,20 @@
+@import "ui-variables";
+
+.feature-usage-modal.send-reminders {
+ @send-reminders-color: #517ff2;
+ .feature-header {
+ @from: @send-reminders-color;
+ @to: lighten(@send-reminders-color, 10%);
+ background: linear-gradient(to top, @from, @to);
+ }
+ .feature-name {
+ color: @send-reminders-color;
+ }
+ .pro-description {
+ li {
+ &:before {
+ color: @send-reminders-color;
+ }
+ }
+ }
+}
diff --git a/app/internal_packages/send-reminders/styles/send-reminders.less b/app/internal_packages/send-reminders/styles/send-reminders.less
new file mode 100644
index 000000000..5d8f1fc6f
--- /dev/null
+++ b/app/internal_packages/send-reminders/styles/send-reminders.less
@@ -0,0 +1,101 @@
+@import "ui-variables";
+@reminders-background-color: #f6f6fe;
+@reminders-color: #5a31e1;
+
+.send-reminders-popover {
+ .section.send-reminders-footer {
+ .reminders-label {
+ color: @text-color-very-subtle;
+ font-size: 0.8em;
+ min-height: 36px;
+ .reminder-date {
+ font-weight: bold;
+ }
+ }
+
+ .btn-cancel {
+ width: 100%;
+ margin-top: 5px;
+ }
+ }
+}
+
+.send-reminders-toolbar-button {
+ order: -102;
+}
+
+.thread-list {
+ .list-item.focused {
+ .timestamp.send-reminders-thread-timestamp {
+ color: #fff;
+ img {
+ background-color: #fff;
+ }
+ }
+ }
+
+ .timestamp.send-reminders-thread-timestamp {
+ opacity: 0.82;
+ color: #979797;
+ img {
+ background-color: #979797;
+ margin-top: -3px;
+ padding-right: 6px;
+ }
+ }
+}
+
+.send-reminders-header {
+ color: @reminders-color;
+ background: linear-gradient(to bottom, #fbfafe 0%, #fff 25%);
+ padding-top: 13px;
+ padding-bottom: 10px;
+ cursor: default;
+ margin-top: -19px;
+ margin-bottom: 15px;
+ border-bottom: 1px solid #e7e1fb;
+ img {
+ background-color: @reminders-color;
+ margin-top: -6px;
+ margin-right: 10px;
+ }
+}
+.message-list-headers .send-reminders-header {
+ padding-left: 15px;
+ margin-top: 0;
+ .reminder-date {
+ font-weight: bold;
+ }
+ .clear-reminder {
+ position: absolute;
+ right: 18px;
+ cursor: pointer;
+ text-decoration: underline;
+ }
+}
+
+.thread-list .list-item.thread-list-reminder-item {
+ background-color: @reminders-background-color;
+
+ &.unread {
+ background-color: @reminders-background-color;
+ &:not(.focused):not(.selected) {
+ background-color: @reminders-background-color;
+ }
+ }
+
+ &.focused,&.selected {
+ background: @list-focused-bg;
+ }
+ .thread-icon-reminder-triggered {
+ margin-top: 1px;
+ background-image:url(../static/images/thread-list/icon-reminder@2x.png);
+ }
+}
+
+.thread-list .list-item {
+ .thread-icon-reminder-pending {
+ margin-top: 1px;
+ background-image:url(../static/images/thread-list/icon-reminder-outline@2x.png);
+ }
+}
diff --git a/app/internal_packages_disabled/thread-sharing/lib/copy-button.jsx b/app/internal_packages_disabled/thread-sharing/lib/copy-button.jsx
new file mode 100644
index 000000000..6392dbd5b
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/copy-button.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import {Utils} from 'nylas-exports';
+import {clipboard} from 'electron';
+
+
+class CopyButton extends React.Component {
+
+ static propTypes = {
+ btnLabel: React.PropTypes.string,
+ copyValue: React.PropTypes.string,
+ }
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ btnLabel: props.btnLabel,
+ }
+ this._timeout = null
+ }
+
+ componentWillReceiveProps(nextProps) {
+ clearTimeout(this._timeout)
+ this._timeout = null
+ this.setState({btnLabel: nextProps.btnLabel})
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this._timeout)
+ }
+
+ _onCopy = () => {
+ if (this._timeout) { return }
+ const {copyValue, btnLabel} = this.props
+ clipboard.writeText(copyValue)
+ this.setState({btnLabel: 'Copied!'})
+ this._timeout = setTimeout(() => {
+ this._timeout = null
+ this.setState({btnLabel: btnLabel})
+ }, 2000)
+ }
+
+ render() {
+ const {btnLabel} = this.state
+ const otherProps = Utils.fastOmit(this.props, Object.keys(CopyButton.propTypes));
+ return (
+
+ )
+ }
+}
+export default CopyButton
diff --git a/app/internal_packages_disabled/thread-sharing/lib/external-threads.es6 b/app/internal_packages_disabled/thread-sharing/lib/external-threads.es6
new file mode 100644
index 000000000..66e3f37e4
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/external-threads.es6
@@ -0,0 +1,56 @@
+import url from 'url'
+import querystring from 'querystring';
+import {ipcRenderer} from 'electron';
+import {DatabaseStore, Thread, Matcher, Actions} from "nylas-exports";
+
+
+const DATE_EPSILON = 60 // Seconds
+
+const parseOpenThreadUrl = (nylasUrlString) => {
+ const parsedUrl = url.parse(nylasUrlString)
+ const params = querystring.parse(parsedUrl.query)
+ params.lastDate = parseInt(params.lastDate, 10)
+ return params;
+}
+
+const findCorrespondingThread = ({subject, lastDate}, dateEpsilon = DATE_EPSILON) => {
+ return DatabaseStore.findBy(Thread).where([
+ Thread.attributes.subject.equal(subject),
+ new Matcher.Or([
+ new Matcher.And([
+ Thread.attributes.lastMessageSentTimestamp.lessThan(lastDate + dateEpsilon),
+ Thread.attributes.lastMessageSentTimestamp.greaterThan(lastDate - dateEpsilon),
+ ]),
+ new Matcher.And([
+ Thread.attributes.lastMessageReceivedTimestamp.lessThan(lastDate + dateEpsilon),
+ Thread.attributes.lastMessageReceivedTimestamp.greaterThan(lastDate - dateEpsilon),
+ ]),
+ ]),
+ ])
+}
+
+const _openExternalThread = (event, nylasUrl) => {
+ const {subject, lastDate} = parseOpenThreadUrl(nylasUrl);
+
+ findCorrespondingThread({subject, lastDate})
+ .then((thread) => {
+ if (!thread) {
+ throw new Error('Thread not found')
+ }
+ Actions.popoutThread(thread);
+ })
+ .catch((error) => {
+ NylasEnv.reportError(error)
+ NylasEnv.showErrorDialog(`The thread ${subject} does not exist in your mailbox!`)
+ })
+}
+
+const activate = () => {
+ ipcRenderer.on('openExternalThread', _openExternalThread)
+}
+
+const deactivate = () => {
+ ipcRenderer.removeListener('openExternalThread', _openExternalThread)
+}
+
+export default {activate, deactivate}
diff --git a/app/internal_packages_disabled/thread-sharing/lib/main.es6 b/app/internal_packages_disabled/thread-sharing/lib/main.es6
new file mode 100644
index 000000000..938103696
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/main.es6
@@ -0,0 +1,15 @@
+import {ComponentRegistry} from 'nylas-exports';
+import ThreadSharingButton from "./thread-sharing-button";
+import ExternalThreads from "./external-threads"
+
+export function activate() {
+ ComponentRegistry.register(ThreadSharingButton, {
+ role: 'ThreadActionsToolbarButton',
+ });
+ ExternalThreads.activate();
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(ThreadSharingButton);
+ ExternalThreads.deactivate();
+}
diff --git a/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-button.jsx b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-button.jsx
new file mode 100644
index 000000000..ea92e2db0
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-button.jsx
@@ -0,0 +1,56 @@
+import {Actions, React, ReactDOM} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+import ThreadSharingPopover from './thread-sharing-popover';
+
+export default class ThreadSharingButton extends React.Component {
+ static displayName = 'ThreadSharingButton';
+
+ static containerRequired = false;
+
+ static propTypes = {
+ items: React.PropTypes.array,
+ thread: React.PropTypes.object,
+ };
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.thread.id !== this.props.thread.id) {
+ Actions.closePopover()
+ }
+ }
+
+ _onClick = () => {
+ const {thread} = this.props;
+
+ Actions.openPopover(
+ ,
+ {
+ originRect: ReactDOM.findDOMNode(this).getBoundingClientRect(),
+ direction: 'down',
+ }
+ )
+ }
+
+ render() {
+ if (this.props.items && this.props.items.length > 1) {
+ return
+ }
+
+ return (
+
+ )
+ }
+}
diff --git a/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-constants.es6 b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-constants.es6
new file mode 100644
index 000000000..fd9c0ecee
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-constants.es6
@@ -0,0 +1,5 @@
+import plugin from '../package.json'
+
+export const PLUGIN_NAME = plugin.title
+export const PLUGIN_ID = plugin.name;
+export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
diff --git a/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-popover.jsx b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-popover.jsx
new file mode 100644
index 000000000..493f1ac7c
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/lib/thread-sharing-popover.jsx
@@ -0,0 +1,142 @@
+/* eslint jsx-a11y/tabindex-no-positive: 0 */
+import React from 'react'
+import ReactDOM from 'react-dom'
+import classnames from 'classnames';
+import {Rx, Actions, NylasAPIHelpers, Thread, DatabaseStore, SyncbackMetadataTask} from 'nylas-exports';
+import {RetinaImg} from 'nylas-component-kit';
+
+import CopyButton from './copy-button';
+import {PLUGIN_ID, PLUGIN_NAME, PLUGIN_URL} from './thread-sharing-constants';
+
+
+function isShared(thread) {
+ const metadata = thread.metadataForPluginId(PLUGIN_ID) || {};
+ return metadata.shared || false;
+}
+
+export default class ThreadSharingPopover extends React.Component {
+ static propTypes = {
+ thread: React.PropTypes.object,
+ accountId: React.PropTypes.string,
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ shared: isShared(props.thread),
+ saving: false,
+ }
+ this._disposable = {dispose: () => {}}
+ }
+
+ componentDidMount() {
+ const {thread} = this.props;
+ this._mounted = true;
+ this._disposable = Rx.Observable.fromQuery(DatabaseStore.find(Thread, thread.id))
+ .subscribe((t) => this.setState({shared: isShared(t)}))
+ }
+
+ componentDidUpdate() {
+ ReactDOM.findDOMNode(this).focus()
+ }
+
+ componentWillUnmount() {
+ this._disposable.dispose();
+ this._mounted = false;
+ }
+
+ _onToggleShared = async () => {
+ const {thread, accountId} = this.props;
+ const {shared} = this.state;
+
+ this.setState({saving: true});
+
+ try {
+ await NylasAPIHelpers.authPlugin(PLUGIN_ID, PLUGIN_NAME, accountId)
+ if (!this._mounted) { return; }
+
+ if (!shared === true) {
+ Actions.recordUserEvent("Thread Sharing Enabled", {accountId, threadId: thread.id})
+ }
+ Actions.queueTask(new SyncbackMetadataTask({
+ model: thread,
+ accountId: thread.accountId,
+ pluginId: PLUGIN_ID,
+ value: {shared: !shared},
+ }));
+ } catch (error) {
+ NylasEnv.reportError(error);
+ NylasEnv.showErrorDialog(`Sorry, we were unable to update your sharing settings.\n\n${error.message}`)
+ }
+
+ if (!this._mounted) { return; }
+ this.setState({saving: false})
+ }
+
+ _onClickInput = (event) => {
+ const input = event.target
+ input.select()
+ }
+
+ render() {
+ const {thread, accountId} = this.props;
+ const {shared, saving} = this.state;
+
+ const url = `${PLUGIN_URL}/thread/${accountId}/${thread.id}`
+ const shareMessage = shared ? 'Anyone with the link can read the thread' : 'Sharing is disabled';
+ const classes = classnames({
+ 'thread-sharing-popover': true,
+ 'disabled': !shared,
+ })
+
+ const control = saving ? (
+
+ ) : (
+
+ );
+
+ // tabIndex is necessary for the popover's onBlur events to work properly
+ return (
+
+
+
+
+
+
+
+
+
{shareMessage}
+
+
+
+
+ )
+ }
+}
diff --git a/app/internal_packages_disabled/thread-sharing/package.json b/app/internal_packages_disabled/thread-sharing/package.json
new file mode 100644
index 000000000..53c6cd7e0
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "thread-sharing",
+ "version": "0.1.0",
+ "serverUrl": {
+ "development": "http://localhost:5100",
+ "staging": "https://share-staging.getmailspring.com",
+ "production": "https://share.getmailspring.com"
+ },
+
+ "title": "Thread Sharing",
+ "description": "Share a thread through the web.",
+ "main": "./lib/main",
+ "private": true,
+ "engines": {
+ "mailspring": "*"
+ },
+ "license": "GPL-3.0"
+}
diff --git a/app/internal_packages_disabled/thread-sharing/styles/main.less b/app/internal_packages_disabled/thread-sharing/styles/main.less
new file mode 100644
index 000000000..948772175
--- /dev/null
+++ b/app/internal_packages_disabled/thread-sharing/styles/main.less
@@ -0,0 +1,51 @@
+@import "ui-variables";
+
+.thread-sharing-button {
+ order: -102;
+}
+
+.fixed-popover .thread-sharing-popover {
+ position: relative;
+ width: 265px;
+
+ .share-toggle {
+ padding: 7px 10px;
+ input {
+ margin-right: @spacing-half;
+ }
+ }
+
+ .share-input {
+ border-top: 2px solid rgba(0, 0, 0, 0.15);
+ padding: 10px 5px 0 5px;
+ input[type="text"] {
+ cursor: default;
+ -webkit-user-select: all;
+ }
+ }
+
+ .share-controls {
+ text-align: center;
+ padding: 10px;
+
+ .share-message {
+ color: @text-color-very-subtle;
+ margin-bottom: 10px;
+ font-size: @font-size-smaller;
+ }
+
+ button.btn + button.btn {
+ margin-left: 10px;
+ }
+ }
+
+ &.disabled {
+ .share-input input[type="text"] {
+ color: @text-color-very-subtle;
+ -webkit-user-select: none;
+ }
+ button.btn {
+ color: @text-color-very-subtle;
+ }
+ }
+}