Compare commits

..

1 Commits

Author SHA1 Message Date
Adrian Castro
a981beae11 Merge b12562d249 into a3f184979e 2024-04-21 15:03:54 +00:00
12 changed files with 106 additions and 460 deletions

View File

@@ -2,6 +2,3 @@ import "expo-router/entry";
import "react-native-gesture-handler"; import "react-native-gesture-handler";
import "@react-native-anywhere/polyfill-base64"; import "@react-native-anywhere/polyfill-base64";
import "text-encoding-polyfill"; import "text-encoding-polyfill";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import crypto from "react-native-quick-crypto";

View File

@@ -96,11 +96,7 @@ export default function Page() {
flexDirection="column" flexDirection="column"
gap="$4" gap="$4"
> >
<MWButton <MWButton type="purple" onPress={() => mutation.mutate()}>
type="purple"
onPress={() => mutation.mutate()}
isLoading={mutation.isPending}
>
Login Login
</MWButton> </MWButton>
{mutation.isError && ( {mutation.isError && (

View File

@@ -6,7 +6,7 @@ import { Avatar } from "~/components/account/Avatar";
import { ColorPicker, colors } from "~/components/account/ColorPicker"; import { ColorPicker, colors } from "~/components/account/ColorPicker";
import { import {
expoIcons, expoIcons,
getDbIconFromExpoIcon, expoIconsToDbIcons,
UserIconPicker, UserIconPicker,
} from "~/components/account/UserIconPicker"; } from "~/components/account/UserIconPicker";
import ScreenLayout from "~/components/layout/ScreenLayout"; import ScreenLayout from "~/components/layout/ScreenLayout";
@@ -19,9 +19,9 @@ export default function Page() {
const [deviceName, setDeviceName] = useState(""); const [deviceName, setDeviceName] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [colorA, setColorA] = useState<string>(colors[0]); const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]);
const [colorB, setColorB] = useState<string>(colors[0]); const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]);
const [icon, setIcon] = useState<string>(expoIcons[0]); const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]);
const handleNext = () => { const handleNext = () => {
if (!deviceName) { if (!deviceName) {
@@ -34,7 +34,7 @@ export default function Page() {
deviceName, deviceName,
colorA, colorA,
colorB, colorB,
icon: getDbIconFromExpoIcon(icon), icon: expoIconsToDbIcons[icon],
}, },
}); });
}; };

View File

@@ -95,11 +95,7 @@ export default function Page() {
</Paragraph> </Paragraph>
)} )}
<MWButton <MWButton type="purple" onPress={() => mutation.mutate()}>
type="purple"
onPress={() => mutation.mutate()}
isLoading={mutation.isPending}
>
Create account Create account
</MWButton> </MWButton>
</MWCard.Footer> </MWCard.Footer>

View File

