From 6cc1628e77e669b7b1a2e896017c355ca8b4492c Mon Sep 17 00:00:00 2001 From: Kato Twofold Date: Tue, 7 Jun 2022 00:02:31 +0300 Subject: [PATCH] Real time WebSocket pasting + Pad update moved to sockets + Pad status updates instantly update TODO: Fix live text highlight --- go.mod | 1 + go.sum | 2 + lib/socketmanager/socketmanager.go | 136 ++++++++++++++++++++++--- static/js/ws.js | 153 ++++++++++++++++++++++++++++- templates/pages/page.html | 7 +- 5 files changed, 278 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 43ef10f..28bb13b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/gin-gonic/gin v1.7.7 + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/joho/godotenv v1.4.0 github.com/mrz1836/go-sanitize v1.1.5 diff --git a/go.sum b/go.sum index 38f7d7f..149ae52 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= diff --git a/lib/socketmanager/socketmanager.go b/lib/socketmanager/socketmanager.go index f836ad2..b7d0b98 100644 --- a/lib/socketmanager/socketmanager.go +++ b/lib/socketmanager/socketmanager.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" + "github.com/JustKato/FreePad/lib/objects" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/gorilla/websocket" ) @@ -15,6 +17,9 @@ var wsUpgrader = websocket.Upgrader{ WriteBufferSize: 1024, // TODO: Make it configurable via the .env file } +// The pad socket map caches all of the existing sockets +var padSocketMap map[string]map[string]*websocket.Conn = make(map[string]map[string]*websocket.Conn) + // TODO: Use generics so that we can take string messages, that'd be nice! type SocketMessage struct { EventType string `json:"eventType"` @@ -25,32 +30,54 @@ type SocketMessage struct { // Bind the websockets to the gin router func BindSocket(router *gin.RouterGroup) { - router.GET("/get", func(ctx *gin.Context) { - webSocketUpgrade(ctx.Writer, ctx.Request) + router.GET("/get/:pad", func(ctx *gin.Context) { + // Get the name of the pad to assign to this socket + padName := ctx.Param("pad") + // Upgrade the socket connection + webSocketUpgrade(ctx.Writer, ctx.Request, padName) }) } -func webSocketUpgrade(w http.ResponseWriter, r *http.Request) { +func webSocketUpgrade(w http.ResponseWriter, r *http.Request, padName string) { conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { fmt.Printf("Failed to set websocket upgrade: %v\n", err) return } + // Check if we have any sockets in this padName + if _, ok := padSocketMap[padName]; !ok { + // Initialize a new map of sockets + padSocketMap[padName] = make(map[string]*websocket.Conn) + } + + // Give this socket a token + socketToken := uuid.NewString() + + // Set the current connection at the socket Token position + padSocketMap[padName][socketToken] = conn + + // Somone just connected + UpdatePadStatus(padName) + // Start listening to this socket for { // Try Read the JSON input from the socket _, msg, err := conn.ReadMessage() - // Check if a close request was sent - if errors.Is(err, websocket.ErrCloseSent) { + // Check if anything but a read limit was created + if err != nil && !errors.Is(err, websocket.ErrReadLimit) { + // Remove self from the cache + delete(padSocketMap[padName], socketToken) + // Somone just disconnected + UpdatePadStatus(padName) break } if err != nil { // There has been an error reading the message - fmt.Println("Failed to read from the socket") + fmt.Println("Failed to read from the socket but probably still connected") // Skip this cycle continue } @@ -67,19 +94,104 @@ func webSocketUpgrade(w http.ResponseWriter, r *http.Request) { } // Pass the message to the proper handlers - - handleSocketMessage(p) + handleSocketMessage(p, socketToken, padName) } } // Handle the socket's message -func handleSocketMessage(msg SocketMessage) { +func handleSocketMessage(msg SocketMessage, socketToken string, padName string) { - // Check the type of message - fmt.Println(msg.EventType) + // Check if this is a pad Update + if msg.EventType == `padUpdate` { + handlePadUpdate(msg, socketToken, padName) + + // Serialize the message + serialized, err := json.Marshal(msg) + // Check if there was an error + if err != nil { + fmt.Println(`Failed to broadcast the padUpdate`, err) + // Stop the execution + return + } + + // Alert all the other pads other than this one. + for k, pad := range padSocketMap[padName] { + // Check if this is the same socket. + if k == socketToken { + // Skip self + continue + } + + // Send the message to the others. + pad.WriteMessage(websocket.TextMessage, serialized) + } + } } -func BroadcastMessage(padName string, message string) { +func handlePadUpdate(msg SocketMessage, socketToken string, padName string) { + + // Check if the msg content is valid + if _, ok := msg.Message[`content`]; !ok { + fmt.Printf("Failed to update pad %s, invalid message\n", padName) + return + } + + // Check that the content is string + newPadContent, ok := msg.Message[`content`].(string) + if !ok { + fmt.Printf("Type assertion failed for %s, invalid message\n", padName) + return + } + + // Get the pad + pad := objects.GetPost(padName, false) + // Update the pad contents + pad.Content = newPadContent + + // Save to file + objects.WritePost(pad) +} + +// Update the current users of the pad about the amount of live viewers. +func UpdatePadStatus(padName string) { + + // Grab info about the map's key + sockets, ok := padSocketMap[padName] + // Check if the pad is set and has sockets connected. + if !ok || len(sockets) < 1 { + // Quit + return + } + + // Generate the message + msg := SocketMessage{ + EventType: `statusUpdate`, + PadName: padName, + Message: gin.H{ + // Send the current amount of live viewers + "currentViewers": len(sockets), + }, + } + + BroadcastMessage(padName, msg) + +} + +func BroadcastMessage(padName string, msg SocketMessage) { + + // Grab info about the map's key + sockets, ok := padSocketMap[padName] + // Check if the pad is set and has sockets connected. + if !ok || len(sockets) < 1 { + // Quit + return + } + + // Get all the participants of the pad group + for _, s := range sockets { + // Send the message to the socket + s.WriteJSON(msg) + } } diff --git a/static/js/ws.js b/static/js/ws.js index 803cd37..c920897 100644 --- a/static/js/ws.js +++ b/static/js/ws.js @@ -1,19 +1,38 @@ class PadSocket { + /** + * @type {WebSocket} + */ ws = null; + /** + * @type {String} + */ padName = null; + /** + * The actual textarea you write in + * @type {HTMLTextAreaElement} + */ + padContents = null; + /** + * The of the preview + * @type {HTMLElement} + */ + padPreview = null; + /** * Create a new PadSocket * @param {string} padName The name of the pad * @param {string} connUrl The URL to the websocket */ constructor(padName, connUrl = null) { + // Assign the pad name + this.padName = padName; // Check if a connection URL was mentioned if ( connUrl == null ) { // Try and connect to the local websocket - connUrl = `ws://` + window.location.host + "/ws/get"; + connUrl = `ws://` + window.location.host + `/ws/get/${padName}`; } // Connect to the websocket @@ -22,10 +41,20 @@ class PadSocket { // Bind the onMessage function ws.onmessage = this.handleMessage; + ws.onopen = () => { + updateStatus(`Established`, `text-success`); + } + + // Try and reconnect on failure + ws.onclose = connectSocket; + ws.onerror = connectSocket; + // Assign the websocket this.ws = ws; - // Assign the pad name - this.padName = padName; + + // Get all relevant references from the HTML + this.padContents = document.getElementById(`pad-content`); + this.padPreview = document.getElementById(`textarea-preview`); } /** @@ -56,8 +85,122 @@ class PadSocket { } + /** + * Handle the message from the socket based on the message type + * @param {MessageEvent} e The websocket message + */ handleMessage = ev => { - console.log(ev); + updateStatus(`Catching Message`, `text-white`); + + // Check if the message has valid data + if ( !!ev.data ) { + // Try and parse the data + let parsedData = null; + + try { + parsedData = JSON.parse(ev.data); + } catch ( err ) { + console.error(`Failed to parse the WebSocket data`,err); + updateStatus(`Parse Fail`, `text-warning`); + } + + if ( !!!parsedData['message'] ) { + console.error(`Failed to find the message`) + updateStatus(`Message Fail`, `text-warning`); + return; + } + + // Check if this is a pad Content Update + if ( parsedData['eventType'] === `padUpdate`) { + // Pass on the parsed data + this.onPadUpdate(parsedData); + } // Check if this is a pad Status Update + else if ( parsedData['eventType'] === `statusUpdate`) { + // Pass on the parsed data + this.onStatusUpdate(parsedData); + } + + updateStatus(`Established`, `text-success`); + } + } + + /** + * Whenever a pad update is trigered, run this function + * @param {Object} The response from the server + */ + onPadUpdate = data => { + // Check that the content is clear + if ( !!data['message']['content'] ) { + // Send over the new content to be updated. + updatePadContent(data['message']['content']); + } + } + + onStatusUpdate = data => { + // Check that the content is clear + if ( !!data['message']['currentViewers'] ) { + // Get the amount of viewers reported by the server + const viewerCount = Number(data['message']['currentViewers']); + // Check if this is a valid number + if ( Number.isNaN(viewerCount) ) { + // Looks like this is a malformed message + return console.error(`Malformed Message`, data); + } + + // Send over the new content to be updated. + updatePadViewers(viewerCount); + } + } + + /** + * Sending a pad update for each keystroke to the server. + * @param {String} msg The new contents of the pad + */ + sendPadUpdate = msg => { + // Get the contents of the pad + const padContents = this.padContents.value; + + // Send the data over the webSocket + this.sendMessage(`padUpdate`, { + "content": padContents, + }); + } + +} + +/** + * Update the contents of the pad + * @param {String} newContent + */ +function updatePadContent(newContent) { + // Update the textarea + document.getElementById(`pad-content`).value = newContent; + // Update the preview + document.getElementById(`textarea-preview`).innerHTML = newContent; + // TODO: Re-run the syntax highlight + +} + +function updatePadViewers(vc) { + // Get the reference to the viewers count inputElement + /** + * @type {HTMLInputElement} + */ + const viewerCount = document.getElementById(`currentViewers`); + + // Get the amount of total viewers + const totalViews = viewerCount.value.split("|")[1].trim(); + + // Set back the real value + viewerCount.value = `${vc} | ${totalViews}`; +} + +function connectSocket() { + // Check if the socket is established + if ( !!!window.socket || window.socket.readyState !== WebSocket.OPEN ) { + updateStatus(`Connecting...`, `text-warning`); + // Connect the socket + window.socket = new PadSocket(padTitle); } } @@ -65,5 +208,5 @@ class PadSocket { // TODO: Test if this is actually necessary or the DOMContentLoaded event would suffice // wait for the whole window to load window.addEventListener(`load`, e => { - window.socket = new PadSocket(padTitle); + connectSocket() }) \ No newline at end of file diff --git a/templates/pages/page.html b/templates/pages/page.html index 7870dcf..a92f21d 100644 --- a/templates/pages/page.html +++ b/templates/pages/page.html @@ -53,8 +53,7 @@
- @@ -73,7 +72,7 @@ -
+
- +