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:
Meisam 2021-08-01 01:21:30 +02:00 committed by GitHub
parent 41a7e8b896
commit 7e6f53c846
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 361 additions and 56 deletions

View file

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

View file

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

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

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

View file

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

View file

@ -0,0 +1 @@
index-standalone-chat-readonly.html

View file

@ -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;
setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken);
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;
}
}

View file

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

View file

@ -0,0 +1,16 @@
:root {
--header-height: 2em;
}
header{
height: var(--header-height);
}
#messages-container {
height: 100vh;
}
#chat-container {
width: 100%;
}