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.
ipad-testing
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 (
ActionLogin = "login" // post the username to backend
// 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,11 +13,14 @@ import (
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
Username string
conn *websocket.Conn
ctx context.Context
messages chan []byte
closeSlow func()
// User properties
ID int // ID assigned by server
Username string
VideoActive bool
conn *websocket.Conn
ctx context.Context
messages chan []byte
closeSlow func()
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
@ -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()
}
// 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.subscribers {
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);
this.pushHistory({
username: msg.username,
message: msg.message,
});
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,72 +44,196 @@
</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
<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
</p>
</header>
<div class="card-content">
<aside class="menu">
<p class="menu-label">
Chat Rooms
</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>
<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 class="menu-label">
Private Messages
</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>
<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>
</div>
</div>
</div>
<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="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"
: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 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 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 -->