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" "time"
barertc "git.kirsle.net/apps/barertc/pkg" barertc "git.kirsle.net/apps/barertc/pkg"
"git.kirsle.net/apps/barertc/pkg/log"
) )
func init() { func init() {
@ -22,6 +23,10 @@ func main() {
flag.StringVar(&address, "address", ":9000", "Address to listen on, like localhost:5000 or :8080") flag.StringVar(&address, "address", ":9000", "Address to listen on, like localhost:5000 or :8080")
flag.Parse() flag.Parse()
if debug {
log.SetDebug(true)
}
app := barertc.NewServer() app := barertc.NewServer()
app.Setup() app.Setup()
panic(app.ListenAndServe(address)) 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"` Action string `json:"action,omitempty"`
Username string `json:"username"` Username string `json:"username"`
Message string `json:"message"` 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 ( const (
// Actions sent by the client side only
ActionLogin = "login" // post the username to backend ActionLogin = "login" // post the username to backend
// Actions sent by server or client
ActionMessage = "message" // post a message to the room 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. // Subscriber represents a connected WebSocket session.
type Subscriber struct { type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string Username string
VideoActive bool
conn *websocket.Conn conn *websocket.Conn
ctx context.Context ctx context.Context
messages chan []byte messages chan []byte
@ -27,6 +30,13 @@ func (sub *Subscriber) ReadLoop(s *Server) {
msgType, data, err := sub.conn.Read(sub.ctx) msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil { if err != nil {
log.Error("ReadLoop error: %+v", err) 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 return
} }
@ -37,6 +47,7 @@ func (sub *Subscriber) ReadLoop(s *Server) {
// Read the user's posted message. // Read the user's posted message.
var msg Message var msg Message
log.Debug("Read(%s): %s", sub.Username, data)
if err := json.Unmarshal(data, &msg); err != nil { if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Message error: %s", err) log.Error("Message error: %s", err)
continue continue
@ -45,28 +56,14 @@ func (sub *Subscriber) ReadLoop(s *Server) {
// What action are they performing? // What action are they performing?
switch msg.Action { switch msg.Action {
case ActionLogin: case ActionLogin:
// TODO: ensure unique? s.OnLogin(sub, msg)
sub.Username = msg.Username
s.Broadcast(Message{
Username: msg.Username,
Message: "has joined the room!",
})
case ActionMessage: case ActionMessage:
if sub.Username == "" { s.OnMessage(sub, msg)
sub.SendJSON(Message{ case ActionMe:
Username: "ChatServer", s.OnMe(sub, msg)
Message: "You must log in first.",
})
continue
}
// Broadcast a chat message to the room.
s.Broadcast(Message{
Username: sub.Username,
Message: msg.Message,
})
default: default:
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: ActionMessage,
Username: "ChatServer", Username: "ChatServer",
Message: "Unsupported message type.", Message: "Unsupported message type.",
}) })
@ -81,9 +78,19 @@ func (sub *Subscriber) SendJSON(v interface{}) error {
if err != nil { if err != nil {
return err return err
} }
log.Debug("SendJSON(%s): %s", sub.Username, data)
return sub.conn.Write(sub.ctx, websocket.MessageText, 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. // WebSocket handles the /ws websocket connection.
func (s *Server) WebSocket() http.HandlerFunc { func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -112,7 +119,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
} }
s.AddSubscriber(sub) s.AddSubscriber(sub)
defer s.DeleteSubscriber(sub) // defer s.DeleteSubscriber(sub)
go sub.ReadLoop(s) go sub.ReadLoop(s)
for { 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. // AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) { func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: %s", sub.ID)
s.subscribersMu.Lock() s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{} s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock() s.subscribersMu.Unlock()
@ -139,23 +154,73 @@ func (s *Server) AddSubscriber(sub *Subscriber) {
// DeleteSubscriber removes a subscriber from the server. // DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) { func (s *Server) DeleteSubscriber(sub *Subscriber) {
log.Error("DeleteSubscriber: %s", sub.Username)
s.subscribersMu.Lock() s.subscribersMu.Lock()
delete(s.subscribers, sub) delete(s.subscribers, sub)
s.subscribersMu.Unlock() s.subscribersMu.Unlock()
} }
// Broadcast a message to the chat room. // IterSubscribers loops over the subscriber list with a read lock. If the
func (s *Server) Broadcast(msg Message) { // 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() s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock() defer s.subscribersMu.RUnlock()
}
for sub := range s.subscribers { 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{ sub.SendJSON(Message{
Action: msg.Action,
Username: msg.Username, Username: msg.Username,
Message: msg.Message, 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 { func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() 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 { return {
busy: false, busy: false,
username: "", channel: "lobby",
username: "", //"test",
message: "", message: "",
// WebSocket connection. // WebSocket connection.
@ -15,8 +16,20 @@ const app = Vue.createApp({
connected: false, 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. // Chat history.
history: [], history: [],
historyScrollbox: null,
DMs: {}, DMs: {},
loginModal: { loginModal: {
@ -25,13 +38,15 @@ const app = Vue.createApp({
} }
}, },
mounted() { mounted() {
this.pushHistory({ this.webcam.elem = document.querySelector("#localVideo");
username: "ChatServer", this.historyScrollbox = document.querySelector("#chatHistory");
message: "Welcome to BareRTC!",
}); this.ChatServer("Welcome to BareRTC!")
if (!this.username) { if (!this.username) {
this.loginModal.visible = true; this.loginModal.visible = true;
} else {
this.signIn();
} }
}, },
methods: { methods: {
@ -46,10 +61,7 @@ const app = Vue.createApp({
} }
if (!this.ws.connected) { if (!this.ws.connected) {
this.pushHistory({ this.ChatClient("You are not connected to the server.");
username: "ChatClient",
message: "You are not connected to the server.",
});
return; return;
} }
@ -62,32 +74,49 @@ const app = Vue.createApp({
this.message = ""; 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 the WebSocket connection.
dial() { dial() {
const conn = new WebSocket(`ws://${location.host}/ws`); const conn = new WebSocket(`ws://${location.host}/ws`);
conn.addEventListener("close", ev => { conn.addEventListener("close", ev => {
this.ws.connected = false; this.ws.connected = false;
this.pushHistory({ this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
username: "ChatClient",
message: `WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`,
});
if (ev.code !== 1001) { if (ev.code !== 1001) {
this.pushHistory({ this.ChatClient("Reconnecting in 1s");
username: "ChatClient",
message: "Reconnecting in 1s",
});
setTimeout(this.dial, 1000); setTimeout(this.dial, 1000);
} }
}); });
conn.addEventListener("open", ev => { conn.addEventListener("open", ev => {
this.ws.connected = true; this.ws.connected = true;
this.pushHistory({ this.ChatClient("Websocket connected!");
username: "ChatClient",
message: "Websocket connected!",
});
// Tell the server our username. // Tell the server our username.
this.ws.conn.send(JSON.stringify({ this.ws.conn.send(JSON.stringify({
@ -104,22 +133,101 @@ const app = Vue.createApp({
} }
let msg = JSON.parse(ev.data); 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({ this.pushHistory({
action: msg.action,
username: msg.username, username: msg.username,
message: msg.message, message: msg.message,
}); });
break;
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
}
}); });
this.ws.conn = conn; 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({ this.history.push({
action: action,
username: username, username: username,
message: message, 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"> <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" 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" 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> <title>BareRTC</title>
</head> </head>
<body> <body>
<div id="BareRTC-App"> <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 --> <!-- Sign In modal -->
<div class="modal" :class="{'is-active': loginModal.visible}"> <div class="modal" :class="{'is-active': loginModal.visible}">
<div class="modal-background"></div> <div class="modal-background"></div>
@ -109,10 +44,9 @@
</div> </div>
</div> </div>
<div class="block p-4"> <div class="chat-container">
<div class="columns"> <div class="left-column">
<div class="column is-one-fifth"> <div class="card grid-card">
<div class="card">
<header class="card-header has-background-success-dark"> <header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
Channels Channels
@ -135,6 +69,24 @@
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#">Chat Room</a></li> <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>
<li><a href="#">DMs</a></li>
</ul> </ul>
</aside> </aside>
@ -142,39 +94,146 @@
</div> </div>
</div> </div>
<div class="column"> <div class="chat-column">
<div class="card"> <div class="card grid-card">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
Chat Room Chat Room
</p> </p>
</header> </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 v-for="(msg, i) in history" v-bind:key="i">
<div> <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>
<div v-if="msg.action === 'presence'">
<em>[[msg.message]]</em>
</div>
<div v-else>
[[msg.message]] [[msg.message]]
</div> </div>
</div>
</div> </div>
</div> </div>
</div>
<div class="chat-footer">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content p-2">
<div class="columns">
<div class="column">
<form @submit.prevent="sendMessage()"> <form @submit.prevent="sendMessage()">
<input type="text" class="input" <input type="text" class="input"
v-model="message" v-model="message"
placeholder="Message"> placeholder="Message">
</form> </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>
</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> </div>
<h1>Hello</h1>
</div> </div>
</div><!-- /app --> </div><!-- /app -->