[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} 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
/>
) )
} }
+17 -49
View File
@@ -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 && (
<>
{' '} {' '}
&middot;{' '} &middot;{' '}
<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>
) )
} }
+43 -12
View File
@@ -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