/collapse
@@ -787,10 +691,13 @@
- /part
+ /part [channel]
-
Close the current channel or private message window.
+
+ Close the specified channel or private message window, or the
+ current channel if channel
is ommitted.
+
Aliases: /close
, /leave
diff --git a/client/js/clipboard.js b/client/js/clipboard.js
new file mode 100644
index 00000000..8b8ba81b
--- /dev/null
+++ b/client/js/clipboard.js
@@ -0,0 +1,38 @@
+"use strict";
+
+const $ = require("jquery");
+const chat = document.getElementById("chat");
+
+function copyMessages() {
+ const selection = window.getSelection();
+
+ // If selection does not span multiple elements, do nothing
+ if (selection.anchorNode === selection.focusNode) {
+ return;
+ }
+
+ const range = selection.getRangeAt(0);
+ const documentFragment = range.cloneContents();
+ const div = document.createElement("div");
+
+ $(documentFragment)
+ .find(".from .user")
+ .each((_, el) => {
+ el = $(el);
+ el.text(`<${el.text()}>`);
+ });
+
+ div.id = "js-copy-hack";
+ div.appendChild(documentFragment);
+ chat.appendChild(div);
+
+ selection.selectAllChildren(div);
+
+ window.setTimeout(() => {
+ chat.removeChild(div);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }, 0);
+}
+
+$(chat).on("copy", ".messages", copyMessages);
diff --git a/client/js/condensed.js b/client/js/condensed.js
index aa1c8808..8f98fcda 100644
--- a/client/js/condensed.js
+++ b/client/js/condensed.js
@@ -23,6 +23,12 @@ function updateText(condensed, addedTypes) {
constants.condensedTypes.forEach((type) => {
if (obj[type]) {
switch (type) {
+ case "away":
+ strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away"));
+ break;
+ case "back":
+ strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back"));
+ break;
case "join":
strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel"));
break;
diff --git a/client/js/constants.js b/client/js/constants.js
index ec3745c4..3f408d0a 100644
--- a/client/js/constants.js
+++ b/client/js/constants.js
@@ -37,6 +37,7 @@ const commands = [
"/join",
"/kick",
"/leave",
+ "/list",
"/me",
"/mode",
"/msg",
@@ -59,6 +60,8 @@ const commands = [
];
const actionTypes = [
+ "away",
+ "back",
"ban_list",
"invite",
"join",
@@ -76,6 +79,8 @@ const actionTypes = [
];
const condensedTypes = [
+ "away",
+ "back",
"join",
"part",
"quit",
diff --git a/client/js/keybinds.js b/client/js/keybinds.js
new file mode 100644
index 00000000..c98a73de
--- /dev/null
+++ b/client/js/keybinds.js
@@ -0,0 +1,101 @@
+"use strict";
+
+const $ = require("jquery");
+const Mousetrap = require("mousetrap");
+const input = $("#input");
+const sidebar = $("#sidebar");
+const windows = $("#windows");
+const contextMenuContainer = $("#context-menu-container");
+
+Mousetrap.bind([
+ "pageup",
+ "pagedown"
+], function(e, key) {
+ let container = windows.find(".window.active");
+
+ // Chat windows scroll message container
+ if (container.attr("id") === "chat-container") {
+ container = container.find(".chan.active .chat");
+ }
+
+ container.finish();
+
+ const offset = container.get(0).clientHeight * 0.9;
+ let scrollTop = container.scrollTop();
+
+ if (key === "pageup") {
+ scrollTop = Math.floor(scrollTop - offset);
+ } else {
+ scrollTop = Math.ceil(scrollTop + offset);
+ }
+
+ container.animate({
+ scrollTop: scrollTop
+ }, 200);
+
+ return false;
+});
+
+Mousetrap.bind([
+ "command+up",
+ "command+down",
+ "ctrl+up",
+ "ctrl+down"
+], function(e, keys) {
+ const channels = sidebar.find(".chan");
+ const index = channels.index(channels.filter(".active"));
+ const direction = keys.split("+").pop();
+ let target;
+
+ switch (direction) {
+ case "up":
+ target = (channels.length + (index - 1 + channels.length)) % channels.length;
+ break;
+
+ case "down":
+ target = (channels.length + (index + 1 + channels.length)) % channels.length;
+ break;
+ }
+
+ channels.eq(target).click();
+});
+
+Mousetrap.bind([
+ "escape"
+], function() {
+ contextMenuContainer.hide();
+});
+
+const colorsHotkeys = {
+ k: "\x03",
+ b: "\x02",
+ u: "\x1F",
+ i: "\x1D",
+ o: "\x0F",
+};
+
+for (const hotkey in colorsHotkeys) {
+ Mousetrap.bind([
+ "command+" + hotkey,
+ "ctrl+" + hotkey
+ ], function(e) {
+ e.preventDefault();
+
+ const cursorPosStart = input.prop("selectionStart");
+ const cursorPosEnd = input.prop("selectionEnd");
+ const value = input.val();
+ let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key];
+
+ if (cursorPosStart === cursorPosEnd) {
+ // If no text is selected, insert at cursor
+ newValue += value.substring(cursorPosEnd, value.length);
+ } else {
+ // If text is selected, insert formatting character at start and the end
+ newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length);
+ }
+
+ input
+ .val(newValue)
+ .get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1);
+ });
+}
diff --git a/client/js/lounge.js b/client/js/lounge.js
index d56aedd8..2ffa9f6d 100644
--- a/client/js/lounge.js
+++ b/client/js/lounge.js
@@ -4,24 +4,23 @@
require("jquery-ui/ui/widgets/sortable");
const $ = require("jquery");
const moment = require("moment");
-const Mousetrap = require("mousetrap");
const URI = require("urijs");
const fuzzy = require("fuzzy");
// our libraries
require("./libs/jquery/inputhistory");
require("./libs/jquery/stickyscroll");
-const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber");
const slideoutMenu = require("./libs/slideout");
const templates = require("../views");
const socket = require("./socket");
require("./socket-events");
const storage = require("./localStorage");
-const options = require("./options");
+require("./options");
const utils = require("./utils");
-const modules = require("./modules");
require("./autocompletion");
require("./webpush");
+require("./keybinds");
+require("./clipboard");
$(function() {
var sidebar = $("#sidebar, #footer");
@@ -29,20 +28,6 @@ $(function() {
$(document.body).data("app-name", document.title);
- var pop;
- try {
- pop = new Audio();
- pop.src = "audio/pop.ogg";
- } catch (e) {
- pop = {
- play: $.noop
- };
- }
-
- $("#play").on("click", function() {
- pop.play();
- });
-
var windows = $("#windows");
var viewport = $("#viewport");
var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]);
@@ -183,6 +168,10 @@ $(function() {
});
}
+ if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) {
+ $(document.body).addClass("is-apple");
+ }
+
$("#form").on("submit", function(e) {
e.preventDefault();
utils.forceFocus();
@@ -199,22 +188,19 @@ $(function() {
const separatorPos = text.indexOf(" ");
const cmd = text.substring(1, separatorPos > 1 ? separatorPos : text.length);
const parameters = separatorPos > text.indexOf(cmd) ? text.substring(text.indexOf(cmd) + cmd.length + 1, text.length) : "";
- switch (cmd) {
- case "clear":
- if (modules.clear()) return;
- break;
- case "collapse":
- if (modules.collapse()) return;
- break;
- case "expand":
- if (modules.expand()) return;
- break;
- case "join":
+ if (typeof utils[cmd] === "function") {
+ if (cmd === "join") {
const channel = parameters.split(" ")[0];
- if (channel != "") {
- if (modules.join(channel)) return;
+ if (channel !== "") {
+ if (utils[cmd](channel)) {
+ return;
+ }
}
- break;
+ } else {
+ if (utils[cmd]()) {
+ return;
+ }
+ }
}
}
@@ -327,16 +313,16 @@ $(function() {
const state = {};
if (self.hasClass("chan")) {
- state.clickTarget = `.chan[data-id="${self.data("id")}"]`;
+ state.clickTarget = `#sidebar .chan[data-id="${self.data("id")}"]`;
} else {
state.clickTarget = `#footer button[data-target="${target}"]`;
}
if (history && history.pushState) {
if (data && data.replaceHistory && history.replaceState) {
- history.replaceState(state, null, null);
+ history.replaceState(state, null, target);
} else {
- history.pushState(state, null, null);
+ history.pushState(state, null, target);
}
}
});
@@ -382,6 +368,7 @@ $(function() {
lastActiveChan
.find(".unread-marker")
+ .data("unread-id", 0)
.appendTo(lastActiveChan.find(".messages"));
var chan = $(target)
@@ -433,7 +420,7 @@ $(function() {
if (chan.hasClass("lobby")) {
cmd = "/quit";
var server = chan.find(".name").html();
- if (!confirm("Disconnect from " + server + "?")) {
+ if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert
return false;
}
}
@@ -490,95 +477,6 @@ $(function() {
container.html(templates.user_filtered({matches: result})).show();
});
- chat.on("msg", ".messages", function(e, target, msg) {
- var unread = msg.unread;
- msg = msg.msg;
-
- if (msg.self) {
- return;
- }
-
- var button = sidebar.find(".chan[data-target='" + target + "']");
- if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) {
- if (!document.hasFocus() || !$(target).hasClass("active")) {
- if (options.notification) {
- try {
- pop.play();
- } catch (exception) {
- // On mobile, sounds can not be played without user interaction.
- }
- }
- utils.toggleNotificationMarkers(true);
-
- if (options.desktopNotifications && Notification.permission === "granted") {
- var title;
- var body;
-
- if (msg.type === "invite") {
- title = "New channel invite:";
- body = msg.from + " invited you to " + msg.channel;
- } else {
- title = msg.from;
- if (!button.hasClass("query")) {
- title += " (" + button.data("title").trim() + ")";
- }
- if (msg.type === "message") {
- title += " says:";
- }
- body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim();
- }
-
- try {
- var notify = new Notification(title, {
- body: body,
- icon: "img/logo-64.png",
- tag: target
- });
- notify.addEventListener("click", function() {
- window.focus();
- button.click();
- this.close();
- });
- } catch (exception) {
- // `new Notification(...)` is not supported and should be silenced.
- }
- }
- }
- }
-
- if (button.hasClass("active")) {
- return;
- }
-
- if (!unread) {
- return;
- }
-
- var badge = button.find(".badge").html(helpers_roundBadgeNumber(unread));
-
- if (msg.highlight) {
- badge.addClass("highlight");
- }
- });
-
- chat.on("click", ".show-more-button", function() {
- var self = $(this);
- var lastMessage = self.parent().next(".messages").children(".msg").first();
- if (lastMessage.is(".condensed")) {
- lastMessage = lastMessage.children(".msg").first();
- }
- var lastMessageId = parseInt(lastMessage[0].id.replace("msg-", ""), 10);
-
- self
- .text("Loading older messages…")
- .prop("disabled", true);
-
- socket.emit("more", {
- target: self.data("id"),
- lastId: lastMessageId
- });
- });
-
var forms = $("#sign-in, #connect, #change-password");
windows.on("show", "#sign-in", function() {
@@ -590,7 +488,8 @@ $(function() {
}
});
});
- if ($("body").hasClass("public")) {
+
+ if ($("body").hasClass("public") && window.location.hash === "#connect") {
$("#connect").one("show", function() {
var params = URI(document.location.search);
params = params.search(true);
@@ -620,23 +519,25 @@ $(function() {
e.preventDefault();
var event = "auth";
var form = $(this);
- form.find(".btn")
- .attr("disabled", true)
- .end();
+ form.find(".btn").attr("disabled", true);
+
if (form.closest(".window").attr("id") === "connect") {
event = "conn";
} else if (form.closest("div").attr("id") === "change-password") {
event = "change-password";
}
+
var values = {};
$.each(form.serializeArray(), function(i, obj) {
if (obj.value !== "") {
values[obj.name] = obj.value;
}
});
+
if (values.user) {
storage.set("user", values.user);
}
+
socket.emit(
event, values
);
@@ -664,111 +565,6 @@ $(function() {
$(this).data("lastvalue", nick);
});
- (function HotkeysScope() {
- Mousetrap.bind([
- "pageup",
- "pagedown"
- ], function(e, key) {
- let container = windows.find(".window.active");
-
- // Chat windows scroll message container
- if (container.attr("id") === "chat-container") {
- container = container.find(".chan.active .chat");
- }
-
- container.finish();
-
- const offset = container.get(0).clientHeight * 0.9;
- let scrollTop = container.scrollTop();
-
- if (key === "pageup") {
- scrollTop = Math.floor(scrollTop - offset);
- } else {
- scrollTop = Math.ceil(scrollTop + offset);
- }
-
- container.animate({
- scrollTop: scrollTop
- }, 200);
-
- return false;
- });
-
- Mousetrap.bind([
- "command+up",
- "command+down",
- "ctrl+up",
- "ctrl+down"
- ], function(e, keys) {
- var channels = sidebar.find(".chan");
- var index = channels.index(channels.filter(".active"));
- var direction = keys.split("+").pop();
- switch (direction) {
- case "up":
- // Loop
- var upTarget = (channels.length + (index - 1 + channels.length)) % channels.length;
- channels.eq(upTarget).click();
- break;
-
- case "down":
- // Loop
- var downTarget = (channels.length + (index + 1 + channels.length)) % channels.length;
- channels.eq(downTarget).click();
- break;
- }
- });
-
- Mousetrap.bind([
- "command+shift+l",
- "ctrl+shift+l"
- ], function(e) {
- if (e.target === input[0]) {
- utils.clear();
- e.preventDefault();
- }
- });
-
- Mousetrap.bind([
- "escape"
- ], function() {
- contextMenuContainer.hide();
- });
-
- var colorsHotkeys = {
- k: "\x03",
- b: "\x02",
- u: "\x1F",
- i: "\x1D",
- o: "\x0F",
- };
-
- for (var hotkey in colorsHotkeys) {
- Mousetrap.bind([
- "command+" + hotkey,
- "ctrl+" + hotkey
- ], function(e) {
- e.preventDefault();
-
- const cursorPosStart = input.prop("selectionStart");
- const cursorPosEnd = input.prop("selectionEnd");
- const value = input.val();
- let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key];
-
- if (cursorPosStart === cursorPosEnd) {
- // If no text is selected, insert at cursor
- newValue += value.substring(cursorPosEnd, value.length);
- } else {
- // If text is selected, insert formatting character at start and the end
- newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length);
- }
-
- input
- .val(newValue)
- .get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1);
- });
- }
- }());
-
$(document).on("visibilitychange focus click", () => {
if (sidebar.find(".highlight").length === 0) {
utils.toggleNotificationMarkers(false);
@@ -786,7 +582,7 @@ $(function() {
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
.closest(".date-marker-container")
.each(function() {
- $(this).replaceWith(templates.date_marker({msgDate: $(this).data("timestamp")}));
+ $(this).replaceWith(templates.date_marker({time: $(this).data("time")}));
});
// This should always be 24h later but re-computing exact value just in case
@@ -794,20 +590,33 @@ $(function() {
}
setTimeout(updateDateMarkers, msUntilNextDay());
- // Only start opening socket.io connection after all events have been registered
- socket.open();
-
window.addEventListener("popstate", (e) => {
const {state} = e;
if (!state) {
return;
}
- const {clickTarget} = state;
+ let {clickTarget} = state;
+
if (clickTarget) {
+ // This will be true when click target corresponds to opening a thumbnail,
+ // browsing to the previous/next thumbnail, or closing the image viewer.
+ const imageViewerRelated = clickTarget.includes(".toggle-thumbnail");
+
+ // If the click target is not related to the image viewer but the viewer
+ // is currently opened, we need to close it.
+ if (!imageViewerRelated && $("#image-viewer").hasClass("opened")) {
+ clickTarget += ", #image-viewer";
+ }
+
+ // Emit the click to the target, while making sure it is not going to be
+ // added to the state again.
$(clickTarget).trigger("click", {
pushState: false
});
}
});
+
+ // Only start opening socket.io connection after all events have been registered
+ socket.open();
});
diff --git a/client/js/modules.js b/client/js/modules.js
deleted file mode 100644
index dab0a22c..00000000
--- a/client/js/modules.js
+++ /dev/null
@@ -1,35 +0,0 @@
-"use strict";
-
-// vendor libraries
-const $ = require("jquery");
-
-// our libraries
-const utils = require("./utils");
-
-module.exports = {
- clear,
- collapse,
- expand,
- join
-};
-
-function clear() {
- utils.clear();
-}
-
-function collapse() {
- $(".chan.active .toggle-button.opened").click();
-}
-
-function expand() {
- $(".chan.active .toggle-button:not(.opened)").click();
-}
-
-function join(channel) {
- var chan = utils.findCurrentNetworkChan(channel);
-
- if (chan.length) {
- chan.click();
- return true;
- }
-}
diff --git a/client/js/render.js b/client/js/render.js
index 748ef771..bb8be296 100644
--- a/client/js/render.js
+++ b/client/js/render.js
@@ -8,6 +8,7 @@ const utils = require("./utils");
const sorting = require("./sorting");
const constants = require("./constants");
const condensed = require("./condensed");
+const helpers_parse = require("./libs/handlebars/parse");
const chat = $("#chat");
const sidebar = $("#sidebar");
@@ -27,45 +28,29 @@ module.exports = {
renderNetworks,
};
-function buildChannelMessages(chanId, chanType, messages) {
+function buildChannelMessages(container, chanId, chanType, messages) {
return messages.reduce((docFragment, message) => {
appendMessage(docFragment, chanId, chanType, message);
return docFragment;
- }, $(document.createDocumentFragment()));
+ }, container);
}
function appendMessage(container, chanId, chanType, msg) {
- const renderedMessage = buildChatMessage(chanId, msg);
+ if (utils.lastMessageId < msg.id) {
+ utils.lastMessageId = msg.id;
+ }
+
+ let lastChild = container.children(".msg, .date-marker-container").last();
+ const renderedMessage = buildChatMessage(msg);
// Check if date changed
- let lastChild = container.find(".msg").last();
const msgTime = new Date(msg.time);
-
- // It's the first message in a window,
- // then just append the message and do nothing else
- if (lastChild.length === 0) {
- container
- .append(templates.date_marker({msgDate: msgTime}))
- .append(renderedMessage);
-
- return;
- }
-
- const prevMsgTime = new Date(lastChild.attr("data-time"));
- const parent = lastChild.parent();
-
- // If this message is condensed, we have to work on the wrapper
- if (parent.hasClass("condensed")) {
- lastChild = parent;
- }
+ const prevMsgTime = new Date(lastChild.data("time"));
// Insert date marker if date changed compared to previous message
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
- lastChild.after(templates.date_marker({msgDate: msgTime}));
-
- // If date changed, we don't need to do condensed logic
- container.append(renderedMessage);
- return;
+ lastChild = $(templates.date_marker({time: msg.time}));
+ container.append(lastChild);
}
// If current window is not a channel or this message is not condensable,
@@ -83,25 +68,16 @@ function appendMessage(container, chanId, chanType, msg) {
return;
}
- const newCondensed = buildChatMessage(chanId, {
- type: "condensed",
- time: msg.time,
- previews: []
- });
+ // Always create a condensed container
+ const newCondensed = $(templates.msg_condensed({time: msg.time}));
condensed.updateText(newCondensed, [msg.type]);
newCondensed.append(renderedMessage);
container.append(newCondensed);
}
-function buildChatMessage(chanId, msg) {
+function buildChatMessage(msg) {
const type = msg.type;
- let target = "#chan-" + chanId;
- if (type === "error") {
- target = "#chan-" + chat.find(".active").data("id");
- }
-
- const chan = chat.find(target);
let template = "msg";
// See if any of the custom highlight regexes match
@@ -117,8 +93,6 @@ function buildChatMessage(chanId, msg) {
template = "msg_action";
} else if (type === "unhandled") {
template = "msg_unhandled";
- } else if (type === "condensed") {
- template = "msg_condensed";
}
const renderedMessage = $(templates[template](msg));
@@ -132,17 +106,6 @@ function buildChatMessage(chanId, msg) {
renderPreview(preview, renderedMessage);
});
- if ((type === "message" || type === "action" || type === "notice") && chan.hasClass("channel")) {
- const nicks = chan.find(".users").data("nicks");
- if (nicks) {
- const find = nicks.indexOf(msg.from);
- if (find !== -1) {
- nicks.splice(find, 1);
- nicks.unshift(msg.from);
- }
- }
- }
-
return renderedMessage;
}
@@ -159,22 +122,28 @@ function renderChannel(data) {
}
function renderChannelMessages(data) {
- const documentFragment = buildChannelMessages(data.id, data.type, data.messages);
+ const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages);
const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment);
- if (data.firstUnread > 0) {
- const first = channel.find("#msg-" + data.firstUnread);
+ const template = $(templates.unread_marker());
+
+ if (data.firstUnread > 0) {
+ let first = channel.find("#msg-" + data.firstUnread);
- // TODO: If the message is far off in the history, we still need to append the marker into DOM
if (!first.length) {
- channel.prepend(templates.unread_marker());
- } else if (first.parent().hasClass("condensed")) {
- first.parent().before(templates.unread_marker());
+ template.data("unread-id", data.firstUnread);
+ channel.prepend(template);
} else {
- first.before(templates.unread_marker());
+ const parent = first.parent();
+
+ if (parent.hasClass("condensed")) {
+ first = parent;
+ }
+
+ first.before(template);
}
} else {
- channel.append(templates.unread_marker());
+ channel.append(template);
}
}
@@ -200,7 +169,7 @@ function renderChannelUsers(data) {
}
}
-function renderNetworks(data) {
+function renderNetworks(data, singleNetwork) {
sidebar.find(".empty").hide();
sidebar.find(".networks").append(
templates.network({
@@ -208,15 +177,51 @@ function renderNetworks(data) {
})
);
+ let newChannels;
const channels = $.map(data.networks, function(n) {
return n.channels;
});
+
+ if (!singleNetwork && utils.lastMessageId > -1) {
+ newChannels = [];
+
+ channels.forEach((channel) => {
+ const chan = $("#chan-" + channel.id);
+
+ if (chan.length > 0) {
+ if (chan.data("type") === "channel") {
+ chan
+ .data("needsNamesRefresh", true)
+ .find(".header .topic")
+ .html(helpers_parse(channel.topic))
+ .attr("title", channel.topic);
+ }
+
+ if (channel.messages.length > 0) {
+ const container = chan.find(".messages");
+ buildChannelMessages(container, channel.id, channel.type, channel.messages);
+
+ if (container.find(".msg").length >= 100) {
+ container.find(".show-more").addClass("show");
+ }
+
+ container.trigger("keepToBottom");
+ }
+ } else {
+ newChannels.push(channel);
+ }
+ });
+ } else {
+ newChannels = channels;
+ }
+
chat.append(
templates.chat({
channels: channels
})
);
- channels.forEach((channel) => {
+
+ newChannels.forEach((channel) => {
renderChannel(channel);
if (channel.type === "channel") {
diff --git a/client/js/renderPreview.js b/client/js/renderPreview.js
index 14edb400..ab73b070 100644
--- a/client/js/renderPreview.js
+++ b/client/js/renderPreview.js
@@ -92,10 +92,12 @@ function handleImageInPreview(content, container) {
const imageViewer = $("#image-viewer");
-$("#chat").on("click", ".toggle-thumbnail", function() {
+$("#chat").on("click", ".toggle-thumbnail", function(event, data = {}) {
const link = $(this);
- openImageViewer(link);
+ // 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.
@@ -103,8 +105,10 @@ $("#chat").on("click", ".toggle-thumbnail", function() {
return false;
});
-imageViewer.on("click", function() {
- closeImageViewer();
+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);
});
$(document).keydown(function(e) {
@@ -125,7 +129,7 @@ $(document).keydown(function(e) {
}
});
-function openImageViewer(link) {
+function openImageViewer(link, {pushState = true} = {}) {
$(".previous-image").removeClass("previous-image");
$(".next-image").removeClass("next-image");
@@ -161,7 +165,20 @@ function openImageViewer(link) {
hasNextImage: nextImage.length > 0,
}));
- imageViewer.addClass("opened");
+ // 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) {
+ const clickTarget =
+ `#${link.closest(".msg").attr("id")} ` +
+ `a.toggle-thumbnail[href="${link.attr("href")}"] ` +
+ "img";
+ history.pushState({clickTarget}, null, null);
+ }
}
imageViewer.on("click", ".previous-image-btn", function() {
@@ -174,7 +191,7 @@ imageViewer.on("click", ".next-image-btn", function() {
return false;
});
-function closeImageViewer() {
+function closeImageViewer({pushState = true} = {}) {
imageViewer
.removeClass("opened")
.one("transitionend", function() {
@@ -182,4 +199,12 @@ function closeImageViewer() {
});
input.focus();
+
+ // History management
+ if (pushState) {
+ const clickTarget =
+ "#sidebar " +
+ `.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`;
+ history.pushState({clickTarget}, null, null);
+ }
}
diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js
index ad3539da..e544948f 100644
--- a/client/js/socket-events/auth.js
+++ b/client/js/socket-events/auth.js
@@ -3,8 +3,20 @@
const $ = require("jquery");
const socket = require("../socket");
const storage = require("../localStorage");
+const utils = require("../utils");
socket.on("auth", function(data) {
+ // If we reconnected and serverHash differs, that means the server restarted
+ // And we will reload the page to grab the latest version
+ if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) {
+ socket.disconnect();
+ $("#connection-error").text("Server restarted, reloading…");
+ location.reload(true);
+ return;
+ }
+
+ utils.serverHash = data.serverHash;
+
const login = $("#sign-in");
let token;
const user = storage.get("user");
@@ -12,6 +24,13 @@ socket.on("auth", function(data) {
login.find(".btn").prop("disabled", false);
if (!data.success) {
+ if (login.length === 0) {
+ socket.disconnect();
+ $("#connection-error").text("Authentication failed, reloading…");
+ location.reload();
+ return;
+ }
+
storage.remove("token");
const error = login.find(".error");
@@ -20,9 +39,15 @@ socket.on("auth", function(data) {
});
} else if (user) {
token = storage.get("token");
+
if (token) {
- $("#loading-page-message").text("Authorizing…");
- socket.emit("auth", {user: user, token: token});
+ $("#loading-page-message, #connection-error").text("Authorizing…");
+
+ socket.emit("auth", {
+ user: user,
+ token: token,
+ lastMessage: utils.lastMessageId,
+ });
}
}
@@ -34,13 +59,9 @@ socket.on("auth", function(data) {
return;
}
- $("#footer").find(".sign-in")
+ $("#footer")
+ .find(".sign-in")
.trigger("click", {
pushState: false,
- })
- .end()
- .find(".networks")
- .html("")
- .next()
- .show();
+ });
});
diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js
index a78c07fa..ec8d4988 100644
--- a/client/js/socket-events/init.js
+++ b/client/js/socket-events/init.js
@@ -1,16 +1,28 @@
"use strict";
const $ = require("jquery");
+const escape = require("css.escape");
const socket = require("../socket");
const render = require("../render");
const webpush = require("../webpush");
const sidebar = $("#sidebar");
const storage = require("../localStorage");
+const utils = require("../utils");
socket.on("init", function(data) {
- $("#loading-page-message").text("Rendering…");
+ $("#loading-page-message, #connection-error").text("Rendering…");
+
+ const lastMessageId = utils.lastMessageId;
+ let previousActive = 0;
+
+ if (lastMessageId > -1) {
+ previousActive = sidebar.find(".active").data("id");
+ sidebar.find(".networks").empty();
+ }
if (data.networks.length === 0) {
+ sidebar.find(".empty").show();
+
$("#footer").find(".connect").trigger("click", {
pushState: false,
});
@@ -18,28 +30,59 @@ socket.on("init", function(data) {
render.renderNetworks(data);
}
- if (data.token) {
- storage.set("token", data.token);
- }
-
- webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
-
- $("body").removeClass("signed-out");
- $("#loading").remove();
- $("#sign-in").remove();
-
- const id = data.active;
- const target = sidebar.find("[data-id='" + id + "']").trigger("click", {
- replaceHistory: true
- });
- if (target.length === 0) {
- const first = sidebar.find(".chan")
- .eq(0)
- .trigger("click");
- if (first.length === 0) {
- $("#footer").find(".connect").trigger("click", {
- pushState: false,
- });
+ if (lastMessageId > -1) {
+ $("#connection-error").removeClass("shown");
+ $(".show-more-button, #input").prop("disabled", false);
+ $("#submit").show();
+ } else {
+ if (data.token) {
+ storage.set("token", data.token);
}
+
+ webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
+
+ $("body").removeClass("signed-out");
+ $("#loading").remove();
+ $("#sign-in").remove();
}
+
+ openCorrectChannel(previousActive, data.active);
});
+
+function openCorrectChannel(clientActive, serverActive) {
+ let target = $();
+
+ // Open last active channel
+ if (clientActive > 0) {
+ target = sidebar.find("[data-id='" + clientActive + "']");
+ }
+
+ // Open window provided in location.hash
+ if (target.length === 0 && window.location.hash) {
+ target = $("#footer, #sidebar").find("[data-target='" + escape(window.location.hash) + "']");
+ }
+
+ // Open last active channel according to the server
+ if (serverActive > 0 && target.length === 0) {
+ target = sidebar.find("[data-id='" + serverActive + "']");
+ }
+
+ // Open first available channel
+ if (target.length === 0) {
+ target = sidebar.find(".chan").first();
+ }
+
+ // If target channel is found, open it
+ if (target.length > 0) {
+ target.trigger("click", {
+ replaceHistory: true
+ });
+
+ return;
+ }
+
+ // Open the connect window
+ $("#footer .connect").trigger("click", {
+ pushState: false
+ });
+}
diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js
index 20d5c12d..ed4f15c4 100644
--- a/client/js/socket-events/more.js
+++ b/client/js/socket-events/more.js
@@ -33,8 +33,30 @@ socket.on("more", function(data) {
}
// Add the older messages
- const documentFragment = render.buildChannelMessages(data.chan, type, data.messages);
- chan.prepend(documentFragment).end();
+ const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages);
+ chan.prepend(documentFragment);
+
+ // Move unread marker to correct spot if needed
+ const unreadMarker = chan.find(".unread-marker");
+ const firstUnread = unreadMarker.data("unread-id");
+
+ if (firstUnread > 0) {
+ let first = chan.find("#msg-" + firstUnread);
+
+ if (!first.length) {
+ chan.prepend(unreadMarker);
+ } else {
+ const parent = first.parent();
+
+ if (parent.hasClass("condensed")) {
+ first = parent;
+ }
+
+ unreadMarker.data("unread-id", 0);
+
+ first.before(unreadMarker);
+ }
+ }
// restore scroll position
const position = chan.height() - heightOld;
@@ -54,3 +76,22 @@ socket.on("more", function(data) {
.text("Show older messages")
.prop("disabled", false);
});
+
+chat.on("click", ".show-more-button", function() {
+ const self = $(this);
+ const lastMessage = self.closest(".chat").find(".msg:not(.condensed)").first();
+ let lastMessageId = -1;
+
+ if (lastMessage.length > 0) {
+ lastMessageId = parseInt(lastMessage.attr("id").replace("msg-", ""), 10);
+ }
+
+ self
+ .text("Loading older messages…")
+ .prop("disabled", true);
+
+ socket.emit("more", {
+ target: self.data("id"),
+ lastId: lastMessageId
+ });
+});
diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js
index 6549c66c..a576451f 100644
--- a/client/js/socket-events/msg.js
+++ b/client/js/socket-events/msg.js
@@ -3,17 +3,27 @@
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
+const utils = require("../utils");
+const options = require("../options");
+const helpers_roundBadgeNumber = require("../libs/handlebars/roundBadgeNumber");
const chat = $("#chat");
+const sidebar = $("#sidebar");
+
+let pop;
+try {
+ pop = new Audio();
+ pop.src = "audio/pop.ogg";
+} catch (e) {
+ pop = {
+ play: $.noop
+ };
+}
+
+$("#play").on("click", () => pop.play());
socket.on("msg", function(data) {
- if (window.requestIdleCallback) {
- // During an idle period the user agent will run idle callbacks in FIFO order
- // until either the idle period ends or there are no more idle callbacks eligible to be run.
- // We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
- window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000});
- } else {
- processReceivedMessage(data);
- }
+ // We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
+ utils.requestIdleCallback(() => processReceivedMessage(data), 2000);
});
function processReceivedMessage(data) {
@@ -32,14 +42,13 @@ function processReceivedMessage(data) {
render.appendMessage(
container,
targetId,
- $(target).attr("data-type"),
+ channel.attr("data-type"),
data.msg
);
- container.trigger("msg", [
- target,
- data
- ]).trigger("keepToBottom");
+ container.trigger("keepToBottom");
+
+ notifyMessage(targetId, channel, data);
var lastVisible = container.find("div:visible").last();
if (data.msg.self
@@ -48,6 +57,7 @@ function processReceivedMessage(data) {
&& lastVisible.prev().hasClass("unread-marker"))) {
container
.find(".unread-marker")
+ .data("unread-id", 0)
.appendTo(container);
}
@@ -63,4 +73,83 @@ function processReceivedMessage(data) {
}
});
}
+
+ if ((data.msg.type === "message" || data.msg.type === "action" || data.msg.type === "notice") && channel.hasClass("channel")) {
+ const nicks = channel.find(".users").data("nicks");
+ if (nicks) {
+ const find = nicks.indexOf(data.msg.from);
+ if (find !== -1) {
+ nicks.splice(find, 1);
+ nicks.unshift(data.msg.from);
+ }
+ }
+ }
+}
+
+function notifyMessage(targetId, channel, msg) {
+ const unread = msg.unread;
+ msg = msg.msg;
+
+ if (msg.self) {
+ return;
+ }
+
+ const button = sidebar.find(".chan[data-id='" + targetId + "']");
+ if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) {
+ if (!document.hasFocus() || !channel.hasClass("active")) {
+ if (options.notification) {
+ try {
+ pop.play();
+ } catch (exception) {
+ // On mobile, sounds can not be played without user interaction.
+ }
+ }
+
+ utils.toggleNotificationMarkers(true);
+
+ if (options.desktopNotifications && Notification.permission === "granted") {
+ let title;
+ let body;
+
+ if (msg.type === "invite") {
+ title = "New channel invite:";
+ body = msg.from + " invited you to " + msg.channel;
+ } else {
+ title = msg.from;
+ if (!button.hasClass("query")) {
+ title += " (" + button.data("title").trim() + ")";
+ }
+ if (msg.type === "message") {
+ title += " says:";
+ }
+ body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim();
+ }
+
+ try {
+ const notify = new Notification(title, {
+ body: body,
+ icon: "img/logo-64.png",
+ tag: `lounge-${targetId}`
+ });
+ notify.addEventListener("click", function() {
+ window.focus();
+ button.click();
+ this.close();
+ });
+ } catch (exception) {
+ // `new Notification(...)` is not supported and should be silenced.
+ }
+ }
+ }
+ }
+
+ if (!unread || button.hasClass("active")) {
+ return;
+ }
+
+ const badge = button.find(".badge").html(helpers_roundBadgeNumber(unread));
+
+ if (msg.highlight) {
+ badge.addClass("highlight");
+ }
}
diff --git a/client/js/socket-events/msg_preview.js b/client/js/socket-events/msg_preview.js
index 42972d61..426bcf9b 100644
--- a/client/js/socket-events/msg_preview.js
+++ b/client/js/socket-events/msg_preview.js
@@ -3,9 +3,9 @@
const $ = require("jquery");
const renderPreview = require("../renderPreview");
const socket = require("../socket");
+const utils = require("../utils");
socket.on("msg:preview", function(data) {
- const msg = $("#msg-" + data.id);
-
- renderPreview(data.preview, msg);
+ // Previews are not as important, we can wait longer for them to appear
+ utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000);
});
diff --git a/client/js/socket-events/network.js b/client/js/socket-events/network.js
index 1fb8036f..a55b0433 100644
--- a/client/js/socket-events/network.js
+++ b/client/js/socket-events/network.js
@@ -6,7 +6,7 @@ const render = require("../render");
const sidebar = $("#sidebar");
socket.on("network", function(data) {
- render.renderNetworks(data);
+ render.renderNetworks(data, true);
sidebar.find(".chan")
.last()
@@ -14,11 +14,9 @@ socket.on("network", function(data) {
$("#connect")
.find(".btn")
- .prop("disabled", false)
- .end();
+ .prop("disabled", false);
});
socket.on("network_changed", function(data) {
sidebar.find("#network-" + data.network).data("options", data.serverOptions);
});
-
diff --git a/client/js/socket-events/quit.js b/client/js/socket-events/quit.js
index dcf1b8bd..8caa26bc 100644
--- a/client/js/socket-events/quit.js
+++ b/client/js/socket-events/quit.js
@@ -6,12 +6,12 @@ const sidebar = $("#sidebar");
socket.on("quit", function(data) {
const id = data.network;
- sidebar.find("#network-" + id)
- .remove()
- .end();
+ sidebar.find("#network-" + id).remove();
+
const chan = sidebar.find(".chan")
.eq(0)
.trigger("click");
+
if (chan.length === 0) {
sidebar.find(".empty").show();
}
diff --git a/client/js/socket.js b/client/js/socket.js
index 6f702fb1..a9916b0a 100644
--- a/client/js/socket.js
+++ b/client/js/socket.js
@@ -2,54 +2,54 @@
const $ = require("jquery");
const io = require("socket.io-client");
+const utils = require("./utils");
const path = window.location.pathname + "socket.io/";
+const status = $("#loading-page-message, #connection-error");
const socket = io({
transports: $(document.body).data("transports"),
path: path,
autoConnect: false,
- reconnection: false
+ reconnection: !$(document.body).hasClass("public")
});
-[
- "connect_error",
- "connect_failed",
- "disconnect",
- "error",
-].forEach(function(e) {
- socket.on(e, function(data) {
- $("#loading-page-message").text("Connection failed: " + data);
- $("#connection-error").addClass("shown").one("click", function() {
- window.onbeforeunload = null;
- window.location.reload();
- });
+socket.on("disconnect", handleDisconnect);
+socket.on("connect_error", handleDisconnect);
+socket.on("error", handleDisconnect);
- // Disables sending a message by pressing Enter. `off` is necessary to
- // cancel `inputhistory`, which overrides hitting Enter. `on` is then
- // necessary to avoid creating new lines when hitting Enter without Shift.
- // This is fairly hacky but this solution is not permanent.
- $("#input").off("keydown").on("keydown", function(event) {
- if (event.which === 13 && !event.shiftKey) {
- event.preventDefault();
- }
- });
- // Hides the "Send Message" button
- $("#submit").remove();
-
- console.error(data);
- });
+socket.on("reconnecting", function(attempt) {
+ status.text(`Reconnecting… (attempt ${attempt})`);
});
socket.on("connecting", function() {
- $("#loading-page-message").text("Connecting…");
+ status.text("Connecting…");
});
socket.on("connect", function() {
- $("#loading-page-message").text("Finalizing connection…");
+ // Clear send buffer when reconnecting, socket.io would emit these
+ // immediately upon connection and it will have no effect, so we ensure
+ // nothing is sent to the server that might have happened.
+ socket.sendBuffer = [];
+
+ status.text("Finalizing connection…");
});
socket.on("authorized", function() {
- $("#loading-page-message").text("Authorized, loading messages…");
+ status.text("Loading messages…");
});
+function handleDisconnect(data) {
+ const message = data.message || data;
+
+ status.text(`Waiting to reconnect… (${message})`).addClass("shown");
+ $(".show-more-button, #input").prop("disabled", true);
+ $("#submit").hide();
+
+ // If the server shuts down, socket.io skips reconnection
+ // and we have to manually call connect to start the process
+ if (socket.io.skipReconnect) {
+ utils.requestIdleCallback(() => socket.connect(), 2000);
+ }
+}
+
module.exports = socket;
diff --git a/client/js/utils.js b/client/js/utils.js
index dc0bd7fa..2eae9a29 100644
--- a/client/js/utils.js
+++ b/client/js/utils.js
@@ -4,16 +4,25 @@ const $ = require("jquery");
const chat = $("#chat");
const input = $("#input");
+var serverHash = -1;
+var lastMessageId = -1;
+
module.exports = {
findCurrentNetworkChan,
clear,
+ collapse,
+ expand,
+ join,
+ serverHash,
+ lastMessageId,
confirmExit,
forceFocus,
move,
resetHeight,
setNick,
toggleNickEditor,
- toggleNotificationMarkers
+ toggleNotificationMarkers,
+ requestIdleCallback,
};
function findCurrentNetworkChan(name) {
@@ -42,6 +51,26 @@ function clear() {
chat.find(".active")
.find(".show-more").addClass("show").end()
.find(".messages .msg, .date-marker-container").remove();
+ return true;
+}
+
+function collapse() {
+ $(".chan.active .toggle-button.opened").click();
+ return true;
+}
+
+function expand() {
+ $(".chan.active .toggle-button:not(.opened)").click();
+ return true;
+}
+
+function join(channel) {
+ var chan = findCurrentNetworkChan(channel);
+
+ if (chan.length) {
+ chan.click();
+ return true;
+ }
}
function toggleNickEditor(toggle) {
@@ -90,3 +119,13 @@ function move(array, old_index, new_index) {
array.splice(new_index, 0, array.splice(old_index, 1)[0]);
return array;
}
+
+function requestIdleCallback(callback, timeout) {
+ if (window.requestIdleCallback) {
+ // During an idle period the user agent will run idle callbacks in FIFO order
+ // until either the idle period ends or there are no more idle callbacks eligible to be run.
+ window.requestIdleCallback(callback, {timeout: timeout});
+ } else {
+ callback();
+ }
+}
diff --git a/client/service-worker.js b/client/service-worker.js
index fcb63300..641e050b 100644
--- a/client/service-worker.js
+++ b/client/service-worker.js
@@ -9,16 +9,30 @@ self.addEventListener("push", function(event) {
const payload = event.data.json();
- if (payload.type === "notification") {
- event.waitUntil(
- self.registration.showNotification(payload.title, {
- badge: "img/logo-64.png",
- icon: "img/touch-icon-192x192.png",
- body: payload.body,
- timestamp: payload.timestamp,
- })
- );
+ if (payload.type !== "notification") {
+ return;
}
+
+ // get current notification, close it, and draw new
+ event.waitUntil(
+ self.registration
+ .getNotifications({
+ tag: `chan-${payload.chanId}`
+ })
+ .then((notifications) => {
+ for (const notification of notifications) {
+ notification.close();
+ }
+
+ return self.registration.showNotification(payload.title, {
+ tag: `chan-${payload.chanId}`,
+ badge: "img/logo-64.png",
+ icon: "img/touch-icon-192x192.png",
+ body: payload.body,
+ timestamp: payload.timestamp,
+ });
+ })
+ );
});
self.addEventListener("notificationclick", function(event) {
diff --git a/client/themes/crypto.css b/client/themes/crypto.css
index 9b7bf72b..2f9f5424 100644
--- a/client/themes/crypto.css
+++ b/client/themes/crypto.css
@@ -65,12 +65,8 @@ a:hover,
background: #00ff0e;
}
-.btn-reconnect {
+#connection-error {
background: #f00;
- color: #fff;
- border: 0;
- border-radius: 0;
- margin: 0;
}
#settings .opt {
diff --git a/client/themes/example.css b/client/themes/example.css
index a8efcbf9..d9764ac1 100644
--- a/client/themes/example.css
+++ b/client/themes/example.css
@@ -46,14 +46,6 @@ body {
border-radius: 2px;
}
-.btn-reconnect {
- background: #e74c3c;
- color: #fff;
- border: 0;
- border-radius: 0;
- margin: 0;
-}
-
@media (max-width: 768px) {
#sidebar {
left: -220px;
diff --git a/client/themes/morning.css b/client/themes/morning.css
index 9067c7f9..0b576e8d 100644
--- a/client/themes/morning.css
+++ b/client/themes/morning.css
@@ -29,14 +29,6 @@ body {
background: #333c4a;
}
-#windows .header .topic,
-#windows #form .input,
-.messages .msg,
-.sidebar {
- font-family: inherit;
- font-size: 13px;
-}
-
#chat .count {
background-color: #2e3642;
}
@@ -213,14 +205,6 @@ body {
color: #99a2b4;
}
-.btn-reconnect {
- background: #e74c3c;
- color: #fff;
- border: 0;
- border-radius: 0;
- margin: 0;
-}
-
/* Form elements */
#chat-container ::-moz-placeholder {
diff --git a/client/themes/zenburn.css b/client/themes/zenburn.css
index 525110c7..e4823cdf 100644
--- a/client/themes/zenburn.css
+++ b/client/themes/zenburn.css
@@ -30,14 +30,6 @@ body {
background: #3f3f3f;
}
-#windows .header .topic,
-#windows #form .input,
-.messages .msg,
-.sidebar {
- font-family: inherit;
- font-size: 13px;
-}
-
#settings,
#sign-in,
#connect .title {
@@ -240,14 +232,6 @@ body {
color: #d2d39b;
}
-.btn-reconnect {
- background: #e74c3c;
- color: #fff;
- border: 0;
- border-radius: 0;
- margin: 0;
-}
-
/* Form elements */
#chat-container ::-moz-placeholder {
diff --git a/client/views/actions/away.tpl b/client/views/actions/away.tpl
new file mode 100644
index 00000000..f4e52519
--- /dev/null
+++ b/client/views/actions/away.tpl
@@ -0,0 +1,3 @@
+{{> ../user_name nick=from}}
+is away
+
({{{parse text}}})
diff --git a/client/views/actions/back.tpl b/client/views/actions/back.tpl
new file mode 100644
index 00000000..cb24ea5e
--- /dev/null
+++ b/client/views/actions/back.tpl
@@ -0,0 +1,2 @@
+{{> ../user_name nick=from}}
+is back
diff --git a/client/views/actions/kick.tpl b/client/views/actions/kick.tpl
index 425a62b8..d739f4c9 100644
--- a/client/views/actions/kick.tpl
+++ b/client/views/actions/kick.tpl
@@ -1,6 +1,6 @@
-{{> ../user_name nick=from}}
+{{> ../user_name nick=from.nick mode=from.mode}}
has kicked
-{{> ../user_name nick=target mode=""}}
+{{> ../user_name nick=target.nick mode=target.mode}}
{{#if text}}
({{{parse text}}})
{{/if}}
diff --git a/client/views/date-marker.tpl b/client/views/date-marker.tpl
index 9e67f09f..b1d20be7 100644
--- a/client/views/date-marker.tpl
+++ b/client/views/date-marker.tpl
@@ -1,5 +1,5 @@
-