Переглянути джерело

Handle GIFs inside Unfurls

- Add ability to play/pause by using `converse-rich-text`
- Make `converse-rich-text` component configurable whether the media URLs for GIF/audio/video are shown
- Add fallback options for GIFs that have errors
JC Brand 4 роки тому
батько
коміт
44a573b6c4

+ 1 - 1
src/plugins/chatview/tests/message-gifs.js

@@ -15,7 +15,7 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => view.querySelectorAll('.chat-content canvas').length);
         expect(view.model.sendMessage).toHaveBeenCalled();
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-        const html = `<converse-gif autoplay="" noloop="" src="${gif_url}">`+
+        const html = `<converse-gif autoplay="" noloop="" fallback="empty" src="${gif_url}">`+
             `<canvas class="gif-canvas"><img class="gif" src="${gif_url}"></canvas></converse-gif>`+
             `<a target="_blank" rel="noopener" href="${gif_url}">${gif_url}</a>`;
         await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === html, 1000);

+ 2 - 2
src/plugins/muc-views/tests/unfurls.js

@@ -43,7 +43,7 @@ describe("A Groupchat Message", function () {
         _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');
+        expect(unfurl.querySelector('.card-img-top').getAttribute('text')).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg');
     }));
 
     it("will render an unfurl with limited OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
@@ -78,7 +78,7 @@ describe("A Groupchat Message", function () {
         _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://conversejs.org/dist/images/custom_emojis/converse.png');
+        expect(unfurl.querySelector('.card-img-top').getAttribute('text')).toBe('https://conversejs.org/dist/images/custom_emojis/converse.png');
         expect(unfurl.querySelector('.card-body')).toBe(null);
         expect(unfurl.querySelector('a')).toBe(null);
     }));

+ 3 - 3
src/shared/chat/templates/unfurl.js

@@ -1,5 +1,5 @@
+import { getURI, isGIFURL, isImageDomainAllowed } from '@converse/headless/utils/url.js';
 import { html } from 'lit';
