Refactor app state to be a state machine with access selectors

This commit is contained in:
Gabe Kangas
2022-05-25 20:38:40 -07:00
parent dde9878a46
commit 7b1667bf6a
21 changed files with 421 additions and 223 deletions

View File

@@ -2,20 +2,19 @@ import { Spin } from 'antd';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { useRef } from 'react'; import { useRef } from 'react';
import { LoadingOutlined } from '@ant-design/icons'; 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 { MessageType } from '../../../interfaces/socket-events';
import s from './ChatContainer.module.scss'; import s from './ChatContainer.module.scss';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatUserMessage } from '..'; import { ChatUserMessage } from '..';
interface Props { interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
state: ChatState; loading: boolean;
} }
export default function ChatContainer(props: Props) { export default function ChatContainer(props: Props) {
const { messages, state } = props; const { messages, loading } = props;
const loading = state === ChatState.Loading;
const chatContainerRef = useRef(null); const chatContainerRef = useRef(null);
const spinIcon = <LoadingOutlined style={{ fontSize: '32px' }} spin />; const spinIcon = <LoadingOutlined style={{ fontSize: '32px' }} spin />;

View File

@@ -1,6 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
interface Props {}
export default function ChatModerationNotification(props: Props) {
return <div>You are now a moderator notification component goes here</div>;
}

View File

@@ -9,27 +9,30 @@ import {
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useState } from 'react'; import { useState } from 'react';
import Modal from '../../ui/Modal/Modal'; import Modal from '../../ui/Modal/Modal';
import { chatVisibilityAtom, chatDisplayNameAtom } from '../../stores/ClientConfigStore'; import {
import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state'; chatVisibleToggleAtom,
chatDisplayNameAtom,
appStateAtom,
} from '../../stores/ClientConfigStore';
import s from './UserDropdown.module.scss'; import s from './UserDropdown.module.scss';
import NameChangeModal from '../../modals/NameChangeModal'; import NameChangeModal from '../../modals/NameChangeModal';
import { AppStateOptions } from '../../stores/application-state';
interface Props { interface Props {
username?: string; username?: string;
chatState: ChatState;
} }
export default function UserDropdown({ username: defaultUsername, chatState }: Props) { export default function UserDropdown({ username: defaultUsername }: Props) {
const [chatVisibility, setChatVisibility] =
useRecoilState<ChatVisibilityState>(chatVisibilityAtom);
const username = defaultUsername || useRecoilValue(chatDisplayNameAtom); const username = defaultUsername || useRecoilValue(chatDisplayNameAtom);
const [showNameChangeModal, setShowNameChangeModal] = useState<boolean>(false); const [showNameChangeModal, setShowNameChangeModal] = useState<boolean>(false);
const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const toggleChatVisibility = () => { const toggleChatVisibility = () => {
if (chatVisibility === ChatVisibilityState.Hidden) { if (!chatToggleVisible) {
setChatVisibility(ChatVisibilityState.Visible); setChatToggleVisible(true);
} else { } else {
setChatVisibility(ChatVisibilityState.Hidden); setChatToggleVisible(false);
} }
}; };
@@ -45,7 +48,7 @@ export default function UserDropdown({ username: defaultUsername, chatState }: P
<Menu.Item key="1" icon={<LockOutlined />}> <Menu.Item key="1" icon={<LockOutlined />}>
Authenticate Authenticate
</Menu.Item> </Menu.Item>
{chatState === ChatState.Available && ( {appState.chatAvailable && (
<Menu.Item key="3" icon={<MessageOutlined />} onClick={() => toggleChatVisibility()}> <Menu.Item key="3" icon={<MessageOutlined />} onClick={() => toggleChatVisibility()}>
Toggle chat Toggle chat
</Menu.Item> </Menu.Item>

View File

@@ -1,31 +1,32 @@
/* eslint-disable no-case-declarations */
import { useEffect } from 'react'; 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 { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
import ClientConfigService from '../../services/client-config-service'; import ClientConfigService from '../../services/client-config-service';
import ChatService from '../../services/chat-service'; import ChatService from '../../services/chat-service';
import WebsocketService from '../../services/websocket-service'; import WebsocketService from '../../services/websocket-service';
import { ChatMessage } from '../../interfaces/chat-message.model'; import { ChatMessage } from '../../interfaces/chat-message.model';
import { ServerStatus, makeEmptyServerStatus } from '../../interfaces/server-status.model'; import { ServerStatus, makeEmptyServerStatus } from '../../interfaces/server-status.model';
import appStateModel, {
import { AppStateEvent,
AppState, AppStateOptions,
ChatState, makeEmptyAppState,
VideoState, } from './application-state';
ChatVisibilityState, import { setLocalStorage, getLocalStorage } from '../../utils/helpers';
getChatState,
getChatVisibilityState,
} from '../../interfaces/application-state';
import { import {
ConnectedClientInfoEvent, ConnectedClientInfoEvent,
MessageType, MessageType,
ChatEvent, ChatEvent,
SocketEvent, SocketEvent,
} from '../../interfaces/socket-events'; } from '../../interfaces/socket-events';
import handleConnectedClientInfoMessage from './eventhandlers/connectedclientinfo';
import handleChatMessage from './eventhandlers/handleChatMessage'; import handleChatMessage from './eventhandlers/handleChatMessage';
import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler';
import ServerStatusService from '../../services/status-service'; 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, // Server status is what gets updated such as viewer count, durations,
// stream title, online/offline state, etc. // stream title, online/offline state, etc.
export const serverStatusState = atom<ServerStatus>({ export const serverStatusState = atom<ServerStatus>({
@@ -39,26 +40,6 @@ export const clientConfigStateAtom = atom({
default: makeEmptyClientConfig(), default: makeEmptyClientConfig(),
}); });
export const appStateAtom = atom<AppState>({
key: 'appStateAtom',
default: AppState.Loading,
});
export const chatStateAtom = atom<ChatState>({
key: 'chatStateAtom',
default: ChatState.Offline,
});
export const videoStateAtom = atom<VideoState>({
key: 'videoStateAtom',
default: VideoState.Unavailable,
});
export const chatVisibilityAtom = atom<ChatVisibilityState>({
key: 'chatVisibility',
default: ChatVisibilityState.Visible,
});
export const chatDisplayNameAtom = atom<string>({ export const chatDisplayNameAtom = atom<string>({
key: 'chatDisplayName', key: 'chatDisplayName',
default: null, default: null,
@@ -79,23 +60,79 @@ export const websocketServiceAtom = atom<WebsocketService>({
default: null, default: null,
}); });
export const appStateAtom = atom<AppStateOptions>({
key: 'appState',
default: makeEmptyAppState(),
});
export const chatVisibleToggleAtom = atom<boolean>({
key: 'chatVisibilityToggleAtom',
default: true,
});
export const isVideoPlayingAtom = atom<boolean>({
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() { export function ClientConfigStore() {
const [appState, appStateSend, appStateService] = useMachine(appStateModel);
const setChatDisplayName = useSetRecoilState<string>(chatDisplayNameAtom);
const setClientConfig = useSetRecoilState<ClientConfig>(clientConfigStateAtom); const setClientConfig = useSetRecoilState<ClientConfig>(clientConfigStateAtom);
const setServerStatus = useSetRecoilState<ServerStatus>(serverStatusState); const setServerStatus = useSetRecoilState<ServerStatus>(serverStatusState);
const setChatVisibility = useSetRecoilState<ChatVisibilityState>(chatVisibilityAtom);
const setChatState = useSetRecoilState<ChatState>(chatStateAtom);
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom); const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
const setChatDisplayName = useSetRecoilState<string>(chatDisplayNameAtom);
const [appState, setAppState] = useRecoilState<AppState>(appStateAtom);
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom); const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom); const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom);
let ws: WebsocketService; let ws: WebsocketService;
const sendEvent = (event: string) => {
// console.log('---- sending event:', event);
appStateSend({ type: event });
};
const updateClientConfig = async () => { const updateClientConfig = async () => {
try { try {
const config = await ClientConfigService.getConfig(); const config = await ClientConfigService.getConfig();
setClientConfig(config); setClientConfig(config);
sendEvent('LOADED');
} catch (error) { } catch (error) {
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`); console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
} }
@@ -105,32 +142,42 @@ export function ClientConfigStore() {
try { try {
const status = await ServerStatusService.getStatus(); const status = await ServerStatusService.getStatus();
setServerStatus(status); setServerStatus(status);
if (status.online) { if (status.online) {
setAppState(AppState.Online); sendEvent(AppStateEvent.Online);
} else { } else if (!status.online) {
setAppState(AppState.Offline); sendEvent(AppStateEvent.Offline);
} }
return status;
} catch (error) { } catch (error) {
sendEvent(AppStateEvent.Fail);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`); console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
return null;
} }
return null;
}; };
const handleUserRegistration = async (optionalDisplayName?: string) => { const handleUserRegistration = async (optionalDisplayName?: string) => {
const savedAccessToken = getLocalStorage(ACCESS_TOKEN_KEY);
if (savedAccessToken) {
setAccessToken(savedAccessToken);
return;
}
try { try {
setAppState(AppState.Registering); sendEvent(AppStateEvent.NeedsRegister);
const response = await ChatService.registerUser(optionalDisplayName); const response = await ChatService.registerUser(optionalDisplayName);
console.log(`ChatService -> registerUser() response: \n${response}`); console.log(`ChatService -> registerUser() response: \n${response}`);
const { accessToken: newAccessToken, displayName: newDisplayName } = response; const { accessToken: newAccessToken, displayName: newDisplayName } = response;
if (!newAccessToken) { if (!newAccessToken) {
return; return;
} }
console.log('setting access token', newAccessToken); console.log('setting access token', newAccessToken);
setAccessToken(newAccessToken); setAccessToken(newAccessToken);
// setLocalStorage('accessToken', newAccessToken); setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken);
setChatDisplayName(newDisplayName); setChatDisplayName(newDisplayName);
// sendEvent(AppStateEvent.Registered);
} catch (e) { } catch (e) {
sendEvent(AppStateEvent.Fail);
console.error(`ChatService -> registerUser() ERROR: \n${e}`); console.error(`ChatService -> registerUser() ERROR: \n${e}`);
} }
}; };
@@ -138,7 +185,7 @@ export function ClientConfigStore() {
const handleMessage = (message: SocketEvent) => { const handleMessage = (message: SocketEvent) => {
switch (message.type) { switch (message.type) {
case MessageType.CONNECTED_USER_INFO: case MessageType.CONNECTED_USER_INFO:
handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent); handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent, setChatDisplayName);
break; break;
case MessageType.CHAT: case MessageType.CHAT:
handleChatMessage(message as ChatEvent, chatMessages, setChatMessages); handleChatMessage(message as ChatEvent, chatMessages, setChatMessages);
@@ -159,11 +206,12 @@ export function ClientConfigStore() {
}; };
const startChat = async () => { const startChat = async () => {
setChatState(ChatState.Loading); sendEvent(AppStateEvent.Loading);
try { try {
ws = new WebsocketService(accessToken, '/ws'); ws = new WebsocketService(accessToken, '/ws');
ws.handleMessage = handleMessage; ws.handleMessage = handleMessage;
setWebsocketService(ws); setWebsocketService(ws);
sendEvent(AppStateEvent.Loaded);
} catch (error) { } catch (error) {
console.error(`ChatService -> startChat() ERROR: \n${error}`); console.error(`ChatService -> startChat() ERROR: \n${error}`);
} }
@@ -172,14 +220,11 @@ export function ClientConfigStore() {
useEffect(() => { useEffect(() => {
updateClientConfig(); updateClientConfig();
handleUserRegistration(); handleUserRegistration();
}, []); updateServerStatus();
useEffect(() => {
setInterval(() => { setInterval(() => {
updateServerStatus(); updateServerStatus();
}, 5000); }, SERVER_STATUS_POLL_DURATION);
updateServerStatus(); }, [appState]);
}, []);
useEffect(() => { useEffect(() => {
if (!accessToken) { if (!accessToken) {
@@ -190,21 +235,18 @@ export function ClientConfigStore() {
startChat(); startChat();
}, [accessToken]); }, [accessToken]);
useEffect(() => { appStateService.onTransition(state => {
const updatedChatState = getChatState(appState); if (!state.changed) {
console.log('updatedChatState', updatedChatState); return;
setChatState(updatedChatState); }
const updatedChatVisibility = getChatVisibilityState(appState);
console.log( const metadata = mergeMeta(state.meta) as AppStateOptions;
'app state: ',
AppState[appState], console.log('--- APP STATE: ', state.value);
'chat state:', console.log('--- APP META: ', metadata);
ChatState[updatedChatState],
'chat visibility:', setAppState(metadata);
ChatVisibilityState[updatedChatVisibility], });
);
setChatVisibility(updatedChatVisibility);
}, [appState]);
return null; return null;
} }

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events';
export default function handleConnectedClientInfoMessage(message: ConnectedClientInfoEvent) {
console.log('connected client', message);
}

View File

@@ -54,6 +54,13 @@
} }
} }
.loadingSpinner {
position: fixed;
left: 50%;
top: 50%;
z-index: 999999;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.mobileChat { .mobileChat {
display: none; display: none;

View File

@@ -1,12 +1,13 @@
import { useRecoilValue } from 'recoil'; 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 { NotificationFilled, HeartFilled } from '@ant-design/icons';
import { import {
chatVisibilityAtom,
clientConfigStateAtom, clientConfigStateAtom,
chatMessagesAtom, chatMessagesAtom,
chatStateAtom, isChatVisibleSelector,
serverStatusState, serverStatusState,
appStateAtom,
isOnlineSelector,
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model'; import { ClientConfig } from '../../../interfaces/client-config.model';
import CustomPageContent from '../../CustomPageContent'; import CustomPageContent from '../../CustomPageContent';
@@ -17,7 +18,6 @@ import Sidebar from '../Sidebar';
import Footer from '../Footer'; import Footer from '../Footer';
import ChatContainer from '../../chat/ChatContainer'; import ChatContainer from '../../chat/ChatContainer';
import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
import ChatTextField from '../../chat/ChatTextField/ChatTextField'; import ChatTextField from '../../chat/ChatTextField/ChatTextField';
import ActionButtonRow from '../../action-buttons/ActionButtonRow'; import ActionButtonRow from '../../action-buttons/ActionButtonRow';
import ActionButton from '../../action-buttons/ActionButton'; import ActionButton from '../../action-buttons/ActionButton';
@@ -28,27 +28,27 @@ import SocialLinks from '../SocialLinks/SocialLinks';
import NotifyReminderPopup from '../NotifyReminderPopup/NotifyReminderPopup'; import NotifyReminderPopup from '../NotifyReminderPopup/NotifyReminderPopup';
import ServerLogo from '../Logo/Logo'; import ServerLogo from '../Logo/Logo';
import CategoryIcon from '../CategoryIcon/CategoryIcon'; import CategoryIcon from '../CategoryIcon/CategoryIcon';
import OfflineBanner from '../OfflineBanner/OfflineBanner';
import { AppStateOptions } from '../../stores/application-state';
const { TabPane } = Tabs; const { TabPane } = Tabs;
const { Content } = Layout; const { Content } = Layout;
export default function ContentComponent() { export default function ContentComponent() {
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const status = useRecoilValue<ServerStatus>(serverStatusState); const status = useRecoilValue<ServerStatus>(serverStatusState);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom); const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const chatVisibility = useRecoilValue<ChatVisibilityState>(chatVisibilityAtom); const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom); const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom);
const chatState = useRecoilValue<ChatState>(chatStateAtom); const online = useRecoilValue<boolean>(isOnlineSelector);
const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig; const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig;
const { online, viewerCount, lastConnectTime, lastDisconnectTime } = status; const { viewerCount, lastConnectTime, lastDisconnectTime } = status;
const followers: Follower[] = []; const followers: Follower[] = [];
const total = 0; const total = 0;
const chatVisible =
chatState === ChatState.Available && chatVisibility === ChatVisibilityState.Visible;
// This is example content. It should be removed. // This is example content. It should be removed.
const externalActions = [ const externalActions = [
{ {
@@ -67,8 +67,12 @@ export default function ContentComponent() {
return ( return (
<Content className={`${s.root}`}> <Content className={`${s.root}`}>
<Spin className={s.loadingSpinner} size="large" spinning={appState.appLoading} />
<div className={`${s.leftCol}`}> <div className={`${s.leftCol}`}>
<OwncastPlayer source="/hls/stream.m3u8" online={online} /> {online && <OwncastPlayer source="/hls/stream.m3u8" online={online} />}
{!online && <OfflineBanner text="Stream is offline text goes here." />}
<Statusbar <Statusbar
online={online} online={online}
lastConnectTime={lastConnectTime} lastConnectTime={lastConnectTime}
@@ -111,16 +115,16 @@ export default function ContentComponent() {
<FollowerCollection total={total} followers={followers} /> <FollowerCollection total={total} followers={followers} />
</TabPane> </TabPane>
</Tabs> </Tabs>
{chatVisibility && ( {isChatVisible && (
<div className={`${s.mobileChat}`}> <div className={`${s.mobileChat}`}>
<ChatContainer messages={messages} state={chatState} /> <ChatContainer messages={messages} loading={appState.chatLoading} />
<ChatTextField /> <ChatTextField />
</div> </div>
)} )}
<Footer version={version} /> <Footer version={version} />
</div> </div>
</div> </div>
{chatVisible && <Sidebar />} {isChatVisible && <Sidebar />}
</Content> </Content>
); );
} }

View File

@@ -1,8 +1,5 @@
import { Layout } from 'antd'; import { Layout } from 'antd';
import { useRecoilValue } from 'recoil';
import { ChatState } from '../../../interfaces/application-state';
import { OwncastLogo, UserDropdown } from '../../common'; import { OwncastLogo, UserDropdown } from '../../common';
import { chatStateAtom } from '../../stores/ClientConfigStore';
import s from './Header.module.scss'; import s from './Header.module.scss';
const { Header } = Layout; const { Header } = Layout;
@@ -12,15 +9,13 @@ interface Props {
} }
export default function HeaderComponent({ name = 'Your stream title' }: Props) { export default function HeaderComponent({ name = 'Your stream title' }: Props) {
const chatState = useRecoilValue<ChatState>(chatStateAtom);
return ( return (
<Header className={`${s.header}`}> <Header className={`${s.header}`}>
<div className={`${s.logo}`}> <div className={`${s.logo}`}>
<OwncastLogo variant="contrast" /> <OwncastLogo variant="contrast" />
<span>{name}</span> <span>{name}</span>
</div> </div>
<UserDropdown chatState={chatState} /> <UserDropdown />
</Header> </Header>
); );
} }

View File

@@ -3,26 +3,17 @@ import { useRecoilValue } from 'recoil';
import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatContainer, ChatTextField } from '../../chat'; import { ChatContainer, ChatTextField } from '../../chat';
import s from './Sidebar.module.scss'; import s from './Sidebar.module.scss';
import {
chatMessagesAtom, import { chatMessagesAtom, appStateAtom } from '../../stores/ClientConfigStore';
chatVisibilityAtom, import { AppStateOptions } from '../../stores/application-state';
chatStateAtom,
} from '../../stores/ClientConfigStore';
import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
export default function Sidebar() { export default function Sidebar() {
const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom); const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom);
const chatVisibility = useRecoilValue<ChatVisibilityState>(chatVisibilityAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const chatState = useRecoilValue<ChatState>(chatStateAtom);
return ( return (
<Sider <Sider className={s.root} collapsedWidth={0} width={320}>
className={s.root} <ChatContainer messages={messages} loading={appState.chatLoading} />
collapsed={chatVisibility === ChatVisibilityState.Hidden}
collapsedWidth={0}
width={320}
>
<ChatContainer messages={messages} state={chatState} />
<ChatTextField /> <ChatTextField />
</Sider> </Sider>
); );

View File

@@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import { useSetRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import VideoJS from './player'; import VideoJS from './player';
import ViewerPing from './viewer-ping'; import ViewerPing from './viewer-ping';
import VideoPoster from './VideoPoster'; import VideoPoster from './VideoPoster';
import { getLocalStorage, setLocalStorage } from '../../utils/helpers'; import { getLocalStorage, setLocalStorage } from '../../utils/helpers';
import { videoStateAtom } from '../stores/ClientConfigStore'; import { isVideoPlayingAtom } from '../stores/ClientConfigStore';
import { VideoState } from '../../interfaces/application-state';
const PLAYER_VOLUME = 'owncast_volume'; const PLAYER_VOLUME = 'owncast_volume';
@@ -19,8 +18,7 @@ interface Props {
export default function OwncastPlayer(props: Props) { export default function OwncastPlayer(props: Props) {
const playerRef = React.useRef(null); const playerRef = React.useRef(null);
const { source, online } = props; const { source, online } = props;
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
const setVideoState = useSetRecoilState<VideoState>(videoStateAtom);
const setSavedVolume = () => { const setSavedVolume = () => {
try { try {
@@ -86,18 +84,19 @@ export default function OwncastPlayer(props: Props) {
player.on('playing', () => { player.on('playing', () => {
player.log('player is playing'); player.log('player is playing');
ping.start(); ping.start();
setVideoState(VideoState.Playing); setVideoPlaying(true);
}); });
player.on('pause', () => { player.on('pause', () => {
player.log('player is paused'); player.log('player is paused');
ping.stop(); ping.stop();
setVideoPlaying(false);
}); });
player.on('ended', () => { player.on('ended', () => {
player.log('player is ended'); player.log('player is ended');
ping.stop(); ping.stop();
setVideoState(VideoState.Unavailable); setVideoPlaying(false);
}); });
player.on('volumechange', handleVolume); player.on('volumechange', handleVolume);
@@ -111,7 +110,7 @@ export default function OwncastPlayer(props: Props) {
</div> </div>
)} )}
<div style={{ gridColumn: 1, gridRow: 1 }}> <div style={{ gridColumn: 1, gridRow: 1 }}>
<VideoPoster online={online} initialSrc="/logo" src="/thumbnail.jpg" /> {!videoPlaying && <VideoPoster online={online} initialSrc="/logo" src="/thumbnail.jpg" />}
</div> </div>
</div> </div>
); );

View File

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

63
web/package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@ant-design/icons": "4.7.0", "@ant-design/icons": "4.7.0",
"@emoji-mart/data": "^1.0.1", "@emoji-mart/data": "^1.0.1",
"@storybook/react": "^6.4.22", "@storybook/react": "^6.4.22",
"@xstate/react": "^3.0.0",
"antd": "^4.20.4", "antd": "^4.20.4",
"autoprefixer": "^10.4.4", "autoprefixer": "^10.4.4",
"chart.js": "3.7.0", "chart.js": "3.7.0",
@@ -40,7 +41,8 @@
"slate-react": "^0.79.0", "slate-react": "^0.79.0",
"storybook-addon-designs": "^6.2.1", "storybook-addon-designs": "^6.2.1",
"ua-parser-js": "1.0.2", "ua-parser-js": "1.0.2",
"video.js": "^7.18.1" "video.js": "^7.18.1",
"xstate": "^4.32.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.9", "@babel/core": "^7.17.9",
@@ -12147,6 +12149,28 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"dev": true "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": { "node_modules/@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "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": { "node_modules/util": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -32553,6 +32585,15 @@
"dev": true, "dev": true,
"peer": 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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -41647,6 +41688,15 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"dev": true "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": { "@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -56627,6 +56677,12 @@
"use-isomorphic-layout-effect": "^1.0.0" "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": { "util": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -57408,6 +57464,11 @@
"dev": true, "dev": true,
"peer": 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": { "xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -15,6 +15,7 @@
"@ant-design/icons": "4.7.0", "@ant-design/icons": "4.7.0",
"@emoji-mart/data": "^1.0.1", "@emoji-mart/data": "^1.0.1",
"@storybook/react": "^6.4.22", "@storybook/react": "^6.4.22",
"@xstate/react": "^3.0.0",
"antd": "^4.20.4", "antd": "^4.20.4",
"autoprefixer": "^10.4.4", "autoprefixer": "^10.4.4",
"chart.js": "3.7.0", "chart.js": "3.7.0",
@@ -44,7 +45,8 @@
"slate-react": "^0.79.0", "slate-react": "^0.79.0",
"storybook-addon-designs": "^6.2.1", "storybook-addon-designs": "^6.2.1",
"ua-parser-js": "1.0.2", "ua-parser-js": "1.0.2",
"video.js": "^7.18.1" "video.js": "^7.18.1",
"xstate": "^4.32.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.9", "@babel/core": "^7.17.9",

View File

@@ -23,6 +23,7 @@ import '../styles/offline-notice.scss';
import { AppProps } from 'next/app'; import { AppProps } from 'next/app';
import { Router, useRouter } from 'next/router'; import { Router, useRouter } from 'next/router';
import { RecoilRoot } from 'recoil';
import AdminLayout from '../components/layouts/admin-layout'; import AdminLayout from '../components/layouts/admin-layout';
import SimpleLayout from '../components/layouts/SimpleLayout'; import SimpleLayout from '../components/layouts/SimpleLayout';
@@ -31,7 +32,11 @@ function App({ Component, pageProps }: AppProps) {
if (router.pathname.startsWith('/admin')) { if (router.pathname.startsWith('/admin')) {
return <AdminLayout pageProps={pageProps} Component={Component} router={router} />; return <AdminLayout pageProps={pageProps} Component={Component} router={router} />;
} }
return <SimpleLayout pageProps={pageProps} Component={Component} router={router} />; return (
<RecoilRoot>
<SimpleLayout pageProps={pageProps} Component={Component} router={router} />
</RecoilRoot>
);
} }
export default App; export default App;

View File

@@ -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 OwncastPlayer from '../../../components/video/OwncastPlayer';
import { ServerStatus } from '../../../interfaces/server-status.model';
export default function VideoEmbed() { export default function VideoEmbed() {
const online = false; const status = useRecoilValue<ServerStatus>(serverStatusState);
// const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig;
const { viewerCount, lastConnectTime, lastDisconnectTime } = status;
const online = useRecoilValue<boolean>(isOnlineSelector);
return ( return (
<div className="video-embed"> <>
<OwncastPlayer source="/hls/stream.m3u8" online={online} /> <ClientConfigStore />
</div> <div className="video-embed">
{online && <OwncastPlayer source="/hls/stream.m3u8" online={online} />}
{!online && <OfflineBanner text="Stream is offline text goes here." />}{' '}
<Statusbar
online={online}
lastConnectTime={lastConnectTime}
lastDisconnectTime={lastDisconnectTime}
viewerCount={viewerCount}
/>
</div>
</>
); );
} }

View File

@@ -1,10 +1,5 @@
import { RecoilRoot } from 'recoil';
import Main from '../components/layouts/Main'; import Main from '../components/layouts/Main';
export default function Home() { export default function Home() {
return ( return <Main />;
<RecoilRoot>
<Main />
</RecoilRoot>
);
} }

View File

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react'; import { ComponentStory, ComponentMeta } from '@storybook/react';
import ChatContainer from '../components/chat/ChatContainer'; import ChatContainer from '../components/chat/ChatContainer';
import { ChatMessage } from '../interfaces/chat-message.model'; import { ChatMessage } from '../interfaces/chat-message.model';
import { ChatState } from '../interfaces/application-state';
export default { export default {
title: 'owncast/Chat/Chat messages container', title: 'owncast/Chat/Chat messages container',
@@ -25,7 +24,7 @@ const testMessages =
const messages: ChatMessage[] = JSON.parse(testMessages); const messages: ChatMessage[] = JSON.parse(testMessages);
const AddMessagesChatExample = args => { const AddMessagesChatExample = args => {
const { messages: m, state } = args; const { messages: m, loading } = args;
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(m); const [chatMessages, setChatMessages] = useState<ChatMessage[]>(m);
return ( return (
@@ -33,7 +32,7 @@ const AddMessagesChatExample = args => {
<button type="button" onClick={() => setChatMessages([...chatMessages, chatMessages[0]])}> <button type="button" onClick={() => setChatMessages([...chatMessages, chatMessages[0]])}>
Add message Add message
</button> </button>
<ChatContainer messages={chatMessages} state={state} /> <ChatContainer messages={chatMessages} loading={loading} />
</div> </div>
); );
}; };
@@ -42,17 +41,17 @@ const Template: ComponentStory<typeof ChatContainer> = args => <AddMessagesChatE
export const Example = Template.bind({}); export const Example = Template.bind({});
Example.args = { Example.args = {
state: ChatState.Available, loading: false,
messages, messages,
}; };
export const SingleMessage = Template.bind({}); export const SingleMessage = Template.bind({});
SingleMessage.args = { SingleMessage.args = {
state: ChatState.Available, loading: false,
messages: [messages[0]], messages: [messages[0]],
}; };
export const Loading = Template.bind({}); export const Loading = Template.bind({});
Loading.args = { Loading.args = {
state: ChatState.Loading, loading: true,
}; };

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react'; import { ComponentStory, ComponentMeta } from '@storybook/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { UserDropdown } from '../components/common'; import { UserDropdown } from '../components/common';
import { ChatState } from '../interfaces/application-state';
export default { export default {
title: 'owncast/Components/User settings menu', title: 'owncast/Components/User settings menu',
@@ -22,11 +21,9 @@ const Template: ComponentStory<typeof UserDropdown> = args => <Example {...args}
export const ChatEnabled = Template.bind({}); export const ChatEnabled = Template.bind({});
ChatEnabled.args = { ChatEnabled.args = {
username: 'test-user', username: 'test-user',
chatState: ChatState.Available,
}; };
export const ChatDisabled = Template.bind({}); export const ChatDisabled = Template.bind({});
ChatDisabled.args = { ChatDisabled.args = {
username: 'test-user', username: 'test-user',
chatState: ChatState.NotAvailable,
}; };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react'; import { ComponentStory, ComponentMeta } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import OwncastPlayer from '../components/video/OwncastPlayer'; import OwncastPlayer from '../components/video/OwncastPlayer';
const streams = { const streams = {
@@ -23,7 +24,11 @@ export default {
parameters: {}, parameters: {},
} as ComponentMeta<typeof OwncastPlayer>; } as ComponentMeta<typeof OwncastPlayer>;
const Template: ComponentStory<typeof OwncastPlayer> = args => <OwncastPlayer {...args} />; const Template: ComponentStory<typeof OwncastPlayer> = args => (
<RecoilRoot>
<OwncastPlayer {...args} />
</RecoilRoot>
);
export const LiveDemo = Template.bind({}); export const LiveDemo = Template.bind({});
LiveDemo.args = { LiveDemo.args = {