[🐴] Fully implement keyboard controller (#4106)

* Revert "[🐴] Ensure keyboard gets dismissed when leaving screen (#4104)"

This reverts commit 3ca671d9aa.

* getting somewhere

* remove some now nuneeded code

* fully implement keyboard controller

* onStartReached check

* fix new messages pill alignment

* scroll to end on press

* simplify pill scroll logic

* update comment

* adjust logic on when to hide the pill

* fix backgrounding jank

* improve look of deleting messages

* add double tap on messages

* better onStartReached logic

* nit

* add hit slop to the gesture

* better gestures for press and hold

* nits
zio/stable
Hailey 2024-05-19 19:25:49 -07:00 committed by GitHub
parent 7de0b0a58c
commit 52beb29a0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 419 additions and 356 deletions

View File

@ -171,6 +171,7 @@
"react-native-get-random-values": "~1.11.0", "react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1", "react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3", "react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
"react-native-pager-view": "6.2.3", "react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0", "react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",

View File

@ -4,6 +4,7 @@ import 'view/icons'
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler' import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {RootSiblingParent} from 'react-native-root-siblings' import {RootSiblingParent} from 'react-native-root-siblings'
import { import {
initialWindowMetrics, initialWindowMetrics,
@ -142,27 +143,29 @@ function App() {
* that is set up in the InnerApp component above. * that is set up in the InnerApp component above.
*/ */
return ( return (
<SessionProvider> <KeyboardProvider enabled={true}>
<ShellStateProvider> <SessionProvider>
<PrefsStateProvider> <ShellStateProvider>
<MutedThreadsProvider> <PrefsStateProvider>
<InvitesStateProvider> <MutedThreadsProvider>
<ModalStateProvider> <InvitesStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<I18nProvider> <LightboxStateProvider>
<PortalProvider> <I18nProvider>
<InnerApp /> <PortalProvider>
</PortalProvider> <InnerApp />
</I18nProvider> </PortalProvider>
</LightboxStateProvider> </I18nProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</InvitesStateProvider> </ModalStateProvider>
</MutedThreadsProvider> </InvitesStateProvider>
</PrefsStateProvider> </MutedThreadsProvider>
</ShellStateProvider> </PrefsStateProvider>
</SessionProvider> </ShellStateProvider>
</SessionProvider>
</KeyboardProvider>
) )
} }

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {Keyboard, Pressable, View} from 'react-native' import {Keyboard} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, { import Animated, {
cancelAnimation, cancelAnimation,
runOnJS, runOnJS,
@ -15,8 +16,6 @@ import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu' import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu' import {useMenuControl} from '#/components/Menu'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
isFromSelf, isFromSelf,
@ -30,56 +29,59 @@ export function ActionsWrapper({
const menuControl = useMenuControl() const menuControl = useMenuControl()
const scale = useSharedValue(1) const scale = useSharedValue(1)
const animationDidComplete = useSharedValue(false)
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}], transform: [{scale: scale.value}],
})) }))
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
// function
const open = React.useCallback(() => { const open = React.useCallback(() => {
playHaptic()
Keyboard.dismiss() Keyboard.dismiss()
menuControl.open() menuControl.open()
}, [menuControl]) }, [menuControl, playHaptic])
const shrink = React.useCallback(() => { const shrink = React.useCallback(() => {
'worklet' 'worklet'
cancelAnimation(scale) cancelAnimation(scale)
scale.value = withTiming(1, {duration: 200}, () => { scale.value = withTiming(1, {duration: 200})
animationDidComplete.value = false }, [scale])
})
}, [animationDidComplete, scale])
const grow = React.useCallback(() => { const doubleTapGesture = Gesture.Tap()
'worklet' .numberOfTaps(2)
scale.value = withTiming(1.05, {duration: 450}, finished => { .hitSlop(HITSLOP_10)
if (!finished) return .onEnd(open)
animationDidComplete.value = true
runOnJS(playHaptic)()
runOnJS(open)()
shrink() const pressAndHoldGesture = Gesture.LongPress()
.onStart(() => {
scale.value = withTiming(1.05, {duration: 200}, finished => {
if (!finished) return
runOnJS(open)()
shrink()
})
}) })
}, [scale, animationDidComplete, playHaptic, shrink, open]) .onTouchesUp(shrink)
.onTouchesMove(shrink)
.cancelsTouchesInView(false)
.runOnJS(true)
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
pressAndHoldGesture,
)
return ( return (
<View <GestureDetector gesture={composedGestures}>
style={[ <Animated.View
{ style={[
maxWidth: '80%', {
}, maxWidth: '80%',
isFromSelf ? a.self_end : a.self_start, },
]}> isFromSelf ? a.self_end : a.self_start,
<AnimatedPressable animatedStyle,
style={animatedStyle} ]}>
unstable_pressDelay={200}
onPressIn={grow}
onTouchEnd={shrink}
hitSlop={HITSLOP_10}>
{children} {children}
</AnimatedPressable> <MessageMenu message={message} control={menuControl} />
<MessageMenu message={message} control={menuControl} /> </Animated.View>
</View> </GestureDetector>
) )
} }

