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/>.
*/
import { ConnectionErrorContext } from "@kyoo/models";
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>
);
};
import { ConnectionError } from "@kyoo/ui";
export default ConnectionError;

View File

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

View File

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

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 "./unauthorized";
export * from "./connection";