Repository: thelounge/thelounge
Pull request #2197 (2018-03-11 18:59:06)

Add support for /ignore, /unignore and /ignorelist commands
![](https://sr.ht/CHl3.png)


McInkay: I'd like to block this until #1878 is merged. Hopefully shouldn't be too long as all current comments have been addressed and it's just waiting for a last review from @astorije 
Adrian-Turjak: Can we possibly add slightly more granularity for the ignore functionality when it comes to joins parts, quits? So for channels, and users, having the option to ignore specific interaction types you see, would be good.

E.g. I want to see joins and such for most stuff, but some bot users I don't since they leave/join all the time.

I tried doing this with css (which would work for me), but joins parts and quits do not have data-from for the user they are related to... so I can't.
McInkay: @Adrian-Turjak Yes, but that'll come later. This is a good start.
McInkay: This will need to be refactored now that #1878 is merged, and use the new structure for adding context menu items. If you have any questions about how this works, or anything is unclear about what I've changed then give me a shout @RockyTV, either here or on irc.
Adrian-Turjak: @xPaw 
> You must be using an outdated version, because actions do have data-from set.

We likely are! Will try when we upgrade. We're currently waiting for the next release with the log reloading.
astorije: Hey @Adrian-Turjak, just curious, when you say "we", do you mean your company? Are you guys using The Lounge for work?
Adrian-Turjak: @astorije Running it for our team at work on a spare workstation machine with coreos under someone's desk. It's just being used for maybe 6 or so people.
astorije: @Adrian-Turjak, that's pretty cool! Any chance I could contact you in private (email) to ask you a few follow-up questions on your experience? If you don't want to expose your email on GH, feel free to drop me an email at jeremie(at)astori.fr.

@McInkay, I believe @RockyTV is done updating this PR, so it's open for review again!
RockyTV: Fixed the merge conflict. Is there anything else I should change or do? If not, then it's ready to be reviewed.
astorije: @xPaw, mind reviewing that? I can also give it a light review after you're okay with it if you want.
astorije: Testing this when rebased on `master`, clicking on "List ignored users" or sending `/ignorelist` doesn't do anything (even after I added someone to the ignore list). Is there something I'm missing?
RockyTV: @astorije I have double-checked the code and I don't think there's an issue with it. I don't know what happened since last month but lists are now broken, i.e., ban list, ignore list and the channel list aren't being displayed by TL.
RockyTV: Should be good to go.
astorije: Alright, testing: `The Lounge source (e81acc4d / v3.0.0-pre.6) (Node.js 8.9.4 on darwin x64)`

`/ignorelist` now works, but right clicking on network and clicking on "List ignored users" does nothing. @RockyTV or @xPaw, could you re-test on your end maybe?
RockyTV: @astorije Sorry, fixed it now. Should be fine.

Patch:
diff --git a/client/css/style.css b/client/css/style.css
index 88501852..6a14aaac 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -1248,15 +1248,18 @@ kbd {
 }
 
 #chat table.channel-list,
-#chat table.ban-list {
+#chat table.ban-list,
+#chat table.ignore-list {
 	margin: 5px 10px;
 	width: calc(100% - 30px);
 }
 
 #chat table.channel-list th,
 #chat table.ban-list th,
+#chat table.ignore-list th,
 #chat table.channel-list td,
-#chat table.ban-list td {
+#chat table.ban-list td,
+#chat.table.ignore-list td {
 	padding: 5px;
 	vertical-align: top;
 	border-bottom: #eee 1px solid;
@@ -1270,7 +1273,9 @@ kbd {
 #chat table.channel-list .topic,
 #chat table.ban-list .hostmask,
 #chat table.ban-list .banned_by,
-#chat table.ban-list .banned_at {
+#chat table.ban-list .banned_at,
+#chat table.ignore-list .hostmask,
+#chat table.ignore-list .when {
 	text-align: left;
 }
 
