From 92358de0b678bfd19d362af97331b4486bad3e24 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 5 Jan 2024 11:23:33 -0500 Subject: [PATCH] Use react-query for UserViews queries --- src/RootApp.tsx | 5 +- .../components/drawers/MainDrawerContent.tsx | 26 +----- .../homeScreenSettings/homeScreenSettings.js | 12 ++- src/components/homesections/homesections.js | 93 ++++++++++--------- src/hooks/useUserViews.ts | 45 +++++++++ src/scripts/libraryMenu.js | 42 +++++---- src/utils/query/queryClient.ts | 3 + src/utils/query/queryOptions.ts | 59 ++++++++++++ 8 files changed, 196 insertions(+), 89 deletions(-) create mode 100644 src/hooks/useUserViews.ts create mode 100644 src/utils/query/queryClient.ts create mode 100644 src/utils/query/queryOptions.ts diff --git a/src/RootApp.tsx b/src/RootApp.tsx index 23ff1bb23..08c7b81dc 100644 --- a/src/RootApp.tsx +++ b/src/RootApp.tsx @@ -1,19 +1,18 @@ import loadable from '@loadable/component'; import { ThemeProvider } from '@mui/material/styles'; import { History } from '@remix-run/router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import { ApiProvider } from 'hooks/useApi'; import { WebConfigProvider } from 'hooks/useWebConfig'; import theme from 'themes/theme'; +import { queryClient } from 'utils/query/queryClient'; const StableAppRouter = loadable(() => import('./apps/stable/AppRouter')); const RootAppRouter = loadable(() => import('./RootAppRouter')); -const queryClient = new QueryClient(); - const RootApp = ({ history }: Readonly<{ history: History }>) => { const layoutMode = localStorage.getItem('layout'); const isExperimentalLayout = layoutMode === 'experimental'; diff --git a/src/apps/experimental/components/drawers/MainDrawerContent.tsx b/src/apps/experimental/components/drawers/MainDrawerContent.tsx index 4bdcbdd19..99834c2d8 100644 --- a/src/apps/experimental/components/drawers/MainDrawerContent.tsx +++ b/src/apps/experimental/components/drawers/MainDrawerContent.tsx @@ -1,5 +1,3 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; -import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api'; import Dashboard from '@mui/icons-material/Dashboard'; import Edit from '@mui/icons-material/Edit'; import Favorite from '@mui/icons-material/Favorite'; @@ -12,12 +10,13 @@ import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import ListSubheader from '@mui/material/ListSubheader'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useLocation } from 'react-router-dom'; import ListItemLink from 'components/ListItemLink'; import { appRouter } from 'components/router/appRouter'; import { useApi } from 'hooks/useApi'; +import { useUserViews } from 'hooks/useUserViews'; import { useWebConfig } from 'hooks/useWebConfig'; import globalize from 'scripts/globalize'; @@ -25,29 +24,14 @@ import LibraryIcon from '../LibraryIcon'; import DrawerHeaderLink from './DrawerHeaderLink'; const MainDrawerContent = () => { - const { api, user } = useApi(); + const { user } = useApi(); const location = useLocation(); - const [ userViews, setUserViews ] = useState([]); + const { data: userViewsData } = useUserViews(user?.Id); + const userViews = userViewsData?.Items || []; const webConfig = useWebConfig(); const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0'); - useEffect(() => { - if (api && user?.Id) { - getUserViewsApi(api) - .getUserViews({ userId: user.Id }) - .then(({ data }) => { - setUserViews(data.Items || []); - }) - .catch(err => { - console.warn('[MainDrawer] failed to fetch user views', err); - setUserViews([]); - }); - } else { - setUserViews([]); - } - }, [ api, user?.Id ]); - return ( <> {/* MAIN LINKS */} diff --git a/src/components/homeScreenSettings/homeScreenSettings.js b/src/components/homeScreenSettings/homeScreenSettings.js index 3a642a634..e7bc741fe 100644 --- a/src/components/homeScreenSettings/homeScreenSettings.js +++ b/src/components/homeScreenSettings/homeScreenSettings.js @@ -1,5 +1,10 @@ import escapeHtml from 'escape-html'; + +import { getUserViewsQuery } from 'hooks/useUserViews'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; +import { queryClient } from 'utils/query/queryClient'; + import layoutManager from '../layoutManager'; import focusManager from '../focusManager'; import globalize from '../../scripts/globalize'; @@ -291,7 +296,12 @@ function loadForm(context, user, userSettings, apiClient) { updateHomeSectionValues(context, userSettings); - const promise1 = apiClient.getUserViews({ IncludeHidden: true }, user.Id); + const promise1 = queryClient + .fetchQuery(getUserViewsQuery( + toApi(apiClient), + user.Id, + { includeHidden: true } + )); const promise2 = apiClient.getJSON(apiClient.getUrl(`Users/${user.Id}/GroupingOptions`)); Promise.all([promise1, promise2]).then(responses => { diff --git a/src/components/homesections/homesections.js b/src/components/homesections/homesections.js index 708fcb04b..c0dd8f167 100644 --- a/src/components/homesections/homesections.js +++ b/src/components/homesections/homesections.js @@ -1,7 +1,10 @@ import layoutManager from 'components/layoutManager'; +import { getUserViewsQuery } from 'hooks/useUserViews'; import globalize from 'scripts/globalize'; import { DEFAULT_SECTIONS, HomeSectionType } from 'types/homeSectionType'; import Dashboard from 'utils/dashboard'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; +import { queryClient } from 'utils/query/queryClient'; import { loadRecordings } from './sections/activeRecordings'; import { loadLibraryButtons } from './sections/libraryButtons'; @@ -50,56 +53,60 @@ function getAllSectionsToShow(userSettings, sectionCount) { } export function loadSections(elem, apiClient, user, userSettings) { - return getUserViews(apiClient, user.Id).then(function (userViews) { - let html = ''; + const userId = user.Id || apiClient.getCurrentUserId(); + return queryClient + .fetchQuery(getUserViewsQuery(toApi(apiClient), userId)) + .then(result => result.Items || []) + .then(function (userViews) { + let html = ''; - if (userViews.length) { - const userSectionCount = 7; - // TV layout can have an extra section to ensure libraries are visible - const totalSectionCount = layoutManager.tv ? userSectionCount + 1 : userSectionCount; - for (let i = 0; i < totalSectionCount; i++) { - html += '
'; - } + if (userViews.length) { + const userSectionCount = 7; + // TV layout can have an extra section to ensure libraries are visible + const totalSectionCount = layoutManager.tv ? userSectionCount + 1 : userSectionCount; + for (let i = 0; i < totalSectionCount; i++) { + html += '
'; + } - elem.innerHTML = html; - elem.classList.add('homeSectionsContainer'); + elem.innerHTML = html; + elem.classList.add('homeSectionsContainer'); - const promises = []; - const sections = getAllSectionsToShow(userSettings, userSectionCount); - for (let i = 0; i < sections.length; i++) { - promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i)); - } + const promises = []; + const sections = getAllSectionsToShow(userSettings, userSectionCount); + for (let i = 0; i < sections.length; i++) { + promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i)); + } - return Promise.all(promises) + return Promise.all(promises) // Timeout for polyfilled CustomElements (webOS 1.2) - .then(() => new Promise((resolve) => setTimeout(resolve, 0))) - .then(() => { - return resume(elem, { - refresh: true + .then(() => new Promise((resolve) => setTimeout(resolve, 0))) + .then(() => { + return resume(elem, { + refresh: true + }); }); - }); - } else { - let noLibDescription; - if (user.Policy?.IsAdministrator) { - noLibDescription = globalize.translate('NoCreatedLibraries', '
', ''); } else { - noLibDescription = globalize.translate('AskAdminToCreateLibrary'); - } + let noLibDescription; + if (user.Policy?.IsAdministrator) { + noLibDescription = globalize.translate('NoCreatedLibraries', '
', ''); + } else { + noLibDescription = globalize.translate('AskAdminToCreateLibrary'); + } - html += '
'; - html += '

' + globalize.translate('MessageNothingHere') + '

'; - html += '

' + noLibDescription + '

'; - html += '
'; - elem.innerHTML = html; + html += '
'; + html += '

' + globalize.translate('MessageNothingHere') + '

'; + html += '

' + noLibDescription + '

'; + html += '
'; + elem.innerHTML = html; - const createNowLink = elem.querySelector('#button-createLibrary'); - if (createNowLink) { - createNowLink.addEventListener('click', function () { - Dashboard.navigate('dashboard/libraries'); - }); + const createNowLink = elem.querySelector('#button-createLibrary'); + if (createNowLink) { + createNowLink.addEventListener('click', function () { + Dashboard.navigate('dashboard/libraries'); + }); + } } - } - }); + }); } export function destroySections(elem) { @@ -167,12 +174,6 @@ function loadSection(page, apiClient, user, userSettings, userViews, allSections return Promise.resolve(); } -function getUserViews(apiClient, userId) { - return apiClient.getUserViews({}, userId || apiClient.getCurrentUserId()).then(function (result) { - return result.Items; - }); -} - function enableScrollX() { return true; } diff --git a/src/hooks/useUserViews.ts b/src/hooks/useUserViews.ts new file mode 100644 index 000000000..c6cd2e137 --- /dev/null +++ b/src/hooks/useUserViews.ts @@ -0,0 +1,45 @@ +import type { Api } from '@jellyfin/sdk/lib/api'; +import type { UserViewsApiGetUserViewsRequest } from '@jellyfin/sdk/lib/generated-client/api/user-views-api'; +import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api'; +import { useQuery } from '@tanstack/react-query'; +import type { AxiosRequestConfig } from 'axios'; + +import { queryOptions } from 'utils/query/queryOptions'; + +import { useApi } from './useApi'; + +const fetchUserViews = async ( + api?: Api, + userId?: string, + params?: UserViewsApiGetUserViewsRequest, + options?: AxiosRequestConfig +) => { + if (!api) throw new Error('No API instance available'); + if (!userId) throw new Error('No User ID provided'); + + const response = await getUserViewsApi(api) + .getUserViews({ ...params, userId }, options); + return response.data; +}; + +export const getUserViewsQuery = ( + api?: Api, + userId?: string, + params?: UserViewsApiGetUserViewsRequest +) => queryOptions({ + queryKey: [ 'User', userId, 'Views', params ], + queryFn: ({ signal }) => fetchUserViews(api, userId, params, { signal }), + // On initial page load we request user views 3x. Setting a 1 second stale time + // allows a single request to be made to resolve all 3. + staleTime: 1000, // 1 second + enabled: !!api && !!userId +}); + +export const useUserViews = ( + userId?: string, + params?: UserViewsApiGetUserViewsRequest +) => { + const apiContext = useApi(); + const { api } = apiContext; + return useQuery(getUserViewsQuery(api, userId, params)); +}; diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index 405bef868..725289e46 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -1,6 +1,10 @@ import escapeHtml from 'escape-html'; import Headroom from 'headroom.js'; +import { getUserViewsQuery } from 'hooks/useUserViews'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; +import { queryClient } from 'utils/query/queryClient'; + import dom from './dom'; import layoutManager from '../components/layoutManager'; import inputManager from './inputManager'; @@ -383,28 +387,30 @@ function onSidebarLinkClick() { } function getUserViews(apiClient, userId) { - return apiClient.getUserViews({}, userId).then(function (result) { - const items = result.Items; - const list = []; + return queryClient + .fetchQuery(getUserViewsQuery(toApi(apiClient), userId)) + .then(function (result) { + const items = result.Items; + const list = []; - for (let i = 0, length = items.length; i < length; i++) { - const view = items[i]; - list.push(view); + for (let i = 0, length = items.length; i < length; i++) { + const view = items[i]; + list.push(view); - if (view.CollectionType == 'livetv') { - view.ImageTags = {}; - view.icon = 'live_tv'; - const guideView = Object.assign({}, view); - guideView.Name = globalize.translate('Guide'); - guideView.ImageTags = {}; - guideView.icon = 'dvr'; - guideView.url = '#/livetv.html?tab=1'; - list.push(guideView); + if (view.CollectionType == 'livetv') { + view.ImageTags = {}; + view.icon = 'live_tv'; + const guideView = Object.assign({}, view); + guideView.Name = globalize.translate('Guide'); + guideView.ImageTags = {}; + guideView.icon = 'dvr'; + guideView.url = '#/livetv.html?tab=1'; + list.push(guideView); + } } - } - return list; - }); + return list; + }); } function showBySelector(selector, show) { diff --git a/src/utils/query/queryClient.ts b/src/utils/query/queryClient.ts new file mode 100644 index 000000000..6d46de591 --- /dev/null +++ b/src/utils/query/queryClient.ts @@ -0,0 +1,3 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient(); diff --git a/src/utils/query/queryOptions.ts b/src/utils/query/queryOptions.ts new file mode 100644 index 000000000..3af7d9efc --- /dev/null +++ b/src/utils/query/queryOptions.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2021-2024 Tanner Linsley +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +/** + * Backport of the `queryOptions` utility function for react-query v4. + * Upgrading to v5 requires React 18. + */ + +import type { QueryKey, UseQueryOptions } from '@tanstack/react-query'; + +export type UndefinedInitialDataOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseQueryOptions & { + initialData?: undefined +}; + +type NonUndefinedGuard = T extends undefined ? never : T; + +export type DefinedInitialDataOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseQueryOptions & { + initialData: + | NonUndefinedGuard + | (() => NonUndefinedGuard) +}; + +export function queryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, +): UndefinedInitialDataOptions & { + queryKey: TQueryKey +}; + +export function queryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DefinedInitialDataOptions & { + queryKey: TQueryKey +}; + +export function queryOptions(options: unknown) { + return options; +}