diff --git a/client/js/contextMenu.js b/client/js/contextMenu.js new file mode 100644 index 00000000..229c6e02 --- /dev/null +++ b/client/js/contextMenu.js @@ -0,0 +1,85 @@ +"use strict"; +const $ = require("jquery"); +const templates = require("../views"); +let contextMenu, contextMenuContainer; + +module.exports = class ContextMenu { + constructor(contextMenuItems, contextMenuActions, selectedElement, event) { + this.contextMenuItems = contextMenuItems; + this.contextMenuActions = contextMenuActions; + this.selectedElement = selectedElement; + this.event = event; + + contextMenuContainer = $("#context-menu-container"); + contextMenu = $("#context-menu"); + } + + show() { + showContextMenu(this.contextMenuItems, this.selectedElement, this.event); + this.bindEvents(); + return false; + } + + bindEvents() { + const contextMenuActions = this.contextMenuActions; + + contextMenuActions.execute = (id, ...args) => contextMenuActions[id] && contextMenuActions[id](...args); + + contextMenu.find(".context-menu-item").on("click", function() { + const $this = $(this); + const itemData = $this.data("data"); + const contextAction = $this.data("action"); + contextMenuActions.execute(contextAction, itemData); + }); + } +}; + +function showContextMenu(contextMenuItems, selectedElement, event) { + const target = $(event.currentTarget); + let output = ""; + + for (const item of contextMenuItems) { + if (item.check(target)) { + if (item.divider) { + output += templates.contextmenu_divider(); + } else { + output += templates.contextmenu_item({ + class: typeof item.className === "function" ? item.className(target) : item.className, + action: item.actionId, + text: typeof item.displayName === "function" ? item.displayName(target) : item.displayName, + data: typeof item.data === "function" ? item.data(target) : item.data, + }); + } + } + } + + contextMenuContainer.show(); + contextMenu + .html(output) + .css(positionContextMenu(selectedElement, event)); +} + +function positionContextMenu(selectedElement, e) { + let offset; + const menuWidth = contextMenu.outerWidth(); + const menuHeight = contextMenu.outerHeight(); + + if (selectedElement.hasClass("menu")) { + offset = selectedElement.offset(); + offset.left -= menuWidth - selectedElement.outerWidth(); + offset.top += selectedElement.outerHeight(); + return offset; + } + + offset = {left: e.pageX, top: e.pageY}; + + if ((window.innerWidth - offset.left) < menuWidth) { + offset.left = window.innerWidth - menuWidth; + } + + if ((window.innerHeight - offset.top) < menuHeight) { + offset.top = window.innerHeight - menuHeight; + } + + return offset; +} diff --git a/client/js/contextMenuFactory.js b/client/js/contextMenuFactory.js new file mode 100644 index 00000000..a822deea --- /dev/null +++ b/client/js/contextMenuFactory.js @@ -0,0 +1,213 @@ +"use strict"; +const $ = require("jquery"); +const socket = require("./socket"); +const utils = require("./utils"); +const JoinChannel = require("./join-channel"); +const ContextMenu = require("./contextMenu"); +const contextMenuActions = []; +const contextMenuItems = []; + +module.exports = { + addContextMenuItem, + createContextMenu, +}; + +addDefaultItems(); + +/** + * Used for adding context menu items. eg: + * + * addContextMenuItem({ + * check: (target) => target.hasClass("user"), + * className: "customItemName", + * data: (target) => target.data("name"), + * displayName: "Do something", + * callback: (name) => console.log(name), // print the name of the user to console + * }); + * + * @param opts + * @param {function(Object)} [opts.check] - Function to check whether item should show on the context menu, called with the target jquery element, shows if return is truthy + * @param {string|function(Object)} opts.className - class name for the menu item, should be prefixed for non-default menu items (if function, called with jquery element, and uses return value) + * @param {string|function(Object)} opts.data - data that will be sent to the callback function (if function, called with jquery element, and uses return value) + * @param {string|function(Object)} opts.displayName - text to display on the menu item (if function, called with jquery element, and uses return value) + * @param {function(Object)} opts.callback - Function to call when the context menu item is clicked, called with the data requested in opts.data + */ +function addContextMenuItem(opts) { + opts.check = opts.check || (() => true); + opts.actionId = contextMenuActions.push(opts.callback) - 1; + contextMenuItems.push(opts); +} + +function addContextDivider(opts) { + opts.check = opts.check || (() => true); + opts.divider = true; + contextMenuItems.push(opts); +} + +function createContextMenu(that, event) { + return new ContextMenu(contextMenuItems, contextMenuActions, that, event); +} + +function addWhoisItem() { + function whois(itemData) { + const chan = utils.findCurrentNetworkChan(itemData); + + if (chan.length) { + chan.click(); + } + + socket.emit("input", { + target: $("#chat").data("id"), + text: "/whois " + itemData, + }); + + $(`.channel.active .userlist .user[data-name="${itemData}"]`).trigger("click"); + } + + addContextMenuItem({ + check: (target) => target.hasClass("user"), + className: "user", + displayName: (target) => target.data("name"), + data: (target) => target.data("name"), + callback: whois, + }); + + addContextDivider({ + check: (target) => target.hasClass("user"), + }); + + addContextMenuItem({ + check: (target) => target.hasClass("user"), + className: "action-whois", + displayName: "User information", + data: (target) => target.data("name"), + callback: whois, + }); +} + +function addQueryItem() { + function query(itemData) { + const chan = utils.findCurrentNetworkChan(itemData); + + if (chan.length) { + chan.click(); + } + + socket.emit("input", { + target: $("#chat").data("id"), + text: "/query " + itemData, + }); + } + + addContextMenuItem({ + check: (target) => target.hasClass("user"), + className: "action-query", + displayName: "Direct messages", + data: (target) => target.data("name"), + callback: query, + }); +} + +function addKickItem() { + function kick(itemData) { + socket.emit("input", { + target: $("#chat").data("id"), + text: "/kick " + itemData, + }); + } + + addContextMenuItem({ + check: (target) => utils.hasRoleInChannel(target.closest(".chan"), ["op"]) && target.closest(".chan").data("type") === "channel", + className: "action-kick", + displayName: "Kick", + data: (target) => target.data("name"), + callback: kick, + }); +} + +function addFocusItem() { + function focusChan(itemData) { + $(`.networks .chan[data-target="${itemData}"]`).click(); + } + + const getClass = (target) => { + if (target.hasClass("lobby")) { + return "network"; + } else if (target.hasClass("query")) { + return "query"; + } + + return "chan"; + }; + + addContextMenuItem({ + check: (target) => target.hasClass("chan"), + className: getClass, + displayName: (target) => target.attr("aria-label"), + data: (target) => target.data("target"), + callback: focusChan, + }); + + addContextDivider({ + check: (target) => target.hasClass("chan"), + }); +} + +function addChannelListItem() { + function list(itemData) { + socket.emit("input", { + target: itemData, + text: "/list", + }); + } + + addContextMenuItem({ + check: (target) => target.hasClass("lobby"), + className: "list", + displayName: "List all channels", + data: (target) => target.data("id"), + callback: list, + }); +} + +function addBanListItem() { + function banlist(itemData) { + socket.emit("input", { + target: itemData, + text: "/banlist", + }); + } + + addContextMenuItem({ + check: (target) => target.hasClass("channel"), + className: "list", + displayName: "List banned users", + data: (target) => target.data("id"), + callback: banlist, + }); +} + +function addJoinItem() { + function openJoinForm(itemData) { + const network = $(`#join-channel-${itemData}`).closest(".network"); + JoinChannel.openForm(network); + } + + addContextMenuItem({ + check: (target) => target.hasClass("lobby"), + className: "join", + displayName: "Join a channel…", + data: (target) => target.data("id"), + callback: openJoinForm, + }); +} + +function addDefaultItems() { + addWhoisItem(); + addQueryItem(); + addKickItem(); + addFocusItem(); + addChannelListItem(); + addBanListItem(); + addJoinItem(); +} diff --git a/client/js/lounge.js b/client/js/lounge.js index 0f4b94ae..3f608019 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -20,7 +20,8 @@ require("./webpush"); require("./keybinds"); require("./clipboard"); const Changelog = require("./socket-events/changelog"); -const JoinChannel = require("./join-channel"); +const contextMenuFactory = require("./contextMenuFactory"); +const contextMenuContainer = $("#context-menu-container"); $(function() { const sidebar = $("#sidebar, #footer"); @@ -29,8 +30,6 @@ $(function() { $(document.body).data("app-name", document.title); const viewport = $("#viewport"); - const contextMenuContainer = $("#context-menu-container"); - const contextMenu = $("#context-menu"); function storeSidebarVisibility(name, state) { if ($(window).outerWidth() < utils.mobileViewportPixels) { @@ -63,128 +62,8 @@ $(function() { return false; }); - function positionContextMenu(that, e) { - let offset; - const menuWidth = contextMenu.outerWidth(); - const menuHeight = contextMenu.outerHeight(); - - if (that.hasClass("menu")) { - offset = that.offset(); - offset.left -= menuWidth - that.outerWidth(); - offset.top += that.outerHeight(); - return offset; - } - - offset = {left: e.pageX, top: e.pageY}; - - if ((window.innerWidth - offset.left) < menuWidth) { - offset.left = window.innerWidth - menuWidth; - } - - if ((window.innerHeight - offset.top) < menuHeight) { - offset.top = window.innerHeight - menuHeight; - } - - return offset; - } - - function showContextMenu(that, e) { - const target = $(e.currentTarget); - let output = ""; - - if (target.hasClass("user")) { - output = templates.contextmenu_item({ - class: "user", - action: "whois", - text: target.text(), - data: target.data("name"), - }); - output += templates.contextmenu_divider(); - output += templates.contextmenu_item({ - class: "action-whois", - action: "whois", - text: "User information", - data: target.data("name"), - }); - output += templates.contextmenu_item({ - class: "action-query", - action: "query", - text: "Direct messages", - data: target.data("name"), - }); - - const channel = target.closest(".chan"); - - if (utils.hasRoleInChannel(channel, ["op"]) && channel.data("type") === "channel") { - output += templates.contextmenu_divider(); - output += templates.contextmenu_item({ - class: "action-kick", - action: "kick", - text: "Kick", - data: target.data("name"), - }); - } - } else if (target.hasClass("chan")) { - let itemClass; - - if (target.hasClass("lobby")) { - itemClass = "network"; - } else if (target.hasClass("query")) { - itemClass = "query"; - } else { - itemClass = "chan"; - } - - output = templates.contextmenu_item({ - class: itemClass, - action: "focusChan", - text: target.attr("aria-label"), - data: target.data("target"), - }); - output += templates.contextmenu_divider(); - - if (target.hasClass("lobby")) { - output += templates.contextmenu_item({ - class: "list", - action: "list", - text: "List all channels", - data: target.data("id"), - }); - output += templates.contextmenu_item({ - class: "join", - action: "join", - text: "Join a channel…", - data: target.data("id"), - }); - } - - if (target.hasClass("channel")) { - output += templates.contextmenu_item({ - class: "list", - action: "banlist", - text: "List banned users", - data: target.data("id"), - }); - } - - output += templates.contextmenu_item({ - class: "close", - action: "close", - text: target.hasClass("lobby") ? "Disconnect" : target.hasClass("channel") ? "Leave" : "Close", - data: target.data("target"), - }); - } - - contextMenuContainer.show(); - contextMenu - .html(output) - .css(positionContextMenu($(that), e)); - - return false; - } - viewport.on("contextmenu", ".network .chan", function(e) { - return showContextMenu(this, e); + return contextMenuFactory.createContextMenu($(this), e).show(); }); viewport.on("click contextmenu", ".user", function(e) { @@ -194,12 +73,12 @@ $(function() { return true; } - return showContextMenu(this, e); + return contextMenuFactory.createContextMenu($(this), e).show(); }); viewport.on("click", "#chat .menu", function(e) { e.currentTarget = $(`#sidebar .chan[data-id="${$(this).closest(".chan").data("id")}"]`)[0]; - return showContextMenu(this, e); + return contextMenuFactory.createContextMenu($(this), e).show(); }); contextMenuContainer.on("click contextmenu", function() { @@ -511,70 +390,22 @@ $(function() { closeChan($(this).closest(".chan")); }); - const contextMenuActions = { - join(itemData) { - const network = $(`#join-channel-${itemData}`).closest(".network"); - JoinChannel.openForm(network); - }, - close(itemData) { - closeChan($(`.networks .chan[data-target="${itemData}"]`)); - }, - focusChan(itemData) { - $(`.networks .chan[data-target="${itemData}"]`).trigger("click"); - }, - list(itemData) { - socket.emit("input", { - target: itemData, - text: "/list", - }); - }, - banlist(itemData) { - socket.emit("input", { - target: itemData, - text: "/banlist", - }); - }, - whois(itemData) { - const chan = utils.findCurrentNetworkChan(itemData); + const getCloseDisplay = (target) => { + if (target.hasClass("lobby")) { + return "Disconnect"; + } else if (target.hasClass("channel")) { + return "Leave"; + } - if (chan.length) { - chan.trigger("click"); - } - - socket.emit("input", { - target: $("#chat").data("id"), - text: "/whois " + itemData, - }); - - $(`.channel.active .userlist .user[data-name="${itemData}"]`).trigger("click"); - }, - query(itemData) { - const chan = utils.findCurrentNetworkChan(itemData); - - if (chan.length) { - chan.trigger("click"); - } - - socket.emit("input", { - target: $("#chat").data("id"), - text: "/query " + itemData, - }); - }, - kick(itemData) { - socket.emit("input", { - target: $("#chat").data("id"), - text: "/kick " + itemData, - }); - }, + return "Close"; }; - contextMenuActions.execute = (name, ...args) => contextMenuActions[name] && contextMenuActions[name](...args); - - contextMenu.on("click", ".context-menu-item", function() { - const $this = $(this); - const itemData = $this.data("data"); - const contextAction = $this.data("action"); - contextMenuActions.execute(contextAction, itemData); + contextMenuFactory.addContextMenuItem({ + check: (target) => target.hasClass("chan"), + className: "close", + displayName: getCloseDisplay, + data: (target) => target.data("target"), + callback: (itemData) => closeChan($(`.networks .chan[data-target="${itemData}"]`)), }); if ($(document.body).hasClass("public") && (window.location.hash === "#connect" || window.location.hash === "")) {