Message Takebacks

This commit is contained in:
Noah 2023-06-24 13:08:15 -07:00
parent a797bc45da
commit b19a4821e4
6 changed files with 165 additions and 24 deletions

View File

@ -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 `<img>` 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.

View File

@ -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(

View File

@ -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"

View File

@ -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",
}

View File

@ -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,

View File

@ -821,26 +821,44 @@
<small v-else class="has-text-grey">internal</small>
</div>
<div class="column is-narrow px-1 pt-0"
v-if="!(msg.username === username || isDM)">
<div class="column is-narrow pl-1 pt-0">
<!-- DMs button -->
<button type="button"
v-if="!(msg.username === username || isDM)"
class="button is-grey is-outlined is-small px-2"
@click="openDMs({username: msg.username})">
@click="openDMs({username: msg.username})"
title="Open a Direct Message (DM) thread">
<i class="fa fa-message"></i>
</button>
</div>
<div class="column is-narrow pl-1 pt-0"
v-if="!(msg.username === username)">
<!-- Mute button -->
<button type="button"
class="button is-grey is-outlined is-small px-2"
v-if="!(msg.username === username)"
class="button is-grey is-outlined is-small px-2 ml-1"
@click="muteUser(msg.username)"
title="Mute user">
<i class="fa fa-comment-slash"
:class="{'has-text-success': isMutedUser(msg.username),
'has-text-danger': !isMutedUser(msg.username)}"></i>
</button>
<!-- Owner or admin: take back the message -->
<button type="button"
v-if="msg.username === username || isOp"
class="button is-grey is-outlined is-small px-2 ml-1"
title="Take back this message (delete it for everybody)"
@click="takeback(msg)">
<i class="fa fa-rotate-left has-text-danger"></i>
</button>
<!-- Everyone else: can hide it locally -->
<button type="button"
v-if="msg.username !== username"
class="button is-grey is-outlined is-small px-2 ml-1"
title="Hide this message (delete it only for your view)"
@click="removeMessage(msg)">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>