Browse Source

Add support for rendering unfurls via Prosody's mod_ogp

See here: https://modules.prosody.im/mod_ogp.html
JC Brand 4 years ago
parent
commit
16edc2954d

+ 1 - 0
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
 

+ 1 - 0
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' },

+ 6 - 0
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;
         }

+ 1 - 0
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";

+ 2 - 1
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(`
                 <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">

+ 103 - 0
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(`
+            <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();
+    }));
+});

+ 1 - 0
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}></converse-chat-message>
 `;
 

+ 1 - 0
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 }
         }
     }

+ 1 - 0
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

+ 41 - 2
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) {

+ 1 - 0
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;
         }

+ 19 - 0
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 {

+ 15 - 0
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`<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 - 0
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);

+ 12 - 0
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;
+}

+ 10 - 1
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}"></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>`;
 }