View File

@ -1,5 +1,5 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {Keyboard, TouchableOpacity, View} from 'react-native' import {TouchableOpacity, View} from 'react-native'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
ModerationCause, ModerationCause,
@ -46,7 +46,6 @@ export let MessagesListHeader = ({
if (isWeb) { if (isWeb) {
navigation.replace('Messages', {}) navigation.replace('Messages', {})
} else { } else {
Keyboard.dismiss()
navigation.goBack() navigation.goBack()
} }
}, [navigation]) }, [navigation])

View File

@ -1,47 +1,97 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {Pressable, View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import { import {
ScaleAndFadeIn, ScaleAndFadeIn,
ScaleAndFadeOut, ScaleAndFadeOut,
} from 'lib/custom-animations/ScaleAndFade' } from 'lib/custom-animations/ScaleAndFade'
import {useHaptics} from 'lib/haptics'
import {isAndroid, isIOS, isWeb} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function NewMessagesPill() { const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const t = useTheme()
React.useEffect(() => {}, []) export function NewMessagesPill({
onPress: onPressInner,
}: {
onPress: () => void
}) {
const t = useTheme()
const playHaptic = useHaptics()
const {bottom: bottomInset} = useSafeAreaInsets()
const bottomBarHeight = isIOS ? 42 : isAndroid ? 60 : 0
const bottomOffset = isWeb ? 0 : bottomInset + bottomBarHeight
const scale = useSharedValue(1)
const onPressIn = React.useCallback(() => {
if (isWeb) return
scale.value = withTiming(1.075, {duration: 100})
}, [scale])
const onPressOut = React.useCallback(() => {
if (isWeb) return
scale.value = withTiming(1, {duration: 100})
}, [scale])
const onPress = React.useCallback(() => {
runOnJS(playHaptic)()
onPressInner?.()
}, [onPressInner, playHaptic])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
return ( return (
<Animated.View <View
style={[ style={[
a.py_sm, a.absolute,
a.rounded_full, a.w_full,
a.shadow_sm, a.z_10,
a.border, a.align_center,
t.atoms.bg_contrast_50,
t.atoms.border_contrast_medium,
{ {
position: 'absolute', bottom: bottomOffset + 70,
bottom: 70, // Don't prevent scrolling in this area _except_ for in the pill itself
width: '40%', pointerEvents: 'box-none',
left: '30%',
alignItems: 'center',
shadowOpacity: 0.125,
shadowRadius: 12,
shadowOffset: {width: 0, height: 5},
}, },
]} ]}>
entering={ScaleAndFadeIn} <AnimatedPressable
exiting={ScaleAndFadeOut}> style={[
<View style={{flex: 1}}> a.py_sm,
a.rounded_full,
a.shadow_sm,
a.border,
t.atoms.bg_contrast_50,
t.atoms.border_contrast_medium,
{
width: 160,
alignItems: 'center',
shadowOpacity: 0.125,
shadowRadius: 12,
shadowOffset: {width: 0, height: 5},
pointerEvents: 'box-only',
},
animatedStyle,
]}
entering={ScaleAndFadeIn}
exiting={ScaleAndFadeOut}
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
<Text style={[a.font_bold]}> <Text style={[a.font_bold]}>
<Trans>New messages</Trans> <Trans>New messages</Trans>
</Text> </Text>
</View> </AnimatedPressable>
</Animated.View> </View>
) )
} }

View File

@ -58,6 +58,9 @@ export function MessageInput({
onSendMessage(message.trimEnd()) onSendMessage(message.trimEnd())
playHaptic() playHaptic()
setMessage('') setMessage('')
// Pressing the send button causes the text input to lose focus, so we need to
// re-focus it after sending
setTimeout(() => { setTimeout(() => {
inputRef.current?.focus() inputRef.current?.focus()
}, 100) }, 100)

View File

@ -1,10 +1,12 @@
import React, {useCallback, useRef} from 'react' import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View} from 'react-native'
import Animated, { import {
KeyboardStickyView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {
runOnJS, runOnJS,
scrollTo, scrollTo,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@ -24,7 +26,6 @@ import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a} from '#/alf'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
@ -80,7 +81,10 @@ export function MessagesList({
const flatListRef = useAnimatedRef<FlatList>() const flatListRef = useAnimatedRef<FlatList>()
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false) const [newMessagesPill, setNewMessagesPill] = React.useState({
show: false,
startContentOffset: 0,
})
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
@ -95,8 +99,14 @@ export function MessagesList({
const prevContentHeight = useRef(0) const prevContentHeight = useRef(0)
const prevItemCount = useRef(0) const prevItemCount = useRef(0)
const isDragging = useSharedValue(false) // -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0) const layoutHeight = useSharedValue(0)
const didBackground = React.useRef(false)
React.useEffect(() => {
if (convoState.status === ConvoStatus.Backgrounded) {
didBackground.current = true
}
}, [convoState.status])
// -- Scroll handling // -- Scroll handling
@ -123,24 +133,28 @@ export function MessagesList({
// This number _must_ be the height of the MaybeLoader component // This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.value) { if (height > 50 && isAtBottom.value) {
// If the size of the content is changing by more than the height of the screen, then we should only // If the size of the content is changing by more than the height of the screen, then we don't
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be // want to scroll further than the start of all the new content. Since we are storing the previous offset,
// really large - and the normal chat behavior would be to still scroll to the end if it's only one // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
// message - we ignore this rule if there's only one additional message // that can be pressed to immediately scroll to the end.
if ( if (
didBackground.current &&
hasScrolled && hasScrolled &&
height - prevContentHeight.current > layoutHeight.value - 50 && height - prevContentHeight.current > layoutHeight.value - 50 &&
convoState.items.length - prevItemCount.current > 1 convoState.items.length - prevItemCount.current > 1
) { ) {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: height - layoutHeight.value + 50, offset: prevContentHeight.current - 65,
animated: hasScrolled, animated: true,
})
setNewMessagesPill({
show: true,
startContentOffset: prevContentHeight.current - 65,
}) })
setShowNewMessagesPill(true)
} else { } else {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: height, offset: height,
animated: hasScrolled, animated: hasScrolled && height > prevContentHeight.current,
}) })
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
@ -158,6 +172,7 @@ export function MessagesList({
prevContentHeight.current = height prevContentHeight.current = height
prevItemCount.current = convoState.items.length prevItemCount.current = convoState.items.length
didBackground.current = false
}, },
[ [
hasScrolled, hasScrolled,
@ -172,88 +187,66 @@ export function MessagesList({
], ],
) )
const onBeginDrag = React.useCallback(() => {
'worklet'
isDragging.value = true
}, [isDragging])
const onEndDrag = React.useCallback(() => {
'worklet'
isDragging.value = false
}, [isDragging])
const onStartReached = useCallback(() => { const onStartReached = useCallback(() => {
if (hasScrolled) { if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
convoState.fetchMessageHistory() convoState.fetchMessageHistory()
} }
}, [convoState, hasScrolled]) }, [convoState, hasScrolled, layoutHeight.value])
const onScroll = React.useCallback( const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => { (e: ReanimatedScrollEvent) => {
'worklet' 'worklet'
layoutHeight.value = e.layoutMeasurement.height layoutHeight.value = e.layoutMeasurement.height
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
if (
showNewMessagesPill &&
e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset
) {
runOnJS(setShowNewMessagesPill)(false)
}
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
// when a new message is added, hence the 100 pixel offset // when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1 isAtTop.value = e.contentOffset.y <= 1
if (
newMessagesPill.show &&
(e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
isAtBottom.value)
) {
runOnJS(setNewMessagesPill)({
show: false,
startContentOffset: 0,
})
}
}, },
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop], [layoutHeight, newMessagesPill, isAtBottom, isAtTop],
) )
// -- Keyboard animation handling // -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {bottom: bottomInset} = useSafeAreaInsets() const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60 const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
const finalKeyboardHeight = useSharedValue(0)
// On web, we don't want to do anything. const keyboardHeight = useSharedValue(0)
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs const keyboardIsOpening = useSharedValue(false)
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction( useKeyboardHandler({
() => animatedKeyboard.height.value, onStart: () => {
(now, prev) => {
'worklet' 'worklet'
// This never applies on web keyboardIsOpening.value = true
if (isWeb) { },
return onMove: e => {
} 'worklet'
keyboardHeight.value = e.height
// We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not if (e.height > bottomOffset) {
// any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the
// scrollview synchronously.
// On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd`
// method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated
// doesn't support a `scrollToEnd`.
if (prev && now > 0 && now >= prev) {
scrollTo(flatListRef, 0, 1e7, false) scrollTo(flatListRef, 0, 1e7, false)
} }
// We want to store the full keyboard height after it fully opens so we can make some
// assumptions in onLayout
if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) {
finalKeyboardHeight.value = now
}
}, },
) onEnd: () => {
'worklet'
keyboardIsOpening.value = false
},
})
// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our const animatedListStyle = useAnimatedStyle(() => ({
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedStyle = useAnimatedStyle(() => ({
marginBottom: marginBottom:
animatedKeyboard.height.value > bottomOffset keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
? animatedKeyboard.height.value
: bottomOffset,
})) }))
// -- Message sending // -- Message sending
@ -282,36 +275,25 @@ export function MessagesList({
[convoState, getAgent], [convoState, getAgent],
) )
// Any time the List layout changes, we want to scroll to the bottom. This only happens whenever // -- List layout changes (opening emoji keyboard, etc.)
// the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`.
// This accounts for things like the emoji keyboard opening, changes in block state, etc.
const onListLayout = React.useCallback(() => { const onListLayout = React.useCallback(() => {
if (isDragging.value) return if (keyboardIsOpening.value) return
if (isWeb || !keyboardIsOpening.value) {
const kh = animatedKeyboard.height.value
const fkh = finalKeyboardHeight.value
// We only run the layout scroll if:
// - We're on web
// - The keyboard is not open. This accounts for changing block states
// - The final keyboard height has been initially set and the keyboard height is greater than that
if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) {
flatListRef.current?.scrollToEnd({animated: true}) flatListRef.current?.scrollToEnd({animated: true})
} }
}, [ }, [flatListRef, keyboardIsOpening.value])
flatListRef,
finalKeyboardHeight.value, const scrollToEndOnPress = React.useCallback(() => {
animatedKeyboard.height.value, flatListRef.current?.scrollToOffset({
isDragging.value, offset: prevContentHeight.current,
]) animated: true,
})
}, [flatListRef])
return ( return (
<Animated.View style={[a.flex_1, animatedStyle]}> <>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider <ScrollProvider onScroll={onScroll}>
onScroll={onScroll}
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}>
<List <List
ref={flatListRef} ref={flatListRef}
data={convoState.items} data={convoState.items}
@ -319,13 +301,14 @@ export function MessagesList({
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
containWeb={true} containWeb={true}
disableVirtualization={true} disableVirtualization={true}
style={animatedListStyle}
// 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="on-drag"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{ maintainVisibleContentPosition={{
minIndexForVisible: 1, minIndexForVisible: 0,
}} }}
removeClippedSubviews={false} removeClippedSubviews={false}
sideBorders={false} sideBorders={false}
@ -339,18 +322,20 @@ export function MessagesList({
} }
/> />
</ScrollProvider> </ScrollProvider>
{!blocked ? ( <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
<> {!blocked ? (
{convoState.status === ConvoStatus.Disabled ? ( <>
<ChatDisabled /> {convoState.status === ConvoStatus.Disabled ? (
) : ( <ChatDisabled />
<MessageInput onSendMessage={onSendMessage} /> ) : (
)} <MessageInput onSendMessage={onSendMessage} />
</> )}
) : ( </>
footer ) : (
)} footer
{showNewMessagesPill && <NewMessagesPill />} )}
</Animated.View> </KeyboardStickyView>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
) )
} }

