Merge branch 'main' into hailey/79fab

This commit is contained in:
Hailey
2025-05-08 10:59:12 -07:00
66 changed files with 3034 additions and 1044 deletions
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 />
</>
)
}
+1 -3
View File
@@ -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]}
+7 -1
View File
@@ -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>
+1
View File
@@ -73,6 +73,7 @@ export function Trigger({children, label}: TriggerProps) {
},
props: {
...props,
onPress: props.onClick,
onFocus: onFocus,
onBlur: onBlur,
onMouseEnter,
+3 -1
View File
@@ -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>
)
}
+13 -2
View File
@@ -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
}
+15 -28
View File
@@ -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 {
+15 -20
View File
@@ -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}
/>
</>
)
}
+91
View File
@@ -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
}
+2 -2
View File
@@ -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(
+22
View File
@@ -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,
],
)
}
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -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>
+43 -18
View File
@@ -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'
+12 -9
View File
@@ -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)
+17 -16
View File
@@ -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`)
+24 -16
View File
@@ -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)
+13 -1
View File
@@ -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 -2
View File
@@ -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,
-37
View File
@@ -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,
},
})
-56
View File
@@ -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,
},
})
-21
View File
@@ -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'}
+30 -82
View File
@@ -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} />
</>
)
}
+8 -8
View File
@@ -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()
+9 -1
View File
@@ -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}>
+9 -1
View File
@@ -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}>
+9 -9
View File
@@ -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
+2 -2
View File
@@ -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,
)
+10 -6
View File
@@ -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,
)
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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(
+9 -6
View File
@@ -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
View File
@@ -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>
)
}
+17 -20
View File
@@ -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>
)
}
+5 -5
View File
@@ -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()
+9 -6
View File
@@ -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,
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+2
View File
@@ -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 />
+2
View File
@@ -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 />
+4 -4
View File
@@ -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"