2023-02-27 03:54:28 +03:00
import { FC , useContext , useEffect , useState } from 'react' ;
2022-11-14 03:03:37 +03:00
import { atom , selector , useRecoilState , useSetRecoilState , RecoilEnv } from 'recoil' ;
2022-05-26 06:38:40 +03:00
import { useMachine } from '@xstate/react' ;
2022-04-28 09:19:20 +03:00
import { makeEmptyClientConfig , ClientConfig } from '../../interfaces/client-config.model' ;
2023-02-27 03:54:28 +03:00
import { ClientConfigServiceContext } from '../../services/client-config-service' ;
import { ChatServiceContext } from '../../services/chat-service' ;
2022-05-03 03:45:22 +03:00
import WebsocketService from '../../services/websocket-service' ;
2022-04-29 00:36:05 +03:00
import { ChatMessage } from '../../interfaces/chat-message.model' ;
2022-10-11 02:26:09 +03:00
import { CurrentUser } from '../../interfaces/current-user' ;
2022-05-14 00:44:16 +03:00
import { ServerStatus , makeEmptyServerStatus } from '../../interfaces/server-status.model' ;
2022-05-26 06:38:40 +03:00
import appStateModel , {
AppStateEvent ,
AppStateOptions ,
makeEmptyAppState ,
} from './application-state' ;
2022-05-26 23:52:04 +03:00
import { setLocalStorage , getLocalStorage } from '../../utils/localStorage' ;
2022-05-03 08:13:36 +03:00
import {
ConnectedClientInfoEvent ,
2022-05-04 00:17:05 +03:00
MessageType ,
2022-05-03 08:13:36 +03:00
ChatEvent ,
2022-09-05 03:58:06 +03:00
MessageVisibilityEvent ,
2022-05-12 09:31:31 +03:00
SocketEvent ,
2023-02-06 06:58:24 +03:00
FediverseEvent ,
2022-05-03 08:13:36 +03:00
} from '../../interfaces/socket-events' ;
2022-10-19 06:40:57 +03:00
import { mergeMeta } from '../../utils/helpers' ;
2022-05-26 06:38:40 +03:00
import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler' ;
2023-02-27 03:54:28 +03:00
import { ServerStatusServiceContext } from '../../services/status-service' ;
2022-05-26 23:52:04 +03:00
import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent' ;
2022-05-28 08:27:20 +03:00
import { DisplayableError } from '../../types/displayable-error' ;
2022-05-14 00:44:16 +03:00
2022-11-14 03:03:37 +03:00
RecoilEnv . RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false ;
2022-05-26 06:38:40 +03:00
const SERVER_STATUS_POLL_DURATION = 5000 ;
const ACCESS_TOKEN_KEY = 'accessToken' ;
2022-09-11 04:08:51 +03:00
let serverStatusRefreshPoll : ReturnType < typeof setInterval > ;
2023-01-14 07:53:10 +03:00
let hasBeenModeratorNotified = false ;
2022-09-11 04:08:51 +03:00
2023-01-18 06:21:24 +03:00
const serverConnectivityError = ` Cannot connect to the Owncast service. Please check your internet connection or if needed, double check this Owncast server is running. ` ;
2022-05-14 00:44:16 +03:00
// 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 ( ) ,
} ) ;
2022-04-26 09:10:07 +03:00
2022-04-28 09:19:20 +03:00
// The config that comes from the API.
2022-05-03 03:45:22 +03:00
export const clientConfigStateAtom = atom ( {
2022-04-26 09:10:07 +03:00
key : 'clientConfigState' ,
default : makeEmptyClientConfig ( ) ,
} ) ;
2022-10-11 02:26:09 +03:00
export const accessTokenAtom = atom < string > ( {
key : 'accessTokenAtom' ,
2022-06-25 07:30:54 +03:00
default : null ,
} ) ;
2022-10-11 02:26:09 +03:00
export const currentUserAtom = atom < CurrentUser > ( {
key : 'currentUserAtom' ,
2022-04-30 01:09:53 +03:00
default : null ,
2022-04-28 09:19:20 +03:00
} ) ;
2022-05-03 03:45:22 +03:00
export const chatMessagesAtom = atom < ChatMessage [ ] > ( {
2022-04-29 00:36:05 +03:00
key : 'chatMessages' ,
default : [ ] as ChatMessage [ ] ,
} ) ;
2022-08-21 02:13:31 +03:00
export const chatAuthenticatedAtom = atom < boolean > ( {
key : 'chatAuthenticatedAtom' ,
default : false ,
} ) ;
2022-05-05 02:55:54 +03:00
export const websocketServiceAtom = atom < WebsocketService > ( {
key : 'websocketServiceAtom' ,
default : null ,
2022-10-19 02:39:49 +03:00
dangerouslyAllowMutability : true ,
2022-05-05 02:55:54 +03:00
} ) ;
2022-05-26 06:38:40 +03:00
export const appStateAtom = atom < AppStateOptions > ( {
key : 'appState' ,
default : makeEmptyAppState ( ) ,
} ) ;
2022-07-03 13:36:30 +03:00
export const isMobileAtom = atom < boolean | undefined > ( {
key : 'isMobileAtom' ,
default : undefined ,
} ) ;
2022-05-26 06:38:40 +03:00
export const chatVisibleToggleAtom = atom < boolean > ( {
key : 'chatVisibilityToggleAtom' ,
default : true ,
} ) ;
export const isVideoPlayingAtom = atom < boolean > ( {
key : 'isVideoPlayingAtom' ,
default : false ,
} ) ;
2022-05-28 08:27:20 +03:00
export const fatalErrorStateAtom = atom < DisplayableError > ( {
key : 'fatalErrorStateAtom' ,
default : null ,
} ) ;
2022-06-03 00:23:51 +03:00
export const clockSkewAtom = atom < Number > ( {
key : 'clockSkewAtom' ,
default : 0.0 ,
} ) ;
2022-09-05 03:58:06 +03:00
export const removedMessageIdsAtom = atom < string [ ] > ( {
key : 'removedMessageIds' ,
default : [ ] ,
} ) ;
2022-05-26 06:38:40 +03:00
// 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 ) ;
2022-10-11 02:26:09 +03:00
const accessToken : string = get ( accessTokenAtom ) ;
2022-05-26 06:38:40 +03:00
return accessToken && state . chatAvailable && userVisibleToggle ;
} ,
} ) ;
2022-05-26 21:08:37 +03:00
export const isChatAvailableSelector = selector ( {
key : 'isChatAvailableSelector' ,
get : ( { get } ) = > {
const state : AppStateOptions = get ( appStateAtom ) ;
2022-10-11 02:26:09 +03:00
const accessToken : string = get ( accessTokenAtom ) ;
2022-05-26 21:08:37 +03:00
return accessToken && state . chatAvailable ;
} ,
} ) ;
2022-05-26 06:38:40 +03:00
// 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 ;
} ,
} ) ;
2022-09-05 03:58:06 +03:00
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 ) ) ;
} ,
} ) ;
2022-09-11 01:37:07 +03:00
export const ClientConfigStore : FC = ( ) = > {
2023-02-27 03:54:28 +03:00
const ClientConfigService = useContext ( ClientConfigServiceContext ) ;
const ChatService = useContext ( ChatServiceContext ) ;
const ServerStatusService = useContext ( ServerStatusServiceContext ) ;
2022-11-14 03:03:37 +03:00
const [ appState , appStateSend , appStateService ] = useMachine ( appStateModel ) ;
2022-10-11 02:26:09 +03:00
const [ currentUser , setCurrentUser ] = useRecoilState ( currentUserAtom ) ;
2022-08-21 02:13:31 +03:00
const setChatAuthenticated = useSetRecoilState < boolean > ( chatAuthenticatedAtom ) ;
2022-09-11 06:03:58 +03:00
const [ clientConfig , setClientConfig ] = useRecoilState < ClientConfig > ( clientConfigStateAtom ) ;
2023-02-01 10:28:05 +03:00
const [ , setServerStatus ] = useRecoilState < ServerStatus > ( serverStatusState ) ;
2022-06-03 00:23:51 +03:00
const setClockSkew = useSetRecoilState < Number > ( clockSkewAtom ) ;
2023-02-06 06:58:24 +03:00
const [ chatMessages , setChatMessages ] = useRecoilState < SocketEvent [ ] > ( chatMessagesAtom ) ;
2022-05-03 08:13:36 +03:00
const [ accessToken , setAccessToken ] = useRecoilState < string > ( accessTokenAtom ) ;
2022-05-26 06:38:40 +03:00
const setAppState = useSetRecoilState < AppStateOptions > ( appStateAtom ) ;
2022-05-28 08:27:20 +03:00
const setGlobalFatalErrorMessage = useSetRecoilState < DisplayableError > ( fatalErrorStateAtom ) ;
2022-05-26 06:38:40 +03:00
const setWebsocketService = useSetRecoilState < WebsocketService > ( websocketServiceAtom ) ;
2022-09-05 03:58:06 +03:00
const [ hiddenMessageIds , setHiddenMessageIds ] = useRecoilState < string [ ] > ( removedMessageIdsAtom ) ;
2022-09-11 04:08:51 +03:00
const [ hasLoadedConfig , setHasLoadedConfig ] = useState ( false ) ;
2022-05-28 08:27:20 +03:00
2022-05-05 09:06:35 +03:00
let ws : WebsocketService ;
2022-04-26 09:10:07 +03:00
2022-05-28 08:27:20 +03:00
const setGlobalFatalError = ( title : string , message : string ) = > {
setGlobalFatalErrorMessage ( {
title ,
message ,
} ) ;
} ;
2023-02-01 10:28:05 +03:00
const sendEvent = ( events : string [ ] ) = > {
2022-11-14 03:03:37 +03:00
// console.debug('---- sending event:', event);
2023-02-01 10:28:05 +03:00
appStateSend ( events ) ;
2022-05-26 06:38:40 +03:00
} ;
2022-11-14 03:03:37 +03:00
const handleStatusChange = ( status : ServerStatus ) = > {
if ( appState . matches ( 'loading' ) ) {
2023-02-01 10:28:05 +03:00
const events = [ AppStateEvent . Loaded ] ;
if ( status . online ) {
events . push ( AppStateEvent . Online ) ;
} else {
events . push ( AppStateEvent . Offline ) ;
}
sendEvent ( events ) ;
2022-11-14 03:03:37 +03:00
return ;
}
2022-11-21 00:27:37 +03:00
if ( status . online && appState . matches ( 'ready' ) ) {
2023-02-01 10:28:05 +03:00
sendEvent ( [ AppStateEvent . Online ] ) ;
2022-11-14 03:03:37 +03:00
} else if ( ! status . online && ! appState . matches ( 'ready.offline' ) ) {
2023-02-01 10:28:05 +03:00
sendEvent ( [ AppStateEvent . Offline ] ) ;
2022-11-14 03:03:37 +03:00
}
} ;
2022-04-26 09:10:07 +03:00
const updateClientConfig = async ( ) = > {
try {
const config = await ClientConfigService . getConfig ( ) ;
setClientConfig ( config ) ;
2022-05-28 08:27:20 +03:00
setGlobalFatalErrorMessage ( null ) ;
2022-09-11 04:08:51 +03:00
setHasLoadedConfig ( true ) ;
2022-04-26 09:10:07 +03:00
} catch ( error ) {
2023-01-18 06:21:24 +03:00
setGlobalFatalError ( 'Unable to reach Owncast server' , serverConnectivityError ) ;
2023-02-27 03:54:28 +03:00
console . error ( ` ClientConfigService -> getConfig() ERROR: \ n ` , error ) ;
2022-04-26 09:10:07 +03:00
}
} ;
2022-05-14 00:44:16 +03:00
const updateServerStatus = async ( ) = > {
try {
const status = await ServerStatusService . getStatus ( ) ;
2023-02-01 10:28:05 +03:00
handleStatusChange ( status ) ;
2022-05-14 00:44:16 +03:00
setServerStatus ( status ) ;
2023-02-01 10:28:05 +03:00
2022-06-03 00:23:51 +03:00
const { serverTime } = status ;
const clockSkew = new Date ( serverTime ) . getTime ( ) - Date . now ( ) ;
setClockSkew ( clockSkew ) ;
2022-05-26 06:38:40 +03:00
2022-05-28 08:27:20 +03:00
setGlobalFatalErrorMessage ( null ) ;
2022-05-14 00:44:16 +03:00
} catch ( error ) {
2023-02-01 10:28:05 +03:00
sendEvent ( [ AppStateEvent . Fail ] ) ;
2023-01-18 06:21:24 +03:00
setGlobalFatalError ( 'Unable to reach Owncast server' , serverConnectivityError ) ;
2023-02-27 03:54:28 +03:00
console . error ( ` serverStatusState -> getStatus() ERROR: \ n ` , error ) ;
2022-05-14 00:44:16 +03:00
}
} ;
2022-05-03 03:45:22 +03:00
const handleUserRegistration = async ( optionalDisplayName? : string ) = > {
2022-05-26 06:38:40 +03:00
const savedAccessToken = getLocalStorage ( ACCESS_TOKEN_KEY ) ;
if ( savedAccessToken ) {
setAccessToken ( savedAccessToken ) ;
return ;
}
2022-04-30 01:09:53 +03:00
try {
2023-02-01 10:28:05 +03:00
sendEvent ( [ AppStateEvent . NeedsRegister ] ) ;
2022-04-30 01:09:53 +03:00
const response = await ChatService . registerUser ( optionalDisplayName ) ;
2022-08-10 05:56:45 +03:00
const { accessToken : newAccessToken , displayName : newDisplayName , displayColor } = response ;
2022-04-30 01:09:53 +03:00
if ( ! newAccessToken ) {
return ;
}
2022-05-26 06:38:40 +03:00
2022-10-11 02:26:09 +03:00
setCurrentUser ( {
. . . currentUser ,
displayName : newDisplayName ,
displayColor ,
} ) ;
2022-05-03 03:45:22 +03:00
setAccessToken ( newAccessToken ) ;
2022-05-26 06:38:40 +03:00
setLocalStorage ( ACCESS_TOKEN_KEY , newAccessToken ) ;
2022-04-30 01:09:53 +03:00
} catch ( e ) {
2023-02-01 10:28:05 +03:00
sendEvent ( [ AppStateEvent . Fail ] ) ;
2022-04-30 01:09:53 +03:00
console . error ( ` ChatService -> registerUser() ERROR: \ n ${ e } ` ) ;
}
} ;
2022-05-26 08:51:17 +03:00
const resetAndReAuth = ( ) = > {
setLocalStorage ( ACCESS_TOKEN_KEY , '' ) ;
2022-10-11 02:26:09 +03:00
setAccessToken ( null ) ;
2022-05-26 08:51:17 +03:00
handleUserRegistration ( ) ;
} ;
2022-09-05 03:58:06 +03:00
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 ) ;
}
} ;
2022-05-03 08:13:36 +03:00
const handleMessage = ( message : SocketEvent ) = > {
switch ( message . type ) {
2022-05-26 08:51:17 +03:00
case MessageType . ERROR_NEEDS_REGISTRATION :
resetAndReAuth ( ) ;
break ;
2022-05-04 00:17:05 +03:00
case MessageType . CONNECTED_USER_INFO :
2022-06-25 07:30:54 +03:00
handleConnectedClientInfoMessage (
message as ConnectedClientInfoEvent ,
2022-08-21 02:13:31 +03:00
setChatAuthenticated ,
2022-10-11 02:26:09 +03:00
setCurrentUser ,
2022-06-25 07:30:54 +03:00
) ;
2023-03-04 08:54:01 +03:00
if ( message as ChatEvent ) {
const m = new ChatEvent ( message ) ;
if ( ! hasBeenModeratorNotified && m . user ? . isModerator ( ) ) {
setChatMessages ( currentState = > [ . . . currentState , message as ChatEvent ] ) ;
hasBeenModeratorNotified = true ;
}
2023-01-14 07:53:10 +03:00
}
2023-03-04 08:54:01 +03:00
2022-05-03 08:13:36 +03:00
break ;
2022-05-04 00:17:05 +03:00
case MessageType . CHAT :
2022-06-29 21:50:56 +03:00
setChatMessages ( currentState = > [ . . . currentState , message as ChatEvent ] ) ;
2022-05-03 08:13:36 +03:00
break ;
2022-05-26 23:52:04 +03:00
case MessageType . NAME_CHANGE :
2022-12-30 07:11:20 +03:00
handleNameChangeEvent ( message as ChatEvent , setChatMessages ) ;
2022-05-26 23:52:04 +03:00
break ;
2022-07-15 07:05:34 +03:00
case MessageType . USER_JOINED :
setChatMessages ( currentState = > [ . . . currentState , message as ChatEvent ] ) ;
break ;
2022-08-11 06:22:00 +03:00
case MessageType . SYSTEM :
setChatMessages ( currentState = > [ . . . currentState , message as ChatEvent ] ) ;
break ;
2022-10-19 05:44:42 +03:00
case MessageType . CHAT_ACTION :
setChatMessages ( currentState = > [ . . . currentState , message as ChatEvent ] ) ;
break ;
2023-02-06 06:58:24 +03:00
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 ;
2022-09-05 03:58:06 +03:00
case MessageType . VISIBILITY_UPDATE :
handleMessageVisibilityChange ( message as MessageVisibilityEvent ) ;
break ;
2022-05-03 08:13:36 +03:00
default :
console . error ( 'Unknown socket message type: ' , message . type ) ;
}
} ;
2022-04-30 01:09:53 +03:00
const getChatHistory = async ( ) = > {
try {
const messages = await ChatService . getChatHistory ( accessToken ) ;
2022-06-29 21:50:56 +03:00
setChatMessages ( currentState = > [ . . . currentState , . . . messages ] ) ;
2022-04-30 01:09:53 +03:00
} catch ( error ) {
console . error ( ` ChatService -> getChatHistory() ERROR: \ n ${ error } ` ) ;
}
2022-05-03 08:13:36 +03:00
} ;
const startChat = async ( ) = > {
try {
2022-10-19 06:40:57 +03:00
const { socketHostOverride } = clientConfig ;
2023-01-22 10:17:11 +03:00
// Get a copy of the browser location without #fragments.
const l = window . location ;
l . hash = '' ;
const location = l . toString ( ) . replaceAll ( '#' , '' ) ;
const host = socketHostOverride || location ;
2022-10-19 06:40:57 +03:00
ws = new WebsocketService ( accessToken , '/ws' , host ) ;
2022-05-05 02:55:54 +03:00
ws . handleMessage = handleMessage ;
setWebsocketService ( ws ) ;
2022-05-03 08:13:36 +03:00
} catch ( error ) {
console . error ( ` ChatService -> startChat() ERROR: \ n ${ error } ` ) ;
}
2022-04-30 01:09:53 +03:00
} ;
2022-10-19 05:21:03 +03:00
const handleChatNotification = ( ) = > { } ;
2022-09-11 01:37:07 +03:00
// 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 ) ;
2022-10-25 07:26:03 +03:00
setHasLoadedConfig ( true ) ;
2022-09-11 01:37:07 +03:00
}
} catch ( e ) {
2022-10-19 05:21:03 +03:00
console . error ( 'Error parsing config hydration' , e ) ;
2022-09-11 01:37:07 +03:00
}
try {
if ( ( window as any ) . statusHydration ) {
const status = JSON . parse ( ( window as any ) . statusHydration ) ;
setServerStatus ( status ) ;
2023-02-01 10:28:05 +03:00
handleStatusChange ( status ) ;
2022-09-11 01:37:07 +03:00
}
} catch ( e ) {
2022-10-19 05:21:03 +03:00
console . error ( 'error parsing status hydration' , e ) ;
2022-09-11 01:37:07 +03:00
}
} , [ ] ) ;
2022-09-11 04:08:51 +03:00
useEffect ( ( ) = > {
2022-09-11 06:03:58 +03:00
if ( ! clientConfig . chatDisabled && accessToken && hasLoadedConfig ) {
startChat ( ) ;
}
} , [ hasLoadedConfig , accessToken ] ) ;
2022-10-19 05:21:03 +03:00
// Notify about chat activity when backgrounded.
useEffect ( ( ) = > {
handleChatNotification ( ) ;
} , [ chatMessages ] ) ;
2022-04-26 09:10:07 +03:00
useEffect ( ( ) = > {
updateClientConfig ( ) ;
2022-04-30 01:09:53 +03:00
handleUserRegistration ( ) ;
2022-05-26 06:38:40 +03:00
updateServerStatus ( ) ;
2022-09-11 04:08:51 +03:00
clearInterval ( serverStatusRefreshPoll ) ;
serverStatusRefreshPoll = setInterval ( ( ) = > {
2022-05-14 00:44:16 +03:00
updateServerStatus ( ) ;
2022-05-26 06:38:40 +03:00
} , SERVER_STATUS_POLL_DURATION ) ;
2022-11-02 10:01:21 +03:00
return ( ) = > {
clearInterval ( serverStatusRefreshPoll ) ;
} ;
} , [ ] ) ;
2022-05-14 00:44:16 +03:00
useEffect ( ( ) = > {
2023-01-11 03:39:12 +03:00
if ( accessToken ) {
getChatHistory ( ) ;
2022-05-03 03:45:22 +03:00
}
2022-04-30 01:09:53 +03:00
} , [ accessToken ] ) ;
2022-05-29 04:43:28 +03:00
useEffect ( ( ) = > {
appStateService . onTransition ( state = > {
const metadata = mergeMeta ( state . meta ) as AppStateOptions ;
2022-05-26 06:38:40 +03:00
2022-08-23 05:23:06 +03:00
// console.debug('--- APP STATE: ', state.value);
// console.debug('--- APP META: ', metadata);
2022-05-26 06:38:40 +03:00
2022-05-29 04:43:28 +03:00
setAppState ( metadata ) ;
} ) ;
2022-11-02 10:01:21 +03:00
} , [ ] ) ;
2022-05-03 03:45:22 +03:00
2022-04-26 09:10:07 +03:00
return null ;
2022-09-07 10:00:28 +03:00
} ;