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 <gabek@real-ity.com>
This commit is contained in:
gingervitis
2021-11-02 19:27:41 -07:00
committed by GitHub
parent 4a52ba9f35
commit 9a91324456
23 changed files with 902 additions and 116 deletions

View File

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

View File

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

View File

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

View File

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