From eea4eab60b336446cf8a30fdc438c922601f725d Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Fri, 19 Apr 2024 19:19:59 +0200
Subject: [PATCH] feat: api hooks n stuff
---
apps/expo/index.js | 1 +
apps/expo/package.json | 1 +
apps/expo/src/app/(tabs)/movie-web.tsx | 44 +++++-
apps/expo/src/hooks/useAuth.ts | 204 +++++++++++++++++++++++++
apps/expo/src/hooks/useAuthData.ts | 170 +++++++++++++++++++++
apps/expo/src/stores/settings/index.ts | 2 +-
pnpm-lock.yaml | 7 +
7 files changed, 427 insertions(+), 2 deletions(-)
create mode 100644 apps/expo/src/hooks/useAuth.ts
create mode 100644 apps/expo/src/hooks/useAuthData.ts
diff --git a/apps/expo/index.js b/apps/expo/index.js
index a561933..7fb719a 100644
--- a/apps/expo/index.js
+++ b/apps/expo/index.js
@@ -1,3 +1,4 @@
import "expo-router/entry";
import "react-native-gesture-handler";
import "@react-native-anywhere/polyfill-base64";
+import "text-encoding-polyfill";
diff --git a/apps/expo/package.json b/apps/expo/package.json
index cad33e4..3cad57d 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -75,6 +75,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": {
diff --git a/apps/expo/src/app/(tabs)/movie-web.tsx b/apps/expo/src/app/(tabs)/movie-web.tsx
index 0479195..3fccc4b 100644
--- a/apps/expo/src/app/(tabs)/movie-web.tsx
+++ b/apps/expo/src/app/(tabs)/movie-web.tsx
@@ -1,12 +1,53 @@
import { Link } from "expo-router";
-import { H2, H5, Paragraph, View } from "tamagui";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import { H2, H5, Paragraph, useTheme, View } 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";
+import { useAuth } from "~/hooks/useAuth";
import { useAuthStore } from "~/stores/settings";
+function TestButtons() {
+ const theme = useTheme();
+ const { login } = useAuth();
+
+ return (
+
+
+ }
+ onPress={async () => {
+ const passhphrase = "";
+ if (!passhphrase) {
+ alert("Please configure your passphrase");
+ return;
+ }
+
+ const account = await login({
+ mnemonic: passhphrase,
+ userData: {
+ device: "phone",
+ },
+ });
+ console.log(account);
+ }}
+ >
+ test login
+
+
+ );
+}
+
export default function MovieWebScreen() {
const { backendUrl, setBackendUrl } = useAuthStore();
@@ -18,6 +59,7 @@ export default function MovieWebScreen() {
justifyContent: "center",
}}
>
+
diff --git a/apps/expo/src/hooks/useAuth.ts b/apps/expo/src/hooks/useAuth.ts
new file mode 100644
index 0000000..91be5d6
--- /dev/null
+++ b/apps/expo/src/hooks/useAuth.ts
@@ -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 = await 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 = await 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,
+ bookmarks: Record,
+ ) => {
+ 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,
+ };
+}
diff --git a/apps/expo/src/hooks/useAuthData.ts b/apps/expo/src/hooks/useAuthData.ts
new file mode 100644
index 0000000..640f594
--- /dev/null
+++ b/apps/expo/src/hooks/useAuthData.ts
@@ -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,
+ };
+}
diff --git a/apps/expo/src/stores/settings/index.ts b/apps/expo/src/stores/settings/index.ts
index e040003..f9ab623 100644
--- a/apps/expo/src/stores/settings/index.ts
+++ b/apps/expo/src/stores/settings/index.ts
@@ -144,7 +144,7 @@ export const useBookmarkStore = create<
),
);
-interface WatchHistoryItem {
+export interface WatchHistoryItem {
item: ItemData;
media: ScrapeMedia;
positionMillis: number;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b131b8f..b4ebd98 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -197,6 +197,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)
@@ -13083,6 +13086,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==}