Bug 1572648 - OpenGraph + Twitter Card support for message compose. r=aleca

Differential Revision: https://phabricator.services.mozilla.com/D137999

--HG--
extra : rebase_source : 68fbdb48a7af20a7a4a2e802690f0e99b618c3f2
extra : amend_source : 2e33bd5cdceab5e2e31c30fe093eb79b4ab17457
This commit is contained in:
Magnus Melin 2022-05-05 20:41:30 +03:00
Родитель 3b244c226a
Коммит 5324b54d28
12 изменённых файлов: 362 добавлений и 9 удалений

Просмотреть файл

@ -307,6 +307,10 @@ pref("editor.singleLine.pasteNewlines", 4); // substitute commas for new lines
pref("editor.CR_creates_new_p", true); pref("editor.CR_creates_new_p", true);
pref("mail.compose.default_to_paragraph", true); pref("mail.compose.default_to_paragraph", true);
// If true, when pasting a URL, paste the Open Graph / Twitter Card details
// we can extract from the URL instead.
pref("mail.compose.add_link_preview", false);
// hidden pref to ensure a certain number of headers in the message pane // hidden pref to ensure a certain number of headers in the message pane
// to avoid the height of the header area from changing when headers are present / not present // to avoid the height of the header area from changing when headers are present / not present
pref("mailnews.headers.minNumHeaders", 0); // 0 means we ignore this pref pref("mailnews.headers.minNumHeaders", 0); // 0 means we ignore this pref

Просмотреть файл

