diff --git a/app/soapbox/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts deleted file mode 100644 index 44e4ff0c0..000000000 --- a/app/soapbox/actions/__tests__/carousels.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { __stub } from 'soapbox/api'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { fetchCarouselAvatars } from '../carousels'; - -describe('fetchCarouselAvatars()', () => { - let store: ReturnType; - - beforeEach(() => { - store = mockStore(rootState); - }); - - describe('with a successful API request', () => { - let avatars: Record[]; - - beforeEach(() => { - avatars = [ - { account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' }, - ]; - - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars); - }); - }); - - it('should fetch the users from the API', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_FAIL' }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index d3252e7fb..53209464e 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -202,9 +202,7 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => { - dispatch(verifyCredentials(token, accountUrl)); - }) + .then(() => dispatch(verifyCredentials(token, accountUrl))) .catch(() => dispatch(verifyCredentials(token, accountUrl))); /** Trim the username and strip the leading @. */ diff --git a/app/soapbox/actions/carousels.ts b/app/soapbox/actions/carousels.ts deleted file mode 100644 index 7935536c4..000000000 --- a/app/soapbox/actions/carousels.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AxiosResponse } from 'axios'; - -import { AppDispatch, RootState } from 'soapbox/store'; - -import api from '../api'; - -const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST'; -const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS'; -const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL'; - -const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: CAROUSEL_AVATAR_REQUEST }); - - return api(getState) - .get('/api/v1/truth/carousels/avatars') - .then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data })) - .catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL })); -}; - -export { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, - fetchCarouselAvatars, -}; diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 220e22af4..3de52e7a3 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -17,6 +17,7 @@ import * as BuildConfig from 'soapbox/build_config'; import GdprBanner from 'soapbox/components/gdpr-banner'; import Helmet from 'soapbox/components/helmet'; import LoadingScreen from 'soapbox/components/loading-screen'; +import { AuthProvider, useAuth } from 'soapbox/contexts/auth-context'; import AuthLayout from 'soapbox/features/auth_layout'; import PublicLayout from 'soapbox/features/public_layout'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; @@ -38,7 +39,7 @@ import { useLocale, } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; -import { queryClient } from 'soapbox/queries/client'; +import { queryClient, useAxiosInterceptors } from 'soapbox/queries/client'; import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { generateThemeCss } from 'soapbox/utils/theme'; @@ -62,7 +63,7 @@ const loadInitial = () => { // @ts-ignore return async(dispatch, getState) => { // Await for authenticated fetch - await dispatch(fetchMe()); + const account = await dispatch(fetchMe()); // Await for feature detection await dispatch(loadInstance()); // Await for configuration @@ -75,12 +76,15 @@ const loadInitial = () => { if (pepeEnabled && !state.me) { await dispatch(fetchVerificationConfig()); } + + return account; }; }; /** Highest level node with the Redux store. */ const SoapboxMount = () => { useCachedLocationHandler(); + const me = useAppSelector(state => state.me); const instance = useAppSelector(state => state.instance); const account = useOwnAccount(); @@ -195,6 +199,9 @@ interface ISoapboxLoad { /** Initial data loader. */ const SoapboxLoad: React.FC = ({ children }) => { const dispatch = useAppDispatch(); + const { setAccount, token, baseApiUri } = useAuth(); + + useAxiosInterceptors(token, baseApiUri); const me = useAppSelector(state => state.me); const account = useOwnAccount(); @@ -224,7 +231,8 @@ const SoapboxLoad: React.FC = ({ children }) => { // Load initial data from the API useEffect(() => { - dispatch(loadInitial()).then(() => { + dispatch(loadInitial()).then((account) => { + setAccount(account); setIsLoaded(true); }).catch(() => { setIsLoaded(true); @@ -282,15 +290,17 @@ const SoapboxHead: React.FC = ({ children }) => { /** The root React node of the application. */ const Soapbox: React.FC = () => { return ( - + - - - - - + + + + + + + - + ); }; diff --git a/app/soapbox/contexts/auth-context.tsx b/app/soapbox/contexts/auth-context.tsx new file mode 100644 index 000000000..7c023e92d --- /dev/null +++ b/app/soapbox/contexts/auth-context.tsx @@ -0,0 +1,77 @@ + +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { localState } from 'soapbox/reducers/auth'; +import { parseBaseURL } from 'soapbox/utils/auth'; + +const AuthContext = createContext(null as any); + +interface IAccount { + acct: string + avatar: string + avatar_static: string + bot: boolean + created_at: string + discoverable: boolean + display_name: string + emojis: string[] + fields: any // not sure + followers_count: number + following_count: number + group: boolean + header: string + header_static: string + id: string + last_status_at: string + location: string + locked: boolean + note: string + statuses_count: number + url: string + username: string + verified: boolean + website: string +} + +export const AuthProvider: React.FC = ({ children }: { children: React.ReactNode }) => { + const [account, setAccount] = useState(); + const [token, setToken] = useState(); + const [baseApiUri, setBaseApiUri] = useState(); + + const value = useMemo(() => ({ + account, + baseApiUri, + setAccount, + token, + }), [account]); + + useEffect(() => { + const cachedAuth: any = localState?.toJS(); + + if (cachedAuth?.me) { + setToken(cachedAuth.users[cachedAuth.me].access_token); + setBaseApiUri(parseBaseURL(cachedAuth.users[cachedAuth.me].url)); + } + }, []); + + return ( + + {children} + + ); +}; + +interface IAuth { + account: IAccount + baseApiUri: string + setAccount(account: IAccount): void + token: string +} + +export const useAuth = (): IAuth => useContext(AuthContext); diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index da1c3faee..7a2b64b2c 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -2,9 +2,9 @@ import classNames from 'classnames'; import React, { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { fetchCarouselAvatars } from 'soapbox/actions/carousels'; import { replaceHomeTimeline } from 'soapbox/actions/timelines'; import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks'; +import useCarouselAvatars from 'soapbox/queries/carousels'; import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; import PlaceholderAvatar from '../placeholder/components/placeholder_avatar'; @@ -15,10 +15,10 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId); const isSelected = avatar.account_id === selectedAccountId; - const [isLoading, setLoading] = useState(false); + const [isFetching, setLoading] = useState(false); const handleClick = () => { - if (isLoading) { + if (isFetching) { return; } @@ -32,7 +32,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { }; return ( -
+
{isSelected && ( @@ -59,17 +59,15 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { }; const FeedCarousel = () => { - const dispatch = useAppDispatch(); const features = useFeatures(); + const { data: avatars, isFetching, isError } = useCarouselAvatars(); + const [cardRef, setCardRef, { width }] = useDimensions(); const [pageSize, setPageSize] = useState(0); const [currentPage, setCurrentPage] = useState(1); - const avatars = useAppSelector((state) => state.carousels.avatars); - const isLoading = useAppSelector((state) => state.carousels.isLoading); - const hasError = useAppSelector((state) => state.carousels.error); const numberOfPages = Math.ceil(avatars.length / pageSize); const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length; @@ -85,17 +83,11 @@ const FeedCarousel = () => { } }, [width, widthPerAvatar]); - useEffect(() => { - if (features.feedUserFiltering) { - dispatch(fetchCarouselAvatars()); - } - }, []); - if (!features.feedUserFiltering) { return null; } - if (hasError) { + if (isError) { return ( @@ -133,7 +125,7 @@ const FeedCarousel = () => { style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }} ref={setCardRef} > - {isLoading ? ( + {isFetching ? ( new Array(pageSize).fill(0).map((_, idx) => (
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index b9fdab67d..58cf10efc 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -1,5 +1,6 @@ 'use strict'; +import { QueryClientProvider } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; @@ -119,6 +120,7 @@ import { WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import 'soapbox/components/status'; +import { queryClient } from 'soapbox/queries/client'; const EmptyPage = HomePage; @@ -648,51 +650,53 @@ const UI: React.FC = ({ children }) => { }; return ( - -
- + + +
+ -
- +
+ - - - {!standalone && } - + + + {!standalone && } + - - {children} - - + + {children} + + - {me && floatingActionButton} + {me && floatingActionButton} - - {Component => } - + + {Component => } + - {me && ( - + {me && ( + + {Component => } + + )} + {me && features.chats && !mobile && ( + + {Component => } + + )} + + + {Component => } - )} - {me && features.chats && !mobile && ( - + + {Component => } - )} - - - - {Component => } - - - - {Component => } - +
-
-
+ +
); }; diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts new file mode 100644 index 000000000..bf56ad0a7 --- /dev/null +++ b/app/soapbox/queries/carousels.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import API from './client'; + +type Avatar = { + account_id: string + account_avatar: string + username: string +} + +const getCarouselAvatars = async () => { + const { data } = await API.get('/api/v1/truth/carousels/avatars'); + return data; +}; + +export default function useCarouselAvatars(): { data: Avatar[], isFetching: boolean, isError: boolean, isSuccess: boolean } { + const { data, isFetching, isError, isSuccess } = useQuery(['carouselAvatars'], getCarouselAvatars, { + placeholderData: [], + }); + + const avatars = data as Avatar[]; + + return { + data: avatars, + isFetching, + isError, + isSuccess, + }; +} diff --git a/app/soapbox/queries/client.ts b/app/soapbox/queries/client.ts index d772e9288..beddfa773 100644 --- a/app/soapbox/queries/client.ts +++ b/app/soapbox/queries/client.ts @@ -1,4 +1,20 @@ import { QueryClient } from '@tanstack/react-query'; +import axios, { AxiosRequestConfig } from 'axios'; + +import * as BuildConfig from 'soapbox/build_config'; +import { isURL } from 'soapbox/utils/auth'; + +const maybeParseJSON = (data: string) => { + try { + return JSON.parse(data); + } catch (Exception) { + return data; + } +}; + +const API = axios.create({ + transformResponse: [maybeParseJSON], +}); const queryClient = new QueryClient({ defaultOptions: { @@ -10,4 +26,22 @@ const queryClient = new QueryClient({ }, }); -export { queryClient }; +const useAxiosInterceptors = (token: string, baseApiUri: string) => { + API.interceptors.request.use( + async(config: AxiosRequestConfig) => { + if (token) { + config.baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseApiUri; + // eslint-disable-next-line no-param-reassign + config.headers = { + ...config.headers, + Authorization: (token ? `Bearer ${token}` : null) as string | number | boolean | string[] | undefined, + } as any; + } + + return config; + }, + (error) => Promise.reject(error), + ); +}; + +export { API as default, queryClient, useAxiosInterceptors }; diff --git a/app/soapbox/reducers/__tests__/carousels.test.ts b/app/soapbox/reducers/__tests__/carousels.test.ts deleted file mode 100644 index 2745b078d..000000000 --- a/app/soapbox/reducers/__tests__/carousels.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AnyAction } from 'redux'; - -import { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, -} from 'soapbox/actions/carousels'; - -import reducer from '../carousels'; - -describe('carousels reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as AnyAction)).toEqual({ - avatars: [], - error: false, - isLoading: false, - }); - }); - - describe('CAROUSEL_AVATAR_REQUEST', () => { - it('sets "isLoading" to "true"', () => { - const initialState = { isLoading: false, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_REQUEST }; - expect(reducer(initialState, action).isLoading).toEqual(true); - }); - }); - - describe('CAROUSEL_AVATAR_SUCCESS', () => { - it('sets the next state', () => { - const initialState = { isLoading: true, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_SUCCESS, payload: [45] }; - const result = reducer(initialState, action); - - expect(result.isLoading).toEqual(false); - expect(result.avatars).toEqual([45]); - expect(result.error).toEqual(false); - }); - }); - - describe('CAROUSEL_AVATAR_FAIL', () => { - it('sets "isLoading" to "true"', () => { - const initialState = { isLoading: true, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_FAIL }; - const result = reducer(initialState, action); - - expect(result.isLoading).toEqual(false); - expect(result.error).toEqual(true); - }); - }); -}); diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index 2f0c773cd..ad0da1a1d 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -38,7 +38,7 @@ const getSessionUser = () => { }; const sessionUser = getSessionUser(); -const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); +export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); // Checks if the user has an ID and access token const validUser = user => { diff --git a/app/soapbox/reducers/carousels.ts b/app/soapbox/reducers/carousels.ts deleted file mode 100644 index 091c47238..000000000 --- a/app/soapbox/reducers/carousels.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AnyAction } from 'redux'; - -import { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, -} from '../actions/carousels'; - -type Avatar = { - account_id: string - account_avatar: string - username: string -} - -type CarouselsState = { - avatars: Avatar[] - isLoading: boolean - error: boolean -} - -const initialState: CarouselsState = { - avatars: [], - isLoading: false, - error: false, -}; - -export default function rules(state: CarouselsState = initialState, action: AnyAction): CarouselsState { - switch (action.type) { - case CAROUSEL_AVATAR_REQUEST: - return { ...state, isLoading: true }; - case CAROUSEL_AVATAR_SUCCESS: - return { ...state, isLoading: false, avatars: action.payload }; - case CAROUSEL_AVATAR_FAIL: - return { ...state, isLoading: false, error: true }; - default: - return state; - } -} diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index fda62e79e..87750381b 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -15,7 +15,6 @@ import aliases from './aliases'; import announcements from './announcements'; import auth from './auth'; import backups from './backups'; -import carousels from './carousels'; import chat_message_lists from './chat_message_lists'; import chat_messages from './chat_messages'; import chats from './chats'; @@ -124,7 +123,6 @@ const reducers = { onboarding, rules, history, - carousels, announcements, };