diff --git a/client/js/lounge.js b/client/js/lounge.js index 9379697b..358f4e60 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -14,13 +14,14 @@ const emojiMap = require("./libs/simplemap.json"); require("./libs/jquery/inputhistory"); require("./libs/jquery/stickyscroll"); require("./libs/jquery/tabcomplete"); -const helpers_parse = require("./libs/handlebars/parse"); const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber"); const slideoutMenu = require("./libs/slideout"); const templates = require("../views"); const socket = require("./socket"); +require("./socket-events"); const constants = require("./constants"); const storage = require("./localStorage"); +const utils = require("./utils"); $(function() { var sidebar = $("#sidebar, #footer"); @@ -28,8 +29,6 @@ $(function() { $(document.body).data("app-name", document.title); - var ignoreSortSync = false; - var pop; try { pop = new Audio(); @@ -44,8 +43,6 @@ $(function() { pop.play(); }); - var favicon = $("#favicon"); - // Autocompletion Strategies const emojiStrategy = { @@ -152,507 +149,6 @@ $(function() { index: 2 }; - socket.on("auth", function(data) { - var login = $("#sign-in"); - var token; - - login.find(".btn").prop("disabled", false); - - if (!data.success) { - storage.remove("token"); - - var error = login.find(".error"); - error.show().closest("form").one("submit", function() { - error.hide(); - }); - } else { - token = storage.get("token"); - if (token) { - $("#loading-page-message").text("Authorizing…"); - socket.emit("auth", {token: token}); - } - } - - var input = login.find("input[name='user']"); - if (input.val() === "") { - input.val(storage.get("user") || ""); - } - if (token) { - return; - } - sidebar.find(".sign-in") - .trigger("click", { - pushState: false, - }) - .end() - .find(".networks") - .html("") - .next() - .show(); - }); - - socket.on("change-password", function(data) { - var passwordForm = $("#change-password"); - if (data.error || data.success) { - var message = data.success ? data.success : data.error; - var feedback = passwordForm.find(".feedback"); - - if (data.success) { - feedback.addClass("success").removeClass("error"); - } else { - feedback.addClass("error").removeClass("success"); - } - - feedback.text(message).show(); - feedback.closest("form").one("submit", function() { - feedback.hide(); - }); - } - - if (data.token && storage.get("token") !== null) { - storage.set("token", data.token); - } - - passwordForm - .find("input") - .val("") - .end() - .find(".btn") - .prop("disabled", false); - }); - - socket.on("init", function(data) { - $("#loading-page-message").text("Rendering…"); - - if (data.networks.length === 0) { - $("#footer").find(".connect").trigger("click", { - pushState: false, - }); - } else { - renderNetworks(data); - } - - if (data.token && $("#sign-in-remember").is(":checked")) { - storage.set("token", data.token); - } else { - storage.remove("token"); - } - - $("body").removeClass("signed-out"); - $("#loading").remove(); - $("#sign-in").remove(); - - var id = data.active; - var target = sidebar.find("[data-id='" + id + "']").trigger("click", { - replaceHistory: true - }); - if (target.length === 0) { - var first = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (first.length === 0) { - $("#footer").find(".connect").trigger("click", { - pushState: false, - }); - } - } - }); - - socket.on("open", function(id) { - // Another client opened the channel, clear the unread counter - sidebar.find(".chan[data-id='" + id + "'] .badge") - .removeClass("highlight") - .empty(); - }); - - socket.on("join", function(data) { - var id = data.network; - var network = sidebar.find("#network-" + id); - network.append( - templates.chan({ - channels: [data.chan] - }) - ); - chat.append( - templates.chat({ - channels: [data.chan] - }) - ); - renderChannel(data.chan); - - // Queries do not automatically focus, unless the user did a whois - if (data.chan.type === "query" && !data.shouldOpen) { - return; - } - - sidebar.find(".chan") - .sort(function(a, b) { - return $(a).data("id") - $(b).data("id"); - }) - .last() - .click(); - }); - - function buildChatMessage(data) { - var type = data.msg.type; - var target = "#chan-" + data.chan; - if (type === "error") { - target = "#chan-" + chat.find(".active").data("id"); - } - - var chan = chat.find(target); - var template = "msg"; - - if (!data.msg.highlight && !data.msg.self && (type === "message" || type === "notice") && options.highlights.some(function(h) { - return data.msg.text.toLocaleLowerCase().indexOf(h.toLocaleLowerCase()) > -1; - })) { - data.msg.highlight = true; - } - - if ([ - "invite", - "join", - "mode", - "kick", - "nick", - "part", - "quit", - "topic", - "topic_set_by", - "action", - "whois", - "ctcp", - "channel_list", - "ban_list", - ].indexOf(type) !== -1) { - template = "msg_action"; - } else if (type === "unhandled") { - template = "msg_unhandled"; - } - - var msg = $(templates[template](data.msg)); - var text = msg.find(".text"); - - if (template === "msg_action") { - text.html(templates.actions[type](data.msg)); - } - - if ((type === "message" || type === "action") && chan.hasClass("channel")) { - var nicks = chan.find(".users").data("nicks"); - if (nicks) { - var find = nicks.indexOf(data.msg.from); - if (find !== -1) { - nicks.splice(find, 1); - nicks.unshift(data.msg.from); - } - } - } - - return msg; - } - - function buildChannelMessages(channel, messages) { - return messages.reduce(function(docFragment, message) { - docFragment.append(buildChatMessage({ - chan: channel, - msg: message - })); - return docFragment; - }, $(document.createDocumentFragment())); - } - - function renderChannel(data) { - renderChannelMessages(data); - - if (data.type === "channel") { - renderChannelUsers(data); - } - } - - function renderChannelMessages(data) { - var documentFragment = buildChannelMessages(data.id, data.messages); - var channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment); - - if (data.firstUnread > 0) { - var 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 { - first.before(templates.unread_marker()); - } - } else { - channel.append(templates.unread_marker()); - } - - if (data.type !== "lobby") { - var lastDate; - $(chat.find("#chan-" + data.id + " .messages .msg[data-time]")).each(function() { - var msg = $(this); - var msgDate = new Date(msg.attr("data-time")); - - // Top-most message in a channel - if (!lastDate) { - lastDate = msgDate; - msg.before(templates.date_marker({msgDate: msgDate})); - } - - if (lastDate.toDateString() !== msgDate.toDateString()) { - msg.before(templates.date_marker({msgDate: msgDate})); - } - - lastDate = msgDate; - }); - } - } - - function renderChannelUsers(data) { - var users = chat.find("#chan-" + data.id).find(".users"); - var nicks = users.data("nicks") || []; - var i, oldSortOrder = {}; - - for (i in nicks) { - oldSortOrder[nicks[i]] = i; - } - - nicks = []; - - for (i in data.users) { - nicks.push(data.users[i].nick); - } - - nicks = nicks.sort(function(a, b) { - return (oldSortOrder[a] || Number.MAX_VALUE) - (oldSortOrder[b] || Number.MAX_VALUE); - }); - - const search = users - .find(".search") - .attr("placeholder", nicks.length + " " + (nicks.length === 1 ? "user" : "users")); - - users - .data("nicks", nicks) - .find(".names-original") - .html(templates.user(data)); - - // Refresh user search - if (search.val().length) { - search.trigger("input"); - } - } - - function renderNetworks(data) { - sidebar.find(".empty").hide(); - sidebar.find(".networks").append( - templates.network({ - networks: data.networks - }) - ); - - var channels = $.map(data.networks, function(n) { - return n.channels; - }); - chat.append( - templates.chat({ - channels: channels - }) - ); - channels.forEach(renderChannel); - - confirmExit(); - sortable(); - - if (sidebar.find(".highlight").length) { - toggleNotificationMarkers(true); - } - } - - socket.on("msg", function(data) { - var msg = buildChatMessage(data); - var target = "#chan-" + data.chan; - var container = chat.find(target + " .messages"); - - if (data.msg.type === "channel_list" || data.msg.type === "ban_list") { - $(container).empty(); - } - - // Check if date changed - var prevMsg = $(container.find(".msg")).last(); - var prevMsgTime = new Date(prevMsg.attr("data-time")); - var msgTime = new Date(msg.attr("data-time")); - - // It's the first message in a channel/query - if (prevMsg.length === 0) { - container.append(templates.date_marker({msgDate: msgTime})); - } - - if (prevMsgTime.toDateString() !== msgTime.toDateString()) { - prevMsg.after(templates.date_marker({msgDate: msgTime})); - } - - // Add message to the container - container - .append(msg) - .trigger("msg", [ - target, - data - ]); - var lastVisible = container.find("div:visible").last(); - if (data.msg.self - || lastVisible.hasClass("unread-marker") - || (lastVisible.hasClass("date-marker") - && lastVisible.prev().hasClass("unread-marker"))) { - container - .find(".unread-marker") - .appendTo(container); - } - }); - - socket.on("more", function(data) { - var documentFragment = buildChannelMessages(data.chan, data.messages); - var chan = chat - .find("#chan-" + data.chan) - .find(".messages"); - - // get the scrollable wrapper around messages - var scrollable = chan.closest(".chat"); - var heightOld = chan.height(); - - // Remove the date-change marker we put at the top, because it may - // not actually be a date change now - var children = $(chan).children(); - if (children.eq(0).hasClass("date-marker-container")) { // Check top most child - children.eq(0).remove(); - } else if (children.eq(1).hasClass("date-marker-container")) { - // The unread-marker could be at index 0, which will cause the date-marker to become "stuck" - children.eq(1).remove(); - } - - // Add the older messages - chan.prepend(documentFragment).end(); - - // restore scroll position - var position = chan.height() - heightOld; - scrollable.scrollTop(position); - - if (data.messages.length !== 100) { - scrollable.find(".show-more").removeClass("show"); - } - - // Date change detect - // Have to use data instaid of the documentFragment because it's being weird - var lastDate; - $(data.messages).each(function() { - var msgData = this; - var msgDate = new Date(msgData.time); - var msg = $(chat.find("#chan-" + data.chan + " .messages #msg-" + msgData.id)); - - // Top-most message in a channel - if (!lastDate) { - lastDate = msgDate; - msg.before(templates.date_marker({msgDate: msgDate})); - } - - if (lastDate.toDateString() !== msgDate.toDateString()) { - msg.before(templates.date_marker({msgDate: msgDate})); - } - - lastDate = msgDate; - }); - - scrollable.find(".show-more-button").prop("disabled", false); - }); - - socket.on("network", function(data) { - renderNetworks(data); - - sidebar.find(".chan") - .last() - .trigger("click"); - - $("#connect") - .find(".btn") - .prop("disabled", false) - .end(); - }); - - socket.on("network_changed", function(data) { - sidebar.find("#network-" + data.network).data("options", data.serverOptions); - }); - - socket.on("nick", function(data) { - var id = data.network; - var nick = data.nick; - var network = sidebar.find("#network-" + id).data("nick", nick); - if (network.find(".active").length) { - setNick(nick); - } - }); - - socket.on("part", function(data) { - var chanMenuItem = sidebar.find(".chan[data-id='" + data.chan + "']"); - - // When parting from the active channel/query, jump to the network's lobby - if (chanMenuItem.hasClass("active")) { - chanMenuItem.parent(".network").find(".lobby").click(); - } - - chanMenuItem.remove(); - $("#chan-" + data.chan).remove(); - }); - - socket.on("quit", function(data) { - var id = data.network; - sidebar.find("#network-" + id) - .remove() - .end(); - var chan = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (chan.length === 0) { - sidebar.find(".empty").show(); - } - }); - - socket.on("toggle", function(data) { - var 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; - } - }); - - socket.on("topic", function(data) { - var topic = $("#chan-" + data.chan).find(".header .topic"); - topic.html(helpers_parse(data.topic)); - // .attr() is safe escape-wise but consider the capabilities of the attribute - topic.attr("title", data.topic); - }); - - socket.on("users", function(data) { - var chan = chat.find("#chan-" + data.chan); - - if (chan.hasClass("active")) { - socket.emit("names", { - target: data.chan - }); - } else { - chan.data("needsNamesRefresh", true); - } - }); - - socket.on("names", renderChannelUsers); - var options = require("./options"); var windows = $("#windows"); @@ -811,15 +307,9 @@ $(function() { }); } - // Triggering click event opens the virtual keyboard on mobile - // This can only be called from another interactive event (e.g. button click) - var forceFocus = function() { - input.trigger("click").focus(); - }; - $("#form").on("submit", function(e) { e.preventDefault(); - forceFocus(); + utils.forceFocus(); var text = input.val(); if (text.length === 0) { @@ -830,7 +320,7 @@ $(function() { resetInputHeight(input.get(0)); if (text.indexOf("/clear") === 0) { - clear(); + utils.clear(); return; } @@ -853,7 +343,7 @@ $(function() { } $("button#set-nick").on("click", function() { - toggleNickEditor(true); + utils.toggleNickEditor(true); // Selects existing nick in the editable text field var element = document.querySelector("#nick-value"); @@ -868,11 +358,6 @@ $(function() { $("button#cancel-nick").on("click", cancelNick); $("button#submit-nick").on("click", submitNick); - function toggleNickEditor(toggle) { - $("#nick").toggleClass("editable", toggle); - $("#nick-value").attr("contenteditable", toggle); - } - function submitNick() { var newNick = $("#nick-value").text().trim(); @@ -881,7 +366,7 @@ $(function() { return; } - toggleNickEditor(false); + utils.toggleNickEditor(false); socket.emit("input", { target: chat.data("id"), @@ -890,7 +375,7 @@ $(function() { } function cancelNick() { - setNick(sidebar.find(".chan.active").closest(".network").data("nick")); + utils.setNick(sidebar.find(".chan.active").closest(".network").data("nick")); } $("#nick-value").keypress(function(e) { @@ -993,7 +478,7 @@ $(function() { .empty(); if (sidebar.find(".highlight").length === 0) { - toggleNotificationMarkers(false); + utils.toggleNotificationMarkers(false); } sidebarSlide.toggle(false); @@ -1031,7 +516,7 @@ $(function() { if (self.hasClass("chan")) { $("#chat-container").addClass("active"); - setNick(self.closest(".network").data("nick")); + utils.setNick(self.closest(".network").data("nick")); } var chanChat = chan.find(".chat"); @@ -1133,7 +618,7 @@ $(function() { // On mobile, sounds can not be played without user interaction. } } - toggleNotificationMarkers(true); + utils.toggleNotificationMarkers(true); if (options.desktopNotifications && Notification.permission === "granted") { var title; @@ -1359,7 +844,7 @@ $(function() { "ctrl+shift+l" ], function(e) { if (e.target === input[0]) { - clear(); + utils.clear(); e.preventDefault(); } }); @@ -1422,12 +907,6 @@ $(function() { }); }, 1000 * 10); - function clear() { - chat.find(".active") - .find(".show-more").addClass("show").end() - .find(".messages .msg, .date-marker-container").remove(); - } - function completeNicks(word) { const users = chat.find(".active .users"); @@ -1470,141 +949,9 @@ $(function() { ); } - function confirmExit() { - if ($("body").hasClass("public")) { - window.onbeforeunload = function() { - return "Are you sure you want to navigate away from this page?"; - }; - } - } - - function sortable() { - sidebar.find(".networks").sortable({ - axis: "y", - containment: "parent", - cursor: "move", - distance: 12, - items: ".network", - handle: ".lobby", - placeholder: "network-placeholder", - forcePlaceholderSize: true, - tolerance: "pointer", // Use the pointer to figure out where the network is in the list - - update: function() { - var order = []; - sidebar.find(".network").each(function() { - var id = $(this).data("id"); - order.push(id); - }); - socket.emit( - "sort", { - type: "networks", - order: order - } - ); - - ignoreSortSync = true; - } - }); - sidebar.find(".network").sortable({ - axis: "y", - containment: "parent", - cursor: "move", - distance: 12, - items: ".chan:not(.lobby)", - placeholder: "chan-placeholder", - forcePlaceholderSize: true, - tolerance: "pointer", // Use the pointer to figure out where the channel is in the list - - update: function(e, ui) { - var order = []; - var network = ui.item.parent(); - network.find(".chan").each(function() { - var id = $(this).data("id"); - order.push(id); - }); - socket.emit( - "sort", { - type: "channels", - target: network.data("id"), - order: order - } - ); - - ignoreSortSync = true; - } - }); - } - - socket.on("sync_sort", function(data) { - // Syncs the order of channels or networks when they are reordered - if (ignoreSortSync) { - ignoreSortSync = false; - return; // Ignore syncing because we 'caused' it - } - - var type = data.type; - var order = data.order; - - if (type === "networks") { - var container = $(".networks"); - - $.each(order, function(index, value) { - var position = $(container.children()[index]); - - if (position.data("id") === value) { // Network in correct place - return true; // No point in continuing - } - - var network = container.find("#network-" + value); - - $(network).insertBefore(position); - }); - } else if (type === "channels") { - var network = $("#network-" + data.target); - - $.each(order, function(index, value) { - if (index === 0) { // Shouldn't attempt to move lobby - return true; // same as `continue` -> skip to next item - } - - var position = $(network.children()[index]); // Target channel at position - - if (position.data("id") === value) { // Channel in correct place - return true; // No point in continuing - } - - var channel = network.find(".chan[data-id=" + value + "]"); // Channel at position - - $(channel).insertBefore(position); - }); - } - }); - - function setNick(nick) { - // Closes the nick editor when canceling, changing channel, or when a nick - // is set in a different tab / browser / device. - toggleNickEditor(false); - - $("#nick-value").text(nick); - } - - function toggleNotificationMarkers(newState) { - // Toggles the favicon to red when there are unread notifications - if (favicon.data("toggled") !== newState) { - var old = favicon.attr("href"); - favicon.attr("href", favicon.data("other")); - favicon.data("other", old); - favicon.data("toggled", newState); - } - - // Toggles a dot on the menu icon when there are unread notifications - $("#viewport .lt").toggleClass("notified", newState); - } - $(document).on("visibilitychange focus click", () => { if (sidebar.find(".highlight").length === 0) { - toggleNotificationMarkers(false); + utils.toggleNotificationMarkers(false); } }); diff --git a/client/js/options.js b/client/js/options.js index cbc79653..a1e0acec 100644 --- a/client/js/options.js +++ b/client/js/options.js @@ -1,4 +1,5 @@ "use strict"; + const $ = require("jquery"); const settings = $("#settings"); const userStyles = $("#user-specified-css"); diff --git a/client/js/render.js b/client/js/render.js new file mode 100644 index 00000000..213a3cb8 --- /dev/null +++ b/client/js/render.js @@ -0,0 +1,193 @@ +"use strict"; + +const $ = require("jquery"); +const templates = require("../views"); +const options = require("./options"); +const utils = require("./utils"); +const sorting = require("./sorting"); + +const chat = $("#chat"); +const sidebar = $("#sidebar"); + +module.exports = { + buildChannelMessages, + buildChatMessage, + renderChannel, + renderChannelMessages, + renderChannelUsers, + renderNetworks +}; + +function buildChannelMessages(channel, messages) { + return messages.reduce(function(docFragment, message) { + docFragment.append(buildChatMessage({ + chan: channel, + msg: message + })); + return docFragment; + }, $(document.createDocumentFragment())); +} + +function buildChatMessage(data) { + const type = data.msg.type; + let target = "#chan-" + data.chan; + if (type === "error") { + target = "#chan-" + chat.find(".active").data("id"); + } + + const chan = chat.find(target); + let template = "msg"; + + if (!data.msg.highlight && !data.msg.self && (type === "message" || type === "notice") && options.highlights.some(function(h) { + return data.msg.text.toLocaleLowerCase().indexOf(h.toLocaleLowerCase()) > -1; + })) { + data.msg.highlight = true; + } + + if ([ + "invite", + "join", + "mode", + "kick", + "nick", + "part", + "quit", + "topic", + "topic_set_by", + "action", + "whois", + "ctcp", + "channel_list", + "ban_list", + ].indexOf(type) !== -1) { + template = "msg_action"; + } else if (type === "unhandled") { + template = "msg_unhandled"; + } + + const msg = $(templates[template](data.msg)); + const text = msg.find(".text"); + + if (template === "msg_action") { + text.html(templates.actions[type](data.msg)); + } + + if ((type === "message" || type === "action") && chan.hasClass("channel")) { + const nicks = chan.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); + } + } + } + + return msg; +} + +function renderChannel(data) { + renderChannelMessages(data); + + if (data.type === "channel") { + renderChannelUsers(data); + } +} + +function renderChannelMessages(data) { + const documentFragment = buildChannelMessages(data.id, data.messages); + const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment); + + if (data.firstUnread > 0) { + const 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 { + first.before(templates.unread_marker()); + } + } else { + channel.append(templates.unread_marker()); + } + + if (data.type !== "lobby") { + let lastDate; + $(chat.find("#chan-" + data.id + " .messages .msg[data-time]")).each(function() { + const msg = $(this); + const msgDate = new Date(msg.attr("data-time")); + + // Top-most message in a channel + if (!lastDate) { + lastDate = msgDate; + msg.before(templates.date_marker({msgDate: msgDate})); + } + + if (lastDate.toDateString() !== msgDate.toDateString()) { + msg.before(templates.date_marker({msgDate: msgDate})); + } + + lastDate = msgDate; + }); + } +} + +function renderChannelUsers(data) { + const users = chat.find("#chan-" + data.id).find(".users"); + let nicks = users.data("nicks") || []; + const oldSortOrder = {}; + + for (const i in nicks) { + oldSortOrder[nicks[i]] = i; + } + + nicks = []; + + for (const i in data.users) { + nicks.push(data.users[i].nick); + } + + nicks = nicks.sort(function(a, b) { + return (oldSortOrder[a] || Number.MAX_VALUE) - (oldSortOrder[b] || Number.MAX_VALUE); + }); + + const search = users + .find(".search") + .attr("placeholder", nicks.length + " " + (nicks.length === 1 ? "user" : "users")); + + users + .data("nicks", nicks) + .find(".names-original") + .html(templates.user(data)); + + // Refresh user search + if (search.val().length) { + search.trigger("input"); + } +} + +function renderNetworks(data) { + sidebar.find(".empty").hide(); + sidebar.find(".networks").append( + templates.network({ + networks: data.networks + }) + ); + + const channels = $.map(data.networks, function(n) { + return n.channels; + }); + chat.append( + templates.chat({ + channels: channels + }) + ); + channels.forEach(renderChannel); + + utils.confirmExit(); + sorting(); + + if (sidebar.find(".highlight").length) { + utils.toggleNotificationMarkers(true); + } +} diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js new file mode 100644 index 00000000..41e8cfd5 --- /dev/null +++ b/client/js/socket-events/auth.js @@ -0,0 +1,44 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const storage = require("../localStorage"); + +socket.on("auth", function(data) { + const login = $("#sign-in"); + let token; + + login.find(".btn").prop("disabled", false); + + if (!data.success) { + storage.remove("token"); + + const error = login.find(".error"); + error.show().closest("form").one("submit", function() { + error.hide(); + }); + } else { + token = storage.get("token"); + if (token) { + $("#loading-page-message").text("Authorizing…"); + socket.emit("auth", {token: token}); + } + } + + const input = login.find("input[name='user']"); + if (input.val() === "") { + input.val(storage.get("user") || ""); + } + if (token) { + return; + } + $("#sidebar, #footer").find(".sign-in") + .trigger("click", { + pushState: false, + }) + .end() + .find(".networks") + .html("") + .next() + .show(); +}); diff --git a/client/js/socket-events/change_password.js b/client/js/socket-events/change_password.js new file mode 100644 index 00000000..3d248ea1 --- /dev/null +++ b/client/js/socket-events/change_password.js @@ -0,0 +1,35 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const storage = require("../localStorage"); + +socket.on("change-password", function(data) { + const passwordForm = $("#change-password"); + if (data.error || data.success) { + const message = data.success ? data.success : data.error; + const feedback = passwordForm.find(".feedback"); + + if (data.success) { + feedback.addClass("success").removeClass("error"); + } else { + feedback.addClass("error").removeClass("success"); + } + + feedback.text(message).show(); + feedback.closest("form").one("submit", function() { + feedback.hide(); + }); + } + + if (data.token && storage.get("token") !== null) { + storage.set("token", data.token); + } + + passwordForm + .find("input") + .val("") + .end() + .find(".btn") + .prop("disabled", false); +}); diff --git a/client/js/socket-events/index.js b/client/js/socket-events/index.js new file mode 100644 index 00000000..be69a379 --- /dev/null +++ b/client/js/socket-events/index.js @@ -0,0 +1,18 @@ +"use strict"; + +require("./auth"); +require("./change_password"); +require("./init"); +require("./join"); +require("./more"); +require("./msg"); +require("./names"); +require("./network"); +require("./nick"); +require("./open"); +require("./part"); +require("./quit"); +require("./sync_sort"); +require("./toggle"); +require("./topic"); +require("./users"); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js new file mode 100644 index 00000000..31c1eb2c --- /dev/null +++ b/client/js/socket-events/init.js @@ -0,0 +1,44 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const render = require("../render"); +const sidebar = $("#sidebar"); +const storage = require("../localStorage"); + +socket.on("init", function(data) { + $("#loading-page-message").text("Rendering…"); + + if (data.networks.length === 0) { + $("#footer").find(".connect").trigger("click", { + pushState: false, + }); + } else { + render.renderNetworks(data); + } + + if (data.token && $("#sign-in-remember").is(":checked")) { + storage.set("token", data.token); + } else { + storage.remove("token"); + } + + $("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, + }); + } + } +}); diff --git a/client/js/socket-events/join.js b/client/js/socket-events/join.js new file mode 100644 index 00000000..761a4622 --- /dev/null +++ b/client/js/socket-events/join.js @@ -0,0 +1,36 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const render = require("../render"); +const chat = $("#chat"); +const templates = require("../../views"); +const sidebar = $("#sidebar"); + +socket.on("join", function(data) { + const id = data.network; + const network = sidebar.find("#network-" + id); + network.append( + templates.chan({ + channels: [data.chan] + }) + ); + chat.append( + templates.chat({ + channels: [data.chan] + }) + ); + render.renderChannel(data.chan); + + // Queries do not automatically focus, unless the user did a whois + if (data.chan.type === "query" && !data.shouldOpen) { + return; + } + + sidebar.find(".chan") + .sort(function(a, b) { + return $(a).data("id") - $(b).data("id"); + }) + .last() + .click(); +}); diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js new file mode 100644 index 00000000..d73105cb --- /dev/null +++ b/client/js/socket-events/more.js @@ -0,0 +1,62 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const render = require("../render"); +const chat = $("#chat"); +const templates = require("../../views"); + +socket.on("more", function(data) { + const documentFragment = render.buildChannelMessages(data.chan, data.messages); + const chan = chat + .find("#chan-" + data.chan) + .find(".messages"); + + // get the scrollable wrapper around messages + const scrollable = chan.closest(".chat"); + const heightOld = chan.height(); + + // Remove the date-change marker we put at the top, because it may + // not actually be a date change now + const children = $(chan).children(); + if (children.eq(0).hasClass("date-marker-container")) { // Check top most child + children.eq(0).remove(); + } else if (children.eq(1).hasClass("date-marker-container")) { + // The unread-marker could be at index 0, which will cause the date-marker to become "stuck" + children.eq(1).remove(); + } + + // Add the older messages + chan.prepend(documentFragment).end(); + + // restore scroll position + const position = chan.height() - heightOld; + scrollable.scrollTop(position); + + if (data.messages.length !== 100) { + scrollable.find(".show-more").removeClass("show"); + } + + // Date change detect + // Have to use data instaid of the documentFragment because it's being weird + let lastDate; + $(data.messages).each(function() { + const msgData = this; + const msgDate = new Date(msgData.time); + const msg = $(chat.find("#chan-" + data.chan + " .messages #msg-" + msgData.id)); + + // Top-most message in a channel + if (!lastDate) { + lastDate = msgDate; + msg.before(templates.date_marker({msgDate: msgDate})); + } + + if (lastDate.toDateString() !== msgDate.toDateString()) { + msg.before(templates.date_marker({msgDate: msgDate})); + } + + lastDate = msgDate; + }); + + scrollable.find(".show-more-button").prop("disabled", false); +}); diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js new file mode 100644 index 00000000..c05d2cec --- /dev/null +++ b/client/js/socket-events/msg.js @@ -0,0 +1,49 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const render = require("../render"); +const chat = $("#chat"); +const templates = require("../../views"); + +socket.on("msg", function(data) { + const msg = render.buildChatMessage(data); + const target = "#chan-" + data.chan; + const container = chat.find(target + " .messages"); + + if (data.msg.type === "channel_list" || data.msg.type === "ban_list") { + $(container).empty(); + } + + // Check if date changed + const prevMsg = $(container.find(".msg")).last(); + const prevMsgTime = new Date(prevMsg.attr("data-time")); + const msgTime = new Date(msg.attr("data-time")); + + // It's the first message in a channel/query + if (prevMsg.length === 0) { + container.append(templates.date_marker({msgDate: msgTime})); + } + + if (prevMsgTime.toDateString() !== msgTime.toDateString()) { + prevMsg.after(templates.date_marker({msgDate: msgTime})); + } + + // Add message to the container + container + .append(msg) + .trigger("msg", [ + target, + data + ]); + + var lastVisible = container.find("div:visible").last(); + if (data.msg.self + || lastVisible.hasClass("unread-marker") + || (lastVisible.hasClass("date-marker") + && lastVisible.prev().hasClass("unread-marker"))) { + container + .find(".unread-marker") + .appendTo(container); + } +}); diff --git a/client/js/socket-events/names.js b/client/js/socket-events/names.js new file mode 100644 index 00000000..4d2036e8 --- /dev/null +++ b/client/js/socket-events/names.js @@ -0,0 +1,6 @@ +"use strict"; + +const socket = require("../socket"); +const render = require("../render"); + +socket.on("names", render.renderChannelUsers); diff --git a/client/js/socket-events/network.js b/client/js/socket-events/network.js new file mode 100644 index 00000000..1fb8036f --- /dev/null +++ b/client/js/socket-events/network.js @@ -0,0 +1,24 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const render = require("../render"); +const sidebar = $("#sidebar"); + +socket.on("network", function(data) { + render.renderNetworks(data); + + sidebar.find(".chan") + .last() + .trigger("click"); + + $("#connect") + .find(".btn") + .prop("disabled", false) + .end(); +}); + +socket.on("network_changed", function(data) { + sidebar.find("#network-" + data.network).data("options", data.serverOptions); +}); + diff --git a/client/js/socket-events/nick.js b/client/js/socket-events/nick.js new file mode 100644 index 00000000..75aa0411 --- /dev/null +++ b/client/js/socket-events/nick.js @@ -0,0 +1,15 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const utils = require("../utils"); +const sidebar = $("#sidebar"); + +socket.on("nick", function(data) { + const id = data.network; + const nick = data.nick; + const network = sidebar.find("#network-" + id).data("nick", nick); + if (network.find(".active").length) { + utils.setNick(nick); + } +}); diff --git a/client/js/socket-events/open.js b/client/js/socket-events/open.js new file mode 100644 index 00000000..8ed8ed3e --- /dev/null +++ b/client/js/socket-events/open.js @@ -0,0 +1,11 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); + +socket.on("open", function(id) { + // Another client opened the channel, clear the unread counter + $("#sidebar").find(".chan[data-id='" + id + "'] .badge") + .removeClass("highlight") + .empty(); +}); diff --git a/client/js/socket-events/part.js b/client/js/socket-events/part.js new file mode 100644 index 00000000..e167653e --- /dev/null +++ b/client/js/socket-events/part.js @@ -0,0 +1,17 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const sidebar = $("#sidebar"); + +socket.on("part", function(data) { + const chanMenuItem = sidebar.find(".chan[data-id='" + data.chan + "']"); + + // When parting from the active channel/query, jump to the network's lobby + if (chanMenuItem.hasClass("active")) { + chanMenuItem.parent(".network").find(".lobby").click(); + } + + chanMenuItem.remove(); + $("#chan-" + data.chan).remove(); +}); diff --git a/client/js/socket-events/quit.js b/client/js/socket-events/quit.js new file mode 100644 index 00000000..dcf1b8bd --- /dev/null +++ b/client/js/socket-events/quit.js @@ -0,0 +1,18 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const sidebar = $("#sidebar"); + +socket.on("quit", function(data) { + const id = data.network; + sidebar.find("#network-" + id) + .remove() + .end(); + const chan = sidebar.find(".chan") + .eq(0) + .trigger("click"); + if (chan.length === 0) { + sidebar.find(".empty").show(); + } +}); diff --git a/client/js/socket-events/sync_sort.js b/client/js/socket-events/sync_sort.js new file mode 100644 index 00000000..2a344eca --- /dev/null +++ b/client/js/socket-events/sync_sort.js @@ -0,0 +1,50 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const options = require("../options"); + +socket.on("sync_sort", function(data) { + // Syncs the order of channels or networks when they are reordered + if (options.ignoreSortSync) { + options.ignoreSortSync = false; + return; // Ignore syncing because we 'caused' it + } + + const type = data.type; + const order = data.order; + + if (type === "networks") { + const container = $(".networks"); + + $.each(order, function(index, value) { + const position = $(container.children()[index]); + + if (position.data("id") === value) { // Network in correct place + return true; // No point in continuing + } + + const network = container.find("#network-" + value); + + $(network).insertBefore(position); + }); + } else if (type === "channels") { + const network = $("#network-" + data.target); + + $.each(order, function(index, value) { + if (index === 0) { // Shouldn't attempt to move lobby + return true; // same as `continue` -> skip to next item + } + + const position = $(network.children()[index]); // Target channel at position + + if (position.data("id") === value) { // Channel in correct place + return true; // No point in continuing + } + + const channel = network.find(".chan[data-id=" + value + "]"); // Channel at position + + $(channel).insertBefore(position); + }); + } +}); diff --git a/client/js/socket-events/toggle.js b/client/js/socket-events/toggle.js new file mode 100644 index 00000000..9e48f72e --- /dev/null +++ b/client/js/socket-events/toggle.js @@ -0,0 +1,24 @@ +"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/js/socket-events/topic.js b/client/js/socket-events/topic.js new file mode 100644 index 00000000..df16f263 --- /dev/null +++ b/client/js/socket-events/topic.js @@ -0,0 +1,12 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const helpers_parse = require("../libs/handlebars/parse"); + +socket.on("topic", function(data) { + const topic = $("#chan-" + data.chan).find(".header .topic"); + topic.html(helpers_parse(data.topic)); + // .attr() is safe escape-wise but consider the capabilities of the attribute + topic.attr("title", data.topic); +}); diff --git a/client/js/socket-events/users.js b/client/js/socket-events/users.js new file mode 100644 index 00000000..a1b130d6 --- /dev/null +++ b/client/js/socket-events/users.js @@ -0,0 +1,17 @@ +"use strict"; + +const $ = require("jquery"); +const socket = require("../socket"); +const chat = $("#chat"); + +socket.on("users", function(data) { + const chan = chat.find("#chan-" + data.chan); + + if (chan.hasClass("active")) { + socket.emit("names", { + target: data.chan + }); + } else { + chan.data("needsNamesRefresh", true); + } +}); diff --git a/client/js/sorting.js b/client/js/sorting.js new file mode 100644 index 00000000..10ccc081 --- /dev/null +++ b/client/js/sorting.js @@ -0,0 +1,64 @@ +"use strict"; + +const $ = require("jquery"); +const sidebar = $("#sidebar, #footer"); +const socket = require("./socket"); +const options = require("./options"); + +module.exports = function() { + sidebar.find(".networks").sortable({ + axis: "y", + containment: "parent", + cursor: "move", + distance: 12, + items: ".network", + handle: ".lobby", + placeholder: "network-placeholder", + forcePlaceholderSize: true, + tolerance: "pointer", // Use the pointer to figure out where the network is in the list + + update: function() { + const order = []; + sidebar.find(".network").each(function() { + const id = $(this).data("id"); + order.push(id); + }); + socket.emit( + "sort", { + type: "networks", + order: order + } + ); + + options.ignoreSortSync = true; + } + }); + sidebar.find(".network").sortable({ + axis: "y", + containment: "parent", + cursor: "move", + distance: 12, + items: ".chan:not(.lobby)", + placeholder: "chan-placeholder", + forcePlaceholderSize: true, + tolerance: "pointer", // Use the pointer to figure out where the channel is in the list + + update: function(e, ui) { + const order = []; + const network = ui.item.parent(); + network.find(".chan").each(function() { + const id = $(this).data("id"); + order.push(id); + }); + socket.emit( + "sort", { + type: "channels", + target: network.data("id"), + order: order + } + ); + + options.ignoreSortSync = true; + } + }); +}; diff --git a/client/js/utils.js b/client/js/utils.js new file mode 100644 index 00000000..00bb3416 --- /dev/null +++ b/client/js/utils.js @@ -0,0 +1,79 @@ +"use strict"; + +const $ = require("jquery"); +const chat = $("#chat"); +const input = $("#input"); + +module.exports = { + clear, + confirmExit, + forceFocus, + move, + resetHeight, + setNick, + toggleNickEditor, + toggleNotificationMarkers +}; + +function resetHeight(element) { + element.style.height = element.style.minHeight; +} + +// Triggering click event opens the virtual keyboard on mobile +// This can only be called from another interactive event (e.g. button click) +function forceFocus() { + input.trigger("click").focus(); +} + +function clear() { + chat.find(".active") + .find(".show-more").addClass("show").end() + .find(".messages .msg, .date-marker-container").remove(); +} + +function toggleNickEditor(toggle) { + $("#nick").toggleClass("editable", toggle); + $("#nick-value").attr("contenteditable", toggle); +} + +function setNick(nick) { + // Closes the nick editor when canceling, changing channel, or when a nick + // is set in a different tab / browser / device. + toggleNickEditor(false); + + $("#nick-value").text(nick); +} + +const favicon = $("#favicon"); + +function toggleNotificationMarkers(newState) { + // Toggles the favicon to red when there are unread notifications + if (favicon.data("toggled") !== newState) { + var old = favicon.attr("href"); + favicon.attr("href", favicon.data("other")); + favicon.data("other", old); + favicon.data("toggled", newState); + } + + // Toggles a dot on the menu icon when there are unread notifications + $("#viewport .lt").toggleClass("notified", newState); +} + +function confirmExit() { + if ($("body").hasClass("public")) { + window.onbeforeunload = function() { + return "Are you sure you want to navigate away from this page?"; + }; + } +} + +function move(array, old_index, new_index) { + if (new_index >= array.length) { + let k = new_index - array.length; + while ((k--) + 1) { + this.push(undefined); + } + } + array.splice(new_index, 0, array.splice(old_index, 1)[0]); + return array; +}