diff --git a/client/components/App.vue b/client/components/App.vue
index 3f341038..a83c0f4d 100644
--- a/client/components/App.vue
+++ b/client/components/App.vue
@@ -6,6 +6,7 @@
+
@@ -16,12 +17,14 @@ const storage = require("../js/localStorage");
import Sidebar from "./Sidebar.vue";
import ImageViewer from "./ImageViewer.vue";
+import ContextMenu from "./ContextMenu.vue";
export default {
name: "App",
components: {
Sidebar,
ImageViewer,
+ ContextMenu,
},
computed: {
viewportClasses() {
@@ -84,6 +87,10 @@ export default {
this.$store.commit("userlistOpen", isUserlistOpen === "true");
},
+ openContextMenu(event, items) {
+ // TODO: maybe move this method to the store or some other more accessible place
+ this.$refs.contextMenu.open(event, items);
+ },
},
};
diff --git a/client/components/ChannelWrapper.vue b/client/components/ChannelWrapper.vue
index b1edf7ba..b5c4b0a0 100644
--- a/client/components/ChannelWrapper.vue
+++ b/client/components/ChannelWrapper.vue
@@ -24,6 +24,7 @@
:style="closed ? {transition: 'none', opacity: 0.4} : null"
role="tab"
@click="click"
+ @contextmenu.prevent="openContextMenu"
>
@@ -31,6 +32,7 @@
diff --git a/client/components/Chat.vue b/client/components/Chat.vue
index 7920bf08..d210eaed 100644
--- a/client/components/Chat.vue
+++ b/client/components/Chat.vue
@@ -38,7 +38,11 @@
:network="network"
:text="channel.topic"
/>
-
+
diff --git a/client/components/ChatUserList.vue b/client/components/ChatUserList.vue
index ed6f2e5a..5aeaffe3 100644
--- a/client/components/ChatUserList.vue
+++ b/client/components/ChatUserList.vue
@@ -32,6 +32,7 @@
:on-hover="hoverUser"
:active="user.original === activeUser"
:user="user"
+ :context-menu-callback="openContextMenu"
/>
@@ -41,6 +42,7 @@
:on-hover="hoverUser"
:active="user === activeUser"
:user="user"
+ :context-menu-callback="openContextMenu"
/>
@@ -52,6 +54,7 @@
const fuzzy = require("fuzzy");
import Username from "./Username.vue";
import UsernameFiltered from "./UsernameFiltered.vue";
+import {generateUserContextMenu} from "../js/helpers/contextMenu.js";
const modes = {
"~": "owner",
@@ -194,6 +197,11 @@ export default {
el.scrollIntoView({block: "nearest", inline: "nearest"});
});
},
+ openContextMenu(event, user) {
+ const {network} = this.$store.getters.findChannel(this.channel.id);
+ const items = generateUserContextMenu(this.$root, this.channel, network, user);
+ this.$root.$refs.app.openContextMenu(event, items);
+ },
},
};
diff --git a/client/components/ContextMenu.vue b/client/components/ContextMenu.vue
new file mode 100644
index 00000000..c69565cc
--- /dev/null
+++ b/client/components/ContextMenu.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
diff --git a/client/components/Message.vue b/client/components/Message.vue
index 82ef608d..8229cca9 100644
--- a/client/components/Message.vue
+++ b/client/components/Message.vue
@@ -20,10 +20,11 @@
*
-
+
<
-
+
>
@@ -50,7 +51,7 @@
-
-
+
-
@@ -73,6 +74,7 @@ import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue";
import MessageTypes from "./MessageTypes";
+import {generateUserContextMenu} from "../js/helpers/contextMenu.js";
const constants = require("../js/constants");
@@ -85,6 +87,7 @@ export default {
components: MessageTypes,
props: {
message: Object,
+ channel: Object,
network: Object,
keepScrollPosition: Function,
},
@@ -104,6 +107,10 @@ export default {
isAction() {
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
},
+ openUserContextMenu($event, user) {
+ const items = generateUserContextMenu(this.$root, this.channel, this.network, user);
+ this.$root.$refs.app.openContextMenu(event, items);
+ },
},
};
diff --git a/client/components/MessageList.vue b/client/components/MessageList.vue
index 54d2b9d5..561cbdac 100644
--- a/client/components/MessageList.vue
+++ b/client/components/MessageList.vue
@@ -42,6 +42,7 @@
{{ user.mode }}{{ user.nick }}
@@ -17,6 +18,7 @@ export default {
user: Object,
active: Boolean,
onHover: Function,
+ contextMenuCallback: Function,
},
computed: {
nickColor() {
@@ -27,6 +29,11 @@ export default {
hover() {
return this.onHover(this.user);
},
+ rightClick($event) {
+ if (this.contextMenuCallback) {
+ this.contextMenuCallback($event, this.user);
+ }
+ },
},
};
diff --git a/client/components/UsernameFiltered.vue b/client/components/UsernameFiltered.vue
index 769bff16..1f50c364 100644
--- a/client/components/UsernameFiltered.vue
+++ b/client/components/UsernameFiltered.vue
@@ -4,6 +4,7 @@
:data-name="user.original.nick"
role="button"
@mouseover="hover"
+ @contextmenu.prevent="rightClick($event)"
v-html="user.original.mode + user.string"
/>
@@ -17,6 +18,7 @@ export default {
user: Object,
active: Boolean,
onHover: Function,
+ contextMenuCallback: Function,
},
computed: {
nickColor() {
@@ -27,6 +29,11 @@ export default {
hover() {
this.onHover ? this.onHover(this.user.original) : null;
},
+ rightClick($event) {
+ if (this.contextMenuCallback) {
+ this.contextMenuCallback($event, this.user);
+ }
+ },
},
};
diff --git a/client/css/style.css b/client/css/style.css
index fdd6cf12..2ec817c8 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -2067,7 +2067,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
#context-menu-container {
- display: none;
position: absolute;
top: 0;
left: 0;
diff --git a/client/index.html.tpl b/client/index.html.tpl
index c0b98556..86fd1cd5 100644
--- a/client/index.html.tpl
+++ b/client/index.html.tpl
@@ -64,7 +64,6 @@
-
diff --git a/client/js/contextMenu.js b/client/js/contextMenu.js
deleted file mode 100644
index 31a68105..00000000
--- a/client/js/contextMenu.js
+++ /dev/null
@@ -1,172 +0,0 @@
-"use strict";
-
-const $ = require("jquery");
-const Mousetrap = require("mousetrap");
-
-const contextMenuContainer = $("#context-menu-container");
-
-module.exports = class ContextMenu {
- constructor(contextMenuItems, contextMenuActions, selectedElement, event) {
- this.previousActiveElement = document.activeElement;
- this.contextMenuItems = contextMenuItems;
- this.contextMenuActions = contextMenuActions;
- this.selectedElement = selectedElement;
- this.event = event;
- }
-
- show() {
- const contextMenu = showContextMenu(
- this.contextMenuItems,
- this.selectedElement,
- this.event
- );
- this.bindEvents(contextMenu);
- return false;
- }
-
- hide() {
- contextMenuContainer
- .hide()
- .empty()
- .off(".contextMenu");
-
- Mousetrap.unbind("escape");
- }
-
- bindEvents(contextMenu) {
- const contextMenuActions = this.contextMenuActions;
-
- contextMenuActions.execute = (id, ...args) =>
- contextMenuActions[id] && contextMenuActions[id](...args);
-
- const clickItem = (item) => {
- const itemData = item.attr("data-data");
- const contextAction = item.attr("data-action");
-
- this.hide();
-
- contextMenuActions.execute(contextAction, itemData);
- };
-
- contextMenu.on("click", ".context-menu-item", function() {
- clickItem($(this));
- });
-
- const trap = Mousetrap(contextMenu.get(0));
-
- trap.bind(["up", "down"], (e, key) => {
- const items = contextMenu.find(".context-menu-item");
-
- let index = items.toArray().findIndex((item) => $(item).is(":focus"));
-
- if (key === "down") {
- index = (index + 1) % items.length;
- } else {
- index = Math.max(index, 0) - 1;
- }
-
- items.eq(index).trigger("focus");
- });
-
- trap.bind("enter", () => {
- const item = contextMenu.find(".context-menu-item:focus");
-
- if (item.length) {
- clickItem(item);
- }
-
- return false;
- });
-
- // Hide context menu when clicking or right clicking outside of it
- contextMenuContainer.on("click.contextMenu contextmenu.contextMenu", (e) => {
- // Do not close the menu when clicking inside of the context menu (e.g. on a divider)
- if ($(e.target).prop("id") === "context-menu") {
- return;
- }
-
- this.hide();
- return false;
- });
-
- // Hide the context menu when pressing escape within the context menu container
- Mousetrap.bind("escape", () => {
- this.hide();
-
- // Return focus to the previously focused element
- $(this.previousActiveElement).trigger("focus");
-
- return false;
- });
- }
-};
-
-function showContextMenu(contextMenuItems, selectedElement, event) {
- const target = $(event.currentTarget);
- const contextMenu = $("", {
- id: "context-menu",
- role: "menu",
- });
-
- for (const item of contextMenuItems) {
- if (item.check(target)) {
- if (item.divider) {
- contextMenu.append('');
- } else {
- //
- contextMenu.append(
- $("- ", {
- class:
- "context-menu-item context-menu-" +
- (typeof item.className === "function"
- ? item.className(target)
- : item.className),
- text:
- typeof item.displayName === "function"
- ? item.displayName(target)
- : item.displayName,
- "data-action": item.actionId,
- "data-data":
- typeof item.data === "function" ? item.data(target) : item.data,
- tabindex: 0,
- role: "menuitem",
- })
- );
- }
- }
- }
-
- contextMenuContainer.html(contextMenu).show();
-
- contextMenu
- .css(positionContextMenu(contextMenu, selectedElement, event))
- .find(".context-menu-item:first-child")
- .trigger("focus");
-
- return contextMenu;
-}
-
-function positionContextMenu(contextMenu, 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
deleted file mode 100644
index 4694414c..00000000
--- a/client/js/contextMenuFactory.js
+++ /dev/null
@@ -1,438 +0,0 @@
-"use strict";
-
-import Vue from "vue";
-
-const $ = require("jquery");
-const socket = require("./socket");
-const utils = require("./utils");
-const ContextMenu = require("./contextMenu");
-const contextMenuActions = [];
-const contextMenuItems = [];
-const {switchToChannel, navigate} = require("./router");
-const store = require("./store").default;
-
-addDefaultItems();
-registerEvents();
-
-/**
- * Used for adding context menu items. eg:
- *
- * addContextMenuItem({
- * check: (target) => target.hasClass("user"),
- * className: "customItemName",
- * data: (target) => target.attr("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 = store.getters.findChannelOnCurrentNetwork(itemData);
-
- if (chan) {
- switchToChannel(chan);
- }
-
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/whois " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("user"),
- className: "user",
- displayName: (target) => target.attr("data-name"),
- data: (target) => target.attr("data-name"),
- callback: whois,
- });
-
- addContextDivider({
- check: (target) => target.hasClass("user"),
- });
-
- addContextMenuItem({
- check: (target) => target.hasClass("user") || target.hasClass("query"),
- className: "action-whois",
- displayName: "User information",
- data: (target) => target.attr("data-name") || target.attr("aria-label"),
- callback: whois,
- });
-}
-
-function addQueryItem() {
- function query(itemData) {
- const chan = store.getters.findChannelOnCurrentNetwork(itemData);
-
- if (chan) {
- switchToChannel(chan);
- }
-
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/query " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("user"),
- className: "action-query",
- displayName: "Direct messages",
- data: (target) => target.attr("data-name"),
- callback: query,
- });
-}
-
-function addCloseItem() {
- function getCloseDisplay(target) {
- if (target.hasClass("lobby")) {
- return "Remove";
- } else if (target.hasClass("channel")) {
- return "Leave";
- }
-
- return "Close";
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("chan"),
- className: "close",
- displayName: getCloseDisplay,
- data: (target) => target.attr("data-target"),
- callback(itemData) {
- const close = document.querySelector(
- `.networks .chan[data-target="${itemData}"] .close`
- );
-
- if (close) {
- // TODO: After context menus are ported to Vue, figure out a direct api
- close.click();
- }
- },
- });
-}
-
-function addConnectItem() {
- function connect(itemData) {
- socket.emit("input", {
- target: Number(itemData),
- text: "/connect",
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby") && target.parent().hasClass("not-connected"),
- className: "connect",
- displayName: "Connect",
- data: (target) => target.attr("data-id"),
- callback: connect,
- });
-}
-
-function addDisconnectItem() {
- function disconnect(itemData) {
- socket.emit("input", {
- target: Number(itemData),
- text: "/disconnect",
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby") && !target.parent().hasClass("not-connected"),
- className: "disconnect",
- displayName: "Disconnect",
- data: (target) => target.attr("data-id"),
- callback: disconnect,
- });
-}
-
-function addKickItem() {
- function kick(itemData) {
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/kick " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) =>
- utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
- target.closest(".chan").attr("data-type") === "channel",
- className: "action-kick",
- displayName: "Kick",
- data: (target) => target.attr("data-name"),
- callback: kick,
- });
-}
-
-function addOpItem() {
- function op(itemData) {
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/op " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) =>
- utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
- !utils.hasRoleInChannel(target.closest(".chan"), ["op"], target.attr("data-name")),
- className: "action-op",
- displayName: "Give operator (+o)",
- data: (target) => target.attr("data-name"),
- callback: op,
- });
-}
-
-function addDeopItem() {
- function deop(itemData) {
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/deop " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) =>
- utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
- utils.hasRoleInChannel(target.closest(".chan"), ["op"], target.attr("data-name")),
- className: "action-op",
- displayName: "Revoke operator (-o)",
- data: (target) => target.attr("data-name"),
- callback: deop,
- });
-}
-
-function addVoiceItem() {
- function voice(itemData) {
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/voice " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) =>
- utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
- !utils.hasRoleInChannel(target.closest(".chan"), ["voice"], target.attr("data-name")),
- className: "action-voice",
- displayName: "Give voice (+v)",
- data: (target) => target.attr("data-name"),
- callback: voice,
- });
-}
-
-function addDevoiceItem() {
- function devoice(itemData) {
- socket.emit("input", {
- target: Number($("#chat").attr("data-id")),
- text: "/devoice " + itemData,
- });
- }
-
- addContextMenuItem({
- check: (target) =>
- utils.hasRoleInChannel(target.closest(".chan"), ["op"]) &&
- utils.hasRoleInChannel(target.closest(".chan"), ["voice"], target.attr("data-name")),
- className: "action-voice",
- displayName: "Revoke voice (-v)",
- data: (target) => target.attr("data-name"),
- callback: devoice,
- });
-}
-
-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("data-name") || target.attr("aria-label"),
- data: (target) => target.attr("data-target"),
- callback: focusChan,
- });
-
- addContextDivider({
- check: (target) => target.hasClass("chan"),
- });
-}
-
-function addEditNetworkItem() {
- function edit(networkUuid) {
- navigate("NetworkEdit", {uuid: networkUuid});
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby"),
- className: "edit",
- displayName: "Edit this network…",
- data: (target) => target.closest(".network").attr("data-uuid"),
- callback: edit,
- });
-}
-
-function addChannelListItem() {
- function list(itemData) {
- socket.emit("input", {
- target: parseInt(itemData, 10),
- text: "/list",
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby"),
- className: "list",
- displayName: "List all channels",
- data: (target) => target.attr("data-id"),
- callback: list,
- });
-}
-
-function addEditTopicItem() {
- function setEditTopic(itemData) {
- store.getters.findChannel(Number(itemData)).channel.editTopic = true;
- document.querySelector(`#sidebar .chan[data-id="${Number(itemData)}"]`).click();
-
- Vue.nextTick(() => {
- document.querySelector(`#chan-${Number(itemData)} .topic-input`).focus();
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("channel"),
- className: "edit",
- displayName: "Edit topic",
- data: (target) => target.attr("data-id"),
- callback: setEditTopic,
- });
-}
-
-function addBanListItem() {
- function banlist(itemData) {
- socket.emit("input", {
- target: parseInt(itemData, 10),
- text: "/banlist",
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("channel"),
- className: "list",
- displayName: "List banned users",
- data: (target) => target.attr("data-id"),
- callback: banlist,
- });
-}
-
-function addJoinItem() {
- function openJoinForm(itemData) {
- store.getters.findChannel(Number(itemData)).network.isJoinChannelShown = true;
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby"),
- className: "join",
- displayName: "Join a channel…",
- data: (target) => target.attr("data-id"),
- callback: openJoinForm,
- });
-}
-
-function addIgnoreListItem() {
- function ignorelist(itemData) {
- socket.emit("input", {
- target: parseInt(itemData, 10),
- text: "/ignorelist",
- });
- }
-
- addContextMenuItem({
- check: (target) => target.hasClass("lobby"),
- className: "list",
- displayName: "List ignored users",
- data: (target) => target.attr("data-id"),
- callback: ignorelist,
- });
-}
-
-function addDefaultItems() {
- addFocusItem();
- addWhoisItem();
- addQueryItem();
- addKickItem();
- addOpItem();
- addDeopItem();
- addVoiceItem();
- addDevoiceItem();
- addEditNetworkItem();
- addJoinItem();
- addChannelListItem();
- addEditTopicItem();
- addBanListItem();
- addIgnoreListItem();
- addConnectItem();
- addDisconnectItem();
- addCloseItem();
-}
-
-function registerEvents() {
- const viewport = $("#viewport");
-
- viewport.on("contextmenu", ".network .chan", function(e) {
- return createContextMenu($(this), e).show();
- });
-
- viewport.on("click contextmenu", ".user", function(e) {
- // If user is selecting text, do not open context menu
- // This primarily only targets mobile devices where selection is performed with touch
- if (!window.getSelection().isCollapsed) {
- return true;
- }
-
- return createContextMenu($(this), e).show();
- });
-
- viewport.on("click", "#chat .menu", function(e) {
- e.currentTarget = $(
- `#sidebar .chan[data-id="${$(this)
- .closest(".chan")
- .attr("data-id")}"]`
- )[0];
- return createContextMenu($(this), e).show();
- });
-}
diff --git a/client/js/helpers/contextMenu.js b/client/js/helpers/contextMenu.js
new file mode 100644
index 00000000..9f85e675
--- /dev/null
+++ b/client/js/helpers/contextMenu.js
@@ -0,0 +1,278 @@
+"use strict";
+import socket from "../socket";
+
+export function generateChannelContextMenu($root, channel, network) {
+ const typeMap = {
+ lobby: "network",
+ channel: "chan",
+ query: "query",
+ special: "chan",
+ };
+
+ const closeMap = {
+ lobby: "Remove",
+ channel: "Leave",
+ query: "Close",
+ special: "Close",
+ };
+
+ let items = [
+ {
+ label: channel.name,
+ type: "item",
+ class: typeMap[channel.type],
+ link: `/chan-${channel.id}`,
+ },
+ {
+ type: "divider",
+ },
+ ];
+
+ // Add menu items for lobbies
+ if (channel.type === "lobby") {
+ items = [
+ ...items,
+ {
+ label: "Edit this network…",
+ type: "item",
+ class: "edit",
+ link: `/edit-network/${network.uuid}`,
+ },
+ {
+ label: "Join a channel…",
+ type: "item",
+ class: "join",
+ action: () => (network.isJoinChannelShown = true),
+ },
+ {
+ label: "List all channels",
+ type: "item",
+ class: "list",
+ action: () =>
+ socket.emit("input", {
+ target: channel.id,
+ text: "/list",
+ }),
+ },
+ {
+ label: "List ignored users",
+ type: "item",
+ class: "list",
+ action: () =>
+ socket.emit("input", {
+ target: channel.id,
+ text: "/ignorelist",
+ }),
+ },
+ network.status.connected
+ ? {
+ label: "Disconnect",
+ type: "item",
+ class: "disconnect",
+ action: () =>
+ socket.emit("input", {
+ target: channel.id,
+ text: "/disconnect",
+ }),
+ }
+ : {
+ label: "Connect",
+ type: "item",
+ class: "connect",
+ action: () =>
+ socket.emit("input", {
+ target: channel.id,
+ text: "/connect",
+ }),
+ },
+ ];
+ }
+
+ // Add menu items for channels
+ if (channel.type === "channel") {
+ items = [
+ ...items,
+ {
+ label: "Edit topic",
+ type: "item",
+ class: "edit",
+ action() {
+ channel.editTopic = true;
+ $root.switchToChannel(channel);
+
+ $root.$nextTick(() =>
+ document.querySelector(`#chan-${channel.id} .topic-input`).focus()
+ );
+ },
+ },
+ {
+ label: "List banned users",
+ type: "item",
+ class: "list",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/banlist",
+ });
+ },
+ },
+ ];
+ }
+
+ // Add menu items for queries
+ if (channel.type === "query") {
+ items = [
+ ...items,
+ {
+ label: "User information",
+ type: "item",
+ class: "action-whois",
+ action() {
+ $root.switchToChannel(channel);
+ socket.emit("input", {
+ target: $root.$store.state.activeChannel.channel.id,
+ text: "/whois " + channel.name,
+ });
+ },
+ },
+ ];
+ }
+
+ // Add close menu item
+ items.push({
+ label: closeMap[channel.type],
+ type: "item",
+ class: "close",
+ action() {
+ const close = document.querySelector(
+ `.networks .chan[data-target="#chan-${channel.id}"] .close`
+ );
+
+ if (close) {
+ close.click();
+ }
+ },
+ });
+
+ return items;
+}
+
+export function generateUserContextMenu($root, channel, network, user) {
+ const currentChannelUser = channel.users.filter((u) => u.nick === network.nick)[0];
+
+ const whois = () => {
+ const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
+
+ if (chan) {
+ $root.switchToChannel(chan);
+ }
+
+ socket.emit("input", {
+ target: channel.id,
+ text: "/whois " + user.nick,
+ });
+ };
+
+ const items = [
+ {
+ label: user.nick,
+ type: "item",
+ class: "user",
+ action: whois,
+ },
+ {
+ type: "divider",
+ },
+ {
+ label: "User information",
+ type: "item",
+ class: "action-whois",
+ action: whois,
+ },
+ {
+ label: "Direct messages",
+ type: "item",
+ class: "action-query",
+ action() {
+ const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
+
+ if (chan) {
+ $root.switchToChannel(chan);
+ }
+
+ socket.emit("input", {
+ target: channel.id,
+ text: "/query " + user.nick,
+ });
+ },
+ },
+ ];
+
+ if (currentChannelUser.mode === "@") {
+ items.push({
+ label: "Kick",
+ type: "item",
+ class: "action-kick",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/kick " + user.nick,
+ });
+ },
+ });
+
+ if (user.mode === "@") {
+ items.push({
+ label: "Revoke operator (-o)",
+ type: "item",
+ class: "action-op",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/deop " + user.nick,
+ });
+ },
+ });
+ } else {
+ items.push({
+ label: "Give operator (+o)",
+ type: "item",
+ class: "action-op",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/op " + user.nick,
+ });
+ },
+ });
+ }
+
+ if (user.mode === "+") {
+ items.push({
+ label: "Revoke voice (-v)",
+ type: "item",
+ class: "action-voice",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/devoice " + user.nick,
+ });
+ },
+ });
+ } else {
+ items.push({
+ label: "Give voice (+v)",
+ type: "item",
+ class: "action-voice",
+ action() {
+ socket.emit("input", {
+ target: channel.id,
+ text: "/voice " + user.nick,
+ });
+ },
+ });
+ }
+ }
+
+ return items;
+}
diff --git a/client/js/helpers/parse.js b/client/js/helpers/parse.js
index a93f70dc..4c7358fb 100644
--- a/client/js/helpers/parse.js
+++ b/client/js/helpers/parse.js
@@ -12,6 +12,8 @@ const LinkPreviewToggle = require("../../components/LinkPreviewToggle.vue").defa
const LinkPreviewFileSize = require("../../components/LinkPreviewFileSize.vue").default;
const InlineChannel = require("../../components/InlineChannel.vue").default;
const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]/gu;
+const store = require("../store").default;
+const {generateUserContextMenu} = require("./contextMenu");
// Create an HTML `span` with styling information for a given fragment
function createFragment(fragment, createElement) {
@@ -69,7 +71,13 @@ function createFragment(fragment, createElement) {
// Transform an IRC message potentially filled with styling control codes, URLs,
// nicknames, and channels into a string of HTML elements to display on the client.
-module.exports = function parse(createElement, text, message = undefined, network = undefined) {
+module.exports = function parse(
+ createElement,
+ text,
+ message = undefined,
+ network = undefined,
+ $root
+) {
// Extract the styling information and get the plain text version from it
const styleFragments = parseStyle(text);
const cleanText = styleFragments.map((fragment) => fragment.text).join("");
@@ -188,6 +196,23 @@ module.exports = function parse(createElement, text, message = undefined, networ
dir: "auto",
"data-name": textPart.nick,
},
+ on: {
+ contextmenu($event) {
+ $event.preventDefault();
+ const channel = store.state.activeChannel.channel;
+ let user = channel.users.find((u) => u.nick === textPart.nick);
+
+ if (!user) {
+ user = {
+ nick: textPart.nick,
+ mode: "",
+ };
+ }
+
+ const items = generateUserContextMenu($root, channel, network, user);
+ $root.$refs.app.openContextMenu($event, items);
+ },
+ },
},
fragments
);
diff --git a/client/js/utils.js b/client/js/utils.js
deleted file mode 100644
index 61f8a0fc..00000000
--- a/client/js/utils.js
+++ /dev/null
@@ -1,21 +0,0 @@
-"use strict";
-
-const $ = require("jquery");
-const escape = require("css.escape");
-
-module.exports = {
- hasRoleInChannel,
-};
-
-// Given a channel element will determine if the lounge user or a given nick is one of the supplied roles.
-function hasRoleInChannel(channel, roles, nick) {
- if (!channel || !roles) {
- return false;
- }
-
- const channelID = channel.attr("data-id");
- const network = $("#sidebar .network").has(`.chan[data-id="${channelID}"]`);
- const target = nick || network.attr("data-nick");
- const user = channel.find(`.names .user[data-name="${escape(target)}"]`).first();
- return user.parent().is("." + roles.join(", ."));
-}
diff --git a/client/js/vue.js b/client/js/vue.js
index df0bb951..6157a9c7 100644
--- a/client/js/vue.js
+++ b/client/js/vue.js
@@ -13,7 +13,6 @@ const socket = require("./socket");
Vue.filter("localetime", localetime);
require("./socket-events");
-require("./contextMenuFactory");
require("./webpush");
require("./keybinds");