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.
This commit is contained in:
Noah 2025-03-30 15:33:58 -07:00
parent 16924a5ff5
commit 3145dde107
10 changed files with 182 additions and 14 deletions

View File

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

View File

@ -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.",
},
{

View File

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

View File

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

View File

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

View File

@ -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"></ProfileModal>
<!-- DMs History of Usernames Modal -->
@ -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"

View File

@ -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 {
<div class="dropdown-menu" :id="`msg-overflow-menu-${uniqueID}`">
<div class="dropdown-content" role="menu">
<!-- Invite this user to watch my camera -->
<a href="#" class="dropdown-item" v-if="!(message.username === username) && myVideoActive && !isInvitedVideo"
@click.prevent="inviteVideo()">
<i class="fa fa-video mr-1 has-text-success"></i>
Invite to watch my webcam
</a>
<!-- Mute/Unmute User -->
<a href="#" class="dropdown-item" v-if="!(message.username === username)"
@click.prevent="muteUser()">
@ -560,6 +572,13 @@ export default {
<i class="fa fa-comment mr-1"></i> Direct Message
</a>
<!-- Invite this user to watch my camera -->
<a href="#" class="dropdown-item" v-if="!(message.username === username) && myVideoActive && !isInvitedVideo"
@click.prevent="inviteVideo()">
<i class="fa fa-video mr-1 has-text-success"></i>
Invite to watch my webcam
</a>
<a href="#" class="dropdown-item" v-if="message.msgID && !message.username !== username"
@click.prevent="muteUser()">
<i class="fa fa-comment-slash mr-1" :class="{

View File

@ -16,6 +16,8 @@ export default {
isBooted: Boolean,
profileWebhookEnabled: Boolean,
vipConfig: Object, // VIP config settings for BareRTC
myVideoActive: Boolean, // local user's camera is on
isInvitedVideo: Boolean, // we had already invited them to watch
},
components: {
AlertModal,
@ -174,6 +176,10 @@ export default {
this.$emit('boot-user', this.user.username);
},
inviteVideo() {
this.$emit('invite-video', this.user.username);
},
// Operator commands (may be rejected by server if not really Op)
markNsfw() {
this.modalConfirm({
@ -410,7 +416,16 @@ export default {
'has-text-danger': !isBooted,
'has-text-success': isBooted,
}"></i>
{{ isBooted ? 'Allow to watch my webcam' : "Don't allow to watch my webcam" }}
{{ isBooted ? 'Unboot from my webcam' : "Boot from my webcam" }}
</button>
<!-- Invite to watch me button -->
<button type="button"
v-if="myVideoActive && !isBooted && !isInvitedVideo"
class="button is-small px-2 ml-1 mb-1"
@click="inviteVideo()" title="Invite to watch my webcam">
<i class="fa fa-video has-text-success mr-1"></i>
Invite to watch me
</button>
<!-- Admin actions -->

View File

@ -7,6 +7,7 @@ const VideoFlag = {
MutualRequired: 1 << 4,
MutualOpen: 1 << 5,
VipOnly: 1 << 6,
Invited: 1 << 7,
};
export default VideoFlag;

View File

@ -137,6 +137,7 @@ class WebRTCController {
streams: {},
muted: {}, // muted bool per username
booted: {}, // booted bool per username
invited: {}, // usernames we had invited
poppedOut: {}, // popped-out video per username
speaking: {}, // speaking boolean per username
@ -580,6 +581,26 @@ class WebRTCController {
username: username,
});
},
sendInviteVideo(username) {
this.WebRTC.invited[username] = true;
this.client.send({
action: "video-invite",
usernames: [username],
});
},
sendInviteVideoBulk() {
let usernames = Object.keys(this.WebRTC.invited);
if (usernames.length === 0) return;
// Re-send the invite list on reconnect to server.
this.client.send({
action: "video-invite",
usernames: usernames,
});
},
isInvited(username) {
return this.WebRTC.invited[username] != undefined;
},
onCandidate(msg) {
// Handle inbound WebRTC signaling messages proxied by the websocket.
if (this.WebRTC.pc[msg.username] == undefined || !this.WebRTC.pc[msg.username].connecting) {
@ -1195,12 +1216,20 @@ class WebRTCController {
if (this.webcam.active) return;
for (let row of this.whoList) {
let username = row.username;
// If this user expressly invited us to watch, skip.
if ((row.video & this.VideoFlag.Active) && (row.video & this.VideoFlag.Invited)) {
continue;
}
if ((row.video & this.VideoFlag.MutualRequired) && this.WebRTC.pc[username] != undefined) {
this.closeVideo(username);
}
}
},
unWatchNonExplicitVideo() {
if (!this.webcam.active) return;
// If we are watching cameras with the NonExplicit setting, and our camera has become
// explicit, excuse ourselves from their watch list.
if (this.webcam.nsfw) {
@ -1226,6 +1255,29 @@ class WebRTCController {
}
},
// Invite a user to watch your camera even if they normally couldn't see.
inviteToWatch(username) {
this.modalConfirm({
title: "Invite to watch my webcam",
icon: "fa fa-video",
message: `Do you want to invite @${username} to watch your camera?\n\n` +
`This will give them permission to see your camera even if, normally, they would not be able to. ` +
`For example, if you have the option enabled to "require my viewer to be on webcam too" and @${username} is not on camera, ` +
`by inviting them to watch they will be allowed to see your camera anyway.\n\n` +
`This permission will be granted for the remainder of your chat session, but you can boot them ` +
`off your camera if you change your mind later.`,
buttons: ['Invite to watch', 'Cancel'],
}).then(() => {
this.sendInviteVideo(username);
this.ChatClient(
`You have granted <strong>@${username}</strong> permission to watch your webcam. This will be in effect for the remainder of your chat session. ` +
"Note: if you change your mind later, you can boot them from your camera or block them from watching by using the button " +
"in their profile card.",
);
});
},
isUsernameOnCamera(username) {
return this.whoMap[username]?.video & VideoFlag.Active;
},
@ -1277,7 +1329,12 @@ class WebRTCController {
// If the user is under the NoVideo rule, always cross it out.
if (this.jwt.rules.IsNoVideoRule) return true;
// If this user expressly invited us to watch.
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.Invited)) {
return false;
}
// Mutual video sharing is required on this camera, and ours is not active
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired)) {
// A nuance to the mutual video required: if we DO have our cam on, but ours is VIP only, and the
@ -1850,12 +1907,21 @@ class WebRTCController {
result = "has-background-vip ";
}
// Invited to watch: green border but otherwise red/blue icon.
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.Invited)) {
// Invited to watch; green border but blue/red icon.
result += "video-button-invited ";
}
// Colors and/or cursors.
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.NSFW)) {
// Explicit camera: red border
result += "is-danger is-outlined";
} else if ((user.video & VideoFlag.Active) && !(user.video & VideoFlag.NSFW)) {
// Normal camera: blue border
result += "is-link is-outlined";
} else if (isVideoNotAllowed) {
// Default: grey border and not-allowed cursor.
result += "cursor-notallowed";
}