diff --git a/client/js/constants.js b/client/js/constants.js
index 05ff6b3d..e7e1ae84 100644
--- a/client/js/constants.js
+++ b/client/js/constants.js
@@ -38,6 +38,8 @@ const commands = [
 	"/expand",
 	"/ho",
 	"/hs",
+	"/ignore",
+	"/ignorelist",
 	"/invite",
 	"/join",
 	"/kick",
@@ -64,6 +66,7 @@ const commands = [
 	"/slap",
 	"/topic",
 	"/unban",
+	"/unignore",
 	"/voice",
 	"/whois",
 ];
diff --git a/client/js/contextMenuFactory.js b/client/js/contextMenuFactory.js
index 526e470a..4ee5fa16 100644
--- a/client/js/contextMenuFactory.js
+++ b/client/js/contextMenuFactory.js
@@ -293,6 +293,23 @@ function addJoinItem() {
 	});
 }
 
+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.data("id"),
+		callback: ignorelist,
+	});
+}
+
 function addDefaultItems() {
 	addWhoisItem();
 	addQueryItem();
@@ -306,4 +323,5 @@ function addDefaultItems() {
 	addChannelListItem();
 	addBanListItem();
 	addJoinItem();
+	addIgnoreListItem();
 }
diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js
index d24c068d..f96e139d 100644
--- a/client/js/socket-events/msg.js
+++ b/client/js/socket-events/msg.js
@@ -52,7 +52,7 @@ function processReceivedMessage(data) {
 	const container = channel.find(".messages");
 	const activeChannelId = chat.find(".chan.active").data("id");
 
-	if (data.msg.type === "channel_list" || data.msg.type === "ban_list") {
+	if (data.msg.type === "channel_list" || data.msg.type === "ban_list" || data.msg.type === "ignore_list") {
 		$(container).empty();
 	}
 
diff --git a/client/views/actions/ignore_list.tpl b/client/views/actions/ignore_list.tpl
new file mode 100644
index 00000000..88e0e70b
--- /dev/null
+++ b/client/views/actions/ignore_list.tpl
@@ -0,0 +1,16 @@
+<table class="ignore-list">
+	<thead>
+		<tr>
+			<th class="hostmask">Hostmask</th>
+			<th class="when">Ignored At</th>
+		</tr>
+	</thead>
+	<tbody>
+		{{#each ignored}}
+			<tr>
+				<td class="hostmask">{{hostmask}}</td>
+				<td class="when">{{{localetime when}}}</td>
+			</tr>
+		{{/each}}
+	</tbody>
+</table>
diff --git a/client/views/windows/help.tpl b/client/views/windows/help.tpl
index 06ab6a84..20cdbdcb 100644
--- a/client/views/windows/help.tpl
+++ b/client/views/windows/help.tpl
@@ -360,6 +360,26 @@
 		</div>
 	</div>
 
+	<div class="help-item">
+		<div class="subject">
+			<code>/ignore nick</code>
+		</div>
+		<div class="description">
+			<p>
+				Block any messages from the specified user on the current network.
+				This can be a nickname or a hostmask.</p>
+		</div>
+	</div>
+
+	<div class="help-item">
+		<div class="subject">
+			<code>/ignorelist</code>
+		</div>
+		<div class="description">
+			<p>Load the list of ignored users for the current network.</p>
+		</div>
+	</div>
+
 	<div class="help-item">
 		<div class="subject">
 			<code>/join channel</code>
@@ -537,6 +557,17 @@
 		</div>
 	</div>
 
+	<div class="help-item">
+		<div class="subject">
+			<code>/unignore nick</code>
+		</div>
+		<div class="description">
+			<p>
+				Unblock messages from the specified user on the current network.
+				This can be a nickname or a hostmask.</p>
+		</div>
+	</div>
+
 	<div class="help-item">
 		<div class="subject">
 			<code>/voice nick [...nick]</code>
diff --git a/src/client.js b/src/client.js
index 0aa1355b..e1b5c456 100644
--- a/src/client.js
+++ b/src/client.js
@@ -48,6 +48,7 @@ const inputs = [
 	"away",
 	"connect",
 	"disconnect",
+	"ignore",
 	"invite",
 	"kick",
 	"mode",
diff --git a/src/helper.js b/src/helper.js
index 2fe864aa..a06267a4 100644
--- a/src/helper.js
+++ b/src/helper.js
@@ -33,6 +33,8 @@ const Helper = {
 	ip2hex,
 	mergeConfig,
 	getDefaultNick,
+	parseHostmask,
+	compareHostmask,
 
 	password: {
 		hash: passwordHash,
@@ -226,3 +228,43 @@ function mergeConfig(oldConfig, newConfig) {
 		}
 	});
 }
+
+function parseHostmask(hostmask) {
+	let nick = "";
+	let ident = "*";
+	let hostname = "*";
+	let parts = [];
+
+	// Parse hostname first, then parse the rest
+	parts = hostmask.split("@");
+
+	if (parts.length >= 2) {
+		hostname = parts[1] || "*";
+		hostmask = parts[0];
+	}
+
+	hostname = hostname.toLowerCase();
+
+	parts = hostmask.split("!");
+
+	if (parts.length >= 2) {
+		ident = parts[1] || "*";
+		hostmask = parts[0];
+	}
+
+	ident = ident.toLowerCase();
+
+	nick = hostmask.toLowerCase() || "*";
+
+	const result = {
+		nick: nick,
+		ident: ident,
+		hostname: hostname,
+	};
+
+	return result;
+}
+
+function compareHostmask(a, b) {
+	return (a.nick.toLowerCase() === b.nick.toLowerCase() || a.nick === "*") && (a.ident.toLowerCase() === b.ident.toLowerCase() || a.ident === "*") && (a.hostname.toLowerCase() === b.hostname.toLowerCase() || a.hostname === "*");
+}
diff --git a/src/models/msg.js b/src/models/msg.js
index 2d292add..ed87240b 100644
--- a/src/models/msg.js
+++ b/src/models/msg.js
@@ -67,6 +67,7 @@ Msg.Type = {
 	TOPIC_SET_BY: "topic_set_by",
 	WHOIS: "whois",
 	BANLIST: "ban_list",
+	IGNORELIST: "ignore_list",
 };
 
 module.exports = Msg;
diff --git a/src/models/network.js b/src/models/network.js
index 22574885..0698c113 100644
--- a/src/models/network.js
+++ b/src/models/network.js
@@ -18,6 +18,7 @@ const filteredFromClient = {
 	highlightRegex: true,
 	irc: true,
 	password: true,
+	ignoreList: true,
 };
 
 function Network(attr) {
@@ -41,6 +42,7 @@ function Network(attr) {
 			NETWORK: "",
 		},
 		chanCache: [],
+		ignoreList: [],
 	});
 
 	if (!this.uuid) {
@@ -325,6 +327,7 @@ Network.prototype.export = function() {
 		"commands",
 		"ip",
 		"hostname",
+		"ignoreList",
 	]);
 
 	network.channels = this.channels
diff --git a/src/plugins/inputs/ignore.js b/src/plugins/inputs/ignore.js
new file mode 100644
index 00000000..4b999570
--- /dev/null
+++ b/src/plugins/inputs/ignore.js
@@ -0,0 +1,120 @@
+"use strict";
+
+const Chan = require("../../models/chan");
+const Msg = require("../../models/msg");
+const Helper = require("../../helper");
+
+exports.commands = [
+	"ignore",
+	"unignore",
+	"ignorelist",
+];
+
+exports.input = function(network, chan, cmd, args) {
+	const client = this;
+	let target;
+	let hostmask;
+
+	if (cmd !== "ignorelist" && (args.length === 0 || args[0].trim().length === 0)) {
+		chan.pushMessage(client, new Msg({
+			type: Msg.Type.ERROR,
+			text: `Usage: /${cmd} <nick>[!ident][@host]`,
+		}));
+
+		return;
+	}
+
+	if (cmd !== "ignorelist") {
+		// Trim to remove any spaces from the hostmask
+		target = args[0].trim();
+		hostmask = Helper.parseHostmask(target);
+	}
+
+	switch (cmd) {
+	case "ignore": {
+		// IRC nicks are case insensitive
+		if (hostmask.nick.toLowerCase() === network.nick.toLowerCase()) {
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: "You can't ignore yourself",
+			}));
+		} else if (!network.ignoreList.some(function(entry) {
+			return Helper.compareHostmask(entry, hostmask);
+		})) {
+			hostmask.when = Date.now();
+			network.ignoreList.push(hostmask);
+
+			client.save();
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: `\u0002${hostmask.nick}!${hostmask.ident}@${hostmask.hostname}\u000f added to ignorelist`,
+			}));
+		} else {
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: "The specified user/hostmask is already ignored",
+			}));
+		}
+
+		break;
+	}
+
+	case "unignore": {
+		const idx = network.ignoreList.findIndex(function(entry) {
+			return Helper.compareHostmask(entry, hostmask);
+		});
+
+		// Check if the entry exists before removing it, otherwise
+		// let the user know.
+		if (idx !== -1) {
+			network.ignoreList.splice(idx, 1);
+			client.save();
+
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: `Successfully removed \u0002${hostmask.nick}!${hostmask.ident}@${hostmask.hostname}\u000f from ignorelist`,
+			}));
+		} else {
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: "The specified user/hostmask is not ignored",
+			}));
+		}
+
+		break;
+	}
+
+	case "ignorelist":
+		if (network.ignoreList.length === 0) {
+			chan.pushMessage(client, new Msg({
+				type: Msg.Type.ERROR,
+				text: "Ignorelist is empty",
+			}));
+		} else {
+			const chanName = "Ignored users";
+			let newChan = network.getChannel(chanName);
+
+			if (typeof newChan === "undefined") {
+				newChan = client.createChannel({
+					type: Chan.Type.SPECIAL,
+					name: chanName,
+				});
+				client.emit("join", {
+					network: network.uuid,
+					chan: newChan.getFilteredClone(true),
+					index: network.addChannel(newChan),
+				});
+			}
+
+			newChan.pushMessage(client, new Msg({
+				type: Msg.Type.IGNORELIST,
+				ignored: network.ignoreList.map((data) => ({
+					hostmask: `${data.nick}!${data.ident}@${data.hostname}`,
+					when: data.when,
+				})),
+			}), true);
+		}
+
+		break;
+	}
+};
diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js
index a6e523ff..c6714258 100644
--- a/src/plugins/irc-events/message.js
+++ b/src/plugins/irc-events/message.js
@@ -4,6 +4,7 @@ const Chan = require("../../models/chan");
 const Msg = require("../../models/msg");
 const LinkPrefetch = require("./link");
 const cleanIrcMessage = require("../../../client/js/libs/handlebars/ircmessageparser/cleanIrcMessage");
