mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	 e59167deaa
			
		
	
	e59167deaa
	
	
	
		
			
			This resolves https://github.com/owncast/owncast/issues/3240 From the comments: This was trickier than expected, but the root of the problem is Firefox will set `#` in the URL bar when `window.location.hash` is set to _any_ string, even a blank string. The morale of the story is, don't mutate base data if you just want to copy values. 😅 Sample of Firefox JavaScript console session that demonstrates the issue: ```javascript >> window.location.href "https://github.com/owncast/owncast/issues/3240" >> const setBlankHash = () => { window.location.hash = ''; }; undefined >> window.location.hash "" >> window.location.href "https://github.com/owncast/owncast/issues/3240" >> setBlankHash() undefined >> // My browser just jumped to the top of the page undefined >> window.location.hash "" >> window.location.href "https://github.com/owncast/owncast/issues/3240#" ```
		
			
				
	
	
		
			481 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			481 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { FC, useContext, useEffect, useState } from 'react';
 | |
| import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil';
 | |
| import { useMachine } from '@xstate/react';
 | |
| import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
 | |
| import { ClientConfigServiceContext } from '../../services/client-config-service';
 | |
| import { ChatServiceContext } 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,
 | |
|   NameChangeEvent,
 | |
|   MessageVisibilityEvent,
 | |
|   SocketEvent,
 | |
|   FediverseEvent,
 | |
| } from '../../interfaces/socket-events';
 | |
| import { mergeMeta } from '../../utils/helpers';
 | |
| import { handleConnectedClientInfoMessage } from './eventhandlers/connected-client-info-handler';
 | |
| import { ServerStatusServiceContext } from '../../services/status-service';
 | |
| import { handleNameChangeEvent } from './eventhandlers/handleNameChangeEvent';
 | |
| import { DisplayableError } from '../../types/displayable-error';
 | |
| 
 | |
| RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false;
 | |
| 
 | |
| const SERVER_STATUS_POLL_DURATION = 5000;
 | |
| const ACCESS_TOKEN_KEY = 'accessToken';
 | |
| 
 | |
| let serverStatusRefreshPoll: ReturnType<typeof setInterval>;
 | |
| let hasBeenModeratorNotified = false;
 | |
| let hasWebsocketDisconnected = false;
 | |
| 
 | |
| const serverConnectivityError = `Cannot connect to the Owncast service. Please check your internet connection and verify this Owncast server is running.`;
 | |
| 
 | |
| // 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,
 | |
|   dangerouslyAllowMutability: true,
 | |
| });
 | |
| 
 | |
| export const appStateAtom = atom<AppStateOptions>({
 | |
|   key: 'appState',
 | |
|   default: makeEmptyAppState(),
 | |
| });
 | |
| 
 | |
| export const isMobileAtom = atom<boolean | undefined>({
 | |
|   key: 'isMobileAtom',
 | |
|   default: undefined,
 | |
| });
 | |
| 
 | |
| 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,
 | |
| });
 | |
| 
 | |
| const removedMessageIdsAtom = atom<string[]>({
 | |
|   key: 'removedMessageIds',
 | |
|   default: [],
 | |
| });
 | |
| 
 | |
| export const isChatAvailableSelector = selector({
 | |
|   key: 'isChatAvailableSelector',
 | |
|   get: ({ get }) => {
 | |
|     const state: AppStateOptions = get(appStateAtom);
 | |
|     const accessToken: string = get(accessTokenAtom);
 | |
|     return accessToken && state.chatAvailable && !hasWebsocketDisconnected;
 | |
|   },
 | |
| });
 | |
| 
 | |
| // The requested state of chat in the UI
 | |
| export enum ChatState {
 | |
|   VISIBLE, // Chat is open (the default state when the stream is online)
 | |
|   HIDDEN, // Chat is hidden
 | |
|   POPPED_OUT, // Chat is playing in a popout window
 | |
|   EMBEDDED, // This window is opened at /embed/chat/readwrite/
 | |
| }
 | |
| 
 | |
| export const chatStateAtom = atom<ChatState>({
 | |
|   key: 'chatState',
 | |
|   default: (() => {
 | |
|     // XXX Somehow, `window` is undefined here, even though this runs in client
 | |
|     const window = globalThis;
 | |
|     return window?.location?.pathname === '/embed/chat/readwrite/'
 | |
|       ? ChatState.EMBEDDED
 | |
|       : ChatState.VISIBLE;
 | |
|   })(),
 | |
| });
 | |
| 
 | |
| // 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));
 | |
|   },
 | |
| });
 | |
| 
 | |
