interactive dismiss for ios

This commit is contained in:
Samuel Newman
2025-01-27 15:54:14 -08:00
parent ea932534df
commit 7daad180b7
4 changed files with 118 additions and 27 deletions
@@ -29,6 +29,7 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField' import {useSharedInputStyles} from '#/components/forms/TextField'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {MessageInputBackground} from './MessageInputBackground'
import {useExtractEmbedFromFacets} from './MessageInputEmbed' import {useExtractEmbedFromFacets} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
@@ -127,7 +128,7 @@ export function MessageInput({
})) }))
return ( return (
<View style={[a.px_md, a.pb_sm, a.pt_xs]}> <MessageInputBackground style={[a.px_md, a.py_xs]}>
{children} {children}
<View <View
style={[ style={[
@@ -160,7 +161,7 @@ export function MessageInput({
animatedStyle, animatedStyle,
]} ]}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
blurOnSubmit={false} submitBehavior="submit"
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
ref={inputRef} ref={inputRef}
@@ -184,6 +185,6 @@ export function MessageInput({
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable> </Pressable>
</View> </View>
</View> </MessageInputBackground>
) )
} }
@@ -0,0 +1,25 @@
import {View} from 'react-native'
import {BlurView} from 'expo-blur'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
export function MessageInputBackground({
style,
children,
}: React.PropsWithChildren<ViewStyleProp>) {
const t = useTheme()
return (
<BlurView style={[a.relative, style]} tint={t.scheme} intensity={100}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
a.pointer_events_none,
{opacity: 0.8},
]}
/>
{children}
</BlurView>
)
}
@@ -0,0 +1,11 @@
import {View} from 'react-native'
import {useTheme, ViewStyleProp} from '#/alf'
export function MessageInputBackground({
style,
children,
}: React.PropsWithChildren<ViewStyleProp>) {
const t = useTheme()
return <View style={[t.atoms.bg, style]}>{children}</View>
}
@@ -4,15 +4,14 @@ import {useKeyboardHandler} from 'react-native-keyboard-controller'
import Animated, { import Animated, {
runOnJS, runOnJS,
scrollTo, scrollTo,
useAnimatedProps,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import { import {
@@ -20,20 +19,22 @@ import {
isBskyPostUrl, isBskyPostUrl,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isIOS, isNative} from '#/platform/detection'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo' import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerState, EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web' } from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List, ListMethods} from '#/view/com/util/List' import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError' import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
@@ -90,6 +91,8 @@ export function MessagesList({
const agent = useAgent() const agent = useAgent()
const getPost = useGetPost() const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed() const {embedUri, setEmbed} = useMessageEmbed()
const {footerHeight} = useShellLayout()
const messageInputHeight = useSharedValue(52)
const flatListRef = useAnimatedRef<ListMethods>() const flatListRef = useAnimatedRef<ListMethods>()
@@ -237,9 +240,6 @@ export function MessagesList({
) )
// -- Keyboard animation handling // -- Keyboard animation handling
const {bottom: bottomInset} = useSafeAreaInsets()
const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
const keyboardHeight = useSharedValue(0) const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false) const keyboardIsOpening = useSharedValue(false)
@@ -263,28 +263,68 @@ export function MessagesList({
onMove: e => { onMove: e => {
'worklet' 'worklet'
keyboardHeight.set(e.height) keyboardHeight.set(e.height)
if (e.height > bottomOffset) { if (isIOS || e.height > footerHeight.get() + messageInputHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false) scrollTo(flatListRef, 0, 1e7, false)
} }
}, },
onInteractive: e => {
'worklet'
keyboardHeight.set(e.height)
},
onEnd: e => { onEnd: e => {
'worklet' 'worklet'
keyboardHeight.set(e.height) keyboardHeight.set(e.height)
if (e.height > bottomOffset) { if (isIOS || e.height > footerHeight.get() + messageInputHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false) scrollTo(flatListRef, 0, 1e7, false)
} }
keyboardIsOpening.set(false) keyboardIsOpening.set(false)
}, },
}, },
[bottomOffset], [],
) )
const animatedListStyle = useAnimatedStyle(() => ({ const animatedListStyle = useAnimatedStyle(() => {
marginBottom: Math.max(keyboardHeight.get(), bottomOffset), if (!isIOS) {
})) return {
marginBottom: Math.max(
keyboardHeight.get(),
footerHeight.get() + messageInputHeight.get(),
),
}
}
return {}
})
const animatedProps = useAnimatedProps(() => {
if (isIOS) {
return {
scrollIndicatorInsets: {
top: 0,
left: 0,
right: 1,
bottom:
Math.max(keyboardHeight.get(), footerHeight.get()) +
messageInputHeight.get(),
},
contentInset: {
top: 0,
left: 0,
right: 0,
bottom:
Math.max(keyboardHeight.get(), footerHeight.get()) +
messageInputHeight.get(),
},
} satisfies Partial<ListProps>
}
return {}
})
const animatedStickyViewStyle = useAnimatedStyle(() => ({ const animatedStickyViewStyle = useAnimatedStyle(() => ({
transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}], transform: [
{
translateY: -Math.max(keyboardHeight.get(), footerHeight.get()),
},
],
})) }))
// -- Message sending // -- Message sending
@@ -406,11 +446,12 @@ export function MessagesList({
disableFullWindowScroll={true} disableFullWindowScroll={true}
disableVirtualization={true} disableVirtualization={true}
style={animatedListStyle} style={animatedListStyle}
animatedProps={animatedProps}
// The extra two items account for the header and the footer components // The extra two items account for the header and the footer components
initialNumToRender={isNative ? 32 : 62} initialNumToRender={isNative ? 32 : 62}
maxToRenderPerBatch={isWeb ? 32 : 62} maxToRenderPerBatch={isWeb ? 32 : 62}
keyboardDismissMode="on-drag" keyboardDismissMode={isIOS ? 'interactive' : 'on-drag'}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps={isIOS ? 'always' : 'handled'}
maintainVisibleContentPosition={{ maintainVisibleContentPosition={{
minIndexForVisible: 0, minIndexForVisible: 0,
}} }}
@@ -424,9 +465,15 @@ export function MessagesList({
ListHeaderComponent={ ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <MaybeLoader isLoading={convoState.isFetchingHistory} />
} }
automaticallyAdjustContentInsets={false}
/> />
</ScrollProvider> </ScrollProvider>
<Animated.View style={animatedStickyViewStyle}> <Animated.View
style={[
a.absolute,
{bottom: 0, left: 0, right: 0},
animatedStickyViewStyle,
]}>
{convoState.status === ConvoStatus.Disabled ? ( {convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled /> <ChatDisabled />
) : blocked ? ( ) : blocked ? (
@@ -436,13 +483,20 @@ export function MessagesList({
{isConvoActive(convoState) && {isConvoActive(convoState) &&
!convoState.isFetchingHistory && !convoState.isFetchingHistory &&
convoState.items.length === 0 && <ChatEmptyPill />} convoState.items.length === 0 && <ChatEmptyPill />}
<MessageInput <View
onSendMessage={onSendMessage} onLayout={evt =>
hasEmbed={!!embedUri} messageInputHeight.set(evt.nativeEvent.layout.height)
setEmbed={setEmbed} }>
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> <MessageInput
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> onSendMessage={onSendMessage}
</MessageInput> hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={pos =>
setEmojiPickerState({isOpen: true, pos})
}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
</View>
</> </>
)} )}
</Animated.View> </Animated.View>