+const Helper = require("../../helper");
 const nickRegExp = /(?:\x03[0-9]{1,2}(?:,[0-9]{1,2})?)?([\w[\]\\`^{|}-]+)/g;
 
 module.exports = function(irc, network) {
@@ -43,11 +44,20 @@ module.exports = function(irc, network) {
 		let showInActive = false;
 		const self = data.nick === irc.user.nick;
 
+		// Check if the sender is in our ignore list
+		const shouldIgnore = network.ignoreList.some(function(entry) {
+			return Helper.compareHostmask(entry, data);
+		});
+
 		// Server messages go to server window, no questions asked
 		if (data.from_server) {
 			chan = network.channels[0];
 			from = chan.getUser(data.nick);
 		} else {
+			if (shouldIgnore) {
+				return;
+			}
+
 			let target = data.target;
 
 			// If the message is targeted at us, use sender as target instead


Test patch:
diff --git a/test/models/network.js b/test/models/network.js
index 7fe7f374..b0e3da8d 100644
--- a/test/models/network.js
+++ b/test/models/network.js
@@ -47,6 +47,7 @@ describe("Network", function() {
 					{name: "&secure", key: "bar"},
 					{name: "PrivateChat", type: "query"},
 				],
+				ignoreList: [],
 			});
 		});
 
diff --git a/test/tests/hostmask.js b/test/tests/hostmask.js
new file mode 100644
index 00000000..2c215643
--- /dev/null
+++ b/test/tests/hostmask.js
@@ -0,0 +1,62 @@
+"use strict";
+
+const expect = require("chai").expect;
+const Helper = require("../../src/helper");
+
+describe("Hostmask", function() {
+	it(".parseHostmask", function() {
+		expect(Helper.parseHostmask("nick").nick).to.equal("nick");
+		expect(Helper.parseHostmask("nick").ident).to.equal("*");
+		expect(Helper.parseHostmask("nick").hostname).to.equal("*");
+
+		expect(Helper.parseHostmask("!user").nick).to.equal("*");
+		expect(Helper.parseHostmask("!user").ident).to.equal("user");
+		expect(Helper.parseHostmask("!user").hostname).to.equal("*");
+
+		expect(Helper.parseHostmask("@host").nick).to.equal("*");
+		expect(Helper.parseHostmask("@host").ident).to.equal("*");
+		expect(Helper.parseHostmask("@host").hostname).to.equal("host");
+
+		expect(Helper.parseHostmask("!").nick).to.equal("*");
+		expect(Helper.parseHostmask("!").ident).to.equal("*");
+		expect(Helper.parseHostmask("!").hostname).to.equal("*");
+
+		expect(Helper.parseHostmask("@").nick).to.equal("*");
+		expect(Helper.parseHostmask("@").ident).to.equal("*");
+		expect(Helper.parseHostmask("@").hostname).to.equal("*");
+
+		expect(Helper.parseHostmask("!@").nick).to.equal("*");
+		expect(Helper.parseHostmask("!@").ident).to.equal("*");
+		expect(Helper.parseHostmask("!@").hostname).to.equal("*");
+
+		expect(Helper.parseHostmask("nick!user@host").nick).to.equal("nick");
+		expect(Helper.parseHostmask("nick!user@host").ident).to.equal("user");
+		expect(Helper.parseHostmask("nick!user@host").hostname).to.equal("host");
+
+		expect(Helper.parseHostmask("nick!!!!@thing@@host").nick).to.equal("nick");
+		expect(Helper.parseHostmask("nick!!!!@thing@@host").ident).to.equal("*");
+		expect(Helper.parseHostmask("nick!!!!@thing@@host").hostname).to.equal("thing");
+
+		expect(Helper.parseHostmask("!!!!@thing@@host").nick).to.equal("*");
+		expect(Helper.parseHostmask("!!!!@thing@@host").ident).to.equal("*");
+		expect(Helper.parseHostmask("!!!!@thing@@host").hostname).to.equal("thing");
+
+		expect(Helper.parseHostmask("NiCK!uSEr@HOST").nick).to.equal("nick");
+		expect(Helper.parseHostmask("NiCK!uSEr@HOST").ident).to.equal("user");
+		expect(Helper.parseHostmask("NiCK!uSEr@HOST").hostname).to.equal("host");
+	});
+
+	it(".compareHostmask (wildcard)", function() {
+		const a = Helper.parseHostmask("nick!user@host");
+		const b = Helper.parseHostmask("nick!*@*");
+		expect(Helper.compareHostmask(b, a)).to.be.true;
+		expect(Helper.compareHostmask(a, b)).to.be.false;
+	});
+
+	it(".compareHostmask", function() {
+		const a = Helper.parseHostmask("nick!user@host");
+		const b = Helper.parseHostmask("NiCK!useR@HOST");
+		expect(Helper.compareHostmask(b, a)).to.be.true;
+		expect(Helper.compareHostmask(a, b)).to.be.true;
+	});
+});