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 @@
-