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:
@@ -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 {
|
||||
|
||||
9
core/chat/events/connectedClientInfo.go
Normal file
9
core/chat/events/connectedClientInfo.go
Normal 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"`
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user