Refactor chat list implementation (#10059)

This commit is contained in:
Samuel Newman
2026-04-10 09:58:16 -07:00
committed by GitHub
parent 2c717dc1e7
commit 888eca73b3
18 changed files with 773 additions and 398 deletions
+18 -14
View File
@@ -5,16 +5,19 @@ import {
moderateProfile,
type ModerationDecision,
} from '@atproto/api'
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {
type RouteProp,
useFocusEffect,
useIsFocused,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {
@@ -30,7 +33,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSetMinimalShellMode} from '#/state/shell'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {
@@ -62,7 +65,6 @@ export function MessagesConversationScreen(props: Props) {
}
export function MessagesConversationScreenInner({route}: Props) {
const {gtMobile} = useBreakpoints()
const setMinimalShellMode = useSetMinimalShellMode()
const convoId = route.params.conversation
@@ -71,25 +73,22 @@ export function MessagesConversationScreenInner({route}: Props) {
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
if (IS_WEB && !gtMobile) {
setMinimalShellMode(true)
} else {
setMinimalShellMode(false)
}
setMinimalShellMode(true)
return () => {
setCurrentConvoId(undefined)
setMinimalShellMode(false)
}
}, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
}, [convoId, setCurrentConvoId, setMinimalShellMode]),
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
</ConvoProvider>
<ScrollEdgeEffectProvider>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
</ConvoProvider>
</ScrollEdgeEffectProvider>
</Layout.Screen>
)
}
@@ -98,6 +97,7 @@ function Inner() {
const t = useTheme()
const convoState = useConvo()
const {_} = useLingui()
const isFocused = useIsFocused()
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
@@ -122,11 +122,13 @@ function Inner() {
// 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.
useEffect(() => {
const [prevState, setPrevState] = useState(convoState.status)
if (prevState !== convoState.status) {
setPrevState(convoState.status)
if (convoState.status === ConvoStatus.Initializing) {
setHasScrolled(false)
}
}, [convoState.status])
}
if (convoState.status === ConvoStatus.Error) {
return (
@@ -150,6 +152,8 @@ function Inner() {
return (
<Layout.Center style={[a.flex_1]}>
{/* MessagesList does not use the body scroll */}
{isFocused && IS_WEB && <RemoveScrollBar />}
{!readyToShow &&
(moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
@@ -1,5 +1,19 @@
import {useEffect, useState} from 'react'
import {Pressable, View} from 'react-native'
import {
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {GlassContainer} from 'expo-glass-effect'
import {LinearGradient} from 'expo-linear-gradient'
import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect'
import {useLingui} from '@lingui/react/macro'
import {countGraphemes} from 'unicode-segmenter/grapheme'
@@ -17,20 +31,24 @@ import {
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_WEB} from '#/env'
import {IS_ANDROID, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
const MIN_HEIGHT = 40
export function MessageComposer({
textInputId,
onSendMessage,
hasEmbed,
setEmbed,
children,
}: {
textInputId?: string
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
@@ -48,16 +66,25 @@ export function MessageComposer({
})
const composerInternalApiRef = useComposerInternalApiRef()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const [text, setText] = useState(getDraft)
useSaveMessageDraft(text)
// Android interactive dismiss sometimes doesn't blur the input
const blur = () => {
composerInternalApiRef.current?.input?.blur()
}
useKeyboardHandler({
onEnd: evt => {
'worklet'
if (IS_ANDROID && evt.progress === 0) {
runOnJS(blur)()
}
},
})
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const openEmojiPicker = (pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}
@@ -65,10 +92,12 @@ export function MessageComposer({
const onSubmit = () => {
if (!editable) return
if (!hasEmbed && text.trim() === '') return
if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(l`Message is too long`, {
type: 'error',
})
const graphemeCount = countGraphemes(text)
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(
l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`,
{type: 'error'},
)
return
}
@@ -91,152 +120,111 @@ export function MessageComposer({
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [])
}, [composerInternalApiRef])
return (
<>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children}
<ComposerContainer>
{children}
<View
collapsable={false}
ref={
IS_WEB
? undefined
: node => {
composerInternalApiRef.current?.setAutocompleteAnchor(node)
}
}
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
style={[a.w_full, a.flex_row, a.gap_sm]}>
{IS_WEB && (
<Pressable
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef: {
current: composerInternalApiRef.current?.input?.element,
<View
collapsable={false}
ref={native(
(node: View) =>
void composerInternalApiRef.current?.setAutocompleteAnchor(node),
)}>
<GlassContainer
style={[a.w_full, a.flex_row, a.gap_sm, a.align_end]}
spacing={tokens.space.sm}>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}>
{IS_WEB && (
<Pressable
onPress={e => {
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
// TODO: rip this horrible system out
openEmojiPicker?.({
top: py,
left: px - 400,
right: px - 400,
bottom: py,
nextFocusRef: {
current:
composerInternalApiRef.current?.input?.element,
},
})
},
})
})
)
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 20,
width: 20,
top: 10,
right: 10,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<EmojiSmileIcon
size="md"
style={
state.hovered ||
state.focused ||
state.pressed ||
emojiPickerState.isOpen
? {color: t.palette.primary_500}
: t.atoms.text_contrast_high
}
/>
)}
</Pressable>
)}
<Composer
nativeID={textInputId}
label={l`Message input field`}
placeholder={l`Message`}
autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]}
contentPaddingStyle={{
paddingLeft: 16,
paddingTop: 10,
paddingBottom: 10,
paddingRight: 16 + platform({web: 20, default: 0}),
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 30,
width: 30,
top: 8,
left: 8,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Pressable>
)}
<Composer
label={l`Message input field`}
placeholder={l`Write a message`}
autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[
a.flex_1,
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 22,
},
editable &&
hovered && {
borderColor: t.atoms.border_contrast_medium.borderColor,
},
editable &&
focused && {
borderColor: t.palette.primary_500,
},
]}
contentTextStyle={[a.text_md, a.leading_snug]}
contentPaddingStyle={{
paddingLeft: IS_WEB ? 30 + 12 : 12,
paddingTop: 12,
paddingBottom: 12,
paddingRight: 12,
}}
onFocus={onFocus}
onBlur={onBlur}
onChange={setText}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value)
}
}}
onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault()
onSubmit()
}}
/>
{focused || text.length ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
a.self_end,
a.z_30,
{
height: 44,
width: 44,
backgroundColor: t.palette.primary_500,
},
]}
onPress={onSubmit}
disabled={!editable}>
<PaperPlane
fill={t.palette.white}
style={[a.relative, {left: 1}]}
/>
</Pressable>
) : null}
</View>
onChange={setText}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value)
}
}}
onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault()
onSubmit()
}}
/>
</GlassView>
<SubmitButton onPress={onSubmit} disabled={submitDisabled} />
</GlassContainer>
</View>
{IS_WEB && (
@@ -246,6 +234,114 @@ export function MessageComposer({
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
</>
</ComposerContainer>
)
}
function SubmitButton({
onPress,
disabled,
}: {
onPress: () => void
disabled: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
return (
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.rounded_full]}
tintColor={disabled ? t.palette.contrast_100 : t.palette.primary_500}
fallbackStyle={{
backgroundColor: disabled
? t.palette.contrast_100
: t.palette.primary_500,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: MIN_HEIGHT, width: MIN_HEIGHT},
]}
onPress={onPress}
disabled={disabled}>
<PaperPlaneIcon size="md" fill={t.palette.white} style={[a.mb_2xs]} />
</Pressable>
</GlassView>
)
}
// TODO: remove export when MessageInput is deleted
export function ComposerContainer({children}: {children: React.ReactNode}) {
const {bottom: bottomInset} = useSafeAreaInsets()
const {progress} = useReanimatedKeyboardAnimation()
const t = useTheme()
const animatedContainerStyle = useAnimatedStyle(() => ({
paddingHorizontal: interpolate(
progress.get(),
[0, 1],
[bottomInset, tokens.space.sm],
{
extrapolateRight: Extrapolation.CLAMP,
extrapolateLeft: Extrapolation.CLAMP,
},
),
}))
if (IS_LIQUID_GLASS) {
return (
<ScrollEdgeEffect edge="bottom">
<Animated.View style={[a.w_full, animatedContainerStyle, a.pb_lg]}>
{children}
</Animated.View>
</ScrollEdgeEffect>
)
} else {
return (
<>
<LinearGradient
style={platform({
native: [a.pt_sm, a.px_lg, , a.pb_lg, a.w_full],
web: [
a.pt_xs,
a.pl_lg,
a.pb_lg,
// prevent overlap with the scrollbar, which looks ugly
a.pr_xs, // xs + md = lg
{width: `calc(100% - ${tokens.space.md}px)` as '100%'},
],
})}
key={t.name} // android does not update when you change the colors. sigh.
start={[0.5, 0]}
end={[0.5, 1]}
colors={[
utils.alpha(t.atoms.bg.backgroundColor, 0),
utils.alpha(t.atoms.bg.backgroundColor, 0.8),
t.atoms.bg.backgroundColor,
]}>
{children}
</LinearGradient>
{/* covers the gap between the keyboard and the input during keyboard animation */}
{IS_NATIVE && (
<View
style={[
t.atoms.bg,
a.absolute,
a.left_0,
a.right_0,
{top: '100%', height: bottomInset + 1, marginTop: -1},
]}
/>
)}
</>
)
}
}
+120 -78
View File
@@ -1,17 +1,20 @@
import {useCallback, useState} from 'react'
import {Pressable, TextInput, useWindowDimensions, View} from 'react-native'
import {Pressable, TextInput, useWindowDimensions} from 'react-native'
import {
useFocusedInputHandler,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import Animated, {
measure,
runOnJS,
useAnimatedProps,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {GlassContainer} from 'expo-glass-effect'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {countGraphemes} from 'unicode-segmenter/grapheme'
@@ -24,21 +27,26 @@ import {
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
import {android, atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {GlassView} from '#/components/GlassView'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_IOS, IS_WEB} from '#/env'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import {ComposerContainer} from './MessageComposer'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
const MIN_HEIGHT = 40
export function MessageInput({
textInputId,
onSendMessage,
hasEmbed,
setEmbed,
children,
}: {
textInputId?: string
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
@@ -57,8 +65,6 @@ export function MessageInput({
const maxHeight = useSharedValue<undefined | number>(undefined)
const isInputScrollable = useSharedValue(false)
const inputStyles = useSharedInputStyles()
const [isFocused, setIsFocused] = useState(false)
const [message, setMessage] = useState(getDraft)
const inputRef = useAnimatedRef<TextInput>()
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
@@ -133,78 +139,114 @@ export function MessageInput({
scrollEnabled: isInputScrollable.get(),
}))
const submitDisabled = needsEmailVerification || message.trim().length === 0
const blur = useCallback(() => {
inputRef.current?.blur()
}, [inputRef])
useKeyboardHandler({
onEnd: evt => {
'worklet'
// small hack: interactive dismiss on Android sometimes doesn't blur the input
if (IS_ANDROID && evt.progress === 0) {
runOnJS(blur)()
}
},
})
return (
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
<ComposerContainer>
{children}
<View
style={[
a.w_full,
a.flex_row,
t.atoms.bg_contrast_25,
{
padding: a.p_sm.padding - 2,
paddingLeft: a.p_md.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
},
isFocused && inputStyles.chromeFocus,
]}>
<AnimatedTextInput
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Write a message`)}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
// including the button we just pressed - and this overrides clearing the input! so we watch for the
// next change and double make sure the input is cleared. It should *always* send an onChange event after
// clearing via setMessage('') that happens in onSubmit()
// -sfn
if (IS_IOS && shouldEnforceClear) {
setShouldEnforceClear(false)
setMessage('')
return
}
const text = evt.nativeEvent.text
setMessage(text)
}}
multiline={true}
style={[
a.flex_1,
a.text_md,
a.px_sm,
t.atoms.text,
android({paddingTop: 0}),
{paddingBottom: IS_IOS ? 5 : 0},
animatedStyle,
]}
keyboardAppearance={t.scheme}
submitBehavior="newline"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
ref={inputRef}
hitSlop={HITSLOP_10}
animatedProps={animatedProps}
editable={!needsEmailVerification}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}
disabled={needsEmailVerification}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
</View>
<GlassContainer
style={[a.flex_row, a.align_end, a.gap_sm]}
spacing={tokens.space.xs}>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}>
<AnimatedTextInput
nativeID={textInputId}
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Message`)}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
// including the button we just pressed - and this overrides clearing the input! so we watch for the
// next change and double make sure the input is cleared. It should *always* send an onChange event after
// clearing via setMessage('') that happens in onSubmit()
// -sfn
if (IS_IOS && shouldEnforceClear) {
setShouldEnforceClear(false)
setMessage('')
return
}
const text = evt.nativeEvent.text
setMessage(text)
}}
multiline={true}
style={[
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
a.flex_shrink_0,
a.flex_grow,
a.text_md,
a.px_lg,
t.atoms.text,
platform({
android: {paddingTop: 2, paddingBottom: 3},
ios: {paddingTop: 10, paddingBottom: 5},
}),
animatedStyle,
]}
verticalAlign="middle"
keyboardAppearance={t.scheme}
submitBehavior="newline"
ref={inputRef}
hitSlop={HITSLOP_10}
animatedProps={animatedProps}
editable={!needsEmailVerification}
/>
</GlassView>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.rounded_full]}
tintColor={
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
}
fallbackStyle={{
backgroundColor: submitDisabled
? t.palette.contrast_100
: t.palette.primary_500,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
height: MIN_HEIGHT,
width: MIN_HEIGHT,
},
]}
onPress={onSubmit}
disabled={submitDisabled}>
<PaperPlaneIcon
size="md"
fill={t.palette.white}
style={[a.mb_2xs]}
/>
</Pressable>
</GlassView>
</GlassContainer>
</ComposerContainer>
)
}
+171 -139
View File
@@ -1,22 +1,28 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {type LayoutChangeEvent, View} from 'react-native'
import {useKeyboardHandler} from 'react-native-keyboard-controller'
import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native'
import {
KeyboardChatScrollView,
type KeyboardChatScrollViewProps,
KeyboardGestureArea,
} from 'react-native-keyboard-controller'
import Animated, {
runOnJS,
scrollTo,
type ScrollEvent,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
type $Typed,
type AppBskyEmbedRecord,
AppBskyRichtextFacet,
RichText,
} from '@atproto/api'
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {mergeRefs} from '#/lib/merge-refs'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
@@ -36,7 +42,6 @@ import {
} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {
EmojiPicker,
type EmojiPickerState,
@@ -46,15 +51,17 @@ import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
@@ -108,9 +115,9 @@ export function MessagesList({
const agent = useAgent()
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
const t = useTheme()
useHideBottomBarBorderForScreen()
const textInputId = 'chat-input-' + useId()
const flatListRef = useAnimatedRef<ListMethods>()
const [newMessagesPill, setNewMessagesPill] = useState({
@@ -123,6 +130,18 @@ export function MessagesList({
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const inputHeightUI = useSharedValue(0)
const [inputHeightJS, setInputHeightJS] = useState(0)
const onInputLayout = useCallback(
(event: LayoutChangeEvent) => {
const inputHeight = event.nativeEvent.layout.height
inputHeightUI.set(inputHeight)
setInputHeightJS(inputHeight)
},
[inputHeightUI],
)
// 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
// the bottom.
@@ -226,12 +245,12 @@ export function MessagesList({
const onStartReached = useCallback(() => {
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
convoState.fetchMessageHistory()
void convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled, layoutHeight])
const onScroll = useCallback(
(e: ReanimatedScrollEvent) => {
(e: ScrollEvent) => {
'worklet'
layoutHeight.set(e.layoutMeasurement.height)
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
@@ -256,56 +275,8 @@ export function MessagesList({
)
// -- Keyboard animation handling
const {footerHeight} = useShellLayout()
const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false)
// In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event.
// We use this value to keep track of when we want to disable the animation.
const layoutScrollWithoutAnimation = useSharedValue(false)
useKeyboardHandler(
{
onStart: e => {
'worklet'
// Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should
// just update the height here instead of having the `onMove` event do it (that event will not fire!)
if (e.duration === 0) {
layoutScrollWithoutAnimation.set(true)
keyboardHeight.set(e.height)
} else {
keyboardIsOpening.set(true)
}
},
onMove: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false)
}
},
onEnd: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false)
}
keyboardIsOpening.set(false)
},
},
[footerHeight],
)
const animatedListStyle = useAnimatedStyle(() => ({
marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
}))
const animatedStickyViewStyle = useAnimatedStyle(() => ({
transform: [
{translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
],
}))
const {bottom: bottomInset} = useSafeAreaInsets()
// -- Message sending
const onSendMessage = useCallback(
@@ -387,26 +358,6 @@ export function MessagesList({
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
)
// -- List layout changes (opening emoji keyboard, etc.)
const onListLayout = useCallback(
(e: LayoutChangeEvent) => {
layoutHeight.set(e.nativeEvent.layout.height)
if (IS_WEB || !keyboardIsOpening.get()) {
flatListRef.current?.scrollToEnd({
animated: !layoutScrollWithoutAnimation.get(),
})
layoutScrollWithoutAnimation.set(false)
}
},
[
flatListRef,
keyboardIsOpening,
layoutScrollWithoutAnimation,
layoutHeight,
],
)
const scrollToEndOnPress = useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current,
@@ -418,66 +369,100 @@ export function MessagesList({
setEmojiPickerState({isOpen: true, pos})
}, [])
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
),
[inputHeightUI],
)
return (
<>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={convoState.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onLayout={onListLayout}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} />
}
/>
</ScrollProvider>
<Animated.View style={animatedStickyViewStyle}>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : blocked ? (
footer
) : (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageComposer>
) : (
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={onOpenEmojiPicker}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={convoState.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} />
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
// pushes up the content under the input on web (renderScrollComponent handles it on native)
ListFooterComponent={web(
<WebInputSpacer inputHeight={inputHeightJS} />,
)}
</ConversationFooter>
)}
</Animated.View>
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
/>
</ScrollProvider>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : blocked ? (
footer
) : (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={onOpenEmojiPicker}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
)}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{IS_WEB && (
<EmojiPicker
@@ -492,6 +477,53 @@ export function MessagesList({
)
}
/** Note: native only */
function ChatScrollComponent({
ref,
inputHeight,
...props
}: ScrollViewProps & {
ref?: React.RefObject<KeyboardChatScrollViewProps>
inputHeight: SharedValue<number>
}) {
const scrollEdgeRef = useScrollEdgeEffectRef()
const {bottom: bottomInset} = useSafeAreaInsets()
const offset = platform({
ios: bottomInset - tokens.space.lg,
android: bottomInset,
default: 0,
})
const inputOffset = platform({
ios: bottomInset - tokens.space.lg,
android: bottomInset,
default: 0,
})
const extraContentPadding = useDerivedValue(
() => inputHeight.get() + inputOffset,
)
return (
<KeyboardChatScrollView
ref={mergeRefs([scrollEdgeRef, ref])}
automaticallyAdjustContentInsets={false}
keyboardDismissMode="interactive"
keyboardLiftBehavior="always"
extraContentPadding={extraContentPadding}
offset={offset}
{...props}
/>
)
}
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
if (!IS_WEB) return null
return <Animated.View style={{height: inputHeight}} />
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
@@ -0,0 +1,48 @@
import {
type KeyboardStickyViewProps,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
// Vendored from https://github.com/kirillzyusko/react-native-keyboard-controller/blob/main/src/components/KeyboardStickyView/index.tsx
// Converted to Reanimated to support `minimumOffset` clamping.
export function KeyboardStickyView({
children,
offset: {closed = 0, opened = 0} = {},
style,
enabled = true,
minimumOffset,
...props
}: KeyboardStickyViewProps & {
/**
* Stop the stickyview going lower than this (i.e. bottom safe area)
*/
minimumOffset?: number
}) {
const {height, progress} = useReanimatedKeyboardAnimation()
const animatedStyle = useAnimatedStyle(() => {
const offset = closed + (opened - closed) * progress.get()
let translateY: number
if (enabled) {
let h = height.get()
if (minimumOffset != null) {
h = Math.min(h, -minimumOffset)
}
translateY = h + offset
} else {
translateY = closed
}
return {
transform: [{translateY}],
}
})
return (
<Animated.View style={[animatedStyle, style]} {...props}>
{children}
</Animated.View>
)
}