Skip to content

Commit

Permalink
[🐴] Fully implement keyboard controller (bluesky-social#4106)
Browse files Browse the repository at this point in the history
* Revert "[🐴] Ensure keyboard gets dismissed when leaving screen (bluesky-social#4104)"

This reverts commit 3ca671d.

* 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
  • Loading branch information
haileyok authored May 20, 2024
1 parent 7de0b0a commit 52beb29
Show file tree
Hide file tree
Showing 10 changed files with 416 additions and 353 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 24 additions & 21 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>
)
}

Expand Down
74 changes: 38 additions & 36 deletions src/components/dms/ActionsWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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>
)
}
3 changes: 1 addition & 2 deletions src/components/dms/MessagesListHeader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -46,7 +46,6 @@ export let MessagesListHeader = ({
if (isWeb) {
navigation.replace('Messages', {})
} else {
Keyboard.dismiss()
navigation.goBack()
}
}, [navigation])
Expand Down
100 changes: 75 additions & 25 deletions src/components/dms/NewMessagesPill.tsx
Original file line number Diff line number Diff line change
@@ -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 AnimatedPressable = Animated.createAnimatedComponent(Pressable)

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])

React.useEffect(() => {}, [])
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>
)
}
3 changes: 3 additions & 0 deletions src/screens/Messages/Conversation/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 52beb29

Please sign in to comment.