Video Status Bitflags + Improvements

* Consolidate all the Video flags (active, nsfw, mutual, mutualOpen)
  into a bitmask flag (single integer)
* New video flag for when the source has muted their video, to show a
  crossed out grey mic on their camera for other chatters
* Bugfixes around syncing the mute state for self and other videos when
  videos are opened, closed and opened again
* Profile pictures on the DMs list
ipad-testing
Noah 2023-06-30 18:41:06 -07:00
parent 3c1ad4ec6d
commit 2445d45d3f
10 changed files with 220 additions and 88 deletions

View File

@ -30,6 +30,22 @@ The video stream can be interrupted and closed via various methods:
* Also, a `who` update that says a person's videoActive went false will instruct all clients who had the video open, to close it (in case the PeerConnection closure didn't already do this).
* If a user exits the room, e.g. exited their browser abruptly without gracefully closing PeerConnections, any client who had their video open will close it immediately.
# Video Flags
The various video settings sent on Who List updates are now consolidated
to a bit flag field:
```javascript
VideoFlag: {
Active: 1 << 0, // or 00000001 in binary
NSFW: 1 << 1, // or 00000010
Muted: 1 << 2, // or 00000100, etc.
IsTalking: 1 << 3,
MutualRequired: 1 << 4,
MutualOpen: 1 << 5,
}
```
# WebSocket Message Actions
Every message has an "action" and may have other fields depending on the action type.
@ -201,8 +217,7 @@ The client sends "me" messages to send their webcam broadcast status and NSFW fl
// Client Me
{
"action": "me",
"videoActive": true,
"nsfw": false
"video": 1,
}
```
@ -213,7 +228,7 @@ The server may also push "me" messages to the user: for example if there is a co
{
"action": "me",
"username": "soandso 12345",
"videoActive": true
"video": 1,
}
```
@ -233,8 +248,7 @@ The `who` action sends the Who Is Online list to all connected chatters.
"op": false, // operator status
"avatar": "/picture/soandso.png",
"profileURL": "/u/soandso",
"videoActive": true,
"nsfW": false
"video": 0,
}
]
}

View File

@ -49,8 +49,8 @@ func (s *Server) Statistics() http.HandlerFunc {
unique[sub.Username] = struct{}{}
// Count cameras by color.
if sub.VideoActive {
if sub.VideoNSFW {
if sub.VideoStatus&VideoFlagActive == VideoFlagActive {
if sub.VideoStatus&VideoFlagNSFW == VideoFlagNSFW {
result.Cameras.Red++
} else {
result.Cameras.Blue++

View File

@ -1,9 +1,12 @@
package barertc
import (
"fmt"
"os"
"strconv"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"github.com/mattn/go-shellwords"
)
@ -42,7 +45,7 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool {
sub.ChatServer("/nsfw: username not found: %s", username)
} else {
other.ChatServer("Your camera has been marked as NSFW by %s", sub.Username)
other.VideoNSFW = true
other.VideoStatus |= VideoFlagNSFW
other.SendMe()
s.SendWhoList()
sub.ChatServer("%s has their camera marked as NSFW", username)
@ -52,11 +55,23 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool {
sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" +
"* `/kick <username>` to kick from chat\n" +
"* `/nsfw <username>` to mark their camera NSFW\n" +
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
"* `/help` to show this message\n\n" +
"Note: shell-style quoting is supported, if a username has a space in it, quote the whole username, e.g.: `/kick \"username 2\"`",
))
return true
case "/shutdown":
s.Broadcast(Message{
Action: ActionError,
Username: "ChatServer",
Message: "The chat server is going down for a reboot NOW!",
})
os.Exit(1)
case "/kickall":
s.KickAllCommand()
}
}
// Not handled.
@ -87,6 +102,48 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
}
}
// KickAllCommand kicks everybody out of the room.
func (s *Server) KickAllCommand() {
// If we have JWT enabled and a landing page, link users to it.
if config.Current.JWT.Enabled && config.Current.JWT.LandingPageURL != "" {
s.Broadcast(Message{
Action: ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(
"<strong>Notice:</strong> The chat operator has requested that you log back in to the chat room. "+
"Probably, this is because a new feature was launched that needs you to reload the page. "+
"You may refresh the tab or <a href=\"%s\">click here</a> to re-enter the room.",
config.Current.JWT.LandingPageURL,
),
})
} else {
s.Broadcast(Message{
Action: ActionError,
Username: "ChatServer",
Message: "<strong>Notice:</strong> The chat operator has kicked everybody from the room. Usually, this " +
"may mean a new feature of the chat has been launched and you need to reload the page for it " +
"to function correctly.",
})
}
// Kick everyone off.
s.Broadcast(Message{
Action: ActionKick,
})
// Disconnect everybody.
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for _, sub := range s.IterSubscribers(true) {
if !sub.authenticated {
continue
}
s.DeleteSubscriber(sub)
}
}
// BanCommand handles the `/ban` operator command.
func (s *Server) BanCommand(words []string, sub *Subscriber) {
if len(words) == 1 {

View File

@ -12,16 +12,17 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 3
var currentVersion = 4
// Config for your BareRTC app.
type Config struct {
Version int // will re-save your settings.toml on migrations
JWT struct {
Enabled bool
Strict bool
SecretKey string
Enabled bool
Strict bool
SecretKey string
LandingPageURL string
}
Title string

View File

@ -252,7 +252,7 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) {
// OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg Message) {
if msg.VideoActive {
if msg.VideoStatus&VideoFlagActive == VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username)
}
@ -278,10 +278,7 @@ func (s *Server) OnMe(sub *Subscriber, msg Message) {
msg.ChatStatus = "away"
}
sub.VideoActive = msg.VideoActive
sub.VideoMutual = msg.VideoMutual
sub.VideoMutualOpen = msg.VideoMutualOpen
sub.VideoNSFW = msg.NSFW
sub.VideoStatus = msg.VideoStatus
sub.ChatStatus = msg.ChatStatus
// Sync the WhoList to everybody.

View File

@ -23,11 +23,8 @@ type Message struct {
WhoList []WhoList `json:"whoList,omitempty"`
// Sent on `me` actions along with Username
VideoActive bool `json:"videoActive,omitempty"` // user tells us their cam status
VideoMutual bool `json:"videoMutual,omitempty"` // user wants mutual viewers
VideoMutualOpen bool `json:"videoMutualOpen,omitempty"`
ChatStatus string `json:"status,omitempty"` // online vs. away
NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW
VideoStatus int `json:"video,omitempty"` // user video flags
ChatStatus string `json:"status,omitempty"` // online vs. away
// Message ID to support takebacks/local deletions
MessageID int `json:"msgID,omitempty"`
@ -75,16 +72,24 @@ const (
// WhoList is a member entry in the chat room.
type WhoList struct {
Username string `json:"username"`
Nickname string `json:"nickname,omitempty"`
VideoActive bool `json:"videoActive,omitempty"`
VideoMutual bool `json:"videoMutual,omitempty"`
VideoMutualOpen bool `json:"videoMutualOpen,omitempty"`
NSFW bool `json:"nsfw,omitempty"`
Status string `json:"status"`
Username string `json:"username"`
Nickname string `json:"nickname,omitempty"`
Status string `json:"status"`
Video int `json:"video"`
// JWT auth extra settings.
Operator bool `json:"op"`
Avatar string `json:"avatar,omitempty"`
ProfileURL string `json:"profileURL,omitempty"`
}
// VideoFlags to convey the state and setting of users' cameras concisely.
// Also see the VideoFlag object in BareRTC.js for front-end sync.
const (
VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting
VideoFlagNSFW // viewer's camera is marked as NSFW
VideoFlagMuted // user source microphone is muted
VideoFlagIsTalking // broadcaster seems to be talking
VideoFlagMutualRequired // video wants viewers to share their camera too
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
)

View File

@ -40,9 +40,16 @@ func IndexPage() http.HandlerFunc {
// Are we enforcing strict JWT authentication?
if config.Current.JWT.Enabled && config.Current.JWT.Strict && !authOK {
// Do we have a landing page to redirect to?
if config.Current.JWT.LandingPageURL != "" {
w.Header().Add("Location", config.Current.JWT.LandingPageURL)
w.WriteHeader(http.StatusFound)
return
}
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(
fmt.Sprintf("Authentication denied. Please go back and try again."),
"Authentication denied. Please go back and try again.",
))
return
}

View File

@ -21,20 +21,17 @@ import (
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
VideoActive bool
VideoMutual bool
VideoMutualOpen bool
VideoNSFW bool
ChatStatus string
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
@ -130,8 +127,7 @@ func (sub *Subscriber) SendMe() {
sub.SendJSON(Message{
Action: ActionMe,
Username: sub.Username,
VideoActive: sub.VideoActive,
NSFW: sub.VideoNSFW,
VideoStatus: sub.VideoStatus,
})
}
@ -384,18 +380,14 @@ func (s *Server) SendWhoList() {
}
who := WhoList{
Username: user.Username,
Status: user.ChatStatus,
VideoActive: user.VideoActive,
VideoMutual: user.VideoMutual,
VideoMutualOpen: user.VideoMutualOpen,
NSFW: user.VideoNSFW,
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
}
// 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
who.Video = 0
}
if user.JWTClaims != nil {

View File

@ -127,6 +127,16 @@ const app = Vue.createApp({
audioDeviceID: null,
},
// Video flag constants (sync with values in messages.go)
VideoFlag: {
Active: 1 << 0,
NSFW: 1 << 1,
Muted: 1 << 2,
IsTalking: 1 << 3,
MutualRequired: 1 << 4,
MutualOpen: 1 << 5,
},
// WebRTC sessions with other users.
WebRTC: {
// Streams per username.
@ -315,6 +325,16 @@ const app = Vue.createApp({
// Returns if the current user has operator rights
return this.jwt.claims.op;
},
myVideoFlag() {
// Compute the current user's video status flags.
let status = 0;
if (this.webcam.active) status |= this.VideoFlag.Active;
if (this.webcam.muted) status |= this.VideoFlag.Muted;
if (this.webcam.nsfw) status |= this.VideoFlag.NSFW;
if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired;
if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen;
return status;
},
},
methods: {
// Load user prefs from localStorage, called on startup
@ -375,11 +395,8 @@ const app = Vue.createApp({
sendMe() {
this.ws.conn.send(JSON.stringify({
action: "me",
videoActive: this.webcam.active,
videoMutual: this.webcam.mutual,
videoMutualOpen: this.webcam.mutualOpen,
video: this.myVideoFlag,
status: this.status,
nsfw: this.webcam.nsfw,
}));
},
onMe(msg) {
@ -391,11 +408,11 @@ const app = Vue.createApp({
}
// The server can set our webcam NSFW flag.
if (this.webcam.nsfw != msg.nsfw) {
this.webcam.nsfw = msg.nsfw;
let myNSFW = this.webcam.nsfw;
let theirNSFW = (msg.video & this.VideoFlag.NSFW) > 0;
if (myNSFW != theirNSFW) {
this.webcam.nsfw = theirNSFW;
}
// this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
},
// WhoList updates.
@ -412,14 +429,14 @@ const app = Vue.createApp({
for (let row of this.whoList) {
this.whoMap[row.username] = row;
if (this.WebRTC.streams[row.username] != undefined &&
row.videoActive !== true) {
!(row.video & this.VideoFlag.Active)) {
this.closeVideo(row.username, "offerer");
}
}
// Has the back-end server forgotten we are on video? This can
// happen if we disconnect/reconnect while we were streaming.
if (this.webcam.active && !this.whoMap[this.username]?.videoActive) {
if (this.webcam.active && !(this.whoMap[this.username]?.video & this.VideoFlag.Active)) {
this.sendMe();
}
},
@ -473,18 +490,10 @@ const app = Vue.createApp({
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
const secret = msg.openSecret;
// console.log("OPEN: connect to %s with secret %s", msg.username, secret);
// this.ChatClient(`onOpen called for ${msg.username}.`);
this.startWebRTC(msg.username, true);
},
onRing(msg) {
// Message for the receiver to begin WebRTC connection.
const secret = msg.openSecret;
// console.log("RING: connection from %s with secret %s", msg.username, secret);
this.ChatServer(`${msg.username} has opened your camera.`);
this.startWebRTC(msg.username, false);
},
onUserExited(msg) {
@ -768,7 +777,7 @@ const app = Vue.createApp({
// If we are the offerer, and this member wants to auto-open our camera
// then add our own stream to the connection.
if (isOfferer && this.whoMap[username].videoMutualOpen && this.webcam.active) {
if (isOfferer && (this.whoMap[username].video & this.VideoFlag.MutualOpen) && this.webcam.active) {
let stream = this.webcam.stream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream)
@ -1113,7 +1122,7 @@ const app = Vue.createApp({
// Is the target user NSFW? Go thru the modal.
let dontShowAgain = localStorage["skip-nsfw-modal"] == "true";
if (user.nsfw && !dontShowAgain && !force) {
if ((user.video & this.VideoFlag.NSFW) && !dontShowAgain && !force) {
this.nsfwModalView.user = user;
this.nsfwModalView.visible = true;
return;
@ -1130,7 +1139,7 @@ const app = Vue.createApp({
}
// If this user requests mutual viewership...
if (user.videoMutual && !this.webcam.active) {
if ((user.video & this.VideoFlag.MutualRequired) && !this.webcam.active) {
this.ChatClient(
`<strong>${user.username}</strong> has requested that you should share your own camera too before opening theirs.`
);
@ -1155,6 +1164,8 @@ const app = Vue.createApp({
if (name === "offerer") {
// We are closing another user's video stream.
delete (this.WebRTC.streams[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
if (this.WebRTC.pc[username] != undefined && this.WebRTC.pc[username].offerer != undefined) {
this.WebRTC.pc[username].offerer.close();
delete (this.WebRTC.pc[username]);
@ -1183,6 +1194,8 @@ const app = Vue.createApp({
this.WebRTC.pc[username].answerer.close();
}
delete (this.WebRTC.pc[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
}
// Inform backend we have closed it.
@ -1193,16 +1206,33 @@ const app = Vue.createApp({
// and then we turn ours off: we should unfollow the ones with mutual video.
for (let row of this.whoList) {
let username = row.username;
if (row.videoMutual && this.WebRTC.pc[username] != undefined) {
if ((row.video & this.VideoFlag.MutualRequired) && this.WebRTC.pc[username] != undefined) {
this.closeVideo(username);
}
}
},
webcamIconClass(user) {
// Return the icon to show on a video button.
// - Usually a video icon
// - May be a crossed-out video if isVideoNotAllowed
// - Or an eyeball for cameras already opened
if (user.username === this.username && this.webcam.active) {
return 'fa-eye'; // user sees their own self camera always
}
// Already opened?
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) {
return 'fa-eye';
}
if (this.isVideoNotAllowed(user)) return 'fa-video-slash';
return 'fa-video';
},
isVideoNotAllowed(user) {
// Returns whether the video button to open a user's cam will be not allowed (crossed out)
// Mutual video sharing is required on this camera, and ours is not active
if (user.videoActive && user.videoMutual && !this.webcam.active) {
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired) && !this.webcam.active) {
return true;
}
@ -1263,6 +1293,7 @@ const app = Vue.createApp({
this.webcam.elem.srcObject = null;
this.webcam.stream = null;
this.webcam.active = false;
this.webcam.muted = false;
this.whoTab = "online";
// Close all WebRTC sessions.
@ -1283,6 +1314,16 @@ const app = Vue.createApp({
this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = !this.webcam.muted;
});
// Communicate our local mute to others.
this.sendMe();
},
isSourceMuted(username) {
// See if the webcam broadcaster muted their mic at the source
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.Muted) {
return true;
}
return false;
},
isMuted(username) {
return this.WebRTC.muted[username] === true;

View File

@ -544,12 +544,26 @@
<a :href="'#'+c.channel"
@click.prevent="setChannel(c.channel)"
:class="{'is-active': c.channel == channel}">
[[c.name]]
<span v-if="hasUnread(c.channel)"
class="tag is-danger">
[[hasUnread(c.channel)]]
</span>
<div class="columns is-mobile">
<!-- Avatar URL if available (copied from Who List) -->
<div class="column is-narrow pr-0" style="position: relative">
<img v-if="avatarForUsername(normalizeUsername(c.channel))" :src="avatarForUsername(normalizeUsername(c.channel))"
width="24" height="24" alt="">
<img v-else src="/static/img/shy.png"
width="24" height="24">
</div>
<div class="column">
[[c.name]]
<span v-if="hasUnread(c.channel)"
class="tag is-danger">
[[hasUnread(c.channel)]]
</span>
</div>
</div>
</a>
</li>
</ul>
@ -621,6 +635,8 @@
</video>
<div class="caption">
<i class="fa fa-microphone-slash mr-1 has-text-grey"
v-if="webcam.muted"></i>
[[username]]
</div>
@ -659,6 +675,8 @@
autoplay>
</video>
<div class="caption">
<i class="fa fa-microphone-slash mr-1 has-text-grey"
v-if="isSourceMuted(username)"></i>
[[username]]
</div>
<div class="close">
@ -1022,19 +1040,19 @@
<!-- Video button -->
<button type="button" class="button is-small px-2 py-1"
:disabled="!u.videoActive"
:disabled="!(u.video & VideoFlag.Active)"
:class="{
'is-danger is-outlined': u.videoActive && u.nsfw,
'is-info is-outlined': u.videoActive && !u.nsfw,
'is-danger is-outlined': (u.video & VideoFlag.Active) && (u.video & VideoFlag.NSFW),
'is-info is-outlined': (u.video & VideoFlag.Active) && !(u.video & VideoFlag.NSFW),
'cursor-notallowed': isVideoNotAllowed(u),
}"
:title="`Open video stream` +
(u.videoActive && u.videoMutual ? '; mutual video sharing required' : '') +
(u.videoActive && u.videoMutualOpen ? '; will auto-open your video' : '')"
(u.video & VideoFlag.MutualRequired ? '; mutual video sharing required' : '') +
(u.video & VideoFlag.MutualOpen ? '; will auto-open your video' : '')"
@click="openVideo(u)">
<i class="fa"
:class="isVideoNotAllowed(u) ? 'fa-video-slash' : 'fa-video'"></i>
:class="webcamIconClass(u)"></i>
</button>
</div>
</div>