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"