Files
owncast/web/components/stores/ClientConfigStore.tsx
T
605c4eed00 Fix for initial client status and config not hydrating (#4772)
* fix(js): fix server hydration not working. will speed up initial load time

* fix: update lockfile

* fix(js): fix for malformed or invalid JSON in hydration

* chore: update extracted translations

---------

Co-authored-by: Owncast default web localizations <owncast@owncast.online>
2026-01-28 18:12:25 -08:00

519 lines
17 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;
// Helper to safely parse hydration data from window object
// This runs during module initialization so data is available for first render
// Returns both the config and whether parsing succeeded
const getInitialConfig = (): { config: ClientConfig; success: boolean } => {
if (typeof window !== 'undefined' && (window as any).configHydration) {
try {
const parsed = JSON.parse((window as any).configHydration);
if (parsed) {
return { config: parsed, success: true };
}
} catch (e) {
console.error('Error parsing config hydration during init', e);
}
}
return { config: makeEmptyClientConfig(), success: false };
};
const getInitialStatus = (): { status: ServerStatus; success: boolean } => {
if (typeof window !== 'undefined' && (window as any).statusHydration) {
try {
const parsed = JSON.parse((window as any).statusHydration);
if (parsed) {
return { status: parsed, success: true };
}
} catch (e) {
console.error('Error parsing status hydration during init', e);
}
}
return { status: makeEmptyServerStatus(), success: false };
};
// Cache the initial values to avoid re-parsing
const configResult = getInitialConfig();
const statusResult = getInitialStatus();
const initialConfig = configResult.config;
const initialStatus = statusResult.status;
const hasHydratedConfig = configResult.success;
const hasHydratedStatus = statusResult.success;
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.
// Initialize with hydration data if available for faster first render.
export const serverStatusState = atom<ServerStatus>({
key: 'serverStatusState',
default: initialStatus,
});
// The config that comes from the API.
// Initialize with hydration data if available for faster first render.
export const clientConfigStateAtom = atom({
key: 'clientConfigState',
default: initialConfig,
});
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,
});
// Stores chat input draft to preserve text across mobile/desktop mode switches
export const chatInputDraftAtom = atom<string>({
key: 'chatInputDraftAtom',
default: '',
});
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(hasHydratedConfig);
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]);
}
};
// Handle initial state machine transition when hydration data was available.
// The atoms are already initialized with hydration data at module load time,
// so we just need to update the app state machine and calculate clock skew.
useEffect(() => {
if (hasHydratedConfig && hasHydratedStatus) {
// Transition app state machine based on hydrated status
const events = [AppStateEvent.Loaded];
if (initialStatus.online) {
events.push(AppStateEvent.Online);
} else {
events.push(AppStateEvent.Offline);
}
sendEvent(events);
// Calculate clock skew from hydrated server time
if (initialStatus.serverTime) {
const clockSkew = new Date(initialStatus.serverTime).getTime() - Date.now();
setClockSkew(clockSkew);
}
}
}, []);
useEffect(() => {
if (clientConfig.chatDisabled) {
return;
}
if (!accessToken) {
return;
}
if (!hasLoadedConfig) {
return;
}
if (ws) {
return;
}
startChat();
}, [hasLoadedConfig, accessToken]);
useEffect(() => {
// Only fetch config from API if not hydrated from server
if (!hasHydratedConfig) {
updateClientConfig();
}
handleUserRegistration();
// Only fetch status from API if not hydrated from server
if (!hasHydratedStatus) {
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;
};