Add support for rendering unfurls via Prosody's mod_ogp

See here: https://modules.prosody.im/mod_ogp.html
This commit is contained in:
JC Brand 2021-02-17 15:59:44 +01:00
parent c69eb6e1bf
commit 16edc2954d
16 changed files with 249 additions and 4 deletions

View File

@ -17,6 +17,7 @@
- New configuration setting: [send_chat_markers](https://conversejs.org/docs/html/configuration.html#send-chat-markers)
- #1823: New config options [mam_request_all_pages](https://conversejs.org/docs/html/configuration.html#mam-request-all-pages)
- Use the MUC stanza id when sending XEP-0333 markers
- Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
### Breaking Changes

View File

@ -53,6 +53,7 @@ module.exports = function(config) {
{ pattern: "spec/markers.js", type: 'module' },
{ pattern: "spec/rai.js", type: 'module' },
{ pattern: "spec/muc_messages.js", type: 'module' },
{ pattern: "spec/unfurls.js", type: 'module' },
{ pattern: "spec/muc-mentions.js", type: 'module' },
{ pattern: "spec/me-messages.js", type: 'module' },
{ pattern: "spec/mentions.js", type: 'module' },

View File

@ -9,6 +9,12 @@
}
}
.message {
.card--unfurl {
margin: 1em 0;
max-width: 18rem;
}
.show-msg-author-modal {
color: var(--text-color) !important;
}

View File

@ -23,6 +23,7 @@
@import "bootstrap/scss/input-group";
@import "bootstrap/scss/custom-forms";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/card";
@import "bootstrap/scss/badge";
@import "bootstrap/scss/alert";
@import "bootstrap/scss/media";

View File

@ -59,7 +59,8 @@ describe("A Groupchat Message", function () {
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const nick = 'romeo';
await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
const view = _converse.api.chatviews.get(muc_jid);
let presence = u.toStanza(`
<presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">

103
spec/unfurls.js Normal file
View File

@ -0,0 +1,103 @@
/*global mock, converse */
const { u } = converse.env;
describe("A Groupchat Message", function () {
it("will render an unfurl based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const nick = 'romeo';
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
const view = _converse.api.chatviews.get(muc_jid);
const message_stanza = u.toStanza(`
<message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
<body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
<active xmlns="http://jabber.org/protocol/chatstates"/>
<origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
<stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
<markable xmlns="urn:xmpp:chat-markers:0"/>
</message>`);
_converse.connection._dataRecv(mock.createRequest(message_stanza));
const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
const metadata_stanza = u.toStanza(`
<message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}">
<apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
</apply-to>
</message>`);
_converse.connection._dataRecv(mock.createRequest(metadata_stanza));
const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg');
done();
}));
it("will render multiple unfurls based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
const nick = 'romeo';
const muc_jid = 'lounge@montague.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
const view = _converse.api.chatviews.get(muc_jid);
const message_stanza = u.toStanza(`
<message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
<body>Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com</body>
<active xmlns="http://jabber.org/protocol/chatstates"/>
<origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
<stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
<markable xmlns="urn:xmpp:chat-markers:0"/>
</message>`);
_converse.connection._dataRecv(mock.createRequest(message_stanza));
const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
expect(el.textContent).toBe('Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com');
let metadata_stanza = u.toStanza(`
<message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}">
<apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
</apply-to>
</message>`);
_converse.connection._dataRecv(mock.createRequest(metadata_stanza));
metadata_stanza = u.toStanza(`
<message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}">
<apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://duckduckgo.com/" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="DuckDuckGo" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://duckduckgo.com/assets/logo_social-media.png" />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="DuckDuckGo - Privacy, simplified." />
<meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs." />
</apply-to>
</message>`);
_converse.connection._dataRecv(mock.createRequest(metadata_stanza));
await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2);
done();
}));
});

View File

@ -47,6 +47,7 @@ const tpl_message = (o) => html`
spoiler_hint=${o.spoiler_hint || ''}
subject=${o.subject || ''}
time=${o.time}
unfurl_metadata=${o.unfurl_metadata}
username=${o.username}></converse-chat-message>
`;

View File

@ -58,6 +58,7 @@ export default class Message extends CustomElement {
spoiler_hint: { type: String },
subject: { type: String },
time: { type: String },
unfurl_metadata: { type: String },
username: { type: String }
}
}

View File

@ -56,6 +56,7 @@ Strophe.addNamespace('STYLING', 'urn:xmpp:styling:0');
Strophe.addNamespace('VCARD', 'vcard-temp');
Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update');
Strophe.addNamespace('XFORM', 'jabber:x:data');
Strophe.addNamespace('XHTML', 'http://www.w3.org/1999/xhtml');
/**
* Custom error for indicating timeouts

View File

@ -1,9 +1,12 @@
import ModelWithContact from './model-with-contact.js';
import filesize from "filesize";
import isMatch from "lodash/isMatch";
import isObject from "lodash/isObject";
import log from '@converse/headless/log';
import pick from "lodash/pick";
import { Model } from '@converse/skeletor/src/model.js';
import { _converse, api, converse } from "../../core.js";
import { find, isMatch, isObject, pick } from "lodash-es";
import { getOpenGraphMetadata } from '@converse/headless/shared/parsers';
import { parseMessage } from './parsers.js';
import { sendMarker } from '@converse/headless/shared/actions';
@ -11,6 +14,24 @@ const { Strophe, $msg } = converse.env;
const u = converse.env.utils;
const METADATA_ATTRIBUTES = [
"og:description",
"og:image",
"og:image:height",
"og:image:width",
"og:site_name",
"og:title",
"og:type",
"og:url",
"og:video:height",
"og:video:secure_url",
"og:video:tag",
"og:video:type",
"og:video:url",
"og:video:width"
];
/**
* Represents an open/ongoing chat conversation.
*
@ -468,6 +489,24 @@ const ChatBox = ModelWithContact.extend({
return false;
},
handleMetadataFastening (stanza) {
const attrs = getOpenGraphMetadata(stanza);
if (attrs.ogp_for_id) {
if (attrs.ogp_for_id) {
const message = this.messages.findWhere({'origin_id': attrs.ogp_for_id});
if (message) {
const list = message.get('ogp_metadata') || [];
list.push(pick(attrs, METADATA_ATTRIBUTES));
message.save('ogp_metadata', list);
return true;
} else {
return false;
}
}
}
return false;
},
/**
* Determines whether the passed in message attributes represent a
* message which corrects a previously received message, or an
@ -524,7 +563,7 @@ const ChatBox = ModelWithContact.extend({
this.getMessageBodyQueryAttrs(attrs)
].filter(s => s);
const msgs = this.messages.models;
return find(msgs, m => queries.reduce((out, q) => (out || isMatch(m.attributes, q)), false));
return msgs.find(m => queries.reduce((out, q) => (out || isMatch(m.attributes, q)), false));
},
getOriginIdQueryAttrs (attrs) {

View File

@ -495,6 +495,7 @@ const ChatRoomMixin = {
*/
async handleMessageStanza (stanza) {
if (stanza.getAttribute('type') !== 'groupchat') {
this.handleMetadataFastening(stanza);
this.handleForwardedMentions(stanza);
return;
}

View File

@ -2,6 +2,7 @@ import dayjs from 'dayjs';
import sizzle from 'sizzle';
import { Strophe } from 'strophe.js/src/strophe';
import { _converse, api } from '@converse/headless/core';
import { decodeHTMLEntities } from 'shared/utils';
import { rejectMessage } from '@converse/headless/shared/actions';
const { NS } = Strophe;
@ -120,6 +121,24 @@ export function getCorrectionAttributes (stanza, original_stanza) {
return {};
}
export function getOpenGraphMetadata (stanza) {
const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (fastening) {
const applies_to_id = fastening.getAttribute('id');
const meta = sizzle(`> meta[xmlns="${Strophe.NS.XHTML}"]`, fastening);
return meta.reduce((acc, el) => {
const property = el.getAttribute('property');
if (property) {
acc[property] = decodeHTMLEntities(el.getAttribute('content') || '');
}
return acc;
}, {
'ogp_for_id': applies_to_id,
});
}
return {};
}
export function getSpoilerAttributes (stanza) {
const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
return {

View File

@ -0,0 +1,15 @@
import { html } from 'lit-element';
import { converse } from "@converse/headless/core";
const u = converse.env.utils;
export default (o) => {
return html`<div class="card card--unfurl">
<a href="${o.url}" target="_blank" rel="noopener">
<img class="card-img-top" src="${o.image}" @load=${o.onload}/>
</a>
<div class="card-body">
<a href="${o.url}" target="_blank" rel="noopener"><h5 class="card-title">${o.title}</h5></a>
<p class="card-text">${u.addHyperlinks(o.description)}</p>
</div>
</div>`;
}

34
src/shared/chat/unfurl.js Normal file
View File

@ -0,0 +1,34 @@
import { CustomElement } from 'components/element.js';
import { _converse, api } from "@converse/headless/core";
import tpl_unfurl from './templates/unfurl.js';
export default class MessageUnfurl extends CustomElement {
static get properties () {
return {
description: { type: String },
image: { type: String },
jid: { type: String },
title: { type: String },
url: { type: String },
}
}
render () {
return tpl_unfurl(Object.assign({
'onload': () => this.onImageLoad()
}, {
description: this.description,
image: this.image,
title: this.title,
url: this.url
}));
}
onImageLoad () {
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
}
}
api.elements.define('converse-message-unfurl', MessageUnfurl);

12
src/shared/utils.js Normal file
View File

@ -0,0 +1,12 @@
import xss from 'xss/dist/xss';
const element = document.createElement('div');
export function decodeHTMLEntities (str) {
if (str && typeof str === 'string') {
element.innerHTML = xss.filterXSS(str);
str = element.textContent;
element.textContent = '';
}
return str;
}

View File

@ -1,5 +1,6 @@
import { html } from "lit-html";
import 'shared/chat/unfurl';
import { __ } from '../i18n';
import { html } from "lit-html";
import { renderAvatar } from './../templates/directives/avatar';
@ -39,6 +40,14 @@ export default (o) => {
?is_retracted="${o.is_retracted}"
message_type="${o.message_type}"></converse-message-actions>
</div>
${ o.model.get('ogp_metadata')?.map(m =>
html`<converse-message-unfurl
jid="${o.jid}"
description="${m['og:description']}"
title="${m['og:title']}"
image="${m['og:image']}"
url="${m['og:url']}"></converse-message-unfurl>`) }
</div>
</div>`;
}