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
This commit is contained in:
Jackie Luo 2016-04-22 17:26:46 -07:00
parent 1c5b9fe490
commit 6a1bed23c1
15 changed files with 501 additions and 5 deletions

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

View file

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

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

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

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

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B