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