[🐴] 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
parent
7de0b0a58c
commit
52beb29a0d
|
@ -171,6 +171,7 @@
|
|||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-image-crop-picker": "^0.38.1",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.12.1",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^8.1.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'view/icons'
|
|||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
|
@ -142,27 +143,29 @@ function App() {
|
|||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
<KeyboardProvider enabled={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
|
@ -15,8 +16,6 @@ import {atoms as a} from '#/alf'
|
|||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
|
@ -30,56 +29,59 @@ export function ActionsWrapper({
|
|||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
const animationDidComplete = useSharedValue(false)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
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(() => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
}, [menuControl, playHaptic])
|
||||
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
animationDidComplete.value = false
|
||||
})
|
||||
}, [animationDidComplete, scale])
|
||||
scale.value = withTiming(1, {duration: 200})
|
||||
}, [scale])
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 450}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
const doubleTapGesture = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(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 (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}
|
||||
hitSlop={HITSLOP_10}>
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
animatedStyle,
|
||||
]}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</View>
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, TouchableOpacity, View} from 'react-native'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationCause,
|
||||
|
@ -46,7 +46,6 @@ export let MessagesListHeader = ({
|
|||
if (isWeb) {
|
||||
navigation.replace('Messages', {})
|
||||
} else {
|
||||
Keyboard.dismiss()
|
||||
navigation.goBack()
|
||||
}
|
||||
}, [navigation])
|
||||
|
|
|
@ -1,47 +1,97 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
ScaleAndFadeIn,
|
||||
ScaleAndFadeOut,
|
||||
} 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 {Text} from '#/components/Typography'
|
||||
|
||||
export function NewMessagesPill() {
|
||||
const t = useTheme()
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
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 (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.shadow_sm,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.absolute,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.align_center,
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: 70,
|
||||
width: '40%',
|
||||
left: '30%',
|
||||
alignItems: 'center',
|
||||
shadowOpacity: 0.125,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: {width: 0, height: 5},
|
||||
bottom: bottomOffset + 70,
|
||||
// Don't prevent scrolling in this area _except_ for in the pill itself
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
]}
|
||||
entering={ScaleAndFadeIn}
|
||||
exiting={ScaleAndFadeOut}>
|
||||
<View style={{flex: 1}}>
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
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]}>
|
||||
<Trans>New messages</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -58,6 +58,9 @@ export function MessageInput({
|
|||
onSendMessage(message.trimEnd())
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
|
||||
// Pressing the send button causes the text input to lose focus, so we need to
|
||||
// re-focus it after sending
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import React, {useCallback, useRef} from 'react'
|
||||
import {FlatList, View} from 'react-native'
|
||||
import Animated, {
|
||||
import {
|
||||
KeyboardStickyView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
useAnimatedKeyboard,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
|
@ -24,7 +26,6 @@ import {List} from 'view/com/util/List'
|
|||
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
@ -80,7 +81,10 @@ export function MessagesList({
|
|||
|
||||
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
|
||||
// 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 prevItemCount = useRef(0)
|
||||
|
||||
const isDragging = useSharedValue(false)
|
||||
// -- Keep track of background state and positioning for new pill
|
||||
const layoutHeight = useSharedValue(0)
|
||||
const didBackground = React.useRef(false)
|
||||
React.useEffect(() => {
|
||||
if (convoState.status === ConvoStatus.Backgrounded) {
|
||||
didBackground.current = true
|
||||
}
|
||||
}, [convoState.status])
|
||||
|
||||
// -- Scroll handling
|
||||
|
||||
|
@ -123,24 +133,28 @@ export function MessagesList({
|
|||
|
||||
// This number _must_ be the height of the MaybeLoader component
|
||||
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
|
||||
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
|
||||
// really large - and the normal chat behavior would be to still scroll to the end if it's only one
|
||||
// message - we ignore this rule if there's only one additional message
|
||||
// If the size of the content is changing by more than the height of the screen, then we don't
|
||||
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
|
||||
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
|
||||
// that can be pressed to immediately scroll to the end.
|
||||
if (
|
||||
didBackground.current &&
|
||||
hasScrolled &&
|
||||
height - prevContentHeight.current > layoutHeight.value - 50 &&
|
||||
convoState.items.length - prevItemCount.current > 1
|
||||
) {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: height - layoutHeight.value + 50,
|
||||
animated: hasScrolled,
|
||||
offset: prevContentHeight.current - 65,
|
||||
animated: true,
|
||||
})
|
||||
setNewMessagesPill({
|
||||
show: true,
|
||||
startContentOffset: prevContentHeight.current - 65,
|
||||
})
|
||||
setShowNewMessagesPill(true)
|
||||
} else {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: height,
|
||||
animated: hasScrolled,
|
||||
animated: hasScrolled && height > prevContentHeight.current,
|
||||
})
|
||||
|
||||
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
|
||||
|
@ -158,6 +172,7 @@ export function MessagesList({
|
|||
|
||||
prevContentHeight.current = height
|
||||
prevItemCount.current = convoState.items.length
|
||||
didBackground.current = false
|
||||
},
|
||||
[
|
||||
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(() => {
|
||||
if (hasScrolled) {
|
||||
if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
|
||||
convoState.fetchMessageHistory()
|
||||
}
|
||||
}, [convoState, hasScrolled])
|
||||
}, [convoState, hasScrolled, layoutHeight.value])
|
||||
|
||||
const onScroll = React.useCallback(
|
||||
(e: ReanimatedScrollEvent) => {
|
||||
'worklet'
|
||||
layoutHeight.value = 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
|
||||
// when a new message is added, hence the 100 pixel offset
|
||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
||||
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
|
||||
const animatedKeyboard = useAnimatedKeyboard()
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const nativeBottomBarHeight = isIOS ? 42 : 60
|
||||
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
|
||||
const finalKeyboardHeight = useSharedValue(0)
|
||||
|
||||
// On web, we don't want to do anything.
|
||||
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
|
||||
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
|
||||
useAnimatedReaction(
|
||||
() => animatedKeyboard.height.value,
|
||||
(now, prev) => {
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
const keyboardIsOpening = useSharedValue(false)
|
||||
|
||||
useKeyboardHandler({
|
||||
onStart: () => {
|
||||
'worklet'
|
||||
// This never applies on web
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
|
||||
// We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not
|
||||
// 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) {
|
||||
keyboardIsOpening.value = true
|
||||
},
|
||||
onMove: e => {
|
||||
'worklet'
|
||||
keyboardHeight.value = e.height
|
||||
if (e.height > bottomOffset) {
|
||||
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
|
||||
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
const animatedListStyle = useAnimatedStyle(() => ({
|
||||
marginBottom:
|
||||
animatedKeyboard.height.value > bottomOffset
|
||||
? animatedKeyboard.height.value
|
||||
: bottomOffset,
|
||||
keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
|
||||
}))
|
||||
|
||||
// -- Message sending
|
||||
|
@ -282,36 +275,25 @@ export function MessagesList({
|
|||
[convoState, getAgent],
|
||||
)
|
||||
|
||||
// Any time the List layout changes, we want to scroll to the bottom. This only happens whenever
|
||||
// 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.
|
||||
// -- List layout changes (opening emoji keyboard, etc.)
|
||||
const onListLayout = React.useCallback(() => {
|
||||
if (isDragging.value) return
|
||||
|
||||
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)) {
|
||||
if (keyboardIsOpening.value) return
|
||||
if (isWeb || !keyboardIsOpening.value) {
|
||||
flatListRef.current?.scrollToEnd({animated: true})
|
||||
}
|
||||
}, [
|
||||
flatListRef,
|
||||
finalKeyboardHeight.value,
|
||||
animatedKeyboard.height.value,
|
||||
isDragging.value,
|
||||
])
|
||||
}, [flatListRef, keyboardIsOpening.value])
|
||||
|
||||
const scrollToEndOnPress = React.useCallback(() => {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: prevContentHeight.current,
|
||||
animated: true,
|
||||
})
|
||||
}, [flatListRef])
|
||||
|
||||
return (
|
||||
<Animated.View style={[a.flex_1, animatedStyle]}>
|
||||
<>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<ScrollProvider
|
||||
onScroll={onScroll}
|
||||
onBeginDrag={onBeginDrag}
|
||||
onEndDrag={onEndDrag}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={convoState.items}
|
||||
|
@ -319,13 +301,14 @@ export function MessagesList({
|
|||
keyExtractor={keyExtractor}
|
||||
containWeb={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={isNative ? 32 : 62}
|
||||
maxToRenderPerBatch={isWeb ? 32 : 62}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 1,
|
||||
minIndexForVisible: 0,
|
||||
}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
|
@ -339,18 +322,20 @@ export function MessagesList({
|
|||
}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
{!blocked ? (
|
||||
<>
|
||||
{convoState.status === ConvoStatus.Disabled ? (
|
||||
<ChatDisabled />
|
||||
) : (
|
||||
<MessageInput onSendMessage={onSendMessage} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
footer
|
||||
)}
|
||||
{showNewMessagesPill && <NewMessagesPill />}
|
||||
</Animated.View>
|
||||
<KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
|
||||
{!blocked ? (
|
||||
<>
|
||||
{convoState.status === ConvoStatus.Disabled ? (
|
||||
<ChatDisabled />
|
||||
) : (
|
||||
<MessageInput onSendMessage={onSendMessage} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
footer
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -83,6 +83,14 @@ function Inner() {
|
|||
!convoState.isFetchingHistory &&
|
||||
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) {
|
||||
return (
|
||||
<CenteredView style={a.flex_1} sideBorders>
|
||||
|
|
|
@ -3,13 +3,15 @@ import {
|
|||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
KeyboardStickyView,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {RichText} from '@atproto/api'
|
||||
|
@ -373,172 +375,178 @@ export const ComposePost = observer(function ComposePost({
|
|||
)
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.outer}>
|
||||
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||
<View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
|
||||
<TouchableOpacity
|
||||
testID="composerDiscardButton"
|
||||
onPress={onPressCancel}
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
accessibilityHint={_(
|
||||
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>
|
||||
{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`)}
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior="padding"
|
||||
style={s.flex1}
|
||||
keyboardVerticalOffset={60}>
|
||||
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||
<View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
|
||||
<TouchableOpacity
|
||||
testID="composerDiscardButton"
|
||||
onPress={onPressCancel}
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
{quote ? (
|
||||
<View style={[s.mt5, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
{error !== '' && (
|
||||
<View style={styles.errorLine}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={{color: colors.red4}}
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
<Text style={[s.red4, s.flex1]}>{error}</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
)}
|
||||
<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={_(
|
||||
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={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
|
@ -565,8 +573,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
<SelectLangBtn />
|
||||
<CharProgress count={graphemeLength} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</KeyboardStickyView>
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
|
@ -575,7 +582,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -18496,6 +18496,11 @@ react-native-ios-context-menu@^1.15.3:
|
|||
dependencies:
|
||||
"@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:
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"
|
||||
|
|
Loading…
Reference in New Issue