mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 12:33:26 +00:00
Compare commits
7 Commits
9694630cdf
...
0f3ccbcabc
Author | SHA1 | Date | |
---|---|---|---|
|
0f3ccbcabc | ||
|
9eb9fb494c | ||
|
bbeb729156 | ||
|
b530284519 | ||
|
fcfd0d99cc | ||
|
75f5256b20 | ||
|
eea4eab60b |
@@ -1,3 +1,4 @@
|
||||
import "expo-router/entry";
|
||||
import "react-native-gesture-handler";
|
||||
import "@react-native-anywhere/polyfill-base64";
|
||||
import "text-encoding-polyfill";
|
||||
|
@@ -41,6 +41,7 @@
|
||||
"expo-av": "~13.10.5",
|
||||
"expo-brightness": "~11.8.0",
|
||||
"expo-build-properties": "~0.11.1",
|
||||
"expo-clipboard": "^5.0.1",
|
||||
"expo-constants": "~15.4.5",
|
||||
"expo-file-system": "~16.0.8",
|
||||
"expo-haptics": "~12.8.1",
|
||||
@@ -75,6 +76,7 @@
|
||||
"react-native-web": "^0.19.10",
|
||||
"subsrt-ts": "^2.1.2",
|
||||
"tamagui": "^1.94.0",
|
||||
"text-encoding-polyfill": "^0.6.7",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@@ -55,7 +55,7 @@ const TestDownloadButton = (props: {
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Link } from "expo-router";
|
||||
import { H2, H5, Paragraph, View } from "tamagui";
|
||||
import { H3, H5, Paragraph, View } from "tamagui";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
@@ -18,15 +18,15 @@ export default function MovieWebScreen() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MWCard bordered>
|
||||
<MWCard.Header padded>
|
||||
<H2 fontWeight="$bold" paddingBottom="$1">
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H3 fontWeight="$bold" paddingBottom="$1">
|
||||
Sync to the cloud
|
||||
</H2>
|
||||
<H5 color="$ash50" fontWeight="$semibold" paddingVertical="$3">
|
||||
</H3>
|
||||
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
|
||||
Share your watch progress between devices and keep them synced.
|
||||
</H5>
|
||||
<Paragraph color="$ash50">
|
||||
<Paragraph color="$shade200">
|
||||
First choose the backend you want to use. If you do not know what
|
||||
this does, use the default and click on 'Get started'.
|
||||
</Paragraph>
|
||||
@@ -35,24 +35,24 @@ export default function MovieWebScreen() {
|
||||
<View padding="$4">
|
||||
<MWInput
|
||||
placeholder={backendUrl}
|
||||
type="search"
|
||||
type="authentication"
|
||||
value={backendUrl}
|
||||
onChangeText={setBackendUrl}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MWCard.Footer padded justifyContent="center">
|
||||
<MWButton type="purple">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/trust/[url]",
|
||||
params: { url: backendUrl },
|
||||
}}
|
||||
style={{ color: "white", fontWeight: "bold" }}
|
||||
>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/trust/[url]",
|
||||
params: { url: backendUrl },
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple" width="100%">
|
||||
Get started
|
||||
</Link>
|
||||
</MWButton>
|
||||
</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
|
@@ -206,7 +206,7 @@ export default function SettingsScreen() {
|
||||
android: "android",
|
||||
})}
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
iconAfter={
|
||||
@@ -229,7 +229,7 @@ export default function SettingsScreen() {
|
||||
<MaterialCommunityIcons
|
||||
name="broom"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => clearCacheDirectory()}
|
||||
@@ -304,7 +304,7 @@ export function UpdateSheet({
|
||||
<MaterialCommunityIcons
|
||||
name={Platform.select({ ios: "apple", android: "android" })}
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
|
||||
|
79
apps/expo/src/app/sync/login.tsx
Normal file
79
apps/expo/src/app/sync/login.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { H4, Label, Paragraph, Text, YStack } from "tamagui";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Login to your account
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$ash50"
|
||||
textAlign="center"
|
||||
fontWeight="$semibold"
|
||||
paddingVertical="$4"
|
||||
>
|
||||
Please enter your passphrase to login to your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Passphrase"
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Device name</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Personal phone"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer
|
||||
padded
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
gap="$4"
|
||||
>
|
||||
<MWButton type="purple">Login</MWButton>
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||
Don't have an account yet?{"\n"}
|
||||
<Text color="$purple100" fontWeight="$bold">
|
||||
Create an account.
|
||||
</Text>
|
||||
</Paragraph>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
190
apps/expo/src/app/sync/register/account.tsx
Normal file
190
apps/expo/src/app/sync/register/account.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { FontAwesome6, Ionicons } from "@expo/vector-icons";
|
||||
import { Circle, H4, Label, Paragraph, View, XStack, YStack } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
|
||||
const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"] as const;
|
||||
|
||||
function ColorPicker(props: {
|
||||
value: (typeof colors)[number];
|
||||
onInput: (v: (typeof colors)[number]) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
{colors.map((color) => {
|
||||
return (
|
||||
<View
|
||||
onPress={() => props.onInput(color)}
|
||||
flexGrow={1}
|
||||
height="$4"
|
||||
borderRadius="$4"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
backgroundColor={color}
|
||||
key={color}
|
||||
>
|
||||
{props.value === color ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
const icons = [
|
||||
"user-group",
|
||||
"couch",
|
||||
"mobile-screen",
|
||||
"ticket",
|
||||
"handcuffs",
|
||||
] as const;
|
||||
|
||||
function UserIconPicker(props: {
|
||||
value: (typeof icons)[number];
|
||||
onInput: (v: (typeof icons)[number]) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
{icons.map((icon) => {
|
||||
return (
|
||||
<View
|
||||
flexGrow={1}
|
||||
height="$4"
|
||||
borderRadius="$4"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
|
||||
borderColor={props.value === icon ? "$purple200" : "$shade400"}
|
||||
borderWidth={1}
|
||||
key={icon}
|
||||
onPress={() => props.onInput(icon)}
|
||||
>
|
||||
<FontAwesome6 name={icon} size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: (typeof icons)[number];
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps) {
|
||||
return (
|
||||
<Circle
|
||||
backgroundColor={props.colorA}
|
||||
height="$6"
|
||||
width="$6"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[props.colorA, props.colorB]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
borderRadius="$12"
|
||||
width="100%"
|
||||
height="100%"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<FontAwesome6 name={props.icon} size={24} color="white" />
|
||||
</LinearGradient>
|
||||
</Circle>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [color, setColor] = useState<(typeof colors)[number]>(colors[0]);
|
||||
const [color2, setColor2] = useState<(typeof colors)[number]>(colors[0]);
|
||||
const [icon, setIcon] = useState<(typeof icons)[number]>(icons[0]);
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<View alignItems="center" marginBottom="$3">
|
||||
<Avatar colorA={color} colorB={color2} icon={icon} />
|
||||
</View>
|
||||
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Account information
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Enter a name for your device and pick colours and a user icon of
|
||||
your choosing
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Device name</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Passphrase"
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Profile color one</Label>
|
||||
<ColorPicker value={color} onInput={(color) => setColor(color)} />
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Profile color two</Label>
|
||||
<ColorPicker value={color2} onInput={(color) => setColor2(color)} />
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">User icon</Label>
|
||||
<UserIconPicker value={icon} onInput={(icon) => setIcon(icon)} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/register/confirm",
|
||||
}}
|
||||
replace
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple" width="100%">
|
||||
Next
|
||||
</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
68
apps/expo/src/app/sync/register/confirm.tsx
Normal file
68
apps/expo/src/app/sync/register/confirm.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { H4, Label, Paragraph, YStack } from "tamagui";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Confirm your passphrase
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Please enter your passphrase from earlier to confirm you have saved
|
||||
it and to create your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Passphrase"
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/(tabs)/movie-web",
|
||||
}}
|
||||
replace
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple">Create account</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
132
apps/expo/src/app/sync/register/index.tsx
Normal file
132
apps/expo/src/app/sync/register/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { H4, Paragraph, Text, useTheme, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import { genMnemonic } from "@movie-web/api";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
|
||||
function PassphraseWord({ word }: { word: string }) {
|
||||
return (
|
||||
<View
|
||||
width="$10"
|
||||
borderRadius="$4"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$shade400"
|
||||
>
|
||||
<Text fontWeight="$bold">{word}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const theme = useTheme();
|
||||
const words = genMnemonic().split(" ");
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Your passphrase
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Your passphrase acts as your username and password. Make sure to
|
||||
keep it safe as you will need to enter it to login to your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack
|
||||
borderRadius="$4"
|
||||
borderColor="$shade200"
|
||||
borderWidth="$0.5"
|
||||
marginBottom="$4"
|
||||
>
|
||||
<XStack
|
||||
gap="$1"
|
||||
borderBottomWidth="$0.5"
|
||||
borderColor="$shade200"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$4"
|
||||
>
|
||||
<Text fontWeight="$bold" flexGrow={1}>
|
||||
Passphrase
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Clipboard.setStringAsync(words.join(""));
|
||||
}}
|
||||
>
|
||||
<Feather name="copy" size={18} color={theme.shade200.val} />
|
||||
<Text color="$shade200" fontWeight="$bold">
|
||||
Copy
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</XStack>
|
||||
<View
|
||||
flexWrap="wrap"
|
||||
flexDirection="row"
|
||||
gap="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$3"
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<PassphraseWord key={index} word={word} />
|
||||
))}
|
||||
</View>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/register/account",
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple">I have saved my passphrase</MWButton>
|
||||
</Link>
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||
Already have an account?{"\n"}
|
||||
<Text color="$purple100" fontWeight="$bold">
|
||||
<Link href="/sync/login">Login here</Link>
|
||||
</Text>
|
||||
</Paragraph>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
@@ -1,30 +1,19 @@
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Link, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { H4, Paragraph, Text, View } from "tamagui";
|
||||
|
||||
import { getBackendMeta } from "@movie-web/api";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
|
||||
// TODO: extract to function with cleanup and types
|
||||
const getBackendMeta = (
|
||||
url: string,
|
||||
): Promise<{
|
||||
description: string;
|
||||
hasCaptcha: boolean;
|
||||
name: string;
|
||||
url: string;
|
||||
}> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return fetch(`${url}/meta`).then((res) => res.json());
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { url } = useLocalSearchParams();
|
||||
const { url } = useLocalSearchParams<{ url: string }>();
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: ["backendMeta", url],
|
||||
queryFn: () => getBackendMeta(url as string),
|
||||
queryFn: () => getBackendMeta(url),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -41,7 +30,7 @@ export default function Page() {
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<MWCard bordered>
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header padded>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Do you trust this server?
|
||||
@@ -101,15 +90,23 @@ export default function Page() {
|
||||
flexDirection="column"
|
||||
gap="$4"
|
||||
>
|
||||
<MWButton type="purple">I trust this server</MWButton>
|
||||
<MWButton type="secondary">Go back</MWButton>
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||
Already have an account?{" "}
|
||||
<Text color="$purple100" fontWeight="$bold">
|
||||
Login here
|
||||
</Text>
|
||||
</Paragraph>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/register",
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple">I trust this server</MWButton>
|
||||
</Link>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/(tabs)/",
|
||||
}}
|
||||
replace
|
||||
asChild
|
||||
>
|
||||
<MWButton type="cancel">Go back</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
|
@@ -58,7 +58,7 @@ export const AudioTrackSelector = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="volume-high"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
|
@@ -65,7 +65,7 @@ export const CaptionsSelector = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="subtitles"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
|
@@ -38,7 +38,7 @@ export const DownloadButton = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() =>
|
||||
|
@@ -47,7 +47,7 @@ const EpisodeSelector = ({
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
onPress={() => {
|
||||
setSelectedSeason(null);
|
||||
props.onOpenChange?.(false);
|
||||
@@ -119,7 +119,7 @@ export const SeasonSelector = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="audio-video"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
|
@@ -23,7 +23,7 @@ export const SettingsSelector = () => {
|
||||
<MaterialIcons
|
||||
name="display-settings"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
|
@@ -102,7 +102,7 @@ const EmbedsPart = ({
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
onPress={() => {
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
@@ -160,7 +160,7 @@ export const SourceSelector = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="video"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
|
@@ -4,35 +4,35 @@ export const MWButton = styled(Button, {
|
||||
variants: {
|
||||
type: {
|
||||
primary: {
|
||||
backgroundColor: "$buttonPrimaryBackground",
|
||||
color: "$buttonPrimaryText",
|
||||
backgroundColor: "white",
|
||||
color: "black",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonPrimaryBackgroundHover",
|
||||
backgroundColor: "$silver100",
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: "$buttonSecondaryBackground",
|
||||
color: "$buttonSecondaryText",
|
||||
backgroundColor: "$ash700",
|
||||
color: "$silver300",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonSecondaryBackgroundHover",
|
||||
backgroundColor: "$ash500",
|
||||
},
|
||||
},
|
||||
purple: {
|
||||
backgroundColor: "$buttonPurpleBackground",
|
||||
backgroundColor: "$purple500",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonPurpleBackgroundHover",
|
||||
backgroundColor: "$purple400",
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
backgroundColor: "$buttonCancelBackground",
|
||||
backgroundColor: "$ash500",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonCancelBackgroundHover",
|
||||
backgroundColor: "$ash300",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Card, styled, withStaticProperties } from "tamagui";
|
||||
|
||||
export const MWCardFrame = styled(Card, {
|
||||
backgroundColor: "$shade400",
|
||||
backgroundColor: "$shade600",
|
||||
borderColor: "$shade400",
|
||||
|
||||
variants: {
|
||||
|
@@ -6,21 +6,37 @@ export const MWInput = styled(Input, {
|
||||
variants: {
|
||||
type: {
|
||||
default: {
|
||||
backgroundColor: "$inputBackground",
|
||||
color: "$inputText",
|
||||
placeholderTextColor: "$placeHolderText",
|
||||
borderColor: "$inputBorder",
|
||||
backgroundColor: "$ash600",
|
||||
color: "$ash100",
|
||||
placeholderTextColor: "$ash200",
|
||||
borderColor: "$ash500",
|
||||
outlineStyle: "none",
|
||||
focusStyle: {
|
||||
borderColor: "$ash300",
|
||||
},
|
||||
},
|
||||
search: {
|
||||
backgroundColor: "$searchBackground",
|
||||
backgroundColor: "$shade500",
|
||||
color: "$shade100",
|
||||
borderColor: "$colorTransparent",
|
||||
placeholderTextColor: "$searchPlaceholder",
|
||||
placeholderTextColor: "$shade100",
|
||||
outlineStyle: "none",
|
||||
focusStyle: {
|
||||
borderColor: "$colorTransparent",
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
backgroundColor: "$shade500",
|
||||
color: "$shade100",
|
||||
placeholderTextColor: "$shade400",
|
||||
outlineStyle: "none",
|
||||
focusStyle: {
|
||||
borderColor: "$shade300",
|
||||
},
|
||||
pressStyle: {
|
||||
backgroundColor: "$shade500",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
204
apps/expo/src/hooks/useAuth.ts
Normal file
204
apps/expo/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
AccountWithToken,
|
||||
BookmarkMediaItem,
|
||||
ProgressMediaItem,
|
||||
SessionResponse,
|
||||
UserResponse,
|
||||
} from "@movie-web/api";
|
||||
import {
|
||||
bookmarkMediaToInput,
|
||||
bytesToBase64,
|
||||
bytesToBase64Url,
|
||||
encryptData,
|
||||
getBookmarks,
|
||||
getLoginChallengeToken,
|
||||
getProgress,
|
||||
getRegisterChallengeToken,
|
||||
getSettings,
|
||||
getUser,
|
||||
importBookmarks,
|
||||
importProgress,
|
||||
keysFromMnemonic,
|
||||
loginAccount,
|
||||
progressMediaItemToInputs,
|
||||
registerAccount,
|
||||
removeSession,
|
||||
signChallenge,
|
||||
} from "@movie-web/api";
|
||||
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
import { useAuthData } from "./useAuthData";
|
||||
|
||||
export interface RegistrationData {
|
||||
recaptchaToken?: string;
|
||||
mnemonic: string;
|
||||
userData: {
|
||||
device: string;
|
||||
profile: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginData {
|
||||
mnemonic: string;
|
||||
userData: {
|
||||
device: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const currentAccount = useAuthStore((s) => s.account);
|
||||
const profile = useAuthStore((s) => s.account?.profile);
|
||||
const loggedIn = !!useAuthStore((s) => s.account);
|
||||
const backendUrl = useAuthStore((s) => s.backendUrl);
|
||||
const {
|
||||
logout: userDataLogout,
|
||||
login: userDataLogin,
|
||||
syncData,
|
||||
} = useAuthData();
|
||||
|
||||
const login = useCallback(
|
||||
async (loginData: LoginData) => {
|
||||
if (!backendUrl) return;
|
||||
const keys = await keysFromMnemonic(loginData.mnemonic);
|
||||
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
|
||||
const { challenge } = await getLoginChallengeToken(
|
||||
backendUrl,
|
||||
publicKeyBase64Url,
|
||||
);
|
||||
const signature = signChallenge(keys, challenge);
|
||||
const loginResult = await loginAccount(backendUrl, {
|
||||
challenge: {
|
||||
code: challenge,
|
||||
signature,
|
||||
},
|
||||
publicKey: publicKeyBase64Url,
|
||||
device: await encryptData(loginData.userData.device, keys.seed),
|
||||
});
|
||||
|
||||
const user = await getUser(backendUrl, loginResult.token);
|
||||
const seedBase64 = bytesToBase64(keys.seed);
|
||||
return userDataLogin(loginResult, user.user, user.session, seedBase64);
|
||||
},
|
||||
[userDataLogin, backendUrl],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (!currentAccount || !backendUrl) return;
|
||||
try {
|
||||
await removeSession(
|
||||
backendUrl,
|
||||
currentAccount.token,
|
||||
currentAccount.sessionId,
|
||||
);
|
||||
} catch {
|
||||
// we dont care about failing to delete session
|
||||
}
|
||||
userDataLogout();
|
||||
}, [userDataLogout, backendUrl, currentAccount]);
|
||||
|
||||
const register = useCallback(
|
||||
async (registerData: RegistrationData) => {
|
||||
if (!backendUrl) return;
|
||||
const { challenge } = await getRegisterChallengeToken(
|
||||
backendUrl,
|
||||
registerData.recaptchaToken,
|
||||
);
|
||||
const keys = await keysFromMnemonic(registerData.mnemonic);
|
||||
const signature = signChallenge(keys, challenge);
|
||||
const registerResult = await registerAccount(backendUrl, {
|
||||
challenge: {
|
||||
code: challenge,
|
||||
signature,
|
||||
},
|
||||
publicKey: bytesToBase64Url(keys.publicKey),
|
||||
device: await encryptData(registerData.userData.device, keys.seed),
|
||||
profile: registerData.userData.profile,
|
||||
});
|
||||
|
||||
return userDataLogin(
|
||||
registerResult,
|
||||
registerResult.user,
|
||||
registerResult.session,
|
||||
bytesToBase64(keys.seed),
|
||||
);
|
||||
},
|
||||
[backendUrl, userDataLogin],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
async (
|
||||
account: AccountWithToken,
|
||||
progressItems: Record<string, ProgressMediaItem>,
|
||||
bookmarks: Record<string, BookmarkMediaItem>,
|
||||
) => {
|
||||
if (!backendUrl) return;
|
||||
if (
|
||||
Object.keys(progressItems).length === 0 &&
|
||||
Object.keys(bookmarks).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressInputs = Object.entries(progressItems).flatMap(
|
||||
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
|
||||
);
|
||||
|
||||
const bookmarkInputs = Object.entries(bookmarks).map(([tmdbId, item]) =>
|
||||
bookmarkMediaToInput(tmdbId, item),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
importProgress(backendUrl, account, progressInputs),
|
||||
importBookmarks(backendUrl, account, bookmarkInputs),
|
||||
]);
|
||||
},
|
||||
[backendUrl],
|
||||
);
|
||||
|
||||
const restore = useCallback(
|
||||
async (account: AccountWithToken) => {
|
||||
if (!backendUrl) return;
|
||||
let user: { user: UserResponse; session: SessionResponse };
|
||||
try {
|
||||
user = await getUser(backendUrl, account.token);
|
||||
} catch (err) {
|
||||
const anyError = err as { response?: { status: number } };
|
||||
if (
|
||||
anyError?.response?.status === 401 ||
|
||||
anyError?.response?.status === 403 ||
|
||||
anyError?.response?.status === 400
|
||||
) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [bookmarks, progress, settings] = await Promise.all([
|
||||
getBookmarks(backendUrl, account),
|
||||
getProgress(backendUrl, account),
|
||||
getSettings(backendUrl, account),
|
||||
]);
|
||||
|
||||
syncData(user.user, user.session, progress, bookmarks, settings);
|
||||
},
|
||||
[backendUrl, syncData, logout],
|
||||
);
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
profile,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
restore,
|
||||
importData,
|
||||
};
|
||||
}
|
170
apps/expo/src/hooks/useAuthData.ts
Normal file
170
apps/expo/src/hooks/useAuthData.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
BookmarkResponse,
|
||||
LoginResponse,
|
||||
ProgressResponse,
|
||||
SessionResponse,
|
||||
SettingsResponse,
|
||||
UserResponse,
|
||||
} from "@movie-web/api";
|
||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||
|
||||
import type { ItemData } from "~/components/item/item";
|
||||
import type { WatchHistoryItem } from "~/stores/settings";
|
||||
import type { ThemeStoreOption } from "~/stores/theme";
|
||||
import {
|
||||
useAuthStore,
|
||||
useBookmarkStore,
|
||||
useWatchHistoryStore,
|
||||
} from "~/stores/settings";
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
export function useAuthData() {
|
||||
const loggedIn = !!useAuthStore((s) => s.account);
|
||||
const setAccount = useAuthStore((s) => s.setAccount);
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const setBookmarks = useBookmarkStore((s) => s.setBookmarks);
|
||||
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||
const setWatchHistory = useWatchHistoryStore((s) => s.setWatchHistory);
|
||||
const clearBookmarks = useCallback(() => setBookmarks([]), [setBookmarks]);
|
||||
const clearProgress = useCallback(
|
||||
() => setWatchHistory([]),
|
||||
[setWatchHistory],
|
||||
);
|
||||
const replaceBookmarks = useCallback(
|
||||
(bookmarks: ItemData[]) => setBookmarks(bookmarks),
|
||||
[setBookmarks],
|
||||
);
|
||||
const replaceItems = useCallback(
|
||||
(items: WatchHistoryItem[]) => setWatchHistory(items),
|
||||
[setWatchHistory],
|
||||
);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
|
||||
// const setAppLanguage = useLanguageStore((s) => s.setLanguage);
|
||||
// const importSubtitleLanguage = useSubtitleStore(
|
||||
// (s) => s.importSubtitleLanguage,
|
||||
// );
|
||||
|
||||
const login = useCallback(
|
||||
(
|
||||
loginResponse: LoginResponse,
|
||||
user: UserResponse,
|
||||
session: SessionResponse,
|
||||
seed: string,
|
||||
) => {
|
||||
const account = {
|
||||
token: loginResponse.token,
|
||||
userId: user.id,
|
||||
sessionId: loginResponse.session.id,
|
||||
deviceName: session.device,
|
||||
profile: user.profile,
|
||||
seed,
|
||||
};
|
||||
setAccount(account);
|
||||
return account;
|
||||
},
|
||||
[setAccount],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
removeAccount();
|
||||
clearBookmarks();
|
||||
clearProgress();
|
||||
}, [removeAccount, clearBookmarks, clearProgress]);
|
||||
|
||||
const syncData = useCallback(
|
||||
(
|
||||
_user: UserResponse,
|
||||
_session: SessionResponse,
|
||||
progress: ProgressResponse[],
|
||||
bookmarks: BookmarkResponse[],
|
||||
settings: SettingsResponse,
|
||||
) => {
|
||||
const bookmarkResponseToItemData = (
|
||||
bookmarks: BookmarkResponse[],
|
||||
): ItemData[] => {
|
||||
return bookmarks.map((bookmark) => ({
|
||||
id: bookmark.tmdbId,
|
||||
title: bookmark.meta.title,
|
||||
type: bookmark.meta.type === "show" ? "tv" : "movie",
|
||||
year: bookmark.meta.year,
|
||||
posterUrl: bookmark.meta.poster ?? "",
|
||||
}));
|
||||
};
|
||||
|
||||
const progressResponseToWatchHistoryItem = (
|
||||
progress: ProgressResponse[],
|
||||
): WatchHistoryItem[] => {
|
||||
return progress.map((entry) => {
|
||||
const isShow = entry.meta.type === "show";
|
||||
const commonMedia = {
|
||||
title: entry.meta.title,
|
||||
releaseYear: entry.meta.year,
|
||||
tmdbId: entry.tmdbId,
|
||||
};
|
||||
|
||||
const media: ScrapeMedia = isShow
|
||||
? {
|
||||
...commonMedia,
|
||||
type: "show",
|
||||
season: {
|
||||
number: entry.season.number ?? 0,
|
||||
tmdbId: entry.season.id ?? "",
|
||||
},
|
||||
episode: {
|
||||
number: entry.episode.number ?? 0,
|
||||
tmdbId: entry.episode.id ?? "",
|
||||
},
|
||||
}
|
||||
: {
|
||||
...commonMedia,
|
||||
type: "movie",
|
||||
};
|
||||
|
||||
return {
|
||||
item: {
|
||||
id: entry.tmdbId,
|
||||
title: entry.meta.title,
|
||||
type: entry.meta.type === "show" ? "tv" : "movie",
|
||||
season: entry.season.number,
|
||||
episode: entry.episode.number,
|
||||
year: entry.meta.year,
|
||||
posterUrl: entry.meta.poster ?? "",
|
||||
},
|
||||
media: media,
|
||||
positionMillis: parseInt(entry.watched, 10),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
replaceBookmarks(bookmarkResponseToItemData(bookmarks));
|
||||
replaceItems(progressResponseToWatchHistoryItem(progress));
|
||||
|
||||
// if (settings.applicationLanguage) {
|
||||
// setAppLanguage(settings.applicationLanguage);
|
||||
// }
|
||||
|
||||
// if (settings.defaultSubtitleLanguage) {
|
||||
// importSubtitleLanguage(settings.defaultSubtitleLanguage);
|
||||
// }
|
||||
|
||||
if (settings.applicationTheme) {
|
||||
setTheme(settings.applicationTheme as unknown as ThemeStoreOption);
|
||||
}
|
||||
|
||||
if (settings.proxyUrls) {
|
||||
setProxySet(settings.proxyUrls);
|
||||
}
|
||||
},
|
||||
[replaceBookmarks, replaceItems, setTheme, setProxySet],
|
||||
);
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
login,
|
||||
logout,
|
||||
syncData,
|
||||
};
|
||||
}
|
@@ -144,7 +144,7 @@ export const useBookmarkStore = create<
|
||||
),
|
||||
);
|
||||
|
||||
interface WatchHistoryItem {
|
||||
export interface WatchHistoryItem {
|
||||
item: ItemData;
|
||||
media: ScrapeMedia;
|
||||
positionMillis: number;
|
||||
|
@@ -57,17 +57,6 @@ const createThemeConfig = (tokens: Tokens) => ({
|
||||
|
||||
loadingIndicator: tokens.purple.c200,
|
||||
|
||||
buttonSecondaryBackground: tokens.ash.c700,
|
||||
buttonSecondaryText: tokens.semantic.silver.c300,
|
||||
buttonSecondaryBackgroundHover: tokens.ash.c700,
|
||||
buttonPrimaryBackground: tokens.white,
|
||||
buttonPrimaryText: tokens.black,
|
||||
buttonPrimaryBackgroundHover: tokens.semantic.silver.c100,
|
||||
buttonPurpleBackground: tokens.purple.c500,
|
||||
buttonPurpleBackgroundHover: tokens.purple.c400,
|
||||
buttonCancelBackground: tokens.ash.c500,
|
||||
buttonCancelBackgroundHover: tokens.ash.c300,
|
||||
|
||||
switchActiveTrackColor: tokens.purple.c300,
|
||||
switchInactiveTrackColor: tokens.ash.c500,
|
||||
switchThumbColor: tokens.white,
|
||||
|
@@ -32,7 +32,6 @@
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@scure/bip39": "^1.3.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"ofetch": "^1.3.4"
|
||||
"node-forge": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { LoginResponse } from "./types";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function getAuthHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
@@ -13,12 +12,12 @@ export async function accountLogin(
|
||||
id: string,
|
||||
deviceName: string,
|
||||
): Promise<LoginResponse> {
|
||||
return ofetch<LoginResponse>("/auth/login", {
|
||||
return f<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id,
|
||||
device: deviceName,
|
||||
},
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
AccountWithToken,
|
||||
BookmarkInput,
|
||||
@@ -7,6 +5,7 @@ import type {
|
||||
BookmarkResponse,
|
||||
} from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function bookmarkMediaToInput(
|
||||
tmdbId: string,
|
||||
@@ -28,12 +27,12 @@ export async function addBookmark(
|
||||
account: AccountWithToken,
|
||||
input: BookmarkInput,
|
||||
) {
|
||||
return ofetch<BookmarkResponse>(
|
||||
return f<BookmarkResponse>(
|
||||
`/users/${account.userId}/bookmarks/${input.tmdbId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
body: input,
|
||||
},
|
||||
);
|
||||
@@ -44,12 +43,9 @@ export async function removeBookmark(
|
||||
account: AccountWithToken,
|
||||
id: string,
|
||||
) {
|
||||
return ofetch<{ tmdbId: string }>(
|
||||
`/users/${account.userId}/bookmarks/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
},
|
||||
);
|
||||
return f<{ tmdbId: string }>(`/users/${account.userId}/bookmarks/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -39,11 +39,7 @@ export function genMnemonic(): string {
|
||||
return generateMnemonic(wordlist);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export async function signCode(
|
||||
code: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
export function signCode(code: string, privateKey: Uint8Array): Uint8Array {
|
||||
return forge.pki.ed25519.sign({
|
||||
encoding: "utf8",
|
||||
message: code,
|
||||
@@ -62,8 +58,8 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export async function signChallenge(keys: Keys, challengeCode: string) {
|
||||
const signature = await signCode(challengeCode, keys.privateKey);
|
||||
export function signChallenge(keys: Keys, challengeCode: string) {
|
||||
const signature = signCode(challengeCode, keys.privateKey);
|
||||
return bytesToBase64Url(signature);
|
||||
}
|
||||
|
||||
|
53
packages/api/src/fetch.ts
Normal file
53
packages/api/src/fetch.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface FetcherOptions {
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
method?: "HEAD" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
readHeaders?: string[];
|
||||
body?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type FullUrlOptions = Pick<FetcherOptions, "query" | "baseUrl">;
|
||||
|
||||
export function makeFullUrl(url: string, ops?: FullUrlOptions): string {
|
||||
// glue baseUrl and rest of url together
|
||||
let leftSide = ops?.baseUrl ?? "";
|
||||
let rightSide = url;
|
||||
|
||||
// left side should always end with slash, if its set
|
||||
if (leftSide.length > 0 && !leftSide.endsWith("/")) leftSide += "/";
|
||||
|
||||
// right side should never start with slash
|
||||
if (rightSide.startsWith("/")) rightSide = rightSide.slice(1);
|
||||
|
||||
const fullUrl = leftSide + rightSide;
|
||||
if (!fullUrl.startsWith("http://") && !fullUrl.startsWith("https://"))
|
||||
throw new Error(
|
||||
`Invald URL -- URL doesn't start with a http scheme: '${fullUrl}'`,
|
||||
);
|
||||
|
||||
const parsedUrl = new URL(fullUrl);
|
||||
Object.entries(ops?.query ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
|
||||
export async function f<T>(url: string, ops?: FetcherOptions): Promise<T> {
|
||||
const fullUrl = makeFullUrl(url, ops);
|
||||
const response = await fetch(fullUrl, {
|
||||
method: ops?.method ?? "GET",
|
||||
headers: ops?.headers,
|
||||
body: ops?.body ? JSON.stringify(ops.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch '${fullUrl}' -- ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
return data;
|
||||
}
|
@@ -1,17 +1,16 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { AccountWithToken, BookmarkInput, ProgressInput } from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function importProgress(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
progressItems: ProgressInput[],
|
||||
) {
|
||||
return ofetch<void>(`/users/${account.userId}/progress/import`, {
|
||||
return f<void>(`/users/${account.userId}/progress/import`, {
|
||||
method: "PUT",
|
||||
body: progressItems,
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
@@ -21,10 +20,10 @@ export function importBookmarks(
|
||||
account: AccountWithToken,
|
||||
bookmarks: BookmarkInput[],
|
||||
) {
|
||||
return ofetch<void>(`/users/${account.userId}/bookmarks`, {
|
||||
return f<void>(`/users/${account.userId}/bookmarks`, {
|
||||
method: "PUT",
|
||||
body: bookmarks,
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
@@ -1,21 +1,20 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
ChallengeTokenResponse,
|
||||
LoginInput,
|
||||
LoginResponse,
|
||||
} from "./types";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export async function getLoginChallengeToken(
|
||||
url: string,
|
||||
publicKey: string,
|
||||
): Promise<ChallengeTokenResponse> {
|
||||
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
|
||||
return f<ChallengeTokenResponse>("/auth/login/start", {
|
||||
method: "POST",
|
||||
body: {
|
||||
publicKey,
|
||||
},
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,12 +22,12 @@ export async function loginAccount(
|
||||
url: string,
|
||||
data: LoginInput,
|
||||
): Promise<LoginResponse> {
|
||||
return ofetch<LoginResponse>("/auth/login/complete", {
|
||||
return f<LoginResponse>("/auth/login/complete", {
|
||||
method: "POST",
|
||||
body: {
|
||||
namespace: "movie-web",
|
||||
...data,
|
||||
},
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { MetaResponse } from "./types";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||
return ofetch<MetaResponse>("/meta", {
|
||||
baseURL: url,
|
||||
export function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||
return f<MetaResponse>("/meta", {
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
AccountWithToken,
|
||||
ProgressInput,
|
||||
@@ -8,6 +6,7 @@ import type {
|
||||
ProgressUpdateItem,
|
||||
} from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function progressUpdateItemToInput(
|
||||
item: ProgressUpdateItem,
|
||||
@@ -72,12 +71,12 @@ export async function setProgress(
|
||||
account: AccountWithToken,
|
||||
input: ProgressInput,
|
||||
) {
|
||||
return ofetch<ProgressResponse>(
|
||||
return f<ProgressResponse>(
|
||||
`/users/${account.userId}/progress/${input.tmdbId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
body: input,
|
||||
},
|
||||
);
|
||||
@@ -90,10 +89,10 @@ export async function removeProgress(
|
||||
episodeId?: string,
|
||||
seasonId?: string,
|
||||
) {
|
||||
await ofetch(`/users/${account.userId}/progress/${id}`, {
|
||||
await f(`/users/${account.userId}/progress/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
body: {
|
||||
episodeId,
|
||||
seasonId,
|
||||
|
@@ -1,22 +1,21 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
ChallengeTokenResponse,
|
||||
RegisterInput,
|
||||
SessionResponse,
|
||||
UserResponse,
|
||||
} from "./types";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export async function getRegisterChallengeToken(
|
||||
url: string,
|
||||
captchaToken?: string,
|
||||
): Promise<ChallengeTokenResponse> {
|
||||
return ofetch<ChallengeTokenResponse>("/auth/register/start", {
|
||||
return f<ChallengeTokenResponse>("/auth/register/start", {
|
||||
method: "POST",
|
||||
body: {
|
||||
captchaToken,
|
||||
},
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,12 +29,12 @@ export async function registerAccount(
|
||||
url: string,
|
||||
data: RegisterInput,
|
||||
): Promise<RegisterResponse> {
|
||||
return ofetch<RegisterResponse>("/auth/register/complete", {
|
||||
return f<RegisterResponse>("/auth/register/complete", {
|
||||
method: "POST",
|
||||
body: {
|
||||
namespace: "movie-web",
|
||||
...data,
|
||||
},
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -1,12 +1,11 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export async function getSessions(url: string, account: AccountWithToken) {
|
||||
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
||||
return f<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,11 +14,11 @@ export async function updateSession(
|
||||
account: AccountWithToken,
|
||||
update: SessionUpdate,
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
||||
return f<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(account.token),
|
||||
body: update,
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,9 +27,9 @@ export async function removeSession(
|
||||
token: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
|
||||
return f<SessionResponse[]>(`/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
@@ -1,29 +1,28 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
AccountWithToken,
|
||||
SettingsInput,
|
||||
SettingsResponse,
|
||||
} from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function updateSettings(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
settings: SettingsInput,
|
||||
) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "PUT",
|
||||
body: settings,
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettings(url: string, account: AccountWithToken) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "GET",
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type {
|
||||
AccountWithToken,
|
||||
BookmarkMediaItem,
|
||||
@@ -11,6 +9,7 @@ import type {
|
||||
UserResponse,
|
||||
} from "./types";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { f } from "./fetch";
|
||||
|
||||
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
||||
const entries = responses.map((bookmark) => {
|
||||
@@ -83,13 +82,10 @@ export async function getUser(
|
||||
url: string,
|
||||
token: string,
|
||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
||||
"/users/@me",
|
||||
{
|
||||
headers: getAuthHeaders(token),
|
||||
baseURL: url,
|
||||
},
|
||||
);
|
||||
return f<{ user: UserResponse; session: SessionResponse }>("/users/@me", {
|
||||
headers: getAuthHeaders(token),
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editUser(
|
||||
@@ -97,13 +93,13 @@ export async function editUser(
|
||||
account: AccountWithToken,
|
||||
object: UserEdit,
|
||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
||||
return f<{ user: UserResponse; session: SessionResponse }>(
|
||||
`/users/${account.userId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(account.token),
|
||||
body: object,
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -112,22 +108,22 @@ export async function deleteUser(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
): Promise<UserResponse> {
|
||||
return ofetch<UserResponse>(`/users/${account.userId}`, {
|
||||
return f<UserResponse>(`/users/${account.userId}`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBookmarks(url: string, account: AccountWithToken) {
|
||||
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
||||
return f<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProgress(url: string, account: AccountWithToken) {
|
||||
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
|
||||
return f<ProgressResponse[]>(`/users/${account.userId}/progress`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
baseUrl: url,
|
||||
});
|
||||
}
|
||||
|
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
||||
expo-build-properties:
|
||||
specifier: ~0.11.1
|
||||
version: 0.11.1(expo@50.0.14)
|
||||
expo-clipboard:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(expo@50.0.14)
|
||||
expo-constants:
|
||||
specifier: ~15.4.5
|
||||
version: 15.4.5(expo@50.0.14)
|
||||
@@ -197,6 +200,9 @@ importers:
|
||||
tamagui:
|
||||
specifier: ^1.94.0
|
||||
version: 1.94.0(@types/react@18.2.52)(immer@10.0.3)(react-dom@18.2.0)(react-native-web@0.19.10)(react-native@0.73.6)(react@18.2.0)
|
||||
text-encoding-polyfill:
|
||||
specifier: ^0.6.7
|
||||
version: 0.6.7
|
||||
zustand:
|
||||
specifier: ^4.4.7
|
||||
version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0)
|
||||
@@ -252,9 +258,6 @@ importers:
|
||||
node-forge:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
ofetch:
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
devDependencies:
|
||||
'@movie-web/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -7115,10 +7118,6 @@ packages:
|
||||
minimalistic-assert: 1.0.1
|
||||
dev: false
|
||||
|
||||
/destr@2.0.3:
|
||||
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
|
||||
dev: false
|
||||
|
||||
/destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
@@ -7864,6 +7863,14 @@ packages:
|
||||
semver: 7.5.4
|
||||
dev: false
|
||||
|
||||
/expo-clipboard@5.0.1(expo@50.0.14):
|
||||
resolution: {integrity: sha512-JH853QJPr5W3h87If3aDTnMK+ESSIrwzU2TdfZrqZttVDY2pMIf/w37mVHHNYodXM4ATHXadtOkjKbAa0DWwUg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
dependencies:
|
||||
expo: 50.0.14(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
|
||||
dev: false
|
||||
|
||||
/expo-constants@15.4.5(expo@50.0.14):
|
||||
resolution: {integrity: sha512-1pVVjwk733hbbIjtQcvUFCme540v4gFemdNlaxM2UXKbfRCOh2hzgKN5joHMOysoXQe736TTUrRj7UaZI5Yyhg==}
|
||||
peerDependencies:
|
||||
@@ -10545,10 +10552,6 @@ packages:
|
||||
engines: {node: '>=10.5.0'}
|
||||
dev: false
|
||||
|
||||
/node-fetch-native@1.6.4:
|
||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
||||
dev: false
|
||||
|
||||
/node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -10727,14 +10730,6 @@ packages:
|
||||
es-abstract: 1.22.3
|
||||
dev: false
|
||||
|
||||
/ofetch@1.3.4:
|
||||
resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==}
|
||||
dependencies:
|
||||
destr: 2.0.3
|
||||
node-fetch-native: 1.6.4
|
||||
ufo: 1.5.3
|
||||
dev: false
|
||||
|
||||
/on-finished@2.3.0:
|
||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -13083,6 +13078,10 @@ packages:
|
||||
source-map-support: 0.5.21
|
||||
dev: false
|
||||
|
||||
/text-encoding-polyfill@0.6.7:
|
||||
resolution: {integrity: sha512-/DZ1XJqhbqRkCop6s9ZFu8JrFRwmVuHg4quIRm+ziFkR3N3ec6ck6yBvJ1GYeEQZhLVwRW0rZE+C3SSJpy0RTg==}
|
||||
dev: false
|
||||
|
||||
/text-table@0.2.0:
|
||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||
|
||||
@@ -13382,10 +13381,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||
dev: false
|
||||
|
||||
/ufo@1.5.3:
|
||||
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
||||
dev: false
|
||||
|
||||
/uglify-js@3.17.4:
|
||||
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
Reference in New Issue
Block a user