Repository: thelounge/thelounge
Pull request #4267 (2021-06-29 22:23:33)

properly track user modes for context menu
The RPL_ISUPPORT response contains a PREFIX element, which not only tracks the
prefix chars ("@", "+" etc) but also their corresponding mode chars (+O, +v)
This commit changes the context menu to not rely on a hardcoded list but rather
user the one given in the prefix response by the server.

@fnutt ping, please test this if you can and find the time

fnutt: Works very well! No errors as before.

Thanks for the quick fix. 👍 
brunnre8: Thanks for the review xPaw, appreciate it.
Bear with me with the style choices and please do correct me if something is not idiomatic. I'm at home in python and go so this will bleed in.
brunnre8: This should be done I think, I've refactored the prefixLookup map to get rid of the separate map that was passed around, should make things slightly easier server side.
On the client, not so much I'm afraid but I don't see a way to make this cleaner.
brunnre8: Got rid of the "Unknown mode" thing as per user feedback.
PTAL

Patch:
diff --git a/client/js/helpers/contextMenu.js b/client/js/helpers/contextMenu.js
index feadd15c..b17c3794 100644
--- a/client/js/helpers/contextMenu.js
+++ b/client/js/helpers/contextMenu.js
@@ -252,22 +252,30 @@ export function generateUserContextMenu($root, channel, network, user) {
 		return items;
 	}
 
