Add “Pro” plugins Nylas has just open sourced!

This commit is contained in:
Ben Gotow 2017-09-06 16:19:48 -07:00
parent 445da1be59
commit 9bddcaf444
88 changed files with 4202 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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(
<ActivityList />,
{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 (
<div
tabIndex={-1}
className="toolbar-activity"
title="View activity"
onClick={this.onClick}
>
<div className={unreadCountClass}>
{this.state.unreadCount}
</div>
<RetinaImg
name="icon-toolbar-activity.png"
className={iconClass}
mode={RetinaImg.Mode.ContentIsMask}
/>
</div>
);
}
}
export default ActivityListButton;

View file

@ -0,0 +1,21 @@
import React from 'react';
import {RetinaImg} from 'nylas-component-kit';
const ActivityListEmptyState = function ActivityListEmptyState() {
return (
<div className="empty">
<RetinaImg
className="logo"
name="activity-list-empty.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<div className="text">
Enable read receipts <RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or
link tracking <RetinaImg name="icon-activity-linkopen.png" mode={RetinaImg.Mode.ContentDark} /> to
see notifications here.
</div>
</div>
);
}
export default ActivityListEmptyState;

View file

@ -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(
<div
key={`${action.messageId}-${action.timestamp}`}
className="activity-list-toggle-item"
>
<Flexbox direction="row">
<div className="action-message">
{action.recipient ? action.recipient.displayName() : "Someone"}
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(date)}
</div>
</Flexbox>
</div>
);
}
return (
<div
key={`activity-toggle-container`}
className={`activity-toggle-container ${this.state.collapsed ? "hidden" : ""}`}
>
{actions}
</div>
);
}
render() {
const lastAction = this.props.group[0];
let className = "activity-list-item";
if (!ActivityListStore.hasBeenViewed(lastAction)) className += " unread";
const text = this._getText();
let disclosureTriangle = (<div style={{width: "7px"}} />);
if (this.props.group.length > 1) {
disclosureTriangle = (
<DisclosureTriangle
visible
collapsed={this.state.collapsed}
onCollapseToggled={this._onCollapseToggled}
/>
);
}
return (
<div onClick={() => { this._onClick(lastAction.threadId) }}>
<Flexbox direction="column" className={className}>
<Flexbox
direction="row"
>
<div className="activity-icon-container">
<RetinaImg
className="activity-icon"
name={pluginFor(lastAction.pluginId).iconName}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
{disclosureTriangle}
<div className="action-message">
{text.recipient} {pluginFor(lastAction.pluginId).predicate}:
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(text.date)}
</div>
</Flexbox>
<div className="title">
{text.title}
</div>
</Flexbox>
{this.renderActivityContainer()}
</div>
);
}
}
export default ActivityListItemContainer;

View file

@ -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();

View file

@ -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 (
<ActivityListEmptyState />
)
}
const groupedActions = this._groupActions(this.state.actions);
return groupedActions.map((group) => {
return (
<ActivityListItemContainer
key={`${group[0].messageId}-${group[0].timestamp}`}
group={group}
/>
);
});
}
render() {
if (!this.state.actions) return null;
const classes = classnames({
"activity-list-container": true,
"empty": this.state.empty,
})
return (
<Flexbox
direction="column"
height="none"
className={classes}
tabIndex="-1"
>
<ScrollRegion style={{height: "100%"}}>
{this.renderActions()}
</ScrollRegion>
</Flexbox>
);
}
}
export default ActivityList;

View file

@ -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);
}

View file

@ -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
}

View file

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

View file

@ -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"
}

View file

@ -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(<ActivityList />);
});
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);
});
});
});
});

View file

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

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -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 (
<MetadataComposerToggleButton
title={this._title}
iconName="icon-composer-linktracking.png"
pluginId={PLUGIN_ID}
pluginName={PLUGIN_NAME}
metadataEnabledValue={{tracked: true}}
stickyToggle
errorMessage={this._errorMessage}
draft={this.props.draft}
session={this.props.session}
/>
)
}
}
LinkTrackingButton.containerRequired = false;

