The Great Unjanking of the Sheets (#9973)

This commit is contained in:
Samuel Newman
2026-03-09 22:53:32 +02:00
committed by GitHub
parent 18d7e775f6
commit aa897f55a0
27 changed files with 318 additions and 196 deletions
+50 -34
View File
@@ -1,5 +1,14 @@
import React, {useImperativeHandle} from 'react'
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Keyboard,
type KeyboardEventListener,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
@@ -34,6 +43,7 @@ import {
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
@@ -58,21 +68,21 @@ export function Outer({
}: React.PropsWithChildren<DialogOuterProps>) {
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = React.useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const ref = useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
const prevSnapPoint = useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
)
const [disableDrag, setDisableDrag] = React.useState(false)
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
const [disableDrag, setDisableDrag] = useState(false)
const [snapPoint, setSnapPoint] = useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
const callQueuedCallbacks = React.useCallback(() => {
const callQueuedCallbacks = useCallback(() => {
for (const cb of closeCallbacks.current) {
try {
cb()
@@ -84,7 +94,7 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(() => {
const open = useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
@@ -92,7 +102,7 @@ export function Outer({
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
const close = useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
@@ -101,7 +111,7 @@ export function Outer({
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
// happen before we run this. It is passed to the `BottomSheet` component.
const onCloseAnimationComplete = React.useCallback(() => {
const onCloseAnimationComplete = useCallback(() => {
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
@@ -147,7 +157,7 @@ export function Outer({
[open, close],
)
const context = React.useMemo(
const context = useMemo(
() => ({
close,
isNativeDialog: true,
@@ -201,25 +211,23 @@ export function Inner({children, style, header}: DialogInnerProps) {
)
}
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = useState(() =>
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
)
let paddingBottom = 0
if (IS_IOS) {
paddingBottom = tokens.space._2xl
} else {
paddingBottom =
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
if (isAtMaxSnapPoint) {
paddingBottom += insets.top
}
}
const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
setKeyboardHeight(e.endCoordinates.height)
}, [])
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!IS_ANDROID) {
@@ -238,7 +246,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
{paddingBottom},
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref}
@@ -250,7 +263,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{...props}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
onScroll={IS_ANDROID ? onScroll : undefined}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
@@ -263,7 +281,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
},
)
export const InnerFlatList = React.forwardRef<
export const InnerFlatList = forwardRef<
ListMethods,
ListProps<any> & {
webInnerStyle?: StyleProp<ViewStyle>
@@ -293,7 +311,10 @@ export const InnerFlatList = React.forwardRef<
}
return (
<ScrollProvider onScroll={onScroll}>
<ScrollProvider
onScroll={onScroll}
onEndDrag={onScroll}
onMomentumEnd={onScroll}>
<List
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior={
@@ -327,7 +348,7 @@ export function FlatListFooter({
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
const {top, bottom} = useSafeAreaInsets()
const {bottom} = useSafeAreaInsets()
const {height} = useReanimatedKeyboardAnimation()
const animatedStyle = useAnimatedStyle(() => {
@@ -350,12 +371,7 @@ export function FlatListFooter({
t.atoms.border_contrast_low,
a.px_lg,
a.pt_md,
{
paddingBottom: platform({
ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0),
android: tokens.space.md + bottom + top,
}),
},
{paddingBottom: bottom + tokens.space.md},
// TODO: had to admit defeat here, but we should
// try and get this to work for Android as well -sfn
ios(animatedStyle),
+3 -10
View File
@@ -1,10 +1,5 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
TextInput,
useWindowDimensions,
View,
type ViewToken,
} from 'react-native'
import {TextInput, View, type ViewToken} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -72,7 +67,6 @@ export function FollowDialog({
const {_} = useLingui()
const control = Dialog.useDialogControl()
const {gtPhone} = useBreakpoints()
const {height: minHeight} = useWindowDimensions()
return (
<>
@@ -89,7 +83,7 @@ export function FollowDialog({
</ButtonText>
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
</Button>
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<DialogInner guide={guide} />
</Dialog.Outer>
@@ -105,9 +99,8 @@ export function FollowDialogWithoutGuide({
}: {
control: Dialog.DialogOuterProps['control']
}) {
const {height: minHeight} = useWindowDimensions()
return (
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<DialogInner />
</Dialog.Outer>
+1 -1
View File
@@ -151,7 +151,7 @@ export function Content<T>({
}, [items, context.value, valueExtractor, setValue])
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
<ContentInner
control={control}
items={items}
@@ -78,7 +78,10 @@ export function WizardEditListDialog({
)
return (
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<Dialog.InnerFlatList
ref={listRef}
+1
View File
@@ -68,6 +68,7 @@ export function GifSelectDialog({
bottomInset: 0,
// use system corner radius on iOS
...ios({cornerRadius: undefined}),
fullHeight: true,
}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
@@ -1,6 +1,5 @@
import {useCallback, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -17,7 +16,7 @@ import {SearchInput} from '#/components/forms/SearchInput'
import * as Toggle from '#/components/forms/Toggle'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
type FlatListItem =
| {
@@ -51,20 +50,13 @@ export function LanguageSelectDialog({
onSelectLanguages: (languages: string[]) => void
maxLanguages?: number
}) {
const {height} = useWindowDimensions()
const insets = useSafeAreaInsets()
const renderErrorBoundary = useCallback(
(error: any) => <DialogError details={String(error)} />,
[],
)
return (
<Dialog.Outer
control={control}
nativeOptions={{
minHeight: IS_LIQUID_GLASS ? height : height - insets.top,
}}>
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<DialogInner
+2 -6
View File
@@ -1,5 +1,5 @@
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -28,7 +28,6 @@ export function ServerInputDialog({
onSelect: (url: string) => void
}) {
const ax = useAnalytics()
const {height} = useWindowDimensions()
const formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
@@ -53,10 +52,7 @@ export function ServerInputDialog({
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={platform({
android: {minHeight: height / 2},
ios: {preventExpansion: true},
})}>
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
formRef={formRef}
+1 -1
View File
@@ -75,7 +75,7 @@ export function StarterPackDialog({
})
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<StarterPackList
onStartWizard={wrappedNavToWizard}
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {View} from 'react-native'
import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -53,7 +53,6 @@ export function CreateOrEditListDialog({
const {_} = useLingui()
const cancelControl = Dialog.useDialogControl()
const [dirty, setDirty] = useState(false)
const {height} = useWindowDimensions()
// 'You might lose unsaved changes' warning
useEffect(() => {
@@ -82,7 +81,7 @@ export function CreateOrEditListDialog({
control={control}
nativeOptions={{
preventDismiss: dirty,
minHeight: height,
fullHeight: true,
}}
testID="createOrEditListDialog">
<DialogInner
@@ -39,7 +39,10 @@ export function ListAddRemoveUsersDialog({
) => void | undefined
}) {
return (
<Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
<Dialog.Outer
control={control}
testID="listAddRemoveUsersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<DialogInner list={list} onChange={onChange} />
</Dialog.Outer>
+4 -1
View File
@@ -70,7 +70,10 @@ export function NewChat({
accessibilityHint=""
/>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<SearchablePeopleList
title={_(msg`Start a new chat`)}
@@ -17,7 +17,10 @@ export function SendViaChatDialog({
onSelectChat: (chatId: string) => void
}) {
return (
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer>
+13 -6
View File
@@ -1,12 +1,19 @@
import React from 'react'
import {Keyboard} from 'react-native'
import {useEffect} from 'react'
import {
Keyboard,
type KeyboardEventListener,
type KeyboardEventName,
} from 'react-native'
export function useOnKeyboardDidShow(cb: () => unknown) {
React.useEffect(() => {
const subscription = Keyboard.addListener('keyboardDidShow', cb)
export function useOnKeyboard(
eventName: KeyboardEventName,
cb: KeyboardEventListener,
) {
useEffect(() => {
const subscription = Keyboard.addListener(eventName, cb)
return () => {
subscription.remove()
}
}, [cb])
}, [eventName, cb])
}