mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 14:33:26 +00:00
feat: api hooks n stuff
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
import "expo-router/entry";
|
import "expo-router/entry";
|
||||||
import "react-native-gesture-handler";
|
import "react-native-gesture-handler";
|
||||||
import "@react-native-anywhere/polyfill-base64";
|
import "@react-native-anywhere/polyfill-base64";
|
||||||
|
import "text-encoding-polyfill";
|
||||||
|
@@ -75,6 +75,7 @@
|
|||||||
"react-native-web": "^0.19.10",
|
"react-native-web": "^0.19.10",
|
||||||
"subsrt-ts": "^2.1.2",
|
"subsrt-ts": "^2.1.2",
|
||||||
"tamagui": "^1.94.0",
|
"tamagui": "^1.94.0",
|
||||||
|
"text-encoding-polyfill": "^0.6.7",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -1,12 +1,53 @@
|
|||||||
import { Link } from "expo-router";
|
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 ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { MWButton } from "~/components/ui/Button";
|
import { MWButton } from "~/components/ui/Button";
|
||||||
import { MWCard } from "~/components/ui/Card";
|
import { MWCard } from "~/components/ui/Card";
|
||||||
import { MWInput } from "~/components/ui/Input";
|
import { MWInput } from "~/components/ui/Input";
|
||||||
|
import { useAuth } from "~/hooks/useAuth";
|
||||||
import { useAuthStore } from "~/stores/settings";
|
import { useAuthStore } from "~/stores/settings";
|
||||||
|
|
||||||
|
function TestButtons() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<MWButton
|
||||||
|
type="secondary"
|
||||||
|
backgroundColor="$sheetItemBackground"
|
||||||
|
marginBottom="$4"
|
||||||
|
icon={
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="login"
|
||||||
|
size={24}
|
||||||
|
color={theme.buttonSecondaryText.val}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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
|
||||||
|
</MWButton>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MovieWebScreen() {
|
export default function MovieWebScreen() {
|
||||||
const { backendUrl, setBackendUrl } = useAuthStore();
|
const { backendUrl, setBackendUrl } = useAuthStore();
|
||||||
|
|
||||||
@@ -18,6 +59,7 @@ export default function MovieWebScreen() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<TestButtons />
|
||||||
<MWCard bordered>
|
<MWCard bordered>
|
||||||
<MWCard.Header padded>
|
<MWCard.Header padded>
|
||||||
<H2 fontWeight="$bold" paddingBottom="$1">
|
<H2 fontWeight="$bold" paddingBottom="$1">
|
||||||
|
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 = 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<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;
|
item: ItemData;
|
||||||
media: ScrapeMedia;
|
media: ScrapeMedia;
|
||||||
positionMillis: number;
|
positionMillis: number;
|
||||||
|
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@@ -197,6 +197,9 @@ importers:
|
|||||||
tamagui:
|
tamagui:
|
||||||
specifier: ^1.94.0
|
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)
|
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:
|
zustand:
|
||||||
specifier: ^4.4.7
|
specifier: ^4.4.7
|
||||||
version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0)
|
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
|
source-map-support: 0.5.21
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/text-encoding-polyfill@0.6.7:
|
||||||
|
resolution: {integrity: sha512-/DZ1XJqhbqRkCop6s9ZFu8JrFRwmVuHg4quIRm+ziFkR3N3ec6ck6yBvJ1GYeEQZhLVwRW0rZE+C3SSJpy0RTg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/text-table@0.2.0:
|
/text-table@0.2.0:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user