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
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

+3 -1
View File
@@ -86,6 +86,7 @@
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.2",
@@ -156,6 +157,7 @@
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-glass-effect": "55.0.8",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
@@ -213,7 +215,7 @@
"react-native-drawer-layout": "^4.2.2",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.21.0",
"react-native-keyboard-controller": "^1.21.5",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
+60
View File
@@ -0,0 +1,60 @@
diff --git a/node_modules/expo-glass-effect/ios/GlassContainer.swift b/node_modules/expo-glass-effect/ios/GlassContainer.swift
index 61fb67c..b2d111e 100644
--- a/node_modules/expo-glass-effect/ios/GlassContainer.swift
+++ b/node_modules/expo-glass-effect/ios/GlassContainer.swift
@@ -1,6 +1,7 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
+import React
public final class GlassContainer: ExpoView {
private var containerEffect: Any?
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
}
}
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
+ // Paper: redirect children into the container effect's contentView
+ public override func didUpdateReactSubviews() {
+ for subview in self.reactSubviews() {
+ containerEffectView.contentView.addSubview(subview)
+ }
+ }
+
+ // Fabric: redirect children into the container effect's contentView
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
containerEffectView.contentView.insertSubview(childComponentView, at: index)
}
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
childComponentView.removeFromSuperview()
}
}
diff --git a/node_modules/expo-glass-effect/ios/GlassView.swift b/node_modules/expo-glass-effect/ios/GlassView.swift
index 35cd8f3..9587306 100644
--- a/node_modules/expo-glass-effect/ios/GlassView.swift
+++ b/node_modules/expo-glass-effect/ios/GlassView.swift
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
#endif
}
}
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
+ // Paper: redirect children into the glass effect's contentView
+ public override func didUpdateReactSubviews() {
+ for subview in self.reactSubviews() {
+ glassEffectView.contentView.addSubview(subview)
+ }
+ }
+
+ // Fabric: redirect children into the glass effect's contentView
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
glassEffectView.contentView.insertSubview(childComponentView, at: index)
}
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
childComponentView.removeFromSuperview()
}
}
@@ -0,0 +1,3 @@
# expo-glass-effect patch
Patches in support for Expo SDK 54. Please delete when we update Expo
+2
View File
@@ -338,6 +338,8 @@ export function Composer({
web({
caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none',
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_200} transparent`,
}),
]}
{...rest}
+35
View File
@@ -0,0 +1,35 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
GlassView as ExpoGlassView,
type GlassViewProps as ExpoGlassViewProps,
isGlassEffectAPIAvailable,
isLiquidGlassAvailable,
} from 'expo-glass-effect'
import {useTheme} from '#/alf'
export const IS_GLASS_AVAILABLE =
isLiquidGlassAvailable() && isGlassEffectAPIAvailable()
/**
* Liquid Glass View that uses `expo-glass-effect`
*
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
*/
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
export type GlassViewProps = ExpoGlassViewProps & {
fallbackStyle?: StyleProp<ViewStyle>
}
function InnerGlassView({
fallbackStyle: _fallbackStyle,
...props
}: GlassViewProps) {
const t = useTheme()
return <ExpoGlassView colorScheme={t.scheme} {...props} />
}
function FallbackView({fallbackStyle, style, ...props}: GlassViewProps) {
return <View style={[fallbackStyle, style]} {...props} />
}
@@ -22,7 +22,13 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function RecentChats({postUri}: {postUri: string}) {
export function RecentChats({
postUri,
onBeforePress,
}: {
postUri: string
onBeforePress?: () => void
}) {
const ax = useAnalytics()
const control = useDialogContext()
const {currentAccount} = useSession()
@@ -32,6 +38,7 @@ export function RecentChats({postUri}: {postUri: string}) {
const navigation = useNavigation<NavigationProp>()
const onSelectChat = (convoId: string) => {
onBeforePress?.()
control.close(() => {
ax.metric('share:press:recentDm', {})
navigation.navigate('MessagesConversation', {
@@ -5,12 +5,14 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {precachePost} from '#/state/queries/post'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
@@ -40,6 +42,7 @@ let ShareMenuItems = ({
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const aa = useAgeAssurance()
const queryClient = useQueryClient()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -77,7 +80,12 @@ let ShareMenuItems = ({
onShareProp()
}
const onBeforeShareViaChat = () => {
precachePost(queryClient, postUri, post)
}
const onSelectChatToShareTo = (conversation: string) => {
onBeforeShareViaChat()
navigation.navigate('MessagesConversation', {
conversation,
embed: postUri,
@@ -98,7 +106,10 @@ let ShareMenuItems = ({
{hasSession && aa.state.access === aa.Access.Full && (
<Menu.Group>
<Menu.ContainerItem>
<RecentChats postUri={postUri} />
<RecentChats
postUri={postUri}
onBeforePress={onBeforeShareViaChat}
/>
</Menu.ContainerItem>
<Menu.Item
testID="postDropdownSendViaDMBtn"
+2 -1
View File
@@ -91,7 +91,8 @@ export function ChatEmptyPill() {
onPressOut={onPressOut}>
<Text
style={[a.font_semi_bold, a.pointer_events_none]}
selectable={false}>
selectable={false}
emoji>
{prompts[promptIndex]}
</Text>
</AnimatedPressable>
+5
View File
@@ -3,3 +3,8 @@ import {createSinglePathSVG} from './TEMPLATE'
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
})
export const PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded =
createSinglePathSVG({
path: 'M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z',
})
+14 -10
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)
}
return () => {
setCurrentConvoId(undefined)
setMinimalShellMode(false)
}
}, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
}, [convoId, setCurrentConvoId, setMinimalShellMode]),
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<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,40 +120,45 @@ export function MessageComposer({
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [])
}, [composerInternalApiRef])
return (
<>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
<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]}>
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) => {
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
// TODO: rip this horrible system out
openEmojiPicker?.({
top: py,
left: px,
right: px,
left: px - 400,
right: px - 400,
bottom: py,
nextFocusRef: {
current: composerInternalApiRef.current?.input?.element,
current:
composerInternalApiRef.current?.input?.element,
},
})
})
},
)
}}
style={[
a.overflow_hidden,
@@ -134,69 +168,48 @@ export function MessageComposer({
a.justify_center,
a.z_30,
{
height: 30,
width: 30,
top: 8,
left: 8,
height: 20,
width: 20,
top: 10,
right: 10,
},
]}
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>
<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`Write a message`}
placeholder={l`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,
},
]}
outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]}
contentPaddingStyle={{
paddingLeft: IS_WEB ? 30 + 12 : 12,
paddingTop: 12,
paddingBottom: 12,
paddingRight: 12,
paddingLeft: 16,
paddingTop: 10,
paddingBottom: 10,
paddingRight: 16 + platform({web: 20, default: 0}),
}}
onFocus={onFocus}
onBlur={onBlur}
onChange={setText}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
@@ -209,34 +222,9 @@ export function MessageComposer({
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>
</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},
]}
/>
)}
</>
)
}
}
@@ -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,27 +139,39 @@ export function MessageInput({
scrollEnabled: isInputScrollable.get(),
}))
return (
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{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',
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)()
}
},
isFocused && inputStyles.chromeFocus,
]}>
})
return (
<ComposerContainer>
{children}
<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`Write a message`)}
placeholder={_(msg`Message`)}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
@@ -172,23 +190,39 @@ export function MessageInput({
}}
multiline={true}
style={[
a.flex_1,
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
a.flex_shrink_0,
a.flex_grow,
a.text_md,
a.px_sm,
a.px_lg,
t.atoms.text,
android({paddingTop: 0}),
{paddingBottom: IS_IOS ? 5 : 0},
platform({
android: {paddingTop: 2, paddingBottom: 3},
ios: {paddingTop: 10, paddingBottom: 5},
}),
animatedStyle,
]}
verticalAlign="middle"
keyboardAppearance={t.scheme}
submitBehavior="newline"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
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`)}
@@ -198,13 +232,21 @@ export function MessageInput({
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
{
height: MIN_HEIGHT,
width: MIN_HEIGHT,
},
]}
onPress={onSubmit}
disabled={needsEmailVerification}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
disabled={submitDisabled}>
<PaperPlaneIcon
size="md"
fill={t.palette.white}
style={[a.mb_2xs]}
/>
</Pressable>
</View>
</View>
</GlassView>
</GlassContainer>
</ComposerContainer>
)
}
+122 -90
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,8 +369,21 @@ export function MessagesList({
setEmojiPickerState({isOpen: true, pos})
}, [])
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
),
[inputHeightUI],
)
return (
<>
<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
@@ -429,28 +393,46 @@ export function MessagesList({
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"
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onLayout={onListLayout}
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} />,
)}
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
/>
</ScrollProvider>
<Animated.View style={animatedStickyViewStyle}>
<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 ? (
@@ -461,6 +443,7 @@ export function MessagesList({
hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
@@ -468,6 +451,7 @@ export function MessagesList({
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
@@ -477,7 +461,8 @@ export function MessagesList({
)}
</ConversationFooter>
)}
</Animated.View>
</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>
)
}
+14 -1
View File
@@ -1,6 +1,11 @@
import {useCallback} from 'react'
import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
import {updatePostShadow} from '#/state/cache/post-shadow'
@@ -43,6 +48,14 @@ export function usePostQuery(uri: string | undefined) {
})
}
export function precachePost(
queryClient: QueryClient,
uri: string,
post: AppBskyFeedDefs.PostView,
) {
queryClient.setQueryData(RQKEY(uri), post)
}
export function useGetPost() {
const queryClient = useQueryClient()
const agent = useAgent()
+3
View File
@@ -19,6 +19,9 @@ const reactNativeWebWebviewConfiguration = {
}
module.exports = async function (env, argv) {
env.babel = {
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo'],
}
let config = await createExpoWebpackConfigAsync(env, argv)
config = withAlias(config, {
'react-native$': 'react-native-web',
+14 -4
View File
@@ -2429,6 +2429,11 @@
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg==
"@bsky.app/expo-scroll-edge-effect@^0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@bsky.app/expo-scroll-edge-effect/-/expo-scroll-edge-effect-0.1.4.tgz#8b785b606c3078b3f8d1ec200adaf13bc59c2fec"
integrity sha512-P94YcYBqZfuUy7ewTrWulPTxTbs6Yvjg2xS5WX4I/F3R3cSQU9fc8vEzb2Pnn4Ieg2wukJdAywqzt+AnyWgJbw==
"@bsky.app/expo-translate-text@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.9.tgz#4ed4552cd50bca7d02d14e706e419bd728d4ab51"
@@ -9013,6 +9018,11 @@ expo-font@~14.0.11:
dependencies:
fontfaceobserver "^2.1.0"
expo-glass-effect@55.0.8:
version "55.0.8"
resolved "https://registry.yarnpkg.com/expo-glass-effect/-/expo-glass-effect-55.0.8.tgz#ace0e662d7c8fc2935c9d1260e8303eb2fde0bc3"
integrity sha512-IvUjHb/4t6r2H/LXDjcQ4uDoHrmO2cLOvEb9leLavQ4HX5+P4LRtQrMDMlkWAn5Wo5DkLcG8+1CrQU2nqgogTA==
expo-haptics@~15.0.8:
version "15.0.8"
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
@@ -13980,10 +13990,10 @@ react-native-is-edge-to-edge@^1.2.1:
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358"
integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==
react-native-keyboard-controller@^1.21.0:
version "1.21.0"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.0.tgz#79ad48c67e6f5ec572b7dc896c7b05a98662a2a2"
integrity sha512-mLHJysehhSzYoM8BAD2DSjVZEcF69t16ZCJrCAos6sfVtbB3tL+kgGZFX+jNVz/f9BEhqnBFO0EA1tc/V6Hkgw==
react-native-keyboard-controller@^1.21.5:
version "1.21.5"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.5.tgz#563aabb7e9ce8dbe2a0dd5f949883ba81620b6c0"
integrity sha512-wxR+vpJ+2g6QMQCP1mRQKySDUietf5xLntZ76cUNHOGsjyqk6LtznXwHBG9YsR9E/b2IrHXISylwqPnIit6Y6A==
dependencies:
react-native-is-edge-to-edge "^1.2.1"