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; + } + } +}