Browse Source
* - 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>pull/1512/head
23 changed files with 902 additions and 116 deletions
@ -0,0 +1,9 @@
@@ -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"` |
||||
} |
@ -0,0 +1,295 @@
@@ -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> |
||||
`;
|
||||
} |
Loading…
Reference in new issue