"use strict"; const $ = require("jquery"); const debounce = require("lodash/debounce"); const Mousetrap = require("mousetrap"); const options = require("./options"); const socket = require("./socket"); const templates = require("../views"); const chat = $("#chat"); const {togglePreviewMoreButtonsIfNeeded} = require("./utils"); module.exports = renderPreview; function renderPreview(preview, msg) { if (preview.type === "loading") { return; } preview.shown = preview.shown && options.shouldOpenMessagePreview(preview.type); const template = $(templates.msg_preview({preview})); const image = template.find("img, video, audio").first(); if (image.length === 0) { return appendPreview(preview, msg, template); } const loadEvent = image.prop("tagName") === "IMG" ? "load" : "canplay"; // If there is an image in preview, wait for it to load before appending it to DOM // This is done to prevent problems keeping scroll to the bottom while images load image.on(`${loadEvent}.preview`, () => { image.off(".preview"); appendPreview(preview, msg, template); }); // If the image fails to load, remove it from DOM and still render the preview if (preview.type === "link") { image.on("abort.preview error.preview", () => { image.parent().remove(); appendPreview(preview, msg, template); }); } } function appendPreview(preview, msg, template) { const escapedLink = preview.link.replace(/["\\]/g, "\\$&"); const previewContainer = msg.find(`.preview[data-url="${escapedLink}"]`); // This is to fix a very rare case of rendering a preview twice // This happens when a very large amount of messages is being sent to the client // and they get queued, so the `preview` object on the server has time to load before // it actually gets sent to the server, which makes the loaded preview sent twice, // once in `msg` and another in `msg:preview` if (!previewContainer.is(":empty")) { return; } const container = msg.closest(".chat"); const channel = container.closest(".chan"); const channelId = channel.data("id") || -1; const activeChannelId = chat.find(".chan.active").data("id") || -2; msg.find(`.text a[href="${escapedLink}"]`) .first() .after(templates.msg_preview_toggle({preview}).trim()); previewContainer.append(template); const moreBtn = previewContainer.find(".more"); const previewContent = previewContainer.find(".toggle-content"); // Depending on the size of the preview and the text within it, show or hide a // "More" button that allows users to expand without having to open the link. // Warning: Make sure to call this only on active channel, link previews only, // expanded only. const showMoreIfNeeded = () => { const isVisible = moreBtn.is(":visible"); const shouldShow = previewContent[0].offsetWidth >= previewContainer[0].offsetWidth; if (!isVisible && shouldShow) { moreBtn.show(); } else if (isVisible && !shouldShow) { togglePreviewMore(moreBtn, false); moreBtn.hide(); } }; // "More" button only applies on text previews if (preview.type === "link") { // This event is triggered when a side menu is opened/closed, or when the // preview gets expanded/collapsed. previewContent.on("showMoreIfNeeded", () => window.requestAnimationFrame(showMoreIfNeeded) ); } if (activeChannelId === channelId) { // If this preview is in active channel, hide "More" button if necessary previewContent.trigger("showMoreIfNeeded"); container.trigger("keepToBottom"); } } // On resize, previews in the current channel that are expanded need to compute // their "More" button. Debounced handler to avoid performance cost. $(window).on("resize", debounce(togglePreviewMoreButtonsIfNeeded, 150)); $("#chat").on("click", ".text .toggle-button", function() { const self = $(this); const container = self.closest(".chat"); const content = self.closest(".content") .find(`.preview[data-url="${self.data("url")}"] .toggle-content`); const bottom = container.isScrollBottom(); self.toggleClass("opened"); content.toggleClass("show"); const isExpanded = content.hasClass("show"); if (isExpanded) { content.trigger("showMoreIfNeeded"); } // Tell the server we're toggling so it remembers at page reload // TODO Avoid sending many single events when using `/collapse` or `/expand` // See https://github.com/thelounge/thelounge/issues/1377 socket.emit("msg:preview:toggle", { target: parseInt(self.closest(".chan").data("id"), 10), msgId: parseInt(self.closest(".msg").prop("id").replace("msg-", ""), 10), link: self.data("url"), shown: isExpanded, }); // If scrollbar was at the bottom before toggling the preview, keep it at the bottom if (bottom) { container.scrollBottom(); } }); $("#chat").on("click", ".toggle-content .more", function() { togglePreviewMore($(this)); return false; }); function togglePreviewMore(moreBtn, state = undefined) { moreBtn.closest(".toggle-content").toggleClass("opened", state); const isExpanded = moreBtn.closest(".toggle-content").hasClass("opened"); moreBtn.attr("aria-expanded", isExpanded); if (isExpanded) { moreBtn.attr("aria-label", moreBtn.data("opened-text")); } else { moreBtn.attr("aria-label", moreBtn.data("closed-text")); } } /* Image viewer */ const imageViewer = $("#image-viewer"); $("#windows").on("click", ".toggle-thumbnail", function(event, data = {}) { const link = $(this); // Passing `data`, specifically `data.pushState`, to not add the action to the // history state if back or forward buttons were pressed. openImageViewer(link, data); // Prevent the link to open a new page since we're opening the image viewer, // but keep it a link to allow for Ctrl/Cmd+click. // By binding this event on #chat we prevent input gaining focus after clicking. return false; }); imageViewer.on("click", function(event, data = {}) { // Passing `data`, specifically `data.pushState`, to not add the action to the // history state if back or forward buttons were pressed. closeImageViewer(data); }); Mousetrap.bind("esc", () => closeImageViewer()); Mousetrap.bind(["left", "right"], (e, key) => { if (imageViewer.hasClass("opened")) { const direction = key === "left" ? "previous" : "next"; imageViewer.find(`.${direction}-image-btn`).trigger("click"); } }); function openImageViewer(link, {pushState = true} = {}) { $(".previous-image").removeClass("previous-image"); $(".next-image").removeClass("next-image"); // The next two blocks figure out what are the previous/next images. We first // look within the same message, as there can be multiple thumbnails per // message, and if not, we look at previous/next messages and take the // last/first thumbnail available. // Only expanded thumbnails are being cycled through. // Previous image let previousImage = link.closest(".preview").prev(".preview") .find(".toggle-content.show .toggle-thumbnail").last(); if (!previousImage.length) { previousImage = link.closest(".msg").prevAll() .find(".toggle-content.show .toggle-thumbnail").last(); } previousImage.addClass("previous-image"); // Next image let nextImage = link.closest(".preview").next(".preview") .find(".toggle-content.show .toggle-thumbnail").first(); if (!nextImage.length) { nextImage = link.closest(".msg").nextAll() .find(".toggle-content.show .toggle-thumbnail").first(); } nextImage.addClass("next-image"); imageViewer.html(templates.image_viewer({ image: link.find("img").prop("src"), link: link.prop("href"), type: link.parent().hasClass("toggle-type-link") ? "link" : "image", hasPreviousImage: previousImage.length > 0, hasNextImage: nextImage.length > 0, })); // Turn off transitionend listener before opening the viewer, // which caused image viewer to become empty in rare cases imageViewer .off("transitionend") .addClass("opened"); // History management if (pushState) { let clickTarget = ""; // Images can be in a message (channel URL previews) or not (window URL // preview, e.g. changelog). This is sub-optimal and needs improvement to // make image preview more generic and not specific for channel previews. if (link.closest(".msg").length > 0) { clickTarget = `#${link.closest(".msg").prop("id")} `; } clickTarget += `a.toggle-thumbnail[href="${link.prop("href")}"] img`; history.pushState({clickTarget}, null, null); } } imageViewer.on("click", ".previous-image-btn", function() { $(".previous-image").trigger("click"); return false; }); imageViewer.on("click", ".next-image-btn", function() { $(".next-image").trigger("click"); return false; }); function closeImageViewer({pushState = true} = {}) { imageViewer .removeClass("opened") .one("transitionend", function() { imageViewer.empty(); }); // History management if (pushState) { const clickTarget = "#sidebar " + `.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`; history.pushState({clickTarget}, null, null); } }