feat: api hooks n stuff

This commit is contained in:
Adrian Castro
2024-04-19 19:19:59 +02:00
parent 3fb2567ae1
commit eea4eab60b
7 changed files with 427 additions and 2 deletions

View File

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

View File

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

View File

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

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

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

View File

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

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