浏览代码

Add support for controlling (e.g. pausing, looping) gifs

- Create new component `converse-gif`
- Draw gif in canvas and add controlls
JC Brand 4 年之前
父节点
当前提交
0ccf25d986

+ 1 - 0
CHANGES.md

@@ -25,6 +25,7 @@
 - File structure reordering: All plugins are now in `./plugins` folders.
 - Show a gap placeholder when there are gaps in the chat history. The user can click these to fill the gaps.
 - Use the MUC stanza id when sending XEP-0333 markers
+- Add support for pausing Gif images
 
 ### New configuration setings
 

+ 1 - 0
karma.conf.js

@@ -46,6 +46,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-audio.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/message-gifs.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-images.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-videos.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },

+ 2 - 0
src/headless/shared/constants.js

@@ -36,3 +36,5 @@ export const CORE_PLUGINS = [
     'converse-status',
     'converse-vcard'
 ];
+
+export const URL_PARSE_OPTIONS = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };

+ 2 - 2
src/headless/shared/parsers.js

@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
 import log from '@converse/headless/log';
 import sizzle from 'sizzle';
 import { Strophe } from 'strophe.js/src/strophe';
+import { URL_PARSE_OPTIONS } from '@converse/headless/shared/constants.js';
 import { _converse, api } from '@converse/headless/core';
 import { decodeHTMLEntities } from '@converse/headless/utils/core.js';
 import { rejectMessage } from '@converse/headless/shared/actions';
@@ -182,7 +183,6 @@ export function getMediaURLs (text) {
     if (!text) {
         return {};
     }
-    const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
     try {
         URI.withinString(
             text,
@@ -190,7 +190,7 @@ export function getMediaURLs (text) {
                 objs.push({ url, start, end });
                 return url;
             },
-            parse_options
+            URL_PARSE_OPTIONS
         );
     } catch (error) {
         log.debug(error);

+ 4 - 0
src/headless/utils/url.js

@@ -85,6 +85,10 @@ export function isURLWithImageExtension (url) {
     return checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
 }
 
+export function isGIFURL (url) {
+    return checkFileTypes(['.gif'], url);
+}
+
 export function isAudioURL (url) {
     return checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
 }

+ 23 - 0
src/plugins/chatview/tests/message-gifs.js

@@ -0,0 +1,23 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+    it("will render gifs from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+        await mock.waitForRoster(_converse, 'current');
+        const gif_url = 'https://media.giphy.com/media/Byana3FscAMGQ/giphy.gif';
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        spyOn(view.model, 'sendMessage').and.callThrough();
+        await mock.sendMessage(view, gif_url);
+        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}">`+
+            `<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);
+    }));
+});

+ 90 - 0
src/shared/components/gif.js

@@ -0,0 +1,90 @@
+import ConverseGif from 'shared/gif/index.js';
+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 () {
+        return {
+            'autoplay': { type: Boolean },
+            'noloop': { type: Boolean },
+            'progress_color': { type: String },
+            'src': { type: String },
+        };
+    }
+
+    constructor () {
+        super();
+        this.autoplay = false;
+        this.noloop = false;
+    }
+
+    initialize () {
+        const options = {
+            'autoplay': this.autoplay,
+            'loop': !this.noloop,
+        }
+        if (this.progress_color) {
+            options['progress_color'] = this.progress_color;
+        }
+        this.supergif = new ConverseGif(this, options);
+    }
+
+    updated (changed) {
+        if (!this.supergif || changed.has('src')) {
+            this.initialize();
+            return;
+        }
+        if (changed.has('autoplay')) {
+            this.supergif.options.autoplay = this.autoplay;
+        }
+        if (changed.has('noloop')) {
+            this.supergif.options.loop = !this.noloop;
+        }
+        if (changed.has('progress_color')) {
+            this.supergif.options.progress_color = this.progress_color;
+        }
+    }
+
+    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>`;
+    }
+
+    setHover () {
+        if (this.supergif) {
+            this.supergif.hovering = true;
+            this.hover_timeout && clearTimeout(this.hover_timeout);
+            this.hover_timeout = setTimeout(() => this.unsetHover(), 2000);
+        }
+    }
+
+    unsetHover () {
+        if (this.supergif) this.supergif.hovering = false;
+    }
+
+    onControlsClicked (ev) {
+        ev.preventDefault();
+        if (this.supergif.playing) {
+            this.supergif.pause();
+        } else {
+            // When the user manually clicks play, we turn on looping
+            this.supergif.options.loop = true;
+            this.supergif.play();
+        }
+    }
+
+    onError () {
+        if (isURLWithImageExtension(this.src)) {
+            this.setValue(getHyperlinkTemplate(this.src));
+        }
+    }
+}
+
+api.elements.define('converse-gif', ConverseGIF);

+ 67 - 0
src/shared/components/styles/gif.scss

@@ -0,0 +1,67 @@
+converse-gif {
+    display: block;
+}
+
+img.gif {
+    visibility: hidden;
+}
+
+.gif-canvas {
+    cursor: pointer;
+    max-width: 100%;
+    max-height: 100%;
+    display: block;
+}
+
+.gifcontrol {
+    cursor: pointer;
+    transition: background 0.25s ease-in-out;
+    z-index: 100;
+    display: contents;
+    position: relative;
+
+    &:after {
+	transition: background 0.25s ease-in-out;
+	position: absolute;
+	content: "";
+	display: block;
+	left: calc(50% - 25px);
+	top: calc(50% - 25px);
+    }
+
+    &.loading {
+	background: rgba(255, 255, 255, 0.75);
+	&:after {
+	    background: #FFF;
+	    width: 50px;
+	    height: 50px;
+	    border-radius: 50px;
+	}
+    }
+
+    &.playing {
+	/* Only show the 'stop' button on hover */
+	&:after {
+	    opacity: 0;
+	    transition: opacity 0.25s ease-in-out;
+	    border-left: 20px solid #FFF;
+	    border-right: 20px solid #FFF;
+	    width: 50px;
+	    height: 50px;
+	}
+	&:hover:after {
+	    opacity: 1;
+	}
+    }
+
+    &.paused {
+	background: rgba(255, 255, 255, 0.5);
+	&:after {
+	    width: 0;
+	    height: 0;
+	    border-style: solid;
+	    border-width: 25px 0 25px 50px;
+	    border-color: transparent transparent transparent #fff;
+	}
+    }
+}

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

@@ -0,0 +1,563 @@
+/**
+ * @copyright Shachaf Ben-Kiki and the Converse.js contributors
+ * @description
+ *  Started as a fork of Shachaf Ben-Kiki's jsgif library
+ *  https://github.com/shachaf/jsgif
+ * @license MIT License
+ */
+
+import Stream from './stream.js';
+import { getOpenPromise } from '@converse/openpromise';
+import { parseGIF } from './utils.js';
+
+
+export default class ConverseGif {
+
+    /**
+     * Creates a new ConverseGif instance
+     * @param { HTMLElement } el
+     * @param { Object } [options]
+     * @param { Number } [options.width] - The width, in pixels, of the canvas
+     * @param { Number } [options.height] - The height, in pixels, of the canvas
+     * @param { Number } [options.loop_delay=0] - The amount of time to pause (in ms) after each single loop (iteration)
+     * @param { Boolean } [options.loop=true] - Setting this to `true` will enable looping of the gif
+     * @param { Boolean } [options.autoplay=true] - Same as the rel:autoplay attribute above, this arg overrides the img tag info.
+     * @param { Number } [options.max_width] - Scale images over max_width down to max_width. Helpful with mobile.
+     * @param { Function } [options.onIterationEnd] - Add a callback for when the gif reaches the end of a single loop (one iteration). The first argument passed will be the gif HTMLElement.
+     * @param { Boolean } [options.show_progress_bar=true]
+     * @param { String } [options.progress_bg_color='rgba(0,0,0,0.4)']
+     * @param { String } [options.progress_color='rgba(255,0,22,.8)']
+     * @param { Number } [options.progress_bar_height=5]
+     */
+    constructor (el, opts) {
+        this.options = Object.assign(
+            {
+                width: null,
+                height: null,
+                autoplay: true,
+                loop_delay: 0,
+                loop: true,
+                show_progress_bar: true,
+                progress_bg_color: 'rgba(0,0,0,0.4)',
+                progress_color: 'rgba(255,0,22,.8)',
+                progress_bar_height: 5
+            },
+            opts
+        );
+
+        this.gif_el = el.querySelector('img');
+        this.canvas = el.querySelector('canvas');
+        this.ctx = this.canvas.getContext('2d');
+        // It's good practice to pre-render to an offscreen canvas
+        this.offscreenCanvas = document.createElement('canvas');
+
+        this.ctx_scaled = false;
+        this.disposal_method = null;
+        this.disposal_restore_from_idx = null;
+        this.frame = null;
+        this.frame_offsets = []; // elements have .x and .y properties
+        this.frames = [];
+        this.last_disposal_method = null;
+        this.last_img = null;
+        this.load_error = null;
+        this.playing = this.options.autoplay;
+        this.transparency = null;
+
+        this.frame_idx = -1;
+        this.iteration_count = 0;
+        this.start = null;
+
+        this.initialize();
+    }
+
+    async initialize () {
+        if (this.options.width && this.options.height) {
+            this.setSizes(this.options.width, this.options.height);
+        }
+        const data = await this.fetchGIF(this.gif_el.src);
+        requestAnimationFrame(() => this.startParsing(data));
+    }
+
+    initPlayer () {
+        if (this.load_error) return;
+
+        if (!(this.options.width && this.options.height)) {
+            this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
+        }
+
+        if (this.options.autoplay) {
+            this.play();
+        } else {
+            this.frame_idx = 0;
+            this.putFrame(this.frame_idx);
+        }
+    }
+
+    getCurrentFrame () {
+        return this.frame_idx;
+    }
+
+    moveTo (frame_idx) {
+        this.frame_idx = frame_idx;
+        this.putFrame(this.frame_idx);
+    }
+
+    /**
+     * Gets the index of the frame "up next".
+     * @returns {number}
+     */
+    getNextFrameNo () {
+        return (this.frame_idx + 1 + this.frames.length) % this.frames.length;
+    }
+
+    stepFrame (amount) {
+        this.frame_idx += amount;
+        this.putFrame(this.frame_idx);
+    }
+
+    completeLoop () {
+        if (!this.playing) {
+            return;
+        }
+        this.options.onIterationEnd?.(this);
+        this.iteration_count++;
+
+        this.moveTo(0);
+
+        if (this.options.loop !== false || this.iteration_count < 0) {
+            this.doStep();
+        } else {
+            this.pause();
+        }
+    }
+
+    doStep (timestamp, start, frame_delay) {
+        if (!this.playing) {
+            return;
+        }
+        if (timestamp) {
+            // If timestamp is set, this is a callback from requestAnimationFrame
+            const elapsed = timestamp - start;
+            if (elapsed < frame_delay) {
+                requestAnimationFrame(ts => this.doStep(ts, start, frame_delay));
+                return;
+            }
+        }
+        this.stepFrame(1);
+
+        const next_frame_no = this.getNextFrameNo();
+        if (next_frame_no === 0) {
+            const delay = ((this.frames[this.frame_idx]?.delay ?? 0) * 10) + (this.options.loop_delay || 0);
+            setTimeout(() => this.completeLoop(), delay);
+        } else {
+            const delay = (this.frames[this.frame_idx]?.delay ?? 0) * 10;
+            const start = (delay ? timestamp : 0) || 0;
+            requestAnimationFrame(ts => this.doStep(ts, start, delay));
+        }
+    }
+
+    setSizes (w, h) {
+        this.canvas.width = w * this.getCanvasScale();
+        this.canvas.height = h * this.getCanvasScale();
+
+        this.offscreenCanvas.width = w;
+        this.offscreenCanvas.height = h;
+        this.offscreenCanvas.style.width = w + 'px';
+        this.offscreenCanvas.style.height = h + 'px';
+        this.offscreenCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
+    }
+
+    setFrameOffset (frame, offset) {
+        if (!this.frame_offsets[frame]) {
+            this.frame_offsets[frame] = offset;
+            return;
+        }
+        if (typeof offset.x !== 'undefined') {
+            this.frame_offsets[frame].x = offset.x;
+        }
+        if (typeof offset.y !== 'undefined') {
+            this.frame_offsets[frame].y = offset.y;
+        }
+    }
+
+    doShowProgress (pos, length, draw) {
+        if (draw && this.options.show_progress_bar) {
+            let height = this.options.progress_bar_height;
+            const top = (this.canvas.height - height) / (this.ctx_scaled ? this.getCanvasScale() : 1);
+            const mid = ((pos / length) * this.canvas.width) / (this.ctx_scaled ? this.getCanvasScale() : 1);
+            const width = this.canvas.width / (this.ctx_scaled ? this.getCanvasScale() : 1);
+            height /= this.ctx_scaled ? this.getCanvasScale() : 1;
+
+            this.ctx.fillStyle = this.options.progress_bg_color;
+            this.ctx.fillRect(mid, top, width - mid, height);
+
+            this.ctx.fillStyle = this.options.progress_color;
+            this.ctx.fillRect(0, top, mid, height);
+        }
+    }
+
+    /**
+     * Starts parsing the GIF stream data by calling `parseGIF` and passing in
+     * a map of handler functions.
+     * @param { String } data - The GIF file data, as returned by the server
+     */
+    startParsing (data) {
+        const stream = new Stream(data);
+        /**
+         * @typedef { Object } GIFParserHandlers
+         * A map of callback functions passed `parseGIF`. These functions are
+         * called as various parts of the GIF file format are parsed.
+         * @property { Function } hdr - Callback to handle the GIF header data
+         * @property { Function } gce - Callback to handle the GIF Graphic Control Extension data
+         * @property { Function } com - Callback to handle the comment extension block
+         * @property { Function } img - Callback to handle image data
+         * @property { Function } eof - Callback once the end of file has been reached
+         */
+        const handler = {
+            'hdr': this.withProgress(stream, header => this.handleHeader(header)),
+            'gce': this.withProgress(stream, gce => this.handleGCE(gce)),
+            'com': this.withProgress(stream, ),
+            'img': this.withProgress(stream, img => this.doImg(img), true),
+            'eof': () => this.handleEOF(stream)
+        };
+        try {
+            parseGIF(stream, handler);
+        } catch (err) {
+            this.showError('parse');
+        }
+    }
+
+    drawError () {
+        this.ctx.fillStyle = 'black';
+        this.ctx.fillRect(
+            0,
+            0,
+            this.options.width ? this.options.width : this.hdr.width,
+            this.options.height ? this.options.height : this.hdr.height
+        );
+        this.ctx.strokeStyle = 'red';
+        this.ctx.lineWidth = 3;
+        this.ctx.moveTo(0, 0);
+        this.ctx.lineTo(
+            this.options.width ? this.options.width : this.hdr.width,
+            this.options.height ? this.options.height : this.hdr.height
+        );
+        this.ctx.moveTo(0, this.options.height ? this.options.height : this.hdr.height);
+        this.ctx.lineTo(this.options.width ? this.options.width : this.hdr.width, 0);
+        this.ctx.stroke();
+    }
+
+    showError (errtype) {
+        this.load_error = errtype;
+        this.hdr = {
+            width: this.gif_el.width,
+            height: this.gif_el.height,
+        }; // Fake header.
+        this.frames = [];
+        this.drawError();
+    }
+
+    handleHeader (header) {
+        this.hdr = header;
+        this.setSizes(
+            this.options.width ?? this.hdr.width,
+            this.options.height ?? this.hdr.height
+        );
+    }
+
+    /**
+     * Handler for GIF Graphic Control Extension (GCE) data
+     */
+    handleGCE (gce) {
+        this.pushFrame(gce.delayTime);
+        this.clear();
+        this.transparency = gce.transparencyGiven ? gce.transparencyIndex : null;
+        this.disposal_method = gce.disposalMethod;
+    }
+
+    /**
+     * Handler for when the end of the GIF's file has been reached
+     */
+    handleEOF (stream) {
+        this.doDecodeProgress(stream, false);
+        if (!(this.options.width && this.options.height)) {
+            this.canvas.width = this.hdr.width * this.getCanvasScale();
+            this.canvas.height = this.hdr.height * this.getCanvasScale();
+        }
+        this.initPlayer();
+        !this.options.autoplay && this.drawPlayIcon();
+    }
+
+    pushFrame (delay) {
+        if (!this.frame) return;
+        this.frames.push({
+            data: this.frame.getImageData(0, 0, this.hdr.width, this.hdr.height),
+            delay,
+        });
+        this.frame_offsets.push({ x: 0, y: 0 });
+    }
+
+    doImg (img) {
+        this.frame = this.frame || this.offscreenCanvas.getContext('2d');
+        const currIdx = this.frames.length;
+
+        //ct = color table, gct = global color table
+        const ct = img.lctFlag ? img.lct : this.hdr.gct; // TODO: What if neither exists?
+
+        /*
+         *  Disposal method indicates the way in which the graphic is to
+         *  be treated after being displayed.
+         *
+         *  Values :    0 - No disposal specified. The decoder is
+         *                  not required to take any action.
+         *              1 - Do not dispose. The graphic is to be left
+         *                  in place.
+         *              2 - Restore to background color. The area used by the
+         *                  graphic must be restored to the background color.
+         *              3 - Restore to previous. The decoder is required to
+         *                  restore the area overwritten by the graphic with
+         *                  what was there prior to rendering the graphic.
+         *
+         *                  Importantly, "previous" means the frame state
+         *                  after the last disposal of method 0, 1, or 2.
+         */
+        if (currIdx > 0) {
+            if (this.last_disposal_method === 3) {
+                // Restore to previous
+                // If we disposed every frame including first frame up to this point, then we have
+                // no composited frame to restore to. In this case, restore to background instead.
+                if (this.disposal_restore_from_idx !== null) {
+                    this.frame.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0);
+                } else {
+                    this.frame.clearRect(
+                        this.last_img.leftPos,
+                        this.last_img.topPos,
+                        this.last_img.width,
+                        this.last_img.height
+                    );
+                }
+            } else {
+                this.disposal_restore_from_idx = currIdx - 1;
+            }
+
+            if (this.last_disposal_method === 2) {
+                // Restore to background color
+                // Browser implementations historically restore to transparent; we do the same.
+                // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079
+                this.frame.clearRect(
+                    this.last_img.leftPos,
+                    this.last_img.topPos,
+                    this.last_img.width,
+                    this.last_img.height
+                );
+            }
+        }
+        // else, Undefined/Do not dispose.
+        // frame contains final pixel data from the last frame; do nothing
+
+        //Get existing pixels for img region after applying disposal method
+        const imgData = this.frame.getImageData(img.leftPos, img.topPos, img.width, img.height);
+
+        //apply color table colors
+        img.pixels.forEach((pixel, i) => {
+            // imgData.data === [R,G,B,A,R,G,B,A,...]
+            if (pixel !== this.transparency) {
+                imgData.data[i * 4 + 0] = ct[pixel][0];
+                imgData.data[i * 4 + 1] = ct[pixel][1];
+                imgData.data[i * 4 + 2] = ct[pixel][2];
+                imgData.data[i * 4 + 3] = 255; // Opaque.
+            }
+        });
+
+        this.frame.putImageData(imgData, img.leftPos, img.topPos);
+
+        if (!this.ctx_scaled) {
+            this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
+            this.ctx_scaled = true;
+        }
+
+        if (!this.last_img) {
+            // This is the first receivd image, so we draw it
+            this.ctx.drawImage(this.offscreenCanvas, 0, 0);
+        }
+        this.last_img = img;
+    }
+
+    /**
+     * Draws a gif frame at a specific index inside the canvas.
+     * @param { Number } i - The frame index
+     */
+    putFrame (i, show_pause_on_hover=true) {
+        i = parseInt(i, 10);
+        if (i > this.frames.length - 1) {
+            i = 0;
+        }
+        if (i < 0) {
+            i = 0;
+        }
+        const offset = this.frame_offsets[i];
+        this.offscreenCanvas.getContext('2d').putImageData(this.frames[i].data, offset.x, offset.y);
+        this.ctx.globalCompositeOperation = 'copy';
+        this.ctx.drawImage(this.offscreenCanvas, 0, 0);
+
+        if (show_pause_on_hover) {
+            this.hovering && this.drawPauseIcon();
+        }
+    }
+
+    clear () {
+        this.transparency = null;
+        this.last_disposal_method = this.disposal_method;
+        this.disposal_method = null;
+        this.frame = null;
+    }
+
+    /**
+     * Start playing the gif
+     */
+    play () {
+        this.playing = true;
+        requestAnimationFrame(() => this.doStep());
+    }
+
+    /**
+     * Pause the gif
+     */
+    pause () {
+        this.playing = false;
+        requestAnimationFrame(() => this.drawPlayIcon())
+    }
+
+    drawPauseIcon () {
+        if (!this.playing) {
+            return;
+        }
+        // Clear the potential play button by re-rendering the current frame
+        this.putFrame(this.getCurrentFrame(), false);
+
+        this.ctx.globalCompositeOperation = 'source-over';
+
+        // Draw dark overlay
+        this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
+        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+        const icon_size = this.canvas.height*0.1;
+
+        // Draw bars
+        this.ctx.lineWidth = this.canvas.height*0.04;
+        this.ctx.beginPath();
+        this.ctx.moveTo(this.canvas.width/2-icon_size/2, this.canvas.height/2-icon_size);
+        this.ctx.lineTo(this.canvas.width/2-icon_size/2, this.canvas.height/2+icon_size);
+        this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+        this.ctx.stroke();
+
+        this.ctx.beginPath();
+        this.ctx.moveTo(this.canvas.width/2+icon_size/2, this.canvas.height/2-icon_size);
+        this.ctx.lineTo(this.canvas.width/2+icon_size/2, this.canvas.height/2+icon_size);
+        this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+        this.ctx.stroke();
+
+        // Draw circle
+        this.ctx.lineWidth = this.canvas.height*0.02;
+        this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
+        this.ctx.beginPath();
+        this.ctx.arc(
+            this.canvas.width/2,
+            this.canvas.height/2,
+            icon_size*1.5,
+            0,
+            2*Math.PI
+        );
+        this.ctx.stroke();
+    }
+
+    drawPlayIcon () {
+        if (this.playing) {
+            return;
+        }
+
+        // Clear the potential pause button by re-rendering the current frame
+        this.putFrame(this.getCurrentFrame(), false);
+
+        this.ctx.globalCompositeOperation = 'source-over';
+
+        // Draw dark overlay
+        this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
+        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+        // Draw triangle
+        const triangle_size = this.canvas.height*0.1;
+        const region = new Path2D();
+        region.moveTo(this.canvas.width/2+triangle_size, this.canvas.height/2); // start at the pointy end
+        region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2+triangle_size);
+        region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2-triangle_size);
+        region.closePath();
+        this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+        this.ctx.fill(region);
+
+        // Draw circle
+        const circle_size = triangle_size*1.5;
+        this.ctx.lineWidth = this.canvas.height*0.02;
+        this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
+        this.ctx.beginPath();
+        this.ctx.arc(
+            this.canvas.width/2,
+            this.canvas.height/2,
+            circle_size,
+            0,
+            2*Math.PI
+        );
+        this.ctx.stroke();
+    }
+
+    doDecodeProgress (stream, draw) {
+        this.doShowProgress(stream.pos, stream.data.length, draw);
+    }
+
+    /**
+     * @param{boolean=} draw Whether to draw progress bar or not;
+     *  this is not idempotent because of translucency.
+     *  Note that this means that the text will be unsynchronized
+     *  with the progress bar on non-frames;
+     *  but those are typically so small (GCE etc.) that it doesn't really matter
+     */
+    withProgress (stream, fn, draw) {
+        return block => {
+            fn?.(block);
+            this.doDecodeProgress(stream, draw);
+        };
+    }
+
+    getCanvasScale () {
+        let scale;
+        if (this.options.max_width && this.hdr && this.hdr.width > this.options.max_width) {
+            scale = this.options.max_width / this.hdr.width;
+        } else {
+            scale = 1;
+        }
+        return scale;
+    }
+
+    /**
+     * Makes an HTTP request to fetch a GIF
+     * @param { String } url
+     * @returns { Promise<String> } Returns a promise which resolves with the response data.
+     */
+    fetchGIF (url) {
+        const promise = getOpenPromise();
+        const h = new XMLHttpRequest();
+        h.open('GET', url, true);
+        h?.overrideMimeType('text/plain; charset=x-user-defined');
+        h.onload = () => {
+            if (h.status != 200) {
+                this.showError('xhr - response');
+                return promise.reject();
+            }
+            promise.resolve(h.response);
+        };
+        h.onprogress = (e) => (e.lengthComputable && this.doShowProgress(e.loaded, e.total, true));
+        h.onerror = () => this.showError('xhr');
+        h.send();
+        return promise;
+    }
+}

