mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-08 21:55:54 +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 plugin from '../package.json'
|
||||||
|
|
||||||
import uuid from 'node-uuid';
|
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_ID = plugin.appId;
|
||||||
const PLUGIN_URL = "n1-link-tracking.herokuapp.com";
|
const PLUGIN_URL = "n1-link-tracking.herokuapp.com";
|
||||||
|
|
||||||
|
@ -26,11 +29,11 @@ export default class LinkTrackingComposerExtension extends ComposerExtension {
|
||||||
const messageUid = uuid.v4().replace(/-/g, "");
|
const messageUid = uuid.v4().replace(/-/g, "");
|
||||||
|
|
||||||
// loop through all <a href> elements, replace with redirect links and save mappings
|
// 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 encoded = encodeURIComponent(url);
|
||||||
const redirectUrl = `http://${PLUGIN_URL}/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
|
const redirectUrl = `http://${PLUGIN_URL}/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
|
||||||
links.push({url: url, click_count: 0, click_data: []});
|
links.push({originalUrl: url, clickCount: 0, clickData: [], redirectUrl: redirectUrl});
|
||||||
return prefix + redirectUrl + suffix;
|
return prefix + redirectUrl + suffix + content + closingTag;
|
||||||
});
|
});
|
||||||
|
|
||||||
// save the draft
|
// 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
|
// If there's metadata, return the total number of link clicks in the most recent metadata
|
||||||
const mostRecentMetadata = metadataObjs.pop();
|
const mostRecentMetadata = metadataObjs.pop();
|
||||||
return {
|
return {
|
||||||
clicks: sum(mostRecentMetadata.links || [], link => link.click_count || 0),
|
clicks: sum(mostRecentMetadata.links || [], link => link.clickCount || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {clicks: null};
|
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() {
|
_renderContents() {
|
||||||
return this.state.links.map(link => {
|
return this.state.links.map(link => {
|
||||||
return (<tr className="link-info">
|
return (<tr className="link-info">
|
||||||
<td className="link-url">{link.url}</td>
|
<td className="link-url">{link.originalUrl}</td>
|
||||||
<td className="link-count">{link.click_count + " clicks"}</td>
|
<td className="link-count">{link.clickCount + " clicks"}</td>
|
||||||
</tr>)
|
</tr>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,8 @@ import {ComponentRegistry, DatabaseStore, Message, ExtensionRegistry, Actions} f
|
||||||
import LinkTrackingButton from './link-tracking-button';
|
import LinkTrackingButton from './link-tracking-button';
|
||||||
import LinkTrackingIcon from './link-tracking-icon';
|
import LinkTrackingIcon from './link-tracking-icon';
|
||||||
import LinkTrackingComposerExtension from './link-tracking-composer-extension';
|
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 plugin from '../package.json'
|
||||||
|
|
||||||
import request from 'request';
|
import request from 'request';
|
||||||
|
@ -45,8 +46,9 @@ function afterDraftSend({draftClientId}) {
|
||||||
export function activate() {
|
export function activate() {
|
||||||
ComponentRegistry.register(LinkTrackingButton, {role: 'Composer:ActionButton'});
|
ComponentRegistry.register(LinkTrackingButton, {role: 'Composer:ActionButton'});
|
||||||
ComponentRegistry.register(LinkTrackingIcon, {role: 'ThreadListIcon'});
|
ComponentRegistry.register(LinkTrackingIcon, {role: 'ThreadListIcon'});
|
||||||
ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'});
|
// ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'});
|
||||||
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
|
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
|
||||||
|
ExtensionRegistry.MessageView.register(LinkTrackingMessageExtension);
|
||||||
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
|
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +57,8 @@ export function serialize() {}
|
||||||
export function deactivate() {
|
export function deactivate() {
|
||||||
ComponentRegistry.unregister(LinkTrackingButton);
|
ComponentRegistry.unregister(LinkTrackingButton);
|
||||||
ComponentRegistry.unregister(LinkTrackingIcon);
|
ComponentRegistry.unregister(LinkTrackingIcon);
|
||||||
ComponentRegistry.unregister(LinkTrackingPanel);
|
// ComponentRegistry.unregister(LinkTrackingPanel);
|
||||||
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
|
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
|
||||||
|
ExtensionRegistry.MessageView.unregister(LinkTrackingMessageExtension);
|
||||||
this._unlistenSendDraftSuccess()
|
this._unlistenSendDraftSuccess()
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,15 +93,15 @@ class MessageItem extends React.Component
|
||||||
<header className={classes} onClick={@_onClickHeader}>
|
<header className={classes} onClick={@_onClickHeader}>
|
||||||
{@_renderHeaderSideItems()}
|
{@_renderHeaderSideItems()}
|
||||||
<div className="message-header-right">
|
<div className="message-header-right">
|
||||||
|
<MessageTimestamp className="message-time"
|
||||||
|
isDetailed={@state.detailedHeaders}
|
||||||
|
date={@props.message.date} />
|
||||||
|
|
||||||
<InjectedComponentSet
|
<InjectedComponentSet
|
||||||
className="message-header-status"
|
className="message-header-status"
|
||||||
matching={role:"MessageHeaderStatus"}
|
matching={role:"MessageHeaderStatus"}
|
||||||
exposedProps={message: @props.message, thread: @props.thread, detailedHeaders: @state.detailedHeaders} />
|
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}/>
|
<MessageControls thread={@props.thread} message={@props.message}/>
|
||||||
</div>
|
</div>
|
||||||
{@_renderFromParticipants()}
|
{@_renderFromParticipants()}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
Autolinker = require 'autolinker'
|
Autolinker = require 'autolinker'
|
||||||
{MessageViewExtension} = require 'nylas-exports'
|
{RegExpUtils, MessageViewExtension} = require 'nylas-exports'
|
||||||
|
|
||||||
class AutolinkerExtension extends MessageViewExtension
|
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
|
# Ensure that the hrefs in the email always have alt text so you can't hide
|
||||||
# the target of links
|
# the target of links
|
||||||
# https://regex101.com/r/cH0qM7/1
|
# https://regex101.com/r/cH0qM7/1
|
||||||
message.body = message.body.replace /href[ ]*=[ ]*?['"]([^'"]*)(['"]+)/gi, (match, url, quoteCharacter) =>
|
titleRe = -> /title\s.*?=\s.*?['"](.*)['"]/gi
|
||||||
return "#{match} title=#{quoteCharacter}#{url}#{quoteCharacter} "
|
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
|
module.exports = AutolinkerExtension
|
||||||
|
|
|
@ -21,8 +21,8 @@ describe "AutolinkerExtension", ->
|
||||||
body: """
|
body: """
|
||||||
<a href="apple.com" title="apple.com" >hello world!</a>
|
<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 ='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 ='mailto://' title="mailto://" >hello world!</a>
|
||||||
"""
|
"""
|
||||||
AutolinkerExtension.formatMessageBody({message})
|
AutolinkerExtension.formatMessageBody({message})
|
||||||
expect(message.body).toEqual(expected.body)
|
expect(message.body).toEqual(expected.body)
|
||||||
|
|
|
@ -33,9 +33,12 @@ class TokenAuthAPI
|
||||||
request: options,
|
request: options,
|
||||||
requestId: requestId
|
requestId: requestId
|
||||||
})
|
})
|
||||||
|
console.log options
|
||||||
nodeRequest options, (error, response, body) ->
|
nodeRequest options, (error, response, body) ->
|
||||||
statusCode = response?.statusCode
|
statusCode = response?.statusCode
|
||||||
|
|
||||||
|
console.log error, response, body
|
||||||
|
|
||||||
Actions.didMakeAPIRequest({
|
Actions.didMakeAPIRequest({
|
||||||
request: options,
|
request: options,
|
||||||
statusCode: statusCode,
|
statusCode: statusCode,
|
||||||
|
|
|
@ -25,7 +25,7 @@ function afterDraftSend({draftClientId}) {
|
||||||
const uid = metadata.uid;
|
const uid = metadata.uid;
|
||||||
|
|
||||||
// set metadata against the message
|
// 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
|
// post the uid and message id pair to the plugin server
|
||||||
const data = {uid: uid, message_id: message.id, thread_id: 1};
|
const data = {uid: uid, message_id: message.id, thread_id: 1};
|
||||||
|
|
|
@ -20,12 +20,16 @@ export default class OpenTrackingIcon extends React.Component {
|
||||||
|
|
||||||
_getStateFromThread(thread) {
|
_getStateFromThread(thread) {
|
||||||
const messages = thread.metadata;
|
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);
|
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 = () => {
|
_renderIcon = () => {
|
||||||
if (this.state.opened == null) {
|
if (!this.state.hasMetadata) {
|
||||||
return <span />;
|
return <span />;
|
||||||
}
|
}
|
||||||
return this.renderImage()
|
return this.renderImage()
|
||||||
|
|
|
@ -1,13 +1,48 @@
|
||||||
import {React} from 'nylas-exports'
|
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";
|
static displayName = "OpenTrackingMessageStatus";
|
||||||
|
|
||||||
render() {
|
static propTypes = {
|
||||||
const txt = this.state.opened ? "Read" : "Unread"
|
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 (
|
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-variables";
|
||||||
@import "ui-mixins";
|
@import "ui-mixins";
|
||||||
|
|
||||||
.open-tracking-icon img.content-mask {
|
.open-tracking-icon img.content-mask.unopened {
|
||||||
background-color: #6b777d;
|
background-color: #6b777d;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
}
|
}
|
||||||
.open-tracking-icon img.content-mask.unopened {
|
.open-tracking-icon img.content-mask.opened {
|
||||||
background-color: @text-color-link;
|
background-color: @text-color-link;
|
||||||
}
|
}
|
||||||
.open-tracking-icon .open-count {
|
.open-tracking-icon .open-count {
|
||||||
|
@ -22,3 +22,18 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
margin-right: 4px;
|
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: ->
|
componentDidMount: ->
|
||||||
if @props.focusOnMount
|
if @props.focusOnMount
|
||||||
console.log "FOCUSING ON MOUNT"
|
|
||||||
React.findDOMNode(@refs["urlInput"]).focus()
|
React.findDOMNode(@refs["urlInput"]).focus()
|
||||||
|
|
||||||
render: =>
|
render: =>
|
||||||
|
|
|
@ -25,6 +25,15 @@ RegExpUtils =
|
||||||
# This is the Gruber Regex.
|
# 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`!()\[\]{};:'".,<>?«»“”‘’]))$/)
|
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
|
# https://regex101.com/r/zG7aW4/3
|
||||||
imageTagRegex: -> /<img\s+[^>]*src="([^"]*)"[^>]*>/g
|
imageTagRegex: -> /<img\s+[^>]*src="([^"]*)"[^>]*>/g
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue