Refactor app state to be a state machine with access selectors
This commit is contained in:
@@ -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 />;
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
134
web/components/stores/application-state.ts
Normal file
134
web/components/stores/application-state.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events';
|
|
||||||
|
|
||||||
export default function handleConnectedClientInfoMessage(message: ConnectedClientInfoEvent) {
|
|
||||||
console.log('connected client', message);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
63
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user