View file

@ -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));
}
});
}
}

View file

@ -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")];

View file

@ -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(
<LinkTrackingMessagePopover
message={message}
linkMetadata={links[nodeHref]}
/>,
{
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);
}
}
}

View file

@ -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 (
<Flexbox key={`${click.timestamp}`} className="click-action">
<div className="recipient">
{recipient ? recipient.displayName() : "Someone"}
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(date)}
</div>
</Flexbox>
);
});
}
render() {
return (
<div
className="link-tracking-message-popover"
tabIndex="-1"
>
<div className="link-tracking-header">Clicked by:</div>
<div className="click-history-container">
{this.renderClickActions()}
</div>
</div>
);
}
}
export default LinkTrackingMessagePopover;

View file

@ -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);
}

View file

@ -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"
}

View file

@ -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<br>
<a href="www.replaced.com">test</a>
<a style="color: #aaa" href="http://replaced.com">asdad</a>
<a hre="www.stillhere.com">adsasd</a>
<a stillhere="">stillhere</a>
<div href="stillhere"></div>
http://www.stillhere.com
<blockquote class="gmail_quote">twst<a style="color: #aaa" href="http://untouched.com">asdad</a></blockquote>`;
const afterBodyFactory = (accountId, messageUid) => `TEST_BODY<br>
<a href="${PLUGIN_URL}/link/${accountId}/${messageUid}/0?redirect=www.replaced.com">test</a>
<a style="color: #aaa" href="${PLUGIN_URL}/link/${accountId}/${messageUid}/1?redirect=http%3A%2F%2Freplaced.com">asdad</a>
<a hre="www.stillhere.com">adsasd</a>
<a stillhere="">stillhere</a>
<div href="stillhere"></div>
http://www.stillhere.com
<blockquote class="gmail_quote">twst<a style="color: #aaa" href="http://untouched.com">asdad</a></blockquote>`;
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);
});
});
});

View file

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

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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);
}

View file

@ -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 (
<MetadataComposerToggleButton
title={this._title}
iconUrl="mailspring://open-tracking/assets/icon-composer-eye@2x.png"
pluginId={PLUGIN_ID}
pluginName={PLUGIN_NAME}
metadataEnabledValue={enabledValue}
stickyToggle
errorMessage={this._errorMessage}
draft={this.props.draft}
session={this.props.session}
/>
)
}
}
OpenTrackingButton.containerRequired = false;

View file

@ -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 <img> into the message
const serverUrl = `${PLUGIN_URL}/open/${draft.headerMessageId}`
const imgFragment = document.createRange().createContextualFragment(`<img class="n1-open" width="0" height="0" style="border:0; width:0; height:0;" data-open-tracking-src="${serverUrl}">`);
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);
}
}
}

View file

@ -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")];

View file

@ -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(
<OpenTrackingMessagePopover
message={this.state.message}
openMetadata={this.state.message.metadataForPluginId(PLUGIN_ID)}
/>,
{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 (
<RetinaImg
className={this.state.opened ? "opened" : "unopened"}
url="mailspring://open-tracking/assets/icon-tracking-opened@2x.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
);
}
render() {
if (!this.state.hasMetadata) return <span style={{width: "19px"}} />;
const openedTitle = `${this.state.openCount} open${this.state.openCount === 1 ? "" : "s"}`;
const title = this.state.opened ? openedTitle : "This message has not been opened";
return (
<div
title={title}
className="open-tracking-icon"
onMouseDown={this.state.opened ? this.onMouseDown : null}
>
{this._renderImage()}
</div>
);
}
}

View file

@ -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 (
<Flexbox key={`${open.timestamp}`} className="open-action">
<div className="recipient">
{recipient ? recipient.displayName() : "Someone"}
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(date)}
</div>
</Flexbox>
);
});
}
render() {
return (
<div
className="open-tracking-message-popover"
tabIndex="-1"
>
<div className="open-tracking-header">Opened by:</div>
<div className="open-history-container">
{this.renderOpenActions()}
</div>
</div>
);
}
}
export default OpenTrackingMessagePopover;

View file

@ -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(
<OpenTrackingMessagePopover
message={this.props.message}
openMetadata={this.props.message.metadataForPluginId(PLUGIN_ID)}
/>,
{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 (
<RetinaImg
className={this.state.opened ? "opened" : "unopened"}
style={{position: 'relative', top: -1}}
url="mailspring://open-tracking/assets/InMessage-opened@2x.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
);
}
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 (
<span
className={`open-tracking-message-status ${this.state.opened ? "opened" : "unopened"}`}
onMouseDown={this.state.opened ? this.onMouseDown : null}
>
{this.renderImage()}&nbsp;&nbsp;{text}
</span>
)
}
}

View file

@ -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"
}

View file

@ -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 <blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> On Feb 25 2016, at 3:38 pm, Drew &lt;drew@nylas.com&gt; wrote: <br> twst </blockquote>`;
const afterBody = `TEST_BODY <img class="n1-open" width="0" height="0" style="border:0; width:0; height:0;" src="${PLUGIN_URL}/open/${accountId}/${clientId}"><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> On Feb 25 2016, at 3:38 pm, Drew &lt;drew@nylas.com&gt; wrote: <br> twst </blockquote>`;
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);
});
});
});

