Message Takebacks
This commit is contained in:
parent
a797bc45da
commit
b19a4821e4
30
Protocol.md
30
Protocol.md
|
@ -123,12 +123,16 @@ The server sends a similar message to push chats to the client:
|
||||||
"action": "message",
|
"action": "message",
|
||||||
"channel": "lobby",
|
"channel": "lobby",
|
||||||
"username": "senderName",
|
"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).
|
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
|
## File
|
||||||
|
|
||||||
Sent by: Client.
|
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.
|
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
|
## Presence
|
||||||
|
|
||||||
Sent by: Server.
|
Sent by: Server.
|
||||||
|
|
|
@ -106,12 +106,20 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
|
||||||
// Detect and expand media such as YouTube videos.
|
// Detect and expand media such as YouTube videos.
|
||||||
markdown = s.ExpandMedia(markdown)
|
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.
|
// Message to be echoed to the channel.
|
||||||
var message = Message{
|
var message = Message{
|
||||||
Action: ActionMessage,
|
Action: ActionMessage,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
Username: sub.Username,
|
Username: sub.Username,
|
||||||
Message: markdown,
|
Message: markdown,
|
||||||
|
MessageID: mid,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this a DM?
|
// Is this a DM?
|
||||||
|
@ -143,6 +151,25 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
|
||||||
s.Broadcast(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.
|
// OnFile handles a picture shared in chat with a channel.
|
||||||
func (s *Server) OnFile(sub *Subscriber, msg Message) {
|
func (s *Server) OnFile(sub *Subscriber, msg Message) {
|
||||||
if sub.Username == "" {
|
if sub.Username == "" {
|
||||||
|
@ -172,11 +199,19 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) {
|
||||||
img, pvWidth, pvHeight := ProcessImage(filetype, msg.Bytes)
|
img, pvWidth, pvHeight := ProcessImage(filetype, msg.Bytes)
|
||||||
var dataURL = fmt.Sprintf("data:%s;base64,%s", filetype, base64.StdEncoding.EncodeToString(img))
|
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.
|
// Message to be echoed to the channel.
|
||||||
var message = Message{
|
var message = Message{
|
||||||
Action: ActionMessage,
|
Action: ActionMessage,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
Username: sub.Username,
|
Username: sub.Username,
|
||||||
|
MessageID: mid,
|
||||||
|
|
||||||
// Their image embedded via a data: URI - no server storage needed!
|
// Their image embedded via a data: URI - no server storage needed!
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
package barertc
|
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.
|
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
|
ChatStatus string `json:"status,omitempty"` // online vs. away
|
||||||
NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW
|
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.
|
// Sent on `open` actions along with the (other) Username.
|
||||||
OpenSecret string `json:"openSecret,omitempty"`
|
OpenSecret string `json:"openSecret,omitempty"`
|
||||||
|
|
||||||
|
@ -53,6 +59,7 @@ const (
|
||||||
ActionWatch = "watch" // user has received video and is watching you
|
ActionWatch = "watch" // user has received video and is watching you
|
||||||
ActionUnwatch = "unwatch" // user has closed your video
|
ActionUnwatch = "unwatch" // user has closed your video
|
||||||
ActionFile = "file" // image sharing in chat
|
ActionFile = "file" // image sharing in chat
|
||||||
|
ActionTakeback = "takeback" // user takes back (deletes) their message for everybody
|
||||||
|
|
||||||
// Actions sent by server only
|
// Actions sent by server only
|
||||||
ActionPing = "ping"
|
ActionPing = "ping"
|
||||||
|
|
|
@ -39,6 +39,10 @@ type Subscriber struct {
|
||||||
muteMu sync.RWMutex
|
muteMu sync.RWMutex
|
||||||
booted map[string]struct{} // usernames booted off your camera
|
booted map[string]struct{} // usernames booted off your camera
|
||||||
muted map[string]struct{} // usernames you muted
|
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.
|
// ReadLoop spawns a goroutine that reads from the websocket connection.
|
||||||
|
@ -102,6 +106,8 @@ func (sub *Subscriber) ReadLoop(s *Server) {
|
||||||
s.OnWatch(sub, msg)
|
s.OnWatch(sub, msg)
|
||||||
case ActionUnwatch:
|
case ActionUnwatch:
|
||||||
s.OnUnwatch(sub, msg)
|
s.OnUnwatch(sub, msg)
|
||||||
|
case ActionTakeback:
|
||||||
|
s.OnTakeback(sub, msg)
|
||||||
default:
|
default:
|
||||||
sub.ChatServer("Unsupported message type.")
|
sub.ChatServer("Unsupported message type.")
|
||||||
}
|
}
|
||||||
|
@ -172,6 +178,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
},
|
},
|
||||||
booted: make(map[string]struct{}),
|
booted: make(map[string]struct{}),
|
||||||
muted: make(map[string]struct{}),
|
muted: make(map[string]struct{}),
|
||||||
|
messageIDs: make(map[int]struct{}),
|
||||||
ChatStatus: "online",
|
ChatStatus: "online",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -311,6 +311,10 @@ const app = Vue.createApp({
|
||||||
// Is the current channel a DM?
|
// Is the current channel a DM?
|
||||||
return this.channel.indexOf("@") === 0;
|
return this.channel.indexOf("@") === 0;
|
||||||
},
|
},
|
||||||
|
isOp() {
|
||||||
|
// Returns if the current user has operator rights
|
||||||
|
return this.jwt.claims.op;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// Load user prefs from localStorage, called on startup
|
// Load user prefs from localStorage, called on startup
|
||||||
|
@ -503,9 +507,26 @@ const app = Vue.createApp({
|
||||||
channel: msg.channel,
|
channel: msg.channel,
|
||||||
username: msg.username,
|
username: msg.username,
|
||||||
message: msg.message,
|
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.
|
// User logged in or out.
|
||||||
onPresence(msg) {
|
onPresence(msg) {
|
||||||
// TODO: make a dedicated leave event
|
// TODO: make a dedicated leave event
|
||||||
|
@ -608,6 +629,9 @@ const app = Vue.createApp({
|
||||||
case "message":
|
case "message":
|
||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
break;
|
break;
|
||||||
|
case "takeback":
|
||||||
|
this.onTakeback(msg);
|
||||||
|
break;
|
||||||
case "presence":
|
case "presence":
|
||||||
this.onPresence(msg);
|
this.onPresence(msg);
|
||||||
break;
|
break;
|
||||||
|
@ -947,6 +971,27 @@ const app = Vue.createApp({
|
||||||
delete(this.channels[channel]);
|
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() {
|
activeChannels() {
|
||||||
// List of current channels, unread indicators etc.
|
// List of current channels, unread indicators etc.
|
||||||
let result = [];
|
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.
|
// Default channel = your current channel.
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
channel = this.channel;
|
channel = this.channel;
|
||||||
|
@ -1372,6 +1417,7 @@ const app = Vue.createApp({
|
||||||
action: action,
|
action: action,
|
||||||
username: username,
|
username: username,
|
||||||
message: message,
|
message: message,
|
||||||
|
msgID: messageID,
|
||||||
at: new Date(),
|
at: new Date(),
|
||||||
isChatServer,
|
isChatServer,
|
||||||
isChatClient,
|
isChatClient,
|
||||||
|
|
|
@ -821,26 +821,44 @@
|
||||||
<small v-else class="has-text-grey">internal</small>
|
<small v-else class="has-text-grey">internal</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-narrow px-1 pt-0"
|
<div class="column is-narrow pl-1 pt-0">
|
||||||
v-if="!(msg.username === username || isDM)">
|
|
||||||
<!-- DMs button -->
|
<!-- DMs button -->
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
v-if="!(msg.username === username || isDM)"
|
||||||
class="button is-grey is-outlined is-small px-2"
|
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>
|
<i class="fa fa-message"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="column is-narrow pl-1 pt-0"
|
|
||||||
v-if="!(msg.username === username)">
|
|
||||||
<!-- Mute button -->
|
<!-- Mute button -->
|
||||||
<button type="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)"
|
@click="muteUser(msg.username)"
|
||||||
title="Mute user">
|
title="Mute user">
|
||||||
<i class="fa fa-comment-slash"
|
<i class="fa fa-comment-slash"
|
||||||
:class="{'has-text-success': isMutedUser(msg.username),
|
:class="{'has-text-success': isMutedUser(msg.username),
|
||||||
'has-text-danger': !isMutedUser(msg.username)}"></i>
|
'has-text-danger': !isMutedUser(msg.username)}"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user