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}}
/>