| export const ClientConfigStore: FC = () => {
 | |
|   const ClientConfigService = useContext(ClientConfigServiceContext);
 | |
|   const ChatService = useContext(ChatServiceContext);
 | |
|   const ServerStatusService = useContext(ServerStatusServiceContext);
 | |
| 
 | |
|   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 setChatMessages = useSetRecoilState<SocketEvent[]>(chatMessagesAtom);
 | |
|   const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
 | |
|   const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
 | |
|   const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
 | |
|   const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom);
 | |
|   const setHiddenMessageIds = useSetRecoilState<string[]>(removedMessageIdsAtom);
 | |
|   const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
 | |
| 
 | |
|   let ws: WebsocketService;
 | |
| 
 | |
|   const setGlobalFatalError = (title: string, message: string) => {
 | |
|     setGlobalFatalErrorMessage({
 | |
|       title,
 | |
|       message,
 | |
|     });
 | |
|   };
 | |
|   const sendEvent = (events: string[]) => {
 | |
|     // console.debug('---- sending event:', event);
 | |
|     appStateSend(events);
 | |
|   };
 | |
| 
 | |
|   const handleStatusChange = (status: ServerStatus) => {
 | |
|     if (appState.matches('loading')) {
 | |
|       const events = [AppStateEvent.Loaded];
 | |
|       if (status.online) {
 | |
|         events.push(AppStateEvent.Online);
 | |
|       } else {
 | |
|         events.push(AppStateEvent.Offline);
 | |
|       }
 | |
|       sendEvent(events);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (status.online && appState.matches('ready')) {
 | |
|       sendEvent([AppStateEvent.Online]);
 | |
|     } else if (!status.online && !appState.matches('ready.offline')) {
 | |
|       sendEvent([AppStateEvent.Offline]);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const updateClientConfig = async () => {
 | |
|     try {
 | |
|       const config = await ClientConfigService.getConfig();
 | |
|       setClientConfig(config);
 | |
|       setGlobalFatalErrorMessage(null);
 | |
|       setHasLoadedConfig(true);
 | |
|     } catch (error) {
 | |
|       setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
 | |
|       console.error(`ClientConfigService -> getConfig() ERROR: \n`, error);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const updateServerStatus = async () => {
 | |
|     try {
 | |
|       const status = await ServerStatusService.getStatus();
 | |
|       handleStatusChange(status);
 | |
|       setServerStatus(status);
 | |
| 
 | |
|       const { serverTime } = status;
 | |
| 
 | |
|       const clockSkew = new Date(serverTime).getTime() - Date.now();
 | |
|       setClockSkew(clockSkew);
 | |
| 
 | |
|       setGlobalFatalErrorMessage(null);
 | |
|     } catch (error) {
 | |
|       sendEvent([AppStateEvent.Fail]);
 | |
|       setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
 | |
|       console.error(`serverStatusState -> getStatus() ERROR: \n`, error);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   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);
 | |
|       const { accessToken: newAccessToken, displayName: newDisplayName, displayColor } = response;
 | |
|       if (!newAccessToken) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       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);
 | |
|     ws?.shutdown();
 | |
|     handleUserRegistration();
 | |
|   };
 | |
| 
 | |
|   const handleMessageVisibilityChange = (message: MessageVisibilityEvent) => {
 | |
|     const { ids, visible } = message;
 | |
|     if (visible) {
 | |
|       setHiddenMessageIds(currentState => currentState.filter(id => !ids.includes(id)));
 | |
|     } else {
 | |
|       setHiddenMessageIds(currentState => [...currentState, ...ids]);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const handleSocketDisconnect = () => {
 | |
|     hasWebsocketDisconnected = true;
 | |
|   };
 | |
| 
 | |
|   const handleSocketConnected = () => {
 | |
|     hasWebsocketDisconnected = false;
 | |
|   };
 | |
| 
 | |
|   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,
 | |
|         );
 | |
|         if (message as ChatEvent) {
 | |
|           const m = new ChatEvent(message);
 | |
|           if (!hasBeenModeratorNotified && m.user?.isModerator) {
 | |
|             setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|             hasBeenModeratorNotified = true;
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         break;
 | |
|       case MessageType.CHAT:
 | |
|         setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|         break;
 | |
|       case MessageType.NAME_CHANGE:
 | |
|         handleNameChangeEvent(message as NameChangeEvent, setChatMessages, setCurrentUser);
 | |
|         break;
 | |
|       case MessageType.USER_JOINED:
 | |
|         setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|         break;
 | |
|       case MessageType.USER_PARTED:
 | |
|         setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|         break;
 | |
|       case MessageType.SYSTEM:
 | |
|         setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|         break;
 | |
|       case MessageType.CHAT_ACTION:
 | |
|         setChatMessages(currentState => [...currentState, message as ChatEvent]);
 | |
|         break;
 | |
|       case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW:
 | |
|         setChatMessages(currentState => [...currentState, message as FediverseEvent]);
 | |
|         break;
 | |
|       case MessageType.FEDIVERSE_ENGAGEMENT_LIKE:
 | |
|         setChatMessages(currentState => [...currentState, message as FediverseEvent]);
 | |
|         break;
 | |
|       case MessageType.FEDIVERSE_ENGAGEMENT_REPOST:
 | |
|         setChatMessages(currentState => [...currentState, message as FediverseEvent]);
 | |
|         break;
 | |
|       case MessageType.VISIBILITY_UPDATE:
 | |
|         handleMessageVisibilityChange(message as MessageVisibilityEvent);
 | |
|         break;
 | |
|       case MessageType.ERROR_USER_DISABLED:
 | |
|         console.log('User has been disabled');
 | |
|         sendEvent([AppStateEvent.ChatUserDisabled]);
 | |
|         break;
 | |
|       default:
 | |
|         console.error('Unknown socket message type: ', message.type);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const getChatHistory = async () => {
 | |
|     try {
 | |
|       const messages = await ChatService.getChatHistory(accessToken);
 | |
|       if (messages) {
 | |
|         setChatMessages(currentState => [...currentState, ...messages]);
 | |
|       }
 | |
|     } catch (error) {
 | |
|       console.error(`ChatService -> getChatHistory() ERROR: \n${error}`);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const startChat = async () => {
 | |
|     try {
 | |
|       if (ws) {
 | |
|         ws?.shutdown();
 | |
|         setWebsocketService(null);
 | |
|         ws = null;
 | |
|       }
 | |
| 
 | |
|       const { socketHostOverride } = clientConfig;
 | |
| 
 | |
|       // Get a copy of the browser location without #fragments.
 | |
|       const location = window.location.origin + window.location.pathname;
 | |
|       const host = socketHostOverride || location;
 | |
| 
 | |
|       ws = new WebsocketService(accessToken, '/ws', host);
 | |
|       ws.handleMessage = handleMessage;
 | |
|       ws.socketDisconnected = handleSocketDisconnect;
 | |
|       ws.socketConnected = handleSocketConnected;
 | |
|       setWebsocketService(ws);
 | |
|     } catch (error) {
 | |
|       console.error(`ChatService -> startChat() ERROR: \n${error}`);
 | |
|       sendEvent([AppStateEvent.ChatUserDisabled]);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   // 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);
 | |
|         setHasLoadedConfig(true);
 | |
|       }
 | |
|     } 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);
 | |
|         handleStatusChange(status);
 | |
|       }
 | |
|     } catch (e) {
 | |
|       console.error('error parsing status hydration', e);
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       if ((window as any).configHydration && (window as any).statusHydration) {
 | |
|         sendEvent([AppStateEvent.Loaded]);
 | |
|       }
 | |
|     } catch (e) {
 | |
|       console.error('error sending loaded event', e);
 | |
|     }
 | |
|   }, []);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (clientConfig.chatDisabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!accessToken) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!hasLoadedConfig) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (ws) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     startChat();
 | |
|   }, [hasLoadedConfig, accessToken]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (!(window as any).configHydration) {
 | |
|       updateClientConfig();
 | |
|     }
 | |
|     handleUserRegistration();
 | |
|     if (!(window as any).statusHydration) {
 | |
|       updateServerStatus();
 | |
|     }
 | |
|     clearInterval(serverStatusRefreshPoll);
 | |
|     serverStatusRefreshPoll = setInterval(() => {
 | |
|       updateServerStatus();
 | |
|     }, SERVER_STATUS_POLL_DURATION);
 | |
| 
 | |
|     return () => {
 | |
|       clearInterval(serverStatusRefreshPoll);
 | |
|     };
 | |
|   }, []);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (accessToken) {
 | |
|       getChatHistory();
 | |
|     }
 | |
|   }, [accessToken]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     appStateService.onTransition(state => {
 | |
|       const metadata = mergeMeta(state.meta) as AppStateOptions;
 | |
| 
 | |
|       // console.debug('--- APP STATE: ', state.value);
 | |
|       // console.debug('--- APP META: ', metadata);
 | |
| 
 | |
|       setAppState(metadata);
 | |
|     });
 | |
|   }, []);
 | |
| 
 | |
|   return null;
 | |
| };
 |