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 {