diff --git a/client/css/style.css b/client/css/style.css
index 0545646c..fe9b32a9 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 c3500be5..fa83f688 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 358f4e60..27faa8b8 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 a1e0acec..a0e1fa50 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 213a3cb8..88c1e311 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 be69a379..b6545192 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 c05d2cec..df3dc35e 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 00000000..8760ce0c
--- /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 9e48f72e..00000000
--- 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 f303fe26..9de19307 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 feb8686d..5dfaf86a 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 00000000..03659742
--- /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 2c8f84eb..00000000
--- a/client/views/toggle.tpl
+++ /dev/null
@@ -1,19 +0,0 @@
-{{#toggle}}
-
-{{/toggle}}
diff --git a/src/models/msg.js b/src/models/msg.js
index 3b36256e..c71aa7d6 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 04e84b75..7dd593b9 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 2dd38a5c..2a7acba5 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("toggle", function(data) {
- assert.equal(data.head, "test");
+ 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("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();
});
});