From b19a4821e43dc93fd5fc90fd555589ad14f1a4b6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 24 Jun 2023 13:08:15 -0700 Subject: [PATCH] Message Takebacks --- Protocol.md | 32 ++++++++++++++++++++++++-- pkg/handlers.go | 49 ++++++++++++++++++++++++++++++++++------ pkg/messages.go | 21 +++++++++++------ pkg/websocket.go | 7 ++++++ web/static/js/BareRTC.js | 48 ++++++++++++++++++++++++++++++++++++++- web/templates/chat.html | 32 ++++++++++++++++++++------ 6 files changed, 165 insertions(+), 24 deletions(-) diff --git a/Protocol.md b/Protocol.md index cc3310b..d862b54 100644 --- a/Protocol.md +++ b/Protocol.md @@ -123,12 +123,16 @@ The server sends a similar message to push chats to the client: "action": "message", "channel": "lobby", "username": "senderName", - "message": "Hello!" + "message": "Hello!", + "msgID": 123 } ``` If the message is a DM, the channel will be the username prepended by an @ symbol and the ChatClient will add it to the appropriate DM thread (creating a new DM thread if needed). +Every message or file share originated from a user has a "msgID" attached +which is useful for [takebacks](#takeback). + ## File Sent by: Client. @@ -146,6 +150,30 @@ The client is posting an image to share in chat. The server will massage and validate the image data and then send it to others in the chat via a normal `message` containing an `` tag with a data: URL - directly passing the image data to other chatters without needing to store it somewhere with a public URL. +## Takeback + +Sent by: Client, Server. + +The takeback message is how a user can delete their previous message from +everybody else's display. Operators may also take back messages sent by +other users. + +```javascript +{ + "action": "takeback", + "msgID": 123 +} +``` + +Every message or file share initiated by a real user (not ChatClient or +ChatServer) is assigned an auto-incrementing message ID, and the chat +server records which message IDs "belong" to which user (so that a +modded chat client or bot can't takeback other peoples' messages without +operator rights). + +When the front-end receives a takeback, it searches all channels to +delete the message with that ID. + ## Presence Sent by: Server. @@ -344,4 +372,4 @@ The `candidate` and `sdp` actions are used as part of WebRTC signaling negotiati } ``` -The server simply proxies the message between the two parties. \ No newline at end of file +The server simply proxies the message between the two parties. diff --git a/pkg/handlers.go b/pkg/handlers.go index 49433a8..1836dec 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -106,12 +106,20 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) { // Detect and expand media such as YouTube videos. markdown = s.ExpandMedia(markdown) + // Assign a message ID and own it to the sender. + MessageID++ + var mid = MessageID + sub.midMu.Lock() + sub.messageIDs[mid] = struct{}{} + sub.midMu.Unlock() + // Message to be echoed to the channel. var message = Message{ - Action: ActionMessage, - Channel: msg.Channel, - Username: sub.Username, - Message: markdown, + Action: ActionMessage, + Channel: msg.Channel, + Username: sub.Username, + Message: markdown, + MessageID: mid, } // Is this a DM? @@ -143,6 +151,25 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) { s.Broadcast(message) } +// OnTakeback handles takebacks (delete your message for everybody) +func (s *Server) OnTakeback(sub *Subscriber, msg Message) { + // Permission check. + if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin { + sub.midMu.Lock() + defer sub.midMu.Unlock() + if _, ok := sub.messageIDs[msg.MessageID]; !ok { + sub.ChatServer("That is not your message to take back.") + return + } + } + + // Broadcast to everybody to remove this message. + s.Broadcast(Message{ + Action: ActionTakeback, + MessageID: msg.MessageID, + }) +} + // OnFile handles a picture shared in chat with a channel. func (s *Server) OnFile(sub *Subscriber, msg Message) { if sub.Username == "" { @@ -172,11 +199,19 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) { img, pvWidth, pvHeight := ProcessImage(filetype, msg.Bytes) var dataURL = fmt.Sprintf("data:%s;base64,%s", filetype, base64.StdEncoding.EncodeToString(img)) + // Assign a message ID and own it to the sender. + MessageID++ + var mid = MessageID + sub.midMu.Lock() + sub.messageIDs[mid] = struct{}{} + sub.midMu.Unlock() + // Message to be echoed to the channel. var message = Message{ - Action: ActionMessage, - Channel: msg.Channel, - Username: sub.Username, + Action: ActionMessage, + Channel: msg.Channel, + Username: sub.Username, + MessageID: mid, // Their image embedded via a data: URI - no server storage needed! Message: fmt.Sprintf( diff --git a/pkg/messages.go b/pkg/messages.go index 78068c9..eba0a12 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -1,5 +1,8 @@ package barertc +// Auto incrementing Message ID for anything pushed out by the server. +var MessageID int + /* Message is the basic carrier of WebSocket chat protocol actions. @@ -26,6 +29,9 @@ type Message struct { ChatStatus string `json:"status,omitempty"` // online vs. away NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW + // Message ID to support takebacks/local deletions + MessageID int `json:"msgID,omitempty"` + // Sent on `open` actions along with the (other) Username. OpenSecret string `json:"openSecret,omitempty"` @@ -46,13 +52,14 @@ const ( ActionUnmute = "unmute" // Actions sent by server or client - ActionMessage = "message" // post a message to the room - ActionMe = "me" // user self-info sent by FE or BE - ActionOpen = "open" // user wants to view a webcam (open WebRTC) - ActionRing = "ring" // receiver of a WebRTC open request - ActionWatch = "watch" // user has received video and is watching you - ActionUnwatch = "unwatch" // user has closed your video - ActionFile = "file" // image sharing in chat + ActionMessage = "message" // post a message to the room + ActionMe = "me" // user self-info sent by FE or BE + ActionOpen = "open" // user wants to view a webcam (open WebRTC) + ActionRing = "ring" // receiver of a WebRTC open request + ActionWatch = "watch" // user has received video and is watching you + ActionUnwatch = "unwatch" // user has closed your video + ActionFile = "file" // image sharing in chat + ActionTakeback = "takeback" // user takes back (deletes) their message for everybody // Actions sent by server only ActionPing = "ping" diff --git a/pkg/websocket.go b/pkg/websocket.go index c6291a9..67b210c 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -39,6 +39,10 @@ type Subscriber struct { muteMu sync.RWMutex booted map[string]struct{} // usernames booted off your camera muted map[string]struct{} // usernames you muted + + // Record which message IDs belong to this user. + midMu sync.Mutex + messageIDs map[int]struct{} } // ReadLoop spawns a goroutine that reads from the websocket connection. @@ -102,6 +106,8 @@ func (sub *Subscriber) ReadLoop(s *Server) { s.OnWatch(sub, msg) case ActionUnwatch: s.OnUnwatch(sub, msg) + case ActionTakeback: + s.OnTakeback(sub, msg) default: sub.ChatServer("Unsupported message type.") } @@ -172,6 +178,7 @@ func (s *Server) WebSocket() http.HandlerFunc { }, booted: make(map[string]struct{}), muted: make(map[string]struct{}), + messageIDs: make(map[int]struct{}), ChatStatus: "online", } diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 3daa317..f304c6b 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -311,6 +311,10 @@ const app = Vue.createApp({ // Is the current channel a DM? return this.channel.indexOf("@") === 0; }, + isOp() { + // Returns if the current user has operator rights + return this.jwt.claims.op; + }, }, methods: { // Load user prefs from localStorage, called on startup @@ -503,9 +507,26 @@ const app = Vue.createApp({ channel: msg.channel, username: msg.username, message: msg.message, + messageID: msg.msgID, }); }, + // A user deletes their message for everybody + onTakeback(msg) { + // Search all channels for this message ID and remove it. + for (let channel of Object.keys(this.channels)) { + for (let i = 0; i < this.channels[channel].history.length; i++) { + let cmp = this.channels[channel].history[i]; + if (cmp.msgID === msg.msgID) { + this.channels[channel].history.splice(i, 1); + return; + } + } + } + + console.error("Got a takeback for msgID %d but did not find it!", msg.msgID); + }, + // User logged in or out. onPresence(msg) { // TODO: make a dedicated leave event @@ -608,6 +629,9 @@ const app = Vue.createApp({ case "message": this.onMessage(msg); break; + case "takeback": + this.onTakeback(msg); + break; case "presence": this.onPresence(msg); break; @@ -947,6 +971,27 @@ const app = Vue.createApp({ delete(this.channels[channel]); }, + /* Take back messages (for everyone) or remove locally */ + takeback(msg) { + if (!window.confirm( + "Do you want to take this message back? Doing so will remove this message from everybody's view in the chat room." + )) return; + + this.ws.conn.send(JSON.stringify({ + action: "takeback", + msgID: msg.msgID, + })); + }, + removeMessage(msg) { + if (!window.confirm( + "Do you want to remove this message from your view? This will delete the message only for you, but others in this chat thread may still see it." + )) return; + + this.onTakeback({ + msgID: msg.msgID, + }) + }, + activeChannels() { // List of current channels, unread indicators etc. let result = []; @@ -1357,7 +1402,7 @@ const app = Vue.createApp({ }; } }, - pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient }) { + pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient, messageID }) { // Default channel = your current channel. if (!channel) { channel = this.channel; @@ -1372,6 +1417,7 @@ const app = Vue.createApp({ action: action, username: username, message: message, + msgID: messageID, at: new Date(), isChatServer, isChatClient, diff --git a/web/templates/chat.html b/web/templates/chat.html index 4b01942..ed2b2ae 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -821,26 +821,44 @@ internal -
+
-
-
+ + + + + + +