Add “Pro” plugins Nylas has just open sourced!
BIN
app/internal_packages/activity-list/assets/icon.png
Normal file
After Width: | Height: | Size: 16 KiB |
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
218
app/internal_packages/activity-list/lib/activity-list-store.jsx
Normal 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();
|
100
app/internal_packages/activity-list/lib/activity-list.jsx
Normal 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;
|
21
app/internal_packages/activity-list/lib/main.es6
Normal 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);
|
||||
}
|
22
app/internal_packages/activity-list/lib/plugin-helpers.es6
Normal 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
|
||||
}
|
18
app/internal_packages/activity-list/lib/test-data-source.es6
Normal 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};
|
||||
}
|
||||
}
|
21
app/internal_packages/activity-list/package.json
Normal 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"
|
||||
}
|
198
app/internal_packages/activity-list/specs/activity-list-spec.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
142
app/internal_packages/activity-list/styles/index.less
Normal 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;
|
||||
}
|
||||
}
|
4
app/internal_packages/link-tracking/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
## Open Tracking
|
||||
|
||||
Adds tracking pixels to messages and tracks whether they have been opened.
|
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 635 B |
BIN
app/internal_packages/link-tracking/icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -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;
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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")];
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
32
app/internal_packages/link-tracking/lib/main.es6
Normal 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);
|
||||
}
|
31
app/internal_packages/link-tracking/package.json
Normal 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"
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
77
app/internal_packages/link-tracking/styles/main.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
4
app/internal_packages/open-tracking/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
## Open Tracking
|
||||
|
||||
Adds tracking pixels to messages and tracks whether they have been opened.
|
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 975 B |
BIN
app/internal_packages/open-tracking/icon.png
Normal file
After Width: | Height: | Size: 16 KiB |
36
app/internal_packages/open-tracking/lib/main.es6
Normal 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);
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")];
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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()} {text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
31
app/internal_packages/open-tracking/package.json
Normal 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"
|
||||
}
|
|
@ -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 <drew@nylas.com> 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 <drew@nylas.com> 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
88
app/internal_packages/open-tracking/styles/main.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 11 KiB |
BIN
app/internal_packages/send-later/icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
23
app/internal_packages/send-later/lib/main.es6
Normal 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() {
|
||||
|
||||
}
|
231
app/internal_packages/send-later/lib/send-later-button.jsx
Normal 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> </span>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SendLaterButton
|
|
@ -0,0 +1,4 @@
|
|||
import plugin from '../package.json'
|
||||
|
||||
export const PLUGIN_ID = plugin.name;
|
||||
export const PLUGIN_NAME = "Send Later"
|
49
app/internal_packages/send-later/lib/send-later-popover.jsx
Normal 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
|
49
app/internal_packages/send-later/lib/send-later-status.jsx
Normal 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 />
|
||||
}
|
||||
}
|
23
app/internal_packages/send-later/package.json
Normal 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"
|
||||
}
|
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
app/internal_packages/send-later/styles/send-later.less
Normal 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;
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/internal_packages/send-reminders/icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
37
app/internal_packages/send-reminders/lib/main.es6
Normal 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()
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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> </span>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SendRemindersComposerButton
|
|
@ -0,0 +1,4 @@
|
|||
import plugin from '../package.json'
|
||||
|
||||
export const PLUGIN_ID = plugin.name;
|
||||
export const PLUGIN_NAME = "Send Reminders"
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
})
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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()
|
|
@ -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 ''
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
};
|
|
@ -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)
|
||||
}
|
22
app/internal_packages/send-reminders/package.json
Normal 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"
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
101
app/internal_packages/send-reminders/styles/send-reminders.less
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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}
|
15
app/internal_packages_disabled/thread-sharing/lib/main.es6
Normal 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();
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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")];
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
18
app/internal_packages_disabled/thread-sharing/package.json
Normal 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"
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|