From 7b1667bf6a9a025aa1828524dc5497344cbd0b93 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Wed, 25 May 2022 20:38:40 -0700 Subject: [PATCH] Refactor app state to be a state machine with access selectors --- .../chat/ChatContainer/ChatContainer.tsx | 9 +- .../chat/ChatModeratorNotification.tsx | 6 - .../common/UserDropdown/UserDropdown.tsx | 23 ++- web/components/stores/ClientConfigStore.tsx | 176 +++++++++++------- web/components/stores/application-state.ts | 134 +++++++++++++ .../connected-client-info-handler.ts | 11 ++ .../eventhandlers/connectedclientinfo.ts | 5 - web/components/ui/Content/Content.module.scss | 7 + web/components/ui/Content/Content.tsx | 32 ++-- web/components/ui/Header/Header.tsx | 7 +- web/components/ui/Sidebar/Sidebar.tsx | 21 +-- web/components/video/OwncastPlayer.tsx | 15 +- web/interfaces/application-state.ts | 64 ------- web/package-lock.json | 63 ++++++- web/package.json | 4 +- web/pages/_app.tsx | 7 +- web/pages/embed/video/index.tsx | 32 +++- web/pages/index.tsx | 7 +- web/stories/ChatContainer.stories.tsx | 11 +- web/stories/UserDropdownMenu.stories.tsx | 3 - web/stories/Video.stories.tsx | 7 +- 21 files changed, 421 insertions(+), 223 deletions(-) delete mode 100644 web/components/chat/ChatModeratorNotification.tsx create mode 100644 web/components/stores/application-state.ts create mode 100644 web/components/stores/eventhandlers/connected-client-info-handler.ts delete mode 100644 web/components/stores/eventhandlers/connectedclientinfo.ts delete mode 100644 web/interfaces/application-state.ts diff --git a/web/components/chat/ChatContainer/ChatContainer.tsx b/web/components/chat/ChatContainer/ChatContainer.tsx index 4c10a5754..e87b2a6b8 100644 --- a/web/components/chat/ChatContainer/ChatContainer.tsx +++ b/web/components/chat/ChatContainer/ChatContainer.tsx @@ -2,20 +2,19 @@ import { Spin } from 'antd'; import { Virtuoso } from 'react-virtuoso'; import { useRef } from 'react'; import { LoadingOutlined } from '@ant-design/icons'; -import { ChatMessage } from '../../../interfaces/chat-message.model'; -import { ChatState } from '../../../interfaces/application-state'; + import { MessageType } from '../../../interfaces/socket-events'; import s from './ChatContainer.module.scss'; +import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatUserMessage } from '..'; interface Props { messages: ChatMessage[]; - state: ChatState; + loading: boolean; } export default function ChatContainer(props: Props) { - const { messages, state } = props; - const loading = state === ChatState.Loading; + const { messages, loading } = props; const chatContainerRef = useRef(null); const spinIcon = ; diff --git a/web/components/chat/ChatModeratorNotification.tsx b/web/components/chat/ChatModeratorNotification.tsx deleted file mode 100644 index f6e39f4fe..000000000 --- a/web/components/chat/ChatModeratorNotification.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -interface Props {} - -export default function ChatModerationNotification(props: Props) { - return
You are now a moderator notification component goes here
; -} diff --git a/web/components/common/UserDropdown/UserDropdown.tsx b/web/components/common/UserDropdown/UserDropdown.tsx index ca92ebe6a..bd7ac8955 100644 --- a/web/components/common/UserDropdown/UserDropdown.tsx +++ b/web/components/common/UserDropdown/UserDropdown.tsx @@ -9,27 +9,30 @@ import { import { useRecoilState, useRecoilValue } from 'recoil'; import { useState } from 'react'; import Modal from '../../ui/Modal/Modal'; -import { chatVisibilityAtom, chatDisplayNameAtom } from '../../stores/ClientConfigStore'; -import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state'; +import { + chatVisibleToggleAtom, + chatDisplayNameAtom, + appStateAtom, +} from '../../stores/ClientConfigStore'; import s from './UserDropdown.module.scss'; import NameChangeModal from '../../modals/NameChangeModal'; +import { AppStateOptions } from '../../stores/application-state'; interface Props { username?: string; - chatState: ChatState; } -export default function UserDropdown({ username: defaultUsername, chatState }: Props) { - const [chatVisibility, setChatVisibility] = - useRecoilState(chatVisibilityAtom); +export default function UserDropdown({ username: defaultUsername }: Props) { const username = defaultUsername || useRecoilValue(chatDisplayNameAtom); const [showNameChangeModal, setShowNameChangeModal] = useState(false); + const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom); + const appState = useRecoilValue(appStateAtom); const toggleChatVisibility = () => { - if (chatVisibility === ChatVisibilityState.Hidden) { - setChatVisibility(ChatVisibilityState.Visible); + if (!chatToggleVisible) { + setChatToggleVisible(true); } else { - setChatVisibility(ChatVisibilityState.Hidden); + setChatToggleVisible(false); } }; @@ -45,7 +48,7 @@ export default function UserDropdown({ username: defaultUsername, chatState }: P }> Authenticate - {chatState === ChatState.Available && ( + {appState.chatAvailable && ( } onClick={() => toggleChatVisibility()}> Toggle chat diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 9073d8d04..19dd0338f 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -1,31 +1,32 @@ -/* eslint-disable no-case-declarations */ import { useEffect } from 'react'; -import { atom, useRecoilState, useSetRecoilState } from 'recoil'; +import { atom, selector, useRecoilState, useSetRecoilState } from 'recoil'; +import { useMachine } from '@xstate/react'; import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model'; import ClientConfigService from '../../services/client-config-service'; import ChatService from '../../services/chat-service'; import WebsocketService from '../../services/websocket-service'; import { ChatMessage } from '../../interfaces/chat-message.model'; import { ServerStatus, makeEmptyServerStatus } from '../../interfaces/server-status.model'; - -import { - AppState, - ChatState, - VideoState, - ChatVisibilityState, - getChatState, - getChatVisibilityState, -} from '../../interfaces/application-state'; +import appStateModel, { + AppStateEvent, + AppStateOptions, + makeEmptyAppState, +} from './application-state'; +import { setLocalStorage, getLocalStorage } from '../../utils/helpers'; import { ConnectedClientInfoEvent, MessageType, ChatEvent, SocketEvent, } from '../../interfaces/socket-events'; -import handleConnectedClientInfoMessage from './eventhandlers/connectedclientinfo'; + import handleChatMessage from './eventhandlers/handleChatMessage'; +import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler'; import ServerStatusService from '../../services/status-service'; +const SERVER_STATUS_POLL_DURATION = 5000; +const ACCESS_TOKEN_KEY = 'accessToken'; + // Server status is what gets updated such as viewer count, durations, // stream title, online/offline state, etc. export const serverStatusState = atom({ @@ -39,26 +40,6 @@ export const clientConfigStateAtom = atom({ default: makeEmptyClientConfig(), }); -export const appStateAtom = atom({ - key: 'appStateAtom', - default: AppState.Loading, -}); - -export const chatStateAtom = atom({ - key: 'chatStateAtom', - default: ChatState.Offline, -}); - -export const videoStateAtom = atom({ - key: 'videoStateAtom', - default: VideoState.Unavailable, -}); - -export const chatVisibilityAtom = atom({ - key: 'chatVisibility', - default: ChatVisibilityState.Visible, -}); - export const chatDisplayNameAtom = atom({ key: 'chatDisplayName', default: null, @@ -79,23 +60,79 @@ export const websocketServiceAtom = atom({ default: null, }); +export const appStateAtom = atom({ + key: 'appState', + default: makeEmptyAppState(), +}); + +export const chatVisibleToggleAtom = atom({ + key: 'chatVisibilityToggleAtom', + default: true, +}); + +export const isVideoPlayingAtom = atom({ + key: 'isVideoPlayingAtom', + default: false, +}); + +// Chat is visible if the user wishes it to be visible AND the required +// chat state is set. +export const isChatVisibleSelector = selector({ + key: 'isChatVisibleSelector', + get: ({ get }) => { + const state: AppStateOptions = get(appStateAtom); + const userVisibleToggle: boolean = get(chatVisibleToggleAtom); + const accessToken: String = get(accessTokenAtom); + return accessToken && state.chatAvailable && userVisibleToggle; + }, +}); + +// We display in an "online/live" state as long as video is actively playing. +// Even during the time where technically the server has said it's no longer +// live, however the last few seconds of video playback is still taking place. +export const isOnlineSelector = selector({ + key: 'isOnlineSelector', + get: ({ get }) => { + const state: AppStateOptions = get(appStateAtom); + const isVideoPlaying: boolean = get(isVideoPlayingAtom); + return state.videoAvailable || isVideoPlaying; + }, +}); + +// Take a nested object of state metadata and merge it into +// a single flattened node. +function mergeMeta(meta) { + return Object.keys(meta).reduce((acc, key) => { + const value = meta[key]; + Object.assign(acc, value); + + return acc; + }, {}); +} + export function ClientConfigStore() { + const [appState, appStateSend, appStateService] = useMachine(appStateModel); + + const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); const setClientConfig = useSetRecoilState(clientConfigStateAtom); const setServerStatus = useSetRecoilState(serverStatusState); - const setChatVisibility = useSetRecoilState(chatVisibilityAtom); - const setChatState = useSetRecoilState(chatStateAtom); const [chatMessages, setChatMessages] = useRecoilState(chatMessagesAtom); - const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); - const [appState, setAppState] = useRecoilState(appStateAtom); const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom); - const setWebsocketService = useSetRecoilState(websocketServiceAtom); + const setAppState = useSetRecoilState(appStateAtom); + const setWebsocketService = useSetRecoilState(websocketServiceAtom); let ws: WebsocketService; + const sendEvent = (event: string) => { + // console.log('---- sending event:', event); + appStateSend({ type: event }); + }; + const updateClientConfig = async () => { try { const config = await ClientConfigService.getConfig(); setClientConfig(config); + sendEvent('LOADED'); } catch (error) { console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`); } @@ -105,32 +142,42 @@ export function ClientConfigStore() { try { const status = await ServerStatusService.getStatus(); setServerStatus(status); + if (status.online) { - setAppState(AppState.Online); - } else { - setAppState(AppState.Offline); + sendEvent(AppStateEvent.Online); + } else if (!status.online) { + sendEvent(AppStateEvent.Offline); } - return status; } catch (error) { + sendEvent(AppStateEvent.Fail); console.error(`serverStatusState -> getStatus() ERROR: \n${error}`); - return null; } + return null; }; const handleUserRegistration = async (optionalDisplayName?: string) => { + const savedAccessToken = getLocalStorage(ACCESS_TOKEN_KEY); + if (savedAccessToken) { + setAccessToken(savedAccessToken); + return; + } + try { - setAppState(AppState.Registering); + sendEvent(AppStateEvent.NeedsRegister); const response = await ChatService.registerUser(optionalDisplayName); console.log(`ChatService -> registerUser() response: \n${response}`); const { accessToken: newAccessToken, displayName: newDisplayName } = response; if (!newAccessToken) { return; } + console.log('setting access token', newAccessToken); setAccessToken(newAccessToken); - // setLocalStorage('accessToken', newAccessToken); + setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken); setChatDisplayName(newDisplayName); + // sendEvent(AppStateEvent.Registered); } catch (e) { + sendEvent(AppStateEvent.Fail); console.error(`ChatService -> registerUser() ERROR: \n${e}`); } }; @@ -138,7 +185,7 @@ export function ClientConfigStore() { const handleMessage = (message: SocketEvent) => { switch (message.type) { case MessageType.CONNECTED_USER_INFO: - handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent); + handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent, setChatDisplayName); break; case MessageType.CHAT: handleChatMessage(message as ChatEvent, chatMessages, setChatMessages); @@ -159,11 +206,12 @@ export function ClientConfigStore() { }; const startChat = async () => { - setChatState(ChatState.Loading); + sendEvent(AppStateEvent.Loading); try { ws = new WebsocketService(accessToken, '/ws'); ws.handleMessage = handleMessage; setWebsocketService(ws); + sendEvent(AppStateEvent.Loaded); } catch (error) { console.error(`ChatService -> startChat() ERROR: \n${error}`); } @@ -172,14 +220,11 @@ export function ClientConfigStore() { useEffect(() => { updateClientConfig(); handleUserRegistration(); - }, []); - - useEffect(() => { + updateServerStatus(); setInterval(() => { updateServerStatus(); - }, 5000); - updateServerStatus(); - }, []); + }, SERVER_STATUS_POLL_DURATION); + }, [appState]); useEffect(() => { if (!accessToken) { @@ -190,21 +235,18 @@ export function ClientConfigStore() { startChat(); }, [accessToken]); - useEffect(() => { - const updatedChatState = getChatState(appState); - console.log('updatedChatState', updatedChatState); - setChatState(updatedChatState); - const updatedChatVisibility = getChatVisibilityState(appState); - console.log( - 'app state: ', - AppState[appState], - 'chat state:', - ChatState[updatedChatState], - 'chat visibility:', - ChatVisibilityState[updatedChatVisibility], - ); - setChatVisibility(updatedChatVisibility); - }, [appState]); + appStateService.onTransition(state => { + if (!state.changed) { + return; + } + + const metadata = mergeMeta(state.meta) as AppStateOptions; + + console.log('--- APP STATE: ', state.value); + console.log('--- APP META: ', metadata); + + setAppState(metadata); + }); return null; } diff --git a/web/components/stores/application-state.ts b/web/components/stores/application-state.ts new file mode 100644 index 000000000..e66c56f77 --- /dev/null +++ b/web/components/stores/application-state.ts @@ -0,0 +1,134 @@ +/* +This is a finite state machine model that is used by xstate. https://xstate.js.org/ +You send events to it and it changes state based on the pre-determined +modeling. +This allows for a clean and reliable way to model the current state of the +web application, and a single place to determine the flow of states. + +You can paste this code into https://stately.ai/viz to see a visual state +map or install the VS Code plugin: +https://marketplace.visualstudio.com/items?itemName=statelyai.stately-vscode +*/ + +import { createMachine } from 'xstate'; + +export interface AppStateOptions { + chatAvailable: boolean; + chatLoading?: boolean; + videoAvailable: boolean; + appLoading?: boolean; +} + +export function makeEmptyAppState(): AppStateOptions { + return { + chatAvailable: false, + chatLoading: true, + videoAvailable: false, + appLoading: true, + }; +} + +const OFFLINE_STATE: AppStateOptions = { + chatAvailable: false, + chatLoading: false, + videoAvailable: false, + appLoading: false, +}; + +const ONLINE_STATE: AppStateOptions = { + chatAvailable: true, + chatLoading: false, + videoAvailable: true, + appLoading: false, +}; + +const LOADING_STATE: AppStateOptions = { + chatAvailable: false, + chatLoading: false, + videoAvailable: false, + appLoading: true, +}; + +const GOODBYE_STATE: AppStateOptions = { + chatAvailable: true, + chatLoading: false, + videoAvailable: false, + appLoading: false, +}; + +export enum AppStateEvent { + Loading = 'LOADING', + Loaded = 'LOADED', + Online = 'ONLINE', + Offline = 'OFFLINE', // Have not pulled configuration data from the server. + NeedsRegister = 'NEEDS_REGISTER', + Fail = 'FAIL', +} + +const appStateModel = + /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDKAXZWwDoAbAe2QgEsA7KAYgCUBRAcQEkMAVBxgEUVFWKxyWcsUp8QAD0QBGAGwz8ABgCscpUoDsAZgAcKgEwrtATgA0IAJ6zNS-CZMLtcuQBY9Jg5t0BfHxbRMHDwiUgpqGgA5BgZuDAB9RlYOLgkBIRExCWkEGRVFVXUtPUNjcytEAxNXfB0DbSNNORMG119-EEDsXAISMipaABkAeQBBbli0wWFRcSQpWQVlNQ0dfSNTC2tc+vwZVwMZWxNbA5kDAz8A9G6QvvDaADFRlkGpjNns2VcCleL1spbRDaA74FS6ORVfYHTSObRXTo3YIEABOYCg5FgeBRA3ozDYnB47xmWXmOQOKj22hUnl02iajjk2iBCCqdgO2n2VRcbQhCK6yPwaIxWLAOIiz1exMyc1A5KMVJpBjpDJczIqCG0enwXk0MiUENMjiUBjk-KRPSFYDIlnwYkIVDANGGj0egxY0WlnzJiF0TXwulU9LqWhMMl0LIM7ipmguIIObU85qClrRNrtADMMw7KE7hpF3Z75ukSbKFgg5Pp7PSQdTXBp9uVtlHtDG464E7okx0BanrRBbVBiMQIAAjSxOyRYy3IDPYgAU2g0y4AlDReyE0wP8EOR+OwF7SXLff7A8ZNCHYeGWedW3qlDIWkz61rLgjKCO4BIN70wgND2W8o3pyyjKrCdZ6q4sYqMmtyouimLYv+xbTDKXyakuyiuHI+QmCoJquCo2FyCyS52PURzqI+mgqIYpqwYKW62vajoAehsJyFS+paPhfoRhqUa6PgTIuLoRznComiEWaPYWpu-bMVmOYHihHxHuWRQ6pCJz0sqGgNMBBjCfohiEUoIJ0kyDF9umu5jhObE+rkqh2BCLS8XhYa6K4wGUuGtEviYZ5qNZ8k2o5x4IJJnGmlUOixoG5kGCy+G1JyXgHGJJhKByrihQQsBigAbmKjzIOQhAAK5ohF5YXJx+q2MYbieFB4KkW0yj6NBfr6vU3n5fglWFSiGblVVNWqaW6H5HY4bNFJbhKI4HV3k0rgnNlS6xm+1wpngtU5NeGoALQXDqbVBct+g5Qofh+EAA */ + createMachine({ + id: 'appState', + initial: 'loading', + states: { + loading: { + meta: { + ...LOADING_STATE, + }, + on: { + NEEDS_REGISTER: { + target: 'loading', + }, + LOADED: { + target: 'ready', + }, + FAIL: { + target: 'serverFailure', + }, + }, + }, + ready: { + initial: 'offline', + states: { + online: { + meta: { + ...ONLINE_STATE, + }, + on: { + OFFLINE: { + target: 'goodbye', + }, + }, + }, + offline: { + meta: { + ...OFFLINE_STATE, + }, + on: { + ONLINE: { + target: 'online', + }, + }, + }, + goodbye: { + meta: { + ...GOODBYE_STATE, + }, + after: { + '300000': { + target: 'offline', + }, + }, + }, + }, + }, + serverFailure: { + type: 'final', + }, + userfailure: { + type: 'final', + }, + }, + }); + +export default appStateModel; diff --git a/web/components/stores/eventhandlers/connected-client-info-handler.ts b/web/components/stores/eventhandlers/connected-client-info-handler.ts new file mode 100644 index 000000000..221c68ae9 --- /dev/null +++ b/web/components/stores/eventhandlers/connected-client-info-handler.ts @@ -0,0 +1,11 @@ +import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events'; + +export default function handleConnectedClientInfoMessage( + message: ConnectedClientInfoEvent, + setChatDisplayName: (string) => void, +) { + console.log('connected client', message); + const { user } = message; + const { displayName } = user; + setChatDisplayName(displayName); +} diff --git a/web/components/stores/eventhandlers/connectedclientinfo.ts b/web/components/stores/eventhandlers/connectedclientinfo.ts deleted file mode 100644 index e5081d4b2..000000000 --- a/web/components/stores/eventhandlers/connectedclientinfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events'; - -export default function handleConnectedClientInfoMessage(message: ConnectedClientInfoEvent) { - console.log('connected client', message); -} diff --git a/web/components/ui/Content/Content.module.scss b/web/components/ui/Content/Content.module.scss index 11538cb51..a557dd212 100644 --- a/web/components/ui/Content/Content.module.scss +++ b/web/components/ui/Content/Content.module.scss @@ -54,6 +54,13 @@ } } +.loadingSpinner { + position: fixed; + left: 50%; + top: 50%; + z-index: 999999; +} + @media (min-width: 768px) { .mobileChat { display: none; diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx index f4797513a..ff78c7a20 100644 --- a/web/components/ui/Content/Content.tsx +++ b/web/components/ui/Content/Content.tsx @@ -1,12 +1,13 @@ import { useRecoilValue } from 'recoil'; -import { Layout, Button, Tabs } from 'antd'; +import { Layout, Button, Tabs, Spin } from 'antd'; import { NotificationFilled, HeartFilled } from '@ant-design/icons'; import { - chatVisibilityAtom, clientConfigStateAtom, chatMessagesAtom, - chatStateAtom, + isChatVisibleSelector, serverStatusState, + appStateAtom, + isOnlineSelector, } from '../../stores/ClientConfigStore'; import { ClientConfig } from '../../../interfaces/client-config.model'; import CustomPageContent from '../../CustomPageContent'; @@ -17,7 +18,6 @@ import Sidebar from '../Sidebar'; import Footer from '../Footer'; import ChatContainer from '../../chat/ChatContainer'; import { ChatMessage } from '../../../interfaces/chat-message.model'; -import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state'; import ChatTextField from '../../chat/ChatTextField/ChatTextField'; import ActionButtonRow from '../../action-buttons/ActionButtonRow'; import ActionButton from '../../action-buttons/ActionButton'; @@ -28,27 +28,27 @@ import SocialLinks from '../SocialLinks/SocialLinks'; import NotifyReminderPopup from '../NotifyReminderPopup/NotifyReminderPopup'; import ServerLogo from '../Logo/Logo'; import CategoryIcon from '../CategoryIcon/CategoryIcon'; +import OfflineBanner from '../OfflineBanner/OfflineBanner'; +import { AppStateOptions } from '../../stores/application-state'; const { TabPane } = Tabs; const { Content } = Layout; export default function ContentComponent() { + const appState = useRecoilValue(appStateAtom); const status = useRecoilValue(serverStatusState); const clientConfig = useRecoilValue(clientConfigStateAtom); - const chatVisibility = useRecoilValue(chatVisibilityAtom); + const isChatVisible = useRecoilValue(isChatVisibleSelector); const messages = useRecoilValue(chatMessagesAtom); - const chatState = useRecoilValue(chatStateAtom); + const online = useRecoilValue(isOnlineSelector); const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig; - const { online, viewerCount, lastConnectTime, lastDisconnectTime } = status; + const { viewerCount, lastConnectTime, lastDisconnectTime } = status; const followers: Follower[] = []; const total = 0; - const chatVisible = - chatState === ChatState.Available && chatVisibility === ChatVisibilityState.Visible; - // This is example content. It should be removed. const externalActions = [ { @@ -67,8 +67,12 @@ export default function ContentComponent() { return ( + +
- + {online && } + {!online && } + - {chatVisibility && ( + {isChatVisible && (
- +
)}
- {chatVisible && } + {isChatVisible && }
); } diff --git a/web/components/ui/Header/Header.tsx b/web/components/ui/Header/Header.tsx index c9ca0f857..d3f7e2076 100644 --- a/web/components/ui/Header/Header.tsx +++ b/web/components/ui/Header/Header.tsx @@ -1,8 +1,5 @@ import { Layout } from 'antd'; -import { useRecoilValue } from 'recoil'; -import { ChatState } from '../../../interfaces/application-state'; import { OwncastLogo, UserDropdown } from '../../common'; -import { chatStateAtom } from '../../stores/ClientConfigStore'; import s from './Header.module.scss'; const { Header } = Layout; @@ -12,15 +9,13 @@ interface Props { } export default function HeaderComponent({ name = 'Your stream title' }: Props) { - const chatState = useRecoilValue(chatStateAtom); - return (
{name}
- +
); } diff --git a/web/components/ui/Sidebar/Sidebar.tsx b/web/components/ui/Sidebar/Sidebar.tsx index c0e178cf5..987712ab3 100644 --- a/web/components/ui/Sidebar/Sidebar.tsx +++ b/web/components/ui/Sidebar/Sidebar.tsx @@ -3,26 +3,17 @@ import { useRecoilValue } from 'recoil'; import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatContainer, ChatTextField } from '../../chat'; import s from './Sidebar.module.scss'; -import { - chatMessagesAtom, - chatVisibilityAtom, - chatStateAtom, -} from '../../stores/ClientConfigStore'; -import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state'; + +import { chatMessagesAtom, appStateAtom } from '../../stores/ClientConfigStore'; +import { AppStateOptions } from '../../stores/application-state'; export default function Sidebar() { const messages = useRecoilValue(chatMessagesAtom); - const chatVisibility = useRecoilValue(chatVisibilityAtom); - const chatState = useRecoilValue(chatStateAtom); + const appState = useRecoilValue(appStateAtom); return ( - - + + ); diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx index d701d1500..b7cb7333a 100644 --- a/web/components/video/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; import VideoJS from './player'; import ViewerPing from './viewer-ping'; import VideoPoster from './VideoPoster'; import { getLocalStorage, setLocalStorage } from '../../utils/helpers'; -import { videoStateAtom } from '../stores/ClientConfigStore'; -import { VideoState } from '../../interfaces/application-state'; +import { isVideoPlayingAtom } from '../stores/ClientConfigStore'; const PLAYER_VOLUME = 'owncast_volume'; @@ -19,8 +18,7 @@ interface Props { export default function OwncastPlayer(props: Props) { const playerRef = React.useRef(null); const { source, online } = props; - - const setVideoState = useSetRecoilState(videoStateAtom); + const [videoPlaying, setVideoPlaying] = useRecoilState(isVideoPlayingAtom); const setSavedVolume = () => { try { @@ -86,18 +84,19 @@ export default function OwncastPlayer(props: Props) { player.on('playing', () => { player.log('player is playing'); ping.start(); - setVideoState(VideoState.Playing); + setVideoPlaying(true); }); player.on('pause', () => { player.log('player is paused'); ping.stop(); + setVideoPlaying(false); }); player.on('ended', () => { player.log('player is ended'); ping.stop(); - setVideoState(VideoState.Unavailable); + setVideoPlaying(false); }); player.on('volumechange', handleVolume); @@ -111,7 +110,7 @@ export default function OwncastPlayer(props: Props) { )}
- + {!videoPlaying && }
); diff --git a/web/interfaces/application-state.ts b/web/interfaces/application-state.ts deleted file mode 100644 index 869dd87f0..000000000 --- a/web/interfaces/application-state.ts +++ /dev/null @@ -1,64 +0,0 @@ -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. - Banned, // Certain features are disabled for this single user. -} - -export enum ChatVisibilityState { - Hidden, // The chat components are not available to the user. - Visible, // The chat components are not available to the user visually. -} - -export enum ChatState { - Available = 'Available', // Normal state. Chat can be visible and used. - NotAvailable = 'NotAvailable', // Chat features are not available. - Loading = 'Loading', // Chat is connecting and loading history. - Offline = 'Offline', // Chat is offline/disconnected for some reason but is visible. -} - -export enum VideoState { - Available, // Play button should be visible and the user can begin playback. - Unavailable, // Play button not be visible and video is not available. - Playing, // Playback is taking place and the play button should not be shown. -} - -export function getChatState(state: AppState): ChatState { - switch (state) { - case AppState.Loading: - return ChatState.NotAvailable; - case AppState.Banned: - return ChatState.NotAvailable; - case AppState.Online: - return ChatState.Available; - case AppState.Offline: - return ChatState.NotAvailable; - case AppState.OfflineWaiting: - return ChatState.Available; - case AppState.Registering: - return ChatState.Loading; - default: - return ChatState.Offline; - } -} - -export function getChatVisibilityState(state: AppState): ChatVisibilityState { - switch (state) { - case AppState.Loading: - return ChatVisibilityState.Hidden; - case AppState.Banned: - return ChatVisibilityState.Hidden; - case AppState.Online: - return ChatVisibilityState.Visible; - case AppState.Offline: - return ChatVisibilityState.Hidden; - case AppState.OfflineWaiting: - return ChatVisibilityState.Visible; - case AppState.Registering: - return ChatVisibilityState.Visible; - default: - return ChatVisibilityState.Hidden; - } -} diff --git a/web/package-lock.json b/web/package-lock.json index 7e23c9571..7c3309a12 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@ant-design/icons": "4.7.0", "@emoji-mart/data": "^1.0.1", "@storybook/react": "^6.4.22", + "@xstate/react": "^3.0.0", "antd": "^4.20.4", "autoprefixer": "^10.4.4", "chart.js": "3.7.0", @@ -40,7 +41,8 @@ "slate-react": "^0.79.0", "storybook-addon-designs": "^6.2.1", "ua-parser-js": "1.0.2", - "video.js": "^7.18.1" + "video.js": "^7.18.1", + "xstate": "^4.32.1" }, "devDependencies": { "@babel/core": "^7.17.9", @@ -12147,6 +12149,28 @@ "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "dev": true }, + "node_modules/@xstate/react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-3.0.0.tgz", + "integrity": "sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@xstate/fsm": "^2.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "xstate": "^4.31.0" + }, + "peerDependenciesMeta": { + "@xstate/fsm": { + "optional": true + }, + "xstate": { + "optional": true + } + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -31588,6 +31612,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -32553,6 +32585,15 @@ "dev": true, "peer": true }, + "node_modules/xstate": { + "version": "4.32.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.32.1.tgz", + "integrity": "sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -41647,6 +41688,15 @@ "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "dev": true }, + "@xstate/react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-3.0.0.tgz", + "integrity": "sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==", + "requires": { + "use-isomorphic-layout-effect": "^1.0.0", + "use-sync-external-store": "^1.0.0" + } + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -56627,6 +56677,12 @@ "use-isomorphic-layout-effect": "^1.0.0" } }, + "use-sync-external-store": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", + "requires": {} + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -57408,6 +57464,11 @@ "dev": true, "peer": true }, + "xstate": { + "version": "4.32.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.32.1.tgz", + "integrity": "sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 8aac6125b..487f657fa 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@ant-design/icons": "4.7.0", "@emoji-mart/data": "^1.0.1", "@storybook/react": "^6.4.22", + "@xstate/react": "^3.0.0", "antd": "^4.20.4", "autoprefixer": "^10.4.4", "chart.js": "3.7.0", @@ -44,7 +45,8 @@ "slate-react": "^0.79.0", "storybook-addon-designs": "^6.2.1", "ua-parser-js": "1.0.2", - "video.js": "^7.18.1" + "video.js": "^7.18.1", + "xstate": "^4.32.1" }, "devDependencies": { "@babel/core": "^7.17.9", diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 2810afa13..44f1ccd1e 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -23,6 +23,7 @@ import '../styles/offline-notice.scss'; import { AppProps } from 'next/app'; import { Router, useRouter } from 'next/router'; +import { RecoilRoot } from 'recoil'; import AdminLayout from '../components/layouts/admin-layout'; import SimpleLayout from '../components/layouts/SimpleLayout'; @@ -31,7 +32,11 @@ function App({ Component, pageProps }: AppProps) { if (router.pathname.startsWith('/admin')) { return ; } - return ; + return ( + + + + ); } export default App; diff --git a/web/pages/embed/video/index.tsx b/web/pages/embed/video/index.tsx index caf295789..4001bd88f 100644 --- a/web/pages/embed/video/index.tsx +++ b/web/pages/embed/video/index.tsx @@ -1,10 +1,34 @@ +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import { + ClientConfigStore, + isOnlineSelector, + serverStatusState, +} from '../../../components/stores/ClientConfigStore'; +import OfflineBanner from '../../../components/ui/OfflineBanner/OfflineBanner'; +import Statusbar from '../../../components/ui/Statusbar/Statusbar'; import OwncastPlayer from '../../../components/video/OwncastPlayer'; +import { ServerStatus } from '../../../interfaces/server-status.model'; export default function VideoEmbed() { - const online = false; + const status = useRecoilValue(serverStatusState); + + // const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig; + const { viewerCount, lastConnectTime, lastDisconnectTime } = status; + const online = useRecoilValue(isOnlineSelector); return ( -
- -
+ <> + +
+ {online && } + {!online && }{' '} + +
+ ); } diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 6fe751c09..f0367c0ca 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,10 +1,5 @@ -import { RecoilRoot } from 'recoil'; import Main from '../components/layouts/Main'; export default function Home() { - return ( - -
- - ); + return
; } diff --git a/web/stories/ChatContainer.stories.tsx b/web/stories/ChatContainer.stories.tsx index 77d50a769..1a0942d7d 100644 --- a/web/stories/ChatContainer.stories.tsx +++ b/web/stories/ChatContainer.stories.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import ChatContainer from '../components/chat/ChatContainer'; import { ChatMessage } from '../interfaces/chat-message.model'; -import { ChatState } from '../interfaces/application-state'; export default { title: 'owncast/Chat/Chat messages container', @@ -25,7 +24,7 @@ const testMessages = const messages: ChatMessage[] = JSON.parse(testMessages); const AddMessagesChatExample = args => { - const { messages: m, state } = args; + const { messages: m, loading } = args; const [chatMessages, setChatMessages] = useState(m); return ( @@ -33,7 +32,7 @@ const AddMessagesChatExample = args => { - + ); }; @@ -42,17 +41,17 @@ const Template: ComponentStory = args => = args => ; -const Template: ComponentStory = args => ; +const Template: ComponentStory = args => ( + + + +); export const LiveDemo = Template.bind({}); LiveDemo.args = {