Merge pull request #5092 from thornbill/home-screen-queries

Use react-query for UserViews queries
This commit is contained in:
Bill Thornton 2024-01-17 12:10:16 -05:00 committed by GitHub
commit 94ed946459
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 196 additions and 89 deletions

View file

@ -1,19 +1,18 @@
import loadable from '@loadable/component'; import loadable from '@loadable/component';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import { History } from '@remix-run/router'; 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 { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react'; import React from 'react';
import { ApiProvider } from 'hooks/useApi'; import { ApiProvider } from 'hooks/useApi';
import { WebConfigProvider } from 'hooks/useWebConfig'; import { WebConfigProvider } from 'hooks/useWebConfig';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { queryClient } from 'utils/query/queryClient';
const StableAppRouter = loadable(() => import('./apps/stable/AppRouter')); const StableAppRouter = loadable(() => import('./apps/stable/AppRouter'));
const RootAppRouter = loadable(() => import('./RootAppRouter')); const RootAppRouter = loadable(() => import('./RootAppRouter'));
const queryClient = new QueryClient();
const RootApp = ({ history }: Readonly<{ history: History }>) => { const RootApp = ({ history }: Readonly<{ history: History }>) => {
const layoutMode = localStorage.getItem('layout'); const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental'; const isExperimentalLayout = layoutMode === 'experimental';

View file

@ -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 Dashboard from '@mui/icons-material/Dashboard';
import Edit from '@mui/icons-material/Edit'; import Edit from '@mui/icons-material/Edit';
import Favorite from '@mui/icons-material/Favorite'; import Favorite from '@mui/icons-material/Favorite';
@ -12,12 +10,13 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import { appRouter } from 'components/router/appRouter'; import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { useUserViews } from 'hooks/useUserViews';
import { useWebConfig } from 'hooks/useWebConfig'; import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
@ -25,29 +24,14 @@ import LibraryIcon from '../LibraryIcon';
import DrawerHeaderLink from './DrawerHeaderLink'; import DrawerHeaderLink from './DrawerHeaderLink';
const MainDrawerContent = () => { const MainDrawerContent = () => {
const { api, user } = useApi(); const { user } = useApi();
const location = useLocation(); const location = useLocation();
const [ userViews, setUserViews ] = useState<BaseItemDto[]>([]); const { data: userViewsData } = useUserViews(user?.Id);
const userViews = userViewsData?.Items || [];
const webConfig = useWebConfig(); const webConfig = useWebConfig();
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0'); 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 ( return (
<> <>
{/* MAIN LINKS */} {/* MAIN LINKS */}

View file

@ -1,5 +1,10 @@
import escapeHtml from 'escape-html'; 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 layoutManager from '../layoutManager';
import focusManager from '../focusManager'; import focusManager from '../focusManager';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
@ -291,7 +296,12 @@ function loadForm(context, user, userSettings, apiClient) {
updateHomeSectionValues(context, userSettings); 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`)); const promise2 = apiClient.getJSON(apiClient.getUrl(`Users/${user.Id}/GroupingOptions`));
Promise.all([promise1, promise2]).then(responses => { Promise.all([promise1, promise2]).then(responses => {

View file

@ -1,7 +1,10 @@
import layoutManager from 'components/layoutManager'; import layoutManager from 'components/layoutManager';
import { getUserViewsQuery } from 'hooks/useUserViews';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import { DEFAULT_SECTIONS, HomeSectionType } from 'types/homeSectionType'; import { DEFAULT_SECTIONS, HomeSectionType } from 'types/homeSectionType';
import Dashboard from 'utils/dashboard'; import Dashboard from 'utils/dashboard';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient';
import { loadRecordings } from './sections/activeRecordings'; import { loadRecordings } from './sections/activeRecordings';
import { loadLibraryButtons } from './sections/libraryButtons'; import { loadLibraryButtons } from './sections/libraryButtons';
@ -50,56 +53,60 @@ function getAllSectionsToShow(userSettings, sectionCount) {
} }
export function loadSections(elem, apiClient, user, userSettings) { export function loadSections(elem, apiClient, user, userSettings) {
return getUserViews(apiClient, user.Id).then(function (userViews) { const userId = user.Id || apiClient.getCurrentUserId();
let html = ''; return queryClient
.fetchQuery(getUserViewsQuery(toApi(apiClient), userId))
.then(result => result.Items || [])
.then(function (userViews) {
let html = '';
if (userViews.length) { if (userViews.length) {
const userSectionCount = 7; const userSectionCount = 7;
// TV layout can have an extra section to ensure libraries are visible // TV layout can have an extra section to ensure libraries are visible
const totalSectionCount = layoutManager.tv ? userSectionCount + 1 : userSectionCount; const totalSectionCount = layoutManager.tv ? userSectionCount + 1 : userSectionCount;
for (let i = 0; i < totalSectionCount; i++) { for (let i = 0; i < totalSectionCount; i++) {
html += '<div class="verticalSection section' + i + '"></div>'; html += '<div class="verticalSection section' + i + '"></div>';
} }
elem.innerHTML = html; elem.innerHTML = html;
elem.classList.add('homeSectionsContainer'); elem.classList.add('homeSectionsContainer');
const promises = []; const promises = [];
const sections = getAllSectionsToShow(userSettings, userSectionCount); const sections = getAllSectionsToShow(userSettings, userSectionCount);
for (let i = 0; i < sections.length; i++) { for (let i = 0; i < sections.length; i++) {
promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, 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) // Timeout for polyfilled CustomElements (webOS 1.2)
.then(() => new Promise((resolve) => setTimeout(resolve, 0))) .then(() => new Promise((resolve) => setTimeout(resolve, 0)))
.then(() => { .then(() => {
return resume(elem, { return resume(elem, {
refresh: true refresh: true
});
}); });
});
} else {
let noLibDescription;
if (user.Policy?.IsAdministrator) {
noLibDescription = globalize.translate('NoCreatedLibraries', '<br><a id="button-createLibrary" class="button-link">', '</a>');
} else { } else {
noLibDescription = globalize.translate('AskAdminToCreateLibrary'); let noLibDescription;
} if (user.Policy?.IsAdministrator) {
noLibDescription = globalize.translate('NoCreatedLibraries', '<br><a id="button-createLibrary" class="button-link">', '</a>');
} else {
noLibDescription = globalize.translate('AskAdminToCreateLibrary');
}
html += '<div class="centerMessage padded-left padded-right">'; html += '<div class="centerMessage padded-left padded-right">';
html += '<h2>' + globalize.translate('MessageNothingHere') + '</h2>'; html += '<h2>' + globalize.translate('MessageNothingHere') + '</h2>';
html += '<p>' + noLibDescription + '</p>'; html += '<p>' + noLibDescription + '</p>';
html += '</div>'; html += '</div>';
elem.innerHTML = html; elem.innerHTML = html;
const createNowLink = elem.querySelector('#button-createLibrary'); const createNowLink = elem.querySelector('#button-createLibrary');
if (createNowLink) { if (createNowLink) {
createNowLink.addEventListener('click', function () { createNowLink.addEventListener('click', function () {
Dashboard.navigate('dashboard/libraries'); Dashboard.navigate('dashboard/libraries');
}); });
}
} }
} });
});
} }
export function destroySections(elem) { export function destroySections(elem) {
@ -167,12 +174,6 @@ function loadSection(page, apiClient, user, userSettings, userViews, allSections
return Promise.resolve(); return Promise.resolve();
} }
function getUserViews(apiClient, userId) {
return apiClient.getUserViews({}, userId || apiClient.getCurrentUserId()).then(function (result) {
return result.Items;
});
}
function enableScrollX() { function enableScrollX() {
return true; return true;
} }

45
src/hooks/useUserViews.ts Normal file
View file

@ -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));
};

View file

@ -1,6 +1,10 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import Headroom from 'headroom.js'; 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 dom from './dom';
import layoutManager from '../components/layoutManager'; import layoutManager from '../components/layoutManager';
import inputManager from './inputManager'; import inputManager from './inputManager';
@ -383,28 +387,30 @@ function onSidebarLinkClick() {
} }
function getUserViews(apiClient, userId) { function getUserViews(apiClient, userId) {
return apiClient.getUserViews({}, userId).then(function (result) { return queryClient
const items = result.Items; .fetchQuery(getUserViewsQuery(toApi(apiClient), userId))
const list = []; .then(function (result) {
const items = result.Items;
const list = [];
for (let i = 0, length = items.length; i < length; i++) { for (let i = 0, length = items.length; i < length; i++) {
const view = items[i]; const view = items[i];
list.push(view); list.push(view);
if (view.CollectionType == 'livetv') { if (view.CollectionType == 'livetv') {
view.ImageTags = {}; view.ImageTags = {};
view.icon = 'live_tv'; view.icon = 'live_tv';
const guideView = Object.assign({}, view); const guideView = Object.assign({}, view);
guideView.Name = globalize.translate('Guide'); guideView.Name = globalize.translate('Guide');
guideView.ImageTags = {}; guideView.ImageTags = {};
guideView.icon = 'dvr'; guideView.icon = 'dvr';
guideView.url = '#/livetv.html?tab=1'; guideView.url = '#/livetv.html?tab=1';
list.push(guideView); list.push(guideView);
}
} }
}
return list; return list;
}); });
} }
function showBySelector(selector, show) { function showBySelector(selector, show) {

View file

@ -0,0 +1,3 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();

View file

@ -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<TQueryFnData, TError, TData, TQueryKey> & {
initialData?: undefined
};
type NonUndefinedGuard<T> = T extends undefined ? never : T;
export type DefinedInitialDataOptions<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
initialData:
| NonUndefinedGuard<TQueryFnData>
| (() => NonUndefinedGuard<TQueryFnData>)
};
export function queryOptions<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: TQueryKey
};
export function queryOptions<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: TQueryKey
};
export function queryOptions(options: unknown) {
return options;
}