Port contextmenus to Vue

This commit is contained in:
Richard Lewis 2019-11-09 22:21:34 +00:00 committed by Pavel Djundik
parent 111c3665f9
commit a71472a427
18 changed files with 507 additions and 644 deletions

View File

@ -6,6 +6,7 @@
<router-view ref="window"></router-view> <router-view ref="window"></router-view>
</article> </article>
<ImageViewer ref="imageViewer" /> <ImageViewer ref="imageViewer" />
<ContextMenu ref="contextMenu" />
</div> </div>
</template> </template>
@ -16,12 +17,14 @@ const storage = require("../js/localStorage");
import Sidebar from "./Sidebar.vue"; import Sidebar from "./Sidebar.vue";
import ImageViewer from "./ImageViewer.vue"; import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue";
export default { export default {
name: "App", name: "App",
components: { components: {
Sidebar, Sidebar,
ImageViewer, ImageViewer,
ContextMenu,
}, },
computed: { computed: {
viewportClasses() { viewportClasses() {
@ -84,6 +87,10 @@ export default {
this.$store.commit("userlistOpen", isUserlistOpen === "true"); 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);
},
}, },
}; };
</script> </script>

View File

@ -24,6 +24,7 @@
:style="closed ? {transition: 'none', opacity: 0.4} : null" :style="closed ? {transition: 'none', opacity: 0.4} : null"
role="tab" role="tab"
@click="click" @click="click"
@contextmenu.prevent="openContextMenu"
> >
<slot :network="network" :channel="channel" :activeChannel="activeChannel" /> <slot :network="network" :channel="channel" :activeChannel="activeChannel" />
</div> </div>
@ -31,6 +32,7 @@
<script> <script>
import socket from "../js/socket"; import socket from "../js/socket";
import {generateChannelContextMenu} from "../js/helpers/contextMenu.js";
export default { export default {
name: "ChannelWrapper", name: "ChannelWrapper",
@ -77,6 +79,10 @@ export default {
click() { click() {
this.$root.switchToChannel(this.channel); this.$root.switchToChannel(this.channel);
}, },
openContextMenu(event) {
const items = generateChannelContextMenu(this.$root, this.channel, this.network);
this.$root.$refs.app.openContextMenu(event, items);
},
}, },
}; };
</script> </script>

View File

@ -38,7 +38,11 @@
:network="network" :network="network"
:text="channel.topic" :text="channel.topic"
/></span> /></span>
<button class="menu" aria-label="Open the context menu" /> <button
class="menu"
aria-label="Open the context menu"
@click="openContextMenu"
/>
<span <span
v-if="channel.type === 'channel'" v-if="channel.type === 'channel'"
class="rt-tooltip tooltipped tooltipped-w" class="rt-tooltip tooltipped tooltipped-w"
@ -93,7 +97,8 @@
</template> </template>
<script> <script>
const socket = require("../js/socket"); import socket from "../js/socket";
import {generateChannelContextMenu} from "../js/helpers/contextMenu.js";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue"; import ChatInput from "./ChatInput.vue";
@ -179,6 +184,10 @@ export default {
socket.emit("input", {target, text}); socket.emit("input", {target, text});
} }
}, },
openContextMenu(event) {
const items = generateChannelContextMenu(this.$root, this.channel, this.network);
this.$root.$refs.app.openContextMenu(event, items);
},
}, },
}; };
</script> </script>

View File

@ -32,6 +32,7 @@
:on-hover="hoverUser" :on-hover="hoverUser"
:active="user.original === activeUser" :active="user.original === activeUser"
:user="user" :user="user"
:context-menu-callback="openContextMenu"
/> />
</template> </template>
<template v-else> <template v-else>
@ -41,6 +42,7 @@
:on-hover="hoverUser" :on-hover="hoverUser"
:active="user === activeUser" :active="user === activeUser"
:user="user" :user="user"
:context-menu-callback="openContextMenu"
/> />
</template> </template>
</div> </div>
@ -52,6 +54,7 @@
const fuzzy = require("fuzzy"); const fuzzy = require("fuzzy");
import Username from "./Username.vue"; import Username from "./Username.vue";
import UsernameFiltered from "./UsernameFiltered.vue"; import UsernameFiltered from "./UsernameFiltered.vue";
import {generateUserContextMenu} from "../js/helpers/contextMenu.js";
const modes = { const modes = {
"~": "owner", "~": "owner",
@ -194,6 +197,11 @@ export default {
el.scrollIntoView({block: "nearest", inline: "nearest"}); 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);
},
}, },
}; };
</script> </script>

View File

