Add connection error page on the web

This commit is contained in:
Zoe Roux 2024-03-03 23:55:14 +01:00
parent af422e62e1
commit e60e2306b7
6 changed files with 99 additions and 61 deletions

View File

@ -18,33 +18,6 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ConnectionErrorContext } from "@kyoo/models"; import { ConnectionError } from "@kyoo/ui";
import { Button, H1, P, ts } from "@kyoo/primitives";
import { useRouter } from "expo-router";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
const ConnectionError = () => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
const { error, retry } = useContext(ConnectionErrorContext);
return (
<View {...css({ padding: ts(2) })}>
<H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1>
<P>{error?.errors[0] ?? t("error.unknown")}</P>
<P>{t("errors.connection-tips")}</P>
<Button onPress={retry} text={t("errors.try-again")} {...css({ m: ts(1) })} />
<Button
onPress={() => router.push("/login")}
text={t("errors.re-login")}
{...css({ m: ts(1) })}
/>
</View>
);
};
export default ConnectionError; export default ConnectionError;

View File

@ -33,14 +33,16 @@ import { WebTooltip } from "@kyoo/primitives/src/tooltip.web";
import { import {
AccountP, AccountP,
AccountProvider, AccountProvider,
ConnectionErrorContext,
createQueryClient, createQueryClient,
fetchQuery, fetchQuery,
getTokenWJ, getTokenWJ,
QueryIdentifier, QueryIdentifier,
QueryPage, QueryPage,
UserP,
useUserTheme, useUserTheme,
} from "@kyoo/models"; } from "@kyoo/models";
import { useState } from "react"; import { Component, ComponentType, useContext, useState } from "react";
import NextApp, { AppContext, type AppProps } from "next/app"; import NextApp, { AppContext, type AppProps } from "next/app";
import { Poppins } from "next/font/google"; import { Poppins } from "next/font/google";
import { useTheme, useMobileHover, useStyleRegistry, StyleRegistryProvider } from "yoshiki/web"; import { useTheme, useMobileHover, useStyleRegistry, StyleRegistryProvider } from "yoshiki/web";
@ -51,6 +53,7 @@ import arrayShuffle from "array-shuffle";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal"; import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { ConnectionError } from "@kyoo/ui";
const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" }); const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" });
@ -114,15 +117,24 @@ const YoshikiDebug = ({ children }: { children: JSX.Element }) => {
return <StyleRegistryProvider registry={registry}>{children}</StyleRegistryProvider>; return <StyleRegistryProvider registry={registry}>{children}</StyleRegistryProvider>;
}; };
const App = ({ Component, pageProps }: AppProps) => { const ConnectionErrorVerifier = ({ children }: { children: JSX.Element }) => {
const [queryClient] = useState(() => createQueryClient()); const { error } = useContext(ConnectionErrorContext);
const { queryState, token, randomItems, account, theme, ...props } = superjson.deserialize<any>(
pageProps ?? { json: {} }, if (!error) return children;
); return <WithLayout Component={ConnectionError} />;
};
const WithLayout = ({ Component, ...props }: { Component: ComponentType }) => {
const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page);
const { Layout, props: layoutProps } = const { Layout, props: layoutProps } =
typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo; typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo;
return <Layout page={<Component {...props} />} randomItems={[]} {...layoutProps} />;
};
const App = ({ Component, pageProps }: AppProps) => {
const [queryClient] = useState(() => createQueryClient());
const { queryState, ssrError, token, randomItems, account, theme, ...props } =
superjson.deserialize<any>(pageProps ?? { json: {} });
const userTheme = useUserTheme(theme); const userTheme = useUserTheme(theme);
useMobileHover(); useMobileHover();
@ -140,25 +152,22 @@ const App = ({ Component, pageProps }: AppProps) => {
<meta name="description" content="A portable and vast media library solution." /> <meta name="description" content="A portable and vast media library solution." />
</Head> </Head>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AccountProvider ssrAccount={account}> <AccountProvider ssrAccount={account} ssrError={ssrError}>
<HydrationBoundary state={queryState}> <HydrationBoundary state={queryState}>
<ThemeSelector theme={userTheme} font={{ normal: "inherit" }}> <ThemeSelector theme={userTheme} font={{ normal: "inherit" }}>
<PortalProvider> <PortalProvider>
<SnackbarProvider> <SnackbarProvider>
<GlobalCssTheme /> <GlobalCssTheme />
<Layout <ConnectionErrorVerifier>
page={ <WithLayout
<Component Component={Component}
randomItems={ randomItems={
randomItems[Component.displayName!] ?? randomItems[Component.displayName!] ??
arrayShuffle((Component as QueryPage).randomItems ?? []) arrayShuffle((Component as QueryPage).randomItems ?? [])
} }
{...props} {...props}
/> />
} </ConnectionErrorVerifier>
randomItems={[]}
{...layoutProps}
/>
<Tooltip id="tooltip" positionStrategy={"fixed"} /> <Tooltip id="tooltip" positionStrategy={"fixed"} />
</SnackbarProvider> </SnackbarProvider>
</PortalProvider> </PortalProvider>
@ -194,8 +203,10 @@ App.getInitialProps = async (ctx: AppContext) => {
]; ];
const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP); const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP);
const [authToken, token] = await getTokenWJ(account); if (account) urls.push({ path: ["auth", "me"], parser: UserP });
appProps.pageProps.queryState = await fetchQuery(urls, authToken); const [authToken, token, error] = await getTokenWJ(account);
if (error) appProps.pageProps.ssrError = error;
else appProps.pageProps.queryState = await fetchQuery(urls, authToken);
appProps.pageProps.token = token; appProps.pageProps.token = token;
appProps.pageProps.account = account; appProps.pageProps.account = account;
appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto"; appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto";

