Convert app state to zustand stores

This commit is contained in:
Mark McDowall 2025-12-02 17:56:14 -08:00
parent 9b0dc0dd4a
commit c62a7c310e
No known key found for this signature in database
26 changed files with 389 additions and 434 deletions

View file

@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
@ -8,6 +7,7 @@ import {
} from 'AddSeries/addSeriesOptionsStore';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { useAppDimension } from 'App/appStore';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -24,7 +24,6 @@ import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import { useIsWindows } from 'System/Status/useSystemStatus';
import { InputChanged } from 'typings/inputs';
@ -45,7 +44,7 @@ function AddNewSeriesModalContent({
}: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const isWindows = useIsWindows();
const { isAdding, addError, addSeries } = useAddSeries();

View file

@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { useAppDimension } from 'App/appStore';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@ -10,7 +11,6 @@ import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
@ -38,7 +38,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
} = series;
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
const seasonCount = statistics.seasonCount;

View file

@ -2,6 +2,7 @@ import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useAppDimension } from 'App/appStore';
import { useSelect } from 'App/Select/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
@ -12,7 +13,6 @@ import {
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { UnmappedFolder } from 'typings/RootFolder';
@ -64,7 +64,7 @@ function ImportSeriesTable({
useAddSeriesOptions();
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const allSeries = useSelector(createAllSeriesSelector());
const {
allSelected,

View file

@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@ -13,7 +12,7 @@ import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
import { useAppValues } from './appStore';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
@ -63,7 +62,7 @@ interface AppUpdatedModalContentProps {
}
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { version, prevVersion } = useAppValues('version', 'prevVersion');
const { isFetched, error, data, refetch } = useUpdates();
const previousVersion = usePrevious(version);

View file

@ -4,33 +4,13 @@ import CommandAppState from './CommandAppState';
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
import ImportSeriesAppState from './ImportSeriesAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MessagesAppState from './MessagesAppState';
import OAuthAppState from './OAuthAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
export interface AppSectionState {
isUpdated: boolean;
isConnected: boolean;
isDisconnected: boolean;
isReconnecting: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
isLargeScreen: boolean;
width: number;
height: number;
};
messages: MessagesAppState;
}
interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
captcha: CaptchaAppState;
commands: CommandAppState;

View file

@ -1,15 +0,0 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
type MessagesAppState = AppSectionState<Message>;
export default MessagesAppState;

View file

@ -0,0 +1,202 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import fetchJson from 'Utilities/requestAction';
function getDimensions(width: number, height: number) {
const dimensions = {
width,
height,
isExtraSmallScreen: width <= 480,
isSmallScreen: width <= 768,
isMediumScreen: width <= 992,
isLargeScreen: width <= 1200,
};
return dimensions;
}
interface Dimensions {
width: number;
height: number;
isExtraSmallScreen: boolean;
isSmallScreen: boolean;
isMediumScreen: boolean;
isLargeScreen: boolean;
}
interface AppState {
dimensions: Dimensions;
version: string;
prevVersion?: string;
isUpdated: boolean;
isConnected: boolean;
isReconnecting: boolean;
isDisconnected: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
}
// Variables for ping functionality
let abortPingServer: (() => void) | null = null;
let pingTimeout: ReturnType<typeof setTimeout> | null = null;
const useAppStore = create<AppState>()(() => {
const dimensions = getDimensions(window.innerWidth, window.innerHeight);
return {
dimensions,
version: window.Sonarr.version,
isUpdated: false,
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !dimensions.isSmallScreen,
};
});
export const useAppValues = <K extends keyof AppState>(...keys: K[]) => {
return useAppStore(
useShallow((state) => {
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
})
);
};
export const useAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore(useShallow((state) => state[key]));
};
export const useAppDimensions = () => {
return useAppStore(useShallow((state) => state.dimensions));
};
export const useAppDimension = <K extends keyof Dimensions>(key: K) => {
return useAppStore(useShallow((state) => state.dimensions[key]));
};
export const getAppDimensions = () => {
return useAppStore.getState().dimensions;
};
export const getAppValues = <K extends keyof AppState>(...keys: K[]) => {
const state = useAppStore.getState();
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
};
export const getAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore.getState()[key];
};
function pingServerAfterTimeout() {
if (abortPingServer) {
abortPingServer();
abortPingServer = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
pingTimeout = setTimeout(async () => {
const { isRestarting, isConnected } = getAppValues(
'isRestarting',
'isConnected'
);
if (!isRestarting && isConnected) {
return;
}
const abortController = new AbortController();
abortPingServer = () => abortController.abort();
try {
await fetchJson({
url: getQueryPath('/system/status'),
method: 'GET',
signal: abortController.signal,
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
});
abortPingServer = null;
pingTimeout = null;
setAppValue({
isRestarting: false,
});
} catch (error: unknown) {
abortPingServer = null;
pingTimeout = null;
if ((error as { status?: number }).status === 401) {
setAppValue({
isRestarting: false,
});
} else if (!abortController.signal.aborted) {
pingServerAfterTimeout();
}
}
}, 5000);
}
export const saveDimensions = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const dimensions = getDimensions(width, height);
useAppStore.setState({ dimensions });
};
export const setVersion = ({ version }: { version: string }) => {
useAppStore.setState((state) => {
const newState: Partial<AppState> = {
version,
};
if (state.version !== version) {
if (!state.prevVersion) {
newState.prevVersion = state.version;
}
newState.isUpdated = true;
}
return newState;
});
};
export const setIsSidebarVisible = ({
isSidebarVisible,
}: {
isSidebarVisible: boolean;
}) => {
useAppStore.setState({ isSidebarVisible });
};
export const toggleIsSidebarVisible = () => {
useAppStore.setState((state) => ({
isSidebarVisible: !state.isSidebarVisible,
}));
};
export const setAppValue = (payload: Partial<AppState>) => {
useAppStore.setState(payload);
};
export const pingServer = () => {
pingServerAfterTimeout();
};

View file

@ -0,0 +1,56 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import ModelBase from './ModelBase';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
interface MessagesState {
messages: Message[];
}
const useMessagesStore = create<MessagesState>()(() => ({
messages: [],
}));
export const useMessages = () => {
return useMessagesStore(useShallow((state) => state.messages));
};
export const getMessages = () => {
return useMessagesStore.getState().messages;
};
export const showMessage = (payload: Message) => {
useMessagesStore.setState((state) => {
const messages = [...state.messages];
const index = messages.findIndex((item) => item.id === payload.id);
if (index >= 0) {
const item = messages[index];
messages.splice(index, 1, { ...item, ...payload });
} else {
messages.push({ ...payload });
}
return {
messages,
};
});
};
export const hideMessage = ({ id }: { id: string | number }) => {
useMessagesStore.setState((state) => {
const messages = state.messages.filter((item) => item.id !== id);
return {
messages,
};
});
};

View file

@ -1,8 +1,7 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { useAppValue } from 'App/appStore';
import { useCalendarOption } from 'Calendar/calendarOptionsStore';
import * as calendarViews from 'Calendar/calendarViews';
import {
@ -14,12 +13,9 @@ import CalendarDay from './CalendarDay';
import styles from './CalendarDays.css';
function CalendarDays() {
const dispatch = useDispatch();
const view = useCalendarOption('view');
const dates = useCalendarDates();
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const isSidebarVisible = useAppValue('isSidebarVisible');
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const touchStart = useRef<number | null>(null);
@ -61,31 +57,28 @@ function CalendarDays() {
[isSidebarVisible]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
const handleTouchEnd = useCallback((event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100
) {
dispatch(goToPreviousRange());
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100
) {
dispatch(goToNextRange());
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100
) {
goToPreviousRange();
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100
) {
goToNextRange();
}
touchStart.current = null;
},
[dispatch]
);
touchStart.current = null;
}, []);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;

View file

@ -1,6 +1,7 @@
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useAppDimensions } from 'App/appStore';
import {
setCalendarOption,
useCalendarOption,
@ -21,7 +22,6 @@ import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
@ -33,9 +33,7 @@ function CalendarHeader() {
const time = useCalendarTime();
const { start, end } = useCalendarRange();
const { isSmallScreen, isLargeScreen } = useSelector(
createDimensionsSelector()
);
const { isSmallScreen, isLargeScreen } = useAppDimensions();
const { longDateFormat } = useSelector(createUISettingsSelector());

View file

@ -1,11 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { toggleIsSidebarVisible } from 'App/appStore';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import { setIsSidebarVisible } from 'Store/Actions/appActions';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
@ -13,18 +11,14 @@ import SeriesSearchInput from './SeriesSearchInput';
import styles from './PageHeader.css';
function PageHeader() {
const dispatch = useDispatch();
const { isSidebarVisible } = useSelector((state: AppState) => state.app);
const [isKeyboardShortcutsModalOpen, setIsKeyboardShortcutsModalOpen] =
useState(false);
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
const handleSidebarToggle = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: !isSidebarVisible }));
}, [isSidebarVisible, dispatch]);
toggleIsSidebarVisible();
}, []);
const handleOpenKeyboardShortcutsModal = useCallback(() => {
setIsKeyboardShortcutsModalOpen(true);

View file

@ -1,14 +1,12 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { saveDimensions, useAppValue } from 'App/appStore';
import AppUpdatedModal from 'App/AppUpdatedModal';
import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModal from 'App/ConnectionLostModal';
import AppState from 'App/State/AppState';
import SignalRListener from 'Components/SignalRListener';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import useAppPage from 'Helpers/Hooks/useAppPage';
import { saveDimensions } from 'Store/Actions/appActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { useSystemStatusData } from 'System/Status/useSystemStatus';
import ErrorPage from './ErrorPage';
@ -22,7 +20,9 @@ interface PageProps {
}
function Page({ children }: PageProps) {
const dispatch = useDispatch();
const isUpdated = useAppValue('isUpdated');
const isDisconnected = useAppValue('isDisconnected');
const version = useAppValue('version');
const { hasError, errors, isPopulated, isLocalStorageSupported } =
useAppPage();
const [isUpdatedModalOpen, setIsUpdatedModalOpen] = useState(false);
@ -30,26 +30,20 @@ function Page({ children }: PageProps) {
useState(false);
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
const { isSmallScreen } = useSelector(createDimensionsSelector());
const { authentication } = useSystemStatusData();
const authenticationEnabled = authentication !== 'none';
const { isSidebarVisible, isUpdated, isDisconnected, version } = useSelector(
(state: AppState) => state.app
);
const handleUpdatedModalClose = useCallback(() => {
setIsUpdatedModalOpen(false);
}, []);
const handleResize = useCallback(() => {
dispatch(
saveDimensions({
width: window.innerWidth,
height: window.innerHeight,
})
);
}, [dispatch]);
saveDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
@ -93,10 +87,7 @@ function Page({ children }: PageProps) {
<PageHeader />
<div className={styles.main}>
<PageSidebar
isSmallScreen={isSmallScreen}
isSidebarVisible={isSidebarVisible}
/>
<PageSidebar />
{children}
</div>

View file

@ -1,10 +1,8 @@
import classNames from 'classnames';
import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { MessageType } from 'App/State/MessagesAppState';
import { hideMessage, MessageType } from 'App/messagesStore';
import Icon, { IconName } from 'Components/Icon';
import { icons } from 'Helpers/Props';
import { hideMessage } from 'Store/Actions/appActions';
import styles from './Message.css';
interface MessageProps {
@ -16,7 +14,6 @@ interface MessageProps {
}
function Message({ id, hideAfter, name, message, type }: MessageProps) {
const dispatch = useDispatch();
const dismissTimeout = useRef<ReturnType<typeof setTimeout>>();
const icon: IconName = useMemo(() => {
@ -49,7 +46,7 @@ function Message({ id, hideAfter, name, message, type }: MessageProps) {
useEffect(() => {
if (hideAfter) {
dismissTimeout.current = setTimeout(() => {
dispatch(hideMessage({ id }));
hideMessage({ id });
dismissTimeout.current = undefined;
}, hideAfter * 1000);
@ -60,7 +57,7 @@ function Message({ id, hideAfter, name, message, type }: MessageProps) {
clearTimeout(dismissTimeout.current);
}
};
}, [id, hideAfter, message, type, dispatch]);
}, [id, hideAfter, message, type]);
return (
<div className={classNames(styles.message, styles[type])}>

View file

@ -1,12 +1,10 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { Message as MessageModel } from 'App/State/MessagesAppState';
import { Message as MessageModel, useMessages } from 'App/messagesStore';
import Message from './Message';
import styles from './Messages.css';
function Messages() {
const items = useSelector((state: AppState) => state.app.messages.items);
const items = useMessages();
const messages = useMemo(() => {
return items.reduce<MessageModel[]>((acc, item) => {

View file

@ -6,9 +6,13 @@ import React, {
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router';
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
import {
setIsSidebarVisible,
useAppDimension,
useAppValue,
} from 'App/appStore';
import { IconName } from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
@ -16,7 +20,6 @@ import OverlayScroller from 'Components/Scroller/OverlayScroller';
import Scroller from 'Components/Scroller/Scroller';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { setIsSidebarVisible } from 'Store/Actions/appActions';
import dimensions from 'Styles/Variables/dimensions';
import HealthStatus from 'System/Status/Health/HealthStatus';
import translate from 'Utilities/String/translate';
@ -211,13 +214,9 @@ function hasActiveChildLink(link: SidebarItem, pathname: string) {
});
}
interface PageSidebarProps {
isSmallScreen: boolean;
isSidebarVisible: boolean;
}
function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
const dispatch = useDispatch();
function PageSidebar() {
const isSidebarVisible = useAppValue('isSidebarVisible');
const isSmallScreen = useAppDimension('isSmallScreen');
const location = useLocation();
const sidebarRef = useRef(null);
const touchStartX = useRef<number | null>(null);
@ -286,15 +285,15 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
) {
event.preventDefault();
event.stopPropagation();
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
setIsSidebarVisible({ isSidebarVisible: false });
}
},
[isSidebarVisible, dispatch]
[isSidebarVisible]
);
const handleItemPress = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
}, [dispatch]);
setIsSidebarVisible({ isSidebarVisible: false });
}, []);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
@ -378,8 +377,8 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
}, []);
const handleSidebarClosePress = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
}, [dispatch]);
setIsSidebarVisible({ isSidebarVisible: false });
}, []);
useEffect(() => {
if (isSmallScreen) {
@ -413,14 +412,14 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
});
} else if (sidebarTransform.transform === 0 && !isSidebarVisible) {
dispatch(setIsSidebarVisible({ isSidebarVisible: true }));
setIsSidebarVisible({ isSidebarVisible: true });
} else if (
sidebarTransform.transform === -SIDEBAR_WIDTH &&
isSidebarVisible
) {
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
setIsSidebarVisible({ isSidebarVisible: false });
}
}, [sidebarTransform, isSidebarVisible, wasSidebarVisible, dispatch]);
}, [sidebarTransform, isSidebarVisible, wasSidebarVisible]);
const containerStyle = useMemo(() => {
if (!isSmallScreen) {

View file

@ -6,12 +6,12 @@ import {
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { setAppValue, setVersion } from 'App/appStore';
import ModelBase from 'App/ModelBase';
import Command from 'Commands/Command';
import Episode from 'Episode/Episode';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import {
fetchCommands,
@ -45,42 +45,36 @@ function SignalRListener() {
console.error('[signalR] failed to connect');
console.error(error);
dispatch(
setAppValue({
isConnected: false,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
})
);
setAppValue({
isConnected: false,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
});
});
const handleStart = useRef(() => {
console.debug('[signalR] connected');
dispatch(
setAppValue({
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
})
);
setAppValue({
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
});
});
const handleReconnecting = useRef(() => {
dispatch(setAppValue({ isReconnecting: true }));
setAppValue({ isReconnecting: true });
});
const handleReconnected = useRef(() => {
dispatch(
setAppValue({
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
})
);
setAppValue({
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
});
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
@ -387,7 +381,7 @@ function SignalRListener() {
}
if (name === 'version') {
dispatch(setVersion({ version: body.version }));
setVersion({ version: body.version });
return;
}
@ -435,7 +429,7 @@ function SignalRListener() {
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
if (retryContext.elapsedMilliseconds > 180000) {
dispatch(setAppValue({ isDisconnected: true }));
setAppValue({ isDisconnected: true });
}
return Math.min(retryContext.previousRetryCount, 10) * 1000;
},

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useAppDimension } from 'App/appStore';
import * as commandNames from 'Commands/commandNames';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@ -35,7 +36,6 @@ import { Statistics } from 'Series/Series';
import useSeries from 'Series/useSeries';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { TableOptionsChangePayload } from 'typings/Table';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import isAfter from 'Utilities/Date/isAfter';
@ -123,7 +123,7 @@ function SeriesDetailsSeason({
const { columns, sortKey, sortDirection } = useEpisodeOptions();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const isSearching = useSelector(
createIsSearchingSelector(seriesId, seasonNumber)
);

View file

@ -7,6 +7,7 @@ import React, {
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { useAppDimension } from 'App/appStore';
import { SelectProvider } from 'App/Select/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
@ -37,7 +38,6 @@ import {
} from 'Store/Actions/seriesIndexActions';
import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSeriesClientSideCollectionItemsSelector from 'Store/Selectors/createSeriesClientSideCollectionItemsSelector';
import translate from 'Utilities/String/translate';
import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu';
@ -95,7 +95,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(RSS_SYNC)
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const dispatch = useDispatch();
const scrollerRef = useRef<HTMLDivElement>(null);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);

View file

@ -1,207 +0,0 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import createHandleActions from './Creators/createHandleActions';
function getDimensions(width, height) {
const dimensions = {
width,
height,
isExtraSmallScreen: width <= 480,
isSmallScreen: width <= 768,
isMediumScreen: width <= 992,
isLargeScreen: width <= 1200
};
return dimensions;
}
//
// Variables
export const section = 'app';
const messagesSection = 'app.messages';
let abortPingServer = null;
let pingTimeout = null;
//
// State
export const defaultState = {
dimensions: getDimensions(window.innerWidth, window.innerHeight),
messages: {
items: []
},
version: window.Sonarr.version,
isUpdated: false,
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
};
//
// Action Types
export const SHOW_MESSAGE = 'app/showMessage';
export const HIDE_MESSAGE = 'app/hideMessage';
export const SAVE_DIMENSIONS = 'app/saveDimensions';
export const SET_VERSION = 'app/setVersion';
export const SET_APP_VALUE = 'app/setAppValue';
export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
export const PING_SERVER = 'app/pingServer';
//
// Action Creators
export const saveDimensions = createAction(SAVE_DIMENSIONS);
export const setVersion = createAction(SET_VERSION);
export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE);
export const setAppValue = createAction(SET_APP_VALUE);
export const showMessage = createAction(SHOW_MESSAGE);
export const hideMessage = createAction(HIDE_MESSAGE);
export const pingServer = createThunk(PING_SERVER);
//
// Helpers
function pingServerAfterTimeout(getState, dispatch) {
if (abortPingServer) {
abortPingServer();
abortPingServer = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
pingTimeout = setTimeout(() => {
if (!getState().isRestarting && getState().isConnected) {
return;
}
const ajaxOptions = {
url: '/system/status',
method: 'GET',
contentType: 'application/json'
};
const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortPingServer = abortRequest;
request.done(() => {
abortPingServer = null;
pingTimeout = null;
dispatch(setAppValue({
isRestarting: false
}));
});
request.fail((xhr) => {
abortPingServer = null;
pingTimeout = null;
// Unauthorized, but back online
if (xhr.status === 401) {
dispatch(setAppValue({
isRestarting: false
}));
} else {
pingServerAfterTimeout(getState, dispatch);
}
});
}, 5000);
}
//
// Action Handlers
export const actionHandlers = handleThunks({
[PING_SERVER]: function(getState, payload, dispatch) {
pingServerAfterTimeout(getState, dispatch);
}
});
//
// Reducers
export const reducers = createHandleActions({
[SAVE_DIMENSIONS]: function(state, { payload }) {
const {
width,
height
} = payload;
const dimensions = getDimensions(width, height);
return Object.assign({}, state, { dimensions });
},
[SHOW_MESSAGE]: function(state, { payload }) {
const newState = getSectionState(state, messagesSection);
const items = newState.items;
const index = _.findIndex(items, { id: payload.id });
newState.items = [...items];
if (index >= 0) {
const item = items[index];
newState.items.splice(index, 1, { ...item, ...payload });
} else {
newState.items.push({ ...payload });
}
return updateSectionState(state, messagesSection, newState);
},
[HIDE_MESSAGE]: function(state, { payload }) {
const newState = getSectionState(state, messagesSection);
newState.items = [...newState.items];
_.remove(newState.items, { id: payload.id });
return updateSectionState(state, messagesSection, newState);
},
[SET_APP_VALUE]: function(state, { payload }) {
const newState = Object.assign(getSectionState(state, section), payload);
return updateSectionState(state, section, newState);
},
[SET_VERSION]: function(state, { payload }) {
const version = payload.version;
const newState = {
version
};
if (state.version !== version) {
if (!state.prevVersion) {
newState.prevVersion = state.version;
}
newState.isUpdated = true;
}
return Object.assign({}, state, newState);
},
[SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
const newState = {
isSidebarVisible: payload.isSidebarVisible
};
return Object.assign({}, state, newState);
}
}, defaultState, section);

View file

@ -1,9 +1,9 @@
import { batchActions } from 'redux-batched-actions';
import { hideMessage, showMessage } from 'App/messagesStore';
import { messageTypes } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import { isSameCommand } from 'Utilities/Command';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { hideMessage, showMessage } from './appActions';
import { removeItem, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
@ -85,13 +85,13 @@ function showCommandMessage(payload, dispatch) {
hideAfter = trigger === 'manual' ? 10 : 4;
}
dispatch(showMessage({
showMessage({
id,
name,
message,
type,
hideAfter
}));
});
}
function scheduleRemoveCommand(command, dispatch) {
@ -112,10 +112,11 @@ function scheduleRemoveCommand(command, dispatch) {
removeCommandTimeoutIds[id] = setTimeout(() => {
dispatch(batchActions([
removeCommand({ section: 'commands', id }),
hideMessage({ id })
removeCommand({ section: 'commands', id })
]));
hideMessage({ id });
delete removeCommandTimeoutIds[id];
}, 60000 * 5);
}

View file

@ -1,4 +1,3 @@
import * as app from './appActions';
import * as captcha from './captchaActions';
import * as commands from './commandActions';
import * as episodeHistory from './episodeHistoryActions';
@ -13,7 +12,6 @@ import * as seriesIndex from './seriesIndexActions';
import * as settings from './settingsActions';
export default [
app,
captcha,
commands,
episodeHistory,

View file

@ -1,13 +1 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createDimensionsSelector() {
return createSelector(
(state: AppState) => state.app.dimensions,
(dimensions) => {
return dimensions;
}
);
}
export default createDimensionsSelector;
// This file has been removed - use useAppDimensions or useAppDimension from App/appStore instead

View file

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useAppValue } from 'App/appStore';
import { Error } from 'App/State/AppSectionState';
import AppState from 'App/State/AppState';
import TextInput from 'Components/Form/TextInput';
import Icon, { IconName, IconProps } from 'Components/Icon';
import Button from 'Components/Link/Button';
@ -79,7 +79,7 @@ function RestoreBackupModalContent({
name,
onModalClose,
}: RestoreBackupModalContentProps) {
const { isRestarting } = useSelector((state: AppState) => state.app);
const isRestarting = useAppValue('isRestarting');
const dispatch = useDispatch();
const { restoreBackupById, isRestoringBackup, restoreBackupError } =

View file

@ -1,13 +1,13 @@
import React, { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { useAppValues } from 'App/appStore';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useHealth from './useHealth';
function HealthStatus() {
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
const { isConnected, isReconnecting } = useAppValues(
'isConnected',
'isReconnecting'
);
const { data, refetch } = useHealth();

View file

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { useAppValue } from 'App/appStore';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
@ -29,7 +29,7 @@ import styles from './Updates.css';
const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
function Updates() {
const currentVersion = useSelector((state: AppState) => state.app.version);
const currentVersion = useAppValue('version');
const { packageUpdateMechanismMessage } = useSystemStatusData();
const { shortDateFormat, longDateFormat, timeFormat } = useSelector(

View file

@ -1,40 +1,30 @@
import { useMutation } from '@tanstack/react-query';
import { useDispatch } from 'react-redux';
import { pingServer, setAppValue } from 'Store/Actions/appActions';
import { pingServer, setAppValue } from 'App/appStore';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
const createSystemMutationFn = (endpoint: string) => {
return async () => {
const response = await fetch(
`${window.Sonarr.urlBase}/system/${endpoint}`,
{
method: 'POST',
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
}
);
export const useRestart = () => {
const mutation = useApiMutation<void, void>({
method: 'POST',
path: '/system/restart',
});
if (!response.ok) {
throw new Error(`Failed to ${endpoint}: ${response.statusText}`);
}
const restart = () => {
mutation.mutate(undefined, {
onSuccess: () => {
setAppValue({ isRestarting: true });
pingServer();
},
});
};
return {
...mutation,
mutate: restart,
};
};
export const useRestart = () => {
const dispatch = useDispatch();
return useMutation<void, Error, void>({
mutationFn: createSystemMutationFn('restart'),
onSuccess: () => {
dispatch(setAppValue({ isRestarting: true }));
dispatch(pingServer());
},
});
};
export const useShutdown = () => {
return useMutation<void, Error, void>({
mutationFn: createSystemMutationFn('shutdown'),
return useApiMutation<void, void>({
method: 'POST',
path: '/system/shutdown',
});
};