mirror of
https://github.com/owncast/owncast.git
synced 2024-11-21 20:28:15 +03:00
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:
parent
4a52ba9f35
commit
9a91324456
23 changed files with 902 additions and 116 deletions
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -37,7 +37,7 @@ function finish {
|
|||
trap finish EXIT
|
||||
|
||||
echo "Waiting..."
|
||||
sleep 13
|
||||
sleep 15
|
||||
|
||||
# Run the tests against the instance.
|
||||
npm test
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
41
webroot/img/menu-filled.svg
Normal file
41
webroot/img/menu-filled.svg
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 330 330" style="enable-background:new 0 0 330 330;" xml:space="preserve"
|
||||
>
|
||||
<path d="M165,0C74.019,0,0,74.019,0,165s74.019,165,165,165s165-74.019,165-165S255.981,0,165,0z M85,190
|
||||
c-13.785,0-25-11.215-25-25s11.215-25,25-25s25,11.215,25,25S98.785,190,85,190z M165,190c-13.785,0-25-11.215-25-25
|
||||
s11.215-25,25-25s25,11.215,25,25S178.785,190,165,190z M245,190c-13.785,0-25-11.215-25-25s11.215-25,25-25
|
||||
c13.785,0,25,11.215,25,25S258.785,190,245,190z"
|
||||
fill="red"
|
||||
/>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 790 B |
1
webroot/img/menu-vert.svg
Normal file
1
webroot/img/menu-vert.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg enable-background="new 0 0 32 32" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg" fill="white"><path id="XMLID_294_" d="m13 16c0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3-3 1.346-3 3z"/><path id="XMLID_295_" d="m13 26c0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3-3 1.346-3 3z"/><path id="XMLID_297_" d="m13 6c0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3-3 1.346-3 3z"/></svg>
|
After Width: | Height: | Size: 411 B |
1
webroot/img/menu.svg
Normal file
1
webroot/img/menu.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg enable-background="new 0 0 515.555 515.555" height="512" viewBox="0 0 515.555 515.555" width="512" xmlns="http://www.w3.org/2000/svg" fill="white"><path d="m496.679 212.208c25.167 25.167 25.167 65.971 0 91.138s-65.971 25.167-91.138 0-25.167-65.971 0-91.138 65.971-25.167 91.138 0"/><path d="m303.347 212.208c25.167 25.167 25.167 65.971 0 91.138s-65.971 25.167-91.138 0-25.167-65.971 0-91.138 65.971-25.167 91.138 0"/><path d="m110.014 212.208c25.167 25.167 25.167 65.971 0 91.138s-65.971 25.167-91.138 0-25.167-65.971 0-91.138 65.971-25.167 91.138 0"/></svg>
|
After Width: | Height: | Size: 564 B |
|
@ -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}
|
||||
|
|
|
@ -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`
|
||||
<div
|
||||
|
@ -73,11 +78,12 @@ export default class ChatMessageView extends Component {
|
|||
<div class="message-content break-words w-full">
|
||||
<div
|
||||
style=${authorTextColor}
|
||||
class="message-author font-bold"
|
||||
class="message-author font-bold${isAuthorModerator ? ' moderator-flag' : ''}"
|
||||
title=${userMetadata}
|
||||
>
|
||||
${displayName}
|
||||
</div>
|
||||
${isMessageModeratable && html`<${ModeratorActions} message=${message} accessToken=${accessToken} />`}
|
||||
<div
|
||||
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
|
||||
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
|
||||
|
@ -88,14 +94,6 @@ export default class ChatMessageView extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
function getSystemMessageClassString() {
|
||||
return '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';
|
||||
}
|
||||
|
||||
function getChatMessageClassString() {
|
||||
return 'message flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm';
|
||||
}
|
||||
|
||||
export async function formatMessageText(message, username) {
|
||||
let formattedText = getMessageWithEmbeds(message);
|
||||
formattedText = convertToMarkup(formattedText);
|
||||
|
|
|
@ -6,7 +6,10 @@ import Message from './message.js';
|
|||
import ChatInput from './chat-input.js';
|
||||
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
import { jumpToBottom, debounce } from '../../utils/helpers.js';
|
||||
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
|
||||
import {
|
||||
extraUserNamesFromMessageHistory,
|
||||
checkIsModerator,
|
||||
} from '../../utils/chat.js';
|
||||
import {
|
||||
URL_CHAT_HISTORY,
|
||||
MESSAGE_JUMPTOBOTTOM_BUFFER,
|
||||
|
@ -21,6 +24,7 @@ export default class Chat extends Component {
|
|||
messages: [],
|
||||
newMessagesReceived: false,
|
||||
webSocketConnected: true,
|
||||
isModerator: false,
|
||||
};
|
||||
|
||||
this.scrollableMessagesContainer = createRef();
|
||||
|
@ -191,31 +195,34 @@ export default class Chat extends Component {
|
|||
(item) => 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;
|
||||
} 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}
|
||||
/>`
|
||||
);
|
||||
|
||||
|
|
|
@ -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`
|
||||
<div class="message message-name-change flex items-center justify-start p-3">
|
||||
<div class="message-content flex flex-row items-center justify-center text-sm w-full">
|
||||
<div class="text-white text-center opacity-50 overflow-hidden break-words">
|
||||
${contents}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
class="message message-name-change flex items-center justify-start p-3"
|
||||
>
|
||||
<div
|
||||
class="message-content flex flex-row items-center justify-center text-sm w-full"
|
||||
>
|
||||
<div
|
||||
class="text-white text-center opacity-50 overflow-hidden break-words"
|
||||
>
|
||||
const contents = html`
|
||||
<>
|
||||
<span class="font-bold">${oldName}</span> is now known as ${' '}
|
||||
<span class="font-bold">${displayName}</span>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
`;
|
||||
return html`<${SystemMessage} contents=${contents}/>`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
|
||||
const { user } = message;
|
||||
const { displayName } = user;
|
||||
return html`
|
||||
<div
|
||||
class="message message-user-joined flex items-center justify-start p-3"
|
||||
>
|
||||
<div
|
||||
class="message-content flex flex-row items-center justify-center text-sm w-full"
|
||||
>
|
||||
<div
|
||||
class="text-white text-center opacity-50 overflow-hidden break-words"
|
||||
>
|
||||
<span class="font-bold">${displayName}</span> joined the chat.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const contents = html`<span class="font-bold">${displayName}</span> joined the chat.`;
|
||||
return html`<${SystemMessage} contents=${contents}/>`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) {
|
||||
const { body } = message;
|
||||
const formattedMessage = `${body}`;
|
||||
return html`
|
||||
<div
|
||||
class="message message-user-joined flex items-center justify-start p-3"
|
||||
>
|
||||
<div
|
||||
class="message-content flex flex-row items-center justify-center text-sm w-full"
|
||||
>
|
||||
<div
|
||||
class="text-white text-center opacity-50 overflow-hidden break-words"
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const contents = html`<span
|
||||
dangerouslySetInnerHTML=${{ __html: body }}
|
||||
></span>`;
|
||||
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`<span class="font-bold moderator-flag">You are now a moderator.</span>`;
|
||||
return html`<${SystemMessage} contents=${contents}/>`;
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown message type:', type);
|
||||
}
|
||||
|
|
295
webroot/js/components/chat/moderator-actions.js
Normal file
295
webroot/js/components/chat/moderator-actions.js
Normal file
|
@ -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`
|
||||
<div class="moderator-actions-group flex flex-row text-xs p-3">
|
||||
<button
|
||||
type="button"
|
||||
class="moderator-menu-button"
|
||||
onClick=${this.handleOpenMenu}
|
||||
title="Moderator actions"
|
||||
alt="Moderator actions"
|
||||
aria-haspopup="true"
|
||||
aria-controls="open-mod-actions-menu"
|
||||
aria-expanded=${isMenuOpen}
|
||||
id="open-mod-actions-button"
|
||||
>
|
||||
<img src="/img/menu-vert.svg" alt="" />
|
||||
</button>
|
||||
|
||||
${isMenuOpen &&
|
||||
html`<${ModeratorMenu}
|
||||
message=${message}
|
||||
onDismiss=${this.handleCloseMenu}
|
||||
accessToken=${accessToken}
|
||||
id=${id}
|
||||
userId=${user.id}
|
||||
/>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
<ul
|
||||
role="menu"
|
||||
id="open-mod-actions-menu"
|
||||
aria-labelledby="open-mod-actions-button"
|
||||
class="moderator-actions-menu bg-gray-700 rounded-lg shadow-md"
|
||||
ref=${this.menuNode}
|
||||
>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon=${HIDE_MESSAGE_ICON}
|
||||
hoverIcon=${HIDE_MESSAGE_ICON_HOVER}
|
||||
label="Hide message"
|
||||
onClick="${this.handleHideMessage}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon=${BAN_USER_ICON}
|
||||
hoverIcon=${BAN_USER_ICON_HOVER}
|
||||
label="Ban user"
|
||||
onClick="${this.handleBanUser}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon=${html`<img src="/img/menu.svg" alt="" />`}
|
||||
label="More Info"
|
||||
onClick=${this.handleToggleMoreInfo}
|
||||
/>
|
||||
</li>
|
||||
${displayMoreInfo &&
|
||||
html`<${ModeratorMoreInfoContainer} message=${message} handleBanUser=${this.handleBanUser} handleHideMessage=${this.handleHideMessage} />`}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3 dots button
|
||||
function ModeratorMenuItem({ icon, hoverIcon, label, onClick }) {
|
||||
return html`
|
||||
<button
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick=${onClick}
|
||||
className="moderator-menu-item w-full py-2 px-4 text-white text-left whitespace-no-wrap rounded-lg hover:bg-gray-600"
|
||||
>
|
||||
${icon &&
|
||||
html`<span
|
||||
className="moderator-menu-icon menu-icon-base inline-block align-bottom mr-4"
|
||||
>${icon}</span
|
||||
>`}
|
||||
<span
|
||||
className="moderator-menu-icon menu-icon-hover inline-block align-bottom mr-4"
|
||||
>${hoverIcon || icon}</span
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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`
|
||||
<div
|
||||
className="moderator-more-info-container text-gray-300 bg-gray-800 rounded-lg p-4 border border-white text-base absolute"
|
||||
>
|
||||
<div
|
||||
className="moderator-more-info-message scrollbar-hidden bg-gray-700 rounded-md pb-2"
|
||||
>
|
||||
<p className="text-xs text-gray-500">
|
||||
Sent at ${sentDate.toLocaleTimeString()}
|
||||
</p>
|
||||
<div className="text-sm" dangerouslySetInnerHTML=${{ __html: body }} />
|
||||
</div>
|
||||
<div className="moderator-more-info-user py-2 my-2">
|
||||
<p className="text-xs text-gray-500">Sent by:</p>
|
||||
<p
|
||||
className="font-bold ${isAuthorModerator && ' moderator-flag'}"
|
||||
style=${authorTextColor}
|
||||
>
|
||||
${displayName}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
First joined: ${createDate.toLocaleString()}
|
||||
</p>
|
||||
|
||||
${previousNames.length > 1 &&
|
||||
html`
|
||||
<p className="text-xs text-gray-500 my-1">
|
||||
Previously known as: ${' '}
|
||||
<span className="text-white text-gray-400"
|
||||
>${previousNames.join(', ')}</span
|
||||
>
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
<div
|
||||
className="moderator-more-info-actions pt-2 flex flex-row border-t border-gray-700 shadow-md"
|
||||
>
|
||||
<${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}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
|
@ -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`
|
||||
<div id="user-info" class="whitespace-nowrap">
|
||||
<div id="user-info-display" style=${styles.info} title="Click to update user name" class="flex flex-row justify-end items-center cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75" onClick=${this.handleDisplayForm}>
|
||||
<span id="username-display" class="text-indigo-600 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap">${username}</span>
|
||||
<div id="user-info-display" style=${styles.info} title="Click to update user name" class="flex flex-row justify-end items-center align-middle cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75" onClick=${this.handleDisplayForm}>
|
||||
<span id="username-display" class="text-indigo-600 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap ${isModerator && 'moderator-flag'}">${username}</span>
|
||||
</div>
|
||||
|
||||
<div id="user-info-change" class="flex flex-row flex-no-wrap p-1 items-center justify-end" style=${styles.form}>
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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 = `${
|
||||
|
|
|
@ -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 */
|
||||
|
|
Loading…
Reference in a new issue