Refactor chat list implementation (#10059)
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
+3
-1
@@ -86,6 +86,7 @@
|
|||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.7",
|
"@bsky.app/alf": "^0.1.7",
|
||||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
"@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/expo-translate-text": "^0.2.9",
|
||||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||||
"@bsky.app/sift": "^0.3.2",
|
"@bsky.app/sift": "^0.3.2",
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-glass-effect": "55.0.8",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-manipulator": "~14.0.8",
|
"expo-image-manipulator": "~14.0.8",
|
||||||
@@ -213,7 +215,7 @@
|
|||||||
"react-native-drawer-layout": "^4.2.2",
|
"react-native-drawer-layout": "^4.2.2",
|
||||||
"react-native-edge-to-edge": "^1.6.0",
|
"react-native-edge-to-edge": "^1.6.0",
|
||||||
"react-native-gesture-handler": "~2.28.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-pager-view": "6.8.0",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
"react-native-qrcode-styled": "^0.3.3",
|
"react-native-qrcode-styled": "^0.3.3",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
diff --git a/node_modules/expo-glass-effect/ios/GlassContainer.swift b/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||||
|
index 61fb67c..b2d111e 100644
|
||||||
|
--- a/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||||
|
+++ b/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||||
|
|
||||||
|
import ExpoModulesCore
|
||||||
|
+import React
|
||||||
|
|
||||||
|
public final class GlassContainer: ExpoView {
|
||||||
|
private var containerEffect: Any?
|
||||||
|
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
+ // Paper: redirect children into the container effect's contentView
|
||||||
|
+ public override func didUpdateReactSubviews() {
|
||||||
|
+ for subview in self.reactSubviews() {
|
||||||
|
+ containerEffectView.contentView.addSubview(subview)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Fabric: redirect children into the container effect's contentView
|
||||||
|
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
containerEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
childComponentView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff --git a/node_modules/expo-glass-effect/ios/GlassView.swift b/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||||
|
index 35cd8f3..9587306 100644
|
||||||
|
--- a/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||||
|
+++ b/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||||
|
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
+ // Paper: redirect children into the glass effect's contentView
|
||||||
|
+ public override func didUpdateReactSubviews() {
|
||||||
|
+ for subview in self.reactSubviews() {
|
||||||
|
+ glassEffectView.contentView.addSubview(subview)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Fabric: redirect children into the glass effect's contentView
|
||||||
|
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
glassEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||||
|
childComponentView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# expo-glass-effect patch
|
||||||
|
|
||||||
|
Patches in support for Expo SDK 54. Please delete when we update Expo
|
||||||
@@ -338,6 +338,8 @@ export function Composer({
|
|||||||
web({
|
web({
|
||||||
caretColor: textStyle.color ?? 'black',
|
caretColor: textStyle.color ?? 'black',
|
||||||
overscrollBehavior: 'none',
|
overscrollBehavior: 'none',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: `${t.palette.contrast_200} transparent`,
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {
|
||||||
|
GlassView as ExpoGlassView,
|
||||||
|
type GlassViewProps as ExpoGlassViewProps,
|
||||||
|
isGlassEffectAPIAvailable,
|
||||||
|
isLiquidGlassAvailable,
|
||||||
|
} from 'expo-glass-effect'
|
||||||
|
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
|
||||||
|
export const IS_GLASS_AVAILABLE =
|
||||||
|
isLiquidGlassAvailable() && isGlassEffectAPIAvailable()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liquid Glass View that uses `expo-glass-effect`
|
||||||
|
*
|
||||||
|
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
|
||||||
|
*/
|
||||||
|
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||||
|
|
||||||
|
export type GlassViewProps = ExpoGlassViewProps & {
|
||||||
|
fallbackStyle?: StyleProp<ViewStyle>
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerGlassView({
|
||||||
|
fallbackStyle: _fallbackStyle,
|
||||||
|
...props
|
||||||
|
}: GlassViewProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
return <ExpoGlassView colorScheme={t.scheme} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function FallbackView({fallbackStyle, style, ...props}: GlassViewProps) {
|
||||||
|
return <View style={[fallbackStyle, style]} {...props} />
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import type * as bsky from '#/types/bsky'
|
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 ax = useAnalytics()
|
||||||
const control = useDialogContext()
|
const control = useDialogContext()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -32,6 +38,7 @@ export function RecentChats({postUri}: {postUri: string}) {
|
|||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const onSelectChat = (convoId: string) => {
|
const onSelectChat = (convoId: string) => {
|
||||||
|
onBeforePress?.()
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
ax.metric('share:press:recentDm', {})
|
ax.metric('share:press:recentDm', {})
|
||||||
navigation.navigate('MessagesConversation', {
|
navigation.navigate('MessagesConversation', {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {shareText, shareUrl} from '#/lib/sharing'
|
import {shareText, shareUrl} from '#/lib/sharing'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {precachePost} from '#/state/queries/post'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
@@ -40,6 +42,7 @@ let ShareMenuItems = ({
|
|||||||
const sendViaChatControl = useDialogControl()
|
const sendViaChatControl = useDialogControl()
|
||||||
const [devModeEnabled] = useDevMode()
|
const [devModeEnabled] = useDevMode()
|
||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
const postAuthor = useProfileShadow(post.author)
|
const postAuthor = useProfileShadow(post.author)
|
||||||
@@ -77,7 +80,12 @@ let ShareMenuItems = ({
|
|||||||
onShareProp()
|
onShareProp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onBeforeShareViaChat = () => {
|
||||||
|
precachePost(queryClient, postUri, post)
|
||||||
|
}
|
||||||
|
|
||||||
const onSelectChatToShareTo = (conversation: string) => {
|
const onSelectChatToShareTo = (conversation: string) => {
|
||||||
|
onBeforeShareViaChat()
|
||||||
navigation.navigate('MessagesConversation', {
|
navigation.navigate('MessagesConversation', {
|
||||||
conversation,
|
conversation,
|
||||||
embed: postUri,
|
embed: postUri,
|
||||||
@@ -98,7 +106,10 @@ let ShareMenuItems = ({
|
|||||||
{hasSession && aa.state.access === aa.Access.Full && (
|
{hasSession && aa.state.access === aa.Access.Full && (
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.ContainerItem>
|
<Menu.ContainerItem>
|
||||||
<RecentChats postUri={postUri} />
|
<RecentChats
|
||||||
|
postUri={postUri}
|
||||||
|
onBeforePress={onBeforeShareViaChat}
|
||||||
|
/>
|
||||||
</Menu.ContainerItem>
|
</Menu.ContainerItem>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownSendViaDMBtn"
|
testID="postDropdownSendViaDMBtn"
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function ChatEmptyPill() {
|
|||||||
onPressOut={onPressOut}>
|
onPressOut={onPressOut}>
|
||||||
<Text
|
<Text
|
||||||
style={[a.font_semi_bold, a.pointer_events_none]}
|
style={[a.font_semi_bold, a.pointer_events_none]}
|
||||||
selectable={false}>
|
selectable={false}
|
||||||
|
emoji>
|
||||||
{prompts[promptIndex]}
|
{prompts[promptIndex]}
|
||||||
</Text>
|
</Text>
|
||||||
</AnimatedPressable>
|
</AnimatedPressable>
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ import {createSinglePathSVG} from './TEMPLATE'
|
|||||||
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
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',
|
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',
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import {
|
|||||||
moderateProfile,
|
moderateProfile,
|
||||||
type ModerationDecision,
|
type ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
import {
|
import {
|
||||||
type RouteProp,
|
type RouteProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
|
useIsFocused,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native'
|
} from '@react-navigation/native'
|
||||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +33,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
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 {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +65,6 @@ export function MessagesConversationScreen(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessagesConversationScreenInner({route}: Props) {
|
export function MessagesConversationScreenInner({route}: Props) {
|
||||||
const {gtMobile} = useBreakpoints()
|
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
|
||||||
const convoId = route.params.conversation
|
const convoId = route.params.conversation
|
||||||
@@ -71,25 +73,22 @@ export function MessagesConversationScreenInner({route}: Props) {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
setCurrentConvoId(convoId)
|
setCurrentConvoId(convoId)
|
||||||
|
setMinimalShellMode(true)
|
||||||
if (IS_WEB && !gtMobile) {
|
|
||||||
setMinimalShellMode(true)
|
|
||||||
} else {
|
|
||||||
setMinimalShellMode(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setCurrentConvoId(undefined)
|
setCurrentConvoId(undefined)
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
}
|
}
|
||||||
}, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
|
}, [convoId, setCurrentConvoId, setMinimalShellMode]),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
|
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
|
||||||
<ConvoProvider key={convoId} convoId={convoId}>
|
<ScrollEdgeEffectProvider>
|
||||||
<Inner />
|
<ConvoProvider key={convoId} convoId={convoId}>
|
||||||
</ConvoProvider>
|
<Inner />
|
||||||
|
</ConvoProvider>
|
||||||
|
</ScrollEdgeEffectProvider>
|
||||||
</Layout.Screen>
|
</Layout.Screen>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,6 +97,7 @@ function Inner() {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const convoState = useConvo()
|
const convoState = useConvo()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const isFocused = useIsFocused()
|
||||||
|
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {data: recipientUnshadowed} = useProfileQuery({
|
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
|
// 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.
|
// 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) {
|
if (convoState.status === ConvoStatus.Initializing) {
|
||||||
setHasScrolled(false)
|
setHasScrolled(false)
|
||||||
}
|
}
|
||||||
}, [convoState.status])
|
}
|
||||||
|
|
||||||
if (convoState.status === ConvoStatus.Error) {
|
if (convoState.status === ConvoStatus.Error) {
|
||||||
return (
|
return (
|
||||||
@@ -150,6 +152,8 @@ function Inner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Center style={[a.flex_1]}>
|
<Layout.Center style={[a.flex_1]}>
|
||||||
|
{/* MessagesList does not use the body scroll */}
|
||||||
|
{isFocused && IS_WEB && <RemoveScrollBar />}
|
||||||
{!readyToShow &&
|
{!readyToShow &&
|
||||||
(moderation ? (
|
(moderation ? (
|
||||||
<MessagesListHeader moderation={moderation} profile={recipient} />
|
<MessagesListHeader moderation={moderation} profile={recipient} />
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
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 {useLingui} from '@lingui/react/macro'
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
|
|
||||||
@@ -17,20 +31,24 @@ import {
|
|||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
type EmojiPickerState,
|
type EmojiPickerState,
|
||||||
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
} 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 {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {GlassView} from '#/components/GlassView'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||||
import * as Toast from '#/components/Toast'
|
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({
|
export function MessageComposer({
|
||||||
|
textInputId,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
hasEmbed,
|
hasEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
textInputId?: string
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
@@ -48,16 +66,25 @@ export function MessageComposer({
|
|||||||
})
|
})
|
||||||
const composerInternalApiRef = useComposerInternalApiRef()
|
const composerInternalApiRef = useComposerInternalApiRef()
|
||||||
|
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
||||||
const {
|
|
||||||
state: hovered,
|
|
||||||
onIn: onHoverIn,
|
|
||||||
onOut: onHoverOut,
|
|
||||||
} = useInteractionState()
|
|
||||||
|
|
||||||
const [text, setText] = useState(getDraft)
|
const [text, setText] = useState(getDraft)
|
||||||
useSaveMessageDraft(text)
|
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) => {
|
const openEmojiPicker = (pos: any) => {
|
||||||
setEmojiPickerState({isOpen: true, pos})
|
setEmojiPickerState({isOpen: true, pos})
|
||||||
}
|
}
|
||||||
@@ -65,10 +92,12 @@ export function MessageComposer({
|
|||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (!editable) return
|
if (!editable) return
|
||||||
if (!hasEmbed && text.trim() === '') return
|
if (!hasEmbed && text.trim() === '') return
|
||||||
if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) {
|
const graphemeCount = countGraphemes(text)
|
||||||
Toast.show(l`Message is too long`, {
|
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
|
||||||
type: 'error',
|
Toast.show(
|
||||||
})
|
l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`,
|
||||||
|
{type: 'error'},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,152 +120,111 @@ export function MessageComposer({
|
|||||||
return () => {
|
return () => {
|
||||||
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [composerInternalApiRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ComposerContainer>
|
||||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
{children}
|
||||||
{children}
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
ref={
|
ref={native(
|
||||||
IS_WEB
|
(node: View) =>
|
||||||
? undefined
|
void composerInternalApiRef.current?.setAutocompleteAnchor(node),
|
||||||
: node => {
|
)}>
|
||||||
composerInternalApiRef.current?.setAutocompleteAnchor(node)
|
<GlassContainer
|
||||||
}
|
style={[a.w_full, a.flex_row, a.gap_sm, a.align_end]}
|
||||||
}
|
spacing={tokens.space.sm}>
|
||||||
// @ts-expect-error web only
|
<GlassView
|
||||||
onMouseEnter={onHoverIn}
|
isInteractive
|
||||||
onMouseLeave={onHoverOut}
|
glassEffectStyle="regular"
|
||||||
style={[a.w_full, a.flex_row, a.gap_sm]}>
|
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
|
||||||
{IS_WEB && (
|
tintColor={t.palette.contrast_50}
|
||||||
<Pressable
|
fallbackStyle={[t.atoms.bg_contrast_50]}>
|
||||||
onPress={e => {
|
{IS_WEB && (
|
||||||
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
<Pressable
|
||||||
openEmojiPicker?.({
|
onPress={e => {
|
||||||
top: py,
|
e.currentTarget.measure(
|
||||||
left: px,
|
(_fx, _fy, _width, _height, px, py) => {
|
||||||
right: px,
|
// TODO: rip this horrible system out
|
||||||
bottom: py,
|
openEmojiPicker?.({
|
||||||
nextFocusRef: {
|
top: py,
|
||||||
current: composerInternalApiRef.current?.input?.element,
|
left: px - 400,
|
||||||
|
right: px - 400,
|
||||||
|
bottom: py,
|
||||||
|
nextFocusRef: {
|
||||||
|
current:
|
||||||
|
composerInternalApiRef.current?.input?.element,
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
}}
|
||||||
|
style={[
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.z_30,
|
||||||
|
{
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
accessibilityLabel={l`Open emoji picker`}
|
||||||
|
accessibilityHint="">
|
||||||
|
{state => (
|
||||||
|
<EmojiSmileIcon
|
||||||
|
size="md"
|
||||||
|
style={
|
||||||
|
state.hovered ||
|
||||||
|
state.focused ||
|
||||||
|
state.pressed ||
|
||||||
|
emojiPickerState.isOpen
|
||||||
|
? {color: t.palette.primary_500}
|
||||||
|
: t.atoms.text_contrast_high
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
nativeID={textInputId}
|
||||||
|
label={l`Message input field`}
|
||||||
|
placeholder={l`Message`}
|
||||||
|
autocompletePlacement="top-start"
|
||||||
|
internalApiRef={composerInternalApiRef}
|
||||||
|
defaultValue={text}
|
||||||
|
editable={editable}
|
||||||
|
autoFocus={IS_WEB}
|
||||||
|
maxRows={12}
|
||||||
|
outerStyle={[a.flex_1]}
|
||||||
|
contentTextStyle={[a.text_md, a.leading_snug]}
|
||||||
|
contentPaddingStyle={{
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
paddingRight: 16 + platform({web: 20, default: 0}),
|
||||||
}}
|
}}
|
||||||
style={[
|
onChange={setText}
|
||||||
a.overflow_hidden,
|
onFacetCommitted={facet => {
|
||||||
a.absolute,
|
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||||
a.rounded_full,
|
setEmbed(facet.value)
|
||||||
a.align_center,
|
}
|
||||||
a.justify_center,
|
}}
|
||||||
a.z_30,
|
onRequestSubmit={req => {
|
||||||
{
|
if (req.platform === 'web' && req.shiftKey) return
|
||||||
height: 30,
|
req.nativeEvent.preventDefault()
|
||||||
width: 30,
|
onSubmit()
|
||||||
top: 8,
|
}}
|
||||||
left: 8,
|
/>
|
||||||
},
|
</GlassView>
|
||||||
]}
|
<SubmitButton onPress={onSubmit} disabled={submitDisabled} />
|
||||||
accessibilityLabel={l`Open emoji picker`}
|
</GlassContainer>
|
||||||
accessibilityHint="">
|
|
||||||
{state => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.inset_0,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
backgroundColor:
|
|
||||||
state.hovered || state.focused || state.pressed
|
|
||||||
? t.atoms.bg.backgroundColor
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<EmojiSmile size="lg" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Composer
|
|
||||||
label={l`Message input field`}
|
|
||||||
placeholder={l`Write a message`}
|
|
||||||
autocompletePlacement="top-start"
|
|
||||||
internalApiRef={composerInternalApiRef}
|
|
||||||
defaultValue={text}
|
|
||||||
editable={editable}
|
|
||||||
autoFocus={IS_WEB}
|
|
||||||
maxRows={12}
|
|
||||||
outerStyle={[
|
|
||||||
a.flex_1,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
borderRadius: 22,
|
|
||||||
},
|
|
||||||
editable &&
|
|
||||||
hovered && {
|
|
||||||
borderColor: t.atoms.border_contrast_medium.borderColor,
|
|
||||||
},
|
|
||||||
editable &&
|
|
||||||
focused && {
|
|
||||||
borderColor: t.palette.primary_500,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
contentTextStyle={[a.text_md, a.leading_snug]}
|
|
||||||
contentPaddingStyle={{
|
|
||||||
paddingLeft: IS_WEB ? 30 + 12 : 12,
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingBottom: 12,
|
|
||||||
paddingRight: 12,
|
|
||||||
}}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onChange={setText}
|
|
||||||
onFacetCommitted={facet => {
|
|
||||||
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
|
||||||
setEmbed(facet.value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRequestSubmit={req => {
|
|
||||||
if (req.platform === 'web' && req.shiftKey) return
|
|
||||||
req.nativeEvent.preventDefault()
|
|
||||||
onSubmit()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{focused || text.length ? (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={l`Send message`}
|
|
||||||
accessibilityHint=""
|
|
||||||
hitSlop={HITSLOP_10}
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
a.self_end,
|
|
||||||
a.z_30,
|
|
||||||
{
|
|
||||||
height: 44,
|
|
||||||
width: 44,
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={onSubmit}
|
|
||||||
disabled={!editable}>
|
|
||||||
<PaperPlane
|
|
||||||
fill={t.palette.white}
|
|
||||||
style={[a.relative, {left: 1}]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{IS_WEB && (
|
{IS_WEB && (
|
||||||
@@ -246,6 +234,114 @@ export function MessageComposer({
|
|||||||
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</ComposerContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SubmitButton({
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onPress: () => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassView
|
||||||
|
isInteractive
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
tintColor={disabled ? t.palette.contrast_100 : t.palette.primary_500}
|
||||||
|
fallbackStyle={{
|
||||||
|
backgroundColor: disabled
|
||||||
|
? t.palette.contrast_100
|
||||||
|
: t.palette.primary_500,
|
||||||
|
}}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Send message`}
|
||||||
|
accessibilityHint=""
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{height: MIN_HEIGHT, width: MIN_HEIGHT},
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}>
|
||||||
|
<PaperPlaneIcon size="md" fill={t.palette.white} style={[a.mb_2xs]} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove export when MessageInput is deleted
|
||||||
|
export function ComposerContainer({children}: {children: React.ReactNode}) {
|
||||||
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||||
|
const {progress} = useReanimatedKeyboardAnimation()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||||
|
paddingHorizontal: interpolate(
|
||||||
|
progress.get(),
|
||||||
|
[0, 1],
|
||||||
|
[bottomInset, tokens.space.sm],
|
||||||
|
{
|
||||||
|
extrapolateRight: Extrapolation.CLAMP,
|
||||||
|
extrapolateLeft: Extrapolation.CLAMP,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (IS_LIQUID_GLASS) {
|
||||||
|
return (
|
||||||
|
<ScrollEdgeEffect edge="bottom">
|
||||||
|
<Animated.View style={[a.w_full, animatedContainerStyle, a.pb_lg]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollEdgeEffect>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LinearGradient
|
||||||
|
style={platform({
|
||||||
|
native: [a.pt_sm, a.px_lg, , a.pb_lg, a.w_full],
|
||||||
|
web: [
|
||||||
|
a.pt_xs,
|
||||||
|
a.pl_lg,
|
||||||
|
a.pb_lg,
|
||||||
|
// prevent overlap with the scrollbar, which looks ugly
|
||||||
|
a.pr_xs, // xs + md = lg
|
||||||
|
{width: `calc(100% - ${tokens.space.md}px)` as '100%'},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
key={t.name} // android does not update when you change the colors. sigh.
|
||||||
|
start={[0.5, 0]}
|
||||||
|
end={[0.5, 1]}
|
||||||
|
colors={[
|
||||||
|
utils.alpha(t.atoms.bg.backgroundColor, 0),
|
||||||
|
utils.alpha(t.atoms.bg.backgroundColor, 0.8),
|
||||||
|
t.atoms.bg.backgroundColor,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</LinearGradient>
|
||||||
|
{/* covers the gap between the keyboard and the input during keyboard animation */}
|
||||||
|
{IS_NATIVE && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg,
|
||||||
|
a.absolute,
|
||||||
|
a.left_0,
|
||||||
|
a.right_0,
|
||||||
|
{top: '100%', height: bottomInset + 1, marginTop: -1},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import {useCallback, useState} from 'react'
|
import {useCallback, useState} from 'react'
|
||||||
import {Pressable, TextInput, useWindowDimensions, View} from 'react-native'
|
import {Pressable, TextInput, useWindowDimensions} from 'react-native'
|
||||||
import {
|
import {
|
||||||
useFocusedInputHandler,
|
useFocusedInputHandler,
|
||||||
|
useKeyboardHandler,
|
||||||
useReanimatedKeyboardAnimation,
|
useReanimatedKeyboardAnimation,
|
||||||
} from 'react-native-keyboard-controller'
|
} from 'react-native-keyboard-controller'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
measure,
|
measure,
|
||||||
|
runOnJS,
|
||||||
useAnimatedProps,
|
useAnimatedProps,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {GlassContainer} from 'expo-glass-effect'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
@@ -24,21 +27,26 @@ import {
|
|||||||
useSaveMessageDraft,
|
useSaveMessageDraft,
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
|
import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||||
import {android, atoms as a, useTheme} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {GlassView} from '#/components/GlassView'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||||
import * as Toast from '#/components/Toast'
|
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'
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 40
|
||||||
|
|
||||||
export function MessageInput({
|
export function MessageInput({
|
||||||
|
textInputId,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
hasEmbed,
|
hasEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
textInputId?: string
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
@@ -57,8 +65,6 @@ export function MessageInput({
|
|||||||
const maxHeight = useSharedValue<undefined | number>(undefined)
|
const maxHeight = useSharedValue<undefined | number>(undefined)
|
||||||
const isInputScrollable = useSharedValue(false)
|
const isInputScrollable = useSharedValue(false)
|
||||||
|
|
||||||
const inputStyles = useSharedInputStyles()
|
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
|
||||||
const [message, setMessage] = useState(getDraft)
|
const [message, setMessage] = useState(getDraft)
|
||||||
const inputRef = useAnimatedRef<TextInput>()
|
const inputRef = useAnimatedRef<TextInput>()
|
||||||
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
|
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
|
||||||
@@ -133,78 +139,114 @@ export function MessageInput({
|
|||||||
scrollEnabled: isInputScrollable.get(),
|
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 (
|
return (
|
||||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
<ComposerContainer>
|
||||||
{children}
|
{children}
|
||||||
<View
|
<GlassContainer
|
||||||
style={[
|
style={[a.flex_row, a.align_end, a.gap_sm]}
|
||||||
a.w_full,
|
spacing={tokens.space.xs}>
|
||||||
a.flex_row,
|
<GlassView
|
||||||
t.atoms.bg_contrast_25,
|
isInteractive
|
||||||
{
|
glassEffectStyle="regular"
|
||||||
padding: a.p_sm.padding - 2,
|
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
|
||||||
paddingLeft: a.p_md.padding - 2,
|
tintColor={t.palette.contrast_50}
|
||||||
borderWidth: 1,
|
fallbackStyle={[t.atoms.bg_contrast_50]}>
|
||||||
borderRadius: 23,
|
<AnimatedTextInput
|
||||||
borderColor: 'transparent',
|
nativeID={textInputId}
|
||||||
},
|
accessibilityLabel={_(msg`Message input field`)}
|
||||||
isFocused && inputStyles.chromeFocus,
|
accessibilityHint={_(msg`Type your message here`)}
|
||||||
]}>
|
placeholder={_(msg`Message`)}
|
||||||
<AnimatedTextInput
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
accessibilityLabel={_(msg`Message input field`)}
|
value={message}
|
||||||
accessibilityHint={_(msg`Type your message here`)}
|
onChange={evt => {
|
||||||
placeholder={_(msg`Write a message`)}
|
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
|
||||||
placeholderTextColor={t.palette.contrast_500}
|
// including the button we just pressed - and this overrides clearing the input! so we watch for the
|
||||||
value={message}
|
// next change and double make sure the input is cleared. It should *always* send an onChange event after
|
||||||
onChange={evt => {
|
// clearing via setMessage('') that happens in onSubmit()
|
||||||
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
|
// -sfn
|
||||||
// including the button we just pressed - and this overrides clearing the input! so we watch for the
|
if (IS_IOS && shouldEnforceClear) {
|
||||||
// next change and double make sure the input is cleared. It should *always* send an onChange event after
|
setShouldEnforceClear(false)
|
||||||
// clearing via setMessage('') that happens in onSubmit()
|
setMessage('')
|
||||||
// -sfn
|
return
|
||||||
if (IS_IOS && shouldEnforceClear) {
|
}
|
||||||
setShouldEnforceClear(false)
|
const text = evt.nativeEvent.text
|
||||||
setMessage('')
|
setMessage(text)
|
||||||
return
|
}}
|
||||||
}
|
multiline={true}
|
||||||
const text = evt.nativeEvent.text
|
style={[
|
||||||
setMessage(text)
|
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
|
||||||
}}
|
a.flex_shrink_0,
|
||||||
multiline={true}
|
a.flex_grow,
|
||||||
style={[
|
a.text_md,
|
||||||
a.flex_1,
|
a.px_lg,
|
||||||
a.text_md,
|
t.atoms.text,
|
||||||
a.px_sm,
|
platform({
|
||||||
t.atoms.text,
|
android: {paddingTop: 2, paddingBottom: 3},
|
||||||
android({paddingTop: 0}),
|
ios: {paddingTop: 10, paddingBottom: 5},
|
||||||
{paddingBottom: IS_IOS ? 5 : 0},
|
}),
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}
|
]}
|
||||||
keyboardAppearance={t.scheme}
|
verticalAlign="middle"
|
||||||
submitBehavior="newline"
|
keyboardAppearance={t.scheme}
|
||||||
onFocus={() => setIsFocused(true)}
|
submitBehavior="newline"
|
||||||
onBlur={() => setIsFocused(false)}
|
ref={inputRef}
|
||||||
ref={inputRef}
|
hitSlop={HITSLOP_10}
|
||||||
hitSlop={HITSLOP_10}
|
animatedProps={animatedProps}
|
||||||
animatedProps={animatedProps}
|
editable={!needsEmailVerification}
|
||||||
editable={!needsEmailVerification}
|
/>
|
||||||
/>
|
</GlassView>
|
||||||
<Pressable
|
<GlassView
|
||||||
accessibilityRole="button"
|
isInteractive
|
||||||
accessibilityLabel={_(msg`Send message`)}
|
glassEffectStyle="regular"
|
||||||
accessibilityHint=""
|
style={[a.rounded_full]}
|
||||||
hitSlop={HITSLOP_10}
|
tintColor={
|
||||||
style={[
|
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
|
||||||
a.rounded_full,
|
}
|
||||||
a.align_center,
|
fallbackStyle={{
|
||||||
a.justify_center,
|
backgroundColor: submitDisabled
|
||||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
? t.palette.contrast_100
|
||||||
]}
|
: t.palette.primary_500,
|
||||||
onPress={onSubmit}
|
}}>
|
||||||
disabled={needsEmailVerification}>
|
<Pressable
|
||||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
accessibilityRole="button"
|
||||||
</Pressable>
|
accessibilityLabel={_(msg`Send message`)}
|
||||||
</View>
|
accessibilityHint=""
|
||||||
</View>
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
height: MIN_HEIGHT,
|
||||||
|
width: MIN_HEIGHT,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={onSubmit}
|
||||||
|
disabled={submitDisabled}>
|
||||||
|
<PaperPlaneIcon
|
||||||
|
size="md"
|
||||||
|
fill={t.palette.white}
|
||||||
|
style={[a.mb_2xs]}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</GlassView>
|
||||||
|
</GlassContainer>
|
||||||
|
</ComposerContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
||||||
import {type LayoutChangeEvent, View} from 'react-native'
|
import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native'
|
||||||
import {useKeyboardHandler} from 'react-native-keyboard-controller'
|
import {
|
||||||
|
KeyboardChatScrollView,
|
||||||
|
type KeyboardChatScrollViewProps,
|
||||||
|
KeyboardGestureArea,
|
||||||
|
} from 'react-native-keyboard-controller'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
scrollTo,
|
type ScrollEvent,
|
||||||
|
type SharedValue,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {
|
import {
|
||||||
type $Typed,
|
type $Typed,
|
||||||
type AppBskyEmbedRecord,
|
type AppBskyEmbedRecord,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} 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 {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +42,6 @@ import {
|
|||||||
} from '#/state/messages/convo/types'
|
} from '#/state/messages/convo/types'
|
||||||
import {useGetPost} from '#/state/queries/post'
|
import {useGetPost} from '#/state/queries/post'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
|
||||||
import {
|
import {
|
||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
type EmojiPickerState,
|
type EmojiPickerState,
|
||||||
@@ -46,15 +51,17 @@ import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
|
|||||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||||
|
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||||
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
||||||
import {MessageItem} from '#/components/dms/MessageItem'
|
import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
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 {ChatStatusInfo} from './ChatStatusInfo'
|
||||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||||
|
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
|
||||||
|
|
||||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||||
return (
|
return (
|
||||||
@@ -108,9 +115,9 @@ export function MessagesList({
|
|||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const getPost = useGetPost()
|
const getPost = useGetPost()
|
||||||
const {embedUri, setEmbed} = useMessageEmbed()
|
const {embedUri, setEmbed} = useMessageEmbed()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
useHideBottomBarBorderForScreen()
|
const textInputId = 'chat-input-' + useId()
|
||||||
|
|
||||||
const flatListRef = useAnimatedRef<ListMethods>()
|
const flatListRef = useAnimatedRef<ListMethods>()
|
||||||
|
|
||||||
const [newMessagesPill, setNewMessagesPill] = useState({
|
const [newMessagesPill, setNewMessagesPill] = useState({
|
||||||
@@ -123,6 +130,18 @@ export function MessagesList({
|
|||||||
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
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
|
// 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
|
// 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.
|
// the bottom.
|
||||||
@@ -226,12 +245,12 @@ export function MessagesList({
|
|||||||
|
|
||||||
const onStartReached = useCallback(() => {
|
const onStartReached = useCallback(() => {
|
||||||
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
|
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
|
||||||
convoState.fetchMessageHistory()
|
void convoState.fetchMessageHistory()
|
||||||
}
|
}
|
||||||
}, [convoState, hasScrolled, layoutHeight])
|
}, [convoState, hasScrolled, layoutHeight])
|
||||||
|
|
||||||
const onScroll = useCallback(
|
const onScroll = useCallback(
|
||||||
(e: ReanimatedScrollEvent) => {
|
(e: ScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
layoutHeight.set(e.layoutMeasurement.height)
|
layoutHeight.set(e.layoutMeasurement.height)
|
||||||
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
||||||
@@ -256,56 +275,8 @@ export function MessagesList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// -- Keyboard animation handling
|
// -- Keyboard animation handling
|
||||||
const {footerHeight} = useShellLayout()
|
|
||||||
|
|
||||||
const keyboardHeight = useSharedValue(0)
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||||
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())},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
// -- Message sending
|
// -- Message sending
|
||||||
const onSendMessage = useCallback(
|
const onSendMessage = useCallback(
|
||||||
@@ -387,26 +358,6 @@ export function MessagesList({
|
|||||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
[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(() => {
|
const scrollToEndOnPress = useCallback(() => {
|
||||||
flatListRef.current?.scrollToOffset({
|
flatListRef.current?.scrollToOffset({
|
||||||
offset: prevContentHeight.current,
|
offset: prevContentHeight.current,
|
||||||
@@ -418,66 +369,100 @@ export function MessagesList({
|
|||||||
setEmojiPickerState({isOpen: true, pos})
|
setEmojiPickerState({isOpen: true, pos})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const renderScrollComponent = useCallback(
|
||||||
|
(props: ScrollViewProps) => (
|
||||||
|
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
|
||||||
|
),
|
||||||
|
[inputHeightUI],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
<KeyboardGestureArea
|
||||||
<ScrollProvider onScroll={onScroll}>
|
interpolator="ios"
|
||||||
<List
|
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||||
ref={flatListRef}
|
offset={Math.round(inputHeightJS)}
|
||||||
data={convoState.items}
|
textInputNativeID={textInputId}
|
||||||
renderItem={renderItem}
|
style={[a.flex_1]}>
|
||||||
keyExtractor={keyExtractor}
|
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||||
disableFullWindowScroll={true}
|
<ScrollProvider onScroll={onScroll}>
|
||||||
disableVirtualization={true}
|
<List
|
||||||
style={animatedListStyle}
|
ref={flatListRef}
|
||||||
// The extra two items account for the header and the footer components
|
data={convoState.items}
|
||||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
renderItem={renderItem}
|
||||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
keyExtractor={keyExtractor}
|
||||||
keyboardDismissMode="on-drag"
|
disableFullWindowScroll={true}
|
||||||
keyboardShouldPersistTaps="handled"
|
disableVirtualization={true}
|
||||||
maintainVisibleContentPosition={{
|
// The extra two items account for the header and the footer components
|
||||||
minIndexForVisible: 0,
|
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||||
}}
|
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||||
removeClippedSubviews={false}
|
keyboardDismissMode="interactive"
|
||||||
sideBorders={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
onContentSizeChange={onContentSizeChange}
|
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||||
onLayout={onListLayout}
|
removeClippedSubviews={false}
|
||||||
onStartReached={onStartReached}
|
sideBorders={false}
|
||||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
onContentSizeChange={onContentSizeChange}
|
||||||
scrollEventThrottle={100}
|
onStartReached={onStartReached}
|
||||||
ListHeaderComponent={
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||||
}
|
scrollEventThrottle={100}
|
||||||
/>
|
ListHeaderComponent={
|
||||||
</ScrollProvider>
|
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||||
<Animated.View style={animatedStickyViewStyle}>
|
}
|
||||||
{convoState.status === ConvoStatus.Disabled ? (
|
// native only (prop is not supported on web)
|
||||||
<ChatDisabled />
|
renderScrollComponent={renderScrollComponent}
|
||||||
) : blocked ? (
|
// pushes up the content under the input on web (renderScrollComponent handles it on native)
|
||||||
footer
|
ListFooterComponent={web(
|
||||||
) : (
|
<WebInputSpacer inputHeight={inputHeightJS} />,
|
||||||
<ConversationFooter
|
|
||||||
convoState={convoState}
|
|
||||||
hasAcceptOverride={hasAcceptOverride}>
|
|
||||||
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
|
||||||
<MessageComposer
|
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
hasEmbed={!!embedUri}
|
|
||||||
setEmbed={setEmbed}>
|
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
|
||||||
</MessageComposer>
|
|
||||||
) : (
|
|
||||||
<MessageInput
|
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
hasEmbed={!!embedUri}
|
|
||||||
setEmbed={setEmbed}
|
|
||||||
openEmojiPicker={onOpenEmojiPicker}>
|
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
|
||||||
</MessageInput>
|
|
||||||
)}
|
)}
|
||||||
</ConversationFooter>
|
style={web({
|
||||||
)}
|
scrollbarWidth: 'thin',
|
||||||
</Animated.View>
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||||
|
scrollbarGutter: 'stable both-edges',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>
|
||||||
|
<KeyboardStickyView
|
||||||
|
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||||
|
onLayout={onInputLayout}
|
||||||
|
minimumOffset={bottomInset}
|
||||||
|
offset={{
|
||||||
|
closed: platform({
|
||||||
|
ios: tokens.space.lg, // hide bottom padding when closed
|
||||||
|
default: 0,
|
||||||
|
}),
|
||||||
|
opened: 0,
|
||||||
|
}}>
|
||||||
|
{convoState.status === ConvoStatus.Disabled ? (
|
||||||
|
<ChatDisabled />
|
||||||
|
) : blocked ? (
|
||||||
|
footer
|
||||||
|
) : (
|
||||||
|
<ConversationFooter
|
||||||
|
convoState={convoState}
|
||||||
|
hasAcceptOverride={hasAcceptOverride}>
|
||||||
|
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
||||||
|
<MessageComposer
|
||||||
|
textInputId={textInputId}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
hasEmbed={!!embedUri}
|
||||||
|
setEmbed={setEmbed}>
|
||||||
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
|
</MessageComposer>
|
||||||
|
) : (
|
||||||
|
<MessageInput
|
||||||
|
textInputId={textInputId}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
hasEmbed={!!embedUri}
|
||||||
|
setEmbed={setEmbed}
|
||||||
|
openEmojiPicker={onOpenEmojiPicker}>
|
||||||
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
|
</MessageInput>
|
||||||
|
)}
|
||||||
|
</ConversationFooter>
|
||||||
|
)}
|
||||||
|
</KeyboardStickyView>
|
||||||
|
</KeyboardGestureArea>
|
||||||
|
|
||||||
{IS_WEB && (
|
{IS_WEB && (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
@@ -492,6 +477,53 @@ export function MessagesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Note: native only */
|
||||||
|
function ChatScrollComponent({
|
||||||
|
ref,
|
||||||
|
inputHeight,
|
||||||
|
...props
|
||||||
|
}: ScrollViewProps & {
|
||||||
|
ref?: React.RefObject<KeyboardChatScrollViewProps>
|
||||||
|
inputHeight: SharedValue<number>
|
||||||
|
}) {
|
||||||
|
const scrollEdgeRef = useScrollEdgeEffectRef()
|
||||||
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||||
|
|
||||||
|
const offset = platform({
|
||||||
|
ios: bottomInset - tokens.space.lg,
|
||||||
|
android: bottomInset,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputOffset = platform({
|
||||||
|
ios: bottomInset - tokens.space.lg,
|
||||||
|
android: bottomInset,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const extraContentPadding = useDerivedValue(
|
||||||
|
() => inputHeight.get() + inputOffset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardChatScrollView
|
||||||
|
ref={mergeRefs([scrollEdgeRef, ref])}
|
||||||
|
automaticallyAdjustContentInsets={false}
|
||||||
|
keyboardDismissMode="interactive"
|
||||||
|
keyboardLiftBehavior="always"
|
||||||
|
extraContentPadding={extraContentPadding}
|
||||||
|
offset={offset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
|
||||||
|
if (!IS_WEB) return null
|
||||||
|
|
||||||
|
return <Animated.View style={{height: inputHeight}} />
|
||||||
|
}
|
||||||
|
|
||||||
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
|
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
|
||||||
|
|
||||||
function getFooterState(
|
function getFooterState(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
type KeyboardStickyViewProps,
|
||||||
|
useReanimatedKeyboardAnimation,
|
||||||
|
} from 'react-native-keyboard-controller'
|
||||||
|
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
// Vendored from https://github.com/kirillzyusko/react-native-keyboard-controller/blob/main/src/components/KeyboardStickyView/index.tsx
|
||||||
|
// Converted to Reanimated to support `minimumOffset` clamping.
|
||||||
|
export function KeyboardStickyView({
|
||||||
|
children,
|
||||||
|
offset: {closed = 0, opened = 0} = {},
|
||||||
|
style,
|
||||||
|
enabled = true,
|
||||||
|
minimumOffset,
|
||||||
|
...props
|
||||||
|
}: KeyboardStickyViewProps & {
|
||||||
|
/**
|
||||||
|
* Stop the stickyview going lower than this (i.e. bottom safe area)
|
||||||
|
*/
|
||||||
|
minimumOffset?: number
|
||||||
|
}) {
|
||||||
|
const {height, progress} = useReanimatedKeyboardAnimation()
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const offset = closed + (opened - closed) * progress.get()
|
||||||
|
let translateY: number
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
let h = height.get()
|
||||||
|
if (minimumOffset != null) {
|
||||||
|
h = Math.min(h, -minimumOffset)
|
||||||
|
}
|
||||||
|
translateY = h + offset
|
||||||
|
} else {
|
||||||
|
translateY = closed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: [{translateY}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[animatedStyle, style]} {...props}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
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 {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
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() {
|
export function useGetPost() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const reactNativeWebWebviewConfiguration = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async function (env, argv) {
|
module.exports = async function (env, argv) {
|
||||||
|
env.babel = {
|
||||||
|
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo'],
|
||||||
|
}
|
||||||
let config = await createExpoWebpackConfigAsync(env, argv)
|
let config = await createExpoWebpackConfigAsync(env, argv)
|
||||||
config = withAlias(config, {
|
config = withAlias(config, {
|
||||||
'react-native$': 'react-native-web',
|
'react-native$': 'react-native-web',
|
||||||
|
|||||||
@@ -2429,6 +2429,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
|
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==
|
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":
|
"@bsky.app/expo-translate-text@^0.2.9":
|
||||||
version "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"
|
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:
|
dependencies:
|
||||||
fontfaceobserver "^2.1.0"
|
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:
|
expo-haptics@~15.0.8:
|
||||||
version "15.0.8"
|
version "15.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
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"
|
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==
|
integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==
|
||||||
|
|
||||||
react-native-keyboard-controller@^1.21.0:
|
react-native-keyboard-controller@^1.21.5:
|
||||||
version "1.21.0"
|
version "1.21.5"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.0.tgz#79ad48c67e6f5ec572b7dc896c7b05a98662a2a2"
|
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.5.tgz#563aabb7e9ce8dbe2a0dd5f949883ba81620b6c0"
|
||||||
integrity sha512-mLHJysehhSzYoM8BAD2DSjVZEcF69t16ZCJrCAos6sfVtbB3tL+kgGZFX+jNVz/f9BEhqnBFO0EA1tc/V6Hkgw==
|
integrity sha512-wxR+vpJ+2g6QMQCP1mRQKySDUietf5xLntZ76cUNHOGsjyqk6LtznXwHBG9YsR9E/b2IrHXISylwqPnIit6Y6A==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-native-is-edge-to-edge "^1.2.1"
|
react-native-is-edge-to-edge "^1.2.1"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user