[Chat] Transparent blur header for chat (iOS 26) (#10252)

This commit is contained in:
Samuel Newman
2026-04-14 10:54:28 -07:00
committed by GitHub
parent 837c404070
commit c9845c19fd
4 changed files with 126 additions and 82 deletions
+13 -3
View File
@@ -26,6 +26,7 @@ export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) {
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
@@ -33,6 +34,7 @@ export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) {
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
@@ -88,12 +90,14 @@ function AvatarBubble({
style,
x,
y,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
@@ -103,10 +107,10 @@ function AvatarBubble({
a.absolute,
a.rounded_full,
a.flex_grow_0,
{
{transform: [{translateX: x}, {translateY: y}]},
includeProfileBorder && {
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
transform: [{translateX: x}, {translateY: y}],
},
style,
]}>
@@ -127,7 +131,13 @@ function Avatar({
size?: number
}) {
return (
<UserAvatar avatar={profile.avatar} size={size} type="user" hideLiveBadge />
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
+17 -49
View File
@@ -8,9 +8,9 @@ import {
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
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 * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {ProfileBadges} from '#/components/ProfileBadges'
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
@@ -40,7 +39,6 @@ export function MessagesListHeader({
moderation?: ModerationDecision
}) {
const t = useTheme()
const convoState = useConvo()
const blockInfo = useMemo(() => {
if (!moderation) return
@@ -55,7 +53,7 @@ export function MessagesListHeader({
}, [moderation])
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={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
@@ -79,21 +77,12 @@ export function MessagesListHeader({
<View style={a.gap_xs}>
<View
style={[
{width: 120, height: 16},
{width: 150, height: 16},
a.rounded_xs,
t.atoms.bg_contrast_25,
a.mt_xs,
]}
/>
{!convoState.isGroup?.() && (
<View
style={[
{width: 175, height: 12},
a.rounded_xs,
t.atoms.bg_contrast_25,
]}
/>
)}
</View>
</View>
@@ -132,10 +121,7 @@ function HeaderReady({
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
: isDeletedAccount
? l`Deleted Account`
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
@@ -176,7 +162,7 @@ function HeaderReady({
) : (
<Link
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)}>
<PreviewableUserAvatar
size={PFP_SIZE}
@@ -193,24 +179,19 @@ function HeaderReady({
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
</View>
{!isDeletedAccount && (
<Text
style={[t.atoms.text_contrast_medium, a.text_xs]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{convoState.convo?.muted && (
<>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
<BellOffIcon
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
</Text>
<BellOffIcon
size="sm"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</View>
</View>
</Link>
)}
@@ -242,19 +223,6 @@ function HeaderReady({
</Layout.Header.Slot>
</View>
</View>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
</View>
</View>
)
}
+43 -12
View File
@@ -1,11 +1,15 @@
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 {
type AppBskyActorDefs,
moderateProfile,
type ModerationDecision,
} 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 {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) {
)
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>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
@@ -98,6 +105,7 @@ function Inner() {
const convoState = useConvo()
const {_} = useLingui()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
@@ -133,7 +141,8 @@ function Inner() {
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Layout.Center style={[a.flex_1]}>
<Layout.Center
style={[a.flex_1, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
@@ -154,12 +163,15 @@ function Inner() {
<Layout.Center style={[a.flex_1]}>
{/* MessagesList does not use the body scroll */}
{isFocused && IS_WEB && <RemoveScrollBar />}
{!readyToShow &&
(moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
))}
{!readyToShow && (
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
</View>
)}
<View style={[a.flex_1]}>
{moderation && recipient ? (
<InnerReady
@@ -205,6 +217,11 @@ function InnerReady({
}) {
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const [headerHeight, setHeaderHeight] = useState(0)
const onHeaderLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
const {params} =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const {needsEmailVerification} = useEmail()
@@ -248,15 +265,29 @@ function InnerReady({
maybeBlockForEmailVerification()
}, [maybeBlockForEmailVerification])
const header = (
<MessagesListHeader profile={recipient} moderation={moderation} />
)
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) && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
blocked={moderation?.blocked}
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
footer={
<MessagesListBlockedFooter
recipient={recipient}
@@ -91,12 +91,14 @@ export function MessagesList({
blocked,
footer,
hasAcceptOverride,
transparentHeaderHeight,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
blocked?: boolean
footer?: React.ReactNode
hasAcceptOverride?: boolean
transparentHeaderHeight?: number
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
@@ -143,6 +145,16 @@ export function MessagesList({
const prevContentHeight = 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
const layoutHeight = useSharedValue(0)
const didBackground = useRef(false)
@@ -175,8 +187,25 @@ export function MessagesList({
})
}
// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.get()) {
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// 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
// 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
@@ -200,17 +229,6 @@ export function MessagesList({
offset: height,
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
}
// 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(
(props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
@@ -428,16 +460,19 @@ export function MessagesList({
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
// adds extra space underneath the absolutely positioned input on web
// as renderScrollComponent isn't available here (luckily we don't need the fancy behaviour)
ListFooterComponent={web(
<View style={{height: tokens.space.md + inputHeightJS}} />,
)}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
<KeyboardStickyView