+ 43 - 0
src/shared/gif/stream.js

@@ -0,0 +1,43 @@
+
+export default class Stream {
+
+    constructor (data) {
+        if (data.toString().indexOf('ArrayBuffer') > 0) {
+            data = new Uint8Array(data);
+        }
+        this.data = data;
+        this.len = this.data.length;
+        this.pos = 0;
+    }
+
+    readByte () {
+        if (this.pos >= this.data.length) {
+            throw new Error('Attempted to read past end of stream.');
+        }
+        if (this.data instanceof Uint8Array)
+            return this.data[this.pos++];
+        else
+            return this.data.charCodeAt(this.pos++) & 0xFF;
+    }
+
+    readBytes (n) {
+        const bytes = [];
+        for (let i = 0; i < n; i++) {
+            bytes.push(this.readByte());
+        }
+        return bytes;
+    }
+
+    read (n) {
+        let s = '';
+        for (let i = 0; i < n; i++) {
+            s += String.fromCharCode(this.readByte());
+        }
+        return s;
+    }
+
+    readUnsigned () { // Little-endian.
+        const a = this.readBytes(2);
+        return (a[1] << 8) + a[0];
+    }
+}

+ 318 - 0
src/shared/gif/utils.js

@@ -0,0 +1,318 @@
+/**
+ * @copyright Shachaf Ben-Kiki and the Converse.js contributors
+ * @description
+ *  Started as a fork of Shachaf Ben-Kiki's jsgif library
+ *  https://github.com/shachaf/jsgif
+ * @license MIT License
+ */
+
+function bitsToNum (ba) {
+    return ba.reduce(function (s, n) {
+        return s * 2 + n;
+    }, 0);
+}
+
+function byteToBitArr (bite) {
+    const a = [];
+    for (let i = 7; i >= 0; i--) {
+        a.push( !! (bite & (1 << i)));
+    }
+    return a;
+}
+
+function lzwDecode (minCodeSize, data) {
+    // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String?
+    let pos = 0; // Maybe this streaming thing should be merged with the Stream?
+    function readCode (size) {
+        let code = 0;
+        for (let i = 0; i < size; i++) {
+            if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) {
+                code |= 1 << i;
+            }
+            pos++;
+        }
+        return code;
+    }
+
+    const output = [];
+    const clearCode = 1 << minCodeSize;
+    const eoiCode = clearCode + 1;
+
+    let codeSize = minCodeSize + 1;
+    let dict = [];
+
+    const clear = function () {
+        dict = [];
+        codeSize = minCodeSize + 1;
+        for (let i = 0; i < clearCode; i++) {
+            dict[i] = [i];
+        }
+        dict[clearCode] = [];
+        dict[eoiCode] = null;
+    };
+
+    let code;
+    let last;
+
+    while (true) { // eslint-disable-line no-constant-condition
+        last = code;
+        code = readCode(codeSize);
+
+        if (code === clearCode) {
+            clear();
+            continue;
+        }
+        if (code === eoiCode) break;
+
+        if (code < dict.length) {
+            if (last !== clearCode) {
+                dict.push(dict[last].concat(dict[code][0]));
+            }
+        }
+        else {
+            if (code !== dict.length) throw new Error('Invalid LZW code.');
+            dict.push(dict[last].concat(dict[last][0]));
+        }
+        output.push.apply(output, dict[code]);
+
+        if (dict.length === (1 << codeSize) && codeSize < 12) {
+            // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long.
+            codeSize++;
+        }
+    }
+    // I don't know if this is technically an error, but some GIFs do it.
+    //if (Math.ceil(pos / 8) !== data.length) throw new Error('Extraneous LZW bytes.');
+    return output;
+}
+
+
+function readSubBlocks (st) {
+    let size, data;
+    data = '';
+    do {
+        size = st.readByte();
+        data += st.read(size);
+    } while (size !== 0);
+    return data;
+}
+
+/**
+ * Parses GIF image color table information
+ * @param { Stream } st
+ * @param { Number } entries
+ */
+function parseCT (st, entries) { // Each entry is 3 bytes, for RGB.
+    const ct = [];
+    for (let i = 0; i < entries; i++) {
+        ct.push(st.readBytes(3));
+    }
+    return ct;
+}
+
+/**
+ * Parses GIF image information
+ * @param { Stream } st
+ * @param { ByteStream } img
+ * @param { Function } [callback]
+ */
+function parseImg (st, img, callback) {
+    function deinterlace (pixels, width) {
+        // Of course this defeats the purpose of interlacing. And it's *probably*
+        // the least efficient way it's ever been implemented. But nevertheless...
+        const newPixels = new Array(pixels.length);
+        const rows = pixels.length / width;
+        function cpRow (toRow, fromRow) {
+            const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width);
+            newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels));
+        }
+
+        // See appendix E.
+        const offsets = [0, 4, 2, 1];
+        const steps = [8, 8, 4, 2];
+        let fromRow = 0;
+        for (let pass = 0; pass < 4; pass++) {
+            for (let toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
+                cpRow(toRow, fromRow)
+                fromRow++;
+            }
+        }
+        return newPixels;
+    }
+
+    img.leftPos = st.readUnsigned();
+    img.topPos = st.readUnsigned();
+    img.width = st.readUnsigned();
+    img.height = st.readUnsigned();
+
+    const bits = byteToBitArr(st.readByte());
+    img.lctFlag = bits.shift();
+    img.interlaced = bits.shift();
+    img.sorted = bits.shift();
+    img.reserved = bits.splice(0, 2);
+    img.lctSize = bitsToNum(bits.splice(0, 3));
+
+    if (img.lctFlag) {
+        img.lct = parseCT(st, 1 << (img.lctSize + 1));
+    }
+    img.lzwMinCodeSize = st.readByte();
+
+    const lzwData = readSubBlocks(st);
+    img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData);
+
+    if (img.interlaced) { // Move
+        img.pixels = deinterlace(img.pixels, img.width);
+    }
+    callback?.(img);
+}
+
+/**
+ * Parses GIF header information
+ * @param { Stream } st
+ * @param { Function } [callback]
+ */
+function parseHeader (st, callback) {
+    const hdr = {};
+    hdr.sig = st.read(3);
+    hdr.ver = st.read(3);
+    if (hdr.sig !== 'GIF') {
+        throw new Error('Not a GIF file.');
+    }
+    hdr.width = st.readUnsigned();
+    hdr.height = st.readUnsigned();
+
+    const bits = byteToBitArr(st.readByte());
+    hdr.gctFlag = bits.shift();
+    hdr.colorRes = bitsToNum(bits.splice(0, 3));
+    hdr.sorted = bits.shift();
+    hdr.gctSize = bitsToNum(bits.splice(0, 3));
+
+    hdr.bgColor = st.readByte();
+    hdr.pixelAspectRatio = st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
+    if (hdr.gctFlag) {
+        hdr.gct = parseCT(st, 1 << (hdr.gctSize + 1));
+    }
+    callback?.(hdr);
+}
+
+function parseExt (st, block, handler) {
+
+    function parseGCExt (block) {
+        st.readByte(); // blocksize, always 4
+        const bits = byteToBitArr(st.readByte());
+        block.reserved = bits.splice(0, 3); // Reserved; should be 000.
+        block.disposalMethod = bitsToNum(bits.splice(0, 3));
+        block.userInput = bits.shift();
+        block.transparencyGiven = bits.shift();
+        block.delayTime = st.readUnsigned();
+        block.transparencyIndex = st.readByte();
+        block.terminator = st.readByte();
+        handler?.gce(block);
+    }
+
+    function parseComExt (block) {
+        block.comment = readSubBlocks(st);
+        handler.com && handler.com(block);
+    }
+
+    function parsePTExt (block) {
+        // No one *ever* uses this. If you use it, deal with parsing it yourself.
+        st.readByte(); // blocksize, always 12
+        block.ptHeader = st.readBytes(12);
+        block.ptData = readSubBlocks(st);
+        handler.pte && handler.pte(block);
+    }
+
+    function parseAppExt (block) {
+        function parseNetscapeExt (block) {
+            st.readByte(); // blocksize, always 3
+            block.unknown = st.readByte(); // ??? Always 1? What is this?
+            block.iterations = st.readUnsigned();
+            block.terminator = st.readByte();
+            handler.app && handler.app.NETSCAPE && handler.app.NETSCAPE(block);
+        }
+
+        function parseUnknownAppExt (block) {
+            block.appData = readSubBlocks(st);
+            // FIXME: This won't work if a handler wants to match on any identifier.
+            handler.app && handler.app[block.identifier] && handler.app[block.identifier](block);
+        }
+
+        st.readByte(); // blocksize, always 11
+        block.identifier = st.read(8);
+        block.authCode = st.read(3);
+        switch (block.identifier) {
+            case 'NETSCAPE':
+                parseNetscapeExt(block);
+                break;
+            default:
+                parseUnknownAppExt(block);
+                break;
+        }
+    }
+
+    function parseUnknownExt (block) {
+        block.data = readSubBlocks(st);
+        handler.unknown && handler.unknown(block);
+    }
+
+    block.label = st.readByte();
+    switch (block.label) {
+        case 0xF9:
+            block.extType = 'gce';
+            parseGCExt(block);
+            break;
+        case 0xFE:
+            block.extType = 'com';
+            parseComExt(block);
+            break;
+        case 0x01:
+            block.extType = 'pte';
+            parsePTExt(block);
+            break;
+        case 0xFF:
+            block.extType = 'app';
+            parseAppExt(block);
+            break;
+        default:
+            block.extType = 'unknown';
+            parseUnknownExt(block);
+            break;
+    }
+}
+
+/**
+ * @param { Stream } st
+ * @param { GIFParserHandlers } handler
+ */
+function parseBlock (st, handler) {
+    const block = {}
+    block.sentinel = st.readByte();
+    switch (String.fromCharCode(block.sentinel)) { // For ease of matching
+        case '!':
+            block.type = 'ext';
+            parseExt(st, block, handler);
+            break;
+        case ',':
+            block.type = 'img';
+            parseImg(st, block, handler?.img);
+            break;
+        case ';':
+            block.type = 'eof';
+            handler?.eof(block);
+            break;
+        default:
+            throw new Error('Unknown block: 0x' + block.sentinel.toString(16)); // TODO: Pad this with a 0.
+    }
+    if (block.type !== 'eof') setTimeout(() => parseBlock(st, handler), 0);
+}
+
+/**
+ * Takes a Stream and parses it for GIF data, calling the relevant handler
+ * methods on the passed in `handler` object.
+ * @param { Stream } st
+ * @param { GIFParserHandlers } handler
+ */
+export function parseGIF (st, handler={}) {
+    parseHeader(st, handler?.hdr);
+    setTimeout(() => parseBlock(st, handler), 0);
+}

