From 9a91324456eb0b487216c165e9e61ab01ac93bc3 Mon Sep 17 00:00:00 2001 From: gingervitis Date: Tue, 2 Nov 2021 19:27:41 -0700 Subject: [PATCH] Inline chat moderation UI (#1331) * - mock detect when user turns into moderator - add moderator indicator to display on messages and username changer * also mock moderator flag in message payload about user to display indicator * add some menu looking icons and a menu of actions * WIP chat moderators * Add support for admin promoting a user to moderator * WIP- open a more info panel of user+message info; add some a11y to buttons * style the details panel * adjust positioning of menus * Merge fixes. ChatClient->Client ChatServer->Server * Remove moderator bool placeholders to use real state * Support inline hiding of messages by moderators * Support inline banning of chat users * Cleanup linter warnings * Puppeteer tests fail after typing take place * Manually resolve conflicts in chat between moderator feature and develop Co-authored-by: Gabe Kangas --- controllers/admin/chat.go | 42 +++ core/chat/chatclient.go | 11 +- core/chat/events/connectedClientInfo.go | 9 + core/chat/server.go | 47 +++ core/user/user.go | 133 +++++++- router/middleware/auth.go | 21 ++ router/router.go | 14 + test/automated/api/chatusers.test.js | 20 ++ test/automated/api/run.sh | 2 +- test/automated/browser/tests/chat.js | 36 ++- utils/utils.go | 25 +- webroot/img/menu-filled.svg | 41 +++ webroot/img/menu-vert.svg | 1 + webroot/img/menu.svg | 1 + webroot/js/app.js | 10 +- .../js/components/chat/chat-message-view.js | 26 +- webroot/js/components/chat/chat.js | 42 ++- webroot/js/components/chat/message.js | 86 ++--- .../js/components/chat/moderator-actions.js | 295 ++++++++++++++++++ webroot/js/components/chat/username.js | 8 +- webroot/js/utils/chat.js | 13 + webroot/js/utils/constants.js | 4 + webroot/styles/chat.css | 131 ++++++++ 23 files changed, 902 insertions(+), 116 deletions(-) create mode 100644 core/chat/events/connectedClientInfo.go create mode 100644 webroot/img/menu-filled.svg create mode 100644 webroot/img/menu-vert.svg create mode 100644 webroot/img/menu.svg create mode 100644 webroot/js/components/chat/moderator-actions.js diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index ee282c7e0..8a66c5a9f 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -103,6 +103,48 @@ func GetDisabledUsers(w http.ResponseWriter, r *http.Request) { controllers.WriteResponse(w, users) } +// UpdateUserModerator will set the moderator status for a user ID. +func UpdateUserModerator(w http.ResponseWriter, r *http.Request) { + type request struct { + UserID string `json:"userId"` + IsModerator bool `json:"isModerator"` + } + + if r.Method != controllers.POST { + controllers.WriteSimpleResponse(w, false, r.Method+" not supported") + return + } + + decoder := json.NewDecoder(r.Body) + var req request + + if err := decoder.Decode(&req); err != nil { + controllers.WriteSimpleResponse(w, false, "") + return + } + + // Update the user object with new moderation access. + if err := user.SetModerator(req.UserID, req.IsModerator); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + // Update the clients for this user to know about the moderator access change. + if err := chat.SendConnectedClientInfoToUser(req.UserID); err != nil { + log.Debugln(err) + } + + controllers.WriteSimpleResponse(w, true, fmt.Sprintf("%s is moderator: %t", req.UserID, req.IsModerator)) +} + +// GetModerators will return a list of moderator users. +func GetModerators(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + users := user.GetModeratorUsers() + controllers.WriteResponse(w, users) +} + // GetChatMessages returns all of the chat messages, unfiltered. func GetChatMessages(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go index d371d078c..80315652c 100644 --- a/core/chat/chatclient.go +++ b/core/chat/chatclient.go @@ -66,11 +66,14 @@ var ( ) func (c *Client) sendConnectedClientInfo() { - payload := events.EventPayload{ - "type": events.ConnectedUserInfo, - "user": c.User, + payload := events.ConnectedClientInfo{ + Event: events.Event{ + Type: events.ConnectedUserInfo, + }, + User: c.User, } + payload.SetDefaults() c.sendPayload(payload) } @@ -204,7 +207,7 @@ func (c *Client) startChatRejectionTimeout() { c.sendAction("You are temporarily blocked from sending chat messages due to perceived flooding.") } -func (c *Client) sendPayload(payload events.EventPayload) { +func (c *Client) sendPayload(payload interface{}) { var data []byte data, err := json.Marshal(payload) if err != nil { diff --git a/core/chat/events/connectedClientInfo.go b/core/chat/events/connectedClientInfo.go new file mode 100644 index 000000000..3b04a5423 --- /dev/null +++ b/core/chat/events/connectedClientInfo.go @@ -0,0 +1,9 @@ +package events + +import "github.com/owncast/owncast/core/user" + +// ConnectedClientInfo represents the information about a connected client. +type ConnectedClientInfo struct { + Event + User *user.User `json:"user"` +} diff --git a/core/chat/server.go b/core/chat/server.go index 37c7d9fd9..08c0317c9 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -2,6 +2,7 @@ package chat import ( "encoding/json" + "fmt" "net/http" "sync" "time" @@ -279,6 +280,49 @@ func (s *Server) DisconnectUser(userID string) { } } +// SendConnectedClientInfoToUser will find all the connected clients assigned to a user +// and re-send each the connected client info. +func SendConnectedClientInfoToUser(userID string) error { + clients, err := GetClientsForUser(userID) + if err != nil { + return err + } + + // Get an updated reference to the user. + user := user.GetUserByID(userID) + if user == nil { + return fmt.Errorf("user not found") + } + + if err != nil { + return err + } + + for _, client := range clients { + // Update the client's reference to its user. + client.User = user + // Send the update to the client. + client.sendConnectedClientInfo() + } + + return nil +} + +// SendActionToUser will send system action text to all connected clients +// assigned to a user ID. +func SendActionToUser(userID string, text string) error { + clients, err := GetClientsForUser(userID) + if err != nil { + return err + } + + for _, client := range clients { + _server.sendActionToClient(client, text) + } + + return nil +} + func (s *Server) eventReceived(event chatClientEvent) { var typecheck map[string]interface{} if err := json.Unmarshal(event.data, &typecheck); err != nil { @@ -342,6 +386,9 @@ func (s *Server) sendActionToClient(c *Client, message string) { MessageEvent: events.MessageEvent{ Body: message, }, + Event: events.Event{ + Type: events.ChatActionSent, + }, } clientMessage.SetDefaults() clientMessage.RenderBody() diff --git a/core/user/user.go b/core/user/user.go index 62f92740a..3216828ce 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -16,6 +16,8 @@ import ( var _datastore *data.Datastore +const moderatorScopeKey = "MODERATOR" + // User represents a single chat user. type User struct { ID string `json:"id"` @@ -26,6 +28,7 @@ type User struct { DisabledAt *time.Time `json:"disabledAt,omitempty"` PreviousNames []string `json:"previousNames"` NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` + Scopes []string `json:"scopes"` } // IsEnabled will return if this single user is enabled. @@ -33,6 +36,12 @@ func (u *User) IsEnabled() bool { return u.DisabledAt == nil } +// IsModerator will return if the user has moderation privileges. +func (u *User) IsModerator() bool { + _, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey) + return hasModerationScope +} + // SetupUsers will perform the initial initialization of the user package. func SetupUsers() { _datastore = data.GetDatastore() @@ -47,7 +56,7 @@ func CreateAnonymousUser(username string) (*User, error) { return nil, err } - var displayName = username + displayName := username if displayName == "" { displayName = utils.GeneratePhrase() } @@ -75,7 +84,6 @@ func ChangeUsername(userID string, username string) { defer _datastore.DbLock.Unlock() tx, err := _datastore.DB.Begin() - if err != nil { log.Debugln(err) } @@ -86,7 +94,6 @@ func ChangeUsername(userID string, username string) { }() stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?") - if err != nil { log.Debugln(err) } @@ -115,7 +122,6 @@ func create(user *User) error { }() stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?, ?)") - if err != nil { log.Debugln(err) } @@ -129,7 +135,7 @@ func create(user *User) error { return tx.Commit() } -// SetEnabled will will set the enabled flag on a single user assigned to userID. +// SetEnabled will set the enabled status of a single user by ID. func SetEnabled(userID string, enabled bool) error { _datastore.DbLock.Lock() defer _datastore.DbLock.Unlock() @@ -166,18 +172,82 @@ func GetUserByToken(token string) *User { _datastore.DbLock.Lock() defer _datastore.DbLock.Unlock() - query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE access_token = ?" + query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE access_token = ?" row := _datastore.DB.QueryRow(query, token) return getUserFromRow(row) } +// SetModerator will add or remove moderator status for a single user by ID. +func SetModerator(userID string, isModerator bool) error { + if isModerator { + return addScopeToUser(userID, moderatorScopeKey) + } + + return removeScopeFromUser(userID, moderatorScopeKey) +} + +func addScopeToUser(userID string, scope string) error { + u := GetUserByID(userID) + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + scopes[scope] = true + + scopesSlice := utils.StringMapKeys(scopes) + + return setScopesOnUser(userID, scopesSlice) +} + +func removeScopeFromUser(userID string, scope string) error { + u := GetUserByID(userID) + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + delete(scopes, scope) + + scopesSlice := utils.StringMapKeys(scopes) + + return setScopesOnUser(userID, scopesSlice) +} + +func setScopesOnUser(userID string, scopes []string) error { + _datastore.DbLock.Lock() + defer _datastore.DbLock.Unlock() + + tx, err := _datastore.DB.Begin() + if err != nil { + return err + } + + defer tx.Rollback() //nolint + + scopesSliceString := strings.TrimSpace(strings.Join(scopes, ",")) + stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?") + if err != nil { + return err + } + + defer stmt.Close() + + var val *string + if scopesSliceString == "" { + val = nil + } else { + val = &scopesSliceString + } + + if _, err := stmt.Exec(val, userID); err != nil { + return err + } + + return tx.Commit() +} + // GetUserByID will return a user by a user ID. func GetUserByID(id string) *User { _datastore.DbLock.Lock() defer _datastore.DbLock.Unlock() - query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE id = ?" + query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?" row := _datastore.DB.QueryRow(query, id) if row == nil { log.Errorln(row) @@ -188,7 +258,7 @@ func GetUserByID(id string) *User { // GetDisabledUsers will return back all the currently disabled users that are not API users. func GetDisabledUsers() []*User { - query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'" + query := "SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'" rows, err := _datastore.DB.Query(query) if err != nil { @@ -206,6 +276,35 @@ func GetDisabledUsers() []*User { return users } +// GetModeratorUsers will return a list of users with moderator access. +func GetModeratorUsers() []*User { + query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM ( + WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS ( + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users + UNION ALL + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',')+1) + FROM split + WHERE rest <> '') + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope + FROM split + WHERE scope <> '' + ORDER BY created_at + ) AS token WHERE token.scope = ?` + + rows, err := _datastore.DB.Query(query, moderatorScopeKey) + if err != nil { + log.Errorln(err) + return nil + } + defer rows.Close() + + users := getUsersFromRows(rows) + + return users +} + func getUsersFromRows(rows *sql.Rows) []*User { users := make([]*User, 0) @@ -217,12 +316,18 @@ func getUsersFromRows(rows *sql.Rows) []*User { var disabledAt *time.Time var previousUsernames string var userNameChangedAt *time.Time + var scopesString *string - if err := rows.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { + if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { log.Errorln("error creating collection of users from results", err) return nil } + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + user := &User{ ID: id, DisplayName: displayName, @@ -231,6 +336,7 @@ func getUsersFromRows(rows *sql.Rows) []*User { DisabledAt: disabledAt, PreviousNames: strings.Split(previousUsernames, ","), NameChangedAt: userNameChangedAt, + Scopes: scopes, } users = append(users, user) } @@ -250,11 +356,17 @@ func getUserFromRow(row *sql.Row) *User { var disabledAt *time.Time var previousUsernames string var userNameChangedAt *time.Time + var scopesString *string - if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { + if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil { return nil } + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + return &User{ ID: id, DisplayName: displayName, @@ -263,5 +375,6 @@ func getUserFromRow(row *sql.Row) *User { DisabledAt: disabledAt, PreviousNames: strings.Split(previousUsernames, ","), NameChangedAt: userNameChangedAt, + Scopes: scopes, } } diff --git a/router/middleware/auth.go b/router/middleware/auth.go index 2b0db88c9..7f1a10832 100644 --- a/router/middleware/auth.go +++ b/router/middleware/auth.go @@ -111,3 +111,24 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc { handler(w, r) }) } + +// RequireUserModerationScopeAccesstoken will validate a provided user's access token and make sure the associated user is enabled +// and has "MODERATOR" scope assigned to the user. +func RequireUserModerationScopeAccesstoken(handler http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.URL.Query().Get("accessToken") + if accessToken == "" { + accessDenied(w) + return + } + + // A user is required to use the websocket + user := user.GetUserByToken(accessToken) + if user == nil || !user.IsEnabled() || !user.IsModerator() { + accessDenied(w) + return + } + + handler(w, r) + }) +} diff --git a/router/router.go b/router/router.go index 2a71f97e8..4612c728b 100644 --- a/router/router.go +++ b/router/router.go @@ -110,6 +110,12 @@ func Start() error { // Get a list of disabled users http.HandleFunc("/api/admin/chat/users/disabled", middleware.RequireAdminAuth(admin.GetDisabledUsers)) + // Set moderator status for a user + http.HandleFunc("/api/admin/chat/users/setmoderator", middleware.RequireAdminAuth(admin.UpdateUserModerator)) + + // Get a list of moderator users + http.HandleFunc("/api/admin/chat/users/moderators", middleware.RequireAdminAuth(admin.GetModerators)) + // Update config values // Change the current streaming key in memory @@ -232,6 +238,14 @@ func Start() error { // set custom style css http.HandleFunc("/api/admin/config/customstyles", middleware.RequireAdminAuth(admin.SetCustomStyles)) + // Inline chat moderation actions + + // Update chat message visibility + http.HandleFunc("/api/chat/updatemessagevisibility", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateMessageVisibility)) + + // Enable/disable a user + http.HandleFunc("/api/chat/users/setenabled", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateUserEnabled)) + // websocket http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { chat.HandleClientConnection(w, r) diff --git a/test/automated/api/chatusers.test.js b/test/automated/api/chatusers.test.js index 6f935c54b..42ac9a818 100644 --- a/test/automated/api/chatusers.test.js +++ b/test/automated/api/chatusers.test.js @@ -80,6 +80,26 @@ test('verify user is enabled', async (done) => { done(); }); +test('can set the user as moderator', async (done) => { + await request + .post('/api/admin/chat/users/setmoderator') + .send({ userId: userId, isModerator: true }) + .auth('admin', 'abc123') + .expect(200); + done(); +}); + +test('verify user is a moderator', async (done) => { + const response = await request + .get('/api/admin/chat/users/moderators') + .auth('admin', 'abc123') + .expect(200); + const tokenCheck = response.body.filter((user) => user.id === userId); + expect(tokenCheck).toHaveLength(1); + + done(); +}); + test('verify user list is populated', async (done) => { const ws = new WebSocket( `ws://localhost:8080/ws?accessToken=${accessToken}`, diff --git a/test/automated/api/run.sh b/test/automated/api/run.sh index 1f19c79d7..0e733be61 100755 --- a/test/automated/api/run.sh +++ b/test/automated/api/run.sh @@ -37,7 +37,7 @@ function finish { trap finish EXIT echo "Waiting..." -sleep 13 +sleep 15 # Run the tests against the instance. npm test \ No newline at end of file diff --git a/test/automated/browser/tests/chat.js b/test/automated/browser/tests/chat.js index 23e9ac725..c9dfc58d2 100644 --- a/test/automated/browser/tests/chat.js +++ b/test/automated/browser/tests/chat.js @@ -1,4 +1,10 @@ -async function interactiveChatTest(browser, page, newName, chatMessage, device) { +async function interactiveChatTest( + browser, + page, + newName, + chatMessage, + device +) { it('should have the chat input', async () => { await page.waitForSelector('#message-input'); }); @@ -16,25 +22,29 @@ async function interactiveChatTest(browser, page, newName, chatMessage, device) it('should allow changing the username on ' + device, async () => { await page.waitForSelector('#username-display'); - await page.evaluate(()=>document.querySelector('#username-display').click()) - + await page.evaluate(() => + document.querySelector('#username-display').click() + ); await page.waitForSelector('#username-change-input'); - await page.evaluate(()=>document.querySelector('#username-change-input').click()) - await page.type('#username-change-input', 'a new name'); - - await page.evaluate(()=>document.querySelector('#username-change-input').click()) - await page.type('#username-change-input', newName); - + await page.evaluate(() => + document.querySelector('#username-change-input').click() + ); + await page.evaluate(() => + document.querySelector('#username-change-input').click() + ); await page.waitForSelector('#button-update-username'); - await page.evaluate(()=>document.querySelector('#button-update-username').click()) + + await page.evaluate(() => + document.querySelector('#button-update-username').click() + ); }); it('should allow typing a chat message', async () => { await page.waitForSelector('#message-input'); - await page.evaluate(()=>document.querySelector('#message-input').click()) + await page.evaluate(() => document.querySelector('#message-input').click()); await page.waitForTimeout(1000); - await page.focus('#message-input') - await page.keyboard.type(chatMessage) + await page.focus('#message-input'); + await page.keyboard.type(chatMessage); page.keyboard.press('Enter'); }); } diff --git a/utils/utils.go b/utils/utils.go index c9dba2888..3d2636c6a 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -251,7 +251,7 @@ func VerifyFFMpegPath(path string) error { } mode := stat.Mode() - //source: https://stackoverflow.com/a/60128480 + // source: https://stackoverflow.com/a/60128480 if mode&0111 == 0 { return errors.New("ffmpeg path is not executable") } @@ -270,7 +270,7 @@ func CleanupDirectory(path string) { } } -// FindInSlice will return the index if a string is located in a slice of strings. +// FindInSlice will return if a string is in a slice, and the index of that string. func FindInSlice(slice []string, val string) (int, bool) { for i, item := range slice { if item == val { @@ -280,6 +280,27 @@ func FindInSlice(slice []string, val string) (int, bool) { return -1, false } +// StringSliceToMap is a convinience function to convert a slice of strings into +// a map using the string as the key. +func StringSliceToMap(stringSlice []string) map[string]interface{} { + stringMap := map[string]interface{}{} + + for _, str := range stringSlice { + stringMap[str] = true + } + + return stringMap +} + +// StringMapKeys returns a slice of string keys from a map. +func StringMapKeys(stringMap map[string]interface{}) []string { + stringSlice := []string{} + for k := range stringMap { + stringSlice = append(stringSlice, k) + } + return stringSlice +} + // GenerateRandomDisplayColor will return a random _hue_ to be used when displaying a user. // The UI should determine the right saturation and lightness in order to make it look right. func GenerateRandomDisplayColor() int { diff --git a/webroot/img/menu-filled.svg b/webroot/img/menu-filled.svg new file mode 100644 index 000000000..3a997b411 --- /dev/null +++ b/webroot/img/menu-filled.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webroot/img/menu-vert.svg b/webroot/img/menu-vert.svg new file mode 100644 index 000000000..ab273376a --- /dev/null +++ b/webroot/img/menu-vert.svg @@ -0,0 +1 @@ + diff --git a/webroot/img/menu.svg b/webroot/img/menu.svg new file mode 100644 index 000000000..38bcba04d --- /dev/null +++ b/webroot/img/menu.svg @@ -0,0 +1 @@ + diff --git a/webroot/js/app.js b/webroot/js/app.js index 2ed3d158e..73d2a79eb 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -51,6 +51,7 @@ import { URL_VIEWER_PING, WIDTH_SINGLE_COL, } from './utils/constants.js'; +import { checkIsModerator } from './utils/chat.js'; export default class App extends Component { constructor(props, context) { @@ -67,6 +68,8 @@ export default class App extends Component { chatInputEnabled: false, // chat input box state accessToken: null, username: getLocalStorage(KEY_USERNAME), + isModerator: false, + isRegistering: false, touchKeyboardActive: false, @@ -558,7 +561,10 @@ export default class App extends Component { const { user } = e; const { displayName } = user; - this.setState({ username: displayName }); + this.setState({ + username: displayName, + isModerator: checkIsModerator(e), + }); } } @@ -627,6 +633,7 @@ export default class App extends Component { configData, displayChatPanel, canChat, + isModerator, isPlaying, orientation, @@ -769,6 +776,7 @@ export default class App extends Component { > <${UsernameForm} username=${username} + isModerator=${isModerator} onUsernameChange=${this.handleUsernameChange} onFocus=${this.handleFormFocus} onBlur=${this.handleFormBlur} diff --git a/webroot/js/components/chat/chat-message-view.js b/webroot/js/components/chat/chat-message-view.js index 4eff92ab8..b9941b8ff 100644 --- a/webroot/js/components/chat/chat-message-view.js +++ b/webroot/js/components/chat/chat-message-view.js @@ -10,12 +10,14 @@ import { import { convertToText } from '../../utils/chat.js'; import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; import { getDiffInDaysFromNow } from '../../utils/helpers.js'; +import ModeratorActions from './moderator-actions.js'; export default class ChatMessageView extends Component { constructor(props) { super(props); this.state = { formattedMessage: '', + moderatorMenuOpen: false, }; } @@ -37,11 +39,14 @@ export default class ChatMessageView extends Component { }); } } - render() { - const { message } = this.props; + const { message, isModerator, accessToken } = this.props; const { user, timestamp } = message; - const { displayName, displayColor, createdAt } = user; + const { displayName, displayColor, createdAt, + isModerator: isAuthorModerator, + } = user; + + const isMessageModeratable = isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT; const { formattedMessage } = this.state; if (!formattedMessage) { @@ -61,8 +66,8 @@ export default class ChatMessageView extends Component { ? { backgroundColor: '#667eea' } : { backgroundColor: messageBubbleColorForHue(displayColor) }; const messageClassString = isSystemMessage - ? getSystemMessageClassString() - : getChatMessageClassString(); + ? 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l' + : `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${isMessageModeratable ? 'moderatable' : ''}`; return html`
${displayName}
+ ${isMessageModeratable && html`<${ModeratorActions} message=${message} accessToken=${accessToken} />`}
item.id === messageId ); + // check moderator status + if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { + const modStatusUpdate = checkIsModerator(message); + if (modStatusUpdate !== this.state.isModerator) { + this.setState({ + isModerator: modStatusUpdate, + }); + } + } + const updatedMessageList = [...curMessages]; // Change the visibility of messages by ID. if (messageType === 'VISIBILITY-UPDATE') { const idsToUpdate = message.ids; const visible = message.visible; - updatedMessageList.forEach((item) => { if (idsToUpdate.includes(item.id)) { item.visible = visible; } - - this.forceRender = true; - this.setState({ - messages: updatedMessageList, - }); }); - return; + this.forceRender = true; } else if (existingIndex === -1 && messageVisible) { + // insert message at timestamp const convertedMessage = { ...message, type: 'CHAT', }; - - // insert message at timestamp const insertAtIndex = curMessages.findIndex((item, index) => { const time = item.timestamp || messageTimestamp; const nextMessage = @@ -252,7 +259,11 @@ export default class Chat extends Component { } // if window is blurred and we get a new message, add 1 to title - if (!readonly && messageType === 'CHAT' && this.windowBlurred) { + if ( + !readonly && + messageType === SOCKET_MESSAGE_TYPES.CHAT && + this.windowBlurred + ) { this.numMessagesSinceBlur += 1; } } @@ -366,10 +377,9 @@ export default class Chat extends Component { } render(props, state) { - const { username, readonly, chatInputEnabled, inputMaxBytes } = props; - const { messages, chatUserNames, webSocketConnected } = state; - - this.forceRender = false; + const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } = + props; + const { messages, chatUserNames, webSocketConnected, isModerator } = state; const messageList = messages .filter((message) => message.visible !== false) @@ -379,6 +389,8 @@ export default class Chat extends Component { message=${message} username=${username} key=${message.id} + isModerator=${isModerator} + accessToken=${accessToken} />` ); diff --git a/webroot/js/components/chat/message.js b/webroot/js/components/chat/message.js index f9733eece..e714eaf29 100644 --- a/webroot/js/components/chat/message.js +++ b/webroot/js/components/chat/message.js @@ -5,74 +5,54 @@ const html = htm.bind(h); import ChatMessageView from './chat-message-view.js'; import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; +import { checkIsModerator } from '../../utils/chat.js'; + +function SystemMessage(props) { + const { contents } = props; + return html` +
+
+
+ ${contents} +
+
+
+ `; +} export default function Message(props) { const { message } = props; - const { type } = message; + const { type, oldName, user, body } = message; if ( type === SOCKET_MESSAGE_TYPES.CHAT || type === SOCKET_MESSAGE_TYPES.SYSTEM ) { return html`<${ChatMessageView} ...${props} />`; } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { - const { oldName, user } = message; const { displayName } = user; - return html` -
-
-
- ${oldName} is now known as ${' '} - ${displayName}. -
-
-
+ const contents = html` + <> + ${oldName} is now known as ${' '} + ${displayName}. + `; + return html`<${SystemMessage} contents=${contents}/>`; } else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) { - const { user } = message; const { displayName } = user; - return html` -
-
-
- ${displayName} joined the chat. -
-
-
- `; + const contents = html`${displayName} joined the chat.`; + return html`<${SystemMessage} contents=${contents}/>`; } else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) { - const { body } = message; - const formattedMessage = `${body}`; - return html` -
-
-
- -
-
-
- `; + const contents = html``; + return html`<${SystemMessage} contents=${contents}/>`; } else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { - // noop for now + // moderator message + const isModerator = checkIsModerator(message); + if (isModerator) { + const contents = html`You are now a moderator.`; + return html`<${SystemMessage} contents=${contents}/>`; + } } else { console.log('Unknown message type:', type); } diff --git a/webroot/js/components/chat/moderator-actions.js b/webroot/js/components/chat/moderator-actions.js new file mode 100644 index 000000000..e12762807 --- /dev/null +++ b/webroot/js/components/chat/moderator-actions.js @@ -0,0 +1,295 @@ +import { h, Component, createRef } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; +import { textColorForHue } from '../../utils/user-colors.js'; +import { URL_BAN_USER, URL_HIDE_MESSAGE } from '../../utils/constants.js'; + +const html = htm.bind(h); + +const HIDE_MESSAGE_ICON = '🐵'; +const HIDE_MESSAGE_ICON_HOVER = '🙈'; +const BAN_USER_ICON = '👤'; +const BAN_USER_ICON_HOVER = '🚫'; + +export default class ModeratorActions extends Component { + constructor(props) { + super(props); + this.state = { + isMenuOpen: false, + }; + this.handleOpenMenu = this.handleOpenMenu.bind(this); + this.handleCloseMenu = this.handleCloseMenu.bind(this); + } + + handleOpenMenu() { + this.setState({ + isMenuOpen: true, + }); + } + + handleCloseMenu() { + this.setState({ + isMenuOpen: false, + }); + } + + render() { + const { isMenuOpen } = this.state; + const { message, accessToken } = this.props; + const { id } = message; + const { user } = message; + + return html` +
+ + + ${isMenuOpen && + html`<${ModeratorMenu} + message=${message} + onDismiss=${this.handleCloseMenu} + accessToken=${accessToken} + id=${id} + userId=${user.id} + />`} +
+ `; + } +} + +class ModeratorMenu extends Component { + constructor(props) { + super(props); + this.menuNode = createRef(); + + this.state = { + displayMoreInfo: false, + }; + this.handleClickOutside = this.handleClickOutside.bind(this); + this.handleToggleMoreInfo = this.handleToggleMoreInfo.bind(this); + this.handleBanUser = this.handleBanUser.bind(this); + this.handleHideMessage = this.handleHideMessage.bind(this); + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClickOutside, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside, false); + } + + handleClickOutside = (e) => { + if ( + this.menuNode && + !this.menuNode.current.contains(e.target) && + this.props.onDismiss + ) { + this.props.onDismiss(); + } + }; + + handleToggleMoreInfo() { + this.setState({ + displayMoreInfo: !this.state.displayMoreInfo, + }); + } + + async handleHideMessage() { + if (!confirm("Are you sure you want to remove this message from chat?")) { + this.props.onDismiss(); + return; + } + + const { accessToken, id } = this.props; + const url = new URL(location.origin + URL_HIDE_MESSAGE); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ idArray: [id] }), + }; + + try { + await fetch(hideMessageUrl, options); + } catch(e) { + console.error(e); + } + + this.props.onDismiss(); + } + + async handleBanUser() { + if (!confirm("Are you sure you want to remove this user from chat?")) { + this.props.onDismiss(); + return; + } + + const { accessToken, userId } = this.props; + const url = new URL(location.origin + URL_BAN_USER); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId: userId }), + }; + + try { + await fetch(hideMessageUrl, options); + } catch(e) { + console.error(e); + } + + this.props.onDismiss(); + } + + render() { + const { message } = this.props; + const { displayMoreInfo } = this.state; + return html` + + `; + } +} + +// 3 dots button +function ModeratorMenuItem({ icon, hoverIcon, label, onClick }) { + return html` + + `; +} + +// more details panel that display message, prev usernames, actions +function ModeratorMoreInfoContainer({ message, handleHideMessage, handleBanUser }) { + const { user, timestamp, body } = message; + const { + displayName, + createdAt, + previousNames, + displayColor, + } = user; + const isAuthorModerator = user.scopes && user.scopes.contains('MODERATOR'); + + const authorTextColor = { color: textColorForHue(displayColor) }; + const createDate = new Date(createdAt); + const sentDate = new Date(timestamp); + return html` +
+
+

+ Sent at ${sentDate.toLocaleTimeString()} +

+
+
+
+

Sent by:

+

+ ${displayName} +

+ +

+ First joined: ${createDate.toLocaleString()} +

+ + ${previousNames.length > 1 && + html` +

+ Previously known as: ${' '} + ${previousNames.join(', ')} +

+ `} +
+
+ <${handleHideMessage && ModeratorMenuItem} + icon=${HIDE_MESSAGE_ICON} + hoverIcon=${HIDE_MESSAGE_ICON_HOVER} + label="Hide message" + onClick="${handleHideMessage}" + /> + <${handleBanUser && ModeratorMenuItem} + icon=${BAN_USER_ICON} + hoverIcon=${BAN_USER_ICON_HOVER} + label="Ban user" + onClick="${handleBanUser}" + /> +
+
+ `; +} diff --git a/webroot/js/components/chat/username.js b/webroot/js/components/chat/username.js index bfbcf8453..ea4350cd4 100644 --- a/webroot/js/components/chat/username.js +++ b/webroot/js/components/chat/username.js @@ -75,7 +75,7 @@ export default class UsernameForm extends Component { } render(props, state) { - const { username } = props; + const { username, isModerator } = props; const { displayForm } = state; const styles = { @@ -87,11 +87,13 @@ export default class UsernameForm extends Component { }, }; + + return ( html`
-
- ${username} +
+ ${username}
diff --git a/webroot/js/utils/chat.js b/webroot/js/utils/chat.js index 4c45980ca..9cafe9b31 100644 --- a/webroot/js/utils/chat.js +++ b/webroot/js/utils/chat.js @@ -182,3 +182,16 @@ export function emojify(HTML, emojiList) { } return HTML; } + + +// MODERATOR UTILS +export function checkIsModerator(message) { + const { user } = message; + const { scopes } = user; + + if (!scopes || scopes.length === 0) { + return false; + } + + return scopes.includes('MODERATOR'); +} diff --git a/webroot/js/utils/constants.js b/webroot/js/utils/constants.js index febb017a6..355784110 100644 --- a/webroot/js/utils/constants.js +++ b/webroot/js/utils/constants.js @@ -6,6 +6,10 @@ export const URL_CUSTOM_EMOJIS = `/api/emoji`; export const URL_CONFIG = `/api/config`; export const URL_VIEWER_PING = `/api/ping`; +// inline moderation actions +export const URL_HIDE_MESSAGE = `/api/chat/updatemessagevisibility`; +export const URL_BAN_USER = `/api/chat/users/setenabled`; + // TODO: This directory is customizable in the config. So we should expose this via the config API. export const URL_STREAM = `/hls/stream.m3u8`; export const URL_WEBSOCKET = `${ diff --git a/webroot/styles/chat.css b/webroot/styles/chat.css index ede7c60f3..73523e699 100644 --- a/webroot/styles/chat.css +++ b/webroot/styles/chat.css @@ -189,3 +189,134 @@ /* MESSAGE TEXT CONTENT */ /* MESSAGE TEXT CONTENT */ + +/* MODERATOR STYLES */ +/* MODERATOR STYLES */ +/* MODERATOR STYLES */ + +.moderator-flag:before { + content: '👑'; /* this can be a path to an svg */ + display: inline-block; + margin-right: .5rem; + vertical-align: bottom; +} + +.moderator-actions-group { + position: absolute; + top: 0; + right: 0; +} + +.message.moderatable .moderator-actions-group { + opacity: 0; +} +.message.moderatable:hover .moderator-actions-group { + opacity: 1; +} +.message.moderator-menu-open .moderator-actions-group { + opacity: 1; +} + +.message.moderatable:focus-within .moderator-actions-group { + opacity: 1; +} + +.moderator-menu-button { + padding: .15rem; + height: 1.75rem; + width: 1.75rem; + border-radius: 50%; + text-align: center; + margin-left: .05rem; + font-size: 1rem; + border: 1px solid transparent; + opacity: .5; +} +.moderator-menu-button:hover { + background-color: rgba(0,0,0,.5); + opacity: 1; +} +.moderator-menu-button:focus { + border-color: white; + opacity: 1; +} +.moderator-action { + padding: .15rem; + height: 1.5rem; + width: 1.5rem; + border-radius: 50%; + text-align: center; + margin-left: .05rem; + font-size: 1rem; +} + +.message button:focus, +.message button:active { + outline: none; +} + +.message.moderatable:last-of-type .moderator-actions-menu, +.moderator-actions-menu { + position: absolute; + bottom: 0; + right: .5rem; + z-index: 999; +} +.message.moderatable:first-of-type .moderator-actions-menu, +.message.moderatable:nth-of-type(2) .moderator-actions-menu, +.message.moderatable:first-of-type .moderator-more-info-container, +.message.moderatable:nth-of-type(2) .moderator-more-info-container { + top: 0; + bottom: unset; +} + + +.moderator-menu-item { + font-size: .875rem; + position: relative; + border: 1px solid transparent; +} +.moderator-menu-item:focus { + border: 1px solid white; +} +.moderator-menu-item .moderator-menu-icon { + height: 1.5rem; + width: 1.5rem; + font-size: 1.5em; + vertical-align: text-bottom; + display: inline-block; +} + +.moderator-menu-item .menu-icon-hover { + display: none; + z-index: 2; +} +.moderator-menu-item:hover .menu-icon-base { + display: none; +} +.moderator-menu-item:hover .menu-icon-hover { + display: inline-block; +} + +.moderator-more-info-container { + position: absolute; + bottom: 0; + right: 0; + z-index: 5; + width: calc(var(--right-col-width) - 2rem); +} + +.moderator-more-info-message { + overflow-y: auto; + max-height: 6em; + padding: .75rem; +} +@media screen and (max-width: 729px) { + .moderator-more-info-container { + width: auto; + } +} + +/* MODERATOR STYLES */ +/* MODERATOR STYLES */ +/* MODERATOR STYLES */