fix(read-receipts): Style fixes to link tracking and read receipts

This commit is contained in:
Evan Morikawa 2016-02-23 18:20:26 -08:00
parent f3d8ddda21
commit 6e48af0c90
19 changed files with 166 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

View file

@ -1,9 +1,12 @@
import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports';
import {
ComposerExtension,
Actions,
QuotedHTMLTransformer,
RegExpUtils} from 'nylas-exports';
import plugin from '../package.json'
import uuid from 'node-uuid';
const LINK_REGEX = (/(<a\s.*?href\s*?=\s*?")([^"]*)("[^>]*>)|(<a\s.*?href\s*?=\s*?')([^']*)('[^>]*>)/g);
const PLUGIN_ID = plugin.appId;
const PLUGIN_URL = "n1-link-tracking.herokuapp.com";
@ -26,11 +29,11 @@ export default class LinkTrackingComposerExtension extends ComposerExtension {
const messageUid = uuid.v4().replace(/-/g, "");
// loop through all <a href> elements, replace with redirect links and save mappings
draftBody.unquoted = draftBody.unquoted.replace(LINK_REGEX, (match, prefix, url, suffix) => {
draftBody.unquoted = draftBody.unquoted.replace(RegExpUtils.linkTagRegex(), (match, prefix, url, suffix, content, closingTag) => {
const encoded = encodeURIComponent(url);
const redirectUrl = `http://${PLUGIN_URL}/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
links.push({url: url, click_count: 0, click_data: []});
return prefix + redirectUrl + suffix;
links.push({originalUrl: url, clickCount: 0, clickData: [], redirectUrl: redirectUrl});
return prefix + redirectUrl + suffix + content + closingTag;
});
// save the draft

View file

@ -29,7 +29,7 @@ export default class LinkTrackingIcon extends React.Component {
// If there's metadata, return the total number of link clicks in the most recent metadata
const mostRecentMetadata = metadataObjs.pop();
return {
clicks: sum(mostRecentMetadata.links || [], link => link.click_count || 0),
clicks: sum(mostRecentMetadata.links || [], link => link.clickCount || 0),
};
}
return {clicks: null};

View file

@ -0,0 +1,49 @@
import {MessageViewExtension, RegExpUtils} from 'nylas-exports'
import plugin from '../package.json'
export default class LinkTrackingMessageExtension extends MessageViewExtension {
static formatMessageBody({message}) {
const metadata = message.metadataForPluginId(plugin.appId) || {};
if ((metadata.links || []).length === 0) { return }
const links = {}
for (const link of metadata.links) {
links[link.redirectUrl] = link
}
message.body = message.body.replace(RegExpUtils.linkTagRegex(), (match, openTagPrefix, aTagHref, openTagSuffix, content, closingTag) => {
if (links[aTagHref]) {
const openTag = openTagPrefix + aTagHref + openTagSuffix
let title;
let dotSrc;
let newOpenTag;
const titleRe = /title="[^"]*"|title='[^']*'/gi;
if (!content) { return match; }
if (content.search("link-tracking-dot") >= 0) { return match; }
const originalUrl = links[aTagHref].originalUrl;
const dotImgSrcPrefix = "nylas://link-tracking/assets/";
const dotStyles = "margin-left: 1px; vertical-align: super; margin-right: 2px; zoom: 0.55;"
if (links[aTagHref].clickCount > 0) {
title = ` title="Number of clicks: ${links[aTagHref].clickCount} | ${originalUrl}" `;
dotSrc = dotImgSrcPrefix + "ic-tracking-visited@2x.png"
} else {
title = ` title="Never been clicked | ${originalUrl}" `
dotSrc = dotImgSrcPrefix + "ic-tracking-unvisited@2x.png"
}
const dot = `<img class="link-tracking-dot" src="${dotSrc}" style="${dotStyles}" />`
if (titleRe.test(openTag)) {
newOpenTag = openTag.replace(titleRe, title)
} else {
const tagLen = openTag.length
newOpenTag = openTag.slice(0, tagLen - 1) + title + openTag.slice(tagLen - 1, tagLen)
}
return newOpenTag + content + dot + closingTag
}
return match;
})
}
}

View file

@ -25,8 +25,8 @@ export default class LinkTrackingPanel extends React.Component {
_renderContents() {
return this.state.links.map(link => {
return (<tr className="link-info">
<td className="link-url">{link.url}</td>
<td className="link-count">{link.click_count + " clicks"}</td>
<td className="link-url">{link.originalUrl}</td>
<td className="link-count">{link.clickCount + " clicks"}</td>
</tr>)
})
}

View file

@ -2,7 +2,8 @@ import {ComponentRegistry, DatabaseStore, Message, ExtensionRegistry, Actions} f
import LinkTrackingButton from './link-tracking-button';
import LinkTrackingIcon from './link-tracking-icon';
import LinkTrackingComposerExtension from './link-tracking-composer-extension';
import LinkTrackingPanel from './link-tracking-panel';
import LinkTrackingMessageExtension from './link-tracking-message-extension';
// import LinkTrackingPanel from './link-tracking-panel';
import plugin from '../package.json'
import request from 'request';
@ -45,8 +46,9 @@ function afterDraftSend({draftClientId}) {
export function activate() {
ComponentRegistry.register(LinkTrackingButton, {role: 'Composer:ActionButton'});
ComponentRegistry.register(LinkTrackingIcon, {role: 'ThreadListIcon'});
ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'});
// ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'});
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.register(LinkTrackingMessageExtension);
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
}
@ -55,7 +57,8 @@ export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(LinkTrackingButton);
ComponentRegistry.unregister(LinkTrackingIcon);
ComponentRegistry.unregister(LinkTrackingPanel);
// ComponentRegistry.unregister(LinkTrackingPanel);
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
ExtensionRegistry.MessageView.unregister(LinkTrackingMessageExtension);
this._unlistenSendDraftSuccess()
}

View file

@ -93,15 +93,15 @@ class MessageItem extends React.Component
<header className={classes} onClick={@_onClickHeader}>
{@_renderHeaderSideItems()}
<div className="message-header-right">
<MessageTimestamp className="message-time"
isDetailed={@state.detailedHeaders}
date={@props.message.date} />
<InjectedComponentSet
className="message-header-status"
matching={role:"MessageHeaderStatus"}
exposedProps={message: @props.message, thread: @props.thread, detailedHeaders: @state.detailedHeaders} />
<MessageTimestamp className="message-time"
isDetailed={@state.detailedHeaders}
date={@props.message.date} />
<MessageControls thread={@props.thread} message={@props.message}/>
</div>
{@_renderFromParticipants()}

View file

@ -1,5 +1,5 @@
Autolinker = require 'autolinker'
{MessageViewExtension} = require 'nylas-exports'
{RegExpUtils, MessageViewExtension} = require 'nylas-exports'
class AutolinkerExtension extends MessageViewExtension
@ -10,7 +10,22 @@ class AutolinkerExtension extends MessageViewExtension
# Ensure that the hrefs in the email always have alt text so you can't hide
# the target of links
# https://regex101.com/r/cH0qM7/1
message.body = message.body.replace /href[ ]*=[ ]*?['"]([^'"]*)(['"]+)/gi, (match, url, quoteCharacter) =>
return "#{match} title=#{quoteCharacter}#{url}#{quoteCharacter} "
titleRe = -> /title\s.*?=\s.*?['"](.*)['"]/gi
message.body = message.body.replace RegExpUtils.linkTagRegex(), (match, openTagPrefix, aTagHref, openTagSuffix, content, closingTag) =>
if not content or not closingTag
return match
openTag = openTagPrefix + aTagHref + openTagSuffix
if titleRe().test(openTag)
oldTitle = titleRe().exec(openTag)[1]
title = """ title="#{oldTitle} | #{aTagHref}" """
openTag = openTag.replace(titleRe(), title)
else
title = """ title="#{aTagHref}" """
tagLen = openTag.length
openTag = openTag.slice(0, tagLen - 1) + title + openTag.slice(tagLen - 1, tagLen)
return openTag + content + closingTag
module.exports = AutolinkerExtension

View file

@ -21,8 +21,8 @@ describe "AutolinkerExtension", ->
body: """
<a href="apple.com" title="apple.com" >hello world!</a>
<a href = "http://apple.com" title="http://apple.com" >hello world!</a>
<a href ='http://apple.com' title='http://apple.com' >hello world!</a>
<a href ='mailto://' title='mailto://' >hello world!</a>
<a href ='http://apple.com' title="http://apple.com" >hello world!</a>
<a href ='mailto://' title="mailto://" >hello world!</a>
"""
AutolinkerExtension.formatMessageBody({message})
expect(message.body).toEqual(expected.body)

View file

@ -33,9 +33,12 @@ class TokenAuthAPI
request: options,
requestId: requestId
})
console.log options
nodeRequest options, (error, response, body) ->
statusCode = response?.statusCode
console.log error, response, body
Actions.didMakeAPIRequest({
request: options,
statusCode: statusCode,

View file

@ -25,7 +25,7 @@ function afterDraftSend({draftClientId}) {
const uid = metadata.uid;
// set metadata against the message
Actions.setMetadata(message, PLUGIN_ID, {open_count: 0, open_data: []});
Actions.setMetadata(message, PLUGIN_ID, {openCount: 0, openData: []});
// post the uid and message id pair to the plugin server
const data = {uid: uid, message_id: message.id, thread_id: 1};

View file

@ -20,12 +20,16 @@ export default class OpenTrackingIcon extends React.Component {
_getStateFromThread(thread) {
const messages = thread.metadata;
if ((messages || []).length === 0) { return {opened: false, hasMetadata: false} }
const metadataObjs = messages.map(msg => msg.metadataForPluginId(plugin.appId)).filter(meta => meta);
return {opened: metadataObjs.length ? metadataObjs.every(m => m.open_count > 0) : null};
return {
hasMetadata: metadataObjs.length > 0,
opened: metadataObjs.every(m => m.openCount > 0),
};
}
_renderIcon = () => {
if (this.state.opened == null) {
if (!this.state.hasMetadata) {
return <span />;
}
return this.renderImage()

View file

@ -1,13 +1,48 @@
import {React} from 'nylas-exports'
import OpenTrackingIcon from './open-tracking-icon'
import {RetinaImg} from 'nylas-component-kit'
import plugin from '../package.json'
export default class OpenTrackingMessageStatus extends OpenTrackingIcon {
export default class OpenTrackingMessageStatus extends React.Component {
static displayName = "OpenTrackingMessageStatus";
render() {
const txt = this.state.opened ? "Read" : "Unread"
static propTypes = {
message: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = this._getStateFromMessage(props.message)
}
_getStateFromMessage(message) {
const metadata = message.metadataForPluginId(plugin.appId);
if (!metadata) {
return {hasMetadata: false, opened: false}
}
return {
hasMetadata: true,
opened: metadata.openCount > 0,
};
}
static containerStyles = {
paddingTop: 4,
};
renderImage() {
return (
<span>{this.renderImage()}&nbsp;{txt}</span>
<RetinaImg
className={this.state.opened ? "opened" : "unopened"}
url="nylas://open-tracking/assets/icon-composer-eye@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
);
}
render() {
if (!this.state.hasMetadata) { return false }
const txt = this.state.opened ? "Read" : "Unread";
return (
<span className={`read-receipt-message-status ${txt}`}>{this.renderImage()}&nbsp;&nbsp;{txt}</span>
)
}
}

View file

@ -1,11 +1,11 @@
@import "ui-variables";
@import "ui-mixins";
.open-tracking-icon img.content-mask {
.open-tracking-icon img.content-mask.unopened {
background-color: #6b777d;
vertical-align: text-bottom;
}
.open-tracking-icon img.content-mask.unopened {
.open-tracking-icon img.content-mask.opened {
background-color: @text-color-link;
}
.open-tracking-icon .open-count {
@ -22,3 +22,18 @@
width: 16px;
margin-right: 4px;
}
.read-receipt-message-status {
color: @text-color-very-subtle;
margin-left: 10px;
&.Unread {
img.content-mask {
background-color: @text-color-very-subtle;
}
}
&.Read {
img.content-mask {
background-color: @text-color-link;
}
}
}

View file

@ -24,7 +24,6 @@ class LinkEditor extends React.Component
componentDidMount: ->
if @props.focusOnMount
console.log "FOCUSING ON MOUNT"
React.findDOMNode(@refs["urlInput"]).focus()
render: =>

View file

@ -25,6 +25,15 @@ RegExpUtils =
# This is the Gruber Regex.
urlRegex: -> new RegExp(/^\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))$/)
# Test cases: https://regex101.com/r/jD5zC7/2
# Retruns the following capturing groups:
# 1. start of the opening a tag to href="
# 2. The contents of the href without quotes
# 3. the rest of the opening a tag
# 4. the contents of the a tag
# 5. the closing tag
linkTagRegex: -> new RegExp(/(<a.*?href\s*?=\s*?['"])(.*?)(['"].*?>)([\s\S]*?)(<\/a>)/gim)
# https://regex101.com/r/zG7aW4/3
imageTagRegex: -> /<img\s+[^>]*src="([^"]*)"[^>]*>/g