Add support for disabling chat. Closes #472 (#799)

This commit is contained in:
Gabe Kangas 2021-03-14 11:46:27 -07:00 committed by GitHub
parent 40264bec8c
commit bf33d08384
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 183 additions and 97 deletions

View file

@ -435,6 +435,23 @@ func SetSocialHandles(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "social handles updated") controllers.WriteSimpleResponse(w, true, "social handles updated")
} }
// SetChatDisabled will disable chat functionality.
func SetChatDisabled(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update chat disabled")
return
}
data.SetChatDisabled(configValue.Value.(bool))
controllers.WriteSimpleResponse(w, true, "chat disabled status updated")
}
func requirePOST(w http.ResponseWriter, r *http.Request) bool { func requirePOST(w http.ResponseWriter, r *http.Request) bool {
if r.Method != controllers.POST { if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") controllers.WriteSimpleResponse(w, false, r.Method+" not supported")

View file

@ -43,6 +43,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
StreamKey: data.GetStreamKey(), StreamKey: data.GetStreamKey(),
WebServerPort: config.WebServerPort, WebServerPort: config.WebServerPort,
RTMPServerPort: data.GetRTMPPortNumber(), RTMPServerPort: data.GetRTMPPortNumber(),
ChatDisabled: data.GetChatDisabled(),
VideoSettings: videoSettings{ VideoSettings: videoSettings{
VideoQualityVariants: videoQualityVariants, VideoQualityVariants: videoQualityVariants,
LatencyLevel: data.GetStreamLatencyLevel().Level, LatencyLevel: data.GetStreamLatencyLevel().Level,
@ -71,6 +72,7 @@ type serverConfigAdminResponse struct {
VideoSettings videoSettings `json:"videoSettings"` VideoSettings videoSettings `json:"videoSettings"`
LatencyLevel int `json:"latencyLevel"` LatencyLevel int `json:"latencyLevel"`
YP yp `json:"yp"` YP yp `json:"yp"`
ChatDisabled bool `json:"chatDisabled"`
} }
type videoSettings struct { type videoSettings struct {

View file

@ -21,6 +21,7 @@ type webConfigResponse struct {
ExtraPageContent string `json:"extraPageContent"` ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"` SocialHandles []models.SocialHandle `json:"socialHandles"`
ChatDisabled bool `json:"chatDisabled"`
} }
// GetWebConfig gets the status of the server. // GetWebConfig gets the status of the server.
@ -48,6 +49,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
ExtraPageContent: pageContent, ExtraPageContent: pageContent,
StreamTitle: data.GetStreamTitle(), StreamTitle: data.GetStreamTitle(),
SocialHandles: socialHandles, SocialHandles: socialHandles,
ChatDisabled: data.GetChatDisabled(),
} }
if err := json.NewEncoder(w).Encode(configuration); err != nil { if err := json.NewEncoder(w).Encode(configuration); err != nil {

View file

@ -135,6 +135,10 @@ func (s *server) Listen() {
case c := <-s.delCh: case c := <-s.delCh:
s.removeClient(c) s.removeClient(c)
case msg := <-s.sendAllCh: case msg := <-s.sendAllCh:
if data.GetChatDisabled() {
break
}
if !msg.Empty() { if !msg.Empty() {
// set defaults before sending msg to anywhere // set defaults before sending msg to anywhere
msg.SetDefaults() msg.SetDefaults()

View file

@ -33,6 +33,7 @@ const s3StorageEnabledKey = "s3_storage_enabled"
const s3StorageConfigKey = "s3_storage_config" const s3StorageConfigKey = "s3_storage_config"
const videoLatencyLevel = "video_latency_level" const videoLatencyLevel = "video_latency_level"
const videoStreamOutputVariantsKey = "video_stream_output_variants" const videoStreamOutputVariantsKey = "video_stream_output_variants"
const chatDisabledKey = "chat_disabled"
// GetExtraPageBodyContent will return the user-supplied body content. // GetExtraPageBodyContent will return the user-supplied body content.
func GetExtraPageBodyContent() string { func GetExtraPageBodyContent() string {
@ -408,6 +409,21 @@ func SetStreamOutputVariants(variants []models.StreamOutputVariant) error {
return _datastore.Save(configEntry) return _datastore.Save(configEntry)
} }
// SetChatDisabled will disable chat if set to true.
func SetChatDisabled(disabled bool) error {
return _datastore.SetBool(chatDisabledKey, disabled)
}
// GetChatDisabled will return if chat is disabled.
func GetChatDisabled() bool {
disabled, err := _datastore.GetBool(chatDisabledKey)
if err == nil {
return disabled
}
return false
}
// VerifySettings will perform a sanity check for specific settings values. // VerifySettings will perform a sanity check for specific settings values.
func VerifySettings() error { func VerifySettings() error {
if GetStreamKey() == "" { if GetStreamKey() == "" {

View file

@ -109,6 +109,9 @@ func Start() error {
// Server summary // Server summary
http.HandleFunc("/api/admin/config/serversummary", middleware.RequireAdminAuth(admin.SetServerSummary)) http.HandleFunc("/api/admin/config/serversummary", middleware.RequireAdminAuth(admin.SetServerSummary))
// Disable chat
http.HandleFunc("/api/admin/config/chat/disable", middleware.RequireAdminAuth(admin.SetChatDisabled))
// Return all webhooks // Return all webhooks
http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks)) http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks))

View file

@ -8,7 +8,11 @@ import UsernameForm from './components/chat/username.js';
import VideoPoster from './components/video-poster.js'; import VideoPoster from './components/video-poster.js';
import Chat from './components/chat/chat.js'; import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js'; import Websocket from './utils/websocket.js';
import { parseSecondsToDurationString, hasTouchScreen, getOrientation } from './utils/helpers.js'; import {
parseSecondsToDurationString,
hasTouchScreen,
getOrientation,
} from './utils/helpers.js';
import { import {
addNewlines, addNewlines,
@ -50,6 +54,7 @@ export default class App extends Component {
websocket: new Websocket(), websocket: new Websocket(),
displayChat: chatStorage === null ? true : chatStorage, displayChat: chatStorage === null ? true : chatStorage,
chatInputEnabled: false, // chat input box state chatInputEnabled: false, // chat input box state
chatDisabled: false,
username: getLocalStorage(KEY_USERNAME) || generateUsername(), username: getLocalStorage(KEY_USERNAME) || generateUsername(),
touchKeyboardActive: false, touchKeyboardActive: false,
@ -195,12 +200,7 @@ export default class App extends Component {
if (!status) { if (!status) {
return; return;
} }
const { const { viewerCount, online, lastConnectTime, streamTitle } = status;
viewerCount,
online,
lastConnectTime,
streamTitle,
} = status;
this.lastDisconnectTime = status.lastDisconnectTime; this.lastDisconnectTime = status.lastDisconnectTime;
@ -265,7 +265,9 @@ export default class App extends Component {
} }
if (this.windowBlurred) { if (this.windowBlurred) {
document.title = ` 🔴 ${this.state.configData && this.state.configData.name}`; document.title = ` 🔴 ${
this.state.configData && this.state.configData.name
}`;
} }
} }
@ -289,7 +291,9 @@ export default class App extends Component {
}); });
if (this.windowBlurred) { if (this.windowBlurred) {
document.title = ` 🟢 ${this.state.configData && this.state.configData.name}`; document.title = ` 🟢 ${
this.state.configData && this.state.configData.name
}`;
} }
} }
@ -302,7 +306,6 @@ export default class App extends Component {
this.setState({ this.setState({
streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`, streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`,
}); });
} }
handleUsernameChange(newName) { handleUsernameChange(newName) {
@ -370,7 +373,7 @@ export default class App extends Component {
handleSpaceBarPressed(e) { handleSpaceBarPressed(e) {
e.preventDefault(); e.preventDefault();
if(this.state.isPlaying) { if (this.state.isPlaying) {
this.setState({ this.setState({
isPlaying: false, isPlaying: false,
}); });
@ -384,7 +387,11 @@ export default class App extends Component {
} }
handleKeyPressed(e) { handleKeyPressed(e) {
if (e.code === 'Space' && e.target === document.body && this.state.streamOnline) { if (
e.code === 'Space' &&
e.target === document.body &&
this.state.streamOnline
) {
this.handleSpaceBarPressed(e); this.handleSpaceBarPressed(e);
} }
} }
@ -408,7 +415,6 @@ export default class App extends Component {
windowWidth, windowWidth,
} = state; } = state;
const { const {
version: appVersion, version: appVersion,
logo = TEMP_IMAGE, logo = TEMP_IMAGE,
@ -417,45 +423,52 @@ export default class App extends Component {
tags = [], tags = [],
name, name,
extraPageContent, extraPageContent,
chatDisabled,
} = configData; } = configData;
const bgUserLogo = { backgroundImage: `url(${logo})` }; const bgUserLogo = { backgroundImage: `url(${logo})` };
const tagList = (tags !== null && tags.length > 0) const tagList =
? tags.map( tags !== null && tags.length > 0
(tag, index) => html` ? tags.map(
<li (tag, index) => html`
key="tag${index}" <li
class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 mb-2 p-2 whitespace-no-wrap" key="tag${index}"
> class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 mb-2 p-2 whitespace-no-wrap"
${tag} >
</li> ${tag}
` </li>
) `
: null; )
: null;
const viewerCountMessage = streamOnline && viewerCount > 0 ? ( const viewerCountMessage =
html`${viewerCount} ${pluralize('viewer', viewerCount)}` streamOnline && viewerCount > 0
) : null; ? html`${viewerCount} ${pluralize('viewer', viewerCount)}`
: null;
const mainClass = playerActive ? 'online' : ''; const mainClass = playerActive ? 'online' : '';
const isPortrait = this.hasTouchScreen && orientation === ORIENTATION_PORTRAIT; const isPortrait =
this.hasTouchScreen && orientation === ORIENTATION_PORTRAIT;
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait; const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait;
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight; const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
const shouldDisplayChat = displayChat && !chatDisabled;
const usernameStyle = chatDisabled ? 'none' : 'flex';
const extraAppClasses = classNames({ const extraAppClasses = classNames({
chat: displayChat, chat: shouldDisplayChat,
'no-chat': !displayChat, 'no-chat': !shouldDisplayChat,
'single-col': singleColMode, 'single-col': singleColMode,
'bg-gray-800': singleColMode && displayChat, 'bg-gray-800': singleColMode && shouldDisplayChat,
'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL, 'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL,
'touch-screen': this.hasTouchScreen, 'touch-screen': this.hasTouchScreen,
'touch-keyboard-active': touchKeyboardActive, 'touch-keyboard-active': touchKeyboardActive,
}); });
const poster = isPlaying ? null : html` const poster = isPlaying
<${VideoPoster} offlineImage=${logo} active=${streamOnline} /> ? null
`; : html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
return html` return html`
<div <div
@ -473,15 +486,20 @@ export default class App extends Component {
id="logo-container" id="logo-container"
class="inline-block rounded-full bg-white w-8 min-w-8 min-h-8 h-8 mr-2 bg-no-repeat bg-center" class="inline-block rounded-full bg-white w-8 min-w-8 min-h-8 h-8 mr-2 bg-no-repeat bg-center"
> >
<img class="logo visually-hidden" src=${OWNCAST_LOGO_LOCAL} alt="owncast logo" /> <img
class="logo visually-hidden"
src=${OWNCAST_LOGO_LOCAL}
alt="owncast logo"
/>
</span> </span>
<span class="instance-title overflow-hidden truncate" <span class="instance-title overflow-hidden truncate"
>${(streamOnline && streamTitle) ? streamTitle : name}</span >${streamOnline && streamTitle ? streamTitle : name}</span
> >
</h1> </h1>
<div <div
id="user-options-container" id="user-options-container"
class="flex flex-row justify-end items-center flex-no-wrap" class="flex flex-row justify-end items-center flex-no-wrap"
style=${{ display: usernameStyle }}
> >
<${UsernameForm} <${UsernameForm}
username=${username} username=${username}
@ -538,9 +556,7 @@ export default class App extends Component {
class="user-content-header border-b border-gray-500 border-solid" class="user-content-header border-b border-gray-500 border-solid"
> >
<h2 class="font-semibold text-5xl"> <h2 class="font-semibold text-5xl">
<span class="streamer-name text-indigo-600" <span class="streamer-name text-indigo-600">${name}</span>
>${name}</span
>
</h2> </h2>
<h3 class="font-semibold text-3xl"> <h3 class="font-semibold text-3xl">
${streamOnline && streamTitle} ${streamOnline && streamTitle}
@ -573,11 +589,10 @@ export default class App extends Component {
<${Chat} <${Chat}
websocket=${websocket} websocket=${websocket}
username=${username} username=${username}
chatInputEnabled=${chatInputEnabled} chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name} instanceTitle=${name}
/> />
</div> </div>
`; `;
} }
} }

View file

@ -5,8 +5,17 @@ const html = htm.bind(h);
import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js'; import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js';
import ContentEditable, { replaceCaret } from './content-editable.js'; import ContentEditable, { replaceCaret } from './content-editable.js';
import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste } from '../../utils/chat.js'; import {
import { getLocalStorage, setLocalStorage, classNames } from '../../utils/helpers.js'; generatePlaceholderText,
getCaretPosition,
convertToText,
convertOnPaste,
} from '../../utils/chat.js';
import {
getLocalStorage,
setLocalStorage,
classNames,
} from '../../utils/helpers.js';
import { import {
URL_CUSTOM_EMOJIS, URL_CUSTOM_EMOJIS,
KEY_CHAT_FIRST_MESSAGE_SENT, KEY_CHAT_FIRST_MESSAGE_SENT,
@ -46,7 +55,9 @@ export default class ChatInput extends Component {
this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this); this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this);
this.handlePaste = this.handlePaste.bind(this); this.handlePaste = this.handlePaste.bind(this);
this.handleContentEditableChange = this.handleContentEditableChange.bind(this); this.handleContentEditableChange = this.handleContentEditableChange.bind(
this
);
} }
componentDidMount() { componentDidMount() {
@ -55,13 +66,13 @@ export default class ChatInput extends Component {
getCustomEmojis() { getCustomEmojis() {
fetch(URL_CUSTOM_EMOJIS) fetch(URL_CUSTOM_EMOJIS)
.then(response => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error(`Network response was not ok ${response.ok}`); throw new Error(`Network response was not ok ${response.ok}`);
} }
return response.json(); return response.json();
}) })
.then(json => { .then((json) => {
this.emojiPicker = new EmojiButton({ this.emojiPicker = new EmojiButton({
zIndex: 100, zIndex: 100,
theme: 'owncast', // see chat.css theme: 'owncast', // see chat.css
@ -75,7 +86,7 @@ export default class ChatInput extends Component {
position: 'right-start', position: 'right-start',
strategy: 'absolute', strategy: 'absolute',
}); });
this.emojiPicker.on('emoji', emoji => { this.emojiPicker.on('emoji', (emoji) => {
this.handleEmojiSelected(emoji); this.handleEmojiSelected(emoji);
}); });
this.emojiPicker.on('hidden', () => { this.emojiPicker.on('hidden', () => {
@ -83,7 +94,7 @@ export default class ChatInput extends Component {
replaceCaret(this.formMessageInput.current); replaceCaret(this.formMessageInput.current);
}); });
}) })
.catch(error => { .catch((error) => {
// this.handleNetworkingError(`Emoji Fetch: ${error}`); // this.handleNetworkingError(`Emoji Fetch: ${error}`);
}); });
} }
@ -98,9 +109,9 @@ export default class ChatInput extends Component {
const { inputHTML } = this.state; const { inputHTML } = this.state;
let content = ''; let content = '';
if (emoji.url) { if (emoji.url) {
const url = location.protocol + "//" + location.host + "/" + emoji.url; const url = location.protocol + '//' + location.host + '/' + emoji.url;
const name = url.split('\\').pop().split('/').pop(); const name = url.split('\\').pop().split('/').pop();
content = "<img class=\"emoji\" alt=\"" + name + "\" src=\"" + url + "\"/>"; content = '<img class="emoji" alt="' + name + '" src="' + url + '"/>';
} else { } else {
content = emoji.emoji; content = emoji.emoji;
} }
@ -109,11 +120,11 @@ export default class ChatInput extends Component {
inputHTML: inputHTML + content, inputHTML: inputHTML + content,
}); });
// a hacky way add focus back into input field // a hacky way add focus back into input field
setTimeout( () => { setTimeout(() => {
const input = this.formMessageInput.current; const input = this.formMessageInput.current;
input.focus(); input.focus();
replaceCaret(input); replaceCaret(input);
}, 100); }, 100);
} }
// autocomplete user names // autocomplete user names
@ -138,7 +149,10 @@ export default class ChatInput extends Component {
return username.toLowerCase().startsWith(partial.toLowerCase()); return username.toLowerCase().startsWith(partial.toLowerCase());
}); });
if (this.completionIndex === undefined || ++this.completionIndex >= possibilities.length) { if (
this.completionIndex === undefined ||
++this.completionIndex >= possibilities.length
) {
this.completionIndex = 0; this.completionIndex = 0;
} }
@ -146,8 +160,12 @@ export default class ChatInput extends Component {
this.suggestion = possibilities[this.completionIndex]; this.suggestion = possibilities[this.completionIndex];
this.setState({ this.setState({
inputHTML: inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position), inputHTML:
}) inputHTML.substring(0, at + 1) +
this.suggestion +
' ' +
inputHTML.substring(position),
});
} }
return true; return true;
@ -204,7 +222,7 @@ export default class ChatInput extends Component {
const { key } = event; const { key } = event;
if (key === 'Control' || key === 'Shift') { if (key === 'Control' || key === 'Shift') {
this.prepNewLine = false; this.prepNewLine = false;
} }
if (CHAT_KEY_MODIFIERS.includes(key)) { if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = false; this.modifierKeyPressed = false;
@ -264,52 +282,61 @@ export default class ChatInput extends Component {
render(props, state) { render(props, state) {
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML } = state; const { hasSentFirstChatMessage, inputCharsLeft, inputHTML } = state;
const { inputEnabled } = props; const { inputEnabled, chatDisabled } = props;
const emojiButtonStyle = { const emojiButtonStyle = {
display: this.emojiPicker && inputCharsLeft > 0 ? 'block' : 'none', display: this.emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
}; };
const extraClasses = classNames({ const extraClasses = classNames({
'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER, 'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER,
}); });
const placeholderText = generatePlaceholderText(inputEnabled, hasSentFirstChatMessage); const placeholderText = generatePlaceholderText(
return ( inputEnabled,
html` hasSentFirstChatMessage
<div id="message-input-container" class="relative shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20 ${extraClasses}"> );
return html`
<div
id="message-input-container"
class="relative shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20 ${extraClasses}"
>
<div
id="message-input-wrap"
class="flex flex-row justify-end appearance-none w-full bg-gray-200 border border-black-500 rounded py-2 px-2 pr-12 my-2 overflow-auto"
>
<${ContentEditable}
id="message-input"
class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none"
placeholderText=${placeholderText}
innerRef=${this.formMessageInput}
html=${inputHTML}
disabled=${!inputEnabled}
onChange=${this.handleContentEditableChange}
onKeyDown=${this.handleMessageInputKeydown}
onKeyUp=${this.handleMessageInputKeyup}
onBlur=${this.handleMessageInputBlur}
onPaste=${this.handlePaste}
/>
</div>
<div
id="message-form-actions"
class="absolute flex flex-col w-10 justify-end items-center"
>
<button
ref=${this.emojiPickerButton}
id="emoji-button"
class="text-3xl leading-3 cursor-pointer text-purple-600"
type="button"
style=${emojiButtonStyle}
onclick=${this.handleEmojiButtonClick}
disabled=${!inputEnabled}
>
<img src="../../../img/smiley.png" />
</button>
<div <span id="message-form-warning" class="text-red-600 text-xs"
id="message-input-wrap" >${inputCharsLeft}/${CHAT_MAX_MESSAGE_LENGTH}</span
class="flex flex-row justify-end appearance-none w-full bg-gray-200 border border-black-500 rounded py-2 px-2 pr-12 my-2 overflow-auto"> >
<${ContentEditable} </div>
id="message-input"
class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none"
placeholderText=${placeholderText}
innerRef=${this.formMessageInput}
html=${inputHTML}
disabled=${!inputEnabled}
onChange=${this.handleContentEditableChange}
onKeyDown=${this.handleMessageInputKeydown}
onKeyUp=${this.handleMessageInputKeyup}
onBlur=${this.handleMessageInputBlur}
onPaste=${this.handlePaste}
/>
</div>
<div id="message-form-actions" class="absolute flex flex-col w-10 justify-end items-center">
<button
ref=${this.emojiPickerButton}
id="emoji-button"
class="text-3xl leading-3 cursor-pointer text-purple-600"
type="button"
style=${emojiButtonStyle}
onclick=${this.handleEmojiButtonClick}
disabled=${!inputEnabled}
><img src="../../../img/smiley.png" /></button>
<span id="message-form-warning" class="text-red-600 text-xs">${inputCharsLeft}/${CHAT_MAX_MESSAGE_LENGTH}</span>
</div>
</div> </div>
`); `;
}
} }
}