diff --git a/assets/icons/paperPlaneVertical_filled_stroke2_corner1_rounded.svg b/assets/icons/paperPlaneVertical_filled_stroke2_corner1_rounded.svg
new file mode 100644
index 0000000000..081ed9c746
--- /dev/null
+++ b/assets/icons/paperPlaneVertical_filled_stroke2_corner1_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 492fa566a5..66f6ddd0a6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/patches/expo-glass-effect+55.0.8.patch b/patches/expo-glass-effect+55.0.8.patch
new file mode 100644
index 0000000000..6619c811c1
--- /dev/null
+++ b/patches/expo-glass-effect+55.0.8.patch
@@ -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()
+ }
+ }
diff --git a/patches/expo-glass-effect+55.0.8.patch.md b/patches/expo-glass-effect+55.0.8.patch.md
new file mode 100644
index 0000000000..ab668c78ff
--- /dev/null
+++ b/patches/expo-glass-effect+55.0.8.patch.md
@@ -0,0 +1,3 @@
+# expo-glass-effect patch
+
+Patches in support for Expo SDK 54. Please delete when we update Expo
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 058e0ea750..b7a67f3033 100644
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -338,6 +338,8 @@ export function Composer({
web({
caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none',
+ scrollbarWidth: 'thin',
+ scrollbarColor: `${t.palette.contrast_200} transparent`,
}),
]}
{...rest}
diff --git a/src/components/GlassView.tsx b/src/components/GlassView.tsx
new file mode 100644
index 0000000000..a695a0678a
--- /dev/null
+++ b/src/components/GlassView.tsx
@@ -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
+}
+
+function InnerGlassView({
+ fallbackStyle: _fallbackStyle,
+ ...props
+}: GlassViewProps) {
+ const t = useTheme()
+ return
+}
+
+function FallbackView({fallbackStyle, style, ...props}: GlassViewProps) {
+ return
+}
diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx
index 178f8c0baa..24fcc87b3a 100644
--- a/src/components/PostControls/ShareMenu/RecentChats.tsx
+++ b/src/components/PostControls/ShareMenu/RecentChats.tsx
@@ -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()
const onSelectChat = (convoId: string) => {
+ onBeforePress?.()
control.close(() => {
ax.metric('share:press:recentDm', {})
navigation.navigate('MessagesConversation', {
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
index 8cdfe1a93d..627034f5d6 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -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 && (
-
+
+ selectable={false}
+ emoji>
{prompts[promptIndex]}
diff --git a/src/components/icons/PaperPlane.tsx b/src/components/icons/PaperPlane.tsx
index eef38638cb..504c15f284 100644
--- a/src/components/icons/PaperPlane.tsx
+++ b/src/components/icons/PaperPlane.tsx
@@ -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',
+ })
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index f6051c9558..509907b331 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -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 (
-
-
-
+
+
+
+
+
)
}
@@ -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 (
+ {/* MessagesList does not use the body scroll */}
+ {isFocused && IS_WEB && }
{!readyToShow &&
(moderation ? (
diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx
index 9d46534849..cb5da5628a 100644
--- a/src/screens/Messages/components/MessageComposer.tsx
+++ b/src/screens/Messages/components/MessageComposer.tsx
@@ -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 (
- <>
-
- {children}
+
+ {children}
- {
- 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 && (
- {
- e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
- openEmojiPicker?.({
- top: py,
- left: px,
- right: px,
- bottom: py,
- nextFocusRef: {
- current: composerInternalApiRef.current?.input?.element,
+
+ void composerInternalApiRef.current?.setAutocompleteAnchor(node),
+ )}>
+
+
+ {IS_WEB && (
+ {
+ 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 => (
+
+ )}
+
+ )}
+
+
- {state => (
-
-
-
- )}
-
- )}
-
- {
- 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 ? (
-
-
-
- ) : null}
-
+ 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()
+ }}
+ />
+
+
+
{IS_WEB && (
@@ -246,6 +234,114 @@ export function MessageComposer({
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
- >
+
)
}
+
+function SubmitButton({
+ onPress,
+ disabled,
+}: {
+ onPress: () => void
+ disabled: boolean
+}) {
+ const {t: l} = useLingui()
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+ )
+}
+
+// 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 (
+
+
+ {children}
+
+
+ )
+ } else {
+ return (
+ <>
+
+ {children}
+
+ {/* covers the gap between the keyboard and the input during keyboard animation */}
+ {IS_NATIVE && (
+
+ )}
+ >
+ )
+ }
+}
diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx
index f39205bd3b..643c0c86a6 100644
--- a/src/screens/Messages/components/MessageInput.tsx
+++ b/src/screens/Messages/components/MessageInput.tsx
@@ -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)
const isInputScrollable = useSharedValue(false)
- const inputStyles = useSharedInputStyles()
- const [isFocused, setIsFocused] = useState(false)
const [message, setMessage] = useState(getDraft)
const inputRef = useAnimatedRef()
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 (
-
+
{children}
-
- {
- // 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}
- />
-
-
-
-
-
+
+
+ {
+ // 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}
+ />
+
+
+
+
+
+
+
+
)
}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 27f1c93387..eda3c593d9 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -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()
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) => (
+
+ ),
+ [inputHeightUI],
+ )
+
return (
<>
- {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
-
-
- }
- />
-
-
- {convoState.status === ConvoStatus.Disabled ? (
-
- ) : blocked ? (
- footer
- ) : (
-
- {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
-
-
-
- ) : (
-
-
-
+
+ {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
+
+
+ }
+ // 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(
+ ,
)}
-
- )}
-
+ style={web({
+ scrollbarWidth: 'thin',
+ scrollbarColor: `${t.palette.contrast_100} transparent`,
+ scrollbarGutter: 'stable both-edges',
+ })}
+ />
+
+
+ {convoState.status === ConvoStatus.Disabled ? (
+
+ ) : blocked ? (
+ footer
+ ) : (
+
+ {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
{IS_WEB && (
+ inputHeight: SharedValue
+}) {
+ 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 (
+
+ )
+}
+
+function WebInputSpacer({inputHeight}: {inputHeight: number}) {
+ if (!IS_WEB) return null
+
+ return
+}
+
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
diff --git a/src/screens/Messages/components/vendor/KeyboardStickyView.tsx b/src/screens/Messages/components/vendor/KeyboardStickyView.tsx
new file mode 100644
index 0000000000..bacb18fb6b
--- /dev/null
+++ b/src/screens/Messages/components/vendor/KeyboardStickyView.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 4024001a90..79df51d1bf 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -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()
diff --git a/webpack.config.js b/webpack.config.js
index db67d72e92..37c110c7bb 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -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',
diff --git a/yarn.lock b/yarn.lock
index 49dcae5214..dc073840d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"