From 9f7a162a96200aaca0512765eff938a88c84d6d6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 16 Nov 2023 11:11:56 -0800 Subject: [PATCH] Refactor app passwords to use react-query (#1932) --- src/state/models/me.ts | 57 ---------- src/state/queries/app-passwords.ts | 56 ++++++++++ src/view/com/modals/AddAppPasswords.tsx | 25 ++++- src/view/com/util/Toast.tsx | 6 +- src/view/com/util/Toast.web.tsx | 9 +- src/view/screens/AppPasswords.tsx | 139 ++++++++++++++++-------- 6 files changed, 177 insertions(+), 115 deletions(-) create mode 100644 src/state/queries/app-passwords.ts diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 7e7a48b5..1e802fb7 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -1,5 +1,4 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {ComAtprotoServerListAppPasswords} from '@atproto/api' import {RootStoreModel} from './root-store' import {isObj, hasProp} from 'lib/type-guards' import {logger} from '#/logger' @@ -14,7 +13,6 @@ export class MeModel { avatar: string = '' followsCount: number | undefined followersCount: number | undefined - appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() constructor(public rootStore: RootStoreModel) { @@ -33,7 +31,6 @@ export class MeModel { this.displayName = '' this.description = '' this.avatar = '' - this.appPasswords = [] } serialize(): unknown { @@ -81,7 +78,6 @@ export class MeModel { this.did = sess.currentSession?.did || '' await this.fetchProfile() this.rootStore.emitSessionLoaded() - await this.fetchAppPasswords() } else { this.clear() } @@ -92,7 +88,6 @@ export class MeModel { logger.debug('Updating me profile information') this.lastProfileStateUpdate = Date.now() await this.fetchProfile() - await this.fetchAppPasswords() } } @@ -117,56 +112,4 @@ export class MeModel { } }) } - - async fetchAppPasswords() { - if (this.rootStore.session) { - try { - const res = - await this.rootStore.agent.com.atproto.server.listAppPasswords({}) - runInAction(() => { - this.appPasswords = res.data.passwords - }) - } catch (e) { - logger.error('Failed to fetch user app passwords', { - error: e, - }) - } - } - } - - async createAppPassword(name: string) { - if (this.rootStore.session) { - try { - if (this.appPasswords.find(p => p.name === name)) { - // TODO: this should be handled by the backend but it's not - throw new Error('App password with this name already exists') - } - const res = - await this.rootStore.agent.com.atproto.server.createAppPassword({ - name, - }) - runInAction(() => { - this.appPasswords.push(res.data) - }) - return res.data - } catch (e) { - logger.error('Failed to create app password', {error: e}) - } - } - } - - async deleteAppPassword(name: string) { - if (this.rootStore.session) { - try { - await this.rootStore.agent.com.atproto.server.revokeAppPassword({ - name: name, - }) - runInAction(() => { - this.appPasswords = this.appPasswords.filter(p => p.name !== name) - }) - } catch (e) { - logger.error('Failed to delete app password', {error: e}) - } - } - } } diff --git a/src/state/queries/app-passwords.ts b/src/state/queries/app-passwords.ts new file mode 100644 index 00000000..22de75ac --- /dev/null +++ b/src/state/queries/app-passwords.ts @@ -0,0 +1,56 @@ +import {ComAtprotoServerCreateAppPassword} from '@atproto/api' +import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' +import {useSession} from '../session' + +export const RQKEY = () => ['app-passwords'] + +export function useAppPasswordsQuery() { + const {agent} = useSession() + return useQuery({ + queryKey: RQKEY(), + queryFn: async () => { + const res = await agent.com.atproto.server.listAppPasswords({}) + return res.data.passwords + }, + }) +} + +export function useAppPasswordCreateMutation() { + const {agent} = useSession() + const queryClient = useQueryClient() + return useMutation< + ComAtprotoServerCreateAppPassword.OutputSchema, + Error, + {name: string} + >({ + mutationFn: async ({name}) => { + return ( + await agent.com.atproto.server.createAppPassword({ + name, + }) + ).data + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: RQKEY(), + }) + }, + }) +} + +export function useAppPasswordDeleteMutation() { + const {agent} = useSession() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({name}) => { + await agent.com.atproto.server.revokeAppPassword({ + name, + }) + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: RQKEY(), + }) + }, + }) +} diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 095ad48b..812a36f4 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -3,7 +3,6 @@ import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {isNative} from 'platform/detection' import { @@ -16,6 +15,10 @@ import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import { + useAppPasswordsQuery, + useAppPasswordCreateMutation, +} from '#/state/queries/app-passwords' export const snapPoints = ['70%'] @@ -56,9 +59,10 @@ const shadesOfBlue: string[] = [ export function Component({}: {}) { const pal = usePalette('default') - const store = useStores() const {_} = useLingui() const {closeModal} = useModalControls() + const {data: passwords} = useAppPasswordsQuery() + const createMutation = useAppPasswordCreateMutation() const [name, setName] = useState( shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], ) @@ -82,25 +86,34 @@ export function Component({}: {}) { if (!name || !name.trim()) { Toast.show( 'Please enter a name for your app password. All spaces is not allowed.', + 'times', ) return } // if name is too short (under 4 chars), we don't allow it if (name.length < 4) { - Toast.show('App Password names must be at least 4 characters long.') + Toast.show( + 'App Password names must be at least 4 characters long.', + 'times', + ) + return + } + + if (passwords?.find(p => p.name === name)) { + Toast.show('This name is already in use', 'times') return } try { - const newPassword = await store.me.createAppPassword(name) + const newPassword = await createMutation.mutateAsync({name}) if (newPassword) { setAppPassword(newPassword.password) } else { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') // TODO: better error handling (?) } } catch (e) { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') logger.error('Failed to create app password', {error: e}) } } diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 4c9045d1..c7134feb 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,7 @@ import RootSiblings from 'react-native-root-siblings' import React from 'react' import {Animated, StyleSheet, View} from 'react-native' +import {Props as FontAwesomeProps} from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' @@ -9,7 +10,10 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const TIMEOUT = 4e3 -export function show(message: string) { +export function show( + message: string, + _icon: FontAwesomeProps['icon'] = 'check', +) { const item = new RootSiblings() setTimeout(() => { item.destroy() diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index c295bad6..beb67c30 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -7,12 +7,14 @@ import {StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, + Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' const DURATION = 3500 interface ActiveToast { text: string + icon: FontAwesomeProps['icon'] } type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void @@ -36,7 +38,7 @@ export const ToastContainer: React.FC = ({}) => { {activeToast && ( @@ -49,11 +51,12 @@ export const ToastContainer: React.FC = ({}) => { // methods // = -export function show(text: string) { + +export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { if (toastTimeout) { clearTimeout(toastTimeout) } - globalSetActiveToast?.({text}) + globalSetActiveToast?.({text, icon}) toastTimeout = setTimeout(() => { globalSetActiveToast?.(undefined) }, DURATION) diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 092f4bc2..b2eee392 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -1,15 +1,18 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from 'react-native-gesture-handler' import {Text} from '../com/util/text/Text' import {Button} from '../com/util/forms/Button' import * as Toast from '../com/util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' import {useAnalytics} from 'lib/analytics/analytics' @@ -21,16 +24,22 @@ import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' import {useLanguagePrefs} from '#/state/preferences' +import { + useAppPasswordsQuery, + useAppPasswordDeleteMutation, +} from '#/state/queries/app-passwords' +import {ErrorScreen} from '../com/util/error/ErrorScreen' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps export const AppPasswords = withAuthRequired( - observer(function AppPasswordsImpl({}: Props) { + function AppPasswordsImpl({}: Props) { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const {isTabletOrDesktop} = useWebMediaQueries() const {openModal} = useModalControls() + const {data: appPasswords, error} = useAppPasswordsQuery() useFocusEffect( React.useCallback(() => { @@ -43,8 +52,27 @@ export const AppPasswords = withAuthRequired( openModal({name: 'add-app-password'}) }, [openModal]) + if (error) { + return ( + + + + ) + } + // no app passwords (empty) state - if (store.me.appPasswords.length === 0) { + if (appPasswords?.length === 0) { return ( - - - {store.me.appPasswords.map((password, i) => ( - - ))} - {isTabletOrDesktop && ( - + ]} + testID="appPasswordsScreen"> + + + {appPasswords.map((password, i) => ( + + ))} + {isTabletOrDesktop && ( + +