diff options
Diffstat (limited to 'src')
55 files changed, 2257 insertions, 402 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 424d73290..c98359387 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -104,8 +104,10 @@ import {Wizard} from '#/screens/StarterPack/Wizard' import TopicScreen from '#/screens/Topic' import {VideoFeed} from '#/screens/VideoFeed' import {useTheme} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' +import { + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' @@ -738,12 +740,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const {currentAccount} = useSession() const prevLoggedRouteName = React.useRef<string | undefined>(undefined) - const verifyEmailDialogControl = useDialogControl() + const emailDialogControl = useEmailDialogControl() function onReady() { prevLoggedRouteName.current = getCurrentRouteName() if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { - verifyEmailDialogControl.open() + emailDialogControl.open({ + id: EmailDialogScreenID.VerificationReminder, + }) snoozeEmailConfirmationPrompt() } } @@ -768,7 +772,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { }}> {children} </NavigationContainer> - <VerifyEmailDialog control={verifyEmailDialogControl} reminder /> </> ) } diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 12bd8819b..c43e9c5c0 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -195,7 +195,13 @@ export function Inner({ onDismiss={close} style={{display: 'flex', flexDirection: 'column'}}> {header} - <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> + <View + style={[ + gtMobile ? a.p_2xl : a.p_xl, + a.overflow_hidden, + a.rounded_md, + contentContainerStyle, + ]}> {children} </View> </DismissableLayer.DismissableLayer> diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index d7afa37d2..de19b0bce 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -18,7 +18,7 @@ import {useNavigation} from '@react-navigation/native' import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' -import {useEmail} from '#/lib/hooks/useEmail' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' import {parseStarterPackUri} from '#/lib/strings/starter-pack' @@ -30,7 +30,6 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Loader} from '#/components/Loader' @@ -197,9 +196,7 @@ function Empty() { const confirmDialogControl = useDialogControl() const followersDialogControl = useDialogControl() const errorDialogControl = useDialogControl() - - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const [isGenerating, setIsGenerating] = useState(false) @@ -230,6 +227,27 @@ function Empty() { generateStarterPack() } + const openConfirmDialog = useCallback(() => { + confirmDialogControl.open() + }, [confirmDialogControl]) + const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, { + instructions: [ + <Trans key="confirm"> + Before creating a starter pack, you must first verify your email. + </Trans>, + ], + }) + const navToWizard = useCallback(() => { + navigation.navigate('StarterPackWizard') + }, [navigation]) + const wrappedNavToWizard = requireEmailVerification(navToWizard, { + instructions: [ + <Trans key="nav"> + Before creating a starter pack, you must first verify your email. + </Trans>, + ], + }) + return ( <LinearGradientBackground style={[ @@ -258,13 +276,7 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - confirmDialogControl.open() - } - }} + onPress={wrappedOpenConfirmDialog} style={{backgroundColor: 'transparent'}}> <ButtonText style={{color: 'white'}}> <Trans>Make one for me</Trans> @@ -277,13 +289,7 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - navigation.navigate('StarterPackWizard') - } - }} + onPress={wrappedNavToWizard} style={{ backgroundColor: 'white', borderColor: 'white', @@ -339,12 +345,6 @@ function Empty() { onConfirm={generate} confirmButtonCta={_(msg`Retry`)} /> - <VerifyEmailDialog - reasonText={_( - msg`Before creating a starter pack, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </LinearGradientBackground> ) } diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index fda904b8b..728044325 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -1,6 +1,7 @@ import {createContext, useContext, useMemo, useState} from 'react' import * as Dialog from '#/components/Dialog' +import {type Screen} from '#/components/dialogs/EmailDialog/types' type Control = Dialog.DialogControlProps @@ -15,6 +16,7 @@ type ControlsContext = { mutedWordsDialogControl: Control signinDialogControl: Control inAppBrowserConsentControl: StatefulControl<string> + emailDialogControl: StatefulControl<Screen> } const ControlsContext = createContext<ControlsContext | null>(null) @@ -33,14 +35,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const mutedWordsDialogControl = Dialog.useDialogControl() const signinDialogControl = Dialog.useDialogControl() const inAppBrowserConsentControl = useStatefulDialogControl<string>() + const emailDialogControl = useStatefulDialogControl<Screen>() const ctx = useMemo<ControlsContext>( () => ({ mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl, + emailDialogControl, }), - [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl], + [ + mutedWordsDialogControl, + signinDialogControl, + inAppBrowserConsentControl, + emailDialogControl, + ], ) return ( @@ -48,7 +57,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> { +export function useStatefulDialogControl<T>( + initialValue?: T, +): StatefulControl<T> { const [value, setValue] = useState(initialValue) const control = Dialog.useDialogControl() return useMemo( diff --git a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx new file mode 100644 index 000000000..0fc9a5469 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx @@ -0,0 +1,56 @@ +import {useState} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {atoms as a, type TextStyleProp, useTheme} from '#/alf' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span, Text} from '#/components/Typography' + +export function ResendEmailText({ + onPress, + style, +}: TextStyleProp & { + onPress: () => Promise<any> +}) { + const t = useTheme() + const {_} = useLingui() + const [status, setStatus] = useState<'sending' | 'success' | null>(null) + + const handleOnPress = async () => { + setStatus('sending') + try { + await wait(1000, onPress()) + setStatus('success') + } finally { + setTimeout(() => { + setStatus(null) + }, 1000) + } + } + + return ( + <Text + style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}> + <Trans> + Don't see an email?{' '} + <InlineLinkText + label={_(msg`Resend`)} + {...createStaticClick(() => { + handleOnPress() + })}> + Click here to resend. + </InlineLinkText> + </Trans>{' '} + <Span style={{top: 1}}> + {status === 'sending' ? ( + <Loader size="xs" /> + ) : status === 'success' ? ( + <Check size="xs" fill={t.palette.positive_500} /> + ) : null} + </Span> + </Text> + ) +} diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx new file mode 100644 index 000000000..26a51e485 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx @@ -0,0 +1,45 @@ +import {type TextInputProps, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' + +export function normalizeCode(value: string) { + const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '') + if (normalized.length <= 5) return normalized + return `${normalized.slice(0, 5)}-${normalized.slice(5)}` +} + +export function isValidCode(value?: string) { + return Boolean(value && /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(value)) +} + +export function TokenField({ + value, + onChangeText, + onSubmitEditing, +}: Pick<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) { + const {_} = useLingui() + const isInvalid = Boolean(value && value.length > 10 && !isValidCode(value)) + + const handleOnChangeText = (v: string) => { + onChangeText?.(normalizeCode(v)) + } + + return ( + <View> + <TextField.Root> + <TextField.Icon icon={Shield} /> + <TextField.Input + isInvalid={isInvalid} + label={_(msg`Confirmation code`)} + placeholder="XXXXX-XXXXX" + value={value} + onChangeText={handleOnChangeText} + onSubmitEditing={onSubmitEditing} + /> + </TextField.Root> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts new file mode 100644 index 000000000..377411107 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts @@ -0,0 +1,79 @@ +import {useCallback, useEffect, useState} from 'react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' + +export type AccountEmailState = { + isEmailVerified: boolean + email2FAEnabled: boolean +} + +export const accountEmailStateQueryKey = ['accountEmailState'] as const + +export function useInvalidateAccountEmailState() { + const qc = useQueryClient() + + return useCallback(() => { + return qc.invalidateQueries({ + queryKey: accountEmailStateQueryKey, + }) + }, [qc]) +} + +export function useUpdateAccountEmailStateQueryCache() { + const qc = useQueryClient() + + return useCallback( + (data: AccountEmailState) => { + return qc.setQueriesData( + { + queryKey: accountEmailStateQueryKey, + }, + data, + ) + }, + [qc], + ) +} + +export function useAccountEmailState() { + const agent = useAgent() + const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( + !!agent.session?.emailConfirmed, + ) + const fallbackData: AccountEmailState = { + isEmailVerified: !!agent.session?.emailConfirmed, + email2FAEnabled: !!agent.session?.emailAuthFactor, + } + const query = useQuery<AccountEmailState>({ + enabled: !!agent.session, + refetchOnWindowFocus: true, + queryKey: accountEmailStateQueryKey, + queryFn: async () => { + // will also trigger updates to `#/state/session` data + const {data} = await agent.resumeSession(agent.session!) + return { + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + } + }, + }) + + const state = query.data ?? fallbackData + + /* + * This will emit `n` times for each instance of this hook. So the listeners + * all use `once` to prevent multiple handlers firing. + */ + useEffect(() => { + if (state.isEmailVerified && !prevIsEmailVerified) { + setPrevEmailIsVerified(true) + emitEmailVerified() + } else if (!state.isEmailVerified && prevIsEmailVerified) { + setPrevEmailIsVerified(false) + } + }, [state, prevIsEmailVerified]) + + return state +} diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts new file mode 100644 index 000000000..73f824fcc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -0,0 +1,29 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useConfirmEmail() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + return useMutation({ + mutationFn: async ({token}: {token: string}) => { + if (!currentAccount?.email) { + throw new Error('No email found for the current account') + } + + await agent.com.atproto.server.confirmEmail({ + email: currentAccount.email, + token: token.trim(), + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts new file mode 100644 index 000000000..39f5fd2d9 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -0,0 +1,35 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useManageEmail2FA() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + return useMutation({ + mutationFn: async ({ + enabled, + token, + }: + | {enabled: true; token?: undefined} + | {enabled: false; token: string}) => { + if (!currentAccount?.email) { + throw new Error('No email found for the current account') + } + + await agent.com.atproto.server.updateEmail({ + email: currentAccount.email, + emailAuthFactor: enabled, + token, + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts new file mode 100644 index 000000000..a442662fc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailUpdate() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + return (await agent.com.atproto.server.requestEmailUpdate()).data + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts new file mode 100644 index 000000000..ae308c7af --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailVerification() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + await agent.com.atproto.server.requestEmailConfirmation() + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts new file mode 100644 index 000000000..2ec1eb6dc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -0,0 +1,45 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' + +async function updateEmailAndRefreshSession( + agent: ReturnType<typeof useAgent>, + email: string, + token?: string, +) { + await agent.com.atproto.server.updateEmail({email: email.trim(), token}) + await agent.resumeSession(agent.session!) +} + +export function useUpdateEmail() { + const agent = useAgent() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + + return useMutation< + {status: 'tokenRequired' | 'success'}, + Error, + {email: string; token?: string} + >({ + mutationFn: async ({email, token}: {email: string; token?: string}) => { + if (token) { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } else { + const {tokenRequired} = await requestEmailUpdate() + if (tokenRequired) { + return { + status: 'tokenRequired', + } + } else { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } + } + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts new file mode 100644 index 000000000..4fa171cad --- /dev/null +++ b/src/components/dialogs/EmailDialog/events.ts @@ -0,0 +1,23 @@ +import {useEffect} from 'react' +import EventEmitter from 'eventemitter3' + +const events = new EventEmitter<{ + emailVerified: void +}>() + +export function emitEmailVerified() { + events.emit('emailVerified') +} + +export function useOnEmailVerified(cb: () => void) { + useEffect(() => { + /* + * N.B. Use `once` here, since the event can fire multiple times for each + * instance of `useAccountEmailState` + */ + events.once('emailVerified', cb) + return () => { + events.off('emailVerified', cb) + } + }, [cb]) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx new file mode 100644 index 000000000..4bac7295a --- /dev/null +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -0,0 +1,71 @@ +import {useCallback, useState} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as Dialog from '#/components/Dialog' +import {type StatefulControl} from '#/components/dialogs/Context' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA' +import {Update} from '#/components/dialogs/EmailDialog/screens/Update' +import {VerificationReminder} from '#/components/dialogs/EmailDialog/screens/VerificationReminder' +import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify' +import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types' + +export type {Screen} from '#/components/dialogs/EmailDialog/types' +export {ScreenID as EmailDialogScreenID} from '#/components/dialogs/EmailDialog/types' + +export function useEmailDialogControl() { + return useGlobalDialogsControlContext().emailDialogControl +} + +export function EmailDialog() { + const {_} = useLingui() + const emailDialogControl = useEmailDialogControl() + const {isEmailVerified} = useAccountEmailState() + const onClose = useCallback(() => { + if (!isEmailVerified) { + if (emailDialogControl.value?.id === ScreenID.Verify) { + emailDialogControl.value.onCloseWithoutVerifying?.() + } + } + emailDialogControl.clear() + }, [isEmailVerified, emailDialogControl]) + + return ( + <Dialog.Outer control={emailDialogControl.control} onClose={onClose}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Make adjustments to email settings for your account`)} + style={[{maxWidth: 400}]}> + <Inner control={emailDialogControl} /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function Inner({control}: {control: StatefulControl<Screen>}) { + const [screen, showScreen] = useState(() => control.value) + + if (!screen) return null + + switch (screen.id) { + case ScreenID.Update: { + return <Update config={screen} showScreen={showScreen} /> + } + case ScreenID.Verify: { + return <Verify config={screen} showScreen={showScreen} /> + } + case ScreenID.VerificationReminder: { + return <VerificationReminder config={screen} showScreen={showScreen} /> + } + case ScreenID.Manage2FA: { + return <Manage2FA config={screen} showScreen={showScreen} /> + } + default: { + return null + } + } +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx new file mode 100644 index 000000000..1896ff27d --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx @@ -0,0 +1,254 @@ +import {useReducer, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {Divider} from '#/components/Divider' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span, Text} from '#/components/Typography' + +type State = { + error: string + step: 'email' | 'token' + emailStatus: 'pending' | 'success' | 'error' | 'default' + tokenStatus: 'pending' | 'success' | 'error' | 'default' +} + +type Action = + | { + type: 'setError' + error: string + } + | { + type: 'setStep' + step: 'email' | 'token' + } + | { + type: 'setEmailStatus' + status: State['emailStatus'] + } + | { + type: 'setTokenStatus' + status: State['tokenStatus'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setError': { + return { + ...state, + error: action.error, + emailStatus: 'error', + tokenStatus: 'error', + } + } + case 'setStep': { + return { + ...state, + error: '', + step: action.step, + } + } + case 'setEmailStatus': { + return { + ...state, + error: '', + emailStatus: action.status, + } + } + case 'setTokenStatus': { + return { + ...state, + error: '', + tokenStatus: action.status, + } + } + default: { + return state + } + } +} + +export function Disable() { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [token, setToken] = useState('') + const [state, dispatch] = useReducer(reducer, { + error: '', + step: 'email', + emailStatus: 'default', + tokenStatus: 'default', + }) + + const handleSendEmail = async () => { + dispatch({type: 'setEmailStatus', status: 'pending'}) + try { + await wait(1000, requestEmailUpdate()) + dispatch({type: 'setEmailStatus', status: 'success'}) + setTimeout(() => { + dispatch({type: 'setStep', step: 'token'}) + }, 1000) + } catch (e) { + logger.error('Manage2FA: email update code request failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleManageEmail2FA = async () => { + if (!isValidCode(token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({type: 'setTokenStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: false, token})) + dispatch({type: 'setTokenStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + return ( + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> + <Trans>Disable email 2FA</Trans> + </Text> + + {state.step === 'email' ? ( + <> + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + To disable your email 2FA method, please verify your access to{' '} + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> + </Trans> + </Text> + + <View style={[a.gap_lg, a.pt_sm]}> + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Send email`)} + size="large" + variant="solid" + color="primary" + onPress={handleSendEmail} + disabled={state.emailStatus === 'pending'}> + <ButtonText> + <Trans>Send email</Trans> + </ButtonText> + <ButtonIcon + icon={ + state.emailStatus === 'pending' + ? Loader + : state.emailStatus === 'success' + ? Check + : Envelope + } + /> + </Button> + + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Have a code?{' '} + <InlineLinkText + label={_(msg`Enter code`)} + {...createStaticClick(() => { + dispatch({type: 'setStep', step: 'token'}) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </View> + </> + ) : ( + <> + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + To disable your email 2FA method, please verify your access to{' '} + <Span style={[a.font_bold]}>{currentAccount?.email}</Span> + </Trans> + </Text> + + <View style={[a.gap_sm, a.py_sm]}> + <TokenField + value={token} + onChangeText={setToken} + onSubmitEditing={handleManageEmail2FA} + /> + <ResendEmailText onPress={handleSendEmail} /> + </View> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Disable 2FA`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={ + !token || token.length !== 11 || state.tokenStatus === 'pending' + }> + <ButtonText> + <Trans>Disable 2FA</Trans> + </ButtonText> + {state.tokenStatus === 'pending' ? ( + <ButtonIcon icon={Loader} /> + ) : state.tokenStatus === 'success' ? ( + <ButtonIcon icon={Check} /> + ) : null} + </Button> + </> + )} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx new file mode 100644 index 000000000..7a126792a --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx @@ -0,0 +1,137 @@ +import {useReducer} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type State = { + error: string + status: 'pending' | 'success' | 'error' | 'default' +} + +type Action = + | { + type: 'setError' + error: string + } + | { + type: 'setStatus' + status: State['status'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setError': { + return { + ...state, + error: action.error, + status: 'error', + } + } + case 'setStatus': { + return { + ...state, + error: '', + status: action.status, + } + } + default: { + return state + } + } +} + +export function Enable() { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {gtPhone} = useBreakpoints() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [state, dispatch] = useReducer(reducer, { + error: '', + status: 'default', + }) + + const handleManageEmail2FA = async () => { + dispatch({type: 'setStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: true})) + dispatch({type: 'setStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> + <Trans>Enable email 2FA</Trans> + </Text> + + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Require an email code to sign in to your account.</Trans> + </Text> + </View> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Enable`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={state.status === 'pending'}> + <ButtonText> + <Trans>Enable</Trans> + </ButtonText> + <ButtonIcon + position="right" + icon={ + state.status === 'pending' + ? Loader + : state.status === 'success' + ? Check + : ShieldIcon + } + /> + </Button> + <Button + label={_(msg`Cancel`)} + size="large" + variant="solid" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx new file mode 100644 index 000000000..427a42b1f --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx @@ -0,0 +1,70 @@ +import {useEffect, useState} from 'react' +import {Trans} from '@lingui/macro' + +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable' +import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' + +export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) { + const {isEmailVerified, email2FAEnabled} = useAccountEmailState() + const [requestedAction, setRequestedAction] = useState< + 'enable' | 'disable' | null + >(null) + + useEffect(() => { + if (!isEmailVerified) { + showScreen({ + id: ScreenID.Verify, + instructions: [ + <Trans key="2fa"> + You need to verify your email address before you can enable email + 2FA. + </Trans>, + ], + onVerify: () => { + showScreen({ + id: ScreenID.Manage2FA, + }) + }, + }) + } + }, [isEmailVerified, showScreen]) + + /* + * Wacky state handling so that once 2FA settings change, we don't show the + * wrong step of this form - esb + */ + + if (email2FAEnabled) { + if (!requestedAction) { + setRequestedAction('disable') + return <Disable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } else { + if (!requestedAction) { + setRequestedAction('enable') + return <Enable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } + + // should never happen + return null +} diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx new file mode 100644 index 000000000..be0af8807 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -0,0 +1,319 @@ +import {useReducer} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {validate as validateEmail} from 'email-validator' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import { + type ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import * as TextField from '#/components/forms/TextField' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type State = { + step: 'email' | 'token' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + emailValid: boolean + email: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setEmail' + value: string + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setEmail': { + const emailValid = validateEmail(action.value) + return { + ...state, + step: 'email', + token: '', + email: action.value, + emailValid, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Update(_props: ScreenProps<ScreenID.Update>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + email: '', + emailValid: true, + token: '', + }) + + const {mutateAsync: updateEmail} = useUpdateEmail() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + + const handleEmailChange = (email: string) => { + dispatch({ + type: 'setEmail', + value: email, + }) + } + + const handleUpdateEmail = async () => { + if (state.step === 'token' && !isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + if (state.emailValid === false) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid email address.`), + }) + return + } + + if (state.email === currentAccount!.email) { + dispatch({ + type: 'setError', + error: _(msg`This email is already associated with your account.`), + }) + return + } + + try { + const {status} = await wait( + 1000, + updateEmail({ + email: state.email, + token: state.token, + }), + ) + + if (status === 'tokenRequired') { + dispatch({ + type: 'setStep', + step: 'token', + }) + dispatch({ + type: 'setMutationStatus', + status: 'default', + }) + } else if (status === 'success') { + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + + try { + // fire off a confirmation email immediately + await requestEmailVerification() + } catch {} + } + } catch (e) { + logger.error('EmailDialog: update email failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email, please try again.`), + }) + } + } + + return ( + <View style={[a.gap_lg]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Update your email</Trans> + </Text> + + {currentAccount?.emailAuthFactor && ( + <Admonition type="warning"> + <Trans> + If you update your email address, email 2FA will be disabled. + </Trans> + </Admonition> + )} + + <View style={[a.gap_md]}> + <View> + <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Please enter your new email address.</Trans> + </Text> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + label={_(msg`New email address`)} + placeholder={_(msg`alice@example.com`)} + defaultValue={state.email} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : handleEmailChange + } + keyboardType="email-address" + autoComplete="email" + autoCapitalize="none" + onSubmitEditing={handleUpdateEmail} + /> + </TextField.Root> + </View> + + {state.step === 'token' && ( + <> + <Divider /> + <View> + <Text style={[a.text_md, a.pb_sm, a.font_bold]}> + <Trans>Security step required</Trans> + </Text> + <Text + style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Please enter the security code we sent to your previous email + address. + </Trans> + </Text> + <TokenField + value={state.token} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : token => { + dispatch({ + type: 'setToken', + value: token, + }) + } + } + onSubmitEditing={handleUpdateEmail} + /> + {state.mutationStatus !== 'success' && ( + <ResendEmailText + onPress={requestEmailUpdate} + style={[a.pt_sm]} + /> + )} + </View> + </> + )} + + {state.error && <Admonition type="error">{state.error}</Admonition>} + </View> + + {state.mutationStatus === 'success' ? ( + <> + <Divider /> + <View style={[a.gap_sm]}> + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + <Check fill={t.palette.positive_600} size="xs" /> + <Text style={[a.text_md, a.font_heavy]}> + <Trans>Success!</Trans> + </Text> + </View> + <Text style={[a.leading_snug]}> + <Trans> + Please click on the link in the email we just sent you to verify + your new email address. This is an important step to allow you + to continue enjoying all the features of Bluesky. + </Trans> + </Text> + </View> + </> + ) : ( + <Button + label={_(msg`Update email`)} + size="large" + variant="solid" + color="primary" + onPress={handleUpdateEmail} + disabled={ + !state.email || + (state.step === 'token' && + (!state.token || state.token.length !== 11)) || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Update email</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + )} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx new file mode 100644 index 000000000..267b784b0 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx @@ -0,0 +1,99 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {GradientFill} from '#/components/GradientFill' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Text} from '#/components/Typography' + +export function VerificationReminder({ + showScreen, +}: ScreenProps<ScreenID.VerificationReminder>) { + const t = useTheme() + const {_} = useLingui() + const {gtPhone, gtMobile} = useBreakpoints() + const control = useDialogContext() + + const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding + + return ( + <View style={[a.gap_lg]}> + <View + style={[ + a.absolute, + { + top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1, + left: dialogPadding * -1, + right: dialogPadding * -1, + height: 150, + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + a.overflow_hidden, + a.pt_md, + t.atoms.bg_contrast_100, + ]}> + <GradientFill gradient={tokens.gradients.primary} /> + <ShieldIcon width={64} fill="white" style={[a.z_10]} /> + </View> + </View> + + <View style={[a.mb_xs, {height: 150 - dialogPadding}]} /> + + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Please verify your email</Trans> + </Text> + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Your email has not yet been verified. Please verify your email in + order to enjoy all the features of Bluesky. + </Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Get started`)} + variant="solid" + color="primary" + size="large" + onPress={() => + showScreen({ + id: ScreenID.Verify, + }) + }> + <ButtonText> + <Trans>Get started</Trans> + </ButtonText> + </Button> + <Button + label={_(msg`Maybe later`)} + accessibilityHint={_(msg`Snoozes the reminder`)} + variant="ghost" + color="secondary" + size="large" + onPress={() => control.close()}> + <ButtonText> + <Trans>Maybe later</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx new file mode 100644 index 000000000..dabd0d2f2 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx @@ -0,0 +1,386 @@ +import {useReducer} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span, Text} from '#/components/Typography' + +type State = { + step: 'email' | 'token' | 'success' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + error: '', + mutationStatus: 'default', + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + token: '', + }) + + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + const {mutateAsync: confirmEmail} = useConfirmEmail() + + useOnEmailVerified(() => { + if (config.onVerify) { + config.onVerify() + } else { + dispatch({ + type: 'setStep', + step: 'success', + }) + } + }) + + const handleRequestEmailVerification = async () => { + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, requestEmailVerification()) + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + } catch (e) { + logger.error('EmailDialog: sending verification email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleConfirmEmail = async () => { + if (!isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, confirmEmail({token: state.token})) + dispatch({ + type: 'setStep', + step: 'success', + }) + } catch (e) { + logger.error('EmailDialog: confirming email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to verify email, please try again.`), + }) + } + } + + if (state.step === 'success') { + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email verification complete!</Trans> + </Text> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + You have successfully verified your email address. You can close + this dialog. + </Trans> + </Text> + </View> + </View> + ) + } + + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email sent!</Trans> + </> + ) : ( + <Trans>Verify your email</Trans> + ) + ) : ( + <Trans>Verify email code</Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <> + {config.instructions?.map((int, i) => ( + <Text + key={i} + style={[ + a.italic, + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {int} + </Text> + ))} + </> + )} + + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <Trans> + We sent an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) : ( + <Trans> + We'll send an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) + ) : ( + <Trans> + Please enter the code we sent to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + below. + </Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + If you need to update your email,{' '} + <InlineLinkText + label={_(msg`Click here to update your email`)} + {...createStaticClick(() => { + showScreen({id: ScreenID.Update}) + })}> + click here + </InlineLinkText> + . + </Trans> + </Text> + )} + + {state.step === 'email' && state.mutationStatus === 'success' && ( + <ResendEmailText onPress={requestEmailVerification} /> + )} + </View> + + {state.step === 'email' && state.mutationStatus !== 'success' ? ( + <> + {state.error && <Admonition type="error">{state.error}</Admonition>} + <Button + label={_(msg`Send verification email`)} + size="large" + variant="solid" + color="primary" + onPress={handleRequestEmailVerification} + disabled={state.mutationStatus === 'pending'}> + <ButtonText> + <Trans>Send email</Trans> + </ButtonText> + <ButtonIcon + icon={state.mutationStatus === 'pending' ? Loader : Envelope} + /> + </Button> + </> + ) : null} + + {state.step === 'email' && ( + <> + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Have a code?{' '} + <InlineLinkText + label={_(msg`Enter code`)} + {...createStaticClick(() => { + dispatch({ + type: 'setStep', + step: 'token', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + )} + + {state.step === 'token' ? ( + <> + <TokenField + value={state.token} + onChangeText={token => { + dispatch({ + type: 'setToken', + value: token, + }) + }} + onSubmitEditing={handleConfirmEmail} + /> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Verify code`)} + size="large" + variant="solid" + color="primary" + onPress={handleConfirmEmail} + disabled={ + !state.token || + state.token.length !== 11 || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Verify code</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Don't have a code or need a new one?{' '} + <InlineLinkText + label={_(msg`Click here to restart the verification process.`)} + {...createStaticClick(() => { + dispatch({ + type: 'setStep', + step: 'email', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + ) : null} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts new file mode 100644 index 000000000..7edc3facc --- /dev/null +++ b/src/components/dialogs/EmailDialog/types.ts @@ -0,0 +1,38 @@ +import {type ReactNode} from 'react' + +import {type DialogControlProps} from '#/components/Dialog' + +export type EmailDialogProps = { + control: DialogControlProps +} + +export type EmailDialogInnerProps = EmailDialogProps & {} + +export type Screen = + | { + id: ScreenID.Update + } + | { + id: ScreenID.Verify + instructions?: ReactNode[] + onVerify?: () => void + onCloseWithoutVerifying?: () => void + } + | { + id: ScreenID.VerificationReminder + } + | { + id: ScreenID.Manage2FA + } + +export enum ScreenID { + Update = 'Update', + Verify = 'Verify', + VerificationReminder = 'VerificationReminder', + Manage2FA = 'Manage2FA', +} + +export type ScreenProps<T extends ScreenID> = { + config: Extract<Screen, {id: T}> + showScreen: (screen: Screen) => void +} diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 7f31f550c..07b3a0c04 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -1,20 +1,18 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' -import {msg} from '@lingui/macro' +import {type AppBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {useEmail} from '#/lib/hooks/useEmail' -import {NavigationProp} from '#/lib/routes/types' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {canBeMessaged} from '#/components/dms/util' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' @@ -26,8 +24,7 @@ export function MessageProfileButton({ const {_} = useLingui() const t = useTheme() const navigation = useNavigation<NavigationProp>() - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {mutate: initiateConvo} = useGetConvoForMembers({ @@ -45,11 +42,6 @@ export function MessageProfileButton({ return } - if (needsEmailVerification) { - verifyEmailControl.open() - return - } - if (convoAvailability.convo) { logEvent('chat:open', {logContext: 'ProfileHeader'}) navigation.navigate('MessagesConversation', { @@ -59,14 +51,15 @@ export function MessageProfileButton({ logEvent('chat:create', {logContext: 'ProfileHeader'}) initiateConvo([profile.did]) } - }, [ - needsEmailVerification, - verifyEmailControl, - navigation, - profile.did, - initiateConvo, - convoAvailability, - ]) + }, [navigation, profile.did, initiateConvo, convoAvailability]) + + const wrappedOnPress = requireEmailVerification(onPress, { + instructions: [ + <Trans key="message"> + Before you can message another user, you must first verify your email. + </Trans>, + ], + }) if (!convoAvailability) { // show pending state based on declaration @@ -102,15 +95,9 @@ export function MessageProfileButton({ shape="round" label={_(msg`Message ${profile.handle}`)} style={[a.justify_center]} - onPress={onPress}> + onPress={wrappedOnPress}> <ButtonIcon icon={Message} size="md" /> </Button> - <VerifyEmailDialog - reasonText={_( - msg`Before you may message another user, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </> ) } else { diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index a5ba793fb..192e36b5d 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -1,8 +1,8 @@ import {useCallback} from 'react' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useEmail} from '#/lib/hooks/useEmail' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' @@ -10,9 +10,7 @@ import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {useDialogControl} from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' export function NewChat({ @@ -24,8 +22,7 @@ export function NewChat({ }) { const t = useTheme() const {_} = useLingui() - const {needsEmailVerification} = useEmail() - const verifyEmailControl = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { @@ -49,17 +46,22 @@ export function NewChat({ [control, createChat], ) + const onPress = useCallback(() => { + control.open() + }, [control]) + const wrappedOnPress = requireEmailVerification(onPress, { + instructions: [ + <Trans key="new-chat"> + Before you can message another user, you must first verify your email. + </Trans>, + ], + }) + return ( <> <FAB testID="newChatFAB" - onPress={() => { - if (needsEmailVerification) { - verifyEmailControl.open() - } else { - control.open() - } - }} + onPress={wrappedOnPress} icon={<Plus size="lg" fill={t.palette.white} />} accessibilityRole="button" accessibilityLabel={_(msg`New chat`)} @@ -74,13 +76,6 @@ export function NewChat({ sortByMessageDeclaration /> </Dialog.Outer> - - <VerifyEmailDialog - reasonText={_( - msg`Before you may message another user, you must first verify your email.`, - )} - control={verifyEmailControl} - /> </> ) } diff --git a/src/lib/hooks/useCleanError.ts b/src/lib/hooks/useCleanError.ts new file mode 100644 index 000000000..dc9284e90 --- /dev/null +++ b/src/lib/hooks/useCleanError.ts @@ -0,0 +1,91 @@ +import {useCallback} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +type CleanedError = { + raw: string | undefined + clean: string | undefined +} + +export function useCleanError() { + const {_} = useLingui() + + return useCallback<(error?: any) => CleanedError>( + error => { + if (!error) + return { + raw: undefined, + clean: undefined, + } + + let raw = error.toString() + + if (isNetworkError(raw)) { + return { + raw, + clean: _( + msg`Unable to connect. Please check your internet connection and try again.`, + ), + } + } + + if ( + raw.includes('Upstream Failure') || + raw.includes('NotEnoughResources') || + raw.includes('pipethrough network error') + ) { + return { + raw, + clean: _( + msg`The server appears to be experiencing issues. Please try again in a few moments.`, + ), + } + } + + if (raw.includes('Bad token scope')) { + return { + raw, + clean: _( + msg`This feature is not available while using an app password. Please sign in with your main password.`, + ), + } + } + + if (raw.includes('Rate Limit Exceeded')) { + return { + raw, + clean: _( + msg`You've reached the maximum number of requests allowed. Please try again later.`, + ), + } + } + + if (raw.startsWith('Error: ')) { + raw = raw.slice('Error: '.length) + } + + return { + raw, + clean: undefined, + } + }, + [_], + ) +} + +const NETWORK_ERRORS = [ + 'Abort', + 'Network request failed', + 'Failed to fetch', + 'Load failed', +] + +export function isNetworkError(e: unknown) { + const str = String(e) + for (const err of NETWORK_ERRORS) { + if (str.includes(err)) { + return true + } + } + return false +} diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index a33aff237..4a5653750 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -1,10 +1,10 @@ import React from 'react' import * as Linking from 'expo-linking' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {logEvent} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' @@ -83,7 +83,7 @@ export function useIntentHandler() { export function useComposeIntent() { const closeAllActiveElements = useCloseAllActiveElements() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {hasSession} = useSession() return React.useCallback( diff --git a/src/lib/hooks/useOpenComposer.tsx b/src/lib/hooks/useOpenComposer.tsx new file mode 100644 index 000000000..50c04d1e1 --- /dev/null +++ b/src/lib/hooks/useOpenComposer.tsx @@ -0,0 +1,22 @@ +import {useMemo} from 'react' +import {Trans} from '@lingui/macro' + +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {useOpenComposer as rootUseOpenComposer} from '#/state/shell/composer' + +export function useOpenComposer() { + const {openComposer} = rootUseOpenComposer() + const requireEmailVerification = useRequireEmailVerification() + return useMemo(() => { + return { + openComposer: requireEmailVerification(openComposer, { + instructions: [ + <Trans key="pre-compose"> + Before creating a post or replying, you must first verify your + email. + </Trans>, + ], + }), + } + }, [openComposer, requireEmailVerification]) +} diff --git a/src/lib/hooks/useRequireEmailVerification.tsx b/src/lib/hooks/useRequireEmailVerification.tsx new file mode 100644 index 000000000..26045847e --- /dev/null +++ b/src/lib/hooks/useRequireEmailVerification.tsx @@ -0,0 +1,53 @@ +import {useCallback} from 'react' +import {Keyboard} from 'react-native' + +import {useEmail} from '#/lib/hooks/useEmail' +import {useRequireAuth, useSession} from '#/state/session' +import {useCloseAllActiveElements} from '#/state/util' +import { + EmailDialogScreenID, + type Screen, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' + +export function useRequireEmailVerification() { + const {currentAccount} = useSession() + const {needsEmailVerification} = useEmail() + const requireAuth = useRequireAuth() + const emailDialogControl = useEmailDialogControl() + const closeAll = useCloseAllActiveElements() + + return useCallback( + <T extends (...args: any[]) => any>( + cb: T, + config: Omit< + Extract<Screen, {id: EmailDialogScreenID.Verify}>, + 'id' + > = {}, + ): ((...args: Parameters<T>) => ReturnType<T>) => { + return (...args: Parameters<T>): ReturnType<T> => { + if (!currentAccount) { + return requireAuth(() => cb(...args)) as ReturnType<T> + } + if (needsEmailVerification) { + Keyboard.dismiss() + closeAll() + emailDialogControl.open({ + id: EmailDialogScreenID.Verify, + ...config, + }) + return undefined as ReturnType<T> + } else { + return cb(...args) + } + } + }, + [ + needsEmailVerification, + currentAccount, + emailDialogControl, + closeAll, + requireAuth, + ], + ) +} diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 2846ed828..9fd54f1b0 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -9,6 +9,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useAppState} from '#/lib/hooks/useAppState' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {type MessagesTabNavigatorParams} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -321,6 +322,18 @@ export function MessagesScreen({navigation, route}: Props) { function Header({newChatControl}: {newChatControl: DialogControlProps}) { const {_} = useLingui() const {gtMobile} = useBreakpoints() + const requireEmailVerification = useRequireEmailVerification() + + const openChatControl = useCallback(() => { + newChatControl.open() + }, [newChatControl]) + const wrappedOpenChatControl = requireEmailVerification(openChatControl, { + instructions: [ + <Trans key="new-chat"> + Before you can message another user, you must first verify your email. + </Trans>, + ], + }) const settingsLink = ( <Link @@ -352,7 +365,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) { color="primary" size="small" variant="solid" - onPress={newChatControl.open}> + onPress={wrappedOpenChatControl}> <ButtonIcon icon={PlusIcon} position="left" /> <ButtonText> <Trans>New chat</Trans> diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 2222084ce..90547a8d4 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,11 +1,11 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useEffect} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, moderateProfile, type ModerationDecision, } from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import { type RouteProp, @@ -17,6 +17,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useEmail} from '#/lib/hooks/useEmail' import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import { type CommonNavigatorParams, type NavigationProp, @@ -31,8 +32,10 @@ import {useProfileQuery} from '#/state/queries/profile' import {useSetMinimalShellMode} from '#/state/shell' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' +import { + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListHeader} from '#/components/dms/MessagesListHeader' import {Error} from '#/components/Error' @@ -183,19 +186,50 @@ function InnerReady({ hasScrolled: boolean setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> }) { - const {_} = useLingui() const convoState = useConvo() const navigation = useNavigation<NavigationProp>() const {params} = useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() - const verifyEmailControl = useDialogControl() const {needsEmailVerification} = useEmail() + const emailDialogControl = useEmailDialogControl() - React.useEffect(() => { + /** + * Must be non-reactive, otherwise the update to open the global dialog will + * cause a re-render loop. + */ + const maybeBlockForEmailVerification = useNonReactiveCallback(() => { if (needsEmailVerification) { - verifyEmailControl.open() + /* + * HACKFIX + * + * Load bearing timeout, to bump this state update until the after the + * `navigator.addListener('state')` handler closes elements from + * `shell/index.*.tsx` - sfn & esb + */ + setTimeout(() => + emailDialogControl.open({ + id: EmailDialogScreenID.Verify, + instructions: [ + <Trans key="pre-compose"> + Before you can message another user, you must first verify your + email. + </Trans>, + ], + onCloseWithoutVerifying: () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Messages', {animation: 'pop'}) + } + }, + }), + ) } - }, [needsEmailVerification, verifyEmailControl]) + }) + + useEffect(() => { + maybeBlockForEmailVerification() + }, [maybeBlockForEmailVerification]) return ( <> @@ -216,15 +250,6 @@ function InnerReady({ } /> )} - <VerifyEmailDialog - reasonText={_( - msg`Before you may message another user, you must first verify your email.`, - )} - control={verifyEmailControl} - onCloseWithoutVerifying={() => { - navigation.navigate('Home') - }} - /> </> ) } diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index 62db09600..3490bec0d 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -1,11 +1,12 @@ import {useCallback} from 'react' -import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {NavigationProp} from '#/lib/routes/types' +import {useEmail} from '#/lib/hooks/useEmail' +import {type NavigationProp} from '#/lib/routes/types' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useAcceptConversation} from '#/state/queries/messages/accept-conversation' import {precacheConvoQuery} from '#/state/queries/messages/conversation' @@ -13,8 +14,17 @@ import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' -import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' +import { + Button, + ButtonIcon, + type ButtonProps, + ButtonText, +} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import { + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {ReportDialog} from '#/components/dms/ReportDialog' import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' @@ -186,6 +196,8 @@ export function AcceptChatButton({ const {_} = useLingui() const queryClient = useQueryClient() const navigation = useNavigation<NavigationProp>() + const {needsEmailVerification} = useEmail() + const emailDialogControl = useEmailDialogControl() const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, { onMutate: () => { @@ -216,8 +228,20 @@ export function AcceptChatButton({ }) const onPressAccept = useCallback(() => { - acceptConvo() - }, [acceptConvo]) + if (needsEmailVerification) { + emailDialogControl.open({ + id: EmailDialogScreenID.Verify, + instructions: [ + <Trans key="request-btn"> + Before you can accept this chat request, you must first verify your + email. + </Trans>, + ], + }) + } else { + acceptConvo() + } + }, [acceptConvo, needsEmailVerification, emailDialogControl]) return ( <Button diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/RequestListItem.tsx index 654691a01..5e09dd2e3 100644 --- a/src/screens/Messages/components/RequestListItem.tsx +++ b/src/screens/Messages/components/RequestListItem.tsx @@ -1,5 +1,5 @@ import {View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyConvoDefs} from '@atproto/api' import {Trans} from '@lingui/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index 3f517d334..2f4b87015 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -5,36 +5,39 @@ import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIsFocused, useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useQueryClient} from '@tanstack/react-query' import {VIDEO_FEED_URIS} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {ComposeIcon2} from '#/lib/icons' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' -import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' +import { + type FeedSourceFeedInfo, + useFeedSourceInfoQuery, +} from '#/state/queries/feed' +import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { usePreferencesQuery, - UsePreferencesQueryResponse, + type UsePreferencesQueryResponse, } from '#/state/queries/preferences' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import {PostFeed} from '#/view/com/posts/PostFeed' import {EmptyState} from '#/view/com/util/EmptyState' import {FAB} from '#/view/com/util/fab/FAB' import {Button} from '#/view/com/util/forms/Button' -import {ListRef} from '#/view/com/util/List' +import {type ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {Text} from '#/view/com/util/text/Text' @@ -156,7 +159,7 @@ export function ProfileFeedScreenInner({ }) { const {_} = useLingui() const {hasSession} = useSession() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const isScreenFocused = useIsFocused() useSetTitle(feedInfo?.displayName) diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 7c50bd8df..393bad2f8 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -9,8 +9,10 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' +import { + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake' import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' @@ -31,8 +33,7 @@ export function AccountSettingsScreen({}: Props) { const {_} = useLingui() const {currentAccount} = useSession() const {openModal} = useModalControls() - const verifyEmailControl = useDialogControl() - const changeEmailControl = useDialogControl() + const emailDialogControl = useEmailDialogControl() const birthdayControl = useDialogControl() const changeHandleControl = useDialogControl() const exportCarControl = useDialogControl() @@ -75,7 +76,11 @@ export function AccountSettingsScreen({}: Props) { {currentAccount && !currentAccount.emailConfirmed && ( <SettingsList.PressableItem label={_(msg`Verify your email`)} - onPress={() => verifyEmailControl.open()} + onPress={() => + emailDialogControl.open({ + id: EmailDialogScreenID.Verify, + }) + } style={[ a.my_xs, a.mx_lg, @@ -96,11 +101,15 @@ export function AccountSettingsScreen({}: Props) { </SettingsList.PressableItem> )} <SettingsList.PressableItem - label={_(msg`Change email`)} - onPress={() => changeEmailControl.open()}> + label={_(msg`Update email`)} + onPress={() => + emailDialogControl.open({ + id: EmailDialogScreenID.Update, + }) + }> <SettingsList.ItemIcon icon={PencilIcon} /> <SettingsList.ItemText> - <Trans>Change email</Trans> + <Trans>Update email</Trans> </SettingsList.ItemText> <SettingsList.Chevron /> </SettingsList.PressableItem> @@ -167,14 +176,6 @@ export function AccountSettingsScreen({}: Props) { </SettingsList.Container> </Layout.Content> - <ChangeEmailDialog - control={changeEmailControl} - verifyEmailControl={verifyEmailControl} - /> - <VerifyEmailDialog - control={verifyEmailControl} - changeEmailControl={changeEmailControl} - /> <BirthDateSettingsDialog control={birthdayControl} /> <ChangeHandleDialog control={changeHandleControl} /> <ExportCarDialog control={exportCarControl} /> diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx index 3e341cd73..584026298 100644 --- a/src/screens/Settings/components/Email2FAToggle.tsx +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -2,11 +2,12 @@ import React from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgent, useSession} from '#/state/session' +import {useSession} from '#/state/session' import {useDialogControl} from '#/components/Dialog' -import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' -import * as Prompt from '#/components/Prompt' +import { + EmailDialogScreenID, + useEmailDialogControl, +} from '#/components/dialogs/EmailDialog' import {DisableEmail2FADialog} from './DisableEmail2FADialog' import * as SettingsList from './SettingsList' @@ -14,63 +15,17 @@ export function Email2FAToggle() { const {_} = useLingui() const {currentAccount} = useSession() const disableDialogControl = useDialogControl() - const enableDialogControl = useDialogControl() - const verifyEmailDialogControl = useDialogControl() - const changeEmailDialogControl = useDialogControl() - const agent = useAgent() - - const enableEmailAuthFactor = React.useCallback(async () => { - if (currentAccount?.email) { - await agent.com.atproto.server.updateEmail({ - email: currentAccount.email, - emailAuthFactor: true, - }) - await agent.resumeSession(agent.session!) - } - }, [currentAccount, agent]) + const emailDialogControl = useEmailDialogControl() const onToggle = React.useCallback(() => { - if (!currentAccount) { - return - } - if (currentAccount.emailAuthFactor) { - disableDialogControl.open() - } else { - if (!currentAccount.emailConfirmed) { - verifyEmailDialogControl.open() - return - } - enableDialogControl.open() - } - }, [ - currentAccount, - enableDialogControl, - verifyEmailDialogControl, - disableDialogControl, - ]) + emailDialogControl.open({ + id: EmailDialogScreenID.Manage2FA, + }) + }, [emailDialogControl]) return ( <> <DisableEmail2FADialog control={disableDialogControl} /> - <Prompt.Basic - control={enableDialogControl} - title={_(msg`Enable Email 2FA`)} - description={_(msg`Require an email code to sign in to your account.`)} - onConfirm={enableEmailAuthFactor} - confirmButtonCta={_(msg`Enable`)} - /> - <VerifyEmailDialog - control={verifyEmailDialogControl} - changeEmailControl={changeEmailDialogControl} - onCloseAfterVerifying={enableDialogControl.open} - reasonText={_( - msg`You need to verify your email address before you can enable email 2FA.`, - )} - /> - <ChangeEmailDialog - control={changeEmailDialogControl} - verifyEmailControl={verifyEmailDialogControl} - /> <SettingsList.BadgeButton label={ currentAccount?.emailAuthFactor ? _(msg`Change`) : _(msg`Enable`) diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 344b93429..aabfe4b20 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -1,18 +1,18 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import { LayoutAnimation, - ListRenderItem, + type ListRenderItem, Pressable, ScrollView, View, - ViewabilityConfig, - ViewToken, + type ViewabilityConfig, + type ViewToken, } from 'react-native' import {SystemBars} from 'react-native-edge-to-edge' import { Gesture, GestureDetector, - NativeGesture, + type NativeGesture, } from 'react-native-gesture-handler' import Animated, { useAnimatedStyle, @@ -24,38 +24,46 @@ import { } from 'react-native-safe-area-context' import {useEvent} from 'expo' import {useEventListener} from 'expo' -import {Image, ImageStyle} from 'expo-image' +import {Image, type ImageStyle} from 'expo-image' import {LinearGradient} from 'expo-linear-gradient' -import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' +import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video' import { AppBskyEmbedVideo, - AppBskyFeedDefs, + type AppBskyFeedDefs, AppBskyFeedPost, AtUri, - ModerationDecision, + type ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import { - RouteProp, + type RouteProp, useFocusEffect, useIsFocused, useNavigation, useRoute, } from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {HITSLOP_20} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import { + type CommonNavigatorParams, + type NavigationProp, +} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {cleanError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' import {isAndroid} from '#/platform/detection' import {useA11y} from '#/state/a11y' -import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import { FeedFeedbackProvider, @@ -64,13 +72,13 @@ import { import {useFeedFeedback} from '#/state/feed-feedback' import {usePostLikeMutationQueue} from '#/state/queries/post' import { - AuthorFilter, - FeedPostSliceItem, + type AuthorFilter, + type FeedPostSliceItem, usePostFeedQuery, } from '#/state/queries/post-feed' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {useComposerControls, useSetMinimalShellMode} from '#/state/shell' +import {useSetMinimalShellMode} from '#/state/shell' import {useSetLightStatusBar} from '#/state/shell/light-status-bar' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' import {List} from '#/view/com/util/List' @@ -685,7 +693,7 @@ function Overlay({ }) { const {_} = useLingui() const t = useTheme() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {currentAccount} = useSession() const navigation = useNavigation<NavigationProp>() const seekingAnimationSV = useSharedValue(0) diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index b425873fc..ad07333be 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -125,5 +125,17 @@ export function useComposerState() { } export function useComposerControls() { - return React.useContext(controlsContext) + const {closeComposer} = React.useContext(controlsContext) + return React.useMemo(() => ({closeComposer}), [closeComposer]) +} + +/** + * DO NOT USE DIRECTLY. The deprecation notice as a warning only, it's not + * actually deprecated. + * + * @deprecated use `#/lib/hooks/useOpenComposer` instead + */ +export function useOpenComposer() { + const {openComposer} = React.useContext(controlsContext) + return React.useMemo(() => ({openComposer}), [openComposer]) } diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx index cfec5c445..4a48f5e45 100644 --- a/src/state/shell/composer/useComposerKeyboardShortcut.tsx +++ b/src/state/shell/composer/useComposerKeyboardShortcut.tsx @@ -1,11 +1,11 @@ import React from 'react' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useDialogStateContext} from '#/state/dialogs' import {useLightbox} from '#/state/lightbox' import {useModals} from '#/state/modals' import {useSession} from '#/state/session' import {useIsDrawerOpen} from '#/state/shell/drawer-open' -import {useComposerControls} from './' /** * Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2} @@ -39,7 +39,7 @@ function shouldIgnore(event: KeyboardEvent) { } export function useComposerKeyboardShortcut() { - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {openDialogs} = useDialogStateContext() const {isModalActive} = useModals() const {activeLightbox} = useLightbox() diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index f61dc3c41..92cf520a3 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import type React from 'react' import {Provider as ColorModeProvider} from './color-mode' import {Provider as DrawerOpenProvider} from './drawer-open' @@ -9,7 +9,6 @@ import {Provider as ShellLayoutProvder} from './shell-layout' import {Provider as TickEveryMinuteProvider} from './tick-every-minute' export {useSetThemePrefs, useThemePrefs} from './color-mode' -export {useComposerControls, useComposerState} from './composer' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { useIsDrawerSwipeDisabled, diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b6d269d28..e690e7256 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -62,7 +62,6 @@ import { type SupportedMimeTypes, } from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' @@ -120,8 +119,6 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -331,15 +328,6 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) - const {needsEmailVerification} = useEmail() - const emailVerificationControl = useDialogControl() - - useEffect(() => { - if (needsEmailVerification) { - emailVerificationControl.open() - } - }, [needsEmailVerification, emailVerificationControl]) - const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { return @@ -620,15 +608,6 @@ export const ComposePost = ({ const isWebFooterSticky = !isNative && thread.posts.length > 1 return ( <BottomSheetPortalProvider> - <VerifyEmailDialog - control={emailVerificationControl} - onCloseWithoutVerifying={() => { - onClose() - }} - reasonText={_( - msg`Before creating a post, you must first verify your email.`, - )} - /> <KeyboardAvoidingView testID="composePostView" behavior={isIOS ? 'padding' : 'height'} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 8d9371f0d..96715955f 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,26 +1,19 @@ import {useCallback} from 'react' -import {Keyboard} from 'react-native' -import {ImagePickerAsset} from 'expo-image-picker' +import {type ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { SUPPORTED_MIME_TYPES, - SupportedMimeTypes, + type SupportedMimeTypes, VIDEO_MAX_DURATION_MS, } from '#/lib/constants' -import {BSKY_SERVICE} from '#/lib/constants' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' -import {getHostnameFromUrl} from '#/lib/strings/url-helpers' import {isWeb} from '#/platform/detection' import {isNative} from '#/platform/detection' -import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' -import * as Prompt from '#/components/Prompt' import {pickVideo} from './pickVideo' type Props = { @@ -33,66 +26,45 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { const {_} = useLingui() const t = useTheme() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() - const control = Prompt.usePromptControl() - const {currentAccount} = useSession() const onPressSelectVideo = useCallback(async () => { if (isNative && !(await requestVideoAccessIfNeeded())) { return } - if ( - currentAccount && - !currentAccount.emailConfirmed && - getHostnameFromUrl(currentAccount.service) === - getHostnameFromUrl(BSKY_SERVICE) - ) { - Keyboard.dismiss() - control.open() - } else { - const response = await pickVideo() - if (response.assets && response.assets.length > 0) { - const asset = response.assets[0] - try { - if (isWeb) { - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - // compression step on native converts to mp4, so no need to check there - if ( - !SUPPORTED_MIME_TYPES.includes( - asset.mimeType as SupportedMimeTypes, - ) - ) { - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) - } - } else { - if (typeof asset.duration !== 'number') { - throw Error('Asset is not a video') - } - if (asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } + const response = await pickVideo() + if (response.assets && response.assets.length > 0) { + const asset = response.assets[0] + try { + if (isWeb) { + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) + if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { + throw Error(_(msg`Videos must be less than 3 minutes long`)) } - onSelectVideo(asset) - } catch (err) { - if (err instanceof Error) { - setError(err.message) - } else { - setError(_(msg`An error occurred while selecting the video`)) + // compression step on native converts to mp4, so no need to check there + if ( + !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) + ) { + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) } + } else { + if (typeof asset.duration !== 'number') { + throw Error('Asset is not a video') + } + if (asset.duration > VIDEO_MAX_DURATION_MS) { + throw Error(_(msg`Videos must be less than 3 minutes long`)) + } + } + onSelectVideo(asset) + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError(_(msg`An error occurred while selecting the video`)) } } } - }, [ - requestVideoAccessIfNeeded, - currentAccount, - control, - setError, - _, - onSelectVideo, - ]) + }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) return ( <> @@ -111,30 +83,6 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { style={disabled && t.atoms.text_contrast_low} /> </Button> - <VerifyEmailPrompt control={control} /> - </> - ) -} - -function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { - const {_} = useLingui() - const verifyEmailDialogControl = useDialogControl() - - return ( - <> - <Prompt.Basic - control={control} - title={_(msg`Verified email required`)} - description={_( - msg`To upload videos to Bluesky, you must first verify your email.`, - )} - confirmButtonCta={_(msg`Verify now`)} - confirmButtonColor="primary" - onConfirm={() => { - verifyEmailDialogControl.open() - }} - /> - <VerifyEmailDialog control={verifyEmailDialogControl} /> </> ) } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index f643adaf9..604533b0f 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -1,32 +1,32 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' +import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NavigationProp, useNavigation} from '@react-navigation/native' +import {type NavigationProp, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {VIDEO_FEED_URIS} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {ComposeIcon2} from '#/lib/icons' import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' -import {AllNavigatorParams} from '#/lib/routes/types' +import {type AllNavigatorParams} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {useSetHomeBadge} from '#/state/home-badge' -import {SavedFeedSourceInfo} from '#/state/queries/feed' +import {type SavedFeedSourceInfo} from '#/state/queries/feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' +import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' import {PostFeed} from '../posts/PostFeed' import {FAB} from '../util/fab/FAB' -import {ListMethods} from '../util/List' +import {type ListMethods} from '../util/List' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' import {MainScrollProvider} from '../util/MainScrollProvider' @@ -57,7 +57,7 @@ export function FeedPage({ const {_} = useLingui() const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() const queryClient = useQueryClient() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const [isScrolledDown, setIsScrolledDown] = React.useState(false) const setMinimalShellMode = useSetMinimalShellMode() const headerOffset = useHeaderOffset() diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 83dbdb553..d974ce6b5 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -5,7 +5,7 @@ import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { AppBskyFeedDefs, - AppBskyFeedThreadgate, + type AppBskyFeedThreadgate, moderatePost, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -14,6 +14,7 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {clamp} from '#/lib/numbers' @@ -25,19 +26,18 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import { fillThreadModerationCache, sortThread, - ThreadBlocked, - ThreadModerationCache, - ThreadNode, - ThreadNotFound, - ThreadPost, + type ThreadBlocked, + type ThreadModerationCache, + type ThreadNode, + type ThreadNotFound, + type ThreadPost, usePostThreadQuery, } from '#/state/queries/post-thread' import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {List, ListMethods} from '#/view/com/util/List' +import {List, type ListMethods} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' @@ -394,7 +394,7 @@ export function PostThread({uri}: {uri: string | undefined}) { [refetch], ) - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const onPressReply = React.useCallback(() => { if (thread?.type !== 'post') { return diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index dfd641f66..10c3e6b4d 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -17,6 +17,7 @@ import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' @@ -36,7 +37,6 @@ import {useProfileShadow} from '#/state/cache/profile-shadow' import {useLanguagePrefs} from '#/state/preferences' import {type ThreadPost} from '#/state/queries/post-thread' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' @@ -204,7 +204,7 @@ let PostThreadItemLoaded = ({ const pal = usePalette('default') const {_, i18n} = useLingui() const langPrefs = useLanguagePrefs() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const [limitLines, setLimitLines] = React.useState( () => countLines(richText?.text) >= MAX_POST_LINES, ) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 600cee428..c6cf254f3 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -1,11 +1,11 @@ import React, {useMemo, useState} from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import { - AppBskyFeedDefs, + type AppBskyFeedDefs, AppBskyFeedPost, AtUri, moderatePost, - ModerationDecision, + type ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -14,15 +14,19 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {countLines} from '#/lib/strings/helpers' import {colors, s} from '#/lib/styles' -import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -113,7 +117,7 @@ function PostInner({ const queryClient = useQueryClient() const pal = usePalette('default') const {_} = useLingui() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const [limitLines, setLimitLines] = useState( () => countLines(richText?.text) >= MAX_POST_LINES, ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index facd31e5f..123a8b0c2 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -19,6 +19,7 @@ import {useQueryClient} from '@tanstack/react-query' import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' @@ -33,7 +34,6 @@ import { import {useFeedFeedbackContext} from '#/state/feed-feedback' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {FeedNameText} from '#/view/com/util/FeedInfoText' import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' @@ -159,7 +159,7 @@ let FeedItemInner = ({ onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { const queryClient = useQueryClient() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const pal = usePalette('default') const {_} = useLingui() diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index d97654a63..a9cae8886 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -22,6 +22,7 @@ import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' import {useGate} from '#/lib/statsig/statsig' @@ -33,7 +34,6 @@ import { usePostRepostMutationQueue, } from '#/state/queries/post' import {useRequireAuth, useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import { ProgressGuideAction, useProgressGuideControls, @@ -76,7 +76,7 @@ let PostCtrls = ({ }): React.ReactNode => { const t = useTheme() const {_, i18n} = useLingui() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {currentAccount} = useSession() const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 520400fd1..ca0280116 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,30 +1,33 @@ import React from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {AppBskyFeedDefs} from '@atproto/api' +import {type AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import debounce from 'lodash.debounce' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {ComposeIcon2} from '#/lib/icons' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {s} from '#/lib/styles' import {isNative, isWeb} from '#/platform/detection' import { - SavedFeedItem, + type SavedFeedItem, useGetPopularFeedsQuery, useSavedFeeds, useSearchPopularFeedsMutation, } from '#/state/queries/feed' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {FAB} from '#/view/com/util/fab/FAB' -import {List, ListMethods} from '#/view/com/util/List' +import {List, type ListMethods} from '#/view/com/util/List' import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {Text} from '#/view/com/util/text/Text' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' @@ -102,7 +105,7 @@ type FlatlistSlice = export function FeedsScreen(_props: Props) { const pal = usePalette('default') - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {isMobile} = useWebMediaQueries() const [query, setQuery] = React.useState('') const [isPTR, setIsPTR] = React.useState(false) diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index 300153966..bcda97dc5 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -4,16 +4,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' -import {useEmail} from '#/lib/hooks/useEmail' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {useModalControls} from '#/state/modals' import {useSetMinimalShellMode} from '#/state/shell' import {MyLists} from '#/view/com/lists/MyLists' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' @@ -23,8 +24,7 @@ export function ListsScreen({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() - const {needsEmailVerification} = useEmail() - const control = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() useFocusEffect( React.useCallback(() => { @@ -33,11 +33,6 @@ export function ListsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { - if (needsEmailVerification) { - control.open() - return - } - openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#curatelist', @@ -51,7 +46,15 @@ export function ListsScreen({}: Props) { } catch {} }, }) - }, [needsEmailVerification, control, openModal, navigation]) + }, [openModal, navigation]) + + const wrappedOnPressNewList = requireEmailVerification(onPressNewList, { + instructions: [ + <Trans key="lists"> + Before creating a list, you must first verify your email. + </Trans>, + ], + }) return ( <Layout.Screen testID="listsScreen"> @@ -68,7 +71,7 @@ export function ListsScreen({}: Props) { color="secondary" variant="solid" size="small" - onPress={onPressNewList}> + onPress={wrappedOnPressNewList}> <ButtonIcon icon={PlusIcon} /> <ButtonText> <Trans context="action">New</Trans> @@ -76,12 +79,6 @@ export function ListsScreen({}: Props) { </Button> </Layout.Header.Outer> <MyLists filter="curate" style={a.flex_grow} /> - <VerifyEmailDialog - reasonText={_( - msg`Before creating a list, you must first verify your email.`, - )} - control={control} - /> </Layout.Screen> ) } diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 0b5090e3d..23ed492f6 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -4,16 +4,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' -import {useEmail} from '#/lib/hooks/useEmail' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {useModalControls} from '#/state/modals' import {useSetMinimalShellMode} from '#/state/shell' import {MyLists} from '#/view/com/lists/MyLists' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' @@ -23,8 +24,7 @@ export function ModerationModlistsScreen({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() - const {needsEmailVerification} = useEmail() - const control = useDialogControl() + const requireEmailVerification = useRequireEmailVerification() useFocusEffect( React.useCallback(() => { @@ -33,11 +33,6 @@ export function ModerationModlistsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { - if (needsEmailVerification) { - control.open() - return - } - openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#modlist', @@ -51,7 +46,15 @@ export function ModerationModlistsScreen({}: Props) { } catch {} }, }) - }, [needsEmailVerification, control, openModal, navigation]) + }, [openModal, navigation]) + + const wrappedOnPressNewList = requireEmailVerification(onPressNewList, { + instructions: [ + <Trans key="modlist"> + Before creating a list, you must first verify your email. + </Trans>, + ], + }) return ( <Layout.Screen testID="moderationModlistsScreen"> @@ -68,7 +71,7 @@ export function ModerationModlistsScreen({}: Props) { color="secondary" variant="solid" size="small" - onPress={onPressNewList}> + onPress={wrappedOnPressNewList}> <ButtonIcon icon={PlusIcon} /> <ButtonText> <Trans context="action">New</Trans> @@ -76,12 +79,6 @@ export function ModerationModlistsScreen({}: Props) { </Button> </Layout.Header.Outer> <MyLists filter="mod" style={a.flex_grow} /> - <VerifyEmailDialog - reasonText={_( - msg`Before creating a list, you must first verify your email.`, - )} - control={control} - /> </Layout.Screen> ) } diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 1880fb816..ace0de2ae 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -6,10 +6,11 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {ComposeIcon2} from '#/lib/icons' import { - NativeStackScreenProps, - NotificationsTabNavigatorParams, + type NativeStackScreenProps, + type NotificationsTabNavigatorParams, } from '#/lib/routes/types' import {s} from '#/lib/styles' import {logger} from '#/logger' @@ -22,12 +23,11 @@ import { } from '#/state/queries/notifications/unread' import {truncateAndInvalidate} from '#/state/queries/util' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {NotificationFeed} from '#/view/com/notifications/NotificationFeed' import {Pager} from '#/view/com/pager/Pager' import {TabBar} from '#/view/com/pager/TabBar' import {FAB} from '#/view/com/util/fab/FAB' -import {ListMethods} from '#/view/com/util/List' +import {type ListMethods} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' import {atoms as a} from '#/alf' @@ -49,7 +49,7 @@ type Props = NativeStackScreenProps< > export function NotificationsScreen({}: Props) { const {_} = useLingui() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const unreadNotifs = useUnreadNotifications() const hasNew = !!unreadNotifs const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 425d55656..cc339bb03 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -2,9 +2,9 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import { - AppBskyActorDefs, + type AppBskyActorDefs, moderateProfile, - ModerationOpts, + type ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' import {msg} from '@lingui/macro' @@ -12,9 +12,13 @@ import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {ComposeIcon2} from '#/lib/icons' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {combinedDisplayName} from '#/lib/strings/display-names' import {cleanError} from '#/lib/strings/errors' import {isInvalidHandle} from '#/lib/strings/handles' @@ -28,13 +32,12 @@ import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useAgent, useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileLists} from '#/view/com/lists/ProfileLists' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {FAB} from '#/view/com/util/fab/FAB' -import {ListRef} from '#/view/com/util/List' +import {type ListRef} from '#/view/com/util/List' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' @@ -165,7 +168,7 @@ function ProfileScreenLoaded({ const profile = useProfileShadow(profileUnshadowed) const {hasSession, currentAccount} = useSession() const setMinimalShellMode = useSetMinimalShellMode() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const { data: labelerInfo, error: labelerError, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 61f1eb745..78cf5d11e 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -16,6 +16,7 @@ import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useHaptics} from '#/lib/haptics' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' @@ -54,7 +55,6 @@ import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {ListMembers} from '#/view/com/lists/ListMembers' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {PostFeed} from '#/view/com/posts/PostFeed' @@ -155,7 +155,7 @@ function ProfileListScreenLoaded({ }) { const {_} = useLingui() const queryClient = useQueryClient() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const setMinimalShellMode = useSetMinimalShellMode() const {currentAccount} = useSession() const {rkey} = route.params diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 0688fb5dc..7d34a3d14 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -10,6 +10,7 @@ import { } from '@react-navigation/native' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {getCurrentRoute, isTab} from '#/lib/routes/helpers' @@ -25,7 +26,6 @@ import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfilesQuery} from '#/state/queries/profile' import {type SessionAccount, useSession, useSessionApi} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' @@ -447,7 +447,7 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) { function ComposeBtn() { const {currentAccount} = useSession() const {getState} = useNavigation() - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const {_} = useLingui() const {leftNavMinimal} = useLayoutBreakpoints() const [isFetchingHandle, setIsFetchingHandle] = React.useState(false) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 1e34f6da5..cd328c457 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' +import {EmailDialog} from '#/components/dialogs/EmailDialog' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' @@ -152,6 +153,7 @@ function ShellInner() { <ModalsContainer /> <MutedWordsDialog /> <SigninDialog /> + <EmailDialog /> <InAppBrowserConsentDialog /> <Lightbox /> <PortalOutlet /> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 898ff8fa8..a7ff76d61 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox' import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' +import {EmailDialog} from '#/components/dialogs/EmailDialog' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' import {Outlet as PortalOutlet} from '#/components/Portal' @@ -67,6 +68,7 @@ function ShellInner() { <ModalsContainer /> <MutedWordsDialog /> <SigninDialog /> + <EmailDialog /> <Lightbox /> <PortalOutlet /> |