From 77481dfc0685bc63447ad3ee543216fe7965dcf0 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Tue, 27 Apr 2021 20:33:22 +0530 Subject: [PATCH] Add MAL tracking logic --- app.json | 3 +- package-lock.json | 35 +++- package.json | 2 + src/components/List.js | 11 +- src/navigation/Router.js | 2 + src/redux/library/library.reducer.js | 31 +-- src/redux/novel/novel.actions.js | 2 +- src/redux/novel/novel.reducer.js | 2 +- src/redux/store.js | 2 + src/redux/tracker/tracker.actions.js | 25 +++ src/redux/tracker/tracker.reducer.js | 46 +++++ src/redux/tracker/tracker.types.js | 5 + src/screens/Library/Library.js | 15 +- src/screens/More/About.js | 4 +- src/screens/More/More.js | 10 + src/screens/More/Trackers.js | 132 +++++++++++++ src/screens/Novel/Novel.js | 39 +++- src/screens/Novel/components/NovelHeader.js | 9 +- .../Novel/components/TrackerBottomSheet.js | 102 ++++++++++ .../Novel/components/TrackerSearchModal.js | 186 ++++++++++++++++++ src/trackers/MyAnimeList.js | 99 ++++++++++ 21 files changed, 733 insertions(+), 29 deletions(-) create mode 100644 src/redux/tracker/tracker.actions.js create mode 100644 src/redux/tracker/tracker.reducer.js create mode 100644 src/redux/tracker/tracker.types.js create mode 100644 src/screens/More/Trackers.js create mode 100644 src/screens/Novel/components/TrackerBottomSheet.js create mode 100644 src/screens/Novel/components/TrackerSearchModal.js create mode 100644 src/trackers/MyAnimeList.js diff --git a/app.json b/app.json index 538b978a..c4eca1cb 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,8 @@ "expo": { "name": "LNReader", "slug": "LNReader", - "version": "1.0.0", + "scheme": "lnreader", + "version": "1.0.14", "orientation": "portrait", "icon": "./assets/icon.png", "splash": { diff --git a/package-lock.json b/package-lock.json index 4d13ffeb..83502a46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6037,6 +6037,11 @@ "is-wsl": "^1.1.0" } }, + "opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==" + }, "options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", @@ -6354,9 +6359,24 @@ } }, "qs": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", - "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "requires": { + "side-channel": "^1.0.4" + }, + "dependencies": { + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + } + } }, "query-string": { "version": "6.13.7", @@ -6801,6 +6821,15 @@ "prop-types": "^15.7.2" } }, + "react-native-inappbrowser-reborn": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/react-native-inappbrowser-reborn/-/react-native-inappbrowser-reborn-3.5.1.tgz", + "integrity": "sha512-pEM9DSvW3LYoI7x1sfr4GtTA1/BSzSoQvHeNRtbJwOpujgRBqnEQrapBwC/24l2pIRhKDWmXGfqc12478IXY3Q==", + "requires": { + "invariant": "^2.2.4", + "opencollective-postinstall": "^2.0.2" + } + }, "react-native-iphone-x-helper": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz", diff --git a/package.json b/package.json index 180753fc..23881ee9 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,13 @@ "expo-status-bar": "~1.0.3", "expo-web-browser": "~8.6.0", "moment": "^2.29.1", + "qs": "^6.10.1", "react": "16.13.1", "react-dom": "16.13.1", "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz", "react-native-collapsible-header-views": "^1.1.2", "react-native-gesture-handler": "^1.8.0", + "react-native-inappbrowser-reborn": "^3.5.1", "react-native-offline": "^5.7.0", "react-native-paper": "^4.5.0", "react-native-reanimated": "^1.13.2", diff --git a/src/components/List.js b/src/components/List.js index 308adf38..9705b77b 100644 --- a/src/components/List.js +++ b/src/components/List.js @@ -14,7 +14,7 @@ const ListSubHeader = ({ children, theme }) => ( ); -const ListItem = ({ title, description, icon, onPress, theme }) => ( +const ListItem = ({ title, description, icon, onPress, theme, right }) => ( ( /> ) } + right={() => + right && ( + + ) + } onPress={onPress} rippleColor={theme.rippleColor} /> diff --git a/src/navigation/Router.js b/src/navigation/Router.js index feaf05e8..d06775d5 100644 --- a/src/navigation/Router.js +++ b/src/navigation/Router.js @@ -17,6 +17,7 @@ import Browse from "../screens/Browse/Browse"; import More from "../screens/More/More"; import About from "../screens/More/About"; import Settings from "../screens/More/Settings"; +import Trackers from "../screens/More/Trackers"; import NovelItem from "../screens/Novel/Novel"; import ChapterItem from "../screens/Chapter/Chapter"; @@ -37,6 +38,7 @@ const SettingsStack = () => { + ); }; diff --git a/src/redux/library/library.reducer.js b/src/redux/library/library.reducer.js index 0005b540..2fa5cdb5 100644 --- a/src/redux/library/library.reducer.js +++ b/src/redux/library/library.reducer.js @@ -1,4 +1,8 @@ -import { FOLLOW_NOVEL, UNFOLLOW_NOVEL } from "../novel/novel.types"; +import { + FOLLOW_NOVEL, + UNFOLLOW_NOVEL, + UPDATE_IN_LIBRARY, +} from "../novel/novel.types"; import { GET_LIBRARY_NOVELS, GET_LIBRARY_SEARCH_RESULTS, @@ -17,18 +21,23 @@ const libraryReducer = (state = initialState, action) => { case GET_LIBRARY_NOVELS: return { ...state, novels: payload, loading: false }; case GET_LIBRARY_SEARCH_RESULTS: - return { ...state, searchResults: payload, loading: false }; - case FOLLOW_NOVEL: + return { ...state, novels: payload, loading: false }; + // case FOLLOW_NOVEL: + // return { + // ...state, + // novels: [...state.novels, { ...payload, followed: 1 }], + // }; + // case UNFOLLOW_NOVEL: + // return { + // ...state, + // novels: state.novels.filter( + // (novel) => novel.novelId !== payload + // ), + // }; + case UPDATE_IN_LIBRARY: return { ...state, - novels: [...state.novels, { ...payload, followed: 1 }], - }; - case UNFOLLOW_NOVEL: - return { - ...state, - novels: state.novels.filter( - (novel) => novel.novelId !== payload - ), + novels: [...state.novels, { novelUrl: payload.novelUrl }], }; default: return state; diff --git a/src/redux/novel/novel.actions.js b/src/redux/novel/novel.actions.js index 4eed5d23..e39fbfc4 100644 --- a/src/redux/novel/novel.actions.js +++ b/src/redux/novel/novel.actions.js @@ -106,7 +106,7 @@ export const followNovelAction = (novel) => async (dispatch) => { dispatch({ type: UPDATE_IN_LIBRARY, - payload: !novel.followed, + payload: { novelUrl: novel.novelUrl, followed: !novel.followed }, }); ToastAndroid.show( diff --git a/src/redux/novel/novel.reducer.js b/src/redux/novel/novel.reducer.js index a94174af..fddac33c 100644 --- a/src/redux/novel/novel.reducer.js +++ b/src/redux/novel/novel.reducer.js @@ -53,7 +53,7 @@ const novelReducer = (state = initialState, action) => { case UPDATE_IN_LIBRARY: return { ...state, - novel: { ...state.novel, followed: payload }, + novel: { ...state.novel, followed: payload.followed }, }; case UPDATE_NOVEL: return { diff --git a/src/redux/store.js b/src/redux/store.js index 337dc253..e333afdc 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -10,6 +10,7 @@ import extensionReducer from "./source/source.reducers"; import novelReducer from "./novel/novel.reducer"; import historyReducer from "./history/history.reducer"; import updatesReducer from "./updates/updates.reducer"; +import trackerReducer from "./tracker/tracker.reducer"; const persistConfig = { key: "root", @@ -27,6 +28,7 @@ const persistedReducer = persistReducer( novelReducer, historyReducer, updatesReducer, + trackerReducer, }) ); diff --git a/src/redux/tracker/tracker.actions.js b/src/redux/tracker/tracker.actions.js new file mode 100644 index 00000000..f4b3bcf0 --- /dev/null +++ b/src/redux/tracker/tracker.actions.js @@ -0,0 +1,25 @@ +import { + REMOVE_TRACKER, + SET_TRACKER, + TRACK_NOVEL, + UNTRACK_NOVEL, +} from "./tracker.types"; + +export const setTracker = (res) => async (dispatch) => { + dispatch({ + type: SET_TRACKER, + payload: res, + }); +}; + +export const removeTracker = () => async (dispatch) => { + dispatch({ type: REMOVE_TRACKER }); +}; + +export const trackNovel = (id) => async (dispatch) => { + dispatch({ type: TRACK_NOVEL, payload: id }); +}; + +export const untrackNovel = (id) => async (dispatch) => { + dispatch({ type: UNTRACK_NOVEL, payload: id }); +}; diff --git a/src/redux/tracker/tracker.reducer.js b/src/redux/tracker/tracker.reducer.js new file mode 100644 index 00000000..c2b41b56 --- /dev/null +++ b/src/redux/tracker/tracker.reducer.js @@ -0,0 +1,46 @@ +import { + REMOVE_TRACKER, + SET_TRACKER, + TRACK_NOVEL, + UNTRACK_NOVEL, +} from "./tracker.types"; + +const initialState = { + tracker: null, + trackedNovels: [], +}; + +const trackerReducer = (state = initialState, action) => { + const { type, payload } = action; + + switch (type) { + case SET_TRACKER: + return { + ...state, + tracker: { ...payload, name: "MyAnimeList" }, + }; + case REMOVE_TRACKER: + return { + ...state, + tracker: null, + }; + case TRACK_NOVEL: + // console.log(payload); + return { + ...state, + trackedNovels: [...state.trackedNovels, payload], + }; + + case UNTRACK_NOVEL: + return { + ...state, + trackedNovels: state.trackedNovels.filter( + (id) => id === payload + ), + }; + default: + return state; + } +}; + +export default trackerReducer; diff --git a/src/redux/tracker/tracker.types.js b/src/redux/tracker/tracker.types.js new file mode 100644 index 00000000..4098c8d5 --- /dev/null +++ b/src/redux/tracker/tracker.types.js @@ -0,0 +1,5 @@ +export const SET_TRACKER = "SET_TRACKER"; +export const REMOVE_TRACKER = "REMOVE_TRACKER"; + +export const TRACK_NOVEL = "TRACK_NOVEL"; +export const UNTRACK_NOVEL = "UNTRACK_NOVEL"; diff --git a/src/screens/Library/Library.js b/src/screens/Library/Library.js index 08d88bfe..02f87d47 100644 --- a/src/screens/Library/Library.js +++ b/src/screens/Library/Library.js @@ -30,16 +30,19 @@ const LibraryScreen = ({ getLibraryAction, searchLibraryAction, setNovel, - searchResults, + // searchResults, itemsPerRow, }) => { const [refreshing, setRefreshing] = useState(false); const [searchText, setSearchText] = useState(""); - useEffect(() => { - getLibraryAction(); - }, [getLibraryAction, novels]); + useFocusEffect( + useCallback(() => { + setSearchText(""); + getLibraryAction(); + }, [getLibraryAction]) + ); /** * TODO: fix refreshing @@ -85,7 +88,7 @@ const LibraryScreen = ({ }} numColumns={itemsPerRow} key={itemsPerRow} - data={!searchText ? novels : searchResults} + data={novels} keyExtractor={(item) => item.novelUrl} renderItem={({ item }) => ( ({ theme: state.themeReducer.theme, itemsPerRow: state.settingsReducer.itemsPerRow, novels: state.libraryReducer.novels, - searchResults: state.libraryReducer.searchResults, + // searchResults: state.libraryReducer.searchResults, loading: state.libraryReducer.loading, }); diff --git a/src/screens/More/About.js b/src/screens/More/About.js index d3d9f4a1..130dd48b 100644 --- a/src/screens/More/About.js +++ b/src/screens/More/About.js @@ -17,12 +17,12 @@ const AboutScreen = ({ navigation }) => { { } theme={theme} /> + + navigation.navigate("SettingsStack", { + screen: "Trackers", + }) + } + theme={theme} + /> { + const theme = useSelector((state) => state.themeReducer.theme); + const tracker = useSelector((state) => state.trackerReducer.tracker); + const dispatch = useDispatch(); + + // Tracker Modal + const [visible, setVisible] = useState(false); + const showModal = () => setVisible(true); + const hideModal = () => setVisible(false); + + return ( + + navigation.goBack()} /> + + + Services + { + if (tracker) { + showModal(); + } else { + await WebBrowser.openAuthSessionAsync( + myAnimeListConfig.authUrl, + myAnimeListConfig.redirectUri + ).then((res) => { + console.log(res.type); + if (res.type === "success") { + const { url } = res; + + const codeExtractor = new RegExp( + /=([^&]+)/ + ); + let code = url.match(codeExtractor); + + console.log(code); + + if (code) { + code = code[1]; + getAccessToken( + code, + myAnimeListConfig.codeChallenge + ).then((objects) => { + console.log(objects); + dispatch(setTracker(objects)); + }); + } + } + }); + } + }} + right={tracker && "check"} + theme={theme} + /> + + + + + Log out from MyAnimeList? + + + + + + + + + + ); +}; + +export default TrackerScreen; diff --git a/src/screens/Novel/Novel.js b/src/screens/Novel/Novel.js index f0ee20db..4a3fe396 100644 --- a/src/screens/Novel/Novel.js +++ b/src/screens/Novel/Novel.js @@ -3,16 +3,23 @@ import { StyleSheet, View, FlatList, + Text, RefreshControl, ToastAndroid, } from "react-native"; -import { Provider, Portal } from "react-native-paper"; +import { + Provider, + Portal, + Modal, + TextInput, + IconButton, +} from "react-native-paper"; import ChapterCard from "./components/ChapterCard"; import NovelInfoHeader from "./components/NovelHeader"; import { BottomSheet } from "./components/BottomSheet"; -import { connect } from "react-redux"; +import { connect, useSelector } from "react-redux"; import { getNovelAction, @@ -22,6 +29,8 @@ import { downloadAllChaptersAction, deleteAllChaptersAction, } from "../../redux/novel/novel.actions"; +import { TrackerBottomSheet } from "./components/TrackerBottomSheet"; +import TrackerSearchModal from "./components/TrackerSearchModal"; const Novel = ({ route, @@ -48,9 +57,15 @@ const Novel = ({ let _panel = useRef(null); // Bottomsheet ref + let trackerBottomsheet = useRef(null); // Bottomsheet ref + const [sort, setSort] = useState("ORDER BY chapterId ASC"); const [filter, setFilter] = useState(""); + const trackedNovels = useSelector( + (state) => state.trackerReducer.trackedNovels + ); + useEffect(() => { getNovelAction(followed, sourceId, novelUrl, novelId, sort, filter); }, [getNovelAction, followNovelAction, sort, filter]); @@ -70,6 +85,13 @@ const Novel = ({ ToastAndroid.show(`Updated ${novelName}`, ToastAndroid.SHORT); }; + // Tracker Modal + const [visible, setVisible] = useState(false); + const showModal = () => { + setVisible(true); + }; + const hideModal = () => setVisible(false); + return ( downloadAllChaptersAction( sourceId, @@ -127,6 +150,18 @@ const Novel = ({ setSort={setSort} setFilter={setFilter} /> + + diff --git a/src/screens/Novel/components/NovelHeader.js b/src/screens/Novel/components/NovelHeader.js index 6603be7b..f8868f24 100644 --- a/src/screens/Novel/components/NovelHeader.js +++ b/src/screens/Novel/components/NovelHeader.js @@ -31,6 +31,7 @@ const NovelInfoHeader = ({ theme, lastRead, downloadAllChapters, + trackerBottomsheetRef, deleteAllChapters, }) => { const navigation = useNavigation(); @@ -161,6 +162,7 @@ const NovelInfoHeader = ({ color={theme.colorAccentDark} size={21} /> + Share.share({ message: novel.sourceUrl }) @@ -169,7 +171,12 @@ const NovelInfoHeader = ({ color={theme.colorAccentDark} size={21} /> - + trackerBottomsheetRef.current.show()} + icon="sync" + color={theme.colorAccentDark} + size={21} + /> { + const theme = useSelector((state) => state.themeReducer.theme); + const tracker = useSelector((state) => state.trackerReducer.tracker); + const trackedNovels = useSelector( + (state) => state.trackerReducer.trackedNovels + ); + + const [myListStatus, setMyListStatus] = useState(); + + const isNovelTracked = async (id) => { + let trackingStatus = + trackedNovels && trackedNovels.find((obj) => obj.novelId === id); + if (trackingStatus) { + let res = await findListItem( + trackingStatus.malId, + tracker.access_token + ); + setMyListStatus(res); + console.log(res); + } + }; + + useEffect(() => { + isNovelTracked(novelId); + }, []); + + return ( + + + {!myListStatus ? ( + + + + Add Tracking + + + ) : ( + {myListStatus.title} + )} + + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { + flex: 1, + // borderTopRightRadius: 8, + // borderTopLeftRadius: 8, + }, + bottomSheetHandle: { + backgroundColor: "rgba(255,255,255,0.25)", + height: 4, + width: 50, + borderRadius: 50, + top: 10, + alignSelf: "center", + }, +}); diff --git a/src/screens/Novel/components/TrackerSearchModal.js b/src/screens/Novel/components/TrackerSearchModal.js new file mode 100644 index 00000000..ecc2db1c --- /dev/null +++ b/src/screens/Novel/components/TrackerSearchModal.js @@ -0,0 +1,186 @@ +import React, { useEffect, useState, useRef } from "react"; +import { StyleSheet, Text, View, Image, ActivityIndicator } from "react-native"; +import { Button, Modal, TextInput, TouchableRipple } from "react-native-paper"; +import { searchNovels } from "../../../trackers/MyAnimeList"; +import { useSelector, useDispatch } from "react-redux"; +import { ScrollView } from "react-native-gesture-handler"; +import { trackNovel } from "../../../redux/tracker/tracker.actions"; + +const TrackerSearchModal = ({ + visible, + hideModal, + novelId, + novelName, + theme, +}) => { + const [loading, setLoading] = useState(true); + const [searchResults, setSearchResults] = useState([]); + const [searchText, setSearchText] = useState(novelName); + + const [selectedNovel, setSelectedNovel] = useState(); + + const tracker = useSelector((state) => state.trackerReducer.tracker); + + const textInputRef = useRef(null); + + const dispatch = useDispatch(); + + const getSearchresults = async () => { + const res = await searchNovels(searchText, tracker.access_token); + setSearchResults(res); + setLoading(false); + }; + + useEffect(() => { + visible && getSearchresults(); + }, [visible]); + + const renderSearcResultCard = (item) => ( + setSelectedNovel(item.node.id)} + rippleColor={theme.rippleColor} + borderless + > + <> + + + {item.node.title} + + + + ); + + return ( + + setSearchText(text)} + onSubmitEditing={getSearchresults} + theme={{ + colors: { + primary: theme.colorAccentDark, + text: theme.textColorPrimary, + background: "transparent", + }, + }} + underlineColor={theme.textColorHintDark} + dense + right={ + setSearchText("")} + /> + } + /> + + {loading ? ( + + ) : ( + searchResults.data.map((result) => + renderSearcResultCard(result) + ) + )} + + + + + + + + + + ); +}; + +export default TrackerSearchModal; + +const styles = StyleSheet.create({ + containerStyle: { + padding: 20, + margin: 20, + borderRadius: 6, + }, +}); diff --git a/src/trackers/MyAnimeList.js b/src/trackers/MyAnimeList.js new file mode 100644 index 00000000..6561c8bf --- /dev/null +++ b/src/trackers/MyAnimeList.js @@ -0,0 +1,99 @@ +import * as Linking from "expo-linking"; +import qs from "qs"; + +const clientId = "d2f499825d64b0213bb77e78193ccbb1"; + +const baseOAuthUrl = "https://myanimelist.net/v1/oauth2/authorize"; +const baseApiUrl = "https://api.myanimelist.net/v2"; + +const pkceChallenger = () => { + const MAX_LENGTH = 88; + let code = ""; + + const codes = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + const randomPicker = () => Math.floor(Math.random() * codes.length); + + for (let index = 0; index < MAX_LENGTH; index++) { + code += codes.charAt(randomPicker()); + } + return code; +}; + +const codeChallenge = pkceChallenger(); + +const redirectUri = Linking.makeUrl(); + +export const authUrl = `${baseOAuthUrl}?response_type=code&client_id=${clientId}&code_challenge_method=plain&code_challenge=${codeChallenge}`; + +const tokenUrl = `https://myanimelist.net/v1/oauth2/token`; + +export const myAnimeListConfig = { + clientId, + redirectUri, + authUrl, + codeChallenge, + tokenUrl, +}; + +export const getAccessToken = async (code, codeChallenge) => { + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + }; + + const body = { + client_id: myAnimeListConfig.clientId, + grant_type: "authorization_code", + code, + code_verifier: codeChallenge, + }; + + const response = await fetch(myAnimeListConfig.tokenUrl, { + headers, + method: "POST", + body: qs.stringify(body), + }); + + const tokenResponse = await response.json(); + + return tokenResponse; +}; + +export const searchNovels = async (novelName, bearerToken) => { + const searchrl = `https://api.myanimelist.net/v2/manga?q=${novelName}`; + + const headers = { + Authorization: "Bearer " + bearerToken, + "Content-Type": "application/x-www-form-urlencoded", + }; + + const res = await fetch(searchrl, { method: "GET", headers }); + + const searchResults = await res.json(); + + return searchResults; +}; + +export const findListItem = async (malId, bearerToken) => { + const url = `https://api.myanimelist.net/v2/manga/${malId}?fields=id,num_chapters,my_list_status{start_date,finish_date}`; + + const headers = { + Authorization: "Bearer " + bearerToken, + "Content-Type": "application/x-www-form-urlencoded", + }; + + const res = await fetch(url, { method: "GET", headers }); + const listItem = await res.json(); + + if (!listItem.my_list_status) { + myListStatus = { + status: "reading", + num_chapters_read: 0, + }; + + listItem.my_list_status = myListStatus; + } + + return listItem; +};