402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import { FC, useEffect, useState } from 'react';
|
|
import { atom, selector, useRecoilState, useSetRecoilState } from 'recoil';
|
|
import { useMachine } from '@xstate/react';
|
|
import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
|
|
import ClientConfigService from '../../services/client-config-service';
|
|
import ChatService from '../../services/chat-service';
|
|
import WebsocketService from '../../services/websocket-service';
|
|
import { ChatMessage } from '../../interfaces/chat-message.model';
|
|
import { CurrentUser } from '../../interfaces/current-user';
|
|
import { ServerStatus, makeEmptyServerStatus } from '../../interfaces/server-status.model';
|
|
import appStateModel, {
|
|
AppStateEvent,
|
|
AppStateOptions,
|
|
makeEmptyAppState,
|
|
} from './application-state';
|
|
import { setLocalStorage, getLocalStorage } from '../../utils/localStorage';
|
|
import {
|
|
ConnectedClientInfoEvent,
|
|
MessageType,
|
|
ChatEvent,
|
|
MessageVisibilityEvent,
|
|
SocketEvent,
|
|
} from '../../interfaces/socket-events';
|
|
|
|
import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler';
|
|
import ServerStatusService from '../../services/status-service';
|
|
import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent';
|
|
import { DisplayableError } from '../../types/displayable-error';
|
|
|
|
const SERVER_STATUS_POLL_DURATION = 5000;
|
|
const ACCESS_TOKEN_KEY = 'accessToken';
|
|
|
|
let serverStatusRefreshPoll: ReturnType<typeof setInterval>;
|
|
|
|
// Server status is what gets updated such as viewer count, durations,
|
|
// stream title, online/offline state, etc.
|
|
export const serverStatusState = atom<ServerStatus>({
|
|
key: 'serverStatusState',
|
|
default: makeEmptyServerStatus(),
|
|
});
|
|
|
|
// The config that comes from the API.
|
|
export const clientConfigStateAtom = atom({
|
|
key: 'clientConfigState',
|
|
default: makeEmptyClientConfig(),
|
|
});
|
|
|
|
export const accessTokenAtom = atom<string>({
|
|
key: 'accessTokenAtom',
|
|
default: null,
|
|
});
|
|
|
|
export const currentUserAtom = atom<CurrentUser>({
|
|
key: 'currentUserAtom',
|
|
default: null,
|
|
});
|
|
|
|
export const chatMessagesAtom = atom<ChatMessage[]>({
|
|
key: 'chatMessages',
|
|
default: [] as ChatMessage[],
|
|
});
|
|
|
|
export const chatAuthenticatedAtom = atom<boolean>({
|
|
key: 'chatAuthenticatedAtom',
|
|
default: false,
|
|
});
|
|
|
|
export const websocketServiceAtom = atom<WebsocketService>({
|
|
key: 'websocketServiceAtom',
|
|
default: null,
|
|
});
|
|
|
|
export const appStateAtom = atom<AppStateOptions>({
|
|
key: 'appState',
|
|
default: makeEmptyAppState(),
|
|
});
|
|
|
|
export const isMobileAtom = atom<boolean | undefined>({
|
|
key: 'isMobileAtom',
|
|
default: undefined,
|
|
});
|
|
|
|
export const chatVisibleToggleAtom = atom<boolean>({
|
|
key: 'chatVisibilityToggleAtom',
|
|
default: true,
|
|
});
|
|
|
|
export const isVideoPlayingAtom = atom<boolean>({
|
|
key: 'isVideoPlayingAtom',
|
|
default: false,
|
|
});
|
|
|
|
export const fatalErrorStateAtom = atom<DisplayableError>({
|
|
key: 'fatalErrorStateAtom',
|
|
default: null,
|
|
});
|
|
|
|
export const clockSkewAtom = atom<Number>({
|
|
key: 'clockSkewAtom',
|
|
default: 0.0,
|
|
});
|
|
|
|
export const removedMessageIdsAtom = atom<string[]>({
|
|
key: 'removedMessageIds',
|
|
default: [],
|
|
});
|
|
|
|
// 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;
|
|
},
|
|
});
|
|
|
|
export const isChatAvailableSelector = selector({
|
|
key: 'isChatAvailableSelector',
|
|
get: ({ get }) => {
|
|
const state: AppStateOptions = get(appStateAtom);
|
|
const accessToken: string = get(accessTokenAtom);
|
|
return accessToken && state.chatAvailable;
|
|
},
|
|
});
|
|
|
|
// 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;
|
|
},
|
|
});
|
|
|
|
export const visibleChatMessagesSelector = selector<ChatMessage[]>({
|
|
key: 'visibleChatMessagesSelector',
|
|
get: ({ get }) => {
|
|
const messages: ChatMessage[] = get(chatMessagesAtom);
|
|
const removedIds: string[] = get(removedMessageIdsAtom);
|
|
return messages.filter(message => !removedIds.includes(message.id));
|
|
},
|
|
});
|
|
|
|
// 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 const ClientConfigStore: FC = () => {
|
|
const [appState, appStateSend, appStateService] = useMachine(appStateModel);
|
|
const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom);
|
|
const setChatAuthenticated = useSetRecoilState<boolean>(chatAuthenticatedAtom);
|
|
const [clientConfig, setClientConfig] = useRecoilState<ClientConfig>(clientConfigStateAtom);
|
|
const setServerStatus = useSetRecoilState<ServerStatus>(serverStatusState);
|
|
const setClockSkew = useSetRecoilState<Number>(clockSkewAtom);
|
|
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
|
|
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
|
|
const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
|
|
const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
|
|
const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom);
|
|
const [hiddenMessageIds, setHiddenMessageIds] = useRecoilState<string[]>(removedMessageIdsAtom);
|
|
const [hasLoadedStatus, setHasLoadedStatus] = useState(false);
|
|
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
|
|
|
let ws: WebsocketService;
|
|
|
|
const setGlobalFatalError = (title: string, message: string) => {
|
|
setGlobalFatalErrorMessage({
|
|
title,
|
|
message,
|
|
});
|
|
};
|
|
const sendEvent = (event: string) => {
|
|
// console.log('---- sending event:', event);
|
|
appStateSend({ type: event });
|
|
};
|
|
|
|
const updateClientConfig = async () => {
|
|
try {
|
|
const config = await ClientConfigService.getConfig();
|
|
setClientConfig(config);
|
|
setGlobalFatalErrorMessage(null);
|
|
setHasLoadedConfig(true);
|
|
} catch (error) {
|
|
setGlobalFatalError(
|
|
'Unable to reach Owncast server',
|
|
`Owncast cannot launch. Please make sure the Owncast server is running.`,
|
|
);
|
|
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
|
|
}
|
|
};
|
|
|
|
const updateServerStatus = async () => {
|
|
try {
|
|
const status = await ServerStatusService.getStatus();
|
|
setServerStatus(status);
|
|
setHasLoadedStatus(true);
|
|
const { serverTime } = status;
|
|
|
|
const clockSkew = new Date(serverTime).getTime() - Date.now();
|
|
setClockSkew(clockSkew);
|
|
|
|
if (status.online) {
|
|
sendEvent(AppStateEvent.Online);
|
|
} else if (!status.online) {
|
|
sendEvent(AppStateEvent.Offline);
|
|
}
|
|
setGlobalFatalErrorMessage(null);
|
|
} catch (error) {
|
|
sendEvent(AppStateEvent.Fail);
|
|
setGlobalFatalError(
|
|
'Unable to reach Owncast server',
|
|
`Owncast cannot launch. Please make sure the Owncast server is running.`,
|
|
);
|
|
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const handleUserRegistration = async (optionalDisplayName?: string) => {
|
|
const savedAccessToken = getLocalStorage(ACCESS_TOKEN_KEY);
|
|
if (savedAccessToken) {
|
|
setAccessToken(savedAccessToken);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
sendEvent(AppStateEvent.NeedsRegister);
|
|
const response = await ChatService.registerUser(optionalDisplayName);
|
|
console.log(`ChatService -> registerUser() response: \n${response}`);
|
|
const { accessToken: newAccessToken, displayName: newDisplayName, displayColor } = response;
|
|
if (!newAccessToken) {
|
|
return;
|
|
}
|
|
|
|
console.log('setting access token', newAccessToken);
|
|
setCurrentUser({
|
|
...currentUser,
|
|
displayName: newDisplayName,
|
|
displayColor,
|
|
});
|
|
setAccessToken(newAccessToken);
|
|
setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken);
|
|
} catch (e) {
|
|
sendEvent(AppStateEvent.Fail);
|
|
console.error(`ChatService -> registerUser() ERROR: \n${e}`);
|
|
}
|
|
};
|
|
|
|
const resetAndReAuth = () => {
|
|
setLocalStorage(ACCESS_TOKEN_KEY, '');
|
|
setAccessToken(null);
|
|
handleUserRegistration();
|
|
};
|
|
|
|
const handleMessageVisibilityChange = (message: MessageVisibilityEvent) => {
|
|
const { ids, visible } = message;
|
|
if (visible) {
|
|
const updatedIds = hiddenMessageIds.filter(id => !ids.includes(id));
|
|
setHiddenMessageIds(updatedIds);
|
|
} else {
|
|
const updatedIds = [...hiddenMessageIds, ...ids];
|
|
setHiddenMessageIds(updatedIds);
|
|
}
|
|
};
|
|
|
|
const handleMessage = (message: SocketEvent) => {
|
|
switch (message.type) {
|
|
case MessageType.ERROR_NEEDS_REGISTRATION:
|
|
resetAndReAuth();
|
|
break;
|
|
case MessageType.CONNECTED_USER_INFO:
|
|
handleConnectedClientInfoMessage(
|
|
message as ConnectedClientInfoEvent,
|
|
setChatAuthenticated,
|
|
setCurrentUser,
|
|
);
|
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
|
break;
|
|
case MessageType.CHAT:
|
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
|
break;
|
|
case MessageType.NAME_CHANGE:
|
|
handleNameChangeEvent(message as ChatEvent, chatMessages, setChatMessages);
|
|
break;
|
|
case MessageType.USER_JOINED:
|
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
|
break;
|
|
case MessageType.SYSTEM:
|
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
|
break;
|
|
case MessageType.VISIBILITY_UPDATE:
|
|
handleMessageVisibilityChange(message as MessageVisibilityEvent);
|
|
break;
|
|
default:
|
|
console.error('Unknown socket message type: ', message.type);
|
|
}
|
|
};
|
|
|
|
const getChatHistory = async () => {
|
|
try {
|
|
const messages = await ChatService.getChatHistory(accessToken);
|
|
setChatMessages(currentState => [...currentState, ...messages]);
|
|
} catch (error) {
|
|
console.error(`ChatService -> getChatHistory() ERROR: \n${error}`);
|
|
}
|
|
};
|
|
|
|
const startChat = async () => {
|
|
try {
|
|
ws = new WebsocketService(accessToken, '/ws');
|
|
ws.handleMessage = handleMessage;
|
|
setWebsocketService(ws);
|
|
} catch (error) {
|
|
console.error(`ChatService -> startChat() ERROR: \n${error}`);
|
|
}
|
|
};
|
|
|
|
// Read the config and status on initial load from a JSON string that lives
|
|
// in window. This is placed there server-side and allows for fast initial
|
|
// load times because we don't have to wait for the API calls to complete.
|
|
useEffect(() => {
|
|
try {
|
|
if ((window as any).configHydration) {
|
|
const config = JSON.parse((window as any).configHydration);
|
|
setClientConfig(config);
|
|
}
|
|
} catch (e) {
|
|
// console.error('Error parsing config hydration', e);
|
|
}
|
|
|
|
try {
|
|
if ((window as any).statusHydration) {
|
|
const status = JSON.parse((window as any).statusHydration);
|
|
setServerStatus(status);
|
|
}
|
|
} catch (e) {
|
|
// console.error('error parsing status hydration', e);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (hasLoadedStatus && hasLoadedConfig) {
|
|
sendEvent(AppStateEvent.Loaded);
|
|
}
|
|
}, [hasLoadedStatus, hasLoadedConfig]);
|
|
|
|
useEffect(() => {
|
|
if (!clientConfig.chatDisabled && accessToken && hasLoadedConfig) {
|
|
startChat();
|
|
}
|
|
}, [hasLoadedConfig, accessToken]);
|
|
|
|
useEffect(() => {
|
|
updateClientConfig();
|
|
handleUserRegistration();
|
|
updateServerStatus();
|
|
|
|
clearInterval(serverStatusRefreshPoll);
|
|
serverStatusRefreshPoll = setInterval(() => {
|
|
updateServerStatus();
|
|
}, SERVER_STATUS_POLL_DURATION);
|
|
}, [appState]);
|
|
|
|
useEffect(() => {
|
|
if (!accessToken) {
|
|
return;
|
|
}
|
|
|
|
getChatHistory();
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
appStateService.onTransition(state => {
|
|
if (!state.changed) {
|
|
return;
|
|
}
|
|
|
|
const metadata = mergeMeta(state.meta) as AppStateOptions;
|
|
|
|
// console.debug('--- APP STATE: ', state.value);
|
|
// console.debug('--- APP META: ', metadata);
|
|
|
|
setAppState(metadata);
|
|
});
|
|
});
|
|
|
|
return null;
|
|
};
|