diff --git a/package.json b/package.json
index 6cb83a3e..8f53fec6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 425d6ac6..7c60d162 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index 3b9a56bd..a349c3cf 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -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 (
-
-
+
+
{children}
-
-
-
+
+
+
)
}
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 1e6fd360..a6dff403 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -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])
diff --git a/src/components/dms/NewMessagesPill.tsx b/src/components/dms/NewMessagesPill.tsx
index 4a0ba22c..924f7c45 100644
--- a/src/components/dms/NewMessagesPill.tsx
+++ b/src/components/dms/NewMessagesPill.tsx
@@ -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 (
-
-
+ ]}>
+
New messages
-
-
+
+
)
}
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index 9deecfd4..1e33efdf 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -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)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index b0723c02..68e68b8c 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -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()
- 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 (
-
+ <>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
-
+
- {!blocked ? (
- <>
- {convoState.status === ConvoStatus.Disabled ? (
-
- ) : (
-
- )}
- >
- ) : (
- footer
- )}
- {showNewMessagesPill && }
-
+
+ {!blocked ? (
+ <>
+ {convoState.status === ConvoStatus.Disabled ? (
+
+ ) : (
+
+ )}
+ >
+ ) : (
+ footer
+ )}
+
+ {newMessagesPill.show && }
+ >
)
}
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 8e806ff7..63175b55 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -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 (
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 61c33902..d85fca29 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -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 (
-
-
-
-
-
- Cancel
-
-
-
- {isProcessing ? (
- <>
- {processingState}
-
-
-
- >
- ) : (
- <>
-
- {replyTo ? null : (
-
- )}
- {canPost ? (
-
-
-
- {replyTo ? (
- Reply
- ) : (
- Post
- )}
-
-
-
- ) : (
-
-
- Post
-
-
- )}
- >
- )}
-
- {isAltTextRequiredAndMissing && (
-
-
-
-
-
- One or more images is missing alt text.
-
-
- )}
- {error !== '' && (
-
-
-
-
- {error}
-
- )}
-
- {replyTo ? : undefined}
-
-
-
-
+
+
+
+
+ msg`Closes post composer and discards post draft`,
+ )}>
+
+ Cancel
+
+
+
+ {isProcessing ? (
+ <>
+ {processingState}
+
+
+
+ >
+ ) : (
+ <>
+
+ {replyTo ? null : (
+
+ )}
+ {canPost ? (
+
+
+
+ {replyTo ? (
+ Reply
+ ) : (
+ Post
+ )}
+
+
+
+ ) : (
+
+
+ Post
+
+
+ )}
+ >
+ )}
-
-
- {gallery.isEmpty && extLink && (
-
- {
- setExtLink(undefined)
- setExtGif(undefined)
- }}
- />
-
+ {isAltTextRequiredAndMissing && (
+
+
+
+
+
+ One or more images is missing alt text.
+
)}
- {quote ? (
-
-
-
+ {error !== '' && (
+
+
+
- {quote.uri !== initQuote?.uri && (
- setQuote(undefined)} />
- )}
+ {error}
- ) : undefined}
-
-
+ )}
+
+ {replyTo ? : undefined}
+
+
+
+
+
+
+
+ {gallery.isEmpty && extLink && (
+
+ {
+ setExtLink(undefined)
+ setExtGif(undefined)
+ }}
+ />
+
+
+ )}
+ {quote ? (
+
+
+
+
+ {quote.uri !== initQuote?.uri && (
+ setQuote(undefined)} />
+ )}
+
+ ) : undefined}
+
+
+
+
+
@@ -565,8 +573,7 @@ export const ComposePost = observer(function ComposePost({
-
-
+
-
+ >
)
})
diff --git a/yarn.lock b/yarn.lock
index 1e7fd33b..db1ebbca 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"