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==}