Boot and Mute

* Users can now boot viewers off their camera. From the viewer's POV the
  booter has just turned off their camera and it will remain "off" for
  the remainder of the booter's session.
* Users can now mute one another: if you mute a user, you will no longer
  see that user's messages or DMs; and the muted user will never see
  your video as being active (like a boot but revokable if you unmute
  later).
This commit is contained in:
Noah 2023-03-22 20:21:04 -07:00
parent 08b8435448
commit c5c8d08c7a
5 changed files with 262 additions and 21 deletions

View File

@ -133,6 +133,20 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
// Echo the message only to both parties.
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
// Don't deliver it if the receiver has muted us.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err == nil && rcpt.Mutes(sub.Username) {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
}
// If the sender already mutes the recipient, reply back with the error.
if sub.Mutes(rcpt.Username) {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -191,6 +205,20 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) {
// Echo the message only to both parties.
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
// Don't deliver it if the receiver has muted us.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err == nil && rcpt.Mutes(sub.Username) {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
}
// If the sender already mutes the recipient, reply back with the error.
if sub.Mutes(rcpt.Username) {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -242,6 +270,35 @@ func (s *Server) OnOpen(sub *Subscriber, msg Message) {
})
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg Message) {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.muteMu.Lock()
sub.booted[msg.Username] = struct{}{}
sub.muteMu.Unlock()
s.SendWhoList()
}
// OnMute is a user kicking setting the mute flag for another user.
func (s *Server) OnMute(sub *Subscriber, msg Message, mute bool) {
log.Info("%s mutes or unmutes %s: %v", sub.Username, msg.Username, mute)
sub.muteMu.Lock()
if mute {
sub.muted[msg.Username] = struct{}{}
} else {
delete(sub.muted, msg.Username)
}
sub.muteMu.Unlock()
// Send the Who List in case our cam will show as disabled to the muted party.
s.SendWhoList()
}
// OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
// Look up the other subscriber.

View File

@ -38,6 +38,9 @@ type Message struct {
const (
// Actions sent by the client side only
ActionLogin = "login" // post the username to backend
ActionBoot = "boot" // boot a user off your video feed
ActionMute = "mute" // mute a user's chat messages
ActionUnmute = "unmute"
// Actions sent by server or client
ActionMessage = "message" // post a message to the room

View File

@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
@ -30,6 +31,10 @@ type Subscriber struct {
cancel context.CancelFunc
messages chan []byte
closeSlow func()
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
muted map[string]struct{} // usernames you muted
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
@ -81,6 +86,10 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnMe(sub, msg)
case ActionOpen:
s.OnOpen(sub, msg)
case ActionBoot:
s.OnBoot(sub, msg)
case ActionMute, ActionUnmute:
s.OnMute(sub, msg, msg.Action == ActionMute)
case ActionCandidate:
s.OnCandidate(sub, msg)
case ActionSDP:
@ -102,7 +111,7 @@ func (sub *Subscriber) SendJSON(v interface{}) error {
if err != nil {
return err
}
// log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
return sub.conn.Write(sub.ctx, websocket.MessageText, data)
}
@ -154,6 +163,8 @@ func (s *Server) WebSocket() http.HandlerFunc {
closeSlow: func() {
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
},
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
}
s.AddSubscriber(sub)
@ -253,6 +264,12 @@ func (s *Server) Broadcast(msg Message) {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
sub.SendJSON(msg)
}
}
@ -286,29 +303,38 @@ func (s *Server) SendTo(username string, msg Message) error {
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
users = []WhoList{}
subscribers = s.IterSubscribers()
)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []WhoList{}
for _, user := range subscribers {
who := WhoList{
Username: sub.Username,
VideoActive: sub.VideoActive,
NSFW: sub.VideoNSFW,
Username: user.Username,
VideoActive: user.VideoActive,
NSFW: user.VideoNSFW,
}
// If this person had booted us, force their camera to "off"
if user.Boots(sub.Username) || user.Mutes(sub.Username) {
who.VideoActive = false
who.NSFW = false
}
if sub.JWTClaims != nil {
who.Operator = sub.JWTClaims.IsAdmin
who.Avatar = sub.JWTClaims.Avatar
who.ProfileURL = sub.JWTClaims.ProfileURL
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
}
users = append(users, who)
}
for _, sub := range subscribers {
sub.SendJSON(Message{
Action: ActionWhoList,
WhoList: users,
@ -316,6 +342,22 @@ func (s *Server) SendWhoList() {
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

View File

@ -67,7 +67,9 @@ const app = Vue.createApp({
// Who List for the room.
whoList: [],
whoTab: 'online',
whoMap: {}, // map username to wholist entry
muted: {}, // muted usernames for client side state
// My video feed.
webcam: {
@ -339,6 +341,36 @@ const app = Vue.createApp({
}
},
// Mute or unmute a user.
muteUser(username) {
let mute = this.muted[username] == undefined;
if (mute) {
this.muted[username] = true;
} else {
delete this.muted[username];
}
this.sendMute(username, mute);
if (mute) {
this.ChatClient(
`You have muted <strong>${username}</strong> and will no longer see their chat messages, `+
`and they will not see whether your webcam is active. You may unmute them via the Who Is Online list.`);
} else {
this.ChatClient(
`You have unmuted <strong>${username}</strong> and can see their chat messages from now on.`,
);
}
},
sendMute(username, mute) {
this.ws.conn.send(JSON.stringify({
action: mute ? "mute" : "unmute",
username: username,
}));
},
isMutedUser(username) {
return this.muted[username] != undefined;
},
// Send a video request to access a user's camera.
sendOpen(username) {
this.ws.conn.send(JSON.stringify({
@ -346,6 +378,12 @@ const app = Vue.createApp({
username: username,
}));
},
sendBoot(username) {
this.ws.conn.send(JSON.stringify({
action: "boot",
username: username,
}));
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
const secret = msg.openSecret;
@ -428,6 +466,7 @@ const app = Vue.createApp({
conn.addEventListener("close", ev => {
// Lost connection to server - scrub who list.
this.onWho({whoList: []});
this.muted = {};
this.ws.connected = false;
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
@ -930,6 +969,39 @@ const app = Vue.createApp({
} else {
this.ChatClient("Your current webcam viewers are:<br><br>" + users.join(", "));
}
// Also focus the Watching list.
this.whoTab = 'watching';
// TODO: if mobile, show the panel - this width matches
// the media query in chat.css
if (screen.width < 1024) {
this.openWhoPanel();
}
},
// Boot someone off yourn video.
bootUser(username) {
if (!window.confirm(
`Kick ${username} off your camera? This will also prevent them `+
`from seeing that your camera is active for the remainder of your `+
`chat session.`)) {
return;
}
this.sendBoot(username);
// Close the WebRTC peer connection.
if (this.WebRTC.pc[username] != undefined) {
this.closeVideo(username, "answerer");
}
this.ChatClient(
`You have booted ${username} off your camera. They will no longer be able `+
`to connect to your camera, or even see that your camera is active at all -- `+
`to them it appears as though you had turned yours off.<br><br>This will be `+
`in place for the remainder of your current chat session.`
);
},
// Stop broadcasting.
@ -937,6 +1009,7 @@ const app = Vue.createApp({
this.webcam.elem.srcObject = null;
this.webcam.stream = null;
this.webcam.active = false;
this.whoTab = "online";
// Close all WebRTC sessions.
for (username of Object.keys(this.WebRTC.pc)) {

View File

@ -574,12 +574,27 @@
<div class="column is-narrow">
<small class="has-text-grey" :title="msg.at">[[prettyDate(msg.at)]]</small>
</div>
<div class="column"
<div class="column is-narrow"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username || isDM)">
<!-- DMs button -->
<button type="button"
class="button is-grey is-outlined is-small px-2"
@click="openDMs({username: msg.username})">
<i class="fa fa-message"></i>
<i class="fa fa-message mr-1"></i>
DM
</button>
</div>
<div class="column is-narrow"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username)">
<!-- Mute button -->
<button type="button"
class="button is-grey is-outlined is-small px-2"
@click="muteUser(msg.username)"
title="Mute user">
<i class="fa fa-comment-slash mr-1"
:class="{'has-text-success': isMutedUser(msg.username),
'has-text-danger': !isMutedUser(msg.username)}"></i>
[[isMutedUser(msg.username) ? 'Unmute' : 'Mute']]
</button>
</div>
</div>
@ -642,7 +657,26 @@
</header>
<div class="card-content p-2">
<ul class="menu-list">
<div class="tabs has-text-small">
<ul>
<li :class="{'is-active': whoTab==='online'}">
<a class="is-size-7"
@click.prevent="whoTab='online'">
Online ([[ whoList.length ]])
</a>
</li>
<li v-if="webcam.active" :class="{'is-active': whoTab==='watching'}">
<a class="is-size-7"
@click.prevent="whoTab='watching'">
<i class="fa fa-eye mr-2"></i>
Watching ([[ Object.keys(webcam.watching).length ]])
</a>
</li>
</ul>
</div>
<!-- Who Is Online -->
<ul class="menu-list" v-if="whoTab==='online'">
<li v-for="(u, i) in whoList" v-bind:key="i">
<div class="columns is-mobile">
<!-- Avatar URL if available -->
@ -670,8 +704,16 @@
<i class="fa fa-user"></i>
</button>
<!-- DM button -->
<button type="button"
<!-- Unmute User button (if muted) -->
<button type="button" v-if="isMutedUser(u.username)"
class="button is-small px-2 py-1"
@click="muteUser(u.username)"
title="This user is muted. Click to unmute them.">
<i class="fa fa-comment-slash has-text-danger"></i>
</button>
<!-- DM button (if not muted) -->
<button type="button" v-else
class="button is-small px-2 py-1"
@click="openDMs(u)"
title="Start direct message thread"
@ -695,6 +737,30 @@
</li>
</ul>
<!-- Watching My Webcam -->
<ul class="menu-list" v-if="whoTab==='watching'">
<li v-for="username in Object.keys(webcam.watching)" v-bind:key="username">
<div class="columns is-mobile">
<!-- Avatar URL if available -->
<div class="column is-narrow pr-0">
<i class="fa fa-eye"></i>
</div>
<div class="column pr-0">
[[ username ]]
</div>
<div class="column is-narrow pl-0">
<!-- Boot from cam button -->
<button type="button"
class="button is-small px-2 py-1"
@click="bootUser(username)"
title="Kick this person off your cam">
<i class="fa fa-user-xmark has-text-danger"></i>
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>