Port contextmenus to Vue
This commit is contained in:
parent
111c3665f9
commit
a71472a427
@ -6,6 +6,7 @@
|
||||
<router-view ref="window"></router-view>
|
||||
</article>
|
||||
<ImageViewer ref="imageViewer" />
|
||||
<ContextMenu ref="contextMenu" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -24,6 +24,7 @@
|
||||
:style="closed ? {transition: 'none', opacity: 0.4} : null"
|
||||
role="tab"
|
||||
@click="click"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<slot :network="network" :channel="channel" :activeChannel="activeChannel" />
|
||||
</div>
|
||||
@ -31,6 +32,7 @@
|
||||
|
||||
<script>
|
||||
import socket from "../js/socket";
|
||||
import {generateChannelContextMenu} from "../js/helpers/contextMenu.js";
|
||||
|
||||
export default {
|
||||
name: "ChannelWrapper",
|
||||
@ -77,6 +79,10 @@ export default {
|
||||
click() {
|
||||
this.$root.switchToChannel(this.channel);
|
||||
},
|
||||
openContextMenu(event) {
|
||||
const items = generateChannelContextMenu(this.$root, this.channel, this.network);
|
||||
this.$root.$refs.app.openContextMenu(event, items);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -38,7 +38,11 @@
|
||||
:network="network"
|
||||
:text="channel.topic"
|
||||
/></span>
|
||||
<button class="menu" aria-label="Open the context menu" />
|
||||
<button
|
||||
class="menu"
|
||||
aria-label="Open the context menu"
|
||||
@click="openContextMenu"
|
||||
/>
|
||||
<span
|
||||
v-if="channel.type === 'channel'"
|
||||
class="rt-tooltip tooltipped tooltipped-w"
|
||||
@ -93,7 +97,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const socket = require("../js/socket");
|
||||
import socket from "../js/socket";
|
||||
import {generateChannelContextMenu} from "../js/helpers/contextMenu.js";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
@ -179,6 +184,10 @@ export default {
|
||||
socket.emit("input", {target, text});
|
||||
}
|
||||
},
|
||||
openContextMenu(event) {
|
||||
const items = generateChannelContextMenu(this.$root, this.channel, this.network);
|
||||
this.$root.$refs.app.openContextMenu(event, items);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -32,6 +32,7 @@
|
||||
:on-hover="hoverUser"
|
||||
:active="user.original === activeUser"
|
||||
:user="user"
|
||||
:context-menu-callback="openContextMenu"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@ -41,6 +42,7 @@
|
||||
:on-hover="hoverUser"
|
||||
:active="user === activeUser"
|
||||
:user="user"
|
||||
:context-menu-callback="openContextMenu"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
141
client/components/ContextMenu.vue
Normal file
141
client/components/ContextMenu.vue
Normal 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>
|
@ -20,10 +20,11 @@
|
||||
<template v-else-if="message.type === 'action'">
|
||||
<span class="from"><span class="only-copy">* </span></span>
|
||||
<span class="content" dir="auto">
|
||||
<Username :user="message.from" dir="auto" /> <ParsedMessage
|
||||
:network="network"
|
||||
:message="message"
|
||||
/>
|
||||
<Username
|
||||
:user="message.from"
|
||||
dir="auto"
|
||||
:context-menu-callback="openUserContextMenu"
|
||||
/> <ParsedMessage :network="network" :message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
@ -36,7 +37,7 @@
|
||||
<span v-if="message.type === 'message'" class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy"><</span>
|
||||
<Username :user="message.from" />
|
||||
<Username :user="message.from" :context-menu-callback="openUserContextMenu" />
|
||||
<span class="only-copy">> </span>
|
||||
</template>
|
||||
</span>
|
||||
@ -50,7 +51,7 @@
|
||||
<span v-else class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy">-</span>
|
||||
<Username :user="message.from" />
|
||||
<Username :user="message.from" :context-menu-callback="openUserContextMenu" />
|
||||
<span class="only-copy">- </span>
|
||||
</template>
|
||||
</span>
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -42,6 +42,7 @@
|
||||
<Message
|
||||
v-else
|
||||
:key="message.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:message="message"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
|
@ -16,7 +16,8 @@ export default {
|
||||
? context.props.text
|
||||
: context.props.message.text,
|
||||
context.props.message,
|
||||
context.props.network
|
||||
context.props.network,
|
||||
context.parent.$root
|
||||
);
|
||||
},
|
||||
};
|
||||
|
@ -4,6 +4,7 @@
|
||||
:data-name="user.nick"
|
||||
role="button"
|
||||
v-on="onHover ? {mouseover: hover} : {}"
|
||||
@contextmenu.prevent="rightClick($event)"
|
||||
>{{ user.mode }}{{ user.nick }}</span
|
||||
>
|
||||
</template>
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -4,6 +4,7 @@
|
||||
:data-name="user.original.nick"
|
||||
role="button"
|
||||
@mouseover="hover"
|
||||
@contextmenu.prevent="rightClick($event)"
|
||||
v-html="user.original.mode + user.string"
|
||||
/>
|
||||
</template>
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -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;
|
||||
|
@ -64,7 +64,6 @@
|
||||
</div>
|
||||
<div id="viewport"></div>
|
||||
|
||||
<div id="context-menu-container"></div>
|
||||
<div id="upload-overlay"></div>
|
||||
|
||||
<script src="js/bundle.vendor.js?v=<%- cacheBust %>"></script>
|
||||
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
}
|
278
client/js/helpers/contextMenu.js
Normal file
278
client/js/helpers/contextMenu.js
Normal 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;
|
||||
}
|
@ -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
|
||||
);
|
||||
|
@ -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(", ."));
|
||||
}
|
@ -13,7 +13,6 @@ const socket = require("./socket");
|
||||
Vue.filter("localetime", localetime);
|
||||
|
||||
require("./socket-events");
|
||||
require("./contextMenuFactory");
|
||||
require("./webpush");
|
||||
require("./keybinds");
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user