-	// Names of the modes we are able to change
-	const modes = {
-		"~": ["owner", "q"],
-		"&": ["admin", "a"],
-		"@": ["operator", "o"],
-		"%": ["half-op", "h"],
-		"+": ["voice", "v"],
+	// Names of the standard modes we are able to change
+	const modeCharToName = {
+		"~": "owner",
+		"&": "admin",
+		"@": "operator",
+		"%": "half-op",
+		"+": "voice",
 	};
 
-	// Labels for the mode changes.  For example .rev(['admin', 'a']) => 'Revoke admin (-a)'
+	// Labels for the mode changes.  For example .rev({mode: "a", symbol: "&"}) => 'Revoke admin (-a)'
 	const modeTextTemplate = {
-		revoke: (m) => `Revoke ${m[0]} (-${m[1]})`,
-		give: (m) => `Give ${m[0]} (+${m[1]})`,
+		revoke(m) {
+			const name = modeCharToName[m.symbol];
+			const res = name ? `Revoke ${name} (-${m.mode})` : `Mode -${m.mode}`;
+			return res;
+		},
+		give(m) {
+			const name = modeCharToName[m.symbol];
+			const res = name ? `Give ${name} (+${m.mode})` : `Mode +${m.mode}`;
+			return res;
+		},
 	};
 
-	const networkModes = network.serverOptions.PREFIX;
+	const networkModeSymbols = network.serverOptions.PREFIX.symbols;
 
 	/**
 	 * Determine whether the prefix of mode p1 has access to perform actions on p2.
@@ -284,38 +292,38 @@ export function generateUserContextMenu($root, channel, network, user) {
 	function compare(p1, p2) {
 		// The modes ~ and @ can perform actions on their own mode.  The others on modes below.
 		return "~@".indexOf(p1) > -1
-			? networkModes.indexOf(p1) <= networkModes.indexOf(p2)
-			: networkModes.indexOf(p1) < networkModes.indexOf(p2);
+			? networkModeSymbols.indexOf(p1) <= networkModeSymbols.indexOf(p2)
+			: networkModeSymbols.indexOf(p1) < networkModeSymbols.indexOf(p2);
 	}
 
-	networkModes.forEach((prefix) => {
-		if (!compare(currentChannelUser.modes[0], prefix)) {
+	network.serverOptions.PREFIX.prefix.forEach((mode) => {
+		if (!compare(currentChannelUser.modes[0], mode.symbol)) {
 			// Our highest mode is below the current mode.  Bail.
 			return;
 		}
 
-		if (!user.modes.includes(prefix)) {
+		if (!user.modes.includes(mode.symbol)) {
 			// The target doesn't already have this mode, therefore we can set it.
 			items.push({
-				label: modeTextTemplate.give(modes[prefix]),
+				label: modeTextTemplate.give(mode),
 				type: "item",
 				class: "action-set-mode",
 				action() {
 					socket.emit("input", {
 						target: channel.id,
-						text: "/mode +" + modes[prefix][1] + " " + user.nick,
+						text: "/mode +" + mode.mode + " " + user.nick,
 					});
 				},
 			});
 		} else {
 			items.push({
-				label: modeTextTemplate.revoke(modes[prefix]),
+				label: modeTextTemplate.revoke(mode),
 				type: "item",
 				class: "action-revoke-mode",
 				action() {
 					socket.emit("input", {
 						target: channel.id,
-						text: "/mode -" + modes[prefix][1] + " " + user.nick,
+						text: "/mode -" + mode.mode + " " + user.nick,
 					});
 				},
 			});
@@ -323,9 +331,9 @@ export function generateUserContextMenu($root, channel, network, user) {
 	});
 
 	// Determine if we are half-op or op depending on the network modes so we can kick.
-	if (!compare(networkModes.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) {
+	if (!compare(networkModeSymbols.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) {
+		// Check if the target user has no mode or a mode lower than ours.
 		if (user.modes.length === 0 || compare(currentChannelUser.modes[0], user.modes[0])) {
-			// Check if the target user has no mode or a mode lower than ours.
 			items.push({
 				label: "Kick",
 				type: "item",
diff --git a/client/js/helpers/parse.js b/client/js/helpers/parse.js
index 9097d96f..675dd0b0 100644
--- a/client/js/helpers/parse.js
+++ b/client/js/helpers/parse.js
@@ -79,7 +79,7 @@ function parse(createElement, text, message = undefined, network = undefined) {
 	// arrays of objects containing start and end markers, as well as metadata
 	// depending on what was found (channel or link).
 	const channelPrefixes = network ? network.serverOptions.CHANTYPES : ["#", "&"];
-	const userModes = network ? network.serverOptions.PREFIX : ["!", "@", "%", "+"];
+	const userModes = network?.serverOptions?.PREFIX.symbols || ["!", "@", "%", "+"];
 	const channelParts = findChannels(cleanText, channelPrefixes, userModes);
 	const linkParts = findLinks(cleanText);
 	const emojiParts = findEmoji(cleanText);
diff --git a/src/models/network.js b/src/models/network.js
index 13879fb2..20e58752 100644
--- a/src/models/network.js
+++ b/src/models/network.js
@@ -5,6 +5,7 @@ const {v4: uuidv4} = require("uuid");
 const IrcFramework = require("irc-framework");
 const Chan = require("./chan");
 const Msg = require("./msg");
+const Prefix = require("./prefix");
 const Helper = require("../helper");
 const STSPolicies = require("../plugins/sts");
 const ClientCertificate = require("../plugins/clientCertificate");
@@ -43,7 +44,12 @@ function Network(attr) {
 		irc: null,
 		serverOptions: {
 			CHANTYPES: ["#", "&"],
-			PREFIX: ["!", "@", "%", "+"],
+			PREFIX: new Prefix([
+				{symbol: "!", mode: "Y"},
+				{symbol: "@", mode: "o"},
+				{symbol: "%", mode: "h"},
+				{symbol: "+", mode: "v"},
+			]),
 			NETWORK: "",
 		},
 
diff --git a/src/models/prefix.js b/src/models/prefix.js
new file mode 100644
index 00000000..331efdff
--- /dev/null
+++ b/src/models/prefix.js
@@ -0,0 +1,33 @@
+"use strict";
+
+class Prefix {
+	constructor(prefix) {
+		this.prefix = prefix || []; // [{symbol: "@", mode: "o"}, ... ]
+		this.modeToSymbol = {};
+		this.symbols = [];
+		this._update_internals();
+	}
+
+	_update_internals() {
+		// clean out the old cruft
+		this.modeToSymbol = {};
+		this.symbols = [];
+
+		const that = this;
+		this.prefix.forEach(function (p) {
+			that.modeToSymbol[p.mode] = p.symbol;
+			that.symbols.push(p.symbol);
+		});
+	}
+
+	update(prefix) {
+		this.prefix = prefix || [];
+		this._update_internals();
+	}
+
+	forEach(f) {
+		return this.prefix.forEach(f);
+	}
+}
+
+module.exports = Prefix;
diff --git a/src/models/user.js b/src/models/user.js
index 591ebddd..dee1e9d0 100644
--- a/src/models/user.js
+++ b/src/models/user.js
@@ -4,7 +4,7 @@ const _ = require("lodash");
 
 module.exports = User;
 
-function User(attr, prefixLookup) {
+function User(attr, prefix) {
 	_.defaults(this, attr, {
 		modes: [],
 		away: "",
@@ -18,12 +18,12 @@ function User(attr, prefixLookup) {
 		},
 	});
 
-	this.setModes(this.modes, prefixLookup);
+	this.setModes(this.modes, prefix);
 }
 
-User.prototype.setModes = function (modes, prefixLookup) {
+User.prototype.setModes = function (modes, prefix) {
 	// irc-framework sets character mode, but The Lounge works with symbols
-	this.modes = modes.map((mode) => prefixLookup[mode]);
+	this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
 };
 
 User.prototype.toJSON = function () {
diff --git a/src/plugins/irc-events/connection.js b/src/plugins/irc-events/connection.js
index 029eefc2..3ef3000a 100644
--- a/src/plugins/irc-events/connection.js
+++ b/src/plugins/irc-events/connection.js
@@ -63,10 +63,9 @@ module.exports = function (irc, network) {
 	});
 
 	irc.on("socket connected", function () {
-		network.prefixLookup = {};
-		irc.network.options.PREFIX.forEach(function (mode) {
-			network.prefixLookup[mode.mode] = mode.symbol;
-		});
+		if (irc.network.options.PREFIX) {
+			network.serverOptions.PREFIX.update(irc.network.options.PREFIX);
+		}
 
 		network.channels[0].pushMessage(
 			client,
@@ -197,20 +196,12 @@ module.exports = function (irc, network) {
 	});
 
 	irc.on("server options", function (data) {
-		network.prefixLookup = {};
-
-		data.options.PREFIX.forEach((mode) => {
-			network.prefixLookup[mode.mode] = mode.symbol;
-		});
+		network.serverOptions.PREFIX.update(data.options.PREFIX);
 
 		if (data.options.CHANTYPES) {
 			network.serverOptions.CHANTYPES = data.options.CHANTYPES;
 		}
 
-		if (network.serverOptions.PREFIX) {
-			network.serverOptions.PREFIX = data.options.PREFIX.map((p) => p.symbol);
-		}
-
 		network.serverOptions.NETWORK = data.options.NETWORK;
 
 		client.emit("network:options", {
diff --git a/src/plugins/irc-events/mode.js b/src/plugins/irc-events/mode.js
index b07f0954..9c9bf6b2 100644
--- a/src/plugins/irc-events/mode.js
+++ b/src/plugins/irc-events/mode.js
@@ -107,7 +107,7 @@ module.exports = function (irc, network) {
 				return;
 			}
 
-			const changedMode = network.prefixLookup[char];
+			const changedMode = network.serverOptions.PREFIX.modeToSymbol[char];
 
 			if (!add) {
 				_.pull(user.modes, changedMode);
diff --git a/src/plugins/irc-events/names.js b/src/plugins/irc-events/names.js
index 2e0a7db8..8368b281 100644
--- a/src/plugins/irc-events/names.js
+++ b/src/plugins/irc-events/names.js
@@ -14,7 +14,7 @@ module.exports = function (irc, network) {
 
 		data.users.forEach((user) => {
 			const newUser = chan.getUser(user.nick);
-			newUser.setModes(user.modes, network.prefixLookup);
+			newUser.setModes(user.modes, network.serverOptions.PREFIX);
 
 			newUsers.set(user.nick.toLowerCase(), newUser);
 		});


Test patch:
diff --git a/test/models/chan.js b/test/models/chan.js
index 33ee41e5..613bece3 100644
--- a/test/models/chan.js
+++ b/test/models/chan.js
@@ -20,10 +20,10 @@ describe("Chan", function () {
 		},
 	};
 
-	const prefixLookup = {};
+	const prefixLookup = {modeToSymbol: {}};
 
 	network.network.options.PREFIX.forEach((mode) => {
-		prefixLookup[mode.mode] = mode.symbol;
+		prefixLookup.modeToSymbol[mode.mode] = mode.symbol;
 	});
 
 	describe("#findMessage(id)", function () {
diff --git a/test/models/msg.js b/test/models/msg.js
index 3c7d59b7..7690754a 100644
--- a/test/models/msg.js
+++ b/test/models/msg.js
@@ -8,7 +8,7 @@ const User = require("../../src/models/user");
 describe("Msg", function () {
 	["from", "target"].forEach((prop) => {
 		it(`should keep a copy of the original user in the \`${prop}\` property`, function () {
-			const prefixLookup = {a: "&", o: "@"};
+			const prefixLookup = {modeToSymbol: {a: "&", o: "@"}};
 			const user = new User(
 				{
 					modes: ["o"],