Add the option to mute channels, queries, and networks (#4282)

Co-authored-by: Reto <reto@labrat.space>
This commit is contained in:
Max Leiter 2022-02-10 17:56:17 -08:00 committed by GitHub
parent 337bfa489b
commit 4be9a282fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 205 additions and 18 deletions

View File

@ -1,9 +1,12 @@
<template> <template>
<ChannelWrapper ref="wrapper" v-bind="$props"> <ChannelWrapper ref="wrapper" v-bind="$props">
<span class="name">{{ channel.name }}</span> <span class="name">{{ channel.name }}</span>
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{ <span
unreadCount v-if="channel.unread"
}}</span> :class="{highlight: channel.highlight && !channel.muted}"
class="badge"
>{{ unreadCount }}</span
>
<template v-if="channel.type === 'channel'"> <template v-if="channel.type === 'channel'">
<span <span
v-if="channel.state === 0" v-if="channel.state === 0"

View File

@ -15,6 +15,7 @@
channel.type === 'lobby' && network.status.connected && !network.status.secure, channel.type === 'lobby' && network.status.connected && !network.status.secure,
}, },
{'not-connected': channel.type === 'lobby' && !network.status.connected}, {'not-connected': channel.type === 'lobby' && !network.status.connected},
{'is-muted': channel.muted},
]" ]"
:aria-label="getAriaLabel()" :aria-label="getAriaLabel()"
:title="getAriaLabel()" :title="getAriaLabel()"

View File

@ -176,7 +176,7 @@ export default {
message.channel = this.$store.getters.findChannel(message.chanId); message.channel = this.$store.getters.findChannel(message.chanId);
} }
return messages; return messages.filter((message) => !message.channel.channel.muted);
}, },
}, },
watch: { watch: {

View File

@ -645,6 +645,20 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/mute [...channel]</code>
</div>
<div class="description">
<p>
Prevent messages from generating any feedback for a channel. This turns off
the highlight indicator, hides mentions and inhibits push notifications.
Muting a network lobby mutes the entire network. Not specifying any channel
target mutes the current channel. Revert with <code>/unmute</code>.
</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/nick newnick</code> <code>/nick newnick</code>
@ -780,6 +794,18 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<code>/unmute [...channel]</code>
</div>
<div class="description">
<p>
Un-mutes the given channel(s) or the current channel if no channel is
provided. See <code>/mute</code> for more information.
</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<code>/voice nick [...nick]</code> <code>/voice nick [...nick]</code>

View File

@ -369,6 +369,7 @@ p {
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ } .context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ } .context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ } .context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
.context-menu-mute::before { content: "\f6a9"; /* https://fontawesome.com/v5.15/icons/volume-mute?style=solid */ }
.channel-list-item .not-secure-icon::before { .channel-list-item .not-secure-icon::before {
content: "\f071"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */ content: "\f071"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */
@ -817,6 +818,10 @@ background on hover (unless active) */
color: #f1978e; color: #f1978e;
} }
.channel-list-item.is-muted {
opacity: 0.5;
}
.channel-list-item::before { .channel-list-item::before {
width: 14px; width: 14px;
margin-right: 12px; margin-right: 12px;

View File

@ -170,6 +170,31 @@ export function generateChannelContextMenu($root, channel, network) {
}); });
} }
const humanFriendlyChanTypeMap = {
lobby: "network",
channel: "channel",
query: "conversation",
};
// We don't allow the muting of Chan.Type.SPECIAL channels
const mutableChanTypes = Object.keys(humanFriendlyChanTypeMap);
if (mutableChanTypes.includes(channel.type)) {
const chanType = humanFriendlyChanTypeMap[channel.type];
items.push({
label: channel.muted ? `Unmute ${chanType}` : `Mute ${chanType}`,
type: "item",
class: "mute",
action() {
socket.emit("mute:change", {
target: channel.id,
setMutedTo: !channel.muted,
});
},
});
}
// Add close menu item // Add close menu item
items.push({ items.push({
label: closeMap[channel.type], label: closeMap[channel.type],

View File

@ -26,3 +26,4 @@ import "./setting";
import "./history_clear"; import "./history_clear";
import "./mentions"; import "./mentions";
import "./search"; import "./search";
import "./mute_changed";

View File

@ -95,6 +95,10 @@ socket.on("msg", function (data) {
}); });
function notifyMessage(targetId, channel, activeChannel, msg) { function notifyMessage(targetId, channel, activeChannel, msg) {
if (channel.muted) {
return;
}
if (msg.highlight || (store.state.settings.notifyAllMessages && msg.type === "message")) { if (msg.highlight || (store.state.settings.notifyAllMessages && msg.type === "message")) {
if (!document.hasFocus() || !activeChannel || activeChannel.channel !== channel) { if (!document.hasFocus() || !activeChannel || activeChannel.channel !== channel) {
if (store.state.settings.notification) { if (store.state.settings.notification) {

View File

@ -0,0 +1,17 @@
import socket from "../socket";
import store from "../store";
socket.on("mute:changed", (response) => {
const {target, status} = response;
const {channel, network} = store.getters.findChannel(target);
if (channel.type === "lobby") {
for (const chan of network.channels) {
if (chan.type !== "special") {
chan.muted = status;
}
}
} else {
channel.muted = status;
}
});

View File

@ -181,6 +181,10 @@ const store = new Vuex.Store({
for (const network of state.networks) { for (const network of state.networks) {
for (const channel of network.channels) { for (const channel of network.channels) {
if (channel.muted) {
continue;
}
highlightCount += channel.highlight; highlightCount += channel.highlight;
} }
} }

View File

@ -202,6 +202,7 @@ Client.prototype.connect = function (args, isStartup = false) {
name: chan.name, name: chan.name,
key: chan.key || "", key: chan.key || "",
type: chan.type, type: chan.type,
muted: chan.muted,
}) })
); );
}); });

View File

@ -41,6 +41,7 @@ function Chan(attr) {
unread: 0, unread: 0,
highlight: 0, highlight: 0,
users: new Map(), users: new Map(),
muted: false,
}); });
} }
@ -276,6 +277,10 @@ Chan.prototype.isLoggable = function () {
return this.type === Chan.Type.CHANNEL || this.type === Chan.Type.QUERY; return this.type === Chan.Type.CHANNEL || this.type === Chan.Type.QUERY;
}; };
Chan.prototype.setMuteStatus = function (muted) {
this.muted = !!muted;
};
function requestZncPlayback(channel, network, from) { function requestZncPlayback(channel, network, from) {
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString()); network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
} }

