Merge branch 'main' into hailey/79fab
This commit is contained in:
@@ -146,7 +146,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
func updateLayout() {
|
||||
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
|
||||
if (self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil),
|
||||
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil,
|
||||
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
|
||||
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
|
||||
preventExpansion: self.preventExpansion)
|
||||
@@ -156,9 +156,15 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
guard let sheetVc = self.sheetVc else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isClosing = true
|
||||
self.sheetVc?.dismiss(animated: true) { [weak self] in
|
||||
self?.destroy()
|
||||
DispatchQueue.main.async {
|
||||
sheetVc.dismiss(animated: true) { [weak self] in
|
||||
self?.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"hls.js": "^1.6.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lande": "^1.0.10",
|
||||
|
||||
+8
-5
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -298,9 +298,7 @@ export const InnerFlatList = React.forwardRef<
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
|
||||
}
|
||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={[style]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
onPress: props.onClick,
|
||||
onFocus: onFocus,
|
||||
onBlur: onBlur,
|
||||
onMouseEnter,
|
||||
|
||||
@@ -53,7 +53,9 @@ export type RadixPassThroughTriggerProps = {
|
||||
['aria-controls']?: string
|
||||
['aria-haspopup']?: boolean
|
||||
['aria-expanded']?: AccessibilityProps['aria-expanded']
|
||||
onPress: () => void
|
||||
onClick: () => void
|
||||
onPointerDown: () => void
|
||||
onKeyDown: () => void
|
||||
}
|
||||
|
||||
export type TriggerProps = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
}
|
||||
+740
-535
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {InfoCircleIcon} from '#/lib/icons'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
|
||||
export function HelpTip({text}: {text: string}) {
|
||||
const bg = useColorSchemeStyle(
|
||||
{backgroundColor: colors.gray1},
|
||||
{backgroundColor: colors.gray8},
|
||||
)
|
||||
const fg = useColorSchemeStyle({color: colors.gray5}, {color: colors.gray4})
|
||||
return (
|
||||
<View style={[styles.helptip, bg]}>
|
||||
<View style={styles.icon}>
|
||||
<InfoCircleIcon size={18} style={fg} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text type="xs-medium" style={[fg, s.ml5, s.flex1]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
icon: {
|
||||
width: 18,
|
||||
},
|
||||
helptip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import {ComponentProps} from 'react'
|
||||
import {StyleSheet, TextInput as RNTextInput, View} from 'react-native'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
|
||||
interface Props extends Omit<ComponentProps<typeof RNTextInput>, 'onChange'> {
|
||||
testID?: string
|
||||
icon: IconProp
|
||||
onChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function TextInput({testID, icon, onChange, ...props}: Props) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[pal.border, styles.container]}>
|
||||
<FontAwesomeIcon icon={icon} style={[pal.textLight, styles.icon]} />
|
||||
<RNTextInput
|
||||
testID={testID}
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
onChangeText={v => onChange(v)}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 10,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.25,
|
||||
fontWeight: '400',
|
||||
borderRadius: 10,
|
||||
},
|
||||
})
|
||||
@@ -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'}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -182,6 +182,7 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
const ProfileFeedgensFooter = React.useCallback(() => {
|
||||
if (isEmpty) return null
|
||||
return (
|
||||
<ListFooter
|
||||
hasNextPage={hasNextPage}
|
||||
@@ -191,7 +192,14 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
height={180 + headerOffset}
|
||||
/>
|
||||
)
|
||||
}, [hasNextPage, error, isFetchingNextPage, headerOffset, fetchNextPage])
|
||||
}, [
|
||||
hasNextPage,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
headerOffset,
|
||||
fetchNextPage,
|
||||
isEmpty,
|
||||
])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
|
||||
@@ -178,6 +178,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
const ProfileListsFooter = React.useCallback(() => {
|
||||
if (isEmpty) return null
|
||||
return (
|
||||
<ListFooter
|
||||
hasNextPage={hasNextPage}
|
||||
@@ -187,7 +188,14 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
height={180 + headerOffset}
|
||||
/>
|
||||
)
|
||||
}, [hasNextPage, error, isFetchingNextPage, headerOffset, fetchNextPage])
|
||||
}, [
|
||||
hasNextPage,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
headerOffset,
|
||||
fetchNextPage,
|
||||
isEmpty,
|
||||
])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-20
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -12333,10 +12333,10 @@ history@^5.3.0:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.7.6"
|
||||
|
||||
hls.js@^1.5.11:
|
||||
version "1.5.11"
|
||||
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"
|
||||
integrity sha512-q3We1izi2+qkOO+TvZdHv+dx6aFzdtk3xc1/Qesrvto4thLTT/x/1FK85c5h1qZE4MmMBNgKg+MIW8nxQfxwBw==
|
||||
hls.js@^1.6.2:
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.2.tgz#02272bea644b5f61f71741256618d6b629ae7834"
|
||||
integrity sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ==
|
||||
|
||||
hmac-drbg@^1.0.1:
|
||||
version "1.0.1"
|
||||
|
||||
Reference in New Issue
Block a user