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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 902 additions and 116 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@ function finish {
trap finish EXIT
echo "Waiting..."
sleep 13
sleep 15
# Run the tests against the instance.
npm test

View file

@ -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');
});
}

View file

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

View 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

View 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
View 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

View file

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

View file

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

View file

@ -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}
/>`
);

View file

@ -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);
}

View 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>
`;
}

View file

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

View file

@ -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');
}

View file

@ -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 = `${

View file

@ -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 */