View file

@ -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(<OpenTrackingIcon {...props} thread={thread} />);
}
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();
});
});
});

View file

@ -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(<div className="temp"><OpenTrackingMessageStatus {...props} message={message} /></div>);
}
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();
});
});

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -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() {
}

View file

@ -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(
<SendLaterPopover
sendLaterDate={this._sendLaterDateForDraft(this.props.draft)}
onAssignSendLaterDate={this.onAssignSendLaterDate}
onCancelSendLater={this.onCancelSendLater}
/>,
{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 (
<button className={className} title="Saving send date..." tabIndex={-1} style={{order: -99}}>
<RetinaImg
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentDark}
style={{width: 14, height: 14}}
/>
</button>
);
}
let sendLaterLabel = false;
const sendLaterDate = this._sendLaterDateForDraft(this.props.draft);
if (sendLaterDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(sendLaterDate);
if (momentDate) {
sendLaterLabel = <span className="at">Sending in {momentDate.fromNow(true)}</span>;
} else {
sendLaterLabel = <span className="at">Sending now</span>;
}
}
return (
<button className={className} title="Send later…" onClick={this.onClick} tabIndex={-1} style={{order: -99}}>
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask} />
{sendLaterLabel}
<span>&nbsp;</span>
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
);
}
}
export default SendLaterButton

View file

@ -0,0 +1,4 @@
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Send Later"

View file

@ -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 = <span key="send-later-header">Send later:</span>
if (sendLaterDate) {
footer = [
<div key="divider-unschedule" className="divider" />,
<div className="section" key="cancel-section">
<button className="btn btn-cancel" onClick={onCancelSendLater}>
Unschedule Send
</button>
</div>,
]
}
return (
<DatePickerPopover
className="send-later-popover"
header={header}
footer={footer}
dateOptions={SendLaterOptions}
onSelectDate={onAssignSendLaterDate}
/>
);
}
SendLaterPopover.displayName = 'SendLaterPopover';
SendLaterPopover.propTypes = {
sendLaterDate: PropTypes.string,
onAssignSendLaterDate: PropTypes.func.isRequired,
onCancelSendLater: PropTypes.func.isRequired,
};
export default SendLaterPopover

View file

