Introduce @TrackLink shorthand for generating tracking links.

The default `{{ TrackLink "https://listmonk.app" }}` template function
is clumsy to write and does breaks WYSIWYG editors and HTML syntax
highlighting because of the quotes. The new syntax doesn't break HTML
and is easier to write.

Eg: `<a href="https://listmonk.app@TrackLink">Link</a>`

- Introduce @TrackLink shorthand.
- Add first-class support for tracking links in the WYSIWYG (TinyMCE)
  editor by introducing an on/off checkbox on the link dialog.
- Improve default dummy campaign content to highlight this.
This commit is contained in:
Kailash Nadh 2021-09-26 16:03:05 +05:30
parent d3f543cb15
commit d86438bde9
18 changed files with 139 additions and 10 deletions

View file

@ -136,7 +136,13 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
"Welcome to listmonk",
"No Reply <noreply@yoursite.com>",
`<h3>Hi {{ .Subscriber.FirstName }}!</h3>
This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`,
<p>This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.</p>
<p>Here is a <a href="https://listmonk.app@TrackLink">tracked link</a>.</p>
<p>Use the link icon in the editor toolbar or when writing raw HTML or Markdown,
simply suffix @TrackLink to the end of a URL to turn it into a tracking link. Example:</p>
<pre>&lt;a href=&quot;https:/&zwnj;/listmonk.app&#064;TrackLink&quot;&gt;&lt;/a&gt;</pre>
<p>For help, refer to the <a href="https://listmonk.app/docs">documentation</a>.</p>
`,
nil,
"richtext",
nil,

View file

@ -225,6 +225,17 @@ body.is-noscroll {
border: 0;
}
}
.tox-track-link {
display: block !important;
cursor: pointer !important;
margin: 5px 0 10px 0 !important;
input {
margin-right: 5px !important;
}
}
.plain-editor textarea {
height: 65vh;
}

View file

@ -148,6 +148,7 @@ export default {
isReady: false,
isRichtextReady: false,
richtextConf: {},
isTrackLink: false,
form: {
body: '',
format: this.contentType,
@ -174,7 +175,18 @@ export default {
const { lang } = this.serverConfig;
this.richtextConf = {
init_instance_callback: () => { this.isReady = true; },
urlconverter_callback: this.onEditorURLConvert,
setup: (editor) => {
editor.on('init', () => {
this.onEditorDialogOpen(editor);
});
},
min_height: 500,
entity_encoding: 'raw',
convert_urls: true,
plugins: [
'autoresize', 'autolink', 'charmap', 'code', 'emoticons', 'fullscreen', 'help',
'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
@ -194,15 +206,14 @@ export default {
table, td { border-color: #ccc;}
`,
language: LANGS[lang] || null,
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
file_picker_types: 'image',
file_picker_callback: (callback) => {
this.isMediaVisible = true;
this.runTinyMceImageCallback = callback;
},
init_instance_callback: () => { this.isReady = true; },
language: LANGS[lang] || null,
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
};
this.isRichtextReady = true;
@ -258,6 +269,72 @@ export default {
);
},
onEditorURLConvert(url) {
let u = url;
if (this.isTrackLink) {
u = `${u}@TrackLink`;
}
this.isTrackLink = false;
return u;
},
onEditorDialogOpen(editor) {
const ed = editor;
const oldEd = ed.windowManager.open;
const self = this;
ed.windowManager.open = (t, r) => {
const isOK = t.initialData && 'url' in t.initialData && 'anchor' in t.initialData;
// Not the link modal.
if (!isOK) {
return oldEd.apply(this, [t, r]);
}
// If an existing link is being edited, check for the tracking flag `@TrackLink` at the end
// of the url. Remove that from the URL and instead check the checkbox.
let checked = false;
if (!t.initialData.link !== '') {
const t2 = t;
const url = t2.initialData.url.value.replace(/@TrackLink$/, '');
if (t2.initialData.url.value !== url) {
t2.initialData.url.value = url;
checked = true;
}
}
// Execute the modal.
const modal = oldEd.apply(this, [t, r]);
// Is it the link dialog?
if (isOK) {
// Insert tracking checkbox.
const c = document.createElement('input');
c.setAttribute('type', 'checkbox');
if (checked) {
c.setAttribute('checked', checked);
}
// Store the checkbox's state in the Vue instance to pick up from
// the TinyMCE link conversion callback.
c.onchange = (e) => {
self.isTrackLink = e.target.checked;
};
const l = document.createElement('label');
l.appendChild(c);
l.appendChild(document.createTextNode('Track link?'));
l.classList.add('tox-label', 'tox-track-link');
document.querySelector('.tox-form__controls-h-stack .tox-control-wrap').appendChild(l);
}
return modal;
};
},
onEditorChange() {
if (!this.isReady) {
return;

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-maily",
"campaigns.testSent": "Testovací zpráva odeslána",
"campaigns.timestamps": "Časová razítka",
"campaigns.trackLink": "Track link",
"campaigns.views": "Pohledy",
"dashboard.campaignViews": "Pohledy na kampaň",
"dashboard.linkClicks": "Klepnutí na odkaz",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-Mails",
"campaigns.testSent": "Testnachricht gesendet",
"campaigns.timestamps": "Zeitstempel",
"campaigns.trackLink": "Track link",
"campaigns.views": "Ansichten",
"dashboard.campaignViews": "Kampagnenansichten",
"dashboard.linkClicks": "Linkklicks",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Test message sent",
"campaigns.timestamps": "Timestamps",
"campaigns.trackLink": "Track link",
"campaigns.views": "Views",
"dashboard.campaignViews": "Campaign views",
"dashboard.linkClicks": "Link clicks",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "Correos electrónicos",
"campaigns.testSent": "Mensaje de prueba enviado",
"campaigns.timestamps": "Marca de timepo",
"campaigns.trackLink": "Track link",
"campaigns.views": "Vistas",
"dashboard.campaignViews": "Vista de campañas",
"dashboard.linkClicks": "Vinculos cliqueados",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "Emails de test",
"campaigns.testSent": "Message de test envoyé",
"campaigns.timestamps": "Horodatages",
"campaigns.trackLink": "Track link",
"campaigns.views": "Vues",
"dashboard.campaignViews": "vues de campagne",
"dashboard.linkClicks": "clics sur liens",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "Emails di prova",
"campaigns.testSent": "Messaggio di prova inviato",
"campaigns.timestamps": "Marcatura temporale ",
"campaigns.trackLink": "Track link",
"campaigns.views": "Visualizzazioni",
"dashboard.campaignViews": "Visualizzazioni della campagna",
"dashboard.linkClicks": "Clic sui link",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "ഈ-മെയിലുകൾ",
"campaigns.testSent": "ടെസ്റ്റ് സന്ദേശം അയച്ചു",
"campaigns.timestamps": "സമയം",
"campaigns.trackLink": "Track link",
"campaigns.views": "കാഴ്ചകൾ",
"dashboard.campaignViews": "ക്യാമ്പേയ്ൻ കാഴ്ചകൾ",
"dashboard.linkClicks": "കണ്ണിയിലെ ക്ലിക്കുകൾ",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-maile",
"campaigns.testSent": "Wiadomość testowa wysłana",
"campaigns.timestamps": "Sygnatury czasowe",
"campaigns.trackLink": "Track link",
"campaigns.views": "Wyświetlenia",
"dashboard.campaignViews": "Wyświetlenia kampanii",
"dashboard.linkClicks": "Kliknięcia linków",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Mensagem de teste enviada",
"campaigns.timestamps": "Data e hora",
"campaigns.trackLink": "Track link",
"campaigns.views": "Visualizações",
"dashboard.campaignViews": "Visualizações da campanha",
"dashboard.linkClicks": "Links clicados",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Mensagem de teste enviada",
"campaigns.timestamps": "Carimbo de hora",
"campaigns.trackLink": "Track link",
"campaigns.views": "Visualizações",
"dashboard.campaignViews": "Vista de campanhas",
"dashboard.linkClicks": "Cliques nos links",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "Emailuri",
"campaigns.testSent": "Mesaju de test a fost trimis",
"campaigns.timestamps": "Marcaje de timp",
"campaigns.trackLink": "Track link",
"campaigns.views": "Vizualizări",
"dashboard.campaignViews": "Vizualizări ale campaniei",
"dashboard.linkClicks": "Clickuri pe link",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Тестовое сообщение отправлено",
"campaigns.timestamps": "Метки времени",
"campaigns.trackLink": "Track link",
"campaigns.views": "Просмотры",
"dashboard.campaignViews": "Просмотров компании",
"dashboard.linkClicks": "Кликов по ссылкам",

View file

@ -78,6 +78,7 @@
"campaigns.testEmails": "E-postalar",
"campaigns.testSent": "Test mesajı gönderildi",
"campaigns.timestamps": "Zaman etiketi",
"campaigns.trackLink": "Track link",
"campaigns.views": "Görüntülenme",
"dashboard.campaignViews": "Kampanya görüntülenme Sayısı",
"dashboard.linkClicks": "Linklerin tıklanması",

View file

@ -80,15 +80,23 @@ type regTplFunc struct {
replace string
}
// Regular expression for matching {{ Track "http://link.com" }} in the template
// and substituting it with {{ Track "http://link.com" .Campaign.UUID .Subscriber.UUID }}
// before compilation. This string gimmick is to make linking easier for users.
var regTplFuncs = []regTplFunc{
regTplFunc{
// Convert the shorthand https://google.com@TrackLink to {{ TrackLink ... }}.
// This is for WYSIWYG editors that encode and break quotes {{ "" }} when inserted
// inside <a href="{{ TrackLink "https://these-quotes-break" }}>.
{
regExp: regexp.MustCompile(`(https?://.+?)@TrackLink`),
replace: `{{ TrackLink "$1" . }}`,
},
// Regular expression for matching {{ TrackLink "http://link.com" }} in the template
// and substituting it with {{ Track "http://link.com" . }} (the dot context)
// before compilation. This is to make linking easier for users.
{
regExp: regexp.MustCompile("{{(\\s+)?TrackLink\\s+?(\"|`)(.+?)(\"|`)(\\s+)?}}"),
replace: `{{ TrackLink "$3" . }}`,
},
regTplFunc{
{
regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|OptinURL|MessageURL)(\s+)?}}`),
replace: `{{ $2 . }}`,
},

View file

@ -15,6 +15,20 @@
color: #444;
}
pre {
background: #f4f4f4f4;
padding: 2px;
}
table {
width: 100%;
border: 1px solid #ddd;
}
table td {
border-color: #ddd;
padding: 5px;
}
.wrap {
background-color: #fff;
padding: 30px;