From 4f93c276514b0df61a81a3d7647572077f7db9c6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 26 Jan 2023 20:34:58 -0800 Subject: [PATCH] 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. --- cmd/BareRTC/main.go | 5 + pkg/handlers.go | 77 +++++++++ pkg/messages.go | 22 ++- pkg/websocket.go | 117 ++++++++++--- web/static/css/chat.css | 102 +++++++++++ web/static/js/BareRTC.js | 164 +++++++++++++++--- web/templates/chat-backup.html | 186 ++++++++++++++++++++ web/templates/chat.html | 305 ++++++++++++++++++++------------- 8 files changed, 800 insertions(+), 178 deletions(-) create mode 100644 pkg/handlers.go create mode 100644 web/static/css/chat.css create mode 100644 web/templates/chat-backup.html diff --git a/cmd/BareRTC/main.go b/cmd/BareRTC/main.go index 39c0bcb..2e77177 100644 --- a/cmd/BareRTC/main.go +++ b/cmd/BareRTC/main.go @@ -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)) diff --git a/pkg/handlers.go b/pkg/handlers.go new file mode 100644 index 0000000..146cc18 --- /dev/null +++ b/pkg/handlers.go @@ -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() +} diff --git a/pkg/messages.go b/pkg/messages.go index 2b9cfe1..d290527 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -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"` +} diff --git a/pkg/websocket.go b/pkg/websocket.go index 9ddf9ca..ca64450 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -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() diff --git a/web/static/css/chat.css b/web/static/css/chat.css new file mode 100644 index 0000000..ea424ff --- /dev/null +++ b/web/static/css/chat.css @@ -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; +} \ No newline at end of file diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 92551b2..5eb7ad2 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -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, //