@ -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 (
<div className="send-later-status">
<span className="time">
{`Scheduled for ${formatted}`}
</span>
<RetinaImg
name="image-cancel-button.png"
title="Cancel Send Later"
onClick={this.onCancelSendLater}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
)
}
return <span />
}
}

View file

@ -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"
}

View file

@ -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(<SendLaterButton draft={draft} session={session} isValidDraft={() => 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()
});
});
});

View file

@ -0,0 +1,28 @@
import React from 'react';
import {mount} from 'enzyme'
import SendLaterPopover from '../lib/send-later-popover';
const makePopover = (props = {}) => {
return mount(
<SendLaterPopover
sendLaterDate={null}
onSendLater={() => {}}
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()
});
});
});

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -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()
}

View file

@ -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),
}
}

View file

@ -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(
<SendRemindersPopover
onRemind={this.onSetReminder}
reminderDate={reminderDateForMessage(draft)}
onCancelReminder={() => this.onSetReminder(null)}
/>,
{originRect: buttonRect, direction: 'up'}
)
};
render() {
const {saving} = this.state
let className = 'btn btn-toolbar btn-send-reminder';
if (saving) {
return (
<button className={className} title="Saving reminder..." tabIndex={-1}>
<RetinaImg
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentDark}
style={{width: 14, height: 14}}
/>
</button>
);
}
const {draft} = this.props
const reminderDate = reminderDateForMessage(draft);
let reminderLabel = 'Set reminder';
if (reminderDate) {
className += ' btn-enabled';
reminderLabel = getReminderLabel(reminderDate, {fromNow: true})
}
return (
<button
tabIndex={-1}
className={className}
title={reminderLabel}
onClick={this.onClick}
>
<RetinaImg name="icon-composer-reminders.png" mode={RetinaImg.Mode.ContentIsMask} />
<span>&nbsp;</span>
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
);
}
}
export default SendRemindersComposerButton

View file

@ -0,0 +1,4 @@
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Send Reminders"

View file

@ -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 <span />
}
const latestMessage = getLatestMessage(thread, messages)
if (message.id !== latestMessage.id) {
return <span />
}
return (
<div className="send-reminders-header">
<RetinaImg
name="ic-timestamp-reminder.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span title="This thread was brought back to the top of your inbox as a reminder">
Reminder
</span>
</div>
)
}
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 <span />
}
const {expiration} = message.metadataForPluginId(PLUGIN_ID) || {}
const clearReminder = () => {
setMessageReminder(message.accountId, message, null)
}
return (
<div className="send-reminders-header">
<RetinaImg
name="ic-timestamp-reminder.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span className="reminder-date">
{` ${getReminderLabel(expiration)}`}
</span>
<span className="clear-reminder" onClick={clearReminder}>Cancel</span>
</div>
)
}
ThreadHeader.displayName = 'ThreadHeader'
ThreadHeader.containerRequired = false
ThreadHeader.propTypes = {
thread: PropTypes.object,
messages: PropTypes.array,
}

View file

@ -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

View file

@ -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(
<SendRemindersPopover
reminderDate={reminderDate}
onRemind={this.onSetReminder}
onCancelReminder={() => this.onSetReminder(null)}
/>,
{originRect: buttonRect, direction}
)
};
render() {
const {className, latestMessage} = this.props
const reminderDate = reminderDateForMessage(latestMessage)
const title = reminderDate ? 'Edit reminder' : 'Set reminder';
return (
<button
title={title}
tabIndex={-1}
className={`send-reminders-toolbar-button ${className}`}
onClick={this.onClick}
>
<RetinaImg
name="ic-toolbar-native-reminder.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button>
);
}
}
export default ListensToObservable(SendRemindersPopoverButton, {
getObservable: getMessageObservable,
getStateFromObservable,
})

View file