@ -0,0 +1,141 @@
<template>
<div v-if="isOpen" id="context-menu-container" @click="close" @contextmenu.prevent="close">
<ul id="context-menu" ref="contextMenu" role="menu" :style="style">
<li
v-for="item of items"
:key="item.name"
:class="[
'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null,
]"
tabindex="0"
role="menuitem"
@click="clickItem(item)"
>
{{ item.label }}
</li>
</ul>
</div>
</template>
<script>
const Mousetrap = require("mousetrap");
export default {
name: "ContextMenu",
props: {
message: Object,
},
data() {
return {
isOpen: false,
previousActiveElement: null,
items: [],
style: {
left: 0,
top: 0,
},
};
},
mounted() {
document.addEventListener("keydown", (e) => {
if (e.code === "Escape") {
this.close();
}
});
const trap = Mousetrap(this.$refs.contextMenu);
trap.bind(["up", "down"], (e, key) => {
if (!this.isOpen) {
return;
}
const items = this.$refs.contextMenu.querySelectorAll(".context-menu-item");
let index = Array.from(items).findIndex((item) => item === document.activeElement);
if (key === "down") {
index = (index + 1) % items.length;
} else {
index = Math.max(index, 0) - 1;
if (index < 0) {
index = items.length + index;
}
}
items[index].focus();
});
trap.bind("enter", () => {
if (!this.isOpen) {
return;
}
const item = this.$refs.contextMenu.querySelector(":focus");
item.click();
return false;
});
},
methods: {
open(event, items) {
this.items = items;
this.isOpen = true;
this.previousActiveElement = document.activeElement;
// Position the menu and set the focus on the first item after it's size has updated
this.$nextTick(() => {
const pos = this.positionContextMenu(event);
this.style.left = pos.left + "px";
this.style.top = pos.top + "px";
this.$refs.contextMenu.querySelector(".context-menu-item:first-child").focus();
});
},
close() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = null;
}
},
clickItem(item) {
if (item.action) {
item.action();
} else if (item.link) {
this.$router.push(item.link);
}
},
positionContextMenu(event) {
const element = event.target;
const menuWidth = this.$refs.contextMenu.offsetWidth;
const menuHeight = this.$refs.contextMenu.offsetHeight;
if (element.classList.contains("menu")) {
return {
left: element.getBoundingClientRect().left - (menuWidth - element.offsetWidth),
top: element.getBoundingClientRect().top + element.offsetHeight,
};
}
const offset = {left: event.pageX, top: event.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;
},
},
};
</script>

View File

@ -20,10 +20,11 @@
<template v-else-if="message.type === 'action'"> <template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span> <span class="from"><span class="only-copy">* </span></span>
<span class="content" dir="auto"> <span class="content" dir="auto">
<Username :user="message.from" dir="auto" />&#32;<ParsedMessage <Username
:network="network" :user="message.from"
:message="message" dir="auto"
/> :context-menu-callback="openUserContextMenu"
/>&#32;<ParsedMessage :network="network" :message="message" />
<LinkPreview <LinkPreview
v-for="preview in message.previews" v-for="preview in message.previews"
:key="preview.link" :key="preview.link"
@ -36,7 +37,7 @@
<span v-if="message.type === 'message'" class="from"> <span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick"> <template v-if="message.from && message.from.nick">
<span class="only-copy">&lt;</span> <span class="only-copy">&lt;</span>
<Username :user="message.from" /> <Username :user="message.from" :context-menu-callback="openUserContextMenu" />
<span class="only-copy">&gt; </span> <span class="only-copy">&gt; </span>
</template> </template>
</span> </span>
@ -50,7 +51,7 @@
<span v-else class="from"> <span v-else class="from">
<template v-if="message.from && message.from.nick"> <template v-if="message.from && message.from.nick">
<span class="only-copy">-</span> <span class="only-copy">-</span>
<Username :user="message.from" /> <Username :user="message.from" :context-menu-callback="openUserContextMenu" />
<span class="only-copy">- </span> <span class="only-copy">- </span>
</template> </template>
</span> </span>
@ -73,6 +74,7 @@ import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue"; import ParsedMessage from "./ParsedMessage.vue";
import MessageTypes from "./MessageTypes"; import MessageTypes from "./MessageTypes";
import {generateUserContextMenu} from "../js/helpers/contextMenu.js";
const constants = require("../js/constants"); const constants = require("../js/constants");
@ -85,6 +87,7 @@ export default {
components: MessageTypes, components: MessageTypes,
props: { props: {
message: Object, message: Object,
channel: Object,
network: Object, network: Object,
keepScrollPosition: Function, keepScrollPosition: Function,
}, },
@ -104,6 +107,10 @@ export default {
isAction() { isAction() {
return typeof MessageTypes["message-" + this.message.type] !== "undefined"; 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);
},
}, },
}; };
</script> </script>

View File

