From c9845c19fd1c975d24d76bd010b7c9ba48d79999 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 14 Apr 2026 10:54:28 -0700 Subject: [PATCH] [Chat] Transparent blur header for chat (iOS 26) (#10252) --- src/components/AvatarBubbles.tsx | 16 ++++- src/components/dms/MessagesListHeader.tsx | 66 +++++------------ src/screens/Messages/Conversation.tsx | 55 ++++++++++---- .../Messages/components/MessagesList.tsx | 71 ++++++++++++++----- 4 files changed, 126 insertions(+), 82 deletions(-) diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index dc975fee0f..f7ebdc3d69 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -26,6 +26,7 @@ export function AvatarBubbles({profiles: allProfiles, size = 'large'}: Props) { x={-2} y={-2} style={[a.z_20]} + includeProfileBorder /> ) @@ -88,12 +90,14 @@ function AvatarBubble({ style, x, y, + includeProfileBorder, }: { profile?: bsky.profile.AnyProfileView size: number style?: StyleProp 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 ( - + ) } diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index cb4f6b95f0..4d7c2d7e33 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -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 ( - + @@ -79,21 +77,12 @@ export function MessagesListHeader({ - {!convoState.isGroup?.() && ( - - )} @@ -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({ ) : ( - - {!isDeletedAccount && ( - - @{profile.handle} - {convoState.convo?.muted && ( - <> + {convoState.convo?.muted && ( + <> + {' '} ·{' '} - - - )} - - )} + + + + )} + )} @@ -242,19 +223,6 @@ function HeaderReady({ - - - - ) } diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 9582386a96..785ce04195 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -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 ( - + @@ -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 ( <> - + {moderation ? ( ) : ( @@ -154,12 +163,15 @@ function Inner() { {/* MessagesList does not use the body scroll */} {isFocused && IS_WEB && } - {!readyToShow && - (moderation ? ( - - ) : ( - - ))} + {!readyToShow && ( + + {moderation ? ( + + ) : ( + + )} + + )} {moderation && recipient ? ( () + const {top: topInset} = useSafeAreaInsets() + const [headerHeight, setHeaderHeight] = useState(0) + const onHeaderLayout = (e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height) + } const {params} = useRoute>() const {needsEmailVerification} = useEmail() @@ -248,15 +265,29 @@ function InnerReady({ maybeBlockForEmailVerification() }, [maybeBlockForEmailVerification]) + const header = ( + + ) + return ( <> - + {IS_LIQUID_GLASS ? ( + + {header} + + ) : ( + header + )} {isConvoActive(convoState) && ( > 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) => ( @@ -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( - , - )} + ListFooterComponent={ + + } style={web({ scrollbarWidth: 'thin', scrollbarColor: `${t.palette.contrast_100} transparent`, scrollbarGutter: 'stable both-edges', })} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} />