Reduce Notification Spam; Unsolicited DMs Option; New SFX

* New "Misc" tab added to the Settings modal with options to reduce spam
  and improve privacy.
    * Opt in (or out) for public channel join/leave presence
      notifications
    * New option to auto-ignore unsolicited DMs
* New sound effects for Watched and Unwatched (your camera)
    * Reduces spam so ChatServer doesn't need to tell you every time
      somebody opens your camera.
* New spinner icon when opening someone else's camera.
    * If their cam takes a while to appear, the video button shows a
      spinner icon as feedback so we avoid ChatClient spam giving you
      acknowledgement of the cam trying to open.
This commit is contained in:
Noah 2023-08-28 22:46:40 -07:00
parent c4f90fadd2
commit 59fc04b99e
5 changed files with 186 additions and 27 deletions

View File

@ -123,6 +123,13 @@ const app = Vue.createApp({
whoMap: {}, // map username to wholist entry whoMap: {}, // map username to wholist entry
muted: {}, // muted usernames for client side state muted: {}, // muted usernames for client side state
// Misc. user preferences (TODO: move all of them here)
prefs: {
joinMessages: true, // show "has entered the room" in public channels
exitMessages: false, // hide exit messages by default in public channels
closeDMs: false, // ignore unsolicited DMs
},
// My video feed. // My video feed.
webcam: { webcam: {
busy: false, busy: false,
@ -192,6 +199,11 @@ const app = Vue.createApp({
// Debounce connection attempts since now every click = try to connect. // Debounce connection attempts since now every click = try to connect.
debounceOpens: {}, // map usernames to bools debounceOpens: {}, // map usernames to bools
// Timeouts for open camera attempts. e.g.: when you click to view
// a camera, the icon changes to a spinner for a few seconds to see
// whether the video goes on to open.
openTimeouts: {}, // map usernames to timeouts
}, },
// Chat history. // Chat history.
@ -367,6 +379,17 @@ const app = Vue.createApp({
this.sendMe(); this.sendMe();
} }
}, },
// Misc preference watches
"prefs.joinMessages": function() {
localStorage.joinMessages = this.prefs.joinMessages;
},
"prefs.exitMessages": function() {
localStorage.exitMessages = this.prefs.exitMessages;
},
"prefs.closeDMs": function() {
localStorage.closeDMs = this.prefs.closeDMs;
},
}, },
computed: { computed: {
chatHistory() { chatHistory() {
@ -557,6 +580,17 @@ const app = Vue.createApp({
if (localStorage.videoAutoMute === "true") { if (localStorage.videoAutoMute === "true") {
this.webcam.autoMute = true; this.webcam.autoMute = true;
} }
// Misc preferences
if (localStorage.joinMessages != undefined) {
this.prefs.joinMessages = localStorage.joinMessages === "true";
}
if (localStorage.exitMessages != undefined) {
this.prefs.exitMessages = localStorage.exitMessages === "true";
}
if (localStorage.closeDMs != undefined) {
this.prefs.closeDMs = localStorage.closeDMs === "true";
}
}, },
signIn() { signIn() {
@ -815,14 +849,7 @@ const app = Vue.createApp({
this.startWebRTC(msg.username, true); this.startWebRTC(msg.username, true);
}, },
onRing(msg) { onRing(msg) {
// Admin moderation feature: if the user has booted an admin off their camera, do not // Request from a viewer to see our broadcast.
// notify if the admin re-opens their camera.
if (this.isBootedAdmin(msg.username)) {
this.startWebRTC(msg.username, false);
return;
}
this.ChatServer(`${msg.username} has opened your camera.`);
this.startWebRTC(msg.username, false); this.startWebRTC(msg.username, false);
}, },
onUserExited(msg) { onUserExited(msg) {
@ -835,7 +862,12 @@ const app = Vue.createApp({
// Play sound effects if this is not the active channel or the window is not focused. // Play sound effects if this is not the active channel or the window is not focused.
if (msg.channel.indexOf("@") === 0) { if (msg.channel.indexOf("@") === 0) {
if (msg.channel !== this.channel || !this.windowFocused) { if (msg.channel !== this.channel || !this.windowFocused) {
this.playSound("DM"); // If we are ignoring unsolicited DMs, don't play the sound effect here.
if (this.prefs.closeDMs && this.channels[msg.channel] == undefined) {
console.log("Unsolicited DM received");
} else {
this.playSound("DM");
}
} }
} else if (msg.channel !== this.channel || !this.windowFocused) { } else if (msg.channel !== this.channel || !this.windowFocused) {
this.playSound("Chat"); this.playSound("Chat");
@ -868,18 +900,20 @@ const app = Vue.createApp({
// User logged in or out. // User logged in or out.
onPresence(msg) { onPresence(msg) {
// TODO: make a dedicated leave event // TODO: make a dedicated leave event
let isLeave = false; let isLeave = false,
isJoin = false;
if (msg.message.indexOf("has exited the room!") > -1) { if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user. // Clean up data about this user.
this.onUserExited(msg); this.onUserExited(msg);
this.playSound("Leave"); this.playSound("Leave");
isLeave = true; isLeave = true;
} else { } else if (msg.message.indexOf("has joined the room!") > -1) {
this.playSound("Enter"); this.playSound("Enter");
isJoin = true;
} }
// Push it to the history of all public channels (not leaves). // Push it to the history of all public channels (depending on user preference).
if (!isLeave) { if ((isJoin && this.prefs.joinMessages) || (isLeave && this.prefs.exitMessages)) {
for (let channel of this.config.channels) { for (let channel of this.config.channels) {
this.pushHistory({ this.pushHistory({
channel: channel.ID, channel: channel.ID,
@ -1078,6 +1112,13 @@ const app = Vue.createApp({
pc.ontrack = event => { pc.ontrack = event => {
const stream = event.streams[0]; const stream = event.streams[0];
// We've received a video! If we had an "open camera spinner timeout",
// clear it before it expires.
if (this.WebRTC.openTimeouts[username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[username]);
delete(this.WebRTC.openTimeouts[username]);
}
// Do we already have it? // Do we already have it?
// this.ChatClient(`Received a video stream from ${username}.`); // this.ChatClient(`Received a video stream from ${username}.`);
if (this.WebRTC.streams[username] == undefined || if (this.WebRTC.streams[username] == undefined ||
@ -1212,10 +1253,12 @@ const app = Vue.createApp({
// The user has our video feed open now. // The user has our video feed open now.
if (this.isBootedAdmin(msg.username)) return; if (this.isBootedAdmin(msg.username)) return;
this.webcam.watching[msg.username] = true; this.webcam.watching[msg.username] = true;
this.playSound("Watch");
}, },
onUnwatch(msg) { onUnwatch(msg) {
// The user has closed our video feed. // The user has closed our video feed.
delete(this.webcam.watching[msg.username]); delete(this.webcam.watching[msg.username]);
this.playSound("Unwatch");
}, },
sendWatch(username, watching) { sendWatch(username, watching) {
// Send the watch or unwatch message to backend. // Send the watch or unwatch message to backend.
@ -1681,15 +1724,26 @@ const app = Vue.createApp({
return; return;
} }
// Set a timeout: the video icon becomes a spinner and we wait a while
// to see if the connection went thru. This gives the user feedback and we
// can avoid a spammy 'ChatClient' notification message.
if (this.WebRTC.openTimeouts[user.username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[user.username]);
delete(this.WebRTC.openTimeouts[user.username]);
}
this.WebRTC.openTimeouts[user.username] = setTimeout(() => {
// It timed out.
this.ChatClient(
`There was an error opening <strong>${user.username}</strong>'s camera.`,
);
delete(this.WebRTC.openTimeouts[user.username]);
}, 10000);
// Send the ChatServer 'open' command.
this.sendOpen(user.username); this.sendOpen(user.username);
// Responsive CSS -> go to chat panel to see the camera // Responsive CSS -> go to chat panel to see the camera
this.openChatPanel(); this.openChatPanel();
// Send some feedback to the chat window.
this.ChatClient(
`A request was sent to open <strong>${user.username}</strong>'s camera which should (hopefully) appear on your screen soon.`,
);
}, },
closeVideo(username, name) { closeVideo(username, name) {
// Clean up any lingering camera freeze states. // Clean up any lingering camera freeze states.
@ -1762,10 +1816,16 @@ const app = Vue.createApp({
// - Usually a video icon // - Usually a video icon
// - May be a crossed-out video if isVideoNotAllowed // - May be a crossed-out video if isVideoNotAllowed
// - Or an eyeball for cameras already opened // - Or an eyeball for cameras already opened
// - Or a spinner if we are actively trying to open the video
if (user.username === this.username && this.webcam.active) { if (user.username === this.username && this.webcam.active) {
return 'fa-eye'; // user sees their own self camera always return 'fa-eye'; // user sees their own self camera always
} }
// In spinner mode? (Trying to open the video)
if (this.WebRTC.openTimeouts[user.username] != undefined) {
return 'fa-spinner fa-spin';
}
// Already opened? // Already opened?
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) { if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) {
return 'fa-eye'; return 'fa-eye';
@ -2016,6 +2076,12 @@ const app = Vue.createApp({
channel = this.channel; channel = this.channel;
} }
// Are we ignoring DMs?
if (this.prefs.closeDMs && channel.indexOf('@') === 0) {
// Don't allow an (incoming) DM to initialize a new chat room for us.
if (username !== this.username && this.channels[channel] == undefined) return;
}
// Initialize this channel's history? // Initialize this channel's history?
this.initHistory(channel); this.initHistory(channel);

View File

@ -23,7 +23,15 @@ const SoundEffects = [
{ {
name: "Sonar", name: "Sonar",
filename: "sonar-ping-95840.mp3" filename: "sonar-ping-95840.mp3"
} },
{
name: "Up Chime",
filename: "notification-6175-up.mp3"
},
{
name: "Down Chime",
filename: "notification-6175-down.mp3"
},
]; ];
// Defaults // Defaults
@ -32,4 +40,6 @@ var DefaultSounds = {
DM: "Trill", DM: "Trill",
Enter: "Quiet", Enter: "Quiet",
Leave: "Quiet", Leave: "Quiet",
Watch: "Up Chime",
Unwatch: "Quiet",
}; };

Binary file not shown.

Binary file not shown.

View File

@ -90,6 +90,11 @@
Camera Camera
</a> </a>
</li> </li>
<li :class="{'is-active': settingsModal.tab==='misc'}">
<a href="#" @click.prevent="settingsModal.tab='misc'">
Misc
</a>
</li>
</ul> </ul>
</div> </div>
@ -181,7 +186,7 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-2 pr-1"> <div class="column is-2 pr-1">
<label class="label">DM chat</label> <label class="label is-size-7">DM chat</label>
</div> </div>
<div class="column"> <div class="column">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -196,7 +201,7 @@
</div> </div>
<div class="column is-2 pr-1"> <div class="column is-2 pr-1">
<label class="label">Public chat</label> <label class="label is-size-7">Public chat</label>
</div> </div>
<div class="column"> <div class="column">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -213,7 +218,7 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-2 pr-1"> <div class="column is-2 pr-1">
<label class="label">Room enter</label> <label class="label is-size-7">Room enter</label>
</div> </div>
<div class="column"> <div class="column">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -228,7 +233,7 @@
</div> </div>
<div class="column is-2 pr-1"> <div class="column is-2 pr-1">
<label class="label">Room leave</label> <label class="label is-size-7">Room leave</label>
</div> </div>
<div class="column"> <div class="column">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -242,6 +247,38 @@
</div> </div>
</div> </div>
</div> </div>
<div class="columns is-mobile">
<div class="column is-2 pr-1">
<label class="label is-size-7">Watched</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Watch" @change="setSoundPref('Watch')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
<div class="column is-2 pr-1">
<label class="label is-size-7">Unwatched</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Unwatch" @change="setSoundPref('Unwatch')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
</div>
</div> </div>
<!-- Webcam preferences --> <!-- Webcam preferences -->
@ -332,6 +369,52 @@
</div> </div>
</div> </div>
<!-- Misc preferences -->
<div v-if="settingsModal.tab==='misc'">
<div class="field">
<label class="label">Presence messages in public channels</label>
<div class="columns is-mobile mb-0">
<div class="column py-1">
<label class="checkbox" title="Show 'has joined the room' messages in public channels">
<input type="checkbox"
v-model="prefs.joinMessages"
:value="true">
Join room
</label>
</div>
<div class="column py-1">
<label class="checkbox" title="Show 'has exited the room' messages in public channels">
<input type="checkbox"
v-model="prefs.exitMessages"
:value="true">
Exit room
</label>
</div>
</div>
<p class="help mt-0">
Whether to show <em>'has joined the room'</em> style messages in public channels.
</p>
</div>
<div class="field">
<label class="label mb-0">Direct Messages</label>
<label class="checkbox">
<input type="checkbox"
v-model="prefs.closeDMs"
:value="true">
Ignore unsolicited DMs from others
</label>
<p class="help">
If you check this box, other chatters may not initiate DMs with you: their messages
will be (silently) ignored. You may still initiate DM chats with others, unless they
also have closed their DMs with this setting.
</p>
</div>
</div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<div class="card-footer-item"> <div class="card-footer-item">
@ -538,7 +621,7 @@
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="classification" <select id="classification"
v-model="reportModal.classification" v-model="reportModal.classification"
:disabled="busy"> :disabled="reportModal.busy">
<option v-for="i in config.reportClassifications" <option v-for="i in config.reportClassifications"
:value="i">[[i]]</option> :value="i">[[i]]</option>
</select> </select>
@ -549,7 +632,7 @@
<label class="label" for="reportComment">Comment:</label> <label class="label" for="reportComment">Comment:</label>
<textarea class="textarea" <textarea class="textarea"
v-model="reportModal.comment" v-model="reportModal.comment"
:disabled="busy" :disabled="reportModal.busy"
cols="80" rows="2" cols="80" rows="2"
placeholder="Optional: describe the issue"></textarea> placeholder="Optional: describe the issue"></textarea>
</div> </div>
@ -558,7 +641,7 @@
<div class="control has-text-centered"> <div class="control has-text-centered">
<button type="button" <button type="button"
class="button is-link mr-4" class="button is-link mr-4"
:disabled="busy" :disabled="reportModal.busy"
@click="doReport()">Submit report</button> @click="doReport()">Submit report</button>
<button type="button" <button type="button"
class="button" class="button"