@ -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 = <span key="reminders-header">Remind me if no one replies:</span>
const footer = [
reminderDate ? <div key="reminders-divider" className="divider" /> : null,
reminderDate ?
<div
key="send-reminders-footer"
className="section send-reminders-footer"
>
<div className="reminders-label">
<span>
This thread will come back to the top of your inbox if nobody replies by:
<span className="reminder-date">
{` ${getReminderLabel(reminderDate)}`}
</span>
</span>
</div>
<button className="btn btn-cancel" onClick={onCancelReminder}>
Clear reminder
</button>
</div> :
null,
]
return (
<DatePickerPopover
className="send-reminders-popover"
header={header}
footer={footer}
onSelectDate={onRemind}
dateOptions={SendRemindersOptions}
/>
);
}
SendRemindersPopover.displayName = 'SendRemindersPopover';
SendRemindersPopover.propTypes = {
reminderDate: PropTypes.string,
onRemind: PropTypes.func,
onCancelReminder: PropTypes.func,
};
export default SendRemindersPopover

View file

@ -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

View file

@ -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()

View file

@ -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 ''
}

View file

@ -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 <Fallback {...this.props} />
}
const {expiration} = message.metadataForPluginId(PLUGIN_ID);
const title = getReminderLabel(expiration, {fromNow: true})
const shortLabel = getReminderLabel(expiration, {shortFormat: true})
return (
<span className="send-reminders-thread-timestamp timestamp" title={title}>
<RetinaImg
name="ic-timestamp-reminder.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span className="date-message">
{shortLabel}
</span>
</span>
)
}
}
export default SendRemindersThreadTimestamp

View file

@ -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 <span />;
}
const thread = threads[0]
if (!canSetReminderOnThread(thread)) {
return <span />;
}
return (
<SendRemindersPopoverButtonWithTip thread={thread} />
);
}
SendRemindersToolbarButton.containerRequired = false;
SendRemindersToolbarButton.displayName = 'SendRemindersToolbarButton';
SendRemindersToolbarButton.propTypes = {
items: PropTypes.array,
};

View file

@ -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)
}

View file

@ -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"
}

View file

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

View file

@ -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);
}
}

View file

@ -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 (
<button onClick={this._onCopy} {...otherProps}>
{btnLabel}
</button>
)
}
}
export default CopyButton

View file

@ -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}

View file

@ -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();
}

View file

@ -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(
<ThreadSharingPopover
thread={thread}
accountId={thread.accountId}
closePopover={Actions.closePopover}
/>,
{
originRect: ReactDOM.findDOMNode(this).getBoundingClientRect(),
direction: 'down',
}
)
}
render() {
if (this.props.items && this.props.items.length > 1) {
return <span />
}
return (
<button
className="btn btn-toolbar thread-sharing-button"
title="Share"
style={{marginRight: 0}}
onClick={this._onClick}
>
<RetinaImg
name="ic-toolbar-native-share.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button>
)
}
}

View file

@ -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")];

View file

@ -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 ? (
<RetinaImg
style={{width: 14, height: 14, marginBottom: 3, marginRight: 4}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
) : (
<input
type="checkbox"
id="shareCheckbox"
checked={shared}
onChange={this._onToggleShared}
/>
);
// tabIndex is necessary for the popover's onBlur events to work properly
return (
<div tabIndex="1" className={classes}>
<div className="share-toggle">
<label htmlFor="shareCheckbox">
{control}
Share this thread
</label>
</div>
<div className="share-input">
<input
ref="urlInput"
id="urlInput"
type="text"
value={url}
readOnly
disabled={!shared}
onClick={this._onClickInput}
/>
</div>
<div className={`share-controls`}>
<div className="share-message">{shareMessage}</div>
<button href={url} className="btn" disabled={!shared}>
Open in browser
</button>
<CopyButton
className="btn"
disabled={!shared}
copyValue={url}
btnLabel="Copy link"
/>
</div>
</div>
)
}
}

View file

@ -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"
}

View file

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