@@ -1,21 +1,15 @@
import { useCallback, useMemo, useState } from "react"; import { useMemo } from "react";
import { MaterialIcons } from "@expo/vector-icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { H5, Spinner, Text, View, XStack, YStack } from "tamagui"; import { H3, Spinner, Text, XStack, YStack } from "tamagui";
import { import {
base64ToBuffer, base64ToBuffer,
decryptData, decryptData,
editUser,
encryptData,
getSessions, getSessions,
removeSession, removeSession,
updateSession,
updateSettings,
} from "@movie-web/api"; } from "@movie-web/api";
import { useAuth } from "~/hooks/useAuth"; import { useAuth } from "~/hooks/useAuth";
import { useSettingsState } from "~/hooks/useSettingsState";
import { useAuthStore } from "~/stores/settings"; import { useAuthStore } from "~/stores/settings";
import ScreenLayout from "../layout/ScreenLayout"; import ScreenLayout from "../layout/ScreenLayout";
import { MWButton } from "../ui/Button"; import { MWButton } from "../ui/Button";
@@ -23,20 +17,12 @@ import { MWCard } from "../ui/Card";
import { MWInput } from "../ui/Input"; import { MWInput } from "../ui/Input";
import { MWSeparator } from "../ui/Separator"; import { MWSeparator } from "../ui/Separator";
import { Avatar } from "./Avatar"; import { Avatar } from "./Avatar";
import { ChangeProfileModal } from "./ChangeProfileModal";
import { DeleteAccountAlert } from "./DeleteAccountAlert"; import { DeleteAccountAlert } from "./DeleteAccountAlert";
import { getDbIconFromExpoIcon, getExpoIconFromDbIcon } from "./UserIconPicker"; import { getExpoIconFromDbIcon } from "./UserIconPicker";
export function AccountInformation() { export function AccountInformation() {
const account = useAuthStore((state) => state.account); const account = useAuthStore((state) => state.account);
const backendUrl = useAuthStore((state) => state.backendUrl); 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 queryClient = useQueryClient();
const { decryptedName, logout } = useAuth(); const { decryptedName, logout } = useAuth();
@@ -64,15 +50,14 @@ export function AccountInformation() {
}); });
const deviceListSorted = useMemo(() => { const deviceListSorted = useMemo(() => {
if (!sessions.data || !account) return [];
let list = let list =
sessions.data?.map((session) => { sessions.data?.map((session) => {
const decryptedName = decryptData( const decryptedName = decryptData(
session.device, session.device,
base64ToBuffer(account.seed), base64ToBuffer(account!.seed),
); );
return { return {
current: session.id === account.sessionId, current: session.id === account!.sessionId,
id: session.id, id: session.id,
name: decryptedName, name: decryptedName,
}; };
@@ -85,222 +70,96 @@ export function AccountInformation() {
return list; return list;
}, [sessions.data, account]); }, [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; if (!account) return null;
return ( return (
<> <ScreenLayout>
<ScreenLayout> <YStack gap="$6">
<YStack gap="$6"> <YStack gap="$4">
<YStack gap="$4"> <Text fontSize="$7" fontWeight="$bold">
<Text fontSize="$7" fontWeight="$bold"> Account
Account </Text>
</Text> <MWSeparator />
<MWSeparator />
{state.profile.state && ( <MWCard bordered padded>
<MWCard bordered padded> <XStack gap="$4" alignItems="center">
<XStack gap="$4" alignItems="center"> <Avatar
<ChangeProfileModal {...account.profile}
colorA={state.profile.state.colorA} icon={getExpoIconFromDbIcon(account.profile.icon)}
setColorA={(v) => { width="$7"
state.profile.set((s) => height="$7"
s ? { ...s, colorA: v } : undefined, />
); <YStack gap="$4">
}} <Text fontWeight="$bold">Device name</Text>
colorB={state.profile.state.colorB} <MWInput
setColorB={(v) => type="authentication"
state.profile.set((s) => value={decryptedName}
s ? { ...s, colorB: v } : undefined, minWidth={"80%"}
) maxWidth={"80%"}
} />
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 <MWButton
type="danger" type="danger"
onPress={() => logoutMutation.mutate()} width={"50%"}
alignSelf="flex-start" onPress={() => logoutMutation.mutate()}
isLoading={logoutMutation.isPending} >
> Logout
Logout </MWButton>
</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>
)}
{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> </YStack>
</MWCard> </XStack>
</YStack> </MWCard>
</YStack> </YStack>
</ScreenLayout>
{state.changed && ( <YStack gap="$4">
<View position="absolute" alignItems="center" bottom="$2" px="$2"> <Text fontSize="$7" fontWeight="$bold">
<XStack Devices
width="100%" </Text>
padding="$4" <MWSeparator />
backgroundColor="$shade800" {sessions.isLoading && <Spinner />}
justifyContent="space-between" {sessions.isError && (
borderRadius="$4" <Text fontWeight="$bold" color="$rose200">
animation="bounce" Error loading sessions
enterStyle={{ </Text>
y: 10, )}
opacity: 0, {deviceListSorted.map((device) => (
}} <MWCard bordered padded key={device.id}>
opacity={1} <XStack gap="$4" alignItems="center">
scale={1} <YStack gap="$1" flexGrow={1}>
> <Text fontWeight="$semibold" color="$ash300">
<MWButton type="cancel" onPress={state.reset}> Device name
Reset </Text>
</MWButton> <Text fontWeight="$bold">{device.name}</Text>
<MWButton </YStack>
type="purple" {!device.current && (
onPress={() => saveChangesMutation.mutate()} <MWButton
isLoading={saveChangesMutation.isPending} type="danger"
> onPress={() => removeSessionMutation.mutate(device.id)}
Save changes >
</MWButton> Remove
</XStack> </MWButton>
</View> )}
)} </XStack>
</> </MWCard>
))}
</YStack>
<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.
</Text>
<DeleteAccountAlert />
</YStack>
</MWCard>
</YStack>
</YStack>
</ScreenLayout>
); );
} }

View File

@@ -1,13 +1,14 @@
import type { CircleProps } from "tamagui"; import type { CircleProps } from "tamagui";
import { FontAwesome6 } from "@expo/vector-icons"; import { FontAwesome6 } from "@expo/vector-icons";
import { Circle, View } from "tamagui"; import { Circle } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient"; import { LinearGradient } from "tamagui/linear-gradient";
import type { expoIcons } from "./UserIconPicker";
export interface AvatarProps { export interface AvatarProps {
colorA: string; colorA: string;
colorB: string; colorB: string;
icon: string; icon: (typeof expoIcons)[number];
bottomItem?: React.ReactNode;
} }
export function Avatar(props: AvatarProps & CircleProps) { export function Avatar(props: AvatarProps & CircleProps) {
@@ -32,9 +33,6 @@ export function Avatar(props: AvatarProps & CircleProps) {
> >
<FontAwesome6 name={props.icon} size={24} color="white" /> <FontAwesome6 name={props.icon} size={24} color="white" />
</LinearGradient> </LinearGradient>
<View position="absolute" bottom={0}>
{props.bottomItem}
</View>
</Circle> </Circle>
); );
} }

View File

@@ -1,81 +0,0 @@
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>
);
}

View File

@@ -11,8 +11,8 @@ export const colors = [
] as const; ] as const;
export function ColorPicker(props: { export function ColorPicker(props: {
value: string; value: (typeof colors)[number];
onInput: (v: string) => void; onInput: (v: (typeof colors)[number]) => void;
}) { }) {
return ( return (
<XStack gap="$2"> <XStack gap="$2">

View File

@@ -75,12 +75,7 @@ export function DeleteAccountAlert() {
asChild asChild
onPress={() => deleteAccountMutation.mutate()} onPress={() => deleteAccountMutation.mutate()}
> >
<MWButton <MWButton type="purple">I am sure</MWButton>
type="purple"
isLoading={deleteAccountMutation.isPending}
>
I am sure
</MWButton>
</AlertDialog.Action> </AlertDialog.Action>
</XStack> </XStack>
</YStack> </YStack>

View File

@@ -24,13 +24,9 @@ export const getExpoIconFromDbIcon = (icon: string) => {
) as (typeof expoIcons)[number]; ) as (typeof expoIcons)[number];
}; };
export const getDbIconFromExpoIcon = (icon: string) => {
return expoIconsToDbIcons[icon as (typeof expoIcons)[number]];
};
export function UserIconPicker(props: { export function UserIconPicker(props: {
value: string; value: (typeof expoIcons)[number];
onInput: (v: string) => void; onInput: (v: (typeof expoIcons)[number]) => void;
}) { }) {
return ( return (
<XStack gap="$2"> <XStack gap="$2">

View File

@@ -1,6 +1,6 @@
import { Button, Spinner, styled, withStaticProperties } from "tamagui"; import { Button, styled } from "tamagui";
const MWButtonFrame = styled(Button, { export const MWButton = styled(Button, {
variants: { variants: {
type: { type: {
primary: { primary: {
@@ -71,22 +71,3 @@ const MWButtonFrame = styled(Button, {
}, },
} as const, } 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, {});

View File

@@ -1,91 +0,0 @@
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,
},
};
}