+ 9 - 4
src/shared/rich-text.js

@@ -1,7 +1,9 @@
 import log from '@converse/headless/log';
 import tpl_audio from 'templates/audio.js';
+import tpl_gif from 'templates/gif.js';
 import tpl_image from 'templates/image.js';
 import tpl_video from 'templates/video.js';
+import { URL_PARSE_OPTIONS } from '@converse/headless/shared/constants.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
 import { getHyperlinkTemplate } from 'utils/html.js';
@@ -16,6 +18,7 @@ import {
     isAudioDomainAllowed,
     isAudioURL,
     isEncryptedFileURL,
+    isGIFURL,
     isImageDomainAllowed,
     isImageURL,
     isVideoDomainAllowed,
@@ -94,7 +97,6 @@ export class RichText extends String {
      */
     addHyperlinks (text, offset) {
         const objs = [];
-        const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
         try {
             URI.withinString(
                 text,
@@ -102,7 +104,7 @@ export class RichText extends String {
                     objs.push({ url, start, end });
                     return url;
                 },
-                parse_options
+                URL_PARSE_OPTIONS
             );
         } catch (error) {
             log.debug(error);
@@ -110,10 +112,13 @@ export class RichText extends String {
         }
 
         objs.filter(o => !isEncryptedFileURL(text.slice(o.start, o.end))).forEach(url_obj => {
-            const url_text = text.slice(url_obj.start, url_obj.end);
+            const url_text = url_obj.url;
             const filtered_url = filterQueryParamsFromURL(url_text);
             let template;
-            if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
+
+            if (this.show_images && isGIFURL(url_text) && isImageDomainAllowed(url_text)) {
+                template = tpl_gif(filtered_url);
+            } else if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
                 template = tpl_image({
                     'url': filtered_url,
                     'onClick': this.onImgClick,

+ 4 - 0
src/templates/gif.js

@@ -0,0 +1,4 @@
+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>`;