View File

@ -76,6 +76,11 @@ function Network(attr) {
new Chan({ new Chan({
name: this.name, name: this.name,
type: Chan.Type.LOBBY, type: Chan.Type.LOBBY,
// The lobby only starts as muted if every channel (unless it's special) is muted.
// This is A) easier to implement and B) stops some confusion on startup.
muted:
this.channels.length >= 1 &&
this.channels.every((chan) => chan.muted || chan.type === Chan.Type.SPECIAL),
}) })
); );
} }
@ -546,7 +551,7 @@ Network.prototype.export = function () {
return channel.type === Chan.Type.CHANNEL || channel.type === Chan.Type.QUERY; return channel.type === Chan.Type.CHANNEL || channel.type === Chan.Type.QUERY;
}) })
.map(function (chan) { .map(function (chan) {
const keys = ["name"]; const keys = ["name", "muted"];
if (chan.type === Chan.Type.CHANNEL) { if (chan.type === Chan.Type.CHANNEL) {
keys.push("key"); keys.push("key");

View File

@ -35,6 +35,7 @@ const userInputs = [
"rejoin", "rejoin",
"topic", "topic",
"whois", "whois",
"mute",
].reduce(function (plugins, name) { ].reduce(function (plugins, name) {
const plugin = require(`./${name}`); const plugin = require(`./${name}`);
plugin.commands.forEach((command) => plugins.set(command, plugin)); plugin.commands.forEach((command) => plugins.set(command, plugin));

View File

@ -0,0 +1,61 @@
"use strict";
const Msg = require("../../models/msg");
exports.commands = ["mute", "unmute"];
function args_to_channels(network, args) {
const targets = [];
for (const arg of args) {
const target = network.channels.find((c) => c.name === arg);
if (target) {
targets.push(target);
}
}
return targets;
}
function change_mute_state(client, target, valueToSet) {
if (target.type === "special") {
return;
}
target.setMuteStatus(valueToSet);
client.emit("mute:changed", {
target: target.id,
status: valueToSet,
});
}
exports.input = function (network, chan, cmd, args) {
const valueToSet = cmd === "mute" ? true : false;
const client = this;
if (args.length === 0) {
change_mute_state(client, chan, valueToSet);
return;
}
const targets = args_to_channels(network, args);
if (targets.length !== args.length) {
const targetNames = targets.map((ch) => ch.name);
const missing = args.filter((x) => !targetNames.includes(x));
chan.pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
text: `No open ${
missing.length === 1 ? "channel or user" : "channels or users"
} found for ${missing.join(",")}`,
})
);
return;
}
for (const target of targets) {
change_mute_state(client, target, valueToSet);
}
};

View File

@ -97,7 +97,7 @@ module.exports = function (irc, network) {
from = chan.getUser(data.nick); from = chan.getUser(data.nick);
// Query messages (unless self) always highlight // Query messages (unless self or muted) always highlight
if (chan.type === Chan.Type.QUERY) { if (chan.type === Chan.Type.QUERY) {
highlight = !self; highlight = !self;
} else if (chan.type === Chan.Type.CHANNEL) { } else if (chan.type === Chan.Type.CHANNEL) {
@ -158,8 +158,8 @@ module.exports = function (irc, network) {
chan.pushMessage(client, msg, !msg.self); chan.pushMessage(client, msg, !msg.self);
// Do not send notifications for messages older than 15 minutes (znc buffer for example) // Do not send notifications if the channel is muted or for messages older than 15 minutes (znc buffer for example)
if (msg.highlight && (!data.time || data.time > Date.now() - 900000)) { if (!chan.muted && msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
let title = chan.name; let title = chan.name;
let body = cleanMessage; let body = cleanMessage;

View File

@ -23,6 +23,7 @@ const themes = require("./plugins/packages/themes");
themes.loadLocalThemes(); themes.loadLocalThemes();
const packages = require("./plugins/packages/index"); const packages = require("./plugins/packages/index");
const Chan = require("./models/chan");
// A random number that will force clients to reload the page if it differs // A random number that will force clients to reload the page if it differs
const serverHash = Math.floor(Date.now() * Math.random()); const serverHash = Math.floor(Date.now() * Math.random());
@ -655,6 +656,32 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.emit("search:results", results); socket.emit("search:results", results);
}); });
}); });
socket.on("mute:change", ({target, setMutedTo}) => {
const {chan, network} = client.find(target);
// If the user mutes the lobby, we mute the entire network.
if (chan.type === Chan.Type.LOBBY) {
for (const channel of network.channels) {
if (channel.type !== Chan.Type.SPECIAL) {
channel.setMuteStatus(setMutedTo);
}
}
} else {
if (chan.type !== Chan.Type.SPECIAL) {
chan.setMuteStatus(setMutedTo);
}
}
for (const attachedClient of Object.keys(client.attachedClients)) {
manager.sockets.in(attachedClient).emit("mute:changed", {
target,
status: setMutedTo,
});
}
client.save();
});
} }
socket.on("sign-out", (tokenToSignOut) => { socket.on("sign-out", (tokenToSignOut) => {

View File

@ -213,6 +213,7 @@ describe("Chan", function () {
"id", "id",
"key", "key",
"messages", "messages",
"muted",
"totalMessages", "totalMessages",
"name", "name",
"state", "state",

View File

@ -18,12 +18,12 @@ describe("Network", function () {
saslAccount: "testaccount", saslAccount: "testaccount",
saslPassword: "testpassword", saslPassword: "testpassword",
channels: [ channels: [
new Chan({name: "#thelounge", key: ""}), new Chan({name: "#thelounge", key: "", muted: false}),
new Chan({name: "&foobar", key: ""}), new Chan({name: "&foobar", key: "", muted: false}),
new Chan({name: "#secret", key: "foo"}), new Chan({name: "#secret", key: "foo", muted: false}),
new Chan({name: "&secure", key: "bar"}), new Chan({name: "&secure", key: "bar", muted: true}),
new Chan({name: "Channel List", type: Chan.Type.SPECIAL}), new Chan({name: "Channel List", type: Chan.Type.SPECIAL}),
new Chan({name: "PrivateChat", type: Chan.Type.QUERY}), new Chan({name: "PrivateChat", type: Chan.Type.QUERY, muted: true}),
], ],
}); });
network.setNick("chillin`"); network.setNick("chillin`");
@ -52,11 +52,11 @@ describe("Network", function () {
proxyPassword: "", proxyPassword: "",
proxyUsername: "", proxyUsername: "",
channels: [ channels: [
{name: "#thelounge", key: ""}, {name: "#thelounge", key: "", muted: false},
{name: "&foobar", key: ""}, {name: "&foobar", key: "", muted: false},
{name: "#secret", key: "foo"}, {name: "#secret", key: "foo", muted: false},
{name: "&secure", key: "bar"}, {name: "&secure", key: "bar", muted: true},
{name: "PrivateChat", type: "query"}, {name: "PrivateChat", type: "query", muted: true},
], ],
ignoreList: [], ignoreList: [],
}); });