diff --git a/CHANGES.md b/CHANGES.md index 25dddf5f6..44451a093 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/karma.conf.js b/karma.conf.js index 1831a0899..cb1c7f997 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -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' }, diff --git a/sass/_messages.scss b/sass/_messages.scss index 19ebaf229..e017c4312 100644 --- a/sass/_messages.scss +++ b/sass/_messages.scss @@ -9,6 +9,12 @@ } } .message { + + .card--unfurl { + margin: 1em 0; + max-width: 18rem; + } + .show-msg-author-modal { color: var(--text-color) !important; } diff --git a/sass/converse.scss b/sass/converse.scss index b551a1785..9ff5f66e4 100644 --- a/sass/converse.scss +++ b/sass/converse.scss @@ -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"; diff --git a/spec/muc_messages.js b/spec/muc_messages.js index 345b05862..18d29817d 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -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(` diff --git a/spec/unfurls.js b/spec/unfurls.js new file mode 100644 index 000000000..73d7ff441 --- /dev/null +++ b/spec/unfurls.js @@ -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(` + + https://www.youtube.com/watch?v=dQw4w9WgXcQ + + + + + `); + _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(` + + + + + + + + + + + + + + + + + `); + _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(` + + Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com + + + + + `); + _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(` + + + + + + + + + + + + + + + + + `); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + metadata_stanza = u.toStanza(` + + + + + + + + + `); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2); + done(); + })); +}); diff --git a/src/components/message-history.js b/src/components/message-history.js index 8ed9459e2..24af94815 100644 --- a/src/components/message-history.js +++ b/src/components/message-history.js @@ -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}> `; diff --git a/src/components/message.js b/src/components/message.js index 7e104b6b2..0dfde56e6 100644 --- a/src/components/message.js +++ b/src/components/message.js @@ -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 } } } diff --git a/src/headless/core.js b/src/headless/core.js index ac2f3d42c..2fc8bf182 100644 --- a/src/headless/core.js +++ b/src/headless/core.js @@ -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 diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index 04279e4fb..5184256ee 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -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) { diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index d64a6117c..bb226bec3 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -495,6 +495,7 @@ const ChatRoomMixin = { */ async handleMessageStanza (stanza) { if (stanza.getAttribute('type') !== 'groupchat') { + this.handleMetadataFastening(stanza); this.handleForwardedMentions(stanza); return; } diff --git a/src/headless/shared/parsers.js b/src/headless/shared/parsers.js index d4cc5a7f9..e55505819 100644 --- a/src/headless/shared/parsers.js +++ b/src/headless/shared/parsers.js @@ -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 { diff --git a/src/shared/chat/templates/unfurl.js b/src/shared/chat/templates/unfurl.js new file mode 100644 index 000000000..a11f36c6b --- /dev/null +++ b/src/shared/chat/templates/unfurl.js @@ -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`
+ + + +
+
${o.title}
+

${u.addHyperlinks(o.description)}

+
+
`; +} diff --git a/src/shared/chat/unfurl.js b/src/shared/chat/unfurl.js new file mode 100644 index 000000000..a211c7595 --- /dev/null +++ b/src/shared/chat/unfurl.js @@ -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); diff --git a/src/shared/utils.js b/src/shared/utils.js new file mode 100644 index 000000000..09fff6e1c --- /dev/null +++ b/src/shared/utils.js @@ -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; +} diff --git a/src/templates/chat_message.js b/src/templates/chat_message.js index ecf132289..50f1ab418 100644 --- a/src/templates/chat_message.js +++ b/src/templates/chat_message.js @@ -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}"> + + ${ o.model.get('ogp_metadata')?.map(m => + html``) } `; }