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:
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;
|
||||
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}
|
||||
/>`
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
Reference in New Issue
Block a user