From 3145dde10772eb60d9ce2ccf5efd9fdefe626b81 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 30 Mar 2025 15:33:58 -0700 Subject: [PATCH] Invite to watch my webcam * Add a feature where a webcam broadcaster may manually invite others on chat to watch, even if normally the other person would not be allowed. For example, it will bypass the mutual webcam requirement setting and allow the invited user to watch even if their own camera is not on. * The button appears in Profile Modals and in the overflow menu on the MessageBox component. --- Protocol.md | 24 ++++++++++++ pkg/handlers.go | 16 +++++++- pkg/messages/messages.go | 18 +++++---- pkg/subscriber.go | 15 ++++++++ public/static/css/chat.css | 5 +++ src/App.vue | 11 +++++- src/components/MessageBox.vue | 21 +++++++++- src/components/ProfileModal.vue | 17 ++++++++- src/lib/VideoFlag.js | 1 + src/lib/WebRTC.js | 68 ++++++++++++++++++++++++++++++++- 10 files changed, 182 insertions(+), 14 deletions(-) diff --git a/Protocol.md b/Protocol.md index 1ddb724..7e5ea21 100644 --- a/Protocol.md +++ b/Protocol.md @@ -43,6 +43,8 @@ VideoFlag: { NonExplicit: 1 << 3, MutualRequired: 1 << 4, MutualOpen: 1 << 5, + OnlyVIP: 1 << 6, + Invited: 1 << 7, } ``` @@ -353,6 +355,28 @@ The server tells the client to turn off their camera. This is done in response t } ``` +## Video Invite + +Sent by: Client. + +The invite-video command allows the client to whitelist permission for other users on chat to watch their webcam, even if normally those other users would not be allowed. + +For example: if the user has the mutual webcam option enabled (to require their viewers to be on camera too before watching), the invite-video command will allow a user who is not on webcam to watch anyway. + +On the front-end app, this manifests as showing a green outline around the video button (with the video icon itself being blue or red as normal). + +Note: multiple usernames can be sent. This is so if the user experiences a temporary disconnect, their page can re-sync the list over when they reconnect. + +```javascript +// Client Video Invite +{ + "action": "video-invite", + "usernames": [ "alice", "bob" ] +} +``` + +The server does not send any response to this message. Instead, to the target users, the sender's video flag will have the Invited bit set on WhoList updates. + ## Mute, Unmute Sent by: Client. diff --git a/pkg/handlers.go b/pkg/handlers.go index 5de0f3f..2d0da8c 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -464,6 +464,17 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) { } } +// OnVideoInvite is a client inviting another to watch their camera. +func (s *Server) OnVideoInvite(sub *Subscriber, msg messages.Message) { + sub.muteMu.Lock() + for _, username := range msg.Usernames { + sub.invited[username] = struct{}{} + } + sub.muteMu.Unlock() + + s.SendWhoList() +} + // OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera. func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) { // Moderation rules? @@ -531,6 +542,7 @@ func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, st theirVideoActive = (other.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive theirMutualRequired = (other.VideoStatus & messages.VideoFlagMutualRequired) == messages.VideoFlagMutualRequired theirVIPRequired = (other.VideoStatus & messages.VideoFlagOnlyVIP) == messages.VideoFlagOnlyVIP + theyInvitedUs = other.InvitesVideo(sub.Username) ) // Conditions in which we can not watch their video. @@ -543,11 +555,11 @@ func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, st Error: "Their video is not currently enabled.", }, { - If: theirMutualRequired && !ourVideoActive, + If: !theyInvitedUs && (theirMutualRequired && !ourVideoActive), Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username), }, { - If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(), + If: !theyInvitedUs && (theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin()), Error: "You do not have permission to view that camera.", }, { diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go index 19be554..217af7b 100644 --- a/pkg/messages/messages.go +++ b/pkg/messages/messages.go @@ -72,14 +72,15 @@ 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 - ActionUnboot = "unboot" // unboot a user - ActionMute = "mute" // mute a user's chat messages - ActionUnmute = "unmute" - ActionBlock = "block" // hard block another user - ActionBlocklist = "blocklist" // mute in bulk for usernames - ActionReport = "report" // user reports a message + ActionLogin = "login" // post the username to backend + ActionBoot = "boot" // boot a user off your video feed + ActionUnboot = "unboot" // unboot a user + ActionMute = "mute" // mute a user's chat messages + ActionUnmute = "unmute" + ActionBlock = "block" // hard block another user + ActionBlocklist = "blocklist" // mute in bulk for usernames + ActionReport = "report" // user reports a message + ActionVideoInvite = "video-invite" // user invites another to watch their webcam // Actions sent by server or client ActionMessage = "message" // post a message to the room @@ -135,6 +136,7 @@ const ( VideoFlagMutualRequired // video wants viewers to share their camera too VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras VideoFlagOnlyVIP // can only shows as active to VIP members + VideoFlagInvited // user invites another to watch their camera ) // Presence message templates. diff --git a/pkg/subscriber.go b/pkg/subscriber.go index 27035b5..8a26b1d 100644 --- a/pkg/subscriber.go +++ b/pkg/subscriber.go @@ -50,6 +50,7 @@ type Subscriber struct { booted map[string]struct{} // usernames booted off your camera blocked map[string]struct{} // usernames you have blocked muted map[string]struct{} // usernames you muted + invited map[string]struct{} // usernames you invited to watch your camera // Admin "unblockable" override command, e.g. especially for your chatbot so it can // still moderate the chat even if users had blocked it. The /unmute-all admin command @@ -75,6 +76,7 @@ func (s *Server) NewSubscriber(ctx context.Context, cancelFunc func()) *Subscrib booted: make(map[string]struct{}), muted: make(map[string]struct{}), blocked: make(map[string]struct{}), + invited: make(map[string]struct{}), messageIDs: make(map[int64]struct{}), ChatStatus: "online", } @@ -154,6 +156,8 @@ func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) { s.OnReact(sub, msg) case messages.ActionReport: s.OnReport(sub, msg) + case messages.ActionVideoInvite: + s.OnVideoInvite(sub, msg) case messages.ActionPing: default: sub.ChatServer("Unsupported message type: %s", msg.Action) @@ -498,6 +502,9 @@ func (s *Server) SendWhoList() { // Force their video to "off" who.Video = 0 } + } else if user.InvitesVideo(sub.Username) { + // This user invited us to see their webcam, set the relevant flag. + who.Video |= messages.VideoFlagInvited } // If this person's VideoFlag is set to VIP Only, force their camera to "off" @@ -535,6 +542,14 @@ func (s *Server) SendWhoList() { } } +// InvitesVideo checks whether the subscriber has invited the username to see their webcam. +func (s *Subscriber) InvitesVideo(username string) bool { + s.muteMu.RLock() + defer s.muteMu.RUnlock() + _, ok := s.invited[username] + return ok +} + // Boots checks whether the subscriber has blocked username from their camera. func (s *Subscriber) Boots(username string) bool { s.muteMu.RLock() diff --git a/public/static/css/chat.css b/public/static/css/chat.css index 5519e83..9633596 100644 --- a/public/static/css/chat.css +++ b/public/static/css/chat.css @@ -55,6 +55,11 @@ img { color: #ff9999 !important; } +/* Video button outline for invited status */ +.video-button-invited { + border-color: hsl(153deg,53%,53%) !important; +} + /* Max height for message in report modal */ .report-modal-message { max-height: 150px; diff --git a/src/App.vue b/src/App.vue index 0561796..57a34a5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1094,7 +1094,7 @@ export default { for (let row of this.whoList) { // If we were watching this user's (blue) camera and we prefer non-Explicit, // and their camera is now becoming explicit (red), close it now. - if (this.webcam.nonExplicit && this.WebRTC.streams[row.username] != undefined) { + if (this.webcam.active && this.webcam.nonExplicit && this.WebRTC.streams[row.username] != undefined) { if (!(this.whoMap[row.username].video & this.VideoFlag.NSFW) && (row.video & this.VideoFlag.NSFW)) { this.closeVideo(row.username, "offerer"); @@ -1281,6 +1281,9 @@ export default { if (mapBlockedUsers[username]) continue; this.sendMute(username, true); } + + // Re-sync our video invite list on reconnect as well. + this.sendInviteVideoBulk(); }, onUserExited(msg) { @@ -3085,12 +3088,15 @@ export default { :profile-webhook-enabled="isWebhookEnabled('profile')" :vip-config="config.VIP" :is-watching="isWatching(profileModal.username)" + :my-video-active="webcam.active" + :is-invited-video="isInvited(profileModal.username)" @send-dm="openDMs" @mute-user="muteUser" @boot-user="bootUser" @send-command="sendCommand" @nudge-nsfw="sendNudgeNsfw" @report="doCustomReport" + @invite-video="inviteToWatch" @cancel="profileModal.visible = false"> @@ -3497,10 +3503,13 @@ export default { :report-enabled="isWebhookEnabled('report')" :is-dm="isDM" :is-op="isOp" + :my-video-active="webcam.active" :is-video-not-allowed="isVideoNotAllowed(getUser(msg.username))" :video-icon-class="webcamIconClass(getUser(msg.username))" + :is-invited-video="isInvited(msg.username)" @open-profile="showProfileModal" @open-video="openVideo" + @invite-video="inviteToWatch" @send-dm="openDMs" @mute-user="muteUser" @takeback="takeback" diff --git a/src/components/MessageBox.vue b/src/components/MessageBox.vue index 41cfcb8..4b775e7 100644 --- a/src/components/MessageBox.vue +++ b/src/components/MessageBox.vue @@ -26,8 +26,10 @@ export default { noButtons: Boolean, // hide all message buttons (e.g. for Report Modal) // User webcam settings + myVideoActive: Boolean, // local user's camera is on isVideoNotAllowed: Boolean, videoIconClass: String, + isInvitedVideo: Boolean, // we had already invited them to watch }, components: { EmojiPicker, @@ -117,7 +119,6 @@ export default { return `${this.position}-${this.user.username}-${this.message.at}-${Math.random()*99999}`; }, - // TODO: make DRY, copied from WhoListRow. videoButtonClass() { return WebRTC.videoButtonClass(this.user, this.isVideoNotAllowed); }, @@ -140,6 +141,10 @@ export default { this.$emit('open-video', this.user); }, + inviteVideo() { + this.$emit('invite-video', this.user.username); + }, + muteUser() { this.$emit('mute-user', this.message.username); }, @@ -346,6 +351,13 @@ export default {