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). * 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. * 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 # WebSocket Message Actions
Every message has an "action" and may have other fields depending on the action type. 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 // Client Me
{ {
"action": "me", "action": "me",
"videoActive": true, "video": 1,
"nsfw": false
} }
``` ```
@ -213,7 +228,7 @@ The server may also push "me" messages to the user: for example if there is a co
{ {
"action": "me", "action": "me",
"username": "soandso 12345", "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 "op": false, // operator status
"avatar": "/picture/soandso.png", "avatar": "/picture/soandso.png",
"profileURL": "/u/soandso", "profileURL": "/u/soandso",
"videoActive": true, "video": 0,
"nsfW": false
} }
] ]
} }

View File

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

View File

@ -1,9 +1,12 @@
package barertc package barertc
import ( import (
"fmt"
"os"
"strconv" "strconv"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
"github.com/mattn/go-shellwords" "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) sub.ChatServer("/nsfw: username not found: %s", username)
} else { } else {
other.ChatServer("Your camera has been marked as NSFW by %s", sub.Username) other.ChatServer("Your camera has been marked as NSFW by %s", sub.Username)
other.VideoNSFW = true other.VideoStatus |= VideoFlagNSFW
other.SendMe() other.SendMe()
s.SendWhoList() s.SendWhoList()
sub.ChatServer("%s has their camera marked as NSFW", username) 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" + sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" +
"* `/kick <username>` to kick from chat\n" + "* `/kick <username>` to kick from chat\n" +
"* `/nsfw <username>` to mark their camera NSFW\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" + "* `/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\"`", "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 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. // 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. // BanCommand handles the `/ban` operator command.
func (s *Server) BanCommand(words []string, sub *Subscriber) { func (s *Server) BanCommand(words []string, sub *Subscriber) {
if len(words) == 1 { if len(words) == 1 {

View File

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

View File

@ -252,7 +252,7 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) {
// OnMe handles current user state updates. // OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg Message) { 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) 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" msg.ChatStatus = "away"
} }
sub.VideoActive = msg.VideoActive sub.VideoStatus = msg.VideoStatus
sub.VideoMutual = msg.VideoMutual
sub.VideoMutualOpen = msg.VideoMutualOpen
sub.VideoNSFW = msg.NSFW
sub.ChatStatus = msg.ChatStatus sub.ChatStatus = msg.ChatStatus
// Sync the WhoList to everybody. // Sync the WhoList to everybody.

View File

@ -23,11 +23,8 @@ type Message struct {
WhoList []WhoList `json:"whoList,omitempty"` WhoList []WhoList `json:"whoList,omitempty"`
// Sent on `me` actions along with Username // Sent on `me` actions along with Username
VideoActive bool `json:"videoActive,omitempty"` // user tells us their cam status VideoStatus int `json:"video,omitempty"` // user video flags
VideoMutual bool `json:"videoMutual,omitempty"` // user wants mutual viewers ChatStatus string `json:"status,omitempty"` // online vs. away
VideoMutualOpen bool `json:"videoMutualOpen,omitempty"`
ChatStatus string `json:"status,omitempty"` // online vs. away
NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW
// Message ID to support takebacks/local deletions // Message ID to support takebacks/local deletions
MessageID int `json:"msgID,omitempty"` MessageID int `json:"msgID,omitempty"`
@ -75,16 +72,24 @@ const (
// WhoList is a member entry in the chat room. // WhoList is a member entry in the chat room.
type WhoList struct { type WhoList struct {
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname,omitempty"` Nickname string `json:"nickname,omitempty"`
VideoActive bool `json:"videoActive,omitempty"` Status string `json:"status"`
VideoMutual bool `json:"videoMutual,omitempty"` Video int `json:"video"`
VideoMutualOpen bool `json:"videoMutualOpen,omitempty"`
NSFW bool `json:"nsfw,omitempty"`
Status string `json:"status"`
// JWT auth extra settings. // JWT auth extra settings.
Operator bool `json:"op"` Operator bool `json:"op"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
ProfileURL string `json:"profileURL,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? // Are we enforcing strict JWT authentication?
if config.Current.JWT.Enabled && config.Current.JWT.Strict && !authOK { 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.WriteHeader(http.StatusForbidden)
w.Write([]byte( w.Write([]byte(
fmt.Sprintf("Authentication denied. Please go back and try again."), "Authentication denied. Please go back and try again.",
)) ))
return return
} }

View File

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

View File

@ -127,6 +127,16 @@ const app = Vue.createApp({
audioDeviceID: null, 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 sessions with other users.
WebRTC: { WebRTC: {
// Streams per username. // Streams per username.
@ -315,6 +325,16 @@ const app = Vue.createApp({
// Returns if the current user has operator rights // Returns if the current user has operator rights
return this.jwt.claims.op; 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: { methods: {
// Load user prefs from localStorage, called on startup // Load user prefs from localStorage, called on startup
@ -375,11 +395,8 @@ const app = Vue.createApp({
sendMe() { sendMe() {
this.ws.conn.send(JSON.stringify({ this.ws.conn.send(JSON.stringify({
action: "me", action: "me",
videoActive: this.webcam.active, video: this.myVideoFlag,
videoMutual: this.webcam.mutual,
videoMutualOpen: this.webcam.mutualOpen,
status: this.status, status: this.status,
nsfw: this.webcam.nsfw,
})); }));
}, },
onMe(msg) { onMe(msg) {
@ -391,11 +408,11 @@ const app = Vue.createApp({
} }
// The server can set our webcam NSFW flag. // The server can set our webcam NSFW flag.
if (this.webcam.nsfw != msg.nsfw) { let myNSFW = this.webcam.nsfw;
this.webcam.nsfw = msg.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. // WhoList updates.
@ -412,14 +429,14 @@ const app = Vue.createApp({
for (let row of this.whoList) { for (let row of this.whoList) {
this.whoMap[row.username] = row; this.whoMap[row.username] = row;
if (this.WebRTC.streams[row.username] != undefined && if (this.WebRTC.streams[row.username] != undefined &&
row.videoActive !== true) { !(row.video & this.VideoFlag.Active)) {
this.closeVideo(row.username, "offerer"); this.closeVideo(row.username, "offerer");
} }
} }
// Has the back-end server forgotten we are on video? This can // Has the back-end server forgotten we are on video? This can
// happen if we disconnect/reconnect while we were streaming. // 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(); this.sendMe();
} }
}, },
@ -473,18 +490,10 @@ const app = Vue.createApp({
}, },
onOpen(msg) { onOpen(msg) {
// Response for the opener to begin WebRTC connection. // 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); this.startWebRTC(msg.username, true);
}, },
onRing(msg) { 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.ChatServer(`${msg.username} has opened your camera.`);
this.startWebRTC(msg.username, false); this.startWebRTC(msg.username, false);
}, },
onUserExited(msg) { onUserExited(msg) {
@ -768,7 +777,7 @@ const app = Vue.createApp({
// If we are the offerer, and this member wants to auto-open our camera // If we are the offerer, and this member wants to auto-open our camera
// then add our own stream to the connection. // 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; let stream = this.webcam.stream;
stream.getTracks().forEach(track => { stream.getTracks().forEach(track => {
pc.addTrack(track, stream) pc.addTrack(track, stream)
@ -1113,7 +1122,7 @@ const app = Vue.createApp({
// Is the target user NSFW? Go thru the modal. // Is the target user NSFW? Go thru the modal.
let dontShowAgain = localStorage["skip-nsfw-modal"] == "true"; 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.user = user;
this.nsfwModalView.visible = true; this.nsfwModalView.visible = true;
return; return;
@ -1130,7 +1139,7 @@ const app = Vue.createApp({
} }
// If this user requests mutual viewership... // If this user requests mutual viewership...
if (user.videoMutual && !this.webcam.active) { if ((user.video & this.VideoFlag.MutualRequired) && !this.webcam.active) {
this.ChatClient( this.ChatClient(
`<strong>${user.username}</strong> has requested that you should share your own camera too before opening theirs.` `<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") { if (name === "offerer") {
// We are closing another user's video stream. // We are closing another user's video stream.
delete (this.WebRTC.streams[username]); 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) { if (this.WebRTC.pc[username] != undefined && this.WebRTC.pc[username].offerer != undefined) {
this.WebRTC.pc[username].offerer.close(); this.WebRTC.pc[username].offerer.close();
delete (this.WebRTC.pc[username]); delete (this.WebRTC.pc[username]);
@ -1183,6 +1194,8 @@ const app = Vue.createApp({
this.WebRTC.pc[username].answerer.close(); this.WebRTC.pc[username].answerer.close();
} }
delete (this.WebRTC.pc[username]); delete (this.WebRTC.pc[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
} }
// Inform backend we have closed it. // 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. // and then we turn ours off: we should unfollow the ones with mutual video.
for (let row of this.whoList) { for (let row of this.whoList) {
let username = row.username; 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); 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) { isVideoNotAllowed(user) {
// Returns whether the video button to open a user's cam will be not allowed (crossed out) // 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 // 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; return true;
} }
@ -1263,6 +1293,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.webcam.muted = false;
this.whoTab = "online"; this.whoTab = "online";
// Close all WebRTC sessions. // Close all WebRTC sessions.
@ -1283,6 +1314,16 @@ const app = Vue.createApp({
this.webcam.stream.getAudioTracks().forEach(track => { this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = !this.webcam.muted; 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) { isMuted(username) {
return this.WebRTC.muted[username] === true; return this.WebRTC.muted[username] === true;

View File

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