mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-04 11:44:47 +08:00
fix(read-receipts): Style fixes to link tracking and read receipts
This commit is contained in:
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 |
|
@ -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
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()} {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()} {txt}</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ class LinkEditor extends React.Component
|
|||
|
||||
componentDidMount: ->
|
||||
if @props.focusOnMount
|
||||
console.log "FOCUSING ON MOUNT"
|
||||
React.findDOMNode(@refs["urlInput"]).focus()
|
||||
|
||||
render: =>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue