Deadlock detection, DND, and Frontend Fixes

* Deadlock detection: the chatbot handlers will spin off a background goroutine
  to ping DMs at itself and test for responsiveness. If the echoes don't return
  for a minute, issue a /api/shutdown command to the HTTP server to force a
  reboot.
* New admin API endpoint: /api/shutdown, equivalent to the operator '/shutdown'
  command sent in chat. Requires your AdminAPIKey to call it. Used by the chatbot
  as part of deadlock detection.
* Adjust some uses of mutexes to hopefully mitigate deadlocks a bit.
* Do Not Disturb: if users opt to "Ignore unsolicited DMs" they will set a DND
  status on the server which will grey-out their DM icon for other chatters.
* Bring back an option for ChatServer to notify you when somebody begins watching
  your camera (on by default).
* Automatically focus the message entry box when changing channels.
* Lower webcam resolution hints to 480p to test performance implications.
This commit is contained in:
Noah 2023-08-29 07:49:50 +07:00
parent 59fc04b99e
commit fd82a463f3
12 changed files with 329 additions and 25 deletions

95
client/deadlock_watch.go Normal file
View File

@ -0,0 +1,95 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.kirsle.net/apps/barertc/client/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
const deadlockTTL = time.Minute
/*
Deadlock detection for the chat server.
Part of the chatbot handlers. The bot will send DMs to itself on an interval
and test whether the server is responsive; if it goes down, it will issue the
/api/shutdown command to reboot the server automatically.
This function is a goroutine spawned in the background.
*/
func (h *BotHandlers) watchForDeadlock() {
log.Info("Deadlock monitor engaged!")
h.deadlockLastOK = time.Now()
for {
time.Sleep(15 * time.Second)
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Channel: "@" + h.client.Username(),
Message: "deadlock ping",
})
// Has it been a while since our last ping?
if time.Since(h.deadlockLastOK) > deadlockTTL {
log.Error("Deadlock detected! Rebooting the chat server!")
h.deadlockLastOK = time.Now()
h.rebootChatServer()
}
}
}
// onMessageFromSelf handles DMs sent to ourself, e.g. for deadlock detection.
func (h *BotHandlers) onMessageFromSelf(msg messages.Message) {
// If it is our own DM channel thread, it's for deadlock detection.
if msg.Channel == "@"+h.client.Username() {
h.deadlockLastOK = time.Now()
}
}
// Reboot the chat server via web API, in case of deadlock.
func (h *BotHandlers) rebootChatServer() error {
// API request struct for BareRTC /api/shutdown endpoint.
var request = struct {
APIKey string
}{
APIKey: config.Current.BareRTC.AdminAPIKey,
}
// JSON request body.
jsonStr, err := json.Marshal(request)
if err != nil {
return err
}
// Make the API request to BareRTC.
var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/shutdown"
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("RebootChatServer: error posting to BareRTC: status %d body %s", resp.StatusCode, body)
}
return nil
}

View File

@ -62,6 +62,10 @@ type BotHandlers struct {
// so we don't accidentally take back our own reactions. // so we don't accidentally take back our own reactions.
reactions map[int]map[string]interface{} reactions map[int]map[string]interface{}
reactionsMu sync.Mutex reactionsMu sync.Mutex
// Deadlock detection (deadlock_watch.go): record time of last successful
// ping to self, to detect when the server is deadlocked.
deadlockLastOK time.Time
} }
// SetupChatbot configures a sensible set of default handlers for the BareBot application. // SetupChatbot configures a sensible set of default handlers for the BareBot application.
@ -105,6 +109,9 @@ func (c *Client) SetupChatbot() error {
c.OnDisconnect = handler.OnDisconnect c.OnDisconnect = handler.OnDisconnect
c.OnPing = handler.OnPing c.OnPing = handler.OnPing
// Watch for deadlocks.
go handler.watchForDeadlock()
return nil return nil
} }
@ -157,6 +164,7 @@ func (h *BotHandlers) OnMessage(msg messages.Message) {
// Ignore echoed message from ourself. // Ignore echoed message from ourself.
if msg.Username == h.client.Username() { if msg.Username == h.client.Username() {
h.onMessageFromSelf(msg)
return return
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
"git.kirsle.net/apps/barertc/client" "git.kirsle.net/apps/barertc/client"
"git.kirsle.net/apps/barertc/client/config" "git.kirsle.net/apps/barertc/client/config"
@ -74,6 +75,11 @@ func init() {
// Run! // Run!
log.Info("Connecting to ChatServer") log.Info("Connecting to ChatServer")
err = client.Run()
if err != nil {
log.Error("Error: %s (and sleeping 5 seconds before exit)", err)
time.Sleep(5 * time.Second)
}
return cli.Exit(client.Run(), 1) return cli.Exit(client.Run(), 1)
}, },
} }

View File

@ -50,6 +50,31 @@ The return schema looks like:
} }
``` ```
## POST /api/shutdown
Shut down (and hopefully, reboot) the chat server. It is equivalent to the `/shutdown` operator command issued in chat, but callable from your web application. It is also used as part of deadlock detection on the BareBot chatbot.
It requires the AdminAPIKey to post:
```json
{
"APIKey": "from settings.toml"
}
```
The return schema looks like:
```json
{
"OK": true,
"Error": "error string, omitted if none"
}
```
The HTTP server will respond OK, and then shut down a couple of seconds later, attempting to send a ChatServer broadcast first (as in the `/shutdown` command). If the chat server is deadlocked, this broadcast won't go out but the program will still exit.
It is up to your process supervisor to automatically restart BareRTC when it exits.
## POST /api/blocklist ## POST /api/blocklist
Your server may pre-cache the user's blocklist for them **before** they Your server may pre-cache the user's blocklist for them **before** they

View File

@ -3,8 +3,10 @@ package barertc
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt" "git.kirsle.net/apps/barertc/pkg/jwt"
@ -174,6 +176,101 @@ func (s *Server) Authenticate() http.HandlerFunc {
}) })
} }
// Shutdown (/api/shutdown) the chat server, hopefully to reboot it.
//
// This endpoint is equivalent to the operator '/shutdown' command but may be
// invoked by your website, or your chatbot. It requires the AdminAPIKey.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// }
//
// The return schema looks like:
//
// {
// "OK": true,
// "Error": "error string, omitted if none",
// }
func (s *Server) ShutdownAPI() http.HandlerFunc {
type request struct {
APIKey string
Claims jwt.Claims
}
type result struct {
OK bool
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Send the response.
enc.Encode(result{
OK: true,
})
// Defer a shutdown a moment later.
go func() {
time.Sleep(2 * time.Second)
os.Exit(1)
}()
// Attempt to broadcast, but if deadlocked this might not go out.
go func() {
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "The chat server is going down for a reboot NOW!",
})
}()
})
}
// BlockList (/api/blocklist) allows your website to pre-sync mute lists between your // BlockList (/api/blocklist) allows your website to pre-sync mute lists between your
// user accounts, so that when they see each other in chat they will pre-emptively mute // user accounts, so that when they see each other in chat they will pre-emptively mute
// or boot one another. // or boot one another.

View File

@ -83,6 +83,19 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
case "/deop": case "/deop":
s.DeopCommand(words, sub) s.DeopCommand(words, sub)
return true return true
case "/debug-dangerous-force-deadlock":
// TEMPORARY debug command to willfully force a deadlock.
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "NOTICE: The admin is testing a force deadlock of the chat server; things may become unresponsive soon.",
})
go func() {
time.Sleep(2 * time.Second)
s.subscribersMu.Lock()
s.subscribersMu.Lock()
}()
return true
} }
} }

View File

@ -89,6 +89,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
// Use their username. // Use their username.
sub.Username = msg.Username sub.Username = msg.Username
sub.authenticated = true sub.authenticated = true
sub.DND = msg.DND
sub.loginAt = time.Now() sub.loginAt = time.Now()
log.Debug("OnLogin: %s joins the room", sub.Username) log.Debug("OnLogin: %s joins the room", sub.Username)
@ -144,9 +145,8 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
markdown = s.ExpandMedia(markdown) markdown = s.ExpandMedia(markdown)
// Assign a message ID and own it to the sender. // Assign a message ID and own it to the sender.
messages.MessageID++
var mid = messages.MessageID
sub.midMu.Lock() sub.midMu.Lock()
var mid = messages.NextMessageID()
sub.messageIDs[mid] = struct{}{} sub.messageIDs[mid] = struct{}{}
sub.midMu.Unlock() sub.midMu.Unlock()
@ -194,8 +194,9 @@ func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// Permission check. // Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin { if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock() sub.midMu.Lock()
defer sub.midMu.Unlock() _, ok := sub.messageIDs[msg.MessageID]
if _, ok := sub.messageIDs[msg.MessageID]; !ok { sub.midMu.Unlock()
if !ok {
sub.ChatServer("That is not your message to take back.") sub.ChatServer("That is not your message to take back.")
return return
} }
@ -249,9 +250,8 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
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. // Assign a message ID and own it to the sender.
messages.MessageID++
var mid = messages.MessageID
sub.midMu.Lock() sub.midMu.Lock()
var mid = messages.NextMessageID()
sub.messageIDs[mid] = struct{}{} sub.messageIDs[mid] = struct{}{}
sub.midMu.Unlock() sub.midMu.Unlock()
@ -329,6 +329,7 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
sub.VideoStatus = msg.VideoStatus sub.VideoStatus = msg.VideoStatus
sub.ChatStatus = msg.ChatStatus sub.ChatStatus = msg.ChatStatus
sub.DND = msg.DND
// Sync the WhoList to everybody. // Sync the WhoList to everybody.
s.SendWhoList() s.SendWhoList()

View File

@ -1,7 +1,21 @@
package messages package messages
import "sync"
// Auto incrementing Message ID for anything pushed out by the server. // Auto incrementing Message ID for anything pushed out by the server.
var MessageID int var (
messageID int
mu sync.Mutex
)
// NextMessageID atomically increments and returns a new MessageID.
func NextMessageID() int {
mu.Lock()
defer mu.Unlock()
messageID++
var mid = messageID
return mid
}
/* /*
Message is the basic carrier of WebSocket chat protocol actions. Message is the basic carrier of WebSocket chat protocol actions.
@ -25,6 +39,7 @@ type Message struct {
// Sent on `me` actions along with Username // Sent on `me` actions along with Username
VideoStatus int `json:"video,omitempty"` // user video flags VideoStatus int `json:"video,omitempty"` // user video flags
ChatStatus string `json:"status,omitempty"` // online vs. away ChatStatus string `json:"status,omitempty"` // online vs. away
DND bool `json:"dnd,omitempty"` // Do Not Disturb, e.g. DMs are closed
// Message ID to support takebacks/local deletions // Message ID to support takebacks/local deletions
MessageID int `json:"msgID,omitempty"` MessageID int `json:"msgID,omitempty"`
@ -87,6 +102,7 @@ type WhoList struct {
Nickname string `json:"nickname,omitempty"` Nickname string `json:"nickname,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Video int `json:"video"` Video int `json:"video"`
DND bool `json:"dnd,omitempty"`
LoginAt int64 `json:"loginAt"` LoginAt int64 `json:"loginAt"`
// JWT auth extra settings. // JWT auth extra settings.

View File

@ -36,6 +36,7 @@ func (s *Server) Setup() error {
mux.Handle("/api/statistics", s.Statistics()) mux.Handle("/api/statistics", s.Statistics())
mux.Handle("/api/blocklist", s.BlockList()) mux.Handle("/api/blocklist", s.BlockList())
mux.Handle("/api/authenticate", s.Authenticate()) mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
s.mux = mux s.mux = mux

View File

@ -26,6 +26,7 @@ type Subscriber struct {
Username string Username string
ChatStatus string ChatStatus string
VideoStatus int VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims JWTClaims *jwt.Claims
authenticated bool // has passed the login step authenticated bool // has passed the login step
loginAt time.Time loginAt time.Time
@ -266,20 +267,16 @@ func (s *Server) DeleteSubscriber(sub *Subscriber) {
s.subscribersMu.Unlock() s.subscribersMu.Unlock()
} }
// IterSubscribers loops over the subscriber list with a read lock. If the // IterSubscribers loops over the subscriber list with a read lock.
// caller already holds a lock, pass the optional `true` parameter for isLocked. func (s *Server) IterSubscribers() []*Subscriber {
func (s *Server) IterSubscribers(isLocked ...bool) []*Subscriber {
var result = []*Subscriber{} var result = []*Subscriber{}
// Has the caller already taken the read lock or do we get it? // Lock for reads.
if locked := len(isLocked) > 0 && isLocked[0]; !locked {
s.subscribersMu.RLock() s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
}
for sub := range s.subscribers { for sub := range s.subscribers {
result = append(result, sub) result = append(result, sub)
} }
s.subscribersMu.RUnlock()
return result return result
} }
@ -399,6 +396,7 @@ func (s *Server) SendWhoList() {
Username: user.Username, Username: user.Username,
Status: user.ChatStatus, Status: user.ChatStatus,
Video: user.VideoStatus, Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(), LoginAt: user.loginAt.Unix(),
} }

View File

@ -103,6 +103,7 @@ const app = Vue.createApp({
username: "", //"test", username: "", //"test",
autoLogin: false, // e.g. from JWT auth autoLogin: false, // e.g. from JWT auth
message: "", message: "",
messageBox: null, // HTML element for message entry box
typingNotifDebounce: null, typingNotifDebounce: null,
status: "online", // away/idle status status: "online", // away/idle status
@ -127,6 +128,7 @@ const app = Vue.createApp({
prefs: { prefs: {
joinMessages: true, // show "has entered the room" in public channels joinMessages: true, // show "has entered the room" in public channels
exitMessages: false, // hide exit messages by default in public channels exitMessages: false, // hide exit messages by default in public channels
watchNotif: true, // notify in chat about cameras being watched
closeDMs: false, // ignore unsolicited DMs closeDMs: false, // ignore unsolicited DMs
}, },
@ -290,6 +292,7 @@ const app = Vue.createApp({
$center: document.querySelector(".chat-column"), $center: document.querySelector(".chat-column"),
$right: document.querySelector(".right-column"), $right: document.querySelector(".right-column"),
}; };
this.messageBox = document.getElementById("messageBox");
// Reset CSS overrides for responsive display on any window size change. In effect, // Reset CSS overrides for responsive display on any window size change. In effect,
// making the chat panel the current screen again on phone rotation. // making the chat panel the current screen again on phone rotation.
@ -387,8 +390,14 @@ const app = Vue.createApp({
"prefs.exitMessages": function() { "prefs.exitMessages": function() {
localStorage.exitMessages = this.prefs.exitMessages; localStorage.exitMessages = this.prefs.exitMessages;
}, },
"prefs.watchNotif": function() {
localStorage.watchNotif = this.prefs.watchNotif;
},
"prefs.closeDMs": function() { "prefs.closeDMs": function() {
localStorage.closeDMs = this.prefs.closeDMs; localStorage.closeDMs = this.prefs.closeDMs;
// Tell ChatServer if we have gone to/from DND.
this.sendMe();
}, },
}, },
computed: { computed: {
@ -588,6 +597,9 @@ const app = Vue.createApp({
if (localStorage.exitMessages != undefined) { if (localStorage.exitMessages != undefined) {
this.prefs.exitMessages = localStorage.exitMessages === "true"; this.prefs.exitMessages = localStorage.exitMessages === "true";
} }
if (localStorage.watchNotif != undefined) {
this.prefs.watchNotif = localStorage.watchNotif === "true";
}
if (localStorage.closeDMs != undefined) { if (localStorage.closeDMs != undefined) {
this.prefs.closeDMs = localStorage.closeDMs === "true"; this.prefs.closeDMs = localStorage.closeDMs === "true";
} }
@ -686,10 +698,12 @@ const app = Vue.createApp({
// Sync the current user state (such as video broadcasting status) to // Sync the current user state (such as video broadcasting status) to
// the backend, which will reload everybody's Who List. // the backend, which will reload everybody's Who List.
sendMe() { sendMe() {
if (!this.ws.connected) return;
this.ws.conn.send(JSON.stringify({ this.ws.conn.send(JSON.stringify({
action: "me", action: "me",
video: this.myVideoFlag, video: this.myVideoFlag,
status: this.status, status: this.status,
dnd: this.prefs.closeDMs,
})); }));
}, },
onMe(msg) { onMe(msg) {
@ -974,7 +988,13 @@ const app = Vue.createApp({
action: "login", action: "login",
username: this.username, username: this.username,
jwt: this.jwt.token, jwt: this.jwt.token,
dnd: this.prefs.closeDMs,
})); }));
// Focus the message entry box.
window.requestAnimationFrame(() => {
this.messageBox.focus();
});
}); });
conn.addEventListener("message", ev => { conn.addEventListener("message", ev => {
@ -1252,6 +1272,14 @@ const app = Vue.createApp({
onWatch(msg) { onWatch(msg) {
// The user has our video feed open now. // The user has our video feed open now.
if (this.isBootedAdmin(msg.username)) return; if (this.isBootedAdmin(msg.username)) return;
// Notify in chat if this was the first watch (viewer may send multiple per each track they received)
if (this.prefs.watchNotif && this.webcam.watching[msg.username] != true) {
this.ChatServer(
`<strong>${msg.username}</strong> is now watching your camera.`,
);
}
this.webcam.watching[msg.username] = true; this.webcam.watching[msg.username] = true;
this.playSound("Watch"); this.playSound("Watch");
}, },
@ -1295,6 +1323,9 @@ const app = Vue.createApp({
// Edit hyperlinks to open in a new window. // Edit hyperlinks to open in a new window.
this.makeLinksExternal(); this.makeLinksExternal();
// Focus the message entry box.
this.messageBox.focus();
}, },
hasUnread(channel) { hasUnread(channel) {
if (this.channels[channel] == undefined) { if (this.channels[channel] == undefined) {
@ -1382,6 +1413,10 @@ const app = Vue.createApp({
} }
return username; return username;
}, },
isUsernameDND(username) {
if (!username) return false;
return this.whoMap[username] != undefined && this.whoMap[username].dnd;
},
isUsernameCamNSFW(username) { isUsernameCamNSFW(username) {
// returns true if the username is broadcasting and NSFW, false otherwise. // returns true if the username is broadcasting and NSFW, false otherwise.
// (used for the color coding of their nickname on their video box - so assumed they are broadcasting) // (used for the color coding of their nickname on their video box - so assumed they are broadcasting)
@ -1490,8 +1525,8 @@ const app = Vue.createApp({
let mediaParams = { let mediaParams = {
audio: true, audio: true,
video: { video: {
width: { max: 1280 }, width: { max: 640 },
height: { max: 720 }, height: { max: 480 },
}, },
}; };

View File

@ -373,7 +373,7 @@
<div v-if="settingsModal.tab==='misc'"> <div v-if="settingsModal.tab==='misc'">
<div class="field"> <div class="field">
<label class="label">Presence messages in public channels</label> <label class="label">Presence messages <small>('has joined the room')</small> in public channels</label>
<div class="columns is-mobile mb-0"> <div class="columns is-mobile mb-0">
<div class="column py-1"> <div class="column py-1">
<label class="checkbox" title="Show 'has joined the room' messages in public channels"> <label class="checkbox" title="Show 'has joined the room' messages in public channels">
@ -393,9 +393,16 @@
</label> </label>
</div> </div>
</div> </div>
<p class="help mt-0"> </div>
Whether to show <em>'has joined the room'</em> style messages in public channels.
</p> <div class="field">
<label class="label mb-0">Server notification messages</label>
<label class="checkbox" title="Show 'has joined the room' messages in public channels">
<input type="checkbox"
v-model="prefs.watchNotif"
:value="true">
Notify when somebody opens my camera
</label>
</div> </div>
<div class="field"> <div class="field">
@ -1114,7 +1121,8 @@
v-if="!(msg.username === username || isDM)" 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"> :title="isUsernameDND(msg.username) ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
:disabled="isUsernameDND(msg.username)">
<i class="fa fa-message"></i> <i class="fa fa-message"></i>
</button> </button>
@ -1250,6 +1258,7 @@
:class="{'pl-1': canUploadFile}"> :class="{'pl-1': canUploadFile}">
<form @submit.prevent="sendMessage()"> <form @submit.prevent="sendMessage()">
<input type="text" class="input" <input type="text" class="input"
id="messageBox"
v-model="message" v-model="message"
placeholder="Write a message" placeholder="Write a message"
@keydown="sendTypingNotification()" @keydown="sendTypingNotification()"
@ -1424,8 +1433,8 @@
<button type="button" v-else <button type="button" v-else
class="button is-small px-2 py-1" class="button is-small px-2 py-1"
@click="openDMs(u)" @click="openDMs(u)"
title="Start direct message thread" :disabled="u.username === username || u.dnd"
:disabled="u.username === username"> :title="u.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
<i class="fa fa-message"></i> <i class="fa fa-message"></i>
</button> </button>