-import { getURI, isImageDomainAllowed } from '@converse/headless/utils/url.js';
 
 
 function isValidURL (url) {
@@ -12,10 +12,10 @@ function isValidImage (image) {
 }
 
 const tpl_url_wrapper = (o, wrapped_template) =>
-    (o.url && isValidURL(o.url)) ?
+    (o.url && isValidURL(o.url) && !isGIFURL(o.url)) ?
         html`<a href="${o.url}" target="_blank" rel="noopener">${wrapped_template(o)}</a>` : wrapped_template(o);
 
-const tpl_image = (o) => html`<img class="card-img-top" src="${o.image}" @load=${o.onload}/>`;
+const tpl_image = (o) => html`<converse-rich-text class="card-img-top" text="${o.image}" show_images hide_media_urls .onImgLoad=${o.onload}></converse-rich-text>`;
 
 export default (o) => {
     const valid_image = isValidImage(o.image);

+ 24 - 11
src/shared/components/gif.js

@@ -3,16 +3,25 @@ import { CustomElement } from 'shared/components/element.js';
 import { api } from '@converse/headless/core';
 import { getHyperlinkTemplate } from 'utils/html.js';
 import { html } from 'lit';
-import { isURLWithImageExtension } from '@converse/headless/utils/url.js';
 
 import './styles/gif.scss';
 
 export default class ConverseGIF extends CustomElement {
     static get properties () {
+        /**
+         * @typedef { Object } ConverseGIFComponentProperties
+         * @property { Boolean } autoplay
+         * @property { Boolean } noloop
+         * @property { String } progress_color
+         * @property { String } nick
+         * @property { ('url'|'empty'|'error') } fallback
+         * @property { String } src
+         */
         return {
             'autoplay': { type: Boolean },
             'noloop': { type: Boolean },
             'progress_color': { type: String },
+            'fallback': { type: String },
             'src': { type: String },
         };
     }
@@ -21,6 +30,7 @@ export default class ConverseGIF extends CustomElement {
         super();
         this.autoplay = false;
         this.noloop = false;
+        this.fallback = 'url';
     }
 
     initialize () {
@@ -51,10 +61,19 @@ export default class ConverseGIF extends CustomElement {
     }
 
     render () {
-        return html`<canvas class="gif-canvas"
-            @mouseover=${() => this.setHover()}
-            @mouseleave=${() => this.unsetHover()}
-            @click=${ev => this.onControlsClicked(ev)}><img class="gif" src="${this.src}"></a></canvas>`;
+        return (this.supergif?.load_error && ['url', 'empty'].includes(this.fallback)) ? this.renderErrorFallback() :
+            html`<canvas class="gif-canvas"
+                @mouseover=${() => this.setHover()}
+                @mouseleave=${() => this.unsetHover()}
+                @click=${ev => this.onControlsClicked(ev)}><img class="gif" src="${this.src}"></a></canvas>`;
+    }
+
+    renderErrorFallback () {
+        if (this.fallback === 'url') {
+            return getHyperlinkTemplate(this.src);
+        } else if (this.fallback === 'empty') {
+            return '';
+        }
     }
 
     setHover () {
@@ -79,12 +98,6 @@ export default class ConverseGIF extends CustomElement {
             this.supergif.play();
         }
     }
-
-    onError () {
-        if (isURLWithImageExtension(this.src)) {
-            this.setValue(getHyperlinkTemplate(this.src));
-        }
-    }
 }
 
 api.elements.define('converse-gif', ConverseGIF);

+ 31 - 0
src/shared/components/rich-text.js

@@ -2,9 +2,36 @@ import renderRichText from 'shared/directives/rich-text.js';
 import { CustomElement } from 'shared/components/element.js';
 import { api } from "@converse/headless/core";
 
+/**
+ * The RichText custom element allows you to parse transform text into rich DOM elements.
+ * @example <converse-rich-text text="*_hello_ world!*"></converse-rich-text>
+ */
 export default class RichText extends CustomElement {
 
     static get properties () {
+        /**
+         * @typedef { Object } RichTextComponentProperties
+         * @property { Boolean } embed_audio
+         *  Whether URLs that point to audio files should render as audio players.
+         * @property { Boolean } embed_videos
+         *  Whether URLs that point to video files should render as video players.
+         * @property { Array } mentions - An array of objects representing chat mentions
+         * @property { String } nick - The current user's nickname, relevant for mentions
+         * @property { Number } offset - The text offset, in case this is a nested RichText element.
+         * @property { Function } onImgClick
+         * @property { Function } onImgLoad
+         * @property { Boolean } render_styling
+         *  Whether XEP-0393 message styling hints should be rendered
+         * @property { Boolean } show_images
+         *  Whether URLs that point to image files should render as images
+         * @property { Boolean } hide_media_urls
+         *  If media URLs are rendered as media, then this option determines
+         *  whether the original URL is also still shown or not.
+         *  Only relevant in conjunction with `show_images`, `embed_audio` and `embed_videos`.
+         * @property { Boolean } show_me_message
+         *  Whether text that starts with /me should be rendered in the 3rd person.
+         * @property { String } text - The text that will get transformed.
+         */
         return {
             embed_audio: { type: Boolean },
             embed_videos: { type: Boolean },
@@ -15,6 +42,7 @@ export default class RichText extends CustomElement {
             onImgLoad: { type: Function },
             render_styling: { type: Boolean },
             show_images: { type: Boolean },
+            hide_media_urls: { type: Boolean },
             show_me_message: { type: Boolean },
             text: { type: String },
         }
@@ -24,9 +52,11 @@ export default class RichText extends CustomElement {
         super();
         this.embed_audio = false;
         this.embed_videos = false;
+        this.hide_media_urls = false;
         this.mentions = [];
         this.offset = 0;
         this.render_styling = false;
+        this.show_image_urls = true;
         this.show_images = false;
         this.show_me_message = false;
     }
@@ -41,6 +71,7 @@ export default class RichText extends CustomElement {
             render_styling: this.render_styling,
             show_images: this.show_images,
             show_me_message: this.show_me_message,
+            hide_media_urls: this.hide_media_urls,
         }
         return renderRichText(this.text, this.offset, this.mentions, options);
     }

+ 14 - 9
src/shared/directives/image.js

@@ -7,21 +7,26 @@ import { isURLWithImageExtension } from '@converse/headless/utils/url.js';
 
 const { URI } = converse.env;
 
+
 class ImageDirective extends AsyncDirective {
+
     render (src, href, onLoad, onClick) {
-        return html`<a href="${href}" class="chat-image__link" target="_blank" rel="noopener"
-                ><img
-                    class="chat-image img-thumbnail"
-                    src="${src}"
-                    @click=${onClick}
-                    @error=${() => this.onError(src, href, onLoad, onClick)}
-                    @load=${onLoad}
-            /></a>`;
+        return href ?
+            html`<a href="${href}" class="chat-image__link" target="_blank" rel="noopener">${ this.renderImage(src, href, onLoad, onClick) }</a>` :
+            this.renderImage(src, href, onLoad, onClick);
+    }
+
+    renderImage (src, href, onLoad, onClick) {
+        return html`<img class="chat-image img-thumbnail"
+                src="${src}"
+                @click=${onClick}
+                @error=${() => this.onError(src, href, onLoad, onClick)}
+                @load=${onLoad}/></a>`;
     }
 
     onError (src, href, onLoad, onClick) {
         if (isURLWithImageExtension(src)) {
-            this.setValue(getHyperlinkTemplate(href));
+            href && this.setValue(getHyperlinkTemplate(href));
         } else {
             // Before giving up and falling back to just rendering a hyperlink,
             // we attach `.png` and try one more time.

+ 2 - 0
src/shared/gif/index.js

@@ -44,6 +44,7 @@ export default class ConverseGif {
             opts
         );
 
+        this.el = el;
         this.gif_el = el.querySelector('img');
         this.canvas = el.querySelector('canvas');
         this.ctx = this.canvas.getContext('2d');
@@ -259,6 +260,7 @@ export default class ConverseGif {
         }; // Fake header.
         this.frames = [];
         this.drawError();
+        this.el.requestUpdate();
     }
 
     handleHeader (header) {

+ 6 - 3
src/shared/rich-text.js

@@ -87,6 +87,7 @@ export class RichText extends String {
         this.references = [];
         this.render_styling = options?.render_styling;
         this.show_images = options?.show_images;
+        this.hide_media_urls = options?.hide_media_urls;
     }
 
     /**
@@ -117,17 +118,19 @@ export class RichText extends String {
             let template;
 
             if (this.show_images && isGIFURL(url_text) && isImageDomainAllowed(url_text)) {
-                template = tpl_gif(filtered_url);
+                template = tpl_gif(filtered_url, this.hide_media_urls);
             } else if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
                 template = tpl_image({
                     'url': filtered_url,
+                    // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here
+                    'href': this.hide_media_urls ? null : filtered_url,
                     'onClick': this.onImgClick,
                     'onLoad': this.onImgLoad
                 });
             } else if (this.embed_videos && isVideoURL(url_text) && isVideoDomainAllowed(url_text)) {
-                template = tpl_video(filtered_url);
+                template = tpl_video(filtered_url, this.hide_media_urls);
             } else if (this.embed_audio && isAudioURL(url_text) && isAudioDomainAllowed(url_text)) {
-                template = tpl_audio(filtered_url);
+                template = tpl_audio(filtered_url, this.hide_media_urls);
             } else {
                 template = getHyperlinkTemplate(filtered_url);
             }

+ 2 - 3
src/templates/audio.js

@@ -1,5 +1,4 @@
 import { html } from 'lit';
 
-export default (url) => {
-    return html`<audio controls src="${url}"></audio><a target="_blank" rel="noopener" href="${url}">${url}</a>`;
-}
+export default (url, hide_url) =>
+    html`<audio controls src="${url}"></audio>${ hide_url ? '' : html`<a target="_blank" rel="noopener" href="${url}">${url}</a>` }`;

+ 2 - 1
src/templates/gif.js

@@ -1,4 +1,5 @@
 import { html } from "lit";
 import 'shared/components/gif.js';
 
-export default (url) => html`<converse-gif autoplay noloop src=${url}></converse-gif><a target="_blank" rel="noopener" href="${url}">${url}</a>`;
+export default (url, hide_url) =>
+    html`<converse-gif autoplay noloop fallback='empty' src=${url}></converse-gif>${ hide_url ? '' : html`<a target="_blank" rel="noopener" href="${url}">${url}</a>` }`;

+ 1 - 1
src/templates/image.js

@@ -1,4 +1,4 @@
 import { html } from "lit";
 import { renderImage } from "shared/directives/image.js";
 
-export default (o) => html`${renderImage(o.url, o.url, o.onLoad, o.onClick)}`;
+export default (o) => html`${renderImage(o.url, o.href, o.onLoad, o.onClick)}`;

+ 2 - 1
src/templates/video.js

@@ -1,3 +1,4 @@
 import { html } from "lit";
 
-export default (url) => html`<video controls preload="metadata" src="${url}"></video><a target="_blank" rel="noopener" href="${url}">${url}</a>`;
+export default (url, hide_url) =>
+    html`<video controls preload="metadata" src="${url}"></video>${ hide_url ? '' : html`<a target="_blank" rel="noopener" href="${url}">${url}</a>` }`;