@ -193,6 +193,8 @@ var gUserTouchedEncryptSubject = false;
var gIsRelatedToEncryptedOriginal = false; var gIsRelatedToEncryptedOriginal = false;
var gIsRelatedToSignedOriginal = false; var gIsRelatedToSignedOriginal = false;
var gOpened = Date.now();
var gEncryptedURIService = Cc[ var gEncryptedURIService = Cc[
"@mozilla.org/messenger-smime/smime-encrypted-uris-service;1" "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1"
].getService(Ci.nsIEncryptedSMIMEURIsService); ].getService(Ci.nsIEncryptedSMIMEURIsService);
@ -3641,23 +3643,186 @@ var dictionaryRemovalObserver = {
}, },
}; };
function EditorClick(event) {
if (event.target.matches(".remove-card")) {
event.target.closest(".moz-card").remove();
} else if (event.target.matches(`.add-card[data-opened='${gOpened}']`)) {
let url = event.target.getAttribute("data-url");
let meRect = document.getElementById("messageEditor").getClientRects()[0];
let settings = document.getElementById("linkPreviewSettings");
let settingsW = 500;
settings.style.position = "fixed";
settings.style.left =
Math.max(settingsW + 20, event.clientX) - settingsW + "px";
settings.style.top = meRect.top + event.clientY + 20 + "px";
settings.hidden = false;
event.target.remove();
settings.querySelector(".close").onclick = event => {
settings.hidden = true;
};
settings.querySelector(".preview-replace").onclick = event => {
addLinkPreview(url, true);
settings.hidden = true;
};
settings.querySelector(".preview-autoadd").onclick = event => {
Services.prefs.setBoolPref(
"mail.compose.add_link_preview",
event.target.checked
);
};
settings.querySelector(".preview-replace").focus();
settings.onkeydown = event => {
if (event.key == "Escape") {
settings.hidden = true;
}
};
}
}
/**
* Grab Open Graph or Twitter card data from the URL and insert a link preview
* into the editor
* @param {string} url - The URL to add preview for.
* @param {boolean} skipBad - Skip adding anything on bad data. When false,
* insert the url.
*/
async function addLinkPreview(url, skipBad) {
return fetch(url)
.then(response => response.text())
.then(text => {
let doc = new DOMParser().parseFromString(text, "text/html");
// If the url has an Open Graph or Twitter card, create a nicer
// representation and use that instead.
// @see https://ogp.me/
// @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/
let title = doc
.querySelector("meta[property='og:title'],meta[name='twitter:title']")
?.getAttribute("content");
let description = doc
.querySelector(
"meta[property='og:description'],meta[name='twitter:description']"
)
?.getAttribute("content");
// Handle the case where we didn't get proper data.
if (!title && !description) {
console.debug(`No link preview data for url=${url}`);
if (skipBad) {
return;
}
getBrowser().contentDocument.execCommand("insertHTML", false, url);
return;
}
let image = doc
.querySelector("meta[property='og:image'],meta[name='twitter:image']")
?.getAttribute("content");
let alt = doc
.querySelector(
"meta[property='og:image:alt'],meta[name='twitter:image:alt']"
)
?.getAttribute("content");
let creator = doc.querySelector("meta[name='twitter:creator']");
// Grab our template and fill in the variables.
let card = document
.getElementById("dataCardTemplate")
.content.cloneNode(true);
card.querySelector("img").src = image;
card.querySelector("img").alt = alt;
card.querySelector(".title").textContent = title;
card.querySelector(".creator").textContent = creator;
if (!creator) {
card.querySelector(".creator").remove();
}
card.querySelector(".description").textContent = description;
card.querySelector(".url").textContent = "🔗 " + url;
card.querySelector(".url").href = url;
card.querySelector(".url").title = new URL(url).hostname;
card.querySelector(".site").textContent = new URL(url).hostname;
// twitter:card "summary" = Summary Card
// twitter:card "summary_large_image" = Summary Card with Large Image
if (
doc.querySelector(
"meta[name='twitter:card'][content='summary_large_image']"
)
) {
card.querySelector("img").style.width = "600px";
card.querySelector(".url").style.maxWidth = "550px";
card.querySelector(".site").parentNode.remove();
}
// If subject is empty, set that as well.
let subject = document.getElementById("msgSubject");
if (!subject.value && title) {
subject.value = title;
}
// Add a line after the card. Otherwise it's hard to continue writing.
let line = GetCurrentEditor().returnInParagraphCreatesNewParagraph
? "<p>&#160;</p>"
: "<br />";
getBrowser().contentDocument.execCommand(
"insertHTML",
false,
card.firstElementChild.outerHTML + line
);
for (let card of getBrowser().contentDocument.querySelectorAll(
".moz-card"
)) {
card.classList.add("loaded");
}
});
}
/** /**
* On paste or drop, we may want to modify the content before inserting it into * On paste or drop, we may want to modify the content before inserting it into
* the editor, replacing file URLs with data URLs when appropriate. * the editor, replacing file URLs with data URLs when appropriate.
*/ */
function onPasteOrDrop(e) { function onPasteOrDrop(e) {
// For paste use e.clipboardData, for drop use e.dataTransfer.
let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer;
if (!dataTransfer.types.includes("text/html")) {
return;
}
if (!gMsgCompose.composeHTML) { if (!gMsgCompose.composeHTML) {
// We're in the plain text editor. Nothing to do here. // We're in the plain text editor. Nothing to do here.
return; return;
} }
// For paste use e.clipboardData, for drop use e.dataTransfer.
let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer;
if (!Services.io.offline && !dataTransfer.types.includes("text/html")) {
let type = dataTransfer.types.find(t =>
["text/uri-list", "text/x-moz-url", "text/plain"].includes(t)
);
if (type) {
let url = dataTransfer
.getData(type)
.split("\n")[0]
.trim();
if (/^https?:\/\//.test(url)) {
e.preventDefault(); // We'll handle the pasting manually.
if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) {
getBrowser().contentDocument.execCommand("insertHTML", false, url);
addLinkPreview(url);
} else {
getBrowser().contentDocument.execCommand("insertHTML", false, url);
/*
// FIXME - see bug 1572648
// Make the below UI nicer, and remove the inserHTML above.
getBrowser().contentDocument.execCommand(
"insertHTML",
false,
`${url} <span class='add-card' data-url='${url}' data-opened='${gOpened}'>📰</span>`
);
*/
}
return;
}
}
}
// Ok, we have html content to paste.
let html = dataTransfer.getData("text/html"); let html = dataTransfer.getData("text/html");
let doc = new DOMParser().parseFromString(html, "text/html"); let doc = new DOMParser().parseFromString(html, "text/html");
let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile); let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile);
@ -5552,6 +5717,15 @@ function GenericSendMessage(msgType) {
msgCompFields.newsgroups = ""; msgCompFields.newsgroups = "";
} }
if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) {
// Remove any card "close" button from content before sending.
for (let close of getBrowser().contentDocument.querySelectorAll(
".moz-card .remove-card"
)) {
close.remove();
}
}
let sendFormat = determineSendFormat(); let sendFormat = determineSendFormat();
switch (sendFormat) { switch (sendFormat) {
case Ci.nsIMsgCompSendFormat.PlainText: case Ci.nsIMsgCompSendFormat.PlainText:

Просмотреть файл

@ -2435,7 +2435,6 @@
<html:hr is="pane-splitter" id="headersSplitter" <html:hr is="pane-splitter" id="headersSplitter"
resize-direction="vertical" resize-direction="vertical"
resize-id="MsgHeadersToolbar" /> resize-id="MsgHeadersToolbar" />
<html:div id="messageArea"> <html:div id="messageArea">
<html:div id="dropAttachmentOverlay" class="drop-attachment-overlay"> <html:div id="dropAttachmentOverlay" class="drop-attachment-overlay">
<html:aside id="addInline" class="drop-attachment-box"> <html:aside id="addInline" class="drop-attachment-box">
@ -2465,8 +2464,24 @@
aria-label="&aria.message.bodyName;" aria-label="&aria.message.bodyName;"
messagemanagergroup="browsers" messagemanagergroup="browsers"
oncontextmenu="this._contextX = event.pageX; this._contextY = event.pageY;" oncontextmenu="this._contextX = event.pageX; this._contextY = event.pageY;"
onclick="EditorClick(event);"
ondblclick="EditorDblClick(event);" ondblclick="EditorDblClick(event);"
context="msgComposeContext"/> context="msgComposeContext"/>
<html:div id="linkPreviewSettings" xmlns="http://www.w3.org/1999/xhtml" hidden="hidden">
<span class="close">+</span>
<h2 data-l10n-id="link-preview-title"></h2>
<p data-l10n-id="link-preview-description"></p>
<p>
<input class="preview-autoadd" id="link-preview-autoadd" type="checkbox" />
<label data-l10n-id="link-preview-autoadd" for="link-preview-autoadd"></label>
</p>
<p class="bottom">
<span data-l10n-id="link-preview-replace-now"></span>
<button class="preview-replace" data-l10n-id="link-preview-yes-replace"></button>
</p>
</html:div>
<findbar id="FindToolbar" browserid="messageEditor"/> <findbar id="FindToolbar" browserid="messageEditor"/>
</html:div> </html:div>
@ -2536,5 +2551,29 @@
#include ../../../base/content/tabDialogs.inc.xhtml #include ../../../base/content/tabDialogs.inc.xhtml
#include ../../../extensions/openpgp/content/ui/keyAssistant.inc.xhtml #include ../../../extensions/openpgp/content/ui/keyAssistant.inc.xhtml
<html:template id="dataCardTemplate" xmlns="http://www.w3.org/1999/xhtml">
<aside class="moz-card" style="width:600px; display:flex; align-items:center; justify-content:center; flex-direction:row; flex-wrap:wrap; border-radius:10px; border:1px solid silver;">
<a class="remove-card">+</a>
<div class="card-pic" style="display:flex; flex-direction:column; flex-basis:100%; flex:1;">
<div style="margin:0 5px;">
<img src="IMAGE" style="width:120px;" alt="ALT" />
</div>
</div>
<div class="card-content" style="display:flex; flex-direction:column; flex-basis:100%; flex:3; background-color:#F5F5F5;">
<div style="margin:5px; ">
<p><small class="site">SITE</small></p>
<p>
<big class="title">TITLE</big>
</p>
<p class="creator">CREATOR</p>
<p class="description">DESCRIPTION</p>
<p>
<a href="#" class="url" style="display:inline-block; max-width:440px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-decoration:none;">URL</a>
</p>
</div>
</div>
</aside>
</html:template>
</html:body> </html:body>
</html> </html>

Просмотреть файл

@ -49,6 +49,12 @@
preference="mail.warn_on_send_accel_key"/> preference="mail.warn_on_send_accel_key"/>
<spacer flex="1"/> <spacer flex="1"/>
</hbox> </hbox>
<hbox>
<checkbox id="addLinkPreviews"
data-l10n-id="add-link-previews"
preference="mail.compose.add_link_preview"/>
<spacer flex="1"/>
</hbox>
</html:fieldset> </html:fieldset>
</html:div> </html:div>

Просмотреть файл

@ -46,6 +46,7 @@ Preferences.addAll([
{ id: "mail.compose.big_attachments.notify", type: "bool" }, { id: "mail.compose.big_attachments.notify", type: "bool" },
{ id: "mail.compose.big_attachments.threshold_kb", type: "int" }, { id: "mail.compose.big_attachments.threshold_kb", type: "int" },
{ id: "mail.default_send_format", type: "int" }, { id: "mail.default_send_format", type: "int" },
{ id: "mail.compose.add_link_preview", type: "bool" },
]); ]);
var gComposePane = { var gComposePane = {

Просмотреть файл

@ -423,3 +423,11 @@ cloud-file-attachment-error = Failed to update the Filelink attachment { $filena
# $filename (string) - name of the file that was renamed and caused the error # $filename (string) - name of the file that was renamed and caused the error
cloud-file-account-error-title = Filelink Account Error cloud-file-account-error-title = Filelink Account Error
cloud-file-account-error = Failed to update the Filelink attachment { $filename }, because its Filelink account has been deleted. cloud-file-account-error = Failed to update the Filelink attachment { $filename }, because its Filelink account has been deleted.
## Link Preview
link-preview-title = Link Preview
link-preview-description = { -brand-short-name } can add an embedded preview when pasting links.
link-preview-autoadd = Automatically add link previews when possible
link-preview-replace-now = Add a Link Preview for this link?
link-preview-yes-replace = Yes

Просмотреть файл

@ -527,6 +527,10 @@ warn-on-send-accel-key =
.label = Confirm when using keyboard shortcut to send message .label = Confirm when using keyboard shortcut to send message
.accesskey = C .accesskey = C
add-link-previews =
.label = Add link previews when pasting URLs
.accesskey = i
spellcheck-label = spellcheck-label =
.label = Check spelling before sending .label = Check spelling before sending
.accesskey = C .accesskey = C

Просмотреть файл

@ -43,7 +43,10 @@ prefs =
datareporting.policy.dataSubmissionPolicyBypassNotification=true datareporting.policy.dataSubmissionPolicyBypassNotification=true
skip-if = os == 'linux' && bits == 32 && debug # Perma-fail skip-if = os == 'linux' && bits == 32 && debug # Perma-fail
subsuite = thunderbird subsuite = thunderbird
support-files = data/** ../openpgp/data/** support-files =
data/**
../openpgp/data/**
html/linkpreview.html
[browser_addressWidgets.js] [browser_addressWidgets.js]
[browser_attachment.js] [browser_attachment.js]
@ -82,6 +85,7 @@ reason = Cannot open the Format menu
[browser_imageInsertionDialog.js] [browser_imageInsertionDialog.js]
[browser_inlineImage.js] [browser_inlineImage.js]
skip-if = headless # clipboard doesn't work with headless skip-if = headless # clipboard doesn't work with headless
[browser_linkPreviews.js]
[browser_messageBody.js] [browser_messageBody.js]
[browser_multipartRelated.js] [browser_multipartRelated.js]
[browser_newmsgComposeIdentity.js] [browser_newmsgComposeIdentity.js]

Просмотреть файл

@ -0,0 +1,38 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Test link previews.
*/
var { close_compose_window, open_compose_new_mail } = ChromeUtils.import(
"resource://testing-common/mozmill/ComposeHelpers.jsm"
);
var url =
"http://mochi.test:8888/browser/comm/mail/test/browser/composition/html/linkpreview.html";
add_task(async function previewEnabled() {
Services.prefs.setBoolPref("mail.compose.add_link_preview", true);
let controller = open_compose_new_mail();
await navigator.clipboard.writeText(url);
let messageEditor = controller.e("messageEditor");
messageEditor.focus();
// Ctrl+V = Paste
EventUtils.synthesizeKey(
"v",
{ shiftKey: false, accelKey: true },
controller.window
);
await TestUtils.waitForCondition(
() => messageEditor.contentDocument.body.querySelector(".moz-card"),
"link preview should have appeared"
);
close_compose_window(controller);
Services.prefs.clearUserPref("mail.compose.add_link_preview");
});

Просмотреть файл

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html prefix="og: https://ogp.me/ns#">
<head>
<title>OG test</title>
<meta charset="UTF-8" />
<meta property="og:title" content="Article title" />
<meta property="og:url" content="https://www.example.com/?test=true" />
<meta property="og:image" content="http://mochi.test:8888/browser/comm/mail/test/browser/content-policy/html/pass.png" />
<meta property="og:description" content="Description of test article." />
</head>
<body>
<p>See <a href="https://ogp.me/">https://ogp.me/</a>
</body>
</html>

Просмотреть файл

@ -59,6 +59,36 @@ img {
-moz-force-broken-image-icon: 1; -moz-force-broken-image-icon: 1;
} }
.moz-card {
position: relative;
opacity: 0;
}
.moz-card.loaded {
opacity: 1;
transition: opacity 1.5s;
}
.moz-card .remove-card {
position: absolute;
inset-inline-end: 15px;
top: 15px;
width: 20px;
height: 20px;
opacity: 0.3;
cursor: pointer;
font-size: 1.4em;
float: inline-end;
font-weight: 600;
display: inline-block;
transform: rotate(45deg);
margin-block: -0.2em 0.2em;
margin-inline: 0.2em -0.2em;
}
.moz-card .remove-card:hover {
opacity: 1;
}
/* Can be removed when it is in messageQuotes.css enabled again */ /* Can be removed when it is in messageQuotes.css enabled again */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {

Просмотреть файл

@ -1332,6 +1332,37 @@ button:is(
display: none; display: none;
} }
#linkPreviewSettings {
border: 1px solid silver;
border-radius: 5px;
padding: 10px 20px;
width: 500px;
}
#linkPreviewSettings h2 {
color: blue;
font-size: 1em;
}
#linkPreviewSettings p {
margin: 0.5em 0.2em;
}
#linkPreviewSettings .bottom {
padding: 1em 0;
}
#linkPreviewSettings button {
background-color: navy;
color: white;
padding: 0.2em 2em;
}
#linkPreviewSettings .close {
font-size: 1.4em;
float: inline-end;
font-weight: 600;
display: inline-block;
transform: rotate(45deg);
margin-block: -0.2em 0.2em;
margin-inline: 0.2em -0.2em;
}
.statusbar { .statusbar {
display: flex; display: flex;
align-items: center; align-items: center;