diff --git a/client/css/style.css b/client/css/style.css index 0545646cf5..fe9b32a9d5 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1096,10 +1096,6 @@ kbd { color: #f00; } -#chat .msg.toggle .time { - visibility: hidden; -} - #chat .toggle-button { background: #f5f5f5; border-radius: 2px; @@ -1110,6 +1106,10 @@ kbd { padding: 0 6px; } +#chat .toggle-button:after { + content: "···"; +} + #chat .toggle-content { background: #f5f5f5; border-radius: 2px; @@ -1122,10 +1122,6 @@ kbd { overflow: hidden; } -#chat .toggle-content a { - color: inherit; -} - #chat .toggle-content img { max-width: 100%; max-height: 128px; @@ -1937,10 +1933,6 @@ kbd { padding: 0; } - #chat .msg.toggle .time { - display: none; - } - #chat .date-marker, #chat .unread-marker { margin: 0; diff --git a/client/js/libs/jquery/stickyscroll.js b/client/js/libs/jquery/stickyscroll.js index c3500be590..fa83f68806 100644 --- a/client/js/libs/jquery/stickyscroll.js +++ b/client/js/libs/jquery/stickyscroll.js @@ -37,7 +37,7 @@ import jQuery from "jquery"; lastStick = Date.now(); this.scrollTop = this.scrollHeight; }) - .on("msg.sticky", keepToBottom) + .on("keepToBottom.sticky", keepToBottom) .scrollBottom(); return self; diff --git a/client/js/lounge.js b/client/js/lounge.js index 358f4e6097..27faa8b893 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -681,25 +681,6 @@ $(function() { }); }); - chat.on("click", ".toggle-button", function() { - var self = $(this); - var localChat = self.closest(".chat"); - var bottom = localChat.isScrollBottom(); - var content = self.parent().next(".toggle-content"); - if (bottom && !content.hasClass("show")) { - var img = content.find("img"); - if (img.length !== 0 && !img.width()) { - img.on("load", function() { - localChat.scrollBottom(); - }); - } - } - content.toggleClass("show"); - if (bottom) { - localChat.scrollBottom(); - } - }); - var forms = $("#sign-in, #connect, #change-password"); windows.on("show", "#sign-in", function() { diff --git a/client/js/options.js b/client/js/options.js index a1e0acec02..a0e1fa5083 100644 --- a/client/js/options.js +++ b/client/js/options.js @@ -30,6 +30,10 @@ const options = $.extend({ module.exports = options; +module.exports.shouldOpenMessagePreview = function(type) { + return (options.links && type === "link") || (options.thumbnails && type === "image"); +}; + for (var i in options) { if (i === "userStyles") { if (!/[?&]nocss/.test(window.location.search)) { diff --git a/client/js/render.js b/client/js/render.js index 213a3cb895..88c1e311d9 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -35,6 +35,10 @@ function buildChatMessage(data) { target = "#chan-" + chat.find(".active").data("id"); } + if (data.msg.preview) { + data.msg.preview.shown = options.shouldOpenMessagePreview(data.msg.preview.type); + } + const chan = chat.find(target); let template = "msg"; diff --git a/client/js/socket-events/index.js b/client/js/socket-events/index.js index be69a379f9..b6545192e0 100644 --- a/client/js/socket-events/index.js +++ b/client/js/socket-events/index.js @@ -6,6 +6,7 @@ require("./init"); require("./join"); require("./more"); require("./msg"); +require("./msg_preview"); require("./names"); require("./network"); require("./nick"); @@ -13,6 +14,5 @@ require("./open"); require("./part"); require("./quit"); require("./sync_sort"); -require("./toggle"); require("./topic"); require("./users"); diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js index c05d2cec66..df3dc35ef4 100644 --- a/client/js/socket-events/msg.js +++ b/client/js/socket-events/msg.js @@ -35,7 +35,8 @@ socket.on("msg", function(data) { .trigger("msg", [ target, data - ]); + ]) + .trigger("keepToBottom"); var lastVisible = container.find("div:visible").last(); if (data.msg.self diff --git a/client/js/socket-events/msg_preview.js b/client/js/socket-events/msg_preview.js new file mode 100644 index 0000000000..8760ce0cd7 --- /dev/null +++ b/client/js/socket-events/msg_preview.js @@ -0,0 +1,51 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const templates = require("../../views"); +const options = require("../options"); + +socket.on("msg:preview", function(data) { + data.preview.shown = options.shouldOpenMessagePreview(data.preview.type); + + const msg = $("#msg-" + data.id); + const container = msg.parent(".messages"); + const bottom = container.isScrollBottom(); + + msg.find(".text").append(templates.msg_preview({preview: data.preview})); + + if (data.preview.shown && bottom) { + handleImageInPreview(msg.find(".toggle-content"), container); + } + + container.trigger("keepToBottom"); +}); + +$("#chat").on("click", ".toggle-button", function() { + const self = $(this); + const container = self.closest(".messages"); + const content = self.parent().next(".toggle-content"); + const bottom = container.isScrollBottom(); + + if (bottom && !content.hasClass("show")) { + handleImageInPreview(content, container); + } + + content.toggleClass("show"); + + // If scrollbar was at the bottom before toggling the preview, keep it at the bottom + if (bottom) { + container.scrollBottom(); + } +}); + +function handleImageInPreview(content, container) { + const img = content.find("img"); + + // Trigger scroll logic after the image loads + if (img.length && !img.width()) { + img.on("load", function() { + container.trigger("keepToBottom"); + }); + } +} diff --git a/client/js/socket-events/toggle.js b/client/js/socket-events/toggle.js deleted file mode 100644 index 9e48f72e6c..0000000000 --- a/client/js/socket-events/toggle.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -const $ = require("jquery"); -const socket = require("../socket"); -const templates = require("../../views"); -const options = require("../options"); - -socket.on("toggle", function(data) { - const toggle = $("#toggle-" + data.id); - toggle.parent().after(templates.toggle({toggle: data})); - switch (data.type) { - case "link": - if (options.links) { - toggle.click(); - } - break; - - case "image": - if (options.thumbnails) { - toggle.click(); - } - break; - } -}); diff --git a/client/views/index.js b/client/views/index.js index f303fe266f..9de19307e3 100644 --- a/client/views/index.js +++ b/client/views/index.js @@ -25,9 +25,9 @@ module.exports = { date_marker: require("./date-marker.tpl"), msg: require("./msg.tpl"), msg_action: require("./msg_action.tpl"), + msg_preview: require("./msg_preview.tpl"), msg_unhandled: require("./msg_unhandled.tpl"), network: require("./network.tpl"), - toggle: require("./toggle.tpl"), unread_marker: require("./unread_marker.tpl"), user: require("./user.tpl"), user_filtered: require("./user_filtered.tpl"), diff --git a/client/views/msg.tpl b/client/views/msg.tpl index feb8686d31..5dfaf86af0 100644 --- a/client/views/msg.tpl +++ b/client/views/msg.tpl @@ -7,17 +7,10 @@ {{> user_name nick=from}} {{/if}} - {{#equal type "toggle"}} - -
- -
- {{#if toggle}} - {{> toggle}} - {{/if}} -
- {{else}} - {{{parse text}}} - {{/equal}} + + {{~{parse text}~}} + {{#if preview}} + {{> msg_preview}} + {{/if}} diff --git a/client/views/msg_preview.tpl b/client/views/msg_preview.tpl new file mode 100644 index 0000000000..03659742bb --- /dev/null +++ b/client/views/msg_preview.tpl @@ -0,0 +1,18 @@ +{{#preview}} +
+ +
+ + {{#equal type "image"}} + + {{else}} + {{#if thumb}} + + {{/if}} +
{{head}}
+
+ {{body}} +
+ {{/equal}} +
+{{/preview}} diff --git a/client/views/toggle.tpl b/client/views/toggle.tpl deleted file mode 100644 index 2c8f84eb03..0000000000 --- a/client/views/toggle.tpl +++ /dev/null @@ -1,19 +0,0 @@ -{{#toggle}} -
- {{#equal type "image"}} - - - - {{else}} - - {{#if thumb}} - - {{/if}} -
{{head}}
-
- {{body}} -
-
- {{/equal}} -
-{{/toggle}} diff --git a/src/models/msg.js b/src/models/msg.js index 3b36256e7b..c71aa7d61c 100644 --- a/src/models/msg.js +++ b/src/models/msg.js @@ -16,7 +16,6 @@ Msg.Type = { NOTICE: "notice", PART: "part", QUIT: "quit", - TOGGLE: "toggle", CTCP: "ctcp", TOPIC: "topic", TOPIC_SET_BY: "topic_set_by", diff --git a/src/plugins/irc-events/link.js b/src/plugins/irc-events/link.js index 04e84b759a..7dd593b992 100644 --- a/src/plugins/irc-events/link.js +++ b/src/plugins/irc-events/link.js @@ -1,35 +1,29 @@ "use strict"; const cheerio = require("cheerio"); -const Msg = require("../../models/msg"); const request = require("request"); const Helper = require("../../helper"); +const findLinks = require("../../../client/js/libs/handlebars/ircmessageparser/findLinks"); const es = require("event-stream"); process.setMaxListeners(0); -module.exports = function(client, chan, originalMsg) { +module.exports = function(client, chan, msg) { if (!Helper.config.prefetch) { return; } - const links = originalMsg.text - .replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, "") - .split(" ") - .filter((w) => /^https?:\/\//.test(w)); + // Remove all IRC formatting characters before searching for links + const cleanText = msg.text.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, ""); + + // We will only try to prefetch http(s) links + const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link)); if (links.length === 0) { return; } - const msg = new Msg({ - type: Msg.Type.TOGGLE, - time: originalMsg.time, - self: originalMsg.self, - }); - chan.pushMessage(client, msg); - - const link = escapeHeader(links[0]); + const link = escapeHeader(links[0].link); fetch(link, function(res) { if (res === null) { return; @@ -40,8 +34,7 @@ module.exports = function(client, chan, originalMsg) { }; function parse(msg, url, res, client) { - var toggle = msg.toggle = { - id: msg.id, + const preview = { type: "", head: "", body: "", @@ -52,35 +45,35 @@ function parse(msg, url, res, client) { switch (res.type) { case "text/html": var $ = cheerio.load(res.text); - toggle.type = "link"; - toggle.head = + preview.type = "link"; + preview.head = $("meta[property=\"og:title\"]").attr("content") || $("title").text() || ""; - toggle.body = + preview.body = $("meta[property=\"og:description\"]").attr("content") || $("meta[name=\"description\"]").attr("content") || ""; - toggle.thumb = + preview.thumb = $("meta[property=\"og:image\"]").attr("content") || $("meta[name=\"twitter:image:src\"]").attr("content") || ""; // Make sure thumbnail is a valid url - if (!/^https?:\/\//.test(toggle.thumb)) { - toggle.thumb = ""; + if (!/^https?:\/\//.test(preview.thumb)) { + preview.thumb = ""; } // Verify that thumbnail pic exists and is under allowed size - if (toggle.thumb.length) { - fetch(escapeHeader(toggle.thumb), (resThumb) => { + if (preview.thumb.length) { + fetch(escapeHeader(preview.thumb), (resThumb) => { if (resThumb === null || !(/^image\/.+/.test(resThumb.type)) || resThumb.size > (Helper.config.prefetchMaxImageSize * 1024)) { - toggle.thumb = ""; + preview.thumb = ""; } - emitToggle(client, toggle); + emitPreview(client, msg, preview); }); return; @@ -93,7 +86,7 @@ function parse(msg, url, res, client) { case "image/jpg": case "image/jpeg": if (res.size < (Helper.config.prefetchMaxImageSize * 1024)) { - toggle.type = "image"; + preview.type = "image"; } else { return; } @@ -103,21 +96,26 @@ function parse(msg, url, res, client) { return; } - emitToggle(client, toggle); + emitPreview(client, msg, preview); } -function emitToggle(client, toggle) { +function emitPreview(client, msg, preview) { // If there is no title but there is preview or description, set title // otherwise bail out and show no preview - if (!toggle.head.length) { - if (toggle.thumb.length || toggle.body.length) { - toggle.head = "Untitled page"; + if (!preview.head.length && preview.type === "link") { + if (preview.thumb.length || preview.body.length) { + preview.head = "Untitled page"; } else { return; } } - client.emit("toggle", toggle); + msg.preview = preview; + + client.emit("msg:preview", { + id: msg.id, + preview: preview + }); } function fetch(url, cb) { diff --git a/test/plugins/link.js b/test/plugins/link.js index 2dd38a5c6e..2a7acba575 100644 --- a/test/plugins/link.js +++ b/test/plugins/link.js @@ -4,10 +4,14 @@ var assert = require("assert"); var util = require("../util"); var link = require("../../src/plugins/irc-events/link.js"); +const path = require("path"); describe("Link plugin", function() { before(function(done) { this.app = util.createWebserver(); + this.app.get("/real-test-image.png", function(req, res) { + res.sendFile(path.resolve(__dirname, "../../client/img/apple-touch-icon-120x120.png")); + }); this.connection = this.app.listen(9002, done); }); @@ -28,11 +32,132 @@ describe("Link plugin", function() { link(this.irc, this.network.channels[0], message); this.app.get("/basic", function(req, res) { - res.send("test"); + res.send("test title"); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.type, "link"); + assert.equal(data.preview.head, "test title"); + assert.equal(data.preview.body, "simple description"); + done(); + }); + }); + + it("should prefer og:title over title", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/basic-og" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/basic-og", function(req, res) { + res.send("test"); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.head, "opengraph test"); + done(); + }); + }); + + it("should prefer og:description over description", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/description-og" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/description-og", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.body, "opengraph description"); + done(); + }); + }); + + it("should find og:image with full url", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb", function(req, res) { + res.send("Google"); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.head, "Google"); + assert.equal(data.preview.thumb, "http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should not use thumbnail with invalid url", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/invalid-thumb" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/invalid-thumb", function(req, res) { + res.send("test"); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.thumb, ""); + done(); + }); + }); + + it("should send untitled page if there is a thumbnail", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-no-title" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-no-title", function(req, res) { + res.send(""); + }); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.head, "Untitled page"); + assert.equal(data.preview.thumb, "http://localhost:9002/real-test-image.png"); + done(); + }); + }); + + it("should not send thumbnail if image is 404", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/thumb-404" + }); + + link(this.irc, this.network.channels[0], message); + + this.app.get("/thumb-404", function(req, res) { + res.send("404 image"); }); - this.irc.once("toggle", function(data) { - assert.equal(data.head, "test"); + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.head, "404 image"); + assert.equal(data.preview.thumb, ""); + done(); + }); + }); + + it("should send image preview", function(done) { + const message = this.irc.createMessage({ + text: "http://localhost:9002/real-test-image.png" + }); + + link(this.irc, this.network.channels[0], message); + + this.irc.once("msg:preview", function(data) { + assert.equal(data.preview.type, "image"); + assert.equal(data.preview.link, "http://localhost:9002/real-test-image.png"); done(); }); });