Inline chat moderation UI (#1331)

* - mock detect when user turns into moderator
- add moderator indicator to display on messages and username changer

* also mock moderator flag in message payload about user to display indicator

* add some menu looking icons and a menu of actions

* WIP chat moderators

* Add support for admin promoting a user to moderator

* WIP-
open a more info panel of user+message info; add some a11y to buttons

* style the details panel

* adjust positioning of menus

* Merge fixes. ChatClient->Client ChatServer->Server

* Remove moderator bool placeholders to use real state

* Support inline hiding of messages by moderators

* Support inline banning of chat users

* Cleanup linter warnings

* Puppeteer tests fail after typing take place

* Manually resolve conflicts in chat between moderator feature and develop

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

View File

@@ -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;
this.forceRender = true;
} else if (existingIndex === -1 && messageVisible) {
// insert message at timestamp
const convertedMessage = {
...message,
type: 'CHAT',
};
// insert message at timestamp
const insertAtIndex = curMessages.findIndex((item, index) => {
const time = item.timestamp || messageTimestamp;
const nextMessage =
@@ -252,7 +259,11 @@ export default class Chat extends Component {
}
// if window is blurred and we get a new message, add 1 to title
if (!readonly && messageType === 'CHAT' && this.windowBlurred) {
if (
!readonly &&
messageType === SOCKET_MESSAGE_TYPES.CHAT &&
this.windowBlurred
) {
this.numMessagesSinceBlur += 1;
}
}
@@ -366,10 +377,9 @@ export default class Chat extends Component {
}
render(props, state) {
const { username, readonly, chatInputEnabled, inputMaxBytes } = props;
const { messages, chatUserNames, webSocketConnected } = state;
this.forceRender = false;
const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } =
props;
const { messages, chatUserNames, webSocketConnected, isModerator } = state;
const messageList = messages
.filter((message) => message.visible !== false)
@@ -379,6 +389,8 @@ export default class Chat extends Component {
message=${message}
username=${username}
key=${message.id}
isModerator=${isModerator}
accessToken=${accessToken}
/>`
);

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"
>
<span class="font-bold">${oldName}</span> is now known as ${' '}
<span class="font-bold">${displayName}</span>.
</div>
</div>
</div>
const contents = html`
<>
<span class="font-bold">${oldName}</span> is now known as ${' '}
<span class="font-bold">${displayName}</span>.
</>
`;
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 = `${