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

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

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