From a0354d6d49f0ac7e57c80c040e80ac9f0303310c Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Mon, 2 May 2022 17:45:22 -0700 Subject: [PATCH] Handle centralized app state and registration + chat history --- controllers/chat.go | 12 +- web/.eslintrc.js | 2 + web/components/UserDropdownMenu.tsx | 2 +- web/components/chat/ChatContainer.tsx | 2 +- web/components/stores/ClientConfigStore.tsx | 93 +++++++++---- web/components/ui/Content/Content.tsx | 4 +- web/components/ui/Sidebar/Sidebar.tsx | 15 ++- web/interfaces/application-state.ts | 5 + web/services/chat-service.ts | 17 +-- web/services/websocket-service.ts | 138 ++++++++++++++++++++ web/utils/apis.ts | 12 ++ 11 files changed, 257 insertions(+), 45 deletions(-) create mode 100644 web/services/websocket-service.ts diff --git a/controllers/chat.go b/controllers/chat.go index 4b1566a23..6dd40bdd0 100644 --- a/controllers/chat.go +++ b/controllers/chat.go @@ -18,6 +18,7 @@ func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWr // GetChatMessages gets all of the chat messages. func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) { + middleware.EnableCors(w) getChatMessages(w, r) } @@ -41,7 +42,16 @@ func getChatMessages(w http.ResponseWriter, r *http.Request) { // RegisterAnonymousChatUser will register a new user. func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { - if r.Method != POST { + middleware.EnableCors(w) + + if r.Method == "OPTIONS" { + // All OPTIONS requests should have a wildcard CORS header. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodPost { WriteSimpleResponse(w, false, r.Method+" not supported") return } diff --git a/web/.eslintrc.js b/web/.eslintrc.js index e25c5092d..18ecdce20 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -38,6 +38,8 @@ module.exports = { 'no-console': 'off', 'no-use-before-define': [0], '@typescript-eslint/no-use-before-define': [1], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error'], 'react/jsx-no-target-blank': [ 1, { diff --git a/web/components/UserDropdownMenu.tsx b/web/components/UserDropdownMenu.tsx index c4b8d8c1c..fdc1490e1 100644 --- a/web/components/UserDropdownMenu.tsx +++ b/web/components/UserDropdownMenu.tsx @@ -2,7 +2,7 @@ import { Menu, Dropdown } from 'antd'; import { DownOutlined } from '@ant-design/icons'; import { useRecoilState } from 'recoil'; import { ChatVisibilityState, ChatState } from '../interfaces/application-state'; -import { chatVisibility as chatVisibilityAtom } from './stores/ClientConfigStore'; +import { chatVisibilityAtom as chatVisibilityAtom } from './stores/ClientConfigStore'; interface Props { username: string; diff --git a/web/components/chat/ChatContainer.tsx b/web/components/chat/ChatContainer.tsx index 2a2995164..0871d3617 100644 --- a/web/components/chat/ChatContainer.tsx +++ b/web/components/chat/ChatContainer.tsx @@ -21,7 +21,7 @@ export default function ChatContainer(props: Props) { ({ - key: 'chatVisibility', - default: ChatVisibilityState.Hidden, +export const appStateAtom = atom({ + key: 'appStateAtom', + default: AppState.Loading, }); -export const chatDisplayName = atom({ +export const chatStateAtom = atom({ + key: 'chatStateAtom', + default: ChatState.Offline, +}); + +export const chatVisibilityAtom = atom({ + key: 'chatVisibility', + default: ChatVisibilityState.Visible, +}); + +export const chatDisplayNameAtom = atom({ key: 'chatDisplayName', default: null, }); -export const accessTokenAtom = atom({ - key: 'accessToken', +export const accessTokenAtom = atom({ + key: 'accessTokenAtom', default: null, }); -export const chatMessages = atom({ +export const chatMessagesAtom = atom({ key: 'chatMessages', default: [] as ChatMessage[], }); export function ClientConfigStore() { - const [, setClientConfig] = useRecoilState(clientConfigState); - const [, setChatMessages] = useRecoilState(chatMessages); + const setClientConfig = useSetRecoilState(clientConfigStateAtom); + const [appState, setAppState] = useRecoilState(appStateAtom); + const setChatVisibility = useSetRecoilState(chatVisibilityAtom); + const [chatState, setChatState] = useRecoilState(chatStateAtom); + const setChatMessages = useSetRecoilState(chatMessagesAtom); const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom); - const [, setChatDisplayName] = useRecoilState(chatDisplayName); + const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); const updateClientConfig = async () => { try { const config = await ClientConfigService.getConfig(); - console.log(`ClientConfig: ${JSON.stringify(config)}`); + // console.log(`ClientConfig: ${JSON.stringify(config)}`); setClientConfig(config); + setAppState(AppState.Online); } catch (error) { console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`); } }; - const handleUserRegistration = async (optionalDisplayName: string) => { + const handleUserRegistration = async (optionalDisplayName?: string) => { try { + setAppState(AppState.Registering); const response = await ChatService.registerUser(optionalDisplayName); - console.log(`ChatService -> registerUser() response: \n${JSON.stringify(response)}`); - const { accessToken: newAccessToken, displayName } = response; + console.log(`ChatService -> registerUser() response: \n${response}`); + const { accessToken: newAccessToken, displayName: newDisplayName } = response; if (!newAccessToken) { return; } - setAccessToken(accessToken); - setLocalStorage('accessToken', newAccessToken); - setChatDisplayName(displayName); + console.log('setting access token', newAccessToken); + setAccessToken(newAccessToken); + // setLocalStorage('accessToken', newAccessToken); + setChatDisplayName(newDisplayName); + setAppState(AppState.Online); } catch (e) { console.error(`ChatService -> registerUser() ERROR: \n${e}`); } }; - // TODO: Requires access token. const getChatHistory = async () => { + setChatState(ChatState.Loading); try { const messages = await ChatService.getChatHistory(accessToken); - console.log(`ChatService -> getChatHistory() messages: \n${JSON.stringify(messages)}`); + // console.log(`ChatService -> getChatHistory() messages: \n${JSON.stringify(messages)}`); setChatMessages(messages); } catch (error) { console.error(`ChatService -> getChatHistory() ERROR: \n${error}`); } + setChatState(ChatState.Available); }; useEffect(() => { @@ -81,9 +106,29 @@ export function ClientConfigStore() { handleUserRegistration(); }, []); - useEffect(() => { + useLayoutEffect(() => { + if (!accessToken) { + return; + } + + console.log('access token changed', accessToken); getChatHistory(); }, [accessToken]); + useEffect(() => { + const updatedChatState = getChatState(appState); + setChatState(updatedChatState); + const updatedChatVisibility = getChatVisibilityState(appState); + console.log( + 'app state: ', + AppState[appState], + 'chat state:', + ChatState[updatedChatState], + 'chat visibility:', + ChatVisibilityState[updatedChatVisibility], + ); + setChatVisibility(updatedChatVisibility); + }, [appState]); + return null; } diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx index 2e5467fd9..ff143c043 100644 --- a/web/components/ui/Content/Content.tsx +++ b/web/components/ui/Content/Content.tsx @@ -1,6 +1,6 @@ import { useRecoilValue } from 'recoil'; import { Layout, Row, Col, Tabs } from 'antd'; -import { clientConfigState } from '../../stores/ClientConfigStore'; +import { clientConfigStateAtom } from '../../stores/ClientConfigStore'; import { ClientConfig } from '../../../interfaces/client-config.model'; import CustomPageContent from '../../CustomPageContent'; import OwncastPlayer from '../../video/OwncastPlayer'; @@ -11,7 +11,7 @@ const { TabPane } = Tabs; const { Content } = Layout; export default function FooterComponent() { - const clientConfig = useRecoilValue(clientConfigState); + const clientConfig = useRecoilValue(clientConfigStateAtom); const { extraPageContent } = clientConfig; return ( diff --git a/web/components/ui/Sidebar/Sidebar.tsx b/web/components/ui/Sidebar/Sidebar.tsx index 5ee44eb4e..6984b0a14 100644 --- a/web/components/ui/Sidebar/Sidebar.tsx +++ b/web/components/ui/Sidebar/Sidebar.tsx @@ -1,18 +1,25 @@ import Sider from 'antd/lib/layout/Sider'; import { useRecoilValue } from 'recoil'; +import { useEffect } from 'react'; import { ChatMessage } from '../../../interfaces/chat-message.model'; import ChatContainer from '../../chat/ChatContainer'; -import { chatMessages, chatVisibility as chatVisibilityAtom } from '../../stores/ClientConfigStore'; -import { ChatVisibilityState } from '../../../interfaces/application-state'; +import { + chatMessagesAtom, + chatVisibilityAtom, + chatStateAtom, +} from '../../stores/ClientConfigStore'; +import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state'; import ChatTextField from '../../chat/ChatTextField'; export default function Sidebar() { - const messages = useRecoilValue(chatMessages); + const messages = useRecoilValue(chatMessagesAtom); const chatVisibility = useRecoilValue(chatVisibilityAtom); + const chatState = useRecoilValue(chatStateAtom); return ( - + ); diff --git a/web/interfaces/application-state.ts b/web/interfaces/application-state.ts index 96e9f6838..2e627562f 100644 --- a/web/interfaces/application-state.ts +++ b/web/interfaces/application-state.ts @@ -1,5 +1,6 @@ export enum AppState { Loading, // Initial loading state as config + status is loading. + Registering, // Creating a default anonymous chat account. Online, // Stream is active. Offline, // Stream is not active. OfflineWaiting, // Period of time after going offline chat is still available. @@ -30,6 +31,8 @@ export function getChatState(state: AppState): ChatState { return ChatState.NotAvailable; case AppState.OfflineWaiting: return ChatState.Available; + case AppState.Registering: + return ChatState.Loading; default: return ChatState.Offline; } @@ -47,6 +50,8 @@ export function getChatVisibilityState(state: AppState): ChatVisibilityState { return ChatVisibilityState.Hidden; case AppState.OfflineWaiting: return ChatVisibilityState.Visible; + case AppState.Registering: + return ChatVisibilityState.Visible; default: return ChatVisibilityState.Hidden; } diff --git a/web/services/chat-service.ts b/web/services/chat-service.ts index 8ba62909b..07e18cf7e 100644 --- a/web/services/chat-service.ts +++ b/web/services/chat-service.ts @@ -1,4 +1,5 @@ import { ChatMessage } from '../interfaces/chat-message.model'; +import { getUnauthedData } from '../utils/apis'; const ENDPOINT = `http://localhost:8080/api/chat`; const URL_CHAT_REGISTRATION = `http://localhost:8080/api/chat/register`; @@ -10,9 +11,8 @@ interface UserRegistrationResponse { class ChatService { public static async getChatHistory(accessToken: string): Promise { - const response = await fetch(`${ENDPOINT}?accessToken=${accessToken}`); - const status = await response.json(); - return status; + const response = await getUnauthedData(`${ENDPOINT}?accessToken=${accessToken}`); + return response; } public static async registerUser(username: string): Promise { @@ -24,15 +24,8 @@ class ChatService { body: JSON.stringify({ displayName: username }), }; - try { - const response = await fetch(URL_CHAT_REGISTRATION, options); - const result = await response.json(); - return result; - } catch (e) { - console.error(e); - } - - return null; + const response = await getUnauthedData(URL_CHAT_REGISTRATION, options); + return response; } } diff --git a/web/services/websocket-service.ts b/web/services/websocket-service.ts new file mode 100644 index 000000000..c0591c92c --- /dev/null +++ b/web/services/websocket-service.ts @@ -0,0 +1,138 @@ +import { message } from "antd"; + +enum SocketMessageType { + CHAT = 'CHAT', + PING = 'PING', + NAME_CHANGE = 'NAME_CHANGE', + PONG = 'PONG', + SYSTEM = 'SYSTEM', + USER_JOINED = 'USER_JOINED', + CHAT_ACTION = 'CHAT_ACTION', + FEDIVERSE_ENGAGEMENT_FOLLOW = 'FEDIVERSE_ENGAGEMENT_FOLLOW', + FEDIVERSE_ENGAGEMENT_LIKE = 'FEDIVERSE_ENGAGEMENT_LIKE', + FEDIVERSE_ENGAGEMENT_REPOST = 'FEDIVERSE_ENGAGEMENT_REPOST', + CONNECTED_USER_INFO = 'CONNECTED_USER_INFO', + ERROR_USER_DISABLED = 'ERROR_USER_DISABLED', + ERROR_NEEDS_REGISTRATION = 'ERROR_NEEDS_REGISTRATION', + ERROR_MAX_CONNECTIONS_EXCEEDED = 'ERROR_MAX_CONNECTIONS_EXCEEDED', + VISIBILITY_UPDATE = 'VISIBILITY-UPDATE', +}; + +interface SocketMessage { + type: SocketMessageType; + data: any; +} + +export default class WebsocketService { + websocket: WebSocket; + + accessToken: string; + + path: string; + + websocketReconnectTimer: ReturnType; + + constructor(accessToken, path) { + this.accessToken = accessToken; + this.path = 'http://localhost:8080/ws'; + // this.websocketReconnectTimer = null; + // this.accessToken = accessToken; + + // this.websocketConnectedListeners = []; + // this.websocketDisconnectListeners = []; + // this.rawMessageListeners = []; + + // this.send = this.send.bind(this); + // this.createAndConnect = this.createAndConnect.bind(this); + // this.scheduleReconnect = this.scheduleReconnect.bind(this); + // this.shutdown = this.shutdown.bind(this); + + // this.isShutdown = false; + + this.createAndConnect(); + } + + createAndConnect() { + const url = new URL(this.path); + url.searchParams.append('accessToken', this.accessToken); + + const ws = new WebSocket(url.toString()); + ws.onopen = this.onOpen.bind(this); + // ws.onclose = this.onClose.bind(this); + ws.onerror = this.onError.bind(this); + ws.onmessage = this.onMessage.bind(this); + + this.websocket = ws; + } + + onOpen() { + if (this.websocketReconnectTimer) { + clearTimeout(this.websocketReconnectTimer); + } + } + + // On ws error just close the socket and let it re-connect again for now. + onError(e) { + handleNetworkingError(`Socket error: ${JSON.parse(e)}`); + this.websocket.close(); + // if (!this.isShutdown) { + // this.scheduleReconnect(); + // } + } + + /* + onMessage is fired when an inbound object comes across the websocket. + If the message is of type `PING` we send a `PONG` back and do not + pass it along to listeners. + */ + onMessage(e: SocketMessage) { + // Optimization where multiple events can be sent within a + // single websocket message. So split them if needed. + const messages = e.data.split('\n'); + let message: SocketMessage; + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < messages.length; i++) { + try { + message = JSON.parse(messages[i]); + } catch (e) { + console.error(e, e.data); + return; + } + + if (!message.type) { + console.error('No type provided', message); + return; + } + + // Send PONGs + if (message.type === SocketMessageType.PING) { + this.sendPong(); + return; + } + } + } + + // Outbound: Other components can pass an object to `send`. + send(message: any) { + // Sanity check that what we're sending is a valid type. + if (!message.type || !SocketMessageType[message.type]) { + console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`); + } + + const messageJSON = JSON.stringify(message); + this.websocket.send(messageJSON); + } + + // Reply to a PING as a keep alive. + sendPong() { + const pong = { type: SocketMessageType.PONG }; + this.send(pong); + } +} + +function handleNetworkingError(error) { + console.error( + `Chat has been disconnected and is likely not working for you. It's possible you were removed from chat. If this is a server configuration issue, visit troubleshooting steps to resolve. https://owncast.online/docs/troubleshooting/#chat-is-disabled: ${error}` + ); +} \ No newline at end of file diff --git a/web/utils/apis.ts b/web/utils/apis.ts index b8f792524..a7c87bb50 100644 --- a/web/utils/apis.ts +++ b/web/utils/apis.ts @@ -120,6 +120,8 @@ interface FetchOptions { auth?: boolean; } + + export async function fetchData(url: string, options?: FetchOptions) { const { data, method = 'GET', auth = true } = options || {}; @@ -151,12 +153,22 @@ export async function fetchData(url: string, options?: FetchOptions) { } return json; } catch (error) { + console.error(error); return error; // console.log(error) // throw new Error(error) } } +export async function getUnauthedData(url: string, options?: FetchOptions) { + const opts = { + method: 'GET', + auth: false, + ...options, + }; + return fetchData(url, opts); +} + export async function fetchExternalData(url: string) { try { const response = await fetch(url, {