376 lines
9.3 KiB
TypeScript
376 lines
9.3 KiB
TypeScript
/**
|
|
* @file App shell
|
|
*/
|
|
|
|
// Setup geolocation
|
|
import "~/lib/geolocation";
|
|
|
|
import {IonRouterOutlet, IonSplitPane} from "@ionic/react";
|
|
import {User} from "@supabase/supabase-js";
|
|
import {isEqual} from "lodash-es";
|
|
import {ComponentProps, FC, useEffect} from "react";
|
|
import {Route, useHistory, useLocation} from "react-router-dom";
|
|
|
|
import {GlobalMessage} from "~/components/global-message";
|
|
import {Menu} from "~/components/menu";
|
|
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
|
import {usePersistentStore} from "~/lib/stores/persistent";
|
|
import {client} from "~/lib/supabase";
|
|
import {AuthState, GlobalMessageMetadata, Theme} from "~/lib/types";
|
|
import {getAuthState} from "~/lib/utils";
|
|
import {Step1 as AuthStep1} from "~/pages/auth/step1";
|
|
import {Step2 as AuthStep2} from "~/pages/auth/step2";
|
|
import {Step3 as AuthStep3} from "~/pages/auth/step3";
|
|
import {Error} from "~/pages/error";
|
|
import {Index} from "~/pages/index";
|
|
import {Markdown} from "~/pages/markdown";
|
|
import {Nearby} from "~/pages/nearby";
|
|
import {Step1 as CreateCommentStep1} from "~/pages/posts/[id]/comments/create/step1";
|
|
import {PostIndex} from "~/pages/posts/[id]/index";
|
|
import {Step1 as CreatePostStep1} from "~/pages/posts/create/step1";
|
|
import {Step2 as CreatePostStep2} from "~/pages/posts/create/step2";
|
|
import {Settings} from "~/pages/settings";
|
|
|
|
/**
|
|
* Signed out message metadata
|
|
*/
|
|
const SIGNED_OUT_MESSAGE_METADATA: GlobalMessageMetadata = {
|
|
symbol: Symbol("app.signed-out"),
|
|
name: "Signed out",
|
|
description: "You have been signed out.",
|
|
};
|
|
|
|
/**
|
|
* Route metadata
|
|
*/
|
|
interface RouteMetadata<T extends FC> {
|
|
/**
|
|
* Unique identifier
|
|
*/
|
|
id: string;
|
|
|
|
/**
|
|
* Regex route path
|
|
*/
|
|
regexPath: RegExp;
|
|
|
|
/**
|
|
* React route path
|
|
*/
|
|
routerPath?: string;
|
|
|
|
/**
|
|
* Whether the React route is exact
|
|
*/
|
|
routerExact?: boolean;
|
|
|
|
/**
|
|
* Required authentication state or undefined if the route is always available
|
|
*/
|
|
requiredState?: AuthState;
|
|
|
|
/**
|
|
* Route component
|
|
*/
|
|
component: T;
|
|
|
|
/**
|
|
* React component props
|
|
*/
|
|
componentProps?: ComponentProps<T>;
|
|
}
|
|
|
|
/**
|
|
* Route metadata
|
|
*/
|
|
const routeMetadata: RouteMetadata<any>[] = [
|
|
{
|
|
id: "index",
|
|
regexPath: /^\/$/,
|
|
routerPath: "/",
|
|
routerExact: true,
|
|
component: Index,
|
|
},
|
|
{
|
|
id: "faq",
|
|
regexPath: /^\/faq$/,
|
|
routerPath: "/faq",
|
|
routerExact: true,
|
|
component: Markdown,
|
|
componentProps: {
|
|
title: "Frequently Asked Questions",
|
|
url: "/custom/faq.md",
|
|
},
|
|
},
|
|
{
|
|
id: "terms-and-conditions",
|
|
regexPath: /^\/terms-and-conditions$/,
|
|
routerPath: "/terms-and-conditions",
|
|
routerExact: true,
|
|
component: Markdown,
|
|
componentProps: {
|
|
title: "Terms and Conditions",
|
|
url: "/custom/terms-and-conditions.md",
|
|
},
|
|
},
|
|
{
|
|
id: "privacy-policy",
|
|
regexPath: /^\/privacy-policy$/,
|
|
routerPath: "/privacy-policy",
|
|
routerExact: true,
|
|
component: Markdown,
|
|
componentProps: {
|
|
title: "Privacy Policy",
|
|
url: "/custom/privacy-policy.md",
|
|
},
|
|
},
|
|
{
|
|
id: "auth-step-1",
|
|
regexPath: /^\/auth\/1$/,
|
|
routerPath: "/auth/1",
|
|
routerExact: true,
|
|
requiredState: AuthState.UNAUTHENTICATED,
|
|
component: AuthStep1,
|
|
},
|
|
{
|
|
id: "auth-step-2",
|
|
regexPath: /^\/auth\/2$/,
|
|
routerPath: "/auth/2",
|
|
routerExact: true,
|
|
requiredState: AuthState.UNAUTHENTICATED,
|
|
component: AuthStep2,
|
|
},
|
|
{
|
|
id: "auth-step-3",
|
|
regexPath: /^\/auth\/3$/,
|
|
routerPath: "/auth/3",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_NO_TERMS,
|
|
component: AuthStep3,
|
|
},
|
|
{
|
|
id: "nearby",
|
|
regexPath: /^\/nearby$/,
|
|
routerPath: "/nearby",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: Nearby,
|
|
},
|
|
{
|
|
id: "posts-create-1",
|
|
regexPath: /^\/posts\/create\/1$/,
|
|
routerPath: "/posts/create/1",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: CreatePostStep1,
|
|
},
|
|
{
|
|
id: "posts-create-2",
|
|
regexPath: /^\/posts\/create\/2$/,
|
|
routerPath: "/posts/create/2",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: CreatePostStep2,
|
|
},
|
|
{
|
|
id: "posts-comments-create-1",
|
|
regexPath:
|
|
/^\/posts\/[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}\/comments\/create\/1$/,
|
|
routerPath: "/posts/:id/comments/create/1",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: CreateCommentStep1,
|
|
},
|
|
{
|
|
id: "posts-index",
|
|
regexPath: /^\/posts\/[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/,
|
|
routerPath: "/posts/:id",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: PostIndex,
|
|
},
|
|
{
|
|
id: "settings",
|
|
regexPath: /^\/settings$/,
|
|
routerPath: "/settings",
|
|
routerExact: true,
|
|
requiredState: AuthState.AUTHENTICATED_TERMS,
|
|
component: Settings,
|
|
},
|
|
{
|
|
id: "error",
|
|
regexPath: /^.*$/,
|
|
component: Error,
|
|
componentProps: {
|
|
name: "404",
|
|
description: "The requested page was not found!",
|
|
homeButton: true,
|
|
},
|
|
},
|
|
];
|
|
|
|
// Set the user from the backend (Don't block because this makes a request to the backend)
|
|
// eslint-disable-next-line unicorn/prefer-top-level-await
|
|
(async () => {
|
|
// If there is no user, return
|
|
if (useEphemeralStore.getState().user === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Get the user
|
|
const {data, error} = await client.auth.getUser();
|
|
|
|
// If the backend returns an error or the user is null, sign out
|
|
if (data.user === null || error !== null) {
|
|
await client.auth.signOut();
|
|
}
|
|
// Otherwise the user is logged in
|
|
else {
|
|
useEphemeralStore.getState().setUser(data.user);
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* App shell
|
|
* @returns JSX
|
|
*/
|
|
export const App: FC = () => {
|
|
// Hooks
|
|
const history = useHistory();
|
|
const location = useLocation();
|
|
|
|
const setMessage = useEphemeralStore(state => state.setMessage);
|
|
const user = useEphemeralStore(state => state.user);
|
|
const setUser = useEphemeralStore(state => state.setUser);
|
|
const theme = usePersistentStore(state => state.theme);
|
|
|
|
// Methods
|
|
/**
|
|
* Guard the current route
|
|
* @param pathname Current route pathname
|
|
* @param user Current user
|
|
*/
|
|
const guardRoute = (pathname: string, user?: User | null) => {
|
|
// Require the user's state to be initialized (even if it's null)
|
|
if (user === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Get the required authentication state
|
|
const requiredState = routeMetadata.find(({regexPath: regex}) =>
|
|
regex.test(pathname),
|
|
)?.requiredState;
|
|
|
|
if (requiredState === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Get the user's current authentication state
|
|
const authState = getAuthState(user);
|
|
|
|
// User needs to authenticate
|
|
if (
|
|
authState === AuthState.UNAUTHENTICATED &&
|
|
[
|
|
AuthState.AUTHENTICATED_NO_TERMS,
|
|
AuthState.AUTHENTICATED_TERMS,
|
|
].includes(requiredState)
|
|
) {
|
|
history.push("/auth/1");
|
|
return;
|
|
}
|
|
|
|
// User needs to accept the terms and conditions
|
|
if (
|
|
authState === AuthState.AUTHENTICATED_NO_TERMS &&
|
|
requiredState === AuthState.AUTHENTICATED_TERMS
|
|
) {
|
|
history.push("/auth/3");
|
|
return;
|
|
}
|
|
|
|
// User is already authenticated and has accepted the terms and conditions
|
|
if (
|
|
([
|
|
AuthState.AUTHENTICATED_NO_TERMS,
|
|
AuthState.AUTHENTICATED_TERMS,
|
|
].includes(authState) &&
|
|
requiredState === AuthState.UNAUTHENTICATED) ||
|
|
(authState === AuthState.AUTHENTICATED_TERMS &&
|
|
requiredState === AuthState.AUTHENTICATED_NO_TERMS)
|
|
) {
|
|
history.push("/nearby");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Effects
|
|
useEffect(() => {
|
|
document.documentElement.classList.toggle("dark", theme === Theme.DARK);
|
|
}, [theme]);
|
|
|
|
useEffect(() => guardRoute(location.pathname, user), [location, user]);
|
|
|
|
// Subscribe to auth changes
|
|
useEffect(() => {
|
|
client.auth.onAuthStateChange(async (event, session) => {
|
|
// eslint-disable-next-line unicorn/no-null
|
|
let newUser = user ?? null;
|
|
|
|
switch (event) {
|
|
case "INITIAL_SESSION":
|
|
case "SIGNED_IN":
|
|
case "TOKEN_REFRESHED":
|
|
case "USER_UPDATED":
|
|
// Set the user
|
|
// eslint-disable-next-line unicorn/no-null
|
|
newUser = session?.user ?? null;
|
|
break;
|
|
|
|
case "SIGNED_OUT": {
|
|
// Display the message
|
|
setMessage(SIGNED_OUT_MESSAGE_METADATA);
|
|
|
|
// Clear the user
|
|
// eslint-disable-next-line unicorn/no-null
|
|
newUser = null;
|
|
}
|
|
}
|
|
|
|
// Set the user
|
|
if (user === undefined || !isEqual(user, newUser)) {
|
|
setUser(newUser);
|
|
}
|
|
|
|
// Guard the route against the new authentication state
|
|
guardRoute(location.pathname, newUser);
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<IonSplitPane contentId="main">
|
|
{location.pathname !== "/" && <Menu />}
|
|
|
|
<IonRouterOutlet id="main">
|
|
{routeMetadata.map(
|
|
({
|
|
id,
|
|
routerPath,
|
|
routerExact,
|
|
component: Component,
|
|
componentProps,
|
|
}) => (
|
|
<Route key={id} path={routerPath} exact={routerExact}>
|
|
<Component {...componentProps} />
|
|
</Route>
|
|
),
|
|
)}
|
|
</IonRouterOutlet>
|
|
</IonSplitPane>
|
|
|
|
<GlobalMessage />
|
|
</>
|
|
);
|
|
};
|