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. // Echo the message only to both parties.
s.SendTo(sub.Username, message) s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username 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 { if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err) 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. // Echo the message only to both parties.
s.SendTo(sub.Username, message) s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username 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 { if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err) 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. // OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg Message) { func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
// Look up the other subscriber. // Look up the other subscriber.

View File

@ -38,6 +38,9 @@ type Message struct {
const ( const (
// Actions sent by the client side only // 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 // Actions sent by server or client
ActionMessage = "message" // post a message to the room ActionMessage = "message" // post a message to the room

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/config"
@ -30,6 +31,10 @@ type Subscriber struct {
cancel context.CancelFunc cancel context.CancelFunc
messages chan []byte messages chan []byte
closeSlow func() 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. // ReadLoop spawns a goroutine that reads from the websocket connection.
@ -81,6 +86,10 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnMe(sub, msg) s.OnMe(sub, msg)
case ActionOpen: case ActionOpen:
s.OnOpen(sub, msg) s.OnOpen(sub, msg)
case ActionBoot:
s.OnBoot(sub, msg)
case ActionMute, ActionUnmute:
s.OnMute(sub, msg, msg.Action == ActionMute)
case ActionCandidate: case ActionCandidate:
s.OnCandidate(sub, msg) s.OnCandidate(sub, msg)
case ActionSDP: case ActionSDP:
@ -102,7 +111,7 @@ func (sub *Subscriber) SendJSON(v interface{}) error {
if err != nil { if err != nil {
return err 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) return sub.conn.Write(sub.ctx, websocket.MessageText, data)
} }
@ -154,6 +163,8 @@ func (s *Server) WebSocket() http.HandlerFunc {
closeSlow: func() { closeSlow: func() {
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages") 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) s.AddSubscriber(sub)
@ -253,6 +264,12 @@ func (s *Server) Broadcast(msg Message) {
continue 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) 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. // SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() { func (s *Server) SendWhoList() {
var ( var (
users = []WhoList{}
subscribers = s.IterSubscribers() 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 { for _, sub := range subscribers {
if !sub.authenticated { if !sub.authenticated {
continue continue
} }
var users = []WhoList{}
for _, user := range subscribers {
who := WhoList{ who := WhoList{
Username: sub.Username, Username: user.Username,
VideoActive: sub.VideoActive, VideoActive: user.VideoActive,
NSFW: sub.VideoNSFW, 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 { if sub.JWTClaims != nil {
who.Operator = sub.JWTClaims.IsAdmin who.Operator = user.JWTClaims.IsAdmin
who.Avatar = sub.JWTClaims.Avatar who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = sub.JWTClaims.ProfileURL who.ProfileURL = user.JWTClaims.ProfileURL
} }
users = append(users, who) users = append(users, who)
} }
for _, sub := range subscribers {
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: ActionWhoList, Action: ActionWhoList,
WhoList: users, 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 { func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()

View File

@ -67,7 +67,9 @@ const app = Vue.createApp({
// Who List for the room. // Who List for the room.
whoList: [], whoList: [],
whoTab: 'online',
whoMap: {}, // map username to wholist entry whoMap: {}, // map username to wholist entry
muted: {}, // muted usernames for client side state
// My video feed. // My video feed.
webcam: { 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. // Send a video request to access a user's camera.
sendOpen(username) { sendOpen(username) {
this.ws.conn.send(JSON.stringify({ this.ws.conn.send(JSON.stringify({
@ -346,6 +378,12 @@ const app = Vue.createApp({
username: username, username: username,
})); }));
}, },
sendBoot(username) {
this.ws.conn.send(JSON.stringify({
action: "boot",
username: username,
}));
},
onOpen(msg) { onOpen(msg) {
// Response for the opener to begin WebRTC connection. // Response for the opener to begin WebRTC connection.
const secret = msg.openSecret; const secret = msg.openSecret;
@ -428,6 +466,7 @@ const app = Vue.createApp({
conn.addEventListener("close", ev => { conn.addEventListener("close", ev => {
// Lost connection to server - scrub who list. // Lost connection to server - scrub who list.
this.onWho({whoList: []}); this.onWho({whoList: []});
this.muted = {};
this.ws.connected = false; this.ws.connected = false;
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`); this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
@ -930,6 +969,39 @@ const app = Vue.createApp({
} else { } else {
this.ChatClient("Your current webcam viewers are:<br><br>" + users.join(", ")); 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. // Stop broadcasting.
@ -937,6 +1009,7 @@ const app = Vue.createApp({
this.webcam.elem.srcObject = null; this.webcam.elem.srcObject = null;
this.webcam.stream = null; this.webcam.stream = null;
this.webcam.active = false; this.webcam.active = false;
this.whoTab = "online";
// Close all WebRTC sessions. // Close all WebRTC sessions.
for (username of Object.keys(this.WebRTC.pc)) { for (username of Object.keys(this.WebRTC.pc)) {

View File

@ -574,12 +574,27 @@
<div class="column is-narrow"> <div class="column is-narrow">
<small class="has-text-grey" :title="msg.at">[[prettyDate(msg.at)]]</small> <small class="has-text-grey" :title="msg.at">[[prettyDate(msg.at)]]</small>
</div> </div>
<div class="column" <div class="column is-narrow"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username || isDM)"> v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username || isDM)">
<!-- DMs button -->
<button type="button" <button type="button"
class="button is-grey is-outlined is-small px-2" class="button is-grey is-outlined is-small px-2"
@click="openDMs({username: msg.username})"> @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> </button>
</div> </div>
</div> </div>
@ -642,7 +657,26 @@
</header> </header>
<div class="card-content p-2"> <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"> <li v-for="(u, i) in whoList" v-bind:key="i">
<div class="columns is-mobile"> <div class="columns is-mobile">
<!-- Avatar URL if available --> <!-- Avatar URL if available -->
@ -670,8 +704,16 @@
<i class="fa fa-user"></i> <i class="fa fa-user"></i>
</button> </button>
<!-- DM button --> <!-- Unmute User button (if muted) -->
<button type="button" <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" class="button is-small px-2 py-1"
@click="openDMs(u)" @click="openDMs(u)"
title="Start direct message thread" title="Start direct message thread"
@ -695,6 +737,30 @@
</li> </li>
</ul> </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> </div>
</div> </div>