View File

@ -83,6 +83,14 @@ function Inner() {
!convoState.isFetchingHistory && !convoState.isFetchingHistory &&
convoState.items.length === 0) convoState.items.length === 0)
// Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
// state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
React.useEffect(() => {
if (convoState.status === ConvoStatus.Initializing) {
setHasScrolled(false)
}
}, [convoState.status])
if (convoState.status === ConvoStatus.Error) { if (convoState.status === ConvoStatus.Error) {
return ( return (
<CenteredView style={a.flex_1} sideBorders> <CenteredView style={a.flex_1} sideBorders>

View File

@ -3,13 +3,15 @@ import {
ActivityIndicator, ActivityIndicator,
BackHandler, BackHandler,
Keyboard, Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {
KeyboardAvoidingView,
KeyboardStickyView,
} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {RichText} from '@atproto/api' import {RichText} from '@atproto/api'
@ -373,172 +375,178 @@ export const ComposePost = observer(function ComposePost({
) )
return ( return (
<KeyboardAvoidingView <>
testID="composePostView" <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} testID="composePostView"
style={styles.outer}> behavior="padding"
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal> style={s.flex1}
<View style={[styles.topbar, isDesktop && styles.topbarDesktop]}> keyboardVerticalOffset={60}>
<TouchableOpacity <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
testID="composerDiscardButton" <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
onPress={onPressCancel} <TouchableOpacity
onAccessibilityEscape={onPressCancel} testID="composerDiscardButton"
accessibilityRole="button" onPress={onPressCancel}
accessibilityLabel={_(msg`Cancel`)} onAccessibilityEscape={onPressCancel}
accessibilityHint={_( accessibilityRole="button"
msg`Closes post composer and discards post draft`, accessibilityLabel={_(msg`Cancel`)}
)}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{replyTo ? null : (
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
/>
)}
{canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, s.flex1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[s.red4, s.flex1]}>{error}</Text>
</View>
)}
<ScrollView
style={styles.scrollView}
keyboardShouldPersistTaps="always">
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
style={[
pal.border,
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus={true}
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={onPressPublish}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_( accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, msg`Closes post composer and discards post draft`,
)} )}>
/> <Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{replyTo ? null : (
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
/>
)}
{canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</>
)}
</View> </View>
{isAltTextRequiredAndMissing && (
<Gallery gallery={gallery} /> <View style={[styles.reminderLine, pal.viewLight]}>
{gallery.isEmpty && extLink && ( <View style={styles.errorIcon}>
<View style={a.relative}> <FontAwesomeIcon
<ExternalEmbed icon="exclamation"
link={extLink} style={{color: colors.red4}}
gif={extGif} size={10}
onRemove={() => { />
setExtLink(undefined) </View>
setExtGif(undefined) <Text style={[pal.text, s.flex1]}>
}} <Trans>One or more images is missing alt text.</Trans>
/> </Text>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View> </View>
)} )}
{quote ? ( {error !== '' && (
<View style={[s.mt5, isWeb && s.mb10]}> <View style={styles.errorLine}>
<View style={{pointerEvents: 'none'}}> <View style={styles.errorIcon}>
<QuoteEmbed quote={quote} /> <FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View> </View>
{quote.uri !== initQuote?.uri && ( <Text style={[s.red4, s.flex1]}>{error}</Text>
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View> </View>
) : undefined} )}
</ScrollView> <ScrollView
<SuggestedLanguage text={richtext.text} /> style={styles.scrollView}
keyboardShouldPersistTaps="always">
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
style={[
pal.border,
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus={true}
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={onPressPublish}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
</ScrollView>
<SuggestedLanguage text={richtext.text} />
</View>
</KeyboardAvoidingView>
<KeyboardStickyView
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
@ -565,8 +573,7 @@ export const ComposePost = observer(function ComposePost({
<SelectLangBtn /> <SelectLangBtn />
<CharProgress count={graphemeLength} /> <CharProgress count={graphemeLength} />
</View> </View>
</View> </KeyboardStickyView>
<Prompt.Basic <Prompt.Basic
control={discardPromptControl} control={discardPromptControl}
title={_(msg`Discard draft?`)} title={_(msg`Discard draft?`)}
@ -575,7 +582,7 @@ export const ComposePost = observer(function ComposePost({
confirmButtonCta={_(msg`Discard`)} confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
</KeyboardAvoidingView> </>
) )
}) })

View File

@ -18496,6 +18496,11 @@ react-native-ios-context-menu@^1.15.3:
dependencies: dependencies:
"@dominicstop/ts-event-emitter" "^1.1.0" "@dominicstop/ts-event-emitter" "^1.1.0"
react-native-keyboard-controller@^1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
react-native-pager-view@6.2.3: react-native-pager-view@6.2.3:
version "6.2.3" version "6.2.3"
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"