feat(activity-list): View message opens and link clicks
Summary: Adds an activity list view that shows message opens and link clicks in a chronological feed. TODO: Add badge for unread notifications and different styling for read/unread notifications. Click item to jump to corresponding thread. Test Plan: TODO. Reviewers: evan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2915
38
internal_packages/activity-list/lib/activity-list-button.jsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import {Actions, ReactDOM} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
|
||||
import ActivityList from './activity-list';
|
||||
|
||||
|
||||
class ActivityListButton extends React.Component {
|
||||
static displayName = 'ActivityListButton';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(
|
||||
<ActivityList />,
|
||||
{originRect: buttonRect, direction: 'down'}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
title="View activity"
|
||||
onClick={this.onClick}>
|
||||
<RetinaImg
|
||||
name="icon-toolbar-activity.png"
|
||||
className="activity-toolbar-icon"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ActivityListButton;
|
|
@ -0,0 +1,140 @@
|
|||
import React from 'react';
|
||||
|
||||
import {DisclosureTriangle,
|
||||
Flexbox,
|
||||
RetinaImg} from 'nylas-component-kit';
|
||||
import {Utils} from 'nylas-exports';
|
||||
|
||||
const plugins = {
|
||||
"1hnytbkg4wd1ahodatwxdqlb5": {
|
||||
name: "open",
|
||||
predicate: "opened",
|
||||
iconName: "icon-activity-mailopen.png",
|
||||
},
|
||||
"a1ec1s3ieddpik6lpob74hmcq": {
|
||||
name: "link",
|
||||
predicate: "clicked",
|
||||
iconName: "icon-activity-linkopen.png",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
class ActivityListItemContainer extends React.Component {
|
||||
|
||||
static displayName = 'ActivityListItemContainer';
|
||||
|
||||
static propTypes = {
|
||||
group: React.PropTypes.array,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
_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.recipients.length === 1) {
|
||||
text.recipient = lastAction.recipients[0].name;
|
||||
} else if (this.props.group.length > 1) {
|
||||
const people = [];
|
||||
for (const action of this.props.group) {
|
||||
for (const person of action.recipients) {
|
||||
if (people.indexOf(person) === -1) {
|
||||
people.push(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (people.length === 1) text.recipient = people[0].name;
|
||||
else if (people.length === 2) text.recipient = `${people[0].name} and 1 other`;
|
||||
else text.recipient = `${people[0].name} and ${people.length - 1} others`;
|
||||
}
|
||||
if (lastAction.title) text.title = lastAction.title;
|
||||
text.date.setUTCSeconds(lastAction.timestamp);
|
||||
return text;
|
||||
}
|
||||
|
||||
_hasBeenViewed(action) {
|
||||
if (!NylasEnv.savedState.activityListViewed) return false;
|
||||
return action.timestamp > NylasEnv.savedState.activityListViewed;
|
||||
}
|
||||
|
||||
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.recipients.length === 1 ? action.recipients[0].name : "Someone"}
|
||||
</div>
|
||||
<div className="spacer"></div>
|
||||
<div className="timestamp">
|
||||
{Utils.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 (this._hasBeenViewed(lastAction)) className += " unread";
|
||||
const text = this._getText();
|
||||
let disclosureTriangle = (<div style={{width: "7px"}}></div>);
|
||||
if (this.props.group.length > 1) {
|
||||
disclosureTriangle = (<DisclosureTriangle
|
||||
visible
|
||||
collapsed={this.state.collapsed}
|
||||
onCollapseToggled={() => {this.setState({collapsed: !this.state.collapsed})}} />);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Flexbox direction="column" className={className}>
|
||||
<Flexbox
|
||||
direction="row">
|
||||
<RetinaImg
|
||||
name={plugins[lastAction.pluginId].iconName}
|
||||
className="activity-icon"
|
||||
mode={RetinaImg.Mode.ContentDark} />
|
||||
{disclosureTriangle}
|
||||
<div className="action-message">
|
||||
{text.recipient} {plugins[lastAction.pluginId].predicate}:
|
||||
</div>
|
||||
<div className="spacer"></div>
|
||||
<div className="timestamp">
|
||||
{Utils.shortTimeString(text.date)}
|
||||
</div>
|
||||
</Flexbox>
|
||||
<div className="title">
|
||||
{text.title}
|
||||
</div>
|
||||
</Flexbox>
|
||||
{this.renderActivityContainer()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ActivityListItemContainer;
|
121
internal_packages/activity-list/lib/activity-list-store.jsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
import NylasStore from 'nylas-store';
|
||||
import Rx from 'rx-lite';
|
||||
|
||||
import {Message,
|
||||
DatabaseStore,
|
||||
NativeNotifications} from 'nylas-exports';
|
||||
|
||||
const OPEN_TRACKING_ID = "1hnytbkg4wd1ahodatwxdqlb5";
|
||||
const LINK_TRACKING_ID = "a1ec1s3ieddpik6lpob74hmcq";
|
||||
|
||||
|
||||
class ActivityListStore extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
activate() {
|
||||
this._getActivity();
|
||||
}
|
||||
|
||||
actions() {
|
||||
return this._actions;
|
||||
}
|
||||
|
||||
_getActivity() {
|
||||
const query = DatabaseStore.findAll(Message).where(Message.attributes.pluginMetadata.contains(OPEN_TRACKING_ID, LINK_TRACKING_ID));
|
||||
this._subscription = Rx.Observable.fromQuery(query).subscribe((messages) => {
|
||||
this._actions = messages ? this._getActions(messages) : [];
|
||||
this.trigger();
|
||||
});
|
||||
}
|
||||
|
||||
_getActions(messages) {
|
||||
let actions = [];
|
||||
this._notifications = [];
|
||||
for (const message of messages) {
|
||||
if (message.metadataForPluginId(OPEN_TRACKING_ID) ||
|
||||
message.metadataForPluginId(LINK_TRACKING_ID)) {
|
||||
actions = actions.concat(this._openActionsForMessage(message));
|
||||
actions = actions.concat(this._linkActionsForMessage(message));
|
||||
}
|
||||
}
|
||||
for (const notification of this._notifications) {
|
||||
NativeNotifications.displayNotification(notification);
|
||||
}
|
||||
const d = new Date();
|
||||
this._lastChecked = d.getTime() / 1000;
|
||||
return actions.sort((a, b) => {return b.timestamp - a.timestamp;});
|
||||
}
|
||||
|
||||
_getRecipients(message) {
|
||||
const recipients = message.to.concat(message.cc, message.bcc);
|
||||
return recipients;
|
||||
}
|
||||
|
||||
_openActionsForMessage(message) {
|
||||
const openMetadata = message.metadataForPluginId(OPEN_TRACKING_ID);
|
||||
const recipients = this._getRecipients(message);
|
||||
const actions = [];
|
||||
if (openMetadata) {
|
||||
if (openMetadata.open_count > 0) {
|
||||
for (const open of openMetadata.open_data) {
|
||||
if (open.timestamp > this._lastChecked) {
|
||||
this._notifications.push({
|
||||
title: "New open",
|
||||
subtitle: `${recipients.length === 1 ? recipients[0].name : "Someone"} just opened your email.`,
|
||||
body: message.subject,
|
||||
canReply: false,
|
||||
tag: "message-open",
|
||||
onActivate: () => {
|
||||
NylasEnv.displayWindow();
|
||||
},
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
messageId: message.id,
|
||||
title: message.subject,
|
||||
recipients: recipients,
|
||||
pluginId: OPEN_TRACKING_ID,
|
||||
timestamp: open.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
_linkActionsForMessage(message) {
|
||||
const linkMetadata = message.metadataForPluginId(LINK_TRACKING_ID)
|
||||
const recipients = this._getRecipients(message);
|
||||
const actions = [];
|
||||
if (linkMetadata && linkMetadata.links) {
|
||||
for (const link of linkMetadata.links) {
|
||||
for (const click of link.click_data) {
|
||||
if (click.timestamp > this._lastChecked) {
|
||||
this._notifications.push({
|
||||
title: "New click",
|
||||
subtitle: `${recipients.length === 1 ? recipients[0].name : "Someone"} just clicked your link.`,
|
||||
body: link.url,
|
||||
canReply: false,
|
||||
tag: "link-open",
|
||||
onActivate: () => {
|
||||
NylasEnv.displayWindow();
|
||||
},
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
messageId: message.id,
|
||||
title: link.url,
|
||||
recipients: recipients,
|
||||
pluginId: LINK_TRACKING_ID,
|
||||
timestamp: click.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ActivityListStore();
|
83
internal_packages/activity-list/lib/activity-list.jsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
|
||||
import {Flexbox,
|
||||
ScrollRegion} from 'nylas-component-kit';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
import ActivityListItemContainer from './activity-list-item-container';
|
||||
|
||||
|
||||
class ActivityList extends React.Component {
|
||||
|
||||
static displayName = 'ActivityList';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unsub = ActivityListStore.listen(this._onDataChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
NylasEnv.savedState.activityListViewed = Date.now() / 1000;
|
||||
this._unsub();
|
||||
}
|
||||
|
||||
_onDataChanged = () => {
|
||||
this.setState(this._getStateFromStores());
|
||||
}
|
||||
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
actions: ActivityListStore.actions(),
|
||||
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() {
|
||||
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;
|
||||
return (
|
||||
<Flexbox
|
||||
direction="column"
|
||||
height="none"
|
||||
className="activity-list-container"
|
||||
tabIndex="-1">
|
||||
<ScrollRegion style={{height: "100%"}}>
|
||||
{this.renderActions()}
|
||||
</ScrollRegion>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ActivityList;
|
16
internal_packages/activity-list/lib/main.es6
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
|
||||
import ActivityListButton from './activity-list-button';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(ActivityListButton, {
|
||||
location: WorkspaceStore.Location.RootSidebar.Toolbar,
|
||||
});
|
||||
ActivityListStore.activate();
|
||||
}
|
||||
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(ActivityListButton);
|
||||
}
|
19
internal_packages/activity-list/package.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "activity-list",
|
||||
"main": "./lib/main",
|
||||
"version": "0.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"engines": {
|
||||
"nylas": ">=0.3.43-b95f1f7"
|
||||
},
|
||||
|
||||
"isOptional": true,
|
||||
|
||||
"title":"Activity List",
|
||||
"description": "See open and link tracking activity.",
|
||||
|
||||
"license": "GPL-3.0"
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.activity-toolbar-icon {
|
||||
margin-top: 20px;
|
||||
background: @gray;
|
||||
}
|
||||
|
||||
.activity-list-container {
|
||||
height: 285px;
|
||||
width: 264px;
|
||||
overflow: hidden;
|
||||
font-size: @font-size-small;
|
||||
color: @text-color-subtle;
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.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;
|
||||
.action-message {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.disclosure-triangle {
|
||||
padding-top: 7px;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {WorkspaceStore, ComponentRegistry, Actions} from 'nylas-exports'
|
||||
import {WorkspaceStore, ComponentRegistry} from 'nylas-exports'
|
||||
import DraftList from './draft-list'
|
||||
import DraftListToolbar from './draft-list-toolbar'
|
||||
import DraftListSendStatus from './draft-list-send-status'
|
||||
|
@ -11,10 +11,6 @@ export function activate() {
|
|||
{root: true},
|
||||
{list: ['RootSidebar', 'DraftList']}
|
||||
)
|
||||
if (NylasEnv.savedState.perspective &&
|
||||
NylasEnv.savedState.perspective.type === "DraftsMailboxPerspective") {
|
||||
Actions.selectRootSheet(WorkspaceStore.Sheet.Drafts);
|
||||
}
|
||||
|
||||
ComponentRegistry.register(DraftList, {location: WorkspaceStore.Location.DraftList})
|
||||
ComponentRegistry.register(DraftListToolbar, {location: WorkspaceStore.Location.DraftList.Toolbar})
|
||||
|
|
BIN
static/images/activity-list/icon-activity-linkopen@1x.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/images/activity-list/icon-activity-linkopen@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/activity-list/icon-activity-mailopen@1x.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/images/activity-list/icon-activity-mailopen@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/activity-list/icon-activity-replied@1x.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/images/activity-list/icon-activity-replied@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/activity-list/icon-toolbar-activity@2x.png
Normal file
After Width: | Height: | Size: 772 B |