mirror of
https://github.com/owncast/owncast.git
synced 2024-11-24 05:38:58 +03:00
Add standalone chat with ability to send messages (#1270)
* properly pass the messagesOnly to chat * use actual username if embed is not messageonly * mv embed chat to chat-overlay * add new embed chat page * fix router * secure random number for non-secure application! * add chat enable/disable functionality * add username form add customStyles * mv overlay css * add style for embed chat style cleanup * rm username form from chat overlay * refactoring * css cleanup css adjust * minor cleanup * mark the embed chats as readonly and readwrite * replace 301 redirects with 307 * add redirect for the cached address * set insatnce name in chat
This commit is contained in:
parent
41a7e8b896
commit
7e6f53c846
9 changed files with 361 additions and 56 deletions
|
@ -4,12 +4,17 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
// GetChatEmbed gets the embed for chat.
|
||||
func GetChatEmbed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/index-standalone-chat.html", http.StatusMovedPermanently)
|
||||
// GetChatEmbedreadwrite gets the embed for readwrite chat.
|
||||
func GetChatEmbedreadwrite(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/index-standalone-chat-readwrite.html", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// GetChatEmbedreadonly gets the embed for readonly chat.
|
||||
func GetChatEmbedreadonly(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/index-standalone-chat-readonly.html", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// GetVideoEmbed gets the embed for video.
|
||||
func GetVideoEmbed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/index-video-only.html", http.StatusMovedPermanently)
|
||||
http.Redirect(w, r, "/index-video-only.html", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
|
|
@ -36,8 +36,14 @@ func Start() error {
|
|||
// web config api
|
||||
http.HandleFunc("/api/config", controllers.GetWebConfig)
|
||||
|
||||
// chat embed
|
||||
http.HandleFunc("/embed/chat", controllers.GetChatEmbed)
|
||||
// pre v0.0.8 chat embed
|
||||
http.HandleFunc("/embed/chat", controllers.GetChatEmbedreadonly)
|
||||
|
||||
// readonly chat embed
|
||||
http.HandleFunc("/embed/chat/readonly", controllers.GetChatEmbedreadonly)
|
||||
|
||||
// readwrite chat embed
|
||||
http.HandleFunc("/embed/chat/readwrite", controllers.GetChatEmbedreadwrite)
|
||||
|
||||
// video embed
|
||||
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
||||
|
|
26
webroot/index-standalone-chat-readonly.html
Normal file
26
webroot/index-standalone-chat-readonly.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/standalone-chat-readonly.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="messages-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import StandaloneChat from './js/app-standalone-chat.js';
|
||||
render(
|
||||
html`<${StandaloneChat} readonly />`, document.getElementById("messages-only")
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
26
webroot/index-standalone-chat-readwrite.html
Normal file
26
webroot/index-standalone-chat-readwrite.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/standalone-chat-readwrite.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="messages-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import StandaloneChat from './js/app-standalone-chat.js';
|
||||
render(
|
||||
html`<${StandaloneChat} />`, document.getElementById("messages-only")
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,26 +0,0 @@
|
|||
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/standalone-chat.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="messages-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import StandaloneChat from './js/app-standalone-chat.js';
|
||||
render(
|
||||
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
1
webroot/index-standalone-chat.html
Symbolic link
1
webroot/index-standalone-chat.html
Symbolic link
|
@ -0,0 +1 @@
|
|||
index-standalone-chat-readonly.html
|
|
@ -1,47 +1,250 @@
|
|||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import UsernameForm from './components/chat/username.js';
|
||||
import Chat from './components/chat/chat.js';
|
||||
import Websocket from './utils/websocket.js';
|
||||
import { getLocalStorage, setLocalStorage } from './utils/helpers.js';
|
||||
import { KEY_EMBED_CHAT_ACCESS_TOKEN } from './utils/constants.js';
|
||||
import Websocket, {
|
||||
CALLBACKS,
|
||||
SOCKET_MESSAGE_TYPES,
|
||||
} from './utils/websocket.js';
|
||||
import { registerChat } from './chat/register.js';
|
||||
import {
|
||||
getLocalStorage,
|
||||
setLocalStorage,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
CHAT_MAX_MESSAGE_LENGTH,
|
||||
EST_SOCKET_PAYLOAD_BUFFER,
|
||||
KEY_EMBED_CHAT_ACCESS_TOKEN,
|
||||
KEY_ACCESS_TOKEN,
|
||||
KEY_USERNAME,
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE,
|
||||
URL_STATUS,
|
||||
URL_CONFIG,
|
||||
TIMER_STATUS_UPDATE,
|
||||
} from './utils/constants.js';
|
||||
|
||||
|
||||
export default class StandaloneChat extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
websocket: null,
|
||||
canChat: false,
|
||||
chatEnabled: true, // always true for standalone chat
|
||||
chatInputEnabled: false, // chat input box state
|
||||
accessToken: null,
|
||||
username: null,
|
||||
isRegistering: false,
|
||||
streamOnline: false, // stream is active/online
|
||||
lastDisconnectTime: null,
|
||||
configData: {
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.isRegistering = false;
|
||||
this.disableChatInputTimer = null;
|
||||
this.hasConfiguredChat = false;
|
||||
this.websocket = null;
|
||||
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
this.handleFormFocus = this.handleFormFocus.bind(this);
|
||||
this.handleFormBlur = this.handleFormBlur.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.disableChatInput = this.disableChatInput.bind(this);
|
||||
this.setupChatAuth = this.setupChatAuth.bind(this);
|
||||
this.disableChat = this.disableChat.bind(this);
|
||||
|
||||
// user events
|
||||
this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this);
|
||||
|
||||
this.getConfig();
|
||||
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
getConfig() {
|
||||
fetch(URL_CONFIG)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.setConfigData(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch stream status
|
||||
getStreamStatus() {
|
||||
fetch(URL_STATUS)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.updateStreamStatus(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { chatDisabled } = data;
|
||||
|
||||
// If this is the first time setting the config
|
||||
// then setup chat if it's enabled.
|
||||
const chatBlocked = getLocalStorage('owncast_chat_blocked');
|
||||
if (!chatBlocked && !this.hasConfiguredChat) {
|
||||
if (!chatBlocked && !this.hasConfiguredChat && !chatDisabled) {
|
||||
this.setupChatAuth();
|
||||
}
|
||||
|
||||
this.hasConfiguredChat = true;
|
||||
|
||||
this.setState({
|
||||
canChat: !chatBlocked,
|
||||
configData: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
online,
|
||||
lastDisconnectTime,
|
||||
} = status;
|
||||
|
||||
if (status.online && !curStreamOnline) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else if (!status.online && curStreamOnline) {
|
||||
// stream has just flipped offline.
|
||||
this.handleOfflineMode();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
lastDisconnectTime,
|
||||
streamOnline: online,
|
||||
});
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode() {
|
||||
const remainingChatTime =
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE -
|
||||
(Date.now() - new Date(this.state.lastDisconnectTime));
|
||||
const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
|
||||
this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
handleOnlineMode() {
|
||||
clearTimeout(this.disableChatInputTimer);
|
||||
this.disableChatInputTimer = null;
|
||||
|
||||
this.setState({
|
||||
streamOnline: true,
|
||||
chatInputEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
handleUsernameChange(newName) {
|
||||
this.setState({
|
||||
username: newName,
|
||||
});
|
||||
this.sendUsernameChange(newName);
|
||||
}
|
||||
|
||||
|
||||
disableChatInput() {
|
||||
this.setState({
|
||||
chatInputEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
handleWebsocketMessage(e) {
|
||||
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
|
||||
// User has been actively disabled on the backend. Turn off chat for them.
|
||||
this.handleBlockedChat();
|
||||
} else if (
|
||||
e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION &&
|
||||
!this.isRegistering
|
||||
) {
|
||||
// User needs an access token, so start the user auth flow.
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null });
|
||||
this.setupChatAuth(true);
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) {
|
||||
// Chat server cannot support any more chat clients. Turn off chat for them.
|
||||
this.disableChat();
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
|
||||
// When connected the user will return an event letting us know what our
|
||||
// user details are so we can display them properly.
|
||||
const { user } = e;
|
||||
const { displayName } = user;
|
||||
|
||||
this.setState({ username: displayName });
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockedChat() {
|
||||
setLocalStorage('owncast_chat_blocked', true);
|
||||
this.disableChat();
|
||||
}
|
||||
|
||||
handleFormFocus() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleFormBlur() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disableChat() {
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null, canChat: false });
|
||||
}
|
||||
|
||||
async setupChatAuth(force) {
|
||||
var accessToken = getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN);
|
||||
const randomInt = Math.floor(Math.random() * 100) + 1;
|
||||
var username = 'chat-embed-' + randomInt;
|
||||
const { readonly } = this.props;
|
||||
var accessToken = readonly ? getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN) : getLocalStorage(KEY_ACCESS_TOKEN);
|
||||
var randomIntArray = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(randomIntArray);
|
||||
var username = readonly ? 'chat-embed-' + randomIntArray[0] : getLocalStorage(KEY_USERNAME);
|
||||
|
||||
if (!accessToken || force) {
|
||||
try {
|
||||
|
@ -50,7 +253,12 @@ export default class StandaloneChat extends Component {
|
|||
accessToken = registration.accessToken;
|
||||
username = registration.displayName;
|
||||
|
||||
if (readonly) {
|
||||
setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken);
|
||||
} else {
|
||||
setLocalStorage(KEY_ACCESS_TOKEN, accessToken);
|
||||
setLocalStorage(KEY_USERNAME, username);
|
||||
}
|
||||
|
||||
this.isRegistering = false;
|
||||
} catch (e) {
|
||||
|
@ -67,6 +275,10 @@ export default class StandaloneChat extends Component {
|
|||
|
||||
// Without a valid access token he websocket connection will be rejected.
|
||||
const websocket = new Websocket(accessToken);
|
||||
websocket.addListener(
|
||||
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
|
||||
this.handleWebsocketMessage
|
||||
);
|
||||
|
||||
this.setState({
|
||||
username,
|
||||
|
@ -75,15 +287,54 @@ export default class StandaloneChat extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
sendUsernameChange(newName) {
|
||||
const nameChange = {
|
||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
newName,
|
||||
};
|
||||
this.state.websocket.send(nameChange);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, websocket, accessToken } = state;
|
||||
return html`
|
||||
const {
|
||||
username,
|
||||
websocket,
|
||||
accessToken,
|
||||
chatInputEnabled,
|
||||
configData,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
chatDisabled,
|
||||
maxSocketPayloadSize,
|
||||
customStyles,
|
||||
name,
|
||||
} = configData;
|
||||
|
||||
const { readonly } = props;
|
||||
return this.state.websocket ?
|
||||
html`${!readonly ?
|
||||
html`<style>
|
||||
${customStyles}
|
||||
</style>
|
||||
<header class="flex flex-row-reverse fixed z-10 w-full bg-gray-900">
|
||||
<${UsernameForm}
|
||||
username=${username}
|
||||
onUsernameChange=${this.handleUsernameChange}
|
||||
onFocus=${this.handleFormFocus}
|
||||
onBlur=${this.handleFormBlur}
|
||||
/>
|
||||
</header>` : ''}
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
accessToken=${accessToken}
|
||||
messagesOnly
|
||||
/>
|
||||
`;
|
||||
readonly=${readonly}
|
||||
instanceTitle=${name}
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
|
||||
CHAT_MAX_MESSAGE_LENGTH}
|
||||
/>`
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ export default class Chat extends Component {
|
|||
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
|
||||
if (!this.props.messagesOnly) {
|
||||
if (!this.props.readonly) {
|
||||
window.addEventListener('blur', this.handleWindowBlur);
|
||||
window.addEventListener('focus', this.handleWindowFocus);
|
||||
}
|
||||
|
@ -113,7 +113,7 @@ export default class Chat extends Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
if (!this.props.messagesOnly) {
|
||||
if (!this.props.readonly) {
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
window.removeEventListener('focus', this.handleWindowFocus);
|
||||
}
|
||||
|
@ -183,7 +183,7 @@ export default class Chat extends Component {
|
|||
visible: messageVisible,
|
||||
} = message;
|
||||
const { messages: curMessages } = this.state;
|
||||
const { username, messagesOnly } = this.props;
|
||||
const { username, readonly } = this.props;
|
||||
|
||||
const existingIndex = curMessages.findIndex(
|
||||
(item) => item.id === messageId
|
||||
|
@ -236,7 +236,7 @@ export default class Chat extends Component {
|
|||
}
|
||||
|
||||
// if window is blurred and we get a new message, add 1 to title
|
||||
if (!messagesOnly && messageType === 'CHAT' && this.windowBlurred) {
|
||||
if (!readonly && messageType === 'CHAT' && this.windowBlurred) {
|
||||
this.numMessagesSinceBlur += 1;
|
||||
}
|
||||
}
|
||||
|
@ -333,7 +333,7 @@ export default class Chat extends Component {
|
|||
// update document title if window blurred
|
||||
if (
|
||||
this.numMessagesSinceBlur &&
|
||||
!this.props.messagesOnly &&
|
||||
!this.props.readonly &&
|
||||
this.windowBlurred
|
||||
) {
|
||||
this.updateDocumentTitle();
|
||||
|
@ -348,7 +348,7 @@ export default class Chat extends Component {
|
|||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, messagesOnly, chatInputEnabled, inputMaxBytes } = props;
|
||||
const { username, readonly, chatInputEnabled, inputMaxBytes } = props;
|
||||
const { messages, chatUserNames, webSocketConnected } = state;
|
||||
|
||||
const messageList = messages
|
||||
|
@ -362,7 +362,7 @@ export default class Chat extends Component {
|
|||
/>`
|
||||
);
|
||||
|
||||
if (messagesOnly) {
|
||||
if (readonly) {
|
||||
return html`
|
||||
<div
|
||||
id="messages-container"
|
||||
|
|
16
webroot/styles/standalone-chat-readwrite.css
Normal file
16
webroot/styles/standalone-chat-readwrite.css
Normal file
|
@ -0,0 +1,16 @@
|
|||
:root {
|
||||
--header-height: 2em;
|
||||
}
|
||||
|
||||
header{
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#chat-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
Loading…
Reference in a new issue