mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 14:53:24 +00:00
Compare commits
6 Commits
a981beae11
...
4ae5c3bc9b
Author | SHA1 | Date | |
---|---|---|---|
|
4ae5c3bc9b | ||
|
6c189d504b | ||
|
118fa2092d | ||
|
5ef90f52a3 | ||
|
d7940766e7 | ||
|
e3d507db72 |
@@ -2,3 +2,6 @@ import "expo-router/entry";
|
||||
import "react-native-gesture-handler";
|
||||
import "@react-native-anywhere/polyfill-base64";
|
||||
import "text-encoding-polyfill";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import crypto from "react-native-quick-crypto";
|
||||
|
@@ -96,7 +96,11 @@ export default function Page() {
|
||||
flexDirection="column"
|
||||
gap="$4"
|
||||
>
|
||||
<MWButton type="purple" onPress={() => mutation.mutate()}>
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => mutation.mutate()}
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Login
|
||||
</MWButton>
|
||||
{mutation.isError && (
|
||||
|
@@ -6,7 +6,7 @@ import { Avatar } from "~/components/account/Avatar";
|
||||
import { ColorPicker, colors } from "~/components/account/ColorPicker";
|
||||
import {
|
||||
expoIcons,
|
||||
expoIconsToDbIcons,
|
||||
getDbIconFromExpoIcon,
|
||||
UserIconPicker,
|
||||
} from "~/components/account/UserIconPicker";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
@@ -19,9 +19,9 @@ export default function Page() {
|
||||
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]);
|
||||
const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]);
|
||||
const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]);
|
||||
const [colorA, setColorA] = useState<string>(colors[0]);
|
||||
const [colorB, setColorB] = useState<string>(colors[0]);
|
||||
const [icon, setIcon] = useState<string>(expoIcons[0]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (!deviceName) {
|
||||
@@ -34,7 +34,7 @@ export default function Page() {
|
||||
deviceName,
|
||||
colorA,
|
||||
colorB,
|
||||
icon: expoIconsToDbIcons[icon],
|
||||
icon: getDbIconFromExpoIcon(icon),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@@ -95,7 +95,11 @@ export default function Page() {
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<MWButton type="purple" onPress={() => mutation.mutate()}>
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => mutation.mutate()}
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Create account
|
||||
</MWButton>
|
||||
</MWCard.Footer>
|
||||
|
@@ -1,15 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { H3, Spinner, Text, XStack, YStack } from "tamagui";
|
||||
import { H5, Spinner, Text, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import {
|
||||
base64ToBuffer,
|
||||
decryptData,
|
||||
editUser,
|
||||
encryptData,
|
||||
getSessions,
|
||||
removeSession,
|
||||
updateSession,
|
||||
updateSettings,
|
||||
} from "@movie-web/api";
|
||||
|
||||
import { useAuth } from "~/hooks/useAuth";
|
||||
import { useSettingsState } from "~/hooks/useSettingsState";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
import ScreenLayout from "../layout/ScreenLayout";
|
||||
import { MWButton } from "../ui/Button";
|
||||
@@ -17,12 +23,20 @@ import { MWCard } from "../ui/Card";
|
||||
import { MWInput } from "../ui/Input";
|
||||
import { MWSeparator } from "../ui/Separator";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { ChangeProfileModal } from "./ChangeProfileModal";
|
||||
import { DeleteAccountAlert } from "./DeleteAccountAlert";
|
||||
import { getExpoIconFromDbIcon } from "./UserIconPicker";
|
||||
import { getDbIconFromExpoIcon, getExpoIconFromDbIcon } from "./UserIconPicker";
|
||||
|
||||
export function AccountInformation() {
|
||||
const account = useAuthStore((state) => state.account);
|
||||
const backendUrl = useAuthStore((state) => state.backendUrl);
|
||||
const proxySet = useAuthStore((s) => s.proxySet);
|
||||
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
||||
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { decryptedName, logout } = useAuth();
|
||||
@@ -50,14 +64,15 @@ export function AccountInformation() {
|
||||
});
|
||||
|
||||
const deviceListSorted = useMemo(() => {
|
||||
if (!sessions.data || !account) return [];
|
||||
let list =
|
||||
sessions.data?.map((session) => {
|
||||
const decryptedName = decryptData(
|
||||
session.device,
|
||||
base64ToBuffer(account!.seed),
|
||||
base64ToBuffer(account.seed),
|
||||
);
|
||||
return {
|
||||
current: session.id === account!.sessionId,
|
||||
current: session.id === account.sessionId,
|
||||
id: session.id,
|
||||
name: decryptedName,
|
||||
};
|
||||
@@ -70,96 +85,222 @@ export function AccountInformation() {
|
||||
return list;
|
||||
}, [sessions.data, account]);
|
||||
|
||||
const state = useSettingsState(decryptedName, proxySet, account?.profile);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (account && backendUrl) {
|
||||
if (state.proxyUrls.changed) {
|
||||
await updateSettings(backendUrl, account, {
|
||||
proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null,
|
||||
});
|
||||
}
|
||||
if (state.deviceName.changed) {
|
||||
const newDeviceName = await encryptData(
|
||||
state.deviceName.state,
|
||||
base64ToBuffer(account.seed),
|
||||
);
|
||||
await updateSession(backendUrl, account, {
|
||||
deviceName: newDeviceName,
|
||||
});
|
||||
updateDeviceName(newDeviceName);
|
||||
}
|
||||
if (state.profile.changed) {
|
||||
await editUser(backendUrl, account, {
|
||||
profile: state.profile.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null);
|
||||
|
||||
if (state.profile.state) {
|
||||
updateProfile(state.profile.state);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
backendUrl,
|
||||
setProxySet,
|
||||
state.deviceName.changed,
|
||||
state.deviceName.state,
|
||||
state.profile.changed,
|
||||
state.profile.state,
|
||||
state.proxyUrls.changed,
|
||||
state.proxyUrls.state,
|
||||
updateDeviceName,
|
||||
updateProfile,
|
||||
]);
|
||||
|
||||
const saveChangesMutation = useMutation({
|
||||
mutationKey: ["saveChanges"],
|
||||
mutationFn: saveChanges,
|
||||
});
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<YStack gap="$6">
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Account
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
|
||||
<MWCard bordered padded>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Avatar
|
||||
{...account.profile}
|
||||
icon={getExpoIconFromDbIcon(account.profile.icon)}
|
||||
width="$7"
|
||||
height="$7"
|
||||
/>
|
||||
<YStack gap="$4">
|
||||
<Text fontWeight="$bold">Device name</Text>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
value={decryptedName}
|
||||
minWidth={"80%"}
|
||||
maxWidth={"80%"}
|
||||
/>
|
||||
|
||||
<MWButton
|
||||
type="danger"
|
||||
width={"50%"}
|
||||
onPress={() => logoutMutation.mutate()}
|
||||
>
|
||||
Logout
|
||||
</MWButton>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MWCard>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Devices
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
{sessions.isLoading && <Spinner />}
|
||||
{sessions.isError && (
|
||||
<Text fontWeight="$bold" color="$rose200">
|
||||
Error loading sessions
|
||||
<>
|
||||
<ScreenLayout>
|
||||
<YStack gap="$6">
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Account
|
||||
</Text>
|
||||
)}
|
||||
{deviceListSorted.map((device) => (
|
||||
<MWCard bordered padded key={device.id}>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<YStack gap="$1" flexGrow={1}>
|
||||
<Text fontWeight="$semibold" color="$ash300">
|
||||
Device name
|
||||
</Text>
|
||||
<Text fontWeight="$bold">{device.name}</Text>
|
||||
</YStack>
|
||||
{!device.current && (
|
||||
<MWButton
|
||||
type="danger"
|
||||
onPress={() => removeSessionMutation.mutate(device.id)}
|
||||
>
|
||||
Remove
|
||||
</MWButton>
|
||||
)}
|
||||
</XStack>
|
||||
</MWCard>
|
||||
))}
|
||||
</YStack>
|
||||
<MWSeparator />
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Actions
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<MWCard bordered padded>
|
||||
<YStack gap="$3">
|
||||
<H3 fontWeight="$bold">Delete account</H3>
|
||||
<Text color="$ash300" fontWeight="$semibold">
|
||||
This action is irreversible. All data will be deleted and
|
||||
nothing can be recovered.
|
||||
{state.profile.state && (
|
||||
<MWCard bordered padded>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<ChangeProfileModal
|
||||
colorA={state.profile.state.colorA}
|
||||
setColorA={(v) => {
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorA: v } : undefined,
|
||||
);
|
||||
}}
|
||||
colorB={state.profile.state.colorB}
|
||||
setColorB={(v) =>
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorB: v } : undefined,
|
||||
)
|
||||
}
|
||||
icon={state.profile.state.icon}
|
||||
setUserIcon={(v) =>
|
||||
state.profile.set((s) =>
|
||||
s
|
||||
? { ...s, icon: getDbIconFromExpoIcon(v) }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
<Avatar
|
||||
{...state.profile.state}
|
||||
icon={getExpoIconFromDbIcon(state.profile.state.icon)}
|
||||
width="$7"
|
||||
height="$7"
|
||||
bottomItem={
|
||||
<XStack
|
||||
backgroundColor="$shade200"
|
||||
px="$2"
|
||||
py="$1"
|
||||
borderRadius="$4"
|
||||
gap="$1.5"
|
||||
alignItems="center"
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<MaterialIcons name="edit" size={10} color="white" />
|
||||
<Text fontSize="$2">Edit</Text>
|
||||
</XStack>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
/>
|
||||
<YStack gap="$4">
|
||||
<Text fontWeight="$bold">Device name</Text>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
value={state.deviceName.state}
|
||||
onChangeText={state.deviceName.set}
|
||||
alignSelf="flex-start"
|
||||
width="$14"
|
||||
/>
|
||||
|
||||
<MWButton
|
||||
type="danger"
|
||||
onPress={() => logoutMutation.mutate()}
|
||||
alignSelf="flex-start"
|
||||
isLoading={logoutMutation.isPending}
|
||||
>
|
||||
Logout
|
||||
</MWButton>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MWCard>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Devices
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
{sessions.isLoading && <Spinner />}
|
||||
{sessions.isError && (
|
||||
<Text fontWeight="$bold" color="$rose200">
|
||||
Error loading sessions
|
||||
</Text>
|
||||
<DeleteAccountAlert />
|
||||
</YStack>
|
||||
</MWCard>
|
||||
)}
|
||||
{deviceListSorted.map((device) => (
|
||||
<MWCard bordered padded key={device.id}>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<YStack gap="$1" flexGrow={1}>
|
||||
<Text fontWeight="$semibold" color="$ash300">
|
||||
Device name
|
||||
</Text>
|
||||
<Text fontWeight="$bold">{device.name}</Text>
|
||||
</YStack>
|
||||
{!device.current && (
|
||||
<MWButton
|
||||
type="danger"
|
||||
isLoading={removeSessionMutation.isPending}
|
||||
onPress={() => removeSessionMutation.mutate(device.id)}
|
||||
>
|
||||
Remove
|
||||
</MWButton>
|
||||
)}
|
||||
</XStack>
|
||||
</MWCard>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Actions
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<MWCard bordered padded>
|
||||
<YStack gap="$3">
|
||||
<H5 fontWeight="$bold">Delete account</H5>
|
||||
<Text color="$ash300" fontWeight="$semibold">
|
||||
This action is irreversible. All data will be deleted and
|
||||
nothing can be recovered.
|
||||
</Text>
|
||||
<DeleteAccountAlert />
|
||||
</YStack>
|
||||
</MWCard>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</ScreenLayout>
|
||||
</ScreenLayout>
|
||||
|
||||
{state.changed && (
|
||||
<View position="absolute" alignItems="center" bottom="$2" px="$2">
|
||||
<XStack
|
||||
width="100%"
|
||||
padding="$4"
|
||||
backgroundColor="$shade800"
|
||||
justifyContent="space-between"
|
||||
borderRadius="$4"
|
||||
animation="bounce"
|
||||
enterStyle={{
|
||||
y: 10,
|
||||
opacity: 0,
|
||||
}}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
>
|
||||
<MWButton type="cancel" onPress={state.reset}>
|
||||
Reset
|
||||
</MWButton>
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => saveChangesMutation.mutate()}
|
||||
isLoading={saveChangesMutation.isPending}
|
||||
>
|
||||
Save changes
|
||||
</MWButton>
|
||||
</XStack>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,14 +1,13 @@
|
||||
import type { CircleProps } from "tamagui";
|
||||
import { FontAwesome6 } from "@expo/vector-icons";
|
||||
import { Circle } from "tamagui";
|
||||
import { Circle, View } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
import type { expoIcons } from "./UserIconPicker";
|
||||
|
||||
export interface AvatarProps {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: (typeof expoIcons)[number];
|
||||
icon: string;
|
||||
bottomItem?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps & CircleProps) {
|
||||
@@ -33,6 +32,9 @@ export function Avatar(props: AvatarProps & CircleProps) {
|
||||
>
|
||||
<FontAwesome6 name={props.icon} size={24} color="white" />
|
||||
</LinearGradient>
|
||||
<View position="absolute" bottom={0}>
|
||||
{props.bottomItem}
|
||||
</View>
|
||||
</Circle>
|
||||
);
|
||||
}
|
||||
|
81
apps/expo/src/components/account/ChangeProfileModal.tsx
Normal file
81
apps/expo/src/components/account/ChangeProfileModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { H3, Sheet, Text, XStack, YStack } from "tamagui";
|
||||
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { UserIconPicker } from "./UserIconPicker";
|
||||
|
||||
export function ChangeProfileModal(props: {
|
||||
colorA: string;
|
||||
setColorA: (s: string) => void;
|
||||
colorB: string;
|
||||
setColorB: (s: string) => void;
|
||||
icon: string;
|
||||
setUserIcon: (s: string) => void;
|
||||
|
||||
open: boolean;
|
||||
setOpen: (b: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet
|
||||
forceRemoveScrollEnabled={props.open}
|
||||
modal
|
||||
open={props.open}
|
||||
onOpenChange={props.setOpen}
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animation="fast"
|
||||
snapPoints={[65]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$shade100" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$shade800"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<YStack padding="$4" gap="$4">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<H3 flexGrow={1} fontWeight="$bold">
|
||||
Edit profile picture
|
||||
</H3>
|
||||
<Avatar
|
||||
colorA={props.colorA}
|
||||
colorB={props.colorB}
|
||||
icon={props.icon}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">Profile color one</Text>
|
||||
<ColorPicker value={props.colorA} onInput={props.setColorA} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">Profile color two</Text>
|
||||
<ColorPicker value={props.colorB} onInput={props.setColorB} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">User icon</Text>
|
||||
<UserIconPicker value={props.icon} onInput={props.setUserIcon} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWButton
|
||||
type="purple"
|
||||
width="100%"
|
||||
onPress={() => props.setOpen(false)}
|
||||
>
|
||||
Finish editing
|
||||
</MWButton>
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
@@ -11,8 +11,8 @@ export const colors = [
|
||||
] as const;
|
||||
|
||||
export function ColorPicker(props: {
|
||||
value: (typeof colors)[number];
|
||||
onInput: (v: (typeof colors)[number]) => void;
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
|
@@ -75,7 +75,12 @@ export function DeleteAccountAlert() {
|
||||
asChild
|
||||
onPress={() => deleteAccountMutation.mutate()}
|
||||
>
|
||||
<MWButton type="purple">I am sure</MWButton>
|
||||
<MWButton
|
||||
type="purple"
|
||||
isLoading={deleteAccountMutation.isPending}
|
||||
>
|
||||
I am sure
|
||||
</MWButton>
|
||||
</AlertDialog.Action>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
@@ -24,9 +24,13 @@ export const getExpoIconFromDbIcon = (icon: string) => {
|
||||
) as (typeof expoIcons)[number];
|
||||
};
|
||||
|
||||
export const getDbIconFromExpoIcon = (icon: string) => {
|
||||
return expoIconsToDbIcons[icon as (typeof expoIcons)[number]];
|
||||
};
|
||||
|
||||
export function UserIconPicker(props: {
|
||||
value: (typeof expoIcons)[number];
|
||||
onInput: (v: (typeof expoIcons)[number]) => void;
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Button, styled } from "tamagui";
|
||||
import { Button, Spinner, styled, withStaticProperties } from "tamagui";
|
||||
|
||||
export const MWButton = styled(Button, {
|
||||
const MWButtonFrame = styled(Button, {
|
||||
variants: {
|
||||
type: {
|
||||
primary: {
|
||||
@@ -71,3 +71,22 @@ export const MWButton = styled(Button, {
|
||||
},
|
||||
} as const,
|
||||
});
|
||||
|
||||
const ButtonComponent = MWButtonFrame.styleable<{
|
||||
isLoading?: boolean;
|
||||
}>(function Button(props, ref) {
|
||||
const spinnerColor =
|
||||
// @ts-expect-error this is a hack to get the color from the variant
|
||||
MWButtonFrame.staticConfig.variants?.type?.[props.type!]?.color as string;
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
<MWButtonFrame {...props} ref={ref}>
|
||||
{props.isLoading && (
|
||||
<Spinner size="small" color={spinnerColor ?? "white"} />
|
||||
)}
|
||||
{props.children}
|
||||
</MWButtonFrame>
|
||||
);
|
||||
});
|
||||
|
||||
export const MWButton = withStaticProperties(ButtonComponent, {});
|
||||
|
91
apps/expo/src/hooks/useSettingsState.ts
Normal file
91
apps/expo/src/hooks/useSettingsState.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
function isEqual(x: unknown, y: unknown): boolean {
|
||||
const ok = Object.keys,
|
||||
tx = typeof x,
|
||||
ty = typeof y;
|
||||
return x && y && tx === "object" && tx === ty
|
||||
? ok(x).length === ok(y).length &&
|
||||
ok(x).every((key) =>
|
||||
isEqual(x[key as keyof typeof x], y[key as keyof typeof y]),
|
||||
)
|
||||
: x === y;
|
||||
}
|
||||
|
||||
export function useDerived<T>(
|
||||
initial: T,
|
||||
): [T, Dispatch<SetStateAction<T>>, () => void, boolean] {
|
||||
const [overwrite, setOverwrite] = useState<T | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
setOverwrite(undefined);
|
||||
}, [initial]);
|
||||
const changed = useMemo(
|
||||
() => !isEqual(overwrite, initial) && overwrite !== undefined,
|
||||
[overwrite, initial],
|
||||
);
|
||||
const setter = useCallback<Dispatch<SetStateAction<T>>>(
|
||||
(inp) => {
|
||||
if (!(inp instanceof Function)) setOverwrite(inp);
|
||||
else setOverwrite((s) => inp(s ?? initial));
|
||||
},
|
||||
[initial, setOverwrite],
|
||||
);
|
||||
const data = overwrite ?? initial;
|
||||
|
||||
const reset = useCallback(() => setOverwrite(undefined), [setOverwrite]);
|
||||
|
||||
return [data, setter, reset, changed];
|
||||
}
|
||||
|
||||
export function useSettingsState(
|
||||
deviceName: string,
|
||||
proxyUrls: string[] | null,
|
||||
profile:
|
||||
| {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
||||
useDerived(proxyUrls);
|
||||
|
||||
const [
|
||||
deviceNameState,
|
||||
setDeviceNameState,
|
||||
resetDeviceName,
|
||||
deviceNameChanged,
|
||||
] = useDerived(deviceName);
|
||||
const [profileState, setProfileState, resetProfile, profileChanged] =
|
||||
useDerived(profile);
|
||||
|
||||
function reset() {
|
||||
resetProxyUrls();
|
||||
resetDeviceName();
|
||||
resetProfile();
|
||||
}
|
||||
|
||||
const changed = deviceNameChanged || proxyUrlsChanged || profileChanged;
|
||||
|
||||
return {
|
||||
reset,
|
||||
changed,
|
||||
deviceName: {
|
||||
state: deviceNameState,
|
||||
set: setDeviceNameState,
|
||||
changed: deviceNameChanged,
|
||||
},
|
||||
proxyUrls: {
|
||||
state: proxyUrlsState,
|
||||
set: setProxyUrls,
|
||||
changed: proxyUrlsChanged,
|
||||
},
|
||||
profile: {
|
||||
state: profileState,
|
||||
set: setProfileState,
|
||||
changed: profileChanged,
|
||||
},
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user