Progress on Chat Server

* Reworked full screen CSS layout for the chat.html, still using Bulma
  components with some custom CSS Grid.
* Duplicate username handling: server can push a new username to change
  the client's selection.
* Who List sync between clients.
* Local video casting works so far - plays back your camera in the local
  feed. Your video broadcasting boolean is synced to backend, which
  lights up the video button in the Who List.
This commit is contained in:
Noah 2023-01-26 20:34:58 -08:00
parent b627fe0ffa
commit 4f93c27651
8 changed files with 800 additions and 178 deletions

View File

@ -6,6 +6,7 @@ import (
"time"
barertc "git.kirsle.net/apps/barertc/pkg"
"git.kirsle.net/apps/barertc/pkg/log"
)
func init() {
@ -22,6 +23,10 @@ func main() {
flag.StringVar(&address, "address", ":9000", "Address to listen on, like localhost:5000 or :8080")
flag.Parse()
if debug {
log.SetDebug(true)
}
app := barertc.NewServer()
app.Setup()
panic(app.ListenAndServe(address))

77
pkg/handlers.go Normal file
View File

@ -0,0 +1,77 @@
package barertc
import (
"fmt"
"time"
"git.kirsle.net/apps/barertc/pkg/log"
)
// OnLogin handles "login" actions from the client.
func (s *Server) OnLogin(sub *Subscriber, msg Message) {
// Ensure the username is unique, or rename it.
var duplicate bool
for other := range s.IterSubscribers() {
if other.ID != sub.ID && other.Username == msg.Username {
duplicate = true
break
}
}
if duplicate {
// Give them one that is unique.
msg.Username = fmt.Sprintf("%s %d",
msg.Username,
time.Now().Nanosecond(),
)
}
// Use their username.
sub.Username = msg.Username
log.Debug("OnLogin: %s joins the room", sub.Username)
// Tell everyone they joined.
s.Broadcast(Message{
Action: ActionPresence,
Username: msg.Username,
Message: "has joined the room!",
})
// Send the user back their settings.
sub.SendMe()
// Send the WhoList to everybody.
s.SendWhoList()
}
// OnMessage handles a chat message posted by the user.
func (s *Server) OnMessage(sub *Subscriber, msg Message) {
log.Info("[%s] %s", sub.Username, msg.Message)
if sub.Username == "" {
sub.SendJSON(Message{
Action: ActionMessage,
Username: "ChatServer",
Message: "You must log in first.",
})
return
}
// Broadcast a chat message to the room.
s.Broadcast(Message{
Action: ActionMessage,
Username: sub.Username,
Message: msg.Message,
})
}
// OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg Message) {
if msg.VideoActive {
log.Debug("User %s turns on their video feed", sub.Username)
}
sub.VideoActive = msg.VideoActive
// Sync the WhoList to everybody.
s.SendWhoList()
}

View File

@ -4,9 +4,29 @@ type Message struct {
Action string `json:"action,omitempty"`
Username string `json:"username"`
Message string `json:"message"`
// WhoList for `who` actions
WhoList []WhoList `json:"whoList"`
// Sent on `me` actions along with Username
VideoActive bool `json:"videoActive"` // user tells us their cam status
}
const (
// Actions sent by the client side only
ActionLogin = "login" // post the username to backend
// Actions sent by server or client
ActionMessage = "message" // post a message to the room
ActionMe = "me" // user self-info sent by FE or BE
// Actions sent by server only
ActionWhoList = "who" // server pushes the Who List
ActionPresence = "presence" // a user joined or left the room
)
// WhoList is a member entry in the chat room.
type WhoList struct {
Username string `json:"username"`
VideoActive bool `json:"videoActive"`
}

View File

@ -13,7 +13,10 @@ import (
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
VideoActive bool
conn *websocket.Conn
ctx context.Context
messages chan []byte
@ -27,6 +30,13 @@ func (sub *Subscriber) ReadLoop(s *Server) {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error: %+v", err)
s.DeleteSubscriber(sub)
s.Broadcast(Message{
Action: ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
})
s.SendWhoList()
return
}
@ -37,6 +47,7 @@ func (sub *Subscriber) ReadLoop(s *Server) {
// Read the user's posted message.
var msg Message
log.Debug("Read(%s): %s", sub.Username, data)
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Message error: %s", err)
continue
@ -45,28 +56,14 @@ func (sub *Subscriber) ReadLoop(s *Server) {
// What action are they performing?
switch msg.Action {
case ActionLogin:
// TODO: ensure unique?
sub.Username = msg.Username
s.Broadcast(Message{
Username: msg.Username,
Message: "has joined the room!",
})
s.OnLogin(sub, msg)
case ActionMessage:
if sub.Username == "" {
sub.SendJSON(Message{
Username: "ChatServer",
Message: "You must log in first.",
})
continue
}
// Broadcast a chat message to the room.
s.Broadcast(Message{
Username: sub.Username,
Message: msg.Message,
})
s.OnMessage(sub, msg)
case ActionMe:
s.OnMe(sub, msg)
default:
sub.SendJSON(Message{
Action: ActionMessage,
Username: "ChatServer",
Message: "Unsupported message type.",
})
@ -81,9 +78,19 @@ func (sub *Subscriber) SendJSON(v interface{}) error {
if err != nil {
return err
}
log.Debug("SendJSON(%s): %s", sub.Username, data)
return sub.conn.Write(sub.ctx, websocket.MessageText, data)
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(Message{
Action: ActionMe,
Username: sub.Username,
VideoActive: sub.VideoActive,
})
}
// WebSocket handles the /ws websocket connection.
func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -112,7 +119,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
}
s.AddSubscriber(sub)
defer s.DeleteSubscriber(sub)
// defer s.DeleteSubscriber(sub)
go sub.ReadLoop(s)
for {
@ -130,8 +137,16 @@ func (s *Server) WebSocket() http.HandlerFunc {
})
}
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: %s", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
@ -139,23 +154,73 @@ func (s *Server) AddSubscriber(sub *Subscriber) {
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
log.Error("DeleteSubscriber: %s", sub.Username)
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg Message) {
// IterSubscribers loops over the subscriber list with a read lock. If the
// caller already holds a lock, pass the optional `true` parameter for isLocked.
func (s *Server) IterSubscribers(isLocked ...bool) chan *Subscriber {
var out = make(chan *Subscriber)
go func() {
log.Debug("IterSubscribers START..")
var result = []*Subscriber{}
// Has the caller already taken the read lock or do we get it?
if locked := len(isLocked) > 0 && isLocked[0]; !locked {
log.Debug("Taking the lock")
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
}
for sub := range s.subscribers {
result = append(result, sub)
}
for _, r := range result {
out <- r
}
close(out)
log.Debug("IterSubscribers STOP!")
}()
return out
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg Message) {
log.Debug("Broadcast: %+v", msg)
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for sub := range s.IterSubscribers(true) {
sub.SendJSON(Message{
Action: msg.Action,
Username: msg.Username,
Message: msg.Message,
})
}
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var users = []WhoList{}
for sub := range s.IterSubscribers() {
users = append(users, WhoList{
Username: sub.Username,
VideoActive: sub.VideoActive,
})
}
for sub := range s.IterSubscribers() {
sub.SendJSON(Message{
Action: ActionWhoList,
WhoList: users,
})
}
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

102
web/static/css/chat.css Normal file
View File

@ -0,0 +1,102 @@
html {
height: 100vh;
}
body {
min-height: 100vh;
}
/************************
* Main CSS Grid Layout *
************************/
.chat-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 0, 0, 0.2);
padding: 10px;
display: grid;
column-gap: 10px;
row-gap: 10px;
grid-template-columns: 260px 1fr 260px;
grid-template-rows: 1fr auto;
}
/* Left column: DMs and channels */
.chat-container > .left-column {
grid-column: 1;
overflow: hidden;
}
/* Main column: chat history */
.chat-container > .chat-column {
grid-column: 2;
grid-row: 1;
background-color: yellow;
overflow: hidden;
}
/* Footer row: message entry box */
.chat-container > .chat-footer {
grid-column: 1 / 4;
grid-row: 2 / 2;
}
/* Right column: Who List */
.chat-container > .right-column {
grid-column: 3;
overflow: hidden;
}
/***********************************************
* Reusable CSS Grid-based Bulma Card layouts *
* with a fixed header, full size scrollable *
* content, and (optionally) video-feeds under *
* the header (main chat card only) *
***********************************************/
.grid-card {
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
}
.grid-card > .card-header {
grid-row: 1;
}
.grid-card > .video-feeds {
grid-row: 2;
}
.grid-card > .card-content {
grid-row: 3;
/* background-color: magenta; */
overflow-y: scroll;
}
/*******************
* Video Feeds CSS *
*******************/
.video-feeds {
background-color: yellow;
width: 100%;
max-width: 100%;
overflow-x: scroll;
display: flex;
align-items: left;
}
.video-feeds > .feed {
flex: 10 0 auto;
width: 120px;
height: 80px;
background-color: black;
margin: 5px;
}

View File

@ -6,7 +6,8 @@ const app = Vue.createApp({
return {
busy: false,
username: "",
channel: "lobby",
username: "", //"test",
message: "",
// WebSocket connection.
@ -15,8 +16,20 @@ const app = Vue.createApp({
connected: false,
},
// Who List for the room.
whoList: [],
// My video feed.
webcam: {
busy: false,
active: false,
elem: null, // <video id="localVideo"> element
stream: null, // MediaStream object
},
// Chat history.
history: [],
historyScrollbox: null,
DMs: {},
loginModal: {
@ -25,13 +38,15 @@ const app = Vue.createApp({
}
},
mounted() {
this.pushHistory({
username: "ChatServer",
message: "Welcome to BareRTC!",
});
this.webcam.elem = document.querySelector("#localVideo");
this.historyScrollbox = document.querySelector("#chatHistory");
this.ChatServer("Welcome to BareRTC!")
if (!this.username) {
this.loginModal.visible = true;
} else {
this.signIn();
}
},
methods: {
@ -46,10 +61,7 @@ const app = Vue.createApp({
}
if (!this.ws.connected) {
this.pushHistory({
username: "ChatClient",
message: "You are not connected to the server.",
});
this.ChatClient("You are not connected to the server.");
return;
}
@ -62,32 +74,49 @@ const app = Vue.createApp({
this.message = "";
},
// Sync the current user state (such as video broadcasting status) to
// the backend, which will reload everybody's Who List.
sendMe() {
this.ws.conn.send(JSON.stringify({
action: "me",
videoActive: this.webcam.active,
}));
},
onMe(msg) {
// We have had settings pushed to us by the server, such as a change
// in our choice of username.
if (this.username != msg.username) {
this.ChatServer(`Your username has been changed to ${msg.username}.`);
}
this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
},
// Handle messages sent in chat.
onMessage(msg) {
this.pushHistory({
username: msg.username,
message: msg.message,
});
},
// Dial the WebSocket connection.
dial() {
const conn = new WebSocket(`ws://${location.host}/ws`);
conn.addEventListener("close", ev => {
this.ws.connected = false;
this.pushHistory({
username: "ChatClient",
message: `WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`,
});
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
if (ev.code !== 1001) {
this.pushHistory({
username: "ChatClient",
message: "Reconnecting in 1s",
});
this.ChatClient("Reconnecting in 1s");
setTimeout(this.dial, 1000);
}
});
conn.addEventListener("open", ev => {
this.ws.connected = true;
this.pushHistory({
username: "ChatClient",
message: "Websocket connected!",
});
this.ChatClient("Websocket connected!");
// Tell the server our username.
this.ws.conn.send(JSON.stringify({
@ -104,22 +133,101 @@ const app = Vue.createApp({
}
let msg = JSON.parse(ev.data);
switch (msg.action) {
case "who":
console.log("Got the Who List: %s", msg);
this.whoList = msg.whoList;
break;
case "me":
console.log("Got a self-update: %s", msg);
this.onMe(msg);
break;
case "message":
this.onMessage(msg);
break;
case "presence":
this.pushHistory({
action: msg.action,
username: msg.username,
message: msg.message,
});
break;
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
}
});
this.ws.conn = conn;
},
pushHistory({username, message}) {
// Start broadcasting my webcam.
startVideo() {
if (this.webcam.busy) return;
this.webcam.busy = true;
navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
}).then(stream => {
this.webcam.active = true;
this.webcam.elem.srcObject = stream;
this.webcam.stream = stream;
// Tell backend the camera is ready.
this.sendMe();
}).catch(err => {
this.ChatClient(`Webcam error: ${err}`);
}).finally(() => {
this.webcam.busy = false;
})
},
// Stop broadcasting.
stopVideo() {
this.webcam.elem.srcObject = null;
this.webcam.stream = null;
this.webcam.active = false;
// Tell backend our camera state.
this.sendMe();
},
pushHistory({username, message, action="message", isChatServer, isChatClient}) {
this.history.push({
action: action,
username: username,
message: message,
isChatServer,
isChatClient,
});
}
this.scrollHistory();
},
scrollHistory() {
window.requestAnimationFrame(() => {
this.historyScrollbox.scroll({
top: this.historyScrollbox.scrollHeight,
behavior: 'smooth',
});
});
},
// Send a chat message as ChatServer
ChatServer(message) {
this.pushHistory({
username: "ChatServer",
message: message,
isChatServer: true,
});
},
ChatClient(message) {
this.pushHistory({
username: "ChatClient",
message: message,
isChatClient: true,
});
},
}
});

View File

@ -0,0 +1,186 @@
{{define "index"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/BareRTC.css?{{CacheHash}}">
<title>BareRTC</title>
</head>
<body>
<div id="BareRTC-App">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
BareRTC
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">
<span class="icon"><i class="fa fa-home"></i></span>
<span>Home</span>
</a>
<a class="navbar-item" href="/about">
About
</a>
<a class="navbar-item" href="/faq">
FAQ
</a>
<div id="navbar-more" class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown is-active">
<a class="navbar-item" href="/about">
<span class="icon"><i class="fa fa-circle-info"></i></span>
<span>About</span>
</a>
<a class="navbar-item" href="/faq">
<span class="icon"><i class="fa fa-circle-question"></i></span>
<span>FAQ</span>
</a>
<a class="navbar-item" href="/tos">
<span class="icon"><i class="fa fa-list"></i></span>
<span>Terms of Service</span>
</a>
<a class="navbar-item" href="/privacy">
<span class="icon"><i class="fa fa-file-shield"></i></span>
<span>Privacy Policy</span>
</a>
<a class="navbar-item" href="/contact">
<span class="icon"><i class="fa fa-message"></i></span>
<span>Contact</span>
</a>
<hr class="navbar-divider">
<a class="navbar-item" href="/contact?intent=report">
<span class="icon"><i class="fa fa-triangle-exclamation"></i></span>
<span>Report an issue</span>
</a>
</div>
</div>
</div>
</div>
</nav>
<!-- Sign In modal -->
<div class="modal" :class="{'is-active': loginModal.visible}">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Sign In</p>
</header>
<div class="card-content">
<form @submit.prevent="signIn()">
<div class="field">
<label class="label">Username</label>
<input class="input"
v-model="username"
placeholder="Username"
autocomplete="off"
autofocus
required>
</div>
<div class="field">
<div class="control">
<button class="button is-link">Submit</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="block p-4">
<div class="columns">
<div class="column is-one-fifth">
<div class="card">
<header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light">
Channels
</p>
</header>
<div class="card-content">
<aside class="menu">
<p class="menu-label">
Chat Rooms
</p>
<ul class="menu-list">
<li><a href="#" class="is-active">Chat Room</a></li>
</ul>
<p class="menu-label">
Private Messages
</p>
<ul class="menu-list">
<li><a href="#">Chat Room</a></li>
<li><a href="#">DMs</a></li>
</ul>
</aside>
</div>
</div>
</div>
<div class="column">
<div class="card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
Chat Room
</p>
</header>
<div class="card-content">
<div v-for="(msg, i) in history" v-bind:key="i">
<div>
<label class="label">[[msg.username]]</label>
</div>
[[msg.message]]
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<form @submit.prevent="sendMessage()">
<input type="text" class="input"
v-model="message"
placeholder="Message">
</form>
</div>
</div>
</div>
</div>
<h1>Hello</h1>
</div>
</div><!-- /app -->
<script src="/static/js/vue-3.2.45.js"></script>
<script src="/static/js/BareRTC.js?{{CacheHash}}"></script>
</body>
</html>
{{end}}

View File

@ -6,76 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/BareRTC.css?{{CacheHash}}">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{CacheHash}}">
<title>BareRTC</title>
</head>
<body>
<div id="BareRTC-App">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
BareRTC
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">
<span class="icon"><i class="fa fa-home"></i></span>
<span>Home</span>
</a>
<a class="navbar-item" href="/about">
About
</a>
<a class="navbar-item" href="/faq">
FAQ
</a>
<div id="navbar-more" class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown is-active">
<a class="navbar-item" href="/about">
<span class="icon"><i class="fa fa-circle-info"></i></span>
<span>About</span>
</a>
<a class="navbar-item" href="/faq">
<span class="icon"><i class="fa fa-circle-question"></i></span>
<span>FAQ</span>
</a>
<a class="navbar-item" href="/tos">
<span class="icon"><i class="fa fa-list"></i></span>
<span>Terms of Service</span>
</a>
<a class="navbar-item" href="/privacy">
<span class="icon"><i class="fa fa-file-shield"></i></span>
<span>Privacy Policy</span>
</a>
<a class="navbar-item" href="/contact">
<span class="icon"><i class="fa fa-message"></i></span>
<span>Contact</span>
</a>
<hr class="navbar-divider">
<a class="navbar-item" href="/contact?intent=report">
<span class="icon"><i class="fa fa-triangle-exclamation"></i></span>
<span>Report an issue</span>
</a>
</div>
</div>
</div>
</div>
</nav>
<!-- Sign In modal -->
<div class="modal" :class="{'is-active': loginModal.visible}">
<div class="modal-background"></div>
@ -109,10 +44,9 @@
</div>
</div>
<div class="block p-4">
<div class="columns">
<div class="column is-one-fifth">
<div class="card">
<div class="chat-container">
<div class="left-column">
<div class="card grid-card">
<header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light">
Channels
@ -135,6 +69,24 @@
<ul class="menu-list">
<li><a href="#">Chat Room</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
</ul>
</aside>
@ -142,39 +94,146 @@
</div>
</div>
<div class="column">
<div class="card">
<div class="chat-column">
<div class="card grid-card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
Chat Room
</p>
</header>
<div class="card-content">
<div class="video-feeds">
<video class="feed"
v-show="webcam.active"
id="localVideo"
autoplay muted>
x
</video>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
</div>
<div class="card-content" id="chatHistory">
<div v-for="(msg, i) in history" v-bind:key="i">
<div>
<label class="label">[[msg.username]]</label>
<label class="label"
:class="{'has-text-success is-dark': msg.isChatServer,
'has-text-warning is-dark': msg.isAdmin,
'has-text-danger': msg.isChatClient}">
[[msg.username]]
</label>
</div>
<div v-if="msg.action === 'presence'">
<em>[[msg.message]]</em>
</div>
<div v-else>
[[msg.message]]
</div>
</div>
</div>
</div>
</div>
<div class="chat-footer">
<div class="card">
<div class="card-content">
<div class="card-content p-2">
<div class="columns">
<div class="column">
<form @submit.prevent="sendMessage()">
<input type="text" class="input"
v-model="message"
placeholder="Message">
</form>
</div>
<div class="column is-narrow">
<button type="button"
v-if="webcam.active"
class="button is-danger"
@click="stopVideo()">
<i class="fa fa-camera mr-2"></i>
Stop
</button>
<button type="button"
v-else
class="button is-success"
@click="startVideo()"
:disabled="webcam.busy">
<i class="fa fa-camera mr-2"></i>
Start
</button>
</div>
</div>
</div>
</div>
</div>
<div class="right-column">
<div class="card grid-card">
<header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light">
Who Is Online
</p>
</header>
<div class="card-content p-2">
<ul class="menu-list">
<li v-for="(u, i) in whoList" v-bind:key="i">
<div class="columns is-mobile">
<div class="column">[[ u.username ]]</div>
<div class="column is-narrow">
<button type="button" class="button is-small"
:disabled="!u.videoActive">
<i class="fa fa-video"></i>
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<h1>Hello</h1>
</div>
</div><!-- /app -->