@ -42,6 +42,7 @@
<Message <Message
v-else v-else
:key="message.id" :key="message.id"
:channel="channel"
:network="network" :network="network"
:message="message" :message="message"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"

View File

@ -16,7 +16,8 @@ export default {
? context.props.text ? context.props.text
: context.props.message.text, : context.props.message.text,
context.props.message, context.props.message,
context.props.network context.props.network,
context.parent.$root
); );
}, },
}; };

View File

@ -4,6 +4,7 @@
:data-name="user.nick" :data-name="user.nick"
role="button" role="button"
v-on="onHover ? {mouseover: hover} : {}" v-on="onHover ? {mouseover: hover} : {}"
@contextmenu.prevent="rightClick($event)"
>{{ user.mode }}{{ user.nick }}</span >{{ user.mode }}{{ user.nick }}</span
> >
</template> </template>
@ -17,6 +18,7 @@ export default {
user: Object, user: Object,
active: Boolean, active: Boolean,
onHover: Function, onHover: Function,
contextMenuCallback: Function,
}, },
computed: { computed: {
nickColor() { nickColor() {
@ -27,6 +29,11 @@ export default {
hover() { hover() {
return this.onHover(this.user); return this.onHover(this.user);
}, },
rightClick($event) {
if (this.contextMenuCallback) {
this.contextMenuCallback($event, this.user);
}
},
}, },
}; };
</script> </script>

View File

@ -4,6 +4,7 @@
:data-name="user.original.nick" :data-name="user.original.nick"
role="button" role="button"
@mouseover="hover" @mouseover="hover"
@contextmenu.prevent="rightClick($event)"
v-html="user.original.mode + user.string" v-html="user.original.mode + user.string"
/> />
</template> </template>
@ -17,6 +18,7 @@ export default {
user: Object, user: Object,
active: Boolean, active: Boolean,
onHover: Function, onHover: Function,
contextMenuCallback: Function,
}, },
computed: { computed: {
nickColor() { nickColor() {
@ -27,6 +29,11 @@ export default {
hover() { hover() {
this.onHover ? this.onHover(this.user.original) : null; this.onHover ? this.onHover(this.user.original) : null;
}, },
rightClick($event) {
if (this.contextMenuCallback) {
this.contextMenuCallback($event, this.user);
}
},
}, },
}; };
</script> </script>

View File

@ -2067,7 +2067,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
} }
#context-menu-container { #context-menu-container {
display: none;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;

View File

@ -64,7 +64,6 @@
</div> </div>
<div id="viewport"></div> <div id="viewport"></div>
<div id="context-menu-container"></div>
<div id="upload-overlay"></div> <div id="upload-overlay"></div>
<script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script> <script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script>

View File

@ -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 = $("<ul>", {
id: "context-menu",
role: "menu",
});
for (const item of contextMenuItems) {
if (item.check(target)) {
if (item.divider) {
contextMenu.append('<li class="context-menu-divider" aria-hidden="true"></li>');
} else {
// <li class="context-menu-item context-menu-{{class}}" data-action="{{action}}"{{#if data}} data-data="{{data}}"{{/if}} tabindex="0" role="menuitem">{{text}}</li>
contextMenu.append(
$("<li>", {
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;
}

View File

@ -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();
});
}

View File

@ -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;
}

View File

@ -12,6 +12,8 @@ const LinkPreviewToggle = require("../../components/LinkPreviewToggle.vue").defa
const LinkPreviewFileSize = require("../../components/LinkPreviewFileSize.vue").default; const LinkPreviewFileSize = require("../../components/LinkPreviewFileSize.vue").default;
const InlineChannel = require("../../components/InlineChannel.vue").default; const InlineChannel = require("../../components/InlineChannel.vue").default;
const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]/gu; 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 // Create an HTML `span` with styling information for a given fragment
function createFragment(fragment, createElement) { function createFragment(fragment, createElement) {
@ -69,7 +71,13 @@ function createFragment(fragment, createElement) {
// Transform an IRC message potentially filled with styling control codes, URLs, // 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. // 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 // Extract the styling information and get the plain text version from it
const styleFragments = parseStyle(text); const styleFragments = parseStyle(text);
const cleanText = styleFragments.map((fragment) => fragment.text).join(""); const cleanText = styleFragments.map((fragment) => fragment.text).join("");
@ -188,6 +196,23 @@ module.exports = function parse(createElement, text, message = undefined, networ
dir: "auto", dir: "auto",
"data-name": textPart.nick, "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 fragments
); );

View File

@ -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(", ."));
}

View File

@ -13,7 +13,6 @@ const socket = require("./socket");
Vue.filter("localetime", localetime); Vue.filter("localetime", localetime);
require("./socket-events"); require("./socket-events");
require("./contextMenuFactory");
require("./webpush"); require("./webpush");
require("./keybinds"); require("./keybinds");