From c5c8d08c7a7736c4f728e6ebaa5d5a1594fd3998 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 22 Mar 2023 20:21:04 -0700 Subject: [PATCH] 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). --- pkg/handlers.go | 57 ++++++++++++++++++++++++++++++ pkg/messages.go | 5 ++- pkg/websocket.go | 72 +++++++++++++++++++++++++++++-------- web/static/js/BareRTC.js | 73 ++++++++++++++++++++++++++++++++++++++ web/templates/chat.html | 76 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 262 insertions(+), 21 deletions(-) diff --git a/pkg/handlers.go b/pkg/handlers.go index 0345e81..b08306e 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -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. diff --git a/pkg/messages.go b/pkg/messages.go index 8e2ce2b..612ba56 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -37,7 +37,10 @@ type Message struct { const ( // Actions sent by the client side only - ActionLogin = "login" // post the username to backend + 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 diff --git a/pkg/websocket.go b/pkg/websocket.go index 0a46faa..010db17 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -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 } - who := WhoList{ - Username: sub.Username, - VideoActive: sub.VideoActive, - NSFW: sub.VideoNSFW, - } - if sub.JWTClaims != nil { - who.Operator = sub.JWTClaims.IsAdmin - who.Avatar = sub.JWTClaims.Avatar - who.ProfileURL = sub.JWTClaims.ProfileURL - } - users = append(users, who) - } + var users = []WhoList{} + for _, user := range subscribers { + who := WhoList{ + 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 = 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() diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 644cad3..2bb68bd 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -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 ${username} 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 ${username} 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:

" + 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.

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)) { diff --git a/web/templates/chat.html b/web/templates/chat.html index 5d9a655..1bf806c 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -574,12 +574,27 @@
[[prettyDate(msg.at)]]
-
+ +
+
+ +
@@ -642,7 +657,26 @@
-