From 20700c8ea0319922653da6b47f3e1d8a83f72c24 Mon Sep 17 00:00:00 2001 From: luke-h1 Date: Sat, 18 Nov 2023 21:16:48 +0000 Subject: [PATCH] feat(app): auth --- App.tsx | 1 + api.json | 2 + src/components/StreamItem.tsx | 115 ++++++++-------- src/components/StreamList.tsx | 20 ++- src/context/AuthContext.tsx | 145 +++++++++++++++++++-- src/screens/FollowingScreen.tsx | 45 ++++++- src/screens/TopScreen.tsx | 13 +- src/screens/authentication/LoginScreen.tsx | 30 +++-- src/services/twitchService.ts | 80 +++++++----- src/utils/getTokens.ts | 11 ++ user.json | 125 ------------------ 11 files changed, 329 insertions(+), 258 deletions(-) create mode 100644 api.json create mode 100644 src/utils/getTokens.ts delete mode 100644 user.json diff --git a/App.tsx b/App.tsx index 4184d406..b1e3b545 100644 --- a/App.tsx +++ b/App.tsx @@ -15,6 +15,7 @@ export default function App() { LogBox.ignoreAllLogs(); activateKeepAwakeAsync(); + // AsyncStorage.clear().then(() => console.log('AsyncStorage cleared')).catch(e => console.error(e)) } return ( diff --git a/api.json b/api.json new file mode 100644 index 00000000..1f570e76 --- /dev/null +++ b/api.json @@ -0,0 +1,2 @@ +"data": [{"game_id": "687129551", "game_name": "Trackmania", "id": "49736973229", "is_mature": false, "language": "en", "started_at": "2023-11-18T20:29:47Z", "tag_ids": [Array], "tags": [Array], "thumbnail_url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_summit1g-{width}x{height}.jpg", "title": "Trackmania Twitch Rivals tourney w/ Wirtual - @summit1g", "type": "live", "user_id": "26490481", "user_login": "summit1g", "user_name": "summit1g", "viewer_count": 6819}, {"game_id": "509658", "game_name": "Just Chatting", "id": "41421803048", "is_mature": true, "language": "en", "started_at": "2023-11-18T20:31:38Z", "tag_ids": [Array], "tags": [Array], "thumbnail_url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_zoil-{width}x{height}.jpg", "title": "Lethal Company and Movie Night today. surely. the ants go marching one by one hoorah. hoorah.", "type": "live", "user_id": "95304188", "user_login": "zoil", "user_name": "Zoil", "viewer_count": 1921}], "pagination": {}} + LOG {"data": [{"game_id": "687129551", "game_name": "Trackmania", "id": "49736973229", "is_mature": false, "language": "en", "started_at": "2023-11-18T20:29:47Z", "tag_ids": [Array], "tags": [Array], "thumbnail_url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_summit1g-{width}x{height}.jpg", "title": "Trackmania Twitch Rivals tourney w/ Wirtual - @summit1g", "type": "live", "user_id": "26490481", "user_login": "summit1g", "user_name": "summit1g", "viewer_count": 6819}, {"game_id": "509658", "game_name": "Just Chatting", "id": "41421803048", "is_mature": true, "language": "en", "started_at": "2023-11-18T20:31:38Z", "tag_ids": [Array], "tags": [Array], "thumbnail_url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_zoil-{width}x{height}.jpg", "title": "Lethal Company and Movie Night today. surely. the ants go marching one by one hoorah. hoorah.", "type": "live", "user_id": "95304188", "user_login": "zoil", "user_name": "Zoil", "viewer_count": 1921}], "pagination": {}} \ No newline at end of file diff --git a/src/components/StreamItem.tsx b/src/components/StreamItem.tsx index f2fb45da..f8fbdef1 100644 --- a/src/components/StreamItem.tsx +++ b/src/components/StreamItem.tsx @@ -1,7 +1,9 @@ -import { Entypo } from '@expo/vector-icons'; -import { Image, StyleSheet, Text, View } from 'react-native'; +import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; import { Stream } from '../services/twitchService'; +import { Entypo } from '@expo/vector-icons'; import colors from '../styles/colors'; +import { FontAwesome } from '@expo/vector-icons'; +import viewFormatter from '../utils/viewFormatter'; interface Props { stream: Stream; @@ -13,104 +15,109 @@ const StreamItem = ({ stream }: Props) => { - - - + - + {stream.user_name} - {stream.game_name} - - {stream.tags.map((tag, index) => ( - // eslint-disable-next-line react/no-array-index-key - - {tag} - - ))} - + + {stream.title} + {stream.game_name} + + + ( + + {item} + + )} + keyExtractor={item => item} + horizontal + /> ); }; - export default StreamItem; -const styles = StyleSheet.create({ +export const styles = StyleSheet.create({ channelBox: { - display: 'flex', - flex: 1, - flexDirection: 'column', + width: '100%', + flexDirection: 'row', alignItems: 'center', marginBottom: 20, + cursor: 'pointer', zIndex: 2, + color: colors.gray, }, liveScreen: { position: 'relative', width: '35%', - minWidth: 100, - minHeight: 100, - }, - icon: { - position: 'absolute', - bottom: 2, - left: 5, + minWidth: 150, + minHeight: 50, + backgroundColor: 'black', + borderRadius: 3, + overflow: 'hidden', + zIndex: -1, color: colors.gray, - fontSize: 12, - display: 'flex', - alignItems: 'center', - textShadowColor: colors.black, - textShadowRadius: 2, - textShadowOffset: { width: 1, height: 1 }, - gap: 3, }, + liveIcon: { color: colors.red, fontSize: 9, }, liveInfo: { flex: 1, - paddingLeft: 10, - paddingRight: 10, + paddingHorizontal: 10, + color: colors.gray, }, user: { - display: 'flex', + flexDirection: 'row', + color: colors.gray, }, - userProfilePic: { + userPp: { width: 15, height: 15, - borderRadius: 50, + borderRadius: 999, overflow: 'hidden', }, userName: { - paddingLeft: 5, - }, - gameTitle: { + // paddingLeft: 5, + fontWeight: '600', color: colors.gray, + }, + titleGame: { fontSize: 14, + color: colors.gray, }, - tags: { - display: 'flex', - fontSize: 13, + tagRow: { + marginTop: 17, + flexDirection: 'row', }, tag: { - paddingTop: 2, - paddingBottom: 2, - paddingLeft: 5, - paddingRight: 5, + backgroundColor: colors.tag, + paddingVertical: 2, + paddingHorizontal: 8, + borderRadius: 10, + marginRight: 10, + }, + tagText: { + color: colors.black, + fontSize: 13, }, }); diff --git a/src/components/StreamList.tsx b/src/components/StreamList.tsx index 9733e036..c4514af0 100644 --- a/src/components/StreamList.tsx +++ b/src/components/StreamList.tsx @@ -1,13 +1,19 @@ -import { StyleSheet, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; +import { Stream } from '../services/twitchService'; +import StreamListItem from './StreamListItem'; -const StreamList = () => { +interface Props { + streams: Stream[]; +} + +const StreamList = ({ streams }: Props) => { return ( - {/* - - - - */} + } + keyExtractor={item => item.id} + /> ); }; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index a0102777..ef00f1f1 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ +/* eslint-disable no-console */ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { TokenResponse } from 'expo-auth-session'; +import { AuthSessionResult, TokenResponse } from 'expo-auth-session'; import React, { createContext, ReactNode, @@ -8,10 +9,21 @@ import React, { useMemo, useState, } from 'react'; -import twitchService from '../services/twitchService'; +import { twitchApi } from '../services/Client'; +import twitchService, { UserInfoResponse } from '../services/twitchService'; + +const StorageKeys = { + anonToken: 'anonToken', + authToken: 'authToken', +} as const; + +type StorageKey = keyof typeof StorageKeys; interface AuthContextState { auth?: Auth; + user?: UserInfoResponse; + login: (response: AuthSessionResult | null) => Promise; + getToken: (key: StorageKey) => Promise; } export const AuthContext = createContext( @@ -37,6 +49,58 @@ export const AuthContextProvider = ({ children }: Props) => { const [state, setState] = useState({ ready: false, }); + const [user, setUser] = useState(); + const [authToken, setAuthToken] = useState(); + const [anonToken, setAnonToken] = useState(); + + const isValidToken = async (token: string | null) => { + if (!token) { + return false; + } + return twitchService.validateToken(token); + }; + + useEffect(() => { + const getTokens = async () => { + // eslint-disable-next-line no-shadow + const anonToken = await AsyncStorage.getItem(StorageKeys.anonToken); + // eslint-disable-next-line no-shadow + const authToken = await AsyncStorage.getItem(StorageKeys.authToken); + + if (!authToken && !isValidToken(authToken) && anonToken) { + setAnonToken(JSON.stringify(anonToken)); + setState({ + ready: true, + auth: { + anonToken, + isAnonAuth: true, + isAuth: false, + }, + }); + + twitchApi.defaults.headers.common.Authorization = `Bearer ${anonToken}`; + } + + if (authToken && await isValidToken(authToken)) { + setAuthToken(authToken as unknown as TokenResponse); + setState({ + ready: true, + auth: { + token: authToken as unknown as TokenResponse, + isAnonAuth: false, + isAuth: true, + }, + }); + + const userInfo = await twitchService.getUserInfo(authToken); + + setUser(userInfo); + twitchApi.defaults.headers.common.Authorization = `Bearer ${authToken}`; + } + }; + + getTokens(); + }, [authToken, anonToken]); const getAnonToken = async () => { const res = await twitchService.getDefaultToken(); @@ -49,7 +113,8 @@ export const AuthContextProvider = ({ children }: Props) => { }, }); - AsyncStorage.setItem('anonToken', res.access_token); + AsyncStorage.setItem(StorageKeys.anonToken, res.access_token); + twitchApi.defaults.headers.common.Authorization = `Bearer ${res.access_token}`; }; const validateToken = async () => { @@ -86,17 +151,20 @@ export const AuthContextProvider = ({ children }: Props) => { auth: { isAuth: true, isAnonAuth: false, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error token: refreshedToken, }, }); + console.log('refreshing token') + setUser(await twitchService.getUserInfo(refreshedToken.accessToken)); - AsyncStorage.setItem('token', JSON.stringify(refreshedToken)); + AsyncStorage.setItem( + StorageKeys.authToken, + refreshedToken, + ); + twitchApi.defaults.headers.common.Authorization = `Bearer ${state.auth.token.accessToken}`; } // token is valid and not expired - setState({ ready: true, auth: { @@ -106,16 +174,68 @@ export const AuthContextProvider = ({ children }: Props) => { }, }); - AsyncStorage.setItem('token', JSON.stringify(state.auth.token)); + setUser(await twitchService.getUserInfo(state.auth.token.accessToken)); + + AsyncStorage.setItem( + StorageKeys.authToken, + state.auth.token.accessToken, + ); + setAuthToken(state.auth.token); + twitchApi.defaults.headers.common.Authorization = `Bearer ${state.auth.token.accessToken}`; + }; + + const getToken = async (key: StorageKey) => { + return AsyncStorage.getItem(key); + }; + + const login = async (response: AuthSessionResult | null) => { + console.log('[authcontext] login'); + if (response?.type !== 'success') { + return null; + } + + if (!response.authentication) { + return null; + } + + setState({ + ready: true, + auth: { + isAuth: true, + isAnonAuth: false, + token: response.authentication, + }, + }); + + setUser( + await twitchService.getUserInfo(response.authentication.accessToken), + ); + + twitchApi.defaults.headers.common.Authorization = `Bearer ${response.authentication.accessToken}`; + + AsyncStorage.setItem( + StorageKeys.authToken, + JSON.stringify(response.authentication), + ); + + return null; }; useEffect(() => { if (!state.auth?.token) { console.info('no token, getting anon token'); - getAnonToken(); + const runAnonToken = async () => { + await getAnonToken(); + }; + + runAnonToken(); } else { console.info('found token, validating'); - validateToken(); + + const runValidateToken = async () => { + await validateToken(); + }; + runValidateToken(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -123,9 +243,12 @@ export const AuthContextProvider = ({ children }: Props) => { const contextState: AuthContextState = useMemo(() => { return { auth: state.auth, + user, + login, + getToken, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.auth]); + }, [state.auth, user]); return state.ready ? ( {children} diff --git a/src/screens/FollowingScreen.tsx b/src/screens/FollowingScreen.tsx index bbdf8549..b90a2311 100644 --- a/src/screens/FollowingScreen.tsx +++ b/src/screens/FollowingScreen.tsx @@ -2,22 +2,25 @@ import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import { CompositeScreenProps } from '@react-navigation/native'; import Constants from 'expo-constants'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { FlatList, SafeAreaView, StyleSheet, View, Platform, -} from 'react-native'; + Text, + Button } from 'react-native'; import Header from '../components/Header'; import StreamList from '../components/StreamList'; import Title from '../components/Title'; +import { useAuthContext } from '../context/AuthContext'; import { HomeTabsParamList, HomeTabsRoutes, HomeTabsScreenProps, } from '../navigation/Home/HomeTabs'; +import twitchService, { Stream } from '../services/twitchService'; import colors from '../styles/colors'; export interface Section { @@ -32,16 +35,32 @@ const FollowingScreen = ({ HomeTabsScreenProps, BottomTabScreenProps >) => { - const run = async () => {}; + const { user, auth, getToken } = useAuthContext(); + const [debugToken, setDebugToken] = useState(''); + const [streams, setStreams] = useState([]); + + + const fetchFollowedStreams = async () => { + try { + console.log('user is', user) + console.log('user id', user?.id) + const res = await twitchService.getFollowedStreams(user?.id as string); + console.log(res) + setStreams(res.data); + } catch (e) { + console.error(e) + } + }; useEffect(() => { - run(); + fetchFollowedStreams(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { data, stickyHeaderIndicies } = useMemo(() => { // eslint-disable-next-line no-shadow const data: Section[] = [ - { key: 'C2', render: () => }, + { key: 'C2', render: () => }, // { // key: 'FOLLOWED_CATEGORIES', @@ -86,7 +105,7 @@ const FollowingScreen = ({ data, stickyHeaderIndicies, }; - }, []); + }, [streams]); return ( @@ -104,6 +123,20 @@ const FollowingScreen = ({ /> + +