Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor chat list implementation (#10059)

authored by

Samuel Newman and committed by
GitHub
888eca73 2c717dc1

+773 -398
+1
assets/icons/paperPlaneVertical_filled_stroke2_corner1_rounded.svg
··· 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>
+3 -1
package.json
··· 86 86 "@braintree/sanitize-url": "^6.0.2", 87 87 "@bsky.app/alf": "^0.1.7", 88 88 "@bsky.app/expo-image-crop-tool": "^0.5.0", 89 + "@bsky.app/expo-scroll-edge-effect": "^0.1.4", 89 90 "@bsky.app/expo-translate-text": "^0.2.9", 90 91 "@bsky.app/react-native-mmkv": "2.12.5", 91 92 "@bsky.app/sift": "^0.3.2", ··· 156 157 "expo-device": "~8.0.10", 157 158 "expo-file-system": "~19.0.21", 158 159 "expo-font": "~14.0.11", 160 + "expo-glass-effect": "55.0.8", 159 161 "expo-haptics": "~15.0.8", 160 162 "expo-image": "~3.0.11", 161 163 "expo-image-manipulator": "~14.0.8", ··· 213 215 "react-native-drawer-layout": "^4.2.2", 214 216 "react-native-edge-to-edge": "^1.6.0", 215 217 "react-native-gesture-handler": "~2.28.0", 216 - "react-native-keyboard-controller": "^1.21.0", 218 + "react-native-keyboard-controller": "^1.21.5", 217 219 "react-native-pager-view": "6.8.0", 218 220 "react-native-progress": "bluesky-social/react-native-progress", 219 221 "react-native-qrcode-styled": "^0.3.3",
+60
patches/expo-glass-effect+55.0.8.patch
··· 1 + diff --git a/node_modules/expo-glass-effect/ios/GlassContainer.swift b/node_modules/expo-glass-effect/ios/GlassContainer.swift 2 + index 61fb67c..b2d111e 100644 3 + --- a/node_modules/expo-glass-effect/ios/GlassContainer.swift 4 + +++ b/node_modules/expo-glass-effect/ios/GlassContainer.swift 5 + @@ -1,6 +1,7 @@ 6 + // Copyright 2022-present 650 Industries. All rights reserved. 7 + 8 + import ExpoModulesCore 9 + +import React 10 + 11 + public final class GlassContainer: ExpoView { 12 + private var containerEffect: Any? 13 + @@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView { 14 + } 15 + } 16 + 17 + - public override func mountChildComponentView(_ childComponentView: UIView, index: Int) { 18 + + // Paper: redirect children into the container effect's contentView 19 + + public override func didUpdateReactSubviews() { 20 + + for subview in self.reactSubviews() { 21 + + containerEffectView.contentView.addSubview(subview) 22 + + } 23 + + } 24 + + 25 + + // Fabric: redirect children into the container effect's contentView 26 + + @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) { 27 + containerEffectView.contentView.insertSubview(childComponentView, at: index) 28 + } 29 + 30 + - public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) { 31 + + @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) { 32 + childComponentView.removeFromSuperview() 33 + } 34 + } 35 + diff --git a/node_modules/expo-glass-effect/ios/GlassView.swift b/node_modules/expo-glass-effect/ios/GlassView.swift 36 + index 35cd8f3..9587306 100644 37 + --- a/node_modules/expo-glass-effect/ios/GlassView.swift 38 + +++ b/node_modules/expo-glass-effect/ios/GlassView.swift 39 + @@ -271,11 +271,19 @@ public final class GlassView: ExpoView { 40 + #endif 41 + } 42 + } 43 + - public override func mountChildComponentView(_ childComponentView: UIView, index: Int) { 44 + + // Paper: redirect children into the glass effect's contentView 45 + + public override func didUpdateReactSubviews() { 46 + + for subview in self.reactSubviews() { 47 + + glassEffectView.contentView.addSubview(subview) 48 + + } 49 + + } 50 + + 51 + + // Fabric: redirect children into the glass effect's contentView 52 + + @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) { 53 + glassEffectView.contentView.insertSubview(childComponentView, at: index) 54 + } 55 + 56 + - public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) { 57 + + @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) { 58 + childComponentView.removeFromSuperview() 59 + } 60 + }
+3
patches/expo-glass-effect+55.0.8.patch.md
··· 1 + # expo-glass-effect patch 2 + 3 + Patches in support for Expo SDK 54. Please delete when we update Expo
+2
src/components/Composer/index.tsx
··· 338 338 web({ 339 339 caretColor: textStyle.color ?? 'black', 340 340 overscrollBehavior: 'none', 341 + scrollbarWidth: 'thin', 342 + scrollbarColor: `${t.palette.contrast_200} transparent`, 341 343 }), 342 344 ]} 343 345 {...rest}
+35
src/components/GlassView.tsx
··· 1 + import {type StyleProp, View, type ViewStyle} from 'react-native' 2 + import { 3 + GlassView as ExpoGlassView, 4 + type GlassViewProps as ExpoGlassViewProps, 5 + isGlassEffectAPIAvailable, 6 + isLiquidGlassAvailable, 7 + } from 'expo-glass-effect' 8 + 9 + import {useTheme} from '#/alf' 10 + 11 + export const IS_GLASS_AVAILABLE = 12 + isLiquidGlassAvailable() && isGlassEffectAPIAvailable() 13 + 14 + /** 15 + * Liquid Glass View that uses `expo-glass-effect` 16 + * 17 + * If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance. 18 + */ 19 + export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView 20 + 21 + export type GlassViewProps = ExpoGlassViewProps & { 22 + fallbackStyle?: StyleProp<ViewStyle> 23 + } 24 + 25 + function InnerGlassView({ 26 + fallbackStyle: _fallbackStyle, 27 + ...props 28 + }: GlassViewProps) { 29 + const t = useTheme() 30 + return <ExpoGlassView colorScheme={t.scheme} {...props} /> 31 + } 32 + 33 + function FallbackView({fallbackStyle, style, ...props}: GlassViewProps) { 34 + return <View style={[fallbackStyle, style]} {...props} /> 35 + }
+8 -1
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 22 22 import {useAnalytics} from '#/analytics' 23 23 import type * as bsky from '#/types/bsky' 24 24 25 - export function RecentChats({postUri}: {postUri: string}) { 25 + export function RecentChats({ 26 + postUri, 27 + onBeforePress, 28 + }: { 29 + postUri: string 30 + onBeforePress?: () => void 31 + }) { 26 32 const ax = useAnalytics() 27 33 const control = useDialogContext() 28 34 const {currentAccount} = useSession() ··· 32 38 const navigation = useNavigation<NavigationProp>() 33 39 34 40 const onSelectChat = (convoId: string) => { 41 + onBeforePress?.() 35 42 control.close(() => { 36 43 ax.metric('share:press:recentDm', {}) 37 44 navigation.navigate('MessagesConversation', {
+12 -1
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {Trans} from '@lingui/react/macro' 7 7 import {useNavigation} from '@react-navigation/native' 8 + import {useQueryClient} from '@tanstack/react-query' 8 9 9 10 import {makeProfileLink} from '#/lib/routes/links' 10 11 import {type NavigationProp} from '#/lib/routes/types' 11 12 import {shareText, shareUrl} from '#/lib/sharing' 12 13 import {toShareUrl} from '#/lib/strings/url-helpers' 13 14 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 + import {precachePost} from '#/state/queries/post' 14 16 import {useSession} from '#/state/session' 15 17 import {atoms as a} from '#/alf' 16 18 import {Admonition} from '#/components/Admonition' ··· 40 42 const sendViaChatControl = useDialogControl() 41 43 const [devModeEnabled] = useDevMode() 42 44 const aa = useAgeAssurance() 45 + const queryClient = useQueryClient() 43 46 44 47 const postUri = post.uri 45 48 const postAuthor = useProfileShadow(post.author) ··· 75 78 type: 'success', 76 79 }) 77 80 onShareProp() 81 + } 82 + 83 + const onBeforeShareViaChat = () => { 84 + precachePost(queryClient, postUri, post) 78 85 } 79 86 80 87 const onSelectChatToShareTo = (conversation: string) => { 88 + onBeforeShareViaChat() 81 89 navigation.navigate('MessagesConversation', { 82 90 conversation, 83 91 embed: postUri, ··· 98 106 {hasSession && aa.state.access === aa.Access.Full && ( 99 107 <Menu.Group> 100 108 <Menu.ContainerItem> 101 - <RecentChats postUri={postUri} /> 109 + <RecentChats 110 + postUri={postUri} 111 + onBeforePress={onBeforeShareViaChat} 112 + /> 102 113 </Menu.ContainerItem> 103 114 <Menu.Item 104 115 testID="postDropdownSendViaDMBtn"
+2 -1
src/components/dms/ChatEmptyPill.tsx
··· 91 91 onPressOut={onPressOut}> 92 92 <Text 93 93 style={[a.font_semi_bold, a.pointer_events_none]} 94 - selectable={false}> 94 + selectable={false} 95 + emoji> 95 96 {prompts[promptIndex]} 96 97 </Text> 97 98 </AnimatedPressable>
+5
src/components/icons/PaperPlane.tsx
··· 3 3 export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 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', 5 5 }) 6 + 7 + export const PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded = 8 + createSinglePathSVG({ 9 + 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', 10 + })
+18 -14
src/screens/Messages/Conversation.tsx
··· 5 5 moderateProfile, 6 6 type ModerationDecision, 7 7 } from '@atproto/api' 8 + import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect' 8 9 import {msg} from '@lingui/core/macro' 9 10 import {useLingui} from '@lingui/react' 10 11 import {Trans} from '@lingui/react/macro' 11 12 import { 12 13 type RouteProp, 13 14 useFocusEffect, 15 + useIsFocused, 14 16 useNavigation, 15 17 useRoute, 16 18 } from '@react-navigation/native' 17 19 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 20 + import {RemoveScrollBar} from 'react-remove-scroll-bar' 18 21 19 22 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 20 23 import { ··· 30 33 import {useProfileQuery} from '#/state/queries/profile' 31 34 import {useSetMinimalShellMode} from '#/state/shell' 32 35 import {MessagesList} from '#/screens/Messages/components/MessagesList' 33 - import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 36 + import {atoms as a, useTheme, web} from '#/alf' 34 37 import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 35 38 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 36 39 import { ··· 62 65 } 63 66 64 67 export function MessagesConversationScreenInner({route}: Props) { 65 - const {gtMobile} = useBreakpoints() 66 68 const setMinimalShellMode = useSetMinimalShellMode() 67 69 68 70 const convoId = route.params.conversation ··· 71 73 useFocusEffect( 72 74 useCallback(() => { 73 75 setCurrentConvoId(convoId) 74 - 75 - if (IS_WEB && !gtMobile) { 76 - setMinimalShellMode(true) 77 - } else { 78 - setMinimalShellMode(false) 79 - } 76 + setMinimalShellMode(true) 80 77 81 78 return () => { 82 79 setCurrentConvoId(undefined) 83 80 setMinimalShellMode(false) 84 81 } 85 - }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]), 82 + }, [convoId, setCurrentConvoId, setMinimalShellMode]), 86 83 ) 87 84 88 85 return ( 89 86 <Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}> 90 - <ConvoProvider key={convoId} convoId={convoId}> 91 - <Inner /> 92 - </ConvoProvider> 87 + <ScrollEdgeEffectProvider> 88 + <ConvoProvider key={convoId} convoId={convoId}> 89 + <Inner /> 90 + </ConvoProvider> 91 + </ScrollEdgeEffectProvider> 93 92 </Layout.Screen> 94 93 ) 95 94 } ··· 98 97 const t = useTheme() 99 98 const convoState = useConvo() 100 99 const {_} = useLingui() 100 + const isFocused = useIsFocused() 101 101 102 102 const moderationOpts = useModerationOpts() 103 103 const {data: recipientUnshadowed} = useProfileQuery({ ··· 122 122 123 123 // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this 124 124 // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added. 125 - useEffect(() => { 125 + const [prevState, setPrevState] = useState(convoState.status) 126 + if (prevState !== convoState.status) { 127 + setPrevState(convoState.status) 126 128 if (convoState.status === ConvoStatus.Initializing) { 127 129 setHasScrolled(false) 128 130 } 129 - }, [convoState.status]) 131 + } 130 132 131 133 if (convoState.status === ConvoStatus.Error) { 132 134 return ( ··· 150 152 151 153 return ( 152 154 <Layout.Center style={[a.flex_1]}> 155 + {/* MessagesList does not use the body scroll */} 156 + {isFocused && IS_WEB && <RemoveScrollBar />} 153 157 {!readyToShow && 154 158 (moderation ? ( 155 159 <MessagesListHeader moderation={moderation} profile={recipient} />
+254 -158
src/screens/Messages/components/MessageComposer.tsx
··· 1 1 import {useEffect, useState} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 + import { 4 + useKeyboardHandler, 5 + useReanimatedKeyboardAnimation, 6 + } from 'react-native-keyboard-controller' 7 + import Animated, { 8 + Extrapolation, 9 + interpolate, 10 + runOnJS, 11 + useAnimatedStyle, 12 + } from 'react-native-reanimated' 13 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 + import {GlassContainer} from 'expo-glass-effect' 15 + import {LinearGradient} from 'expo-linear-gradient' 16 + import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect' 3 17 import {useLingui} from '@lingui/react/macro' 4 18 import {countGraphemes} from 'unicode-segmenter/grapheme' 5 19 ··· 17 31 EmojiPicker, 18 32 type EmojiPickerState, 19 33 } from '#/view/com/composer/text-input/web/EmojiPicker' 20 - import {atoms as a, useTheme} from '#/alf' 34 + import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' 21 35 import {Composer, useComposerInternalApiRef} from '#/components/Composer' 22 - import {useInteractionState} from '#/components/hooks/useInteractionState' 23 - import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 24 - import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 36 + import {GlassView} from '#/components/GlassView' 37 + import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 38 + import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 25 39 import * as Toast from '#/components/Toast' 26 - import {IS_WEB} from '#/env' 40 + import {IS_ANDROID, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 41 + 42 + const MIN_HEIGHT = 40 27 43 28 44 export function MessageComposer({ 45 + textInputId, 29 46 onSendMessage, 30 47 hasEmbed, 31 48 setEmbed, 32 49 children, 33 50 }: { 51 + textInputId?: string 34 52 onSendMessage: (message: string) => void 35 53 hasEmbed: boolean 36 54 setEmbed: (embedUrl: string | undefined) => void ··· 48 66 }) 49 67 const composerInternalApiRef = useComposerInternalApiRef() 50 68 51 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 52 - const { 53 - state: hovered, 54 - onIn: onHoverIn, 55 - onOut: onHoverOut, 56 - } = useInteractionState() 57 - 58 69 const [text, setText] = useState(getDraft) 59 70 useSaveMessageDraft(text) 60 71 72 + // Android interactive dismiss sometimes doesn't blur the input 73 + const blur = () => { 74 + composerInternalApiRef.current?.input?.blur() 75 + } 76 + 77 + useKeyboardHandler({ 78 + onEnd: evt => { 79 + 'worklet' 80 + if (IS_ANDROID && evt.progress === 0) { 81 + runOnJS(blur)() 82 + } 83 + }, 84 + }) 85 + 86 + const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) 87 + 61 88 const openEmojiPicker = (pos: any) => { 62 89 setEmojiPickerState({isOpen: true, pos}) 63 90 } ··· 65 92 const onSubmit = () => { 66 93 if (!editable) return 67 94 if (!hasEmbed && text.trim() === '') return 68 - if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) { 69 - Toast.show(l`Message is too long`, { 70 - type: 'error', 71 - }) 95 + const graphemeCount = countGraphemes(text) 96 + if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) { 97 + Toast.show( 98 + l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`, 99 + {type: 'error'}, 100 + ) 72 101 return 73 102 } 74 103 ··· 91 120 return () => { 92 121 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 93 122 } 94 - }, []) 123 + }, [composerInternalApiRef]) 95 124 96 125 return ( 97 - <> 98 - <View style={[a.px_md, a.pb_sm, a.pt_xs]}> 99 - {children} 126 + <ComposerContainer> 127 + {children} 100 128 101 - <View 102 - collapsable={false} 103 - ref={ 104 - IS_WEB 105 - ? undefined 106 - : node => { 107 - composerInternalApiRef.current?.setAutocompleteAnchor(node) 108 - } 109 - } 110 - // @ts-expect-error web only 111 - onMouseEnter={onHoverIn} 112 - onMouseLeave={onHoverOut} 113 - style={[a.w_full, a.flex_row, a.gap_sm]}> 114 - {IS_WEB && ( 115 - <Pressable 116 - onPress={e => { 117 - e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { 118 - openEmojiPicker?.({ 119 - top: py, 120 - left: px, 121 - right: px, 122 - bottom: py, 123 - nextFocusRef: { 124 - current: composerInternalApiRef.current?.input?.element, 125 - }, 126 - }) 127 - }) 128 - }} 129 - style={[ 130 - a.overflow_hidden, 131 - a.absolute, 132 - a.rounded_full, 133 - a.align_center, 134 - a.justify_center, 135 - a.z_30, 136 - { 137 - height: 30, 138 - width: 30, 139 - top: 8, 140 - left: 8, 141 - }, 142 - ]} 143 - accessibilityLabel={l`Open emoji picker`} 144 - accessibilityHint=""> 145 - {state => ( 146 - <View 147 - style={[ 148 - a.absolute, 149 - a.inset_0, 150 - a.align_center, 151 - a.justify_center, 152 - { 153 - backgroundColor: 154 - state.hovered || state.focused || state.pressed 155 - ? t.atoms.bg.backgroundColor 156 - : undefined, 129 + <View 130 + collapsable={false} 131 + ref={native( 132 + (node: View) => 133 + void composerInternalApiRef.current?.setAutocompleteAnchor(node), 134 + )}> 135 + <GlassContainer 136 + style={[a.w_full, a.flex_row, a.gap_sm, a.align_end]} 137 + spacing={tokens.space.sm}> 138 + <GlassView 139 + isInteractive 140 + glassEffectStyle="regular" 141 + style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]} 142 + tintColor={t.palette.contrast_50} 143 + fallbackStyle={[t.atoms.bg_contrast_50]}> 144 + {IS_WEB && ( 145 + <Pressable 146 + onPress={e => { 147 + e.currentTarget.measure( 148 + (_fx, _fy, _width, _height, px, py) => { 149 + // TODO: rip this horrible system out 150 + openEmojiPicker?.({ 151 + top: py, 152 + left: px - 400, 153 + right: px - 400, 154 + bottom: py, 155 + nextFocusRef: { 156 + current: 157 + composerInternalApiRef.current?.input?.element, 158 + }, 159 + }) 157 160 }, 158 - ]}> 159 - <EmojiSmile size="lg" /> 160 - </View> 161 - )} 162 - </Pressable> 163 - )} 164 - 165 - <Composer 166 - label={l`Message input field`} 167 - placeholder={l`Write a message`} 168 - autocompletePlacement="top-start" 169 - internalApiRef={composerInternalApiRef} 170 - defaultValue={text} 171 - editable={editable} 172 - autoFocus={IS_WEB} 173 - maxRows={12} 174 - outerStyle={[ 175 - a.flex_1, 176 - t.atoms.bg_contrast_25, 177 - { 178 - borderWidth: 1, 179 - borderColor: 'transparent', 180 - borderRadius: 22, 181 - }, 182 - editable && 183 - hovered && { 184 - borderColor: t.atoms.border_contrast_medium.borderColor, 185 - }, 186 - editable && 187 - focused && { 188 - borderColor: t.palette.primary_500, 189 - }, 190 - ]} 191 - contentTextStyle={[a.text_md, a.leading_snug]} 192 - contentPaddingStyle={{ 193 - paddingLeft: IS_WEB ? 30 + 12 : 12, 194 - paddingTop: 12, 195 - paddingBottom: 12, 196 - paddingRight: 12, 197 - }} 198 - onFocus={onFocus} 199 - onBlur={onBlur} 200 - onChange={setText} 201 - onFacetCommitted={facet => { 202 - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { 203 - setEmbed(facet.value) 204 - } 205 - }} 206 - onRequestSubmit={req => { 207 - if (req.platform === 'web' && req.shiftKey) return 208 - req.nativeEvent.preventDefault() 209 - onSubmit() 210 - }} 211 - /> 161 + ) 162 + }} 163 + style={[ 164 + a.overflow_hidden, 165 + a.absolute, 166 + a.rounded_full, 167 + a.align_center, 168 + a.justify_center, 169 + a.z_30, 170 + { 171 + height: 20, 172 + width: 20, 173 + top: 10, 174 + right: 10, 175 + }, 176 + ]} 177 + accessibilityLabel={l`Open emoji picker`} 178 + accessibilityHint=""> 179 + {state => ( 180 + <EmojiSmileIcon 181 + size="md" 182 + style={ 183 + state.hovered || 184 + state.focused || 185 + state.pressed || 186 + emojiPickerState.isOpen 187 + ? {color: t.palette.primary_500} 188 + : t.atoms.text_contrast_high 189 + } 190 + /> 191 + )} 192 + </Pressable> 193 + )} 212 194 213 - {focused || text.length ? ( 214 - <Pressable 215 - accessibilityRole="button" 216 - accessibilityLabel={l`Send message`} 217 - accessibilityHint="" 218 - hitSlop={HITSLOP_10} 219 - style={[ 220 - a.rounded_full, 221 - a.align_center, 222 - a.justify_center, 223 - a.self_end, 224 - a.z_30, 225 - { 226 - height: 44, 227 - width: 44, 228 - backgroundColor: t.palette.primary_500, 229 - }, 230 - ]} 231 - onPress={onSubmit} 232 - disabled={!editable}> 233 - <PaperPlane 234 - fill={t.palette.white} 235 - style={[a.relative, {left: 1}]} 236 - /> 237 - </Pressable> 238 - ) : null} 239 - </View> 195 + <Composer 196 + nativeID={textInputId} 197 + label={l`Message input field`} 198 + placeholder={l`Message`} 199 + autocompletePlacement="top-start" 200 + internalApiRef={composerInternalApiRef} 201 + defaultValue={text} 202 + editable={editable} 203 + autoFocus={IS_WEB} 204 + maxRows={12} 205 + outerStyle={[a.flex_1]} 206 + contentTextStyle={[a.text_md, a.leading_snug]} 207 + contentPaddingStyle={{ 208 + paddingLeft: 16, 209 + paddingTop: 10, 210 + paddingBottom: 10, 211 + paddingRight: 16 + platform({web: 20, default: 0}), 212 + }} 213 + onChange={setText} 214 + onFacetCommitted={facet => { 215 + if (facet.type === 'url' && isBskyPostUrl(facet.value)) { 216 + setEmbed(facet.value) 217 + } 218 + }} 219 + onRequestSubmit={req => { 220 + if (req.platform === 'web' && req.shiftKey) return 221 + req.nativeEvent.preventDefault() 222 + onSubmit() 223 + }} 224 + /> 225 + </GlassView> 226 + <SubmitButton onPress={onSubmit} disabled={submitDisabled} /> 227 + </GlassContainer> 240 228 </View> 241 229 242 230 {IS_WEB && ( ··· 246 234 close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} 247 235 /> 248 236 )} 249 - </> 237 + </ComposerContainer> 250 238 ) 251 239 } 240 + 241 + function SubmitButton({ 242 + onPress, 243 + disabled, 244 + }: { 245 + onPress: () => void 246 + disabled: boolean 247 + }) { 248 + const {t: l} = useLingui() 249 + const t = useTheme() 250 + 251 + return ( 252 + <GlassView 253 + isInteractive 254 + glassEffectStyle="regular" 255 + style={[a.rounded_full]} 256 + tintColor={disabled ? t.palette.contrast_100 : t.palette.primary_500} 257 + fallbackStyle={{ 258 + backgroundColor: disabled 259 + ? t.palette.contrast_100 260 + : t.palette.primary_500, 261 + }}> 262 + <Pressable 263 + accessibilityRole="button" 264 + accessibilityLabel={l`Send message`} 265 + accessibilityHint="" 266 + hitSlop={HITSLOP_10} 267 + style={[ 268 + a.rounded_full, 269 + a.align_center, 270 + a.justify_center, 271 + {height: MIN_HEIGHT, width: MIN_HEIGHT}, 272 + ]} 273 + onPress={onPress} 274 + disabled={disabled}> 275 + <PaperPlaneIcon size="md" fill={t.palette.white} style={[a.mb_2xs]} /> 276 + </Pressable> 277 + </GlassView> 278 + ) 279 + } 280 + 281 + // TODO: remove export when MessageInput is deleted 282 + export function ComposerContainer({children}: {children: React.ReactNode}) { 283 + const {bottom: bottomInset} = useSafeAreaInsets() 284 + const {progress} = useReanimatedKeyboardAnimation() 285 + const t = useTheme() 286 + 287 + const animatedContainerStyle = useAnimatedStyle(() => ({ 288 + paddingHorizontal: interpolate( 289 + progress.get(), 290 + [0, 1], 291 + [bottomInset, tokens.space.sm], 292 + { 293 + extrapolateRight: Extrapolation.CLAMP, 294 + extrapolateLeft: Extrapolation.CLAMP, 295 + }, 296 + ), 297 + })) 298 + 299 + if (IS_LIQUID_GLASS) { 300 + return ( 301 + <ScrollEdgeEffect edge="bottom"> 302 + <Animated.View style={[a.w_full, animatedContainerStyle, a.pb_lg]}> 303 + {children} 304 + </Animated.View> 305 + </ScrollEdgeEffect> 306 + ) 307 + } else { 308 + return ( 309 + <> 310 + <LinearGradient 311 + style={platform({ 312 + native: [a.pt_sm, a.px_lg, , a.pb_lg, a.w_full], 313 + web: [ 314 + a.pt_xs, 315 + a.pl_lg, 316 + a.pb_lg, 317 + // prevent overlap with the scrollbar, which looks ugly 318 + a.pr_xs, // xs + md = lg 319 + {width: `calc(100% - ${tokens.space.md}px)` as '100%'}, 320 + ], 321 + })} 322 + key={t.name} // android does not update when you change the colors. sigh. 323 + start={[0.5, 0]} 324 + end={[0.5, 1]} 325 + colors={[ 326 + utils.alpha(t.atoms.bg.backgroundColor, 0), 327 + utils.alpha(t.atoms.bg.backgroundColor, 0.8), 328 + t.atoms.bg.backgroundColor, 329 + ]}> 330 + {children} 331 + </LinearGradient> 332 + {/* covers the gap between the keyboard and the input during keyboard animation */} 333 + {IS_NATIVE && ( 334 + <View 335 + style={[ 336 + t.atoms.bg, 337 + a.absolute, 338 + a.left_0, 339 + a.right_0, 340 + {top: '100%', height: bottomInset + 1, marginTop: -1}, 341 + ]} 342 + /> 343 + )} 344 + </> 345 + ) 346 + } 347 + }
+120 -78
src/screens/Messages/components/MessageInput.tsx
··· 1 1 import {useCallback, useState} from 'react' 2 - import {Pressable, TextInput, useWindowDimensions, View} from 'react-native' 2 + import {Pressable, TextInput, useWindowDimensions} from 'react-native' 3 3 import { 4 4 useFocusedInputHandler, 5 + useKeyboardHandler, 5 6 useReanimatedKeyboardAnimation, 6 7 } from 'react-native-keyboard-controller' 7 8 import Animated, { 8 9 measure, 10 + runOnJS, 9 11 useAnimatedProps, 10 12 useAnimatedRef, 11 13 useAnimatedStyle, 12 14 useSharedValue, 13 15 } from 'react-native-reanimated' 14 16 import {useSafeAreaInsets} from 'react-native-safe-area-context' 17 + import {GlassContainer} from 'expo-glass-effect' 15 18 import {msg} from '@lingui/core/macro' 16 19 import {useLingui} from '@lingui/react' 17 20 import {countGraphemes} from 'unicode-segmenter/grapheme' ··· 24 27 useSaveMessageDraft, 25 28 } from '#/state/messages/message-drafts' 26 29 import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' 27 - import {android, atoms as a, useTheme} from '#/alf' 28 - import {useSharedInputStyles} from '#/components/forms/TextField' 29 - import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 30 + import {atoms as a, platform, tokens, useTheme} from '#/alf' 31 + import {GlassView} from '#/components/GlassView' 32 + import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 30 33 import * as Toast from '#/components/Toast' 31 - import {IS_IOS, IS_WEB} from '#/env' 34 + import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' 35 + import {ComposerContainer} from './MessageComposer' 32 36 import {useExtractEmbedFromFacets} from './MessageInputEmbed' 33 37 34 38 const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) 35 39 40 + const MIN_HEIGHT = 40 41 + 36 42 export function MessageInput({ 43 + textInputId, 37 44 onSendMessage, 38 45 hasEmbed, 39 46 setEmbed, 40 47 children, 41 48 }: { 49 + textInputId?: string 42 50 onSendMessage: (message: string) => void 43 51 hasEmbed: boolean 44 52 setEmbed: (embedUrl: string | undefined) => void ··· 57 65 const maxHeight = useSharedValue<undefined | number>(undefined) 58 66 const isInputScrollable = useSharedValue(false) 59 67 60 - const inputStyles = useSharedInputStyles() 61 - const [isFocused, setIsFocused] = useState(false) 62 68 const [message, setMessage] = useState(getDraft) 63 69 const inputRef = useAnimatedRef<TextInput>() 64 70 const [shouldEnforceClear, setShouldEnforceClear] = useState(false) ··· 133 139 scrollEnabled: isInputScrollable.get(), 134 140 })) 135 141 142 + const submitDisabled = needsEmailVerification || message.trim().length === 0 143 + 144 + const blur = useCallback(() => { 145 + inputRef.current?.blur() 146 + }, [inputRef]) 147 + 148 + useKeyboardHandler({ 149 + onEnd: evt => { 150 + 'worklet' 151 + // small hack: interactive dismiss on Android sometimes doesn't blur the input 152 + if (IS_ANDROID && evt.progress === 0) { 153 + runOnJS(blur)() 154 + } 155 + }, 156 + }) 157 + 136 158 return ( 137 - <View style={[a.px_md, a.pb_sm, a.pt_xs]}> 159 + <ComposerContainer> 138 160 {children} 139 - <View 140 - style={[ 141 - a.w_full, 142 - a.flex_row, 143 - t.atoms.bg_contrast_25, 144 - { 145 - padding: a.p_sm.padding - 2, 146 - paddingLeft: a.p_md.padding - 2, 147 - borderWidth: 1, 148 - borderRadius: 23, 149 - borderColor: 'transparent', 150 - }, 151 - isFocused && inputStyles.chromeFocus, 152 - ]}> 153 - <AnimatedTextInput 154 - accessibilityLabel={_(msg`Message input field`)} 155 - accessibilityHint={_(msg`Type your message here`)} 156 - placeholder={_(msg`Write a message`)} 157 - placeholderTextColor={t.palette.contrast_500} 158 - value={message} 159 - onChange={evt => { 160 - // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen 161 - // including the button we just pressed - and this overrides clearing the input! so we watch for the 162 - // next change and double make sure the input is cleared. It should *always* send an onChange event after 163 - // clearing via setMessage('') that happens in onSubmit() 164 - // -sfn 165 - if (IS_IOS && shouldEnforceClear) { 166 - setShouldEnforceClear(false) 167 - setMessage('') 168 - return 169 - } 170 - const text = evt.nativeEvent.text 171 - setMessage(text) 172 - }} 173 - multiline={true} 174 - style={[ 175 - a.flex_1, 176 - a.text_md, 177 - a.px_sm, 178 - t.atoms.text, 179 - android({paddingTop: 0}), 180 - {paddingBottom: IS_IOS ? 5 : 0}, 181 - animatedStyle, 182 - ]} 183 - keyboardAppearance={t.scheme} 184 - submitBehavior="newline" 185 - onFocus={() => setIsFocused(true)} 186 - onBlur={() => setIsFocused(false)} 187 - ref={inputRef} 188 - hitSlop={HITSLOP_10} 189 - animatedProps={animatedProps} 190 - editable={!needsEmailVerification} 191 - /> 192 - <Pressable 193 - accessibilityRole="button" 194 - accessibilityLabel={_(msg`Send message`)} 195 - accessibilityHint="" 196 - hitSlop={HITSLOP_10} 197 - style={[ 198 - a.rounded_full, 199 - a.align_center, 200 - a.justify_center, 201 - {height: 30, width: 30, backgroundColor: t.palette.primary_500}, 202 - ]} 203 - onPress={onSubmit} 204 - disabled={needsEmailVerification}> 205 - <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> 206 - </Pressable> 207 - </View> 208 - </View> 161 + <GlassContainer 162 + style={[a.flex_row, a.align_end, a.gap_sm]} 163 + spacing={tokens.space.xs}> 164 + <GlassView 165 + isInteractive 166 + glassEffectStyle="regular" 167 + style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]} 168 + tintColor={t.palette.contrast_50} 169 + fallbackStyle={[t.atoms.bg_contrast_50]}> 170 + <AnimatedTextInput 171 + nativeID={textInputId} 172 + accessibilityLabel={_(msg`Message input field`)} 173 + accessibilityHint={_(msg`Type your message here`)} 174 + placeholder={_(msg`Message`)} 175 + placeholderTextColor={t.palette.contrast_500} 176 + value={message} 177 + onChange={evt => { 178 + // bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen 179 + // including the button we just pressed - and this overrides clearing the input! so we watch for the 180 + // next change and double make sure the input is cleared. It should *always* send an onChange event after 181 + // clearing via setMessage('') that happens in onSubmit() 182 + // -sfn 183 + if (IS_IOS && shouldEnforceClear) { 184 + setShouldEnforceClear(false) 185 + setMessage('') 186 + return 187 + } 188 + const text = evt.nativeEvent.text 189 + setMessage(text) 190 + }} 191 + multiline={true} 192 + style={[ 193 + {flexBasis: 'auto', minHeight: MIN_HEIGHT}, 194 + a.flex_shrink_0, 195 + a.flex_grow, 196 + a.text_md, 197 + a.px_lg, 198 + t.atoms.text, 199 + platform({ 200 + android: {paddingTop: 2, paddingBottom: 3}, 201 + ios: {paddingTop: 10, paddingBottom: 5}, 202 + }), 203 + animatedStyle, 204 + ]} 205 + verticalAlign="middle" 206 + keyboardAppearance={t.scheme} 207 + submitBehavior="newline" 208 + ref={inputRef} 209 + hitSlop={HITSLOP_10} 210 + animatedProps={animatedProps} 211 + editable={!needsEmailVerification} 212 + /> 213 + </GlassView> 214 + <GlassView 215 + isInteractive 216 + glassEffectStyle="regular" 217 + style={[a.rounded_full]} 218 + tintColor={ 219 + submitDisabled ? t.palette.contrast_100 : t.palette.primary_500 220 + } 221 + fallbackStyle={{ 222 + backgroundColor: submitDisabled 223 + ? t.palette.contrast_100 224 + : t.palette.primary_500, 225 + }}> 226 + <Pressable 227 + accessibilityRole="button" 228 + accessibilityLabel={_(msg`Send message`)} 229 + accessibilityHint="" 230 + hitSlop={HITSLOP_10} 231 + style={[ 232 + a.rounded_full, 233 + a.align_center, 234 + a.justify_center, 235 + { 236 + height: MIN_HEIGHT, 237 + width: MIN_HEIGHT, 238 + }, 239 + ]} 240 + onPress={onSubmit} 241 + disabled={submitDisabled}> 242 + <PaperPlaneIcon 243 + size="md" 244 + fill={t.palette.white} 245 + style={[a.mb_2xs]} 246 + /> 247 + </Pressable> 248 + </GlassView> 249 + </GlassContainer> 250 + </ComposerContainer> 209 251 ) 210 252 }
+171 -139
src/screens/Messages/components/MessagesList.tsx
··· 1 - import {useCallback, useEffect, useRef, useState} from 'react' 2 - import {type LayoutChangeEvent, View} from 'react-native' 3 - import {useKeyboardHandler} from 'react-native-keyboard-controller' 1 + import {useCallback, useEffect, useId, useRef, useState} from 'react' 2 + import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native' 3 + import { 4 + KeyboardChatScrollView, 5 + type KeyboardChatScrollViewProps, 6 + KeyboardGestureArea, 7 + } from 'react-native-keyboard-controller' 4 8 import Animated, { 5 9 runOnJS, 6 - scrollTo, 10 + type ScrollEvent, 11 + type SharedValue, 7 12 useAnimatedRef, 8 - useAnimatedStyle, 13 + useDerivedValue, 9 14 useSharedValue, 10 15 } from 'react-native-reanimated' 11 - import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' 16 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 12 17 import { 13 18 type $Typed, 14 19 type AppBskyEmbedRecord, 15 20 AppBskyRichtextFacet, 16 21 RichText, 17 22 } from '@atproto/api' 23 + import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' 18 24 19 - import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' 25 + import {mergeRefs} from '#/lib/merge-refs' 20 26 import {ScrollProvider} from '#/lib/ScrollContext' 21 27 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 22 28 import { ··· 36 42 } from '#/state/messages/convo/types' 37 43 import {useGetPost} from '#/state/queries/post' 38 44 import {useAgent} from '#/state/session' 39 - import {useShellLayout} from '#/state/shell/shell-layout' 40 45 import { 41 46 EmojiPicker, 42 47 type EmojiPickerState, ··· 46 51 import {MessageComposer} from '#/screens/Messages/components/MessageComposer' 47 52 import {MessageInput} from '#/screens/Messages/components/MessageInput' 48 53 import {MessageListError} from '#/screens/Messages/components/MessageListError' 54 + import {atoms as a, platform, tokens, useTheme, web} from '#/alf' 49 55 import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' 50 56 import {MessageItem} from '#/components/dms/MessageItem' 51 57 import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 52 58 import {Loader} from '#/components/Loader' 53 59 import {Text} from '#/components/Typography' 54 60 import {useAnalytics} from '#/analytics' 55 - import {IS_NATIVE, IS_WEB} from '#/env' 61 + import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' 56 62 import {ChatStatusInfo} from './ChatStatusInfo' 57 63 import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' 64 + import {KeyboardStickyView} from './vendor/KeyboardStickyView' 58 65 59 66 function MaybeLoader({isLoading}: {isLoading: boolean}) { 60 67 return ( ··· 108 115 const agent = useAgent() 109 116 const getPost = useGetPost() 110 117 const {embedUri, setEmbed} = useMessageEmbed() 111 - 112 - useHideBottomBarBorderForScreen() 118 + const t = useTheme() 113 119 120 + const textInputId = 'chat-input-' + useId() 114 121 const flatListRef = useAnimatedRef<ListMethods>() 115 122 116 123 const [newMessagesPill, setNewMessagesPill] = useState({ ··· 123 130 pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 124 131 }) 125 132 133 + const inputHeightUI = useSharedValue(0) 134 + const [inputHeightJS, setInputHeightJS] = useState(0) 135 + 136 + const onInputLayout = useCallback( 137 + (event: LayoutChangeEvent) => { 138 + const inputHeight = event.nativeEvent.layout.height 139 + inputHeightUI.set(inputHeight) 140 + setInputHeightJS(inputHeight) 141 + }, 142 + [inputHeightUI], 143 + ) 144 + 126 145 // 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 127 146 // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to 128 147 // the bottom. ··· 226 245 227 246 const onStartReached = useCallback(() => { 228 247 if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { 229 - convoState.fetchMessageHistory() 248 + void convoState.fetchMessageHistory() 230 249 } 231 250 }, [convoState, hasScrolled, layoutHeight]) 232 251 233 252 const onScroll = useCallback( 234 - (e: ReanimatedScrollEvent) => { 253 + (e: ScrollEvent) => { 235 254 'worklet' 236 255 layoutHeight.set(e.layoutMeasurement.height) 237 256 const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height ··· 256 275 ) 257 276 258 277 // -- Keyboard animation handling 259 - const {footerHeight} = useShellLayout() 260 - 261 - const keyboardHeight = useSharedValue(0) 262 - const keyboardIsOpening = useSharedValue(false) 263 278 264 - // In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event. 265 - // We use this value to keep track of when we want to disable the animation. 266 - const layoutScrollWithoutAnimation = useSharedValue(false) 267 - 268 - useKeyboardHandler( 269 - { 270 - onStart: e => { 271 - 'worklet' 272 - // Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should 273 - // just update the height here instead of having the `onMove` event do it (that event will not fire!) 274 - if (e.duration === 0) { 275 - layoutScrollWithoutAnimation.set(true) 276 - keyboardHeight.set(e.height) 277 - } else { 278 - keyboardIsOpening.set(true) 279 - } 280 - }, 281 - onMove: e => { 282 - 'worklet' 283 - keyboardHeight.set(e.height) 284 - if (e.height > footerHeight.get()) { 285 - scrollTo(flatListRef, 0, 1e7, false) 286 - } 287 - }, 288 - onEnd: e => { 289 - 'worklet' 290 - keyboardHeight.set(e.height) 291 - if (e.height > footerHeight.get()) { 292 - scrollTo(flatListRef, 0, 1e7, false) 293 - } 294 - keyboardIsOpening.set(false) 295 - }, 296 - }, 297 - [footerHeight], 298 - ) 299 - 300 - const animatedListStyle = useAnimatedStyle(() => ({ 301 - marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()), 302 - })) 303 - 304 - const animatedStickyViewStyle = useAnimatedStyle(() => ({ 305 - transform: [ 306 - {translateY: -Math.max(keyboardHeight.get(), footerHeight.get())}, 307 - ], 308 - })) 279 + const {bottom: bottomInset} = useSafeAreaInsets() 309 280 310 281 // -- Message sending 311 282 const onSendMessage = useCallback( ··· 387 358 [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], 388 359 ) 389 360 390 - // -- List layout changes (opening emoji keyboard, etc.) 391 - const onListLayout = useCallback( 392 - (e: LayoutChangeEvent) => { 393 - layoutHeight.set(e.nativeEvent.layout.height) 394 - 395 - if (IS_WEB || !keyboardIsOpening.get()) { 396 - flatListRef.current?.scrollToEnd({ 397 - animated: !layoutScrollWithoutAnimation.get(), 398 - }) 399 - layoutScrollWithoutAnimation.set(false) 400 - } 401 - }, 402 - [ 403 - flatListRef, 404 - keyboardIsOpening, 405 - layoutScrollWithoutAnimation, 406 - layoutHeight, 407 - ], 408 - ) 409 - 410 361 const scrollToEndOnPress = useCallback(() => { 411 362 flatListRef.current?.scrollToOffset({ 412 363 offset: prevContentHeight.current, ··· 418 369 setEmojiPickerState({isOpen: true, pos}) 419 370 }, []) 420 371 372 + const renderScrollComponent = useCallback( 373 + (props: ScrollViewProps) => ( 374 + <ChatScrollComponent {...props} inputHeight={inputHeightUI} /> 375 + ), 376 + [inputHeightUI], 377 + ) 378 + 421 379 return ( 422 380 <> 423 - {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} 424 - <ScrollProvider onScroll={onScroll}> 425 - <List 426 - ref={flatListRef} 427 - data={convoState.items} 428 - renderItem={renderItem} 429 - keyExtractor={keyExtractor} 430 - disableFullWindowScroll={true} 431 - disableVirtualization={true} 432 - style={animatedListStyle} 433 - // The extra two items account for the header and the footer components 434 - initialNumToRender={IS_NATIVE ? 32 : 62} 435 - maxToRenderPerBatch={IS_WEB ? 32 : 62} 436 - keyboardDismissMode="on-drag" 437 - keyboardShouldPersistTaps="handled" 438 - maintainVisibleContentPosition={{ 439 - minIndexForVisible: 0, 440 - }} 441 - removeClippedSubviews={false} 442 - sideBorders={false} 443 - onContentSizeChange={onContentSizeChange} 444 - onLayout={onListLayout} 445 - onStartReached={onStartReached} 446 - onScrollToIndexFailed={onScrollToIndexFailed} 447 - scrollEventThrottle={100} 448 - ListHeaderComponent={ 449 - <MaybeLoader isLoading={convoState.isFetchingHistory} /> 450 - } 451 - /> 452 - </ScrollProvider> 453 - <Animated.View style={animatedStickyViewStyle}> 454 - {convoState.status === ConvoStatus.Disabled ? ( 455 - <ChatDisabled /> 456 - ) : blocked ? ( 457 - footer 458 - ) : ( 459 - <ConversationFooter 460 - convoState={convoState} 461 - hasAcceptOverride={hasAcceptOverride}> 462 - {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( 463 - <MessageComposer 464 - onSendMessage={onSendMessage} 465 - hasEmbed={!!embedUri} 466 - setEmbed={setEmbed}> 467 - <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 468 - </MessageComposer> 469 - ) : ( 470 - <MessageInput 471 - onSendMessage={onSendMessage} 472 - hasEmbed={!!embedUri} 473 - setEmbed={setEmbed} 474 - openEmojiPicker={onOpenEmojiPicker}> 475 - <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 476 - </MessageInput> 381 + <KeyboardGestureArea 382 + interpolator="ios" 383 + // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 384 + offset={Math.round(inputHeightJS)} 385 + textInputNativeID={textInputId} 386 + style={[a.flex_1]}> 387 + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} 388 + <ScrollProvider onScroll={onScroll}> 389 + <List 390 + ref={flatListRef} 391 + data={convoState.items} 392 + renderItem={renderItem} 393 + keyExtractor={keyExtractor} 394 + disableFullWindowScroll={true} 395 + disableVirtualization={true} 396 + // The extra two items account for the header and the footer components 397 + initialNumToRender={IS_NATIVE ? 32 : 62} 398 + maxToRenderPerBatch={IS_WEB ? 32 : 62} 399 + keyboardDismissMode="interactive" 400 + keyboardShouldPersistTaps="handled" 401 + maintainVisibleContentPosition={{minIndexForVisible: 0}} 402 + removeClippedSubviews={false} 403 + sideBorders={false} 404 + onContentSizeChange={onContentSizeChange} 405 + onStartReached={onStartReached} 406 + onScrollToIndexFailed={onScrollToIndexFailed} 407 + showsVerticalScrollIndicator={!IS_ANDROID} 408 + scrollEventThrottle={100} 409 + ListHeaderComponent={ 410 + <MaybeLoader isLoading={convoState.isFetchingHistory} /> 411 + } 412 + // native only (prop is not supported on web) 413 + renderScrollComponent={renderScrollComponent} 414 + // pushes up the content under the input on web (renderScrollComponent handles it on native) 415 + ListFooterComponent={web( 416 + <WebInputSpacer inputHeight={inputHeightJS} />, 477 417 )} 478 - </ConversationFooter> 479 - )} 480 - </Animated.View> 418 + style={web({ 419 + scrollbarWidth: 'thin', 420 + scrollbarColor: `${t.palette.contrast_100} transparent`, 421 + scrollbarGutter: 'stable both-edges', 422 + })} 423 + /> 424 + </ScrollProvider> 425 + <KeyboardStickyView 426 + style={[a.absolute, a.bottom_0, a.left_0, a.right_0]} 427 + onLayout={onInputLayout} 428 + minimumOffset={bottomInset} 429 + offset={{ 430 + closed: platform({ 431 + ios: tokens.space.lg, // hide bottom padding when closed 432 + default: 0, 433 + }), 434 + opened: 0, 435 + }}> 436 + {convoState.status === ConvoStatus.Disabled ? ( 437 + <ChatDisabled /> 438 + ) : blocked ? ( 439 + footer 440 + ) : ( 441 + <ConversationFooter 442 + convoState={convoState} 443 + hasAcceptOverride={hasAcceptOverride}> 444 + {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( 445 + <MessageComposer 446 + textInputId={textInputId} 447 + onSendMessage={onSendMessage} 448 + hasEmbed={!!embedUri} 449 + setEmbed={setEmbed}> 450 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 451 + </MessageComposer> 452 + ) : ( 453 + <MessageInput 454 + textInputId={textInputId} 455 + onSendMessage={onSendMessage} 456 + hasEmbed={!!embedUri} 457 + setEmbed={setEmbed} 458 + openEmojiPicker={onOpenEmojiPicker}> 459 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 460 + </MessageInput> 461 + )} 462 + </ConversationFooter> 463 + )} 464 + </KeyboardStickyView> 465 + </KeyboardGestureArea> 481 466 482 467 {IS_WEB && ( 483 468 <EmojiPicker ··· 490 475 {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} 491 476 </> 492 477 ) 478 + } 479 + 480 + /** Note: native only */ 481 + function ChatScrollComponent({ 482 + ref, 483 + inputHeight, 484 + ...props 485 + }: ScrollViewProps & { 486 + ref?: React.RefObject<KeyboardChatScrollViewProps> 487 + inputHeight: SharedValue<number> 488 + }) { 489 + const scrollEdgeRef = useScrollEdgeEffectRef() 490 + const {bottom: bottomInset} = useSafeAreaInsets() 491 + 492 + const offset = platform({ 493 + ios: bottomInset - tokens.space.lg, 494 + android: bottomInset, 495 + default: 0, 496 + }) 497 + 498 + const inputOffset = platform({ 499 + ios: bottomInset - tokens.space.lg, 500 + android: bottomInset, 501 + default: 0, 502 + }) 503 + 504 + const extraContentPadding = useDerivedValue( 505 + () => inputHeight.get() + inputOffset, 506 + ) 507 + 508 + return ( 509 + <KeyboardChatScrollView 510 + ref={mergeRefs([scrollEdgeRef, ref])} 511 + automaticallyAdjustContentInsets={false} 512 + keyboardDismissMode="interactive" 513 + keyboardLiftBehavior="always" 514 + extraContentPadding={extraContentPadding} 515 + offset={offset} 516 + {...props} 517 + /> 518 + ) 519 + } 520 + 521 + function WebInputSpacer({inputHeight}: {inputHeight: number}) { 522 + if (!IS_WEB) return null 523 + 524 + return <Animated.View style={{height: inputHeight}} /> 493 525 } 494 526 495 527 type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
+48
src/screens/Messages/components/vendor/KeyboardStickyView.tsx
··· 1 + import { 2 + type KeyboardStickyViewProps, 3 + useReanimatedKeyboardAnimation, 4 + } from 'react-native-keyboard-controller' 5 + import Animated, {useAnimatedStyle} from 'react-native-reanimated' 6 + 7 + // Vendored from https://github.com/kirillzyusko/react-native-keyboard-controller/blob/main/src/components/KeyboardStickyView/index.tsx 8 + // Converted to Reanimated to support `minimumOffset` clamping. 9 + export function KeyboardStickyView({ 10 + children, 11 + offset: {closed = 0, opened = 0} = {}, 12 + style, 13 + enabled = true, 14 + minimumOffset, 15 + ...props 16 + }: KeyboardStickyViewProps & { 17 + /** 18 + * Stop the stickyview going lower than this (i.e. bottom safe area) 19 + */ 20 + minimumOffset?: number 21 + }) { 22 + const {height, progress} = useReanimatedKeyboardAnimation() 23 + 24 + const animatedStyle = useAnimatedStyle(() => { 25 + const offset = closed + (opened - closed) * progress.get() 26 + let translateY: number 27 + 28 + if (enabled) { 29 + let h = height.get() 30 + if (minimumOffset != null) { 31 + h = Math.min(h, -minimumOffset) 32 + } 33 + translateY = h + offset 34 + } else { 35 + translateY = closed 36 + } 37 + 38 + return { 39 + transform: [{translateY}], 40 + } 41 + }) 42 + 43 + return ( 44 + <Animated.View style={[animatedStyle, style]} {...props}> 45 + {children} 46 + </Animated.View> 47 + ) 48 + }
+14 -1
src/state/queries/post.ts
··· 1 1 import {useCallback} from 'react' 2 2 import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api' 3 - import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 3 + import { 4 + type QueryClient, 5 + useMutation, 6 + useQuery, 7 + useQueryClient, 8 + } from '@tanstack/react-query' 4 9 5 10 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 6 11 import {updatePostShadow} from '#/state/cache/post-shadow' ··· 41 46 }, 42 47 enabled: !!uri, 43 48 }) 49 + } 50 + 51 + export function precachePost( 52 + queryClient: QueryClient, 53 + uri: string, 54 + post: AppBskyFeedDefs.PostView, 55 + ) { 56 + queryClient.setQueryData(RQKEY(uri), post) 44 57 } 45 58 46 59 export function useGetPost() {
+3
webpack.config.js
··· 19 19 } 20 20 21 21 module.exports = async function (env, argv) { 22 + env.babel = { 23 + dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo'], 24 + } 22 25 let config = await createExpoWebpackConfigAsync(env, argv) 23 26 config = withAlias(config, { 24 27 'react-native$': 'react-native-web',
+14 -4
yarn.lock
··· 2429 2429 resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" 2430 2430 integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg== 2431 2431 2432 + "@bsky.app/expo-scroll-edge-effect@^0.1.4": 2433 + version "0.1.4" 2434 + resolved "https://registry.yarnpkg.com/@bsky.app/expo-scroll-edge-effect/-/expo-scroll-edge-effect-0.1.4.tgz#8b785b606c3078b3f8d1ec200adaf13bc59c2fec" 2435 + integrity sha512-P94YcYBqZfuUy7ewTrWulPTxTbs6Yvjg2xS5WX4I/F3R3cSQU9fc8vEzb2Pnn4Ieg2wukJdAywqzt+AnyWgJbw== 2436 + 2432 2437 "@bsky.app/expo-translate-text@^0.2.9": 2433 2438 version "0.2.9" 2434 2439 resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.9.tgz#4ed4552cd50bca7d02d14e706e419bd728d4ab51" ··· 9013 9018 dependencies: 9014 9019 fontfaceobserver "^2.1.0" 9015 9020 9021 + expo-glass-effect@55.0.8: 9022 + version "55.0.8" 9023 + resolved "https://registry.yarnpkg.com/expo-glass-effect/-/expo-glass-effect-55.0.8.tgz#ace0e662d7c8fc2935c9d1260e8303eb2fde0bc3" 9024 + integrity sha512-IvUjHb/4t6r2H/LXDjcQ4uDoHrmO2cLOvEb9leLavQ4HX5+P4LRtQrMDMlkWAn5Wo5DkLcG8+1CrQU2nqgogTA== 9025 + 9016 9026 expo-haptics@~15.0.8: 9017 9027 version "15.0.8" 9018 9028 resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" ··· 13980 13990 resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358" 13981 13991 integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== 13982 13992 13983 - react-native-keyboard-controller@^1.21.0: 13984 - version "1.21.0" 13985 - resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.0.tgz#79ad48c67e6f5ec572b7dc896c7b05a98662a2a2" 13986 - integrity sha512-mLHJysehhSzYoM8BAD2DSjVZEcF69t16ZCJrCAos6sfVtbB3tL+kgGZFX+jNVz/f9BEhqnBFO0EA1tc/V6Hkgw== 13993 + react-native-keyboard-controller@^1.21.5: 13994 + version "1.21.5" 13995 + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.5.tgz#563aabb7e9ce8dbe2a0dd5f949883ba81620b6c0" 13996 + integrity sha512-wxR+vpJ+2g6QMQCP1mRQKySDUietf5xLntZ76cUNHOGsjyqk6LtznXwHBG9YsR9E/b2IrHXISylwqPnIit6Y6A== 13987 13997 dependencies: 13988 13998 react-native-is-edge-to-edge "^1.2.1" 13989 13999