Compare commits

..

5 Commits

Author SHA1 Message Date
Adrian Castro
6c189d504b chore: make pull handle visible 🔍 2024-04-21 23:05:30 +02:00
Adrian Castro
118fa2092d fix: aaa funi crypto lib being funi ahahahahahahahahahahahahahahahahahahahahahahaaaaaaaaaaaaaaa 2024-04-21 22:36:12 +02:00
Jorrin
5ef90f52a3 change delete account title size 2024-04-21 20:43:27 +02:00
Jorrin
d7940766e7 edit account (profile, color, name) 2024-04-21 20:37:22 +02:00
Jorrin
e3d507db72 add loading states to buttons 2024-04-21 18:58:55 +02:00
12 changed files with 461 additions and 107 deletions

View File

@@ -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";

View File

@@ -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 && (

View File

@@ -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),
},
});
};

View File

@@ -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>

View File

@@ -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,9 +85,60 @@ 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">
@@ -81,33 +147,76 @@ export function AccountInformation() {
</Text>
<MWSeparator />
{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
{...account.profile}
icon={getExpoIconFromDbIcon(account.profile.icon)}
{...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={decryptedName}
minWidth={"80%"}
maxWidth={"80%"}
value={state.deviceName.state}
onChangeText={state.deviceName.set}
alignSelf="flex-start"
width="$14"
/>
<MWButton
type="danger"
width={"50%"}
onPress={() => logoutMutation.mutate()}
alignSelf="flex-start"
isLoading={logoutMutation.isPending}
>
Logout
</MWButton>
</YStack>
</XStack>
</MWCard>
)}
</YStack>
<YStack gap="$4">
@@ -133,6 +242,7 @@ export function AccountInformation() {
{!device.current && (
<MWButton
type="danger"
isLoading={removeSessionMutation.isPending}
onPress={() => removeSessionMutation.mutate(device.id)}
>
Remove
@@ -150,7 +260,7 @@ export function AccountInformation() {
<MWSeparator />
<MWCard bordered padded>
<YStack gap="$3">
<H3 fontWeight="$bold">Delete account</H3>
<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.
@@ -161,5 +271,36 @@ export function AccountInformation() {
</YStack>
</YStack>
</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>
)}
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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, {});

View 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,
},
};
}