[Chat] Transparent blur header for chat (iOS 26) (#10252)
This commit is contained in:
@@ -26,6 +26,7 @@ export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) {
|
|||||||
x={-2}
|
x={-2}
|
||||||
y={-2}
|
y={-2}
|
||||||
style={[a.z_20]}
|
style={[a.z_20]}
|
||||||
|
includeProfileBorder
|
||||||
/>
|
/>
|
||||||
<AvatarBubble
|
<AvatarBubble
|
||||||
profile={profiles[1]}
|
profile={profiles[1]}
|
||||||
@@ -33,6 +34,7 @@ export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) {
|
|||||||
x={42}
|
x={42}
|
||||||
y={42}
|
y={42}
|
||||||
style={[a.z_10]}
|
style={[a.z_10]}
|
||||||
|
includeProfileBorder
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -88,12 +90,14 @@ function AvatarBubble({
|
|||||||
style,
|
style,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
includeProfileBorder,
|
||||||
}: {
|
}: {
|
||||||
profile?: bsky.profile.AnyProfileView
|
profile?: bsky.profile.AnyProfileView
|
||||||
size: number
|
size: number
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
includeProfileBorder?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -103,10 +107,10 @@ function AvatarBubble({
|
|||||||
a.absolute,
|
a.absolute,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.flex_grow_0,
|
a.flex_grow_0,
|
||||||
{
|
{transform: [{translateX: x}, {translateY: y}]},
|
||||||
|
includeProfileBorder && {
|
||||||
borderColor: t.atoms.text_inverted.color,
|
borderColor: t.atoms.text_inverted.color,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
transform: [{translateX: x}, {translateY: y}],
|
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
@@ -127,7 +131,13 @@ function Avatar({
|
|||||||
size?: number
|
size?: number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<UserAvatar avatar={profile.avatar} size={size} type="user" hideLiveBadge />
|
<UserAvatar
|
||||||
|
avatar={profile.avatar}
|
||||||
|
size={size}
|
||||||
|
type="user"
|
||||||
|
hideLiveBadge
|
||||||
|
noBorder
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
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 {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||||
@@ -25,10 +25,9 @@ import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons
|
|||||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
|
||||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||||
|
|
||||||
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export function MessagesListHeader({
|
|||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const convoState = useConvo()
|
|
||||||
|
|
||||||
const blockInfo = useMemo(() => {
|
const blockInfo = useMemo(() => {
|
||||||
if (!moderation) return
|
if (!moderation) return
|
||||||
@@ -55,7 +53,7 @@ export function MessagesListHeader({
|
|||||||
}, [moderation])
|
}, [moderation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Header.Outer>
|
<Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
|
||||||
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
|
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
|
||||||
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||||
<Layout.Header.BackButton />
|
<Layout.Header.BackButton />
|
||||||
@@ -79,21 +77,12 @@ export function MessagesListHeader({
|
|||||||
<View style={a.gap_xs}>
|
<View style={a.gap_xs}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{width: 120, height: 16},
|
{width: 150, height: 16},
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
a.mt_xs,
|
a.mt_xs,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{!convoState.isGroup?.() && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{width: 175, height: 12},
|
|
||||||
a.rounded_xs,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -132,10 +121,7 @@ function HeaderReady({
|
|||||||
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
|
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
|
||||||
: isDeletedAccount
|
: isDeletedAccount
|
||||||
? l`Deleted Account`
|
? l`Deleted Account`
|
||||||
: sanitizeDisplayName(
|
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||||
profile.displayName || profile.handle,
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const latestMessageFromOther = convoState.items.findLast(
|
const latestMessageFromOther = convoState.items.findLast(
|
||||||
(item: ConvoItem) =>
|
(item: ConvoItem) =>
|
||||||
@@ -176,7 +162,7 @@ function HeaderReady({
|
|||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
label={l`View ${displayName}'s profile`}
|
label={l`View ${displayName}'s profile`}
|
||||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
|
||||||
to={makeProfileLink(profile)}>
|
to={makeProfileLink(profile)}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={PFP_SIZE}
|
size={PFP_SIZE}
|
||||||
@@ -193,24 +179,19 @@ function HeaderReady({
|
|||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||||
</View>
|
{convoState.convo?.muted && (
|
||||||
{!isDeletedAccount && (
|
<>
|
||||||
<Text
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
style={[t.atoms.text_contrast_medium, a.text_xs]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
@{profile.handle}
|
|
||||||
{convoState.convo?.muted && (
|
|
||||||
<>
|
|
||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
·{' '}
|
||||||
<BellOffIcon
|
</Text>
|
||||||
size="xs"
|
<BellOffIcon
|
||||||
style={t.atoms.text_contrast_medium}
|
size="sm"
|
||||||
/>
|
style={t.atoms.text_contrast_medium}
|
||||||
</>
|
/>
|
||||||
)}
|
</>
|
||||||
</Text>
|
)}
|
||||||
)}
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -242,19 +223,6 @@ function HeaderReady({
|
|||||||
</Layout.Header.Slot>
|
</Layout.Header.Slot>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
paddingLeft: PFP_SIZE + a.gap_md.gap,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<PostAlerts
|
|
||||||
modui={moderation.ui('contentList')}
|
|
||||||
size="lg"
|
|
||||||
style={[a.pt_xs]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {type LayoutChangeEvent, View} from 'react-native'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {
|
import {
|
||||||
type AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
type ModerationDecision,
|
type ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
|
import {
|
||||||
|
ScrollEdgeEffect,
|
||||||
|
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'
|
||||||
@@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
|
|||||||
import {Error} from '#/components/Error'
|
import {Error} from '#/components/Error'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
|
<Layout.Screen
|
||||||
|
testID="convoScreen"
|
||||||
|
noInsetTop={IS_LIQUID_GLASS}
|
||||||
|
style={web([{minHeight: 0}, a.flex_1])}>
|
||||||
<ScrollEdgeEffectProvider>
|
<ScrollEdgeEffectProvider>
|
||||||
<ConvoProvider key={convoId} convoId={convoId}>
|
<ConvoProvider key={convoId} convoId={convoId}>
|
||||||
<Inner />
|
<Inner />
|
||||||
@@ -98,6 +105,7 @@ function Inner() {
|
|||||||
const convoState = useConvo()
|
const convoState = useConvo()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const isFocused = useIsFocused()
|
const isFocused = useIsFocused()
|
||||||
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
|
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {data: recipientUnshadowed} = useProfileQuery({
|
const {data: recipientUnshadowed} = useProfileQuery({
|
||||||
@@ -133,7 +141,8 @@ function Inner() {
|
|||||||
if (convoState.status === ConvoStatus.Error) {
|
if (convoState.status === ConvoStatus.Error) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Layout.Center style={[a.flex_1]}>
|
<Layout.Center
|
||||||
|
style={[a.flex_1, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
|
||||||
{moderation ? (
|
{moderation ? (
|
||||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||||
) : (
|
) : (
|
||||||
@@ -154,12 +163,15 @@ function Inner() {
|
|||||||
<Layout.Center style={[a.flex_1]}>
|
<Layout.Center style={[a.flex_1]}>
|
||||||
{/* MessagesList does not use the body scroll */}
|
{/* MessagesList does not use the body scroll */}
|
||||||
{isFocused && IS_WEB && <RemoveScrollBar />}
|
{isFocused && IS_WEB && <RemoveScrollBar />}
|
||||||
{!readyToShow &&
|
{!readyToShow && (
|
||||||
(moderation ? (
|
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
|
||||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
{moderation ? (
|
||||||
) : (
|
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||||
<MessagesListHeader />
|
) : (
|
||||||
))}
|
<MessagesListHeader />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
{moderation && recipient ? (
|
{moderation && recipient ? (
|
||||||
<InnerReady
|
<InnerReady
|
||||||
@@ -205,6 +217,11 @@ function InnerReady({
|
|||||||
}) {
|
}) {
|
||||||
const convoState = useConvo()
|
const convoState = useConvo()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
|
const onHeaderLayout = (e: LayoutChangeEvent) => {
|
||||||
|
setHeaderHeight(e.nativeEvent.layout.height)
|
||||||
|
}
|
||||||
const {params} =
|
const {params} =
|
||||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||||
const {needsEmailVerification} = useEmail()
|
const {needsEmailVerification} = useEmail()
|
||||||
@@ -248,15 +265,29 @@ function InnerReady({
|
|||||||
maybeBlockForEmailVerification()
|
maybeBlockForEmailVerification()
|
||||||
}, [maybeBlockForEmailVerification])
|
}, [maybeBlockForEmailVerification])
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
{IS_LIQUID_GLASS ? (
|
||||||
|
<ScrollEdgeEffect
|
||||||
|
edge="top"
|
||||||
|
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
|
||||||
|
onLayout={onHeaderLayout}>
|
||||||
|
{header}
|
||||||
|
</ScrollEdgeEffect>
|
||||||
|
) : (
|
||||||
|
header
|
||||||
|
)}
|
||||||
{isConvoActive(convoState) && (
|
{isConvoActive(convoState) && (
|
||||||
<MessagesList
|
<MessagesList
|
||||||
hasScrolled={hasScrolled}
|
hasScrolled={hasScrolled}
|
||||||
setHasScrolled={setHasScrolled}
|
setHasScrolled={setHasScrolled}
|
||||||
blocked={moderation?.blocked}
|
blocked={moderation?.blocked}
|
||||||
hasAcceptOverride={!!params.accept}
|
hasAcceptOverride={!!params.accept}
|
||||||
|
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
|
||||||
footer={
|
footer={
|
||||||
<MessagesListBlockedFooter
|
<MessagesListBlockedFooter
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
|
|||||||
@@ -91,12 +91,14 @@ export function MessagesList({
|
|||||||
blocked,
|
blocked,
|
||||||
footer,
|
footer,
|
||||||
hasAcceptOverride,
|
hasAcceptOverride,
|
||||||
|
transparentHeaderHeight,
|
||||||
}: {
|
}: {
|
||||||
hasScrolled: boolean
|
hasScrolled: boolean
|
||||||
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
blocked?: boolean
|
blocked?: boolean
|
||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
hasAcceptOverride?: boolean
|
hasAcceptOverride?: boolean
|
||||||
|
transparentHeaderHeight?: number
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const convoState = useConvoActive()
|
const convoState = useConvoActive()
|
||||||
@@ -143,6 +145,16 @@ export function MessagesList({
|
|||||||
const prevContentHeight = useRef(0)
|
const prevContentHeight = useRef(0)
|
||||||
const prevItemCount = useRef(0)
|
const prevItemCount = useRef(0)
|
||||||
|
|
||||||
|
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
|
||||||
|
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
|
||||||
|
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
|
||||||
|
const hasInitiallyScrolled = useRef(false)
|
||||||
|
const prevHasScrolled = useRef(hasScrolled)
|
||||||
|
if (prevHasScrolled.current && !hasScrolled) {
|
||||||
|
hasInitiallyScrolled.current = false
|
||||||
|
}
|
||||||
|
prevHasScrolled.current = hasScrolled
|
||||||
|
|
||||||
// -- Keep track of background state and positioning for new pill
|
// -- Keep track of background state and positioning for new pill
|
||||||
const layoutHeight = useSharedValue(0)
|
const layoutHeight = useSharedValue(0)
|
||||||
const didBackground = useRef(false)
|
const didBackground = useRef(false)
|
||||||
@@ -175,8 +187,25 @@ export function MessagesList({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// This number _must_ be the height of the MaybeLoader component
|
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
|
||||||
if (height > 50 && isAtBottom.get()) {
|
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
|
||||||
|
if (!hasInitiallyScrolled.current && convoState.items.length > 0) {
|
||||||
|
hasInitiallyScrolled.current = true
|
||||||
|
flatListRef.current?.scrollToOffset({offset: height, animated: false})
|
||||||
|
// If history is already done loading, mark ready after a frame for the scroll to settle.
|
||||||
|
// Otherwise, the footer sentinel's onLayout will handle it when history finishes.
|
||||||
|
if (!convoState.isFetchingHistory) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setHasScrolled(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prevContentHeight.current = height
|
||||||
|
prevItemCount.current = convoState.items.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent: auto-scroll only if user is at the bottom
|
||||||
|
if (isAtBottom.get()) {
|
||||||
// If the size of the content is changing by more than the height of the screen, then we don't
|
// If the size of the content is changing by more than the height of the screen, then we don't
|
||||||
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
|
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
|
||||||
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
|
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
|
||||||
@@ -200,17 +229,6 @@ export function MessagesList({
|
|||||||
offset: height,
|
offset: height,
|
||||||
animated: hasScrolled && height > prevContentHeight.current,
|
animated: hasScrolled && height > prevContentHeight.current,
|
||||||
})
|
})
|
||||||
|
|
||||||
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
|
|
||||||
// because otherwise there is too much of a delay between the time the content
|
|
||||||
// scrolls and the time the screen appears, causing a flicker.
|
|
||||||
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
|
|
||||||
// is actually async itself - all the info has to come across the bridge first.
|
|
||||||
if (!hasScrolled && !convoState.isFetchingHistory) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setHasScrolled(true)
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +395,20 @@ export function MessagesList({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
|
||||||
|
// new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
|
||||||
|
const onFooterLayout = useCallback(() => {
|
||||||
|
if (
|
||||||
|
hasInitiallyScrolled.current &&
|
||||||
|
!hasScrolled &&
|
||||||
|
!convoState.isFetchingHistory
|
||||||
|
) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setHasScrolled(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
|
||||||
|
|
||||||
const renderScrollComponent = useCallback(
|
const renderScrollComponent = useCallback(
|
||||||
(props: ScrollViewProps) => (
|
(props: ScrollViewProps) => (
|
||||||
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
|
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
|
||||||
@@ -428,16 +460,19 @@ export function MessagesList({
|
|||||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
// adds extra space underneath the absolutely positioned input on web
|
ListFooterComponent={
|
||||||
// as renderScrollComponent isn't available here (luckily we don't need the fancy behaviour)
|
<View
|
||||||
ListFooterComponent={web(
|
style={web({height: tokens.space.md + inputHeightJS})}
|
||||||
<View style={{height: tokens.space.md + inputHeightJS}} />,
|
onLayout={onFooterLayout}
|
||||||
)}
|
/>
|
||||||
|
}
|
||||||
style={web({
|
style={web({
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||||
scrollbarGutter: 'stable both-edges',
|
scrollbarGutter: 'stable both-edges',
|
||||||
})}
|
})}
|
||||||
|
contentInset={{top: transparentHeaderHeight}}
|
||||||
|
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||||
/>
|
/>
|
||||||
</ScrollProvider>
|
</ScrollProvider>
|
||||||
<KeyboardStickyView
|
<KeyboardStickyView
|
||||||
|
|||||||
Reference in New Issue
Block a user