View File

@ -60,9 +60,11 @@ export const ConnectionErrorContext = createContext<{
export const AccountProvider = ({ export const AccountProvider = ({
children, children,
ssrAccount, ssrAccount,
ssrError,
}: { }: {
children: ReactNode; children: ReactNode;
ssrAccount?: Account; ssrAccount?: Account;
ssrError?: KyooErrors;
}) => { }) => {
if (Platform.OS === "web" && typeof window === "undefined") { if (Platform.OS === "web" && typeof window === "undefined") {
const accs = ssrAccount const accs = ssrAccount
@ -72,7 +74,7 @@ export const AccountProvider = ({
<AccountContext.Provider value={accs}> <AccountContext.Provider value={accs}>
<ConnectionErrorContext.Provider <ConnectionErrorContext.Provider
value={{ value={{
error: null, error: ssrError || null,
loading: false, loading: false,
retry: () => { retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
@ -85,6 +87,8 @@ export const AccountProvider = ({
); );
} }
const initialSsrError = useRef(ssrError);
const [accStr] = useMMKVString("accounts"); const [accStr] = useMMKVString("accounts");
const acc = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null; const acc = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null;
const accounts = useMemo( const accounts = useMemo(
@ -99,16 +103,11 @@ export const AccountProvider = ({
// update user's data from kyoo un startup, it could have changed. // update user's data from kyoo un startup, it could have changed.
const selected = useMemo(() => accounts.find((x) => x.selected), [accounts]); const selected = useMemo(() => accounts.find((x) => x.selected), [accounts]);
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const user = useFetch({ const user = useFetch({
path: ["auth", "me"], path: ["auth", "me"],
parser: UserP, parser: UserP,
placeholderData: selected as User, placeholderData: selected as User,
enabled: !!selected, enabled: !!selected,
options: {
signal: controller.signal,
},
}); });
useEffect(() => { useEffect(() => {
if (!selected || !user.isSuccess || user.isPlaceholderData) return; if (!selected || !user.isSuccess || user.isPlaceholderData) return;
@ -122,7 +121,10 @@ export const AccountProvider = ({
const oldSelectedId = useRef<string | undefined>(selected?.id); const oldSelectedId = useRef<string | undefined>(selected?.id);
useEffect(() => { useEffect(() => {
// if the user change account (or connect/disconnect), reset query cache. // if the user change account (or connect/disconnect), reset query cache.
if (selected?.id !== oldSelectedId.current) queryClient.invalidateQueries(); if (selected?.id !== oldSelectedId.current) {
initialSsrError.current = undefined;
queryClient.invalidateQueries();
}
oldSelectedId.current = selected?.id; oldSelectedId.current = selected?.id;
// update cookies for ssr (needs to contains token, theme, language...) // update cookies for ssr (needs to contains token, theme, language...)
@ -136,7 +138,7 @@ export const AccountProvider = ({
<AccountContext.Provider value={accounts}> <AccountContext.Provider value={accounts}>
<ConnectionErrorContext.Provider <ConnectionErrorContext.Provider
value={{ value={{
error: selected ? user.error : null, error: selected ? initialSsrError.current ?? user.error : null,
loading: user.isLoading, loading: user.isLoading,
retry: () => { retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); queryClient.invalidateQueries({ queryKey: ["auth", "me"] });

View File

@ -96,7 +96,7 @@ let running: ReturnType<typeof getTokenWJ> | null = null;
export const getTokenWJ = async (account?: Account | null): ReturnType<typeof run> => { export const getTokenWJ = async (account?: Account | null): ReturnType<typeof run> => {
async function run() { async function run() {
if (account === undefined) account = getCurrentAccount(); if (account === undefined) account = getCurrentAccount();
if (!account) return [null, null] as const; if (!account) return [null, null, null] as const;
let token = account.token; let token = account.token;
@ -115,10 +115,10 @@ export const getTokenWJ = async (account?: Account | null): ReturnType<typeof ru
updateAccount(account.id, { ...account, token }); updateAccount(account.id, { ...account, token });
} catch (e) { } catch (e) {
console.error("Error refreshing token durring ssr:", e); console.error("Error refreshing token durring ssr:", e);
return [null, null]; return [null, null, e as KyooErrors] as const;
} }
} }
return [`${token.token_type} ${token.access_token}`, token] as const; return [`${token.token_type} ${token.access_token}`, token, null] as const;
} }
// Do not cache promise durring ssr. // Do not cache promise durring ssr.

View File

@ -0,0 +1,51 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ConnectionErrorContext } from "@kyoo/models";
import { Button, H1, P, ts } from "@kyoo/primitives";
import { useRouter } from "solito/router";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
export const ConnectionError = () => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
const { error, retry } = useContext(ConnectionErrorContext);
return (
<View {...css({ padding: ts(2) })}>
<H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1>
<P>{error?.errors[0] ?? t("error.unknown")}</P>
<P>{t("errors.connection-tips")}</P>
<Button onPress={retry} text={t("errors.try-again")} {...css({ m: ts(1) })} />
<Button
onPress={() => router.push("/login")}
text={t("errors.re-login")}
{...css({ m: ts(1) })}
/>
</View>
);
};
ConnectionError.getLayout = DefaultLayout;

View File

@ -20,3 +20,4 @@
export * from "./error"; export * from "./error";
export * from "./unauthorized"; export * from "./unauthorized";
export * from "./connection";