Compare commits

...

6 Commits

Author SHA1 Message Date
Samuel Newman 19bb9f9f6a fix rect type 2026-02-27 18:18:31 +02:00
Samuel Newman a2f7819ec6 run lint-native 2026-02-27 18:14:21 +02:00
Samuel Newman 9836939728 replace react-nav header hack with scroll edge interaction module
- new expo-scroll-edge-interaction native module (iOS-only) that sets up
  UIScrollEdgeElementContainerInteraction on iOS 26+
- new ScrollEdgeInteractionProvider context and hooks
- Layout.Screen gains `transparentHeader` prop
- Layout.Header.Outer auto-detects transparent mode from context
- List auto-registers scroll view and applies content inset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-27 18:14:21 +02:00
Samuel Newman b7283d92bf compensate for uneven padding 2026-02-27 18:14:20 +02:00
Samuel Newman 3a4c267106 get rid of reanimated stuff, not necessary 2026-02-27 18:14:20 +02:00
Samuel Newman 43aee8c014 blurred header on iOS 26 2026-02-27 18:14:20 +02:00
18 changed files with 684 additions and 312 deletions
@@ -142,6 +142,7 @@ class BottomSheetView(
when {
// Full height sheets
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollEdgeInteractionModule"]
}
}
@@ -0,0 +1,2 @@
export type {ScrollEdgeInteractionEdge} from './src/ExpoScrollEdgeInteraction.types'
export {ExpoScrollEdgeInteractionView} from './src/ExpoScrollEdgeInteractionView'
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollEdgeInteraction'
s.version = '1.0.0'
s.summary = 'UIScrollEdgeElementContainerInteraction for React Native views'
s.description = 'UIScrollEdgeElementContainerInteraction for React Native views'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,19 @@
import ExpoModulesCore
public class ExpoScrollEdgeInteractionModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollEdgeInteraction")
View(ExpoScrollEdgeInteractionView.self) {
Prop("nodeHandle") { (view: ExpoScrollEdgeInteractionView, prop: Int?) in
view.nodeHandle = prop
}
Prop("scrollViewTag") { (view: ExpoScrollEdgeInteractionView, prop: Int?) in
view.scrollViewTag = prop
}
Prop("edge") { (view: ExpoScrollEdgeInteractionView, prop: String?) in
view.edge = prop
}
}
}
}
@@ -0,0 +1,73 @@
import ExpoModulesCore
import React
class ExpoScrollEdgeInteractionView: ExpoView {
var nodeHandle: Int? {
didSet {
setupInteraction()
}
}
var scrollViewTag: Int? {
didSet {
setupInteraction()
}
}
var edge: String? {
didSet {
setupInteraction()
}
}
private var currentInteraction: NSObject?
private func setupInteraction() {
guard #available(iOS 26.0, *) else { return }
// Clean up old interaction
if let interaction = currentInteraction as? UIScrollEdgeElementContainerInteraction,
let headerView = findHeaderView() {
headerView.removeInteraction(interaction)
currentInteraction = nil
}
guard let nodeHandle = nodeHandle,
let scrollViewTag = scrollViewTag
else { return }
guard let headerView = appContext?.findView(withTag: nodeHandle, ofType: UIView.self) else {
return
}
// RCTScrollView wraps a UIScrollView get the inner one
let scrollView: UIScrollView?
if let rctScrollView = appContext?.findView(withTag: scrollViewTag, ofType: RCTScrollView.self) {
scrollView = rctScrollView.scrollView
} else {
scrollView = appContext?.findView(withTag: scrollViewTag, ofType: UIScrollView.self)
}
guard let scrollView = scrollView else { return }
let interaction = UIScrollEdgeElementContainerInteraction()
interaction.edge = resolveEdge()
interaction.scrollView = scrollView
headerView.addInteraction(interaction)
currentInteraction = interaction
}
private func resolveEdge() -> UIRectEdge {
switch edge {
case "bottom": return .bottom
case "left": return .left
case "right": return .right
default: return .top
}
}
private func findHeaderView() -> UIView? {
guard let nodeHandle = nodeHandle else { return nil }
return appContext?.findView(withTag: nodeHandle, ofType: UIView.self)
}
}
@@ -0,0 +1,11 @@
import {type ViewStyle} from 'react-native'
export type ScrollEdgeInteractionEdge = 'top' | 'bottom' | 'left' | 'right'
export interface ExpoScrollEdgeInteractionViewProps {
nodeHandle: number | null
scrollViewTag: number | null
edge?: ScrollEdgeInteractionEdge
children: React.ReactNode
style?: ViewStyle
}
@@ -0,0 +1,13 @@
import {requireNativeViewManager} from 'expo-modules-core'
import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types'
const NativeView: React.ComponentType<ExpoScrollEdgeInteractionViewProps> =
requireNativeViewManager('ExpoScrollEdgeInteraction')
export function ExpoScrollEdgeInteractionView({
children,
...rest
}: ExpoScrollEdgeInteractionViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}
@@ -0,0 +1,7 @@
import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types'
export function ExpoScrollEdgeInteractionView({
children,
}: React.PropsWithChildren<ExpoScrollEdgeInteractionViewProps>) {
return children
}
@@ -0,0 +1,61 @@
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {atoms as a, useGutters} from '#/alf'
import {useTransparentHeaderProps} from '#/components/Layout/ScrollEdgeInteraction'
import {Outer as DefaultOuter, type OuterProps} from './index.shared'
export {
BackButton,
Content,
MenuButton,
type OuterProps,
Slot,
SubtitleText,
TitleText,
} from './index.shared'
export function Outer(props: OuterProps) {
const transparentHeaderProps = useTransparentHeaderProps()
if (transparentHeaderProps) {
return (
<TransparentOuter
transparentHeaderProps={transparentHeaderProps}
{...props}
/>
)
}
return <DefaultOuter {...props} />
}
function TransparentOuter({
children,
transparentHeaderProps,
}: OuterProps & {
transparentHeaderProps: {ref: (node: View | null) => void; onLayout: any}
}) {
const {top} = useSafeAreaInsets()
const gutters = useGutters([0, 'base'])
return (
<View
collapsable={false}
ref={transparentHeaderProps.ref}
onLayout={transparentHeaderProps.onLayout}
style={[
a.absolute,
a.top_0,
a.left_0,
a.right_0,
a.z_10,
a.flex_row,
a.align_center,
a.gap_sm,
gutters,
{paddingTop: top, minHeight: 48 + top},
a.pb_xs,
]}>
{children}
</View>
)
}
@@ -0,0 +1,217 @@
import {createContext, useCallback, useContext} from 'react'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
CENTER_COLUMN_OFFSET,
HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
export type OuterProps = {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
sticky?: boolean
}
export function Outer({
children,
noBottomBorder,
headerRef,
sticky = true,
}: OuterProps) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints()
return (
<View
ref={headerRef}
style={[
a.w_full,
!noBottomBorder && a.border_b,
a.flex_row,
a.align_center,
a.gap_sm,
sticky && web([a.sticky, {top: 0}, a.z_10, t.atoms.bg]),
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{children}
</View>
)
}
const AlignmentContext = createContext<'platform' | 'left'>('platform')
AlignmentContext.displayName = 'AlignmentContext'
export function Content({
children,
align = 'platform',
}: {
children?: React.ReactNode
align?: 'platform' | 'left'
}) {
return (
<View
style={[
a.flex_1,
a.justify_center,
IS_IOS && align === 'platform' && a.align_center,
{minHeight: HEADER_SLOT_SIZE},
]}>
<AlignmentContext.Provider value={align}>
{children}
</AlignmentContext.Provider>
</View>
)
}
export function Slot({children}: {children?: React.ReactNode}) {
return <View style={[a.z_50, {width: HEADER_SLOT_SIZE}]}>{children}</View>
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
export function MenuButton() {
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {gtMobile} = useBreakpoints()
const onPress = useCallback(() => {
Keyboard.dismiss()
setDrawerOpen(true)
}, [setDrawerOpen])
return gtMobile ? null : (
<Slot>
<Button
label={_(msg`Open drawer menu`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
)
}
export function TitleText({
children,
style,
}: {children: React.ReactNode} & TextStyleProp) {
const {gtMobile} = useBreakpoints()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_lg,
a.font_semi_bold,
a.leading_tight,
IS_IOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
style,
]}
numberOfLines={2}
emoji>
{children}
</Text>
)
}
export function SubtitleText({children}: {children: React.ReactNode}) {
const t = useTheme()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_sm,
a.leading_snug,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
{children}
</Text>
)
}
+1 -215
View File
@@ -1,215 +1 @@
import {createContext, useCallback, useContext} from 'react'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
CENTER_COLUMN_OFFSET,
HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
export function Outer({
children,
noBottomBorder,
headerRef,
sticky = true,
}: {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
sticky?: boolean
}) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints()
return (
<View
ref={headerRef}
style={[
a.w_full,
!noBottomBorder && a.border_b,
a.flex_row,
a.align_center,
a.gap_sm,
sticky && web([a.sticky, {top: 0}, a.z_10, t.atoms.bg]),
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{children}
</View>
)
}
const AlignmentContext = createContext<'platform' | 'left'>('platform')
AlignmentContext.displayName = 'AlignmentContext'
export function Content({
children,
align = 'platform',
}: {
children?: React.ReactNode
align?: 'platform' | 'left'
}) {
return (
<View
style={[
a.flex_1,
a.justify_center,
IS_IOS && align === 'platform' && a.align_center,
{minHeight: HEADER_SLOT_SIZE},
]}>
<AlignmentContext.Provider value={align}>
{children}
</AlignmentContext.Provider>
</View>
)
}
export function Slot({children}: {children?: React.ReactNode}) {
return <View style={[a.z_50, {width: HEADER_SLOT_SIZE}]}>{children}</View>
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
export function MenuButton() {
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {gtMobile} = useBreakpoints()
const onPress = useCallback(() => {
Keyboard.dismiss()
setDrawerOpen(true)
}, [setDrawerOpen])
return gtMobile ? null : (
<Slot>
<Button
label={_(msg`Open drawer menu`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
)
}
export function TitleText({
children,
style,
}: {children: React.ReactNode} & TextStyleProp) {
const {gtMobile} = useBreakpoints()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_lg,
a.font_semi_bold,
a.leading_tight,
IS_IOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
style,
]}
numberOfLines={2}
emoji>
{children}
</Text>
)
}
export function SubtitleText({children}: {children: React.ReactNode}) {
const t = useTheme()
const align = useContext(AlignmentContext)
return (
<Text
style={[
a.text_sm,
a.leading_snug,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
{children}
</Text>
)
}
export * from './index.shared'
@@ -0,0 +1,118 @@
import {createContext, useContext, useRef, useState} from 'react'
import {findNodeHandle, type LayoutChangeEvent, type View} from 'react-native'
import {atoms as a} from '#/alf'
import {IS_LIQUID_GLASS} from '#/env'
import {
ExpoScrollEdgeInteractionView,
type ScrollEdgeInteractionEdge,
} from '../../../modules/expo-scroll-edge-interaction'
interface ScrollEdgeContextValue {
setNodeHandle: (update: React.SetStateAction<number | null>) => void
setScrollViewTag: (update: React.SetStateAction<number | null>) => void
setHeaderHeight: (height: number) => void
}
const ScrollEdgeContext = createContext<ScrollEdgeContextValue | null>(null)
const ScrollEdgeHeightContext = createContext<number | null>(null)
export function ScrollEdgeInteractionProvider({
children,
edge = 'top',
}: {
children: React.ReactNode
edge?: ScrollEdgeInteractionEdge
}) {
const [nodeHandle, setNodeHandle] = useState<number | null>(null)
const [scrollViewTag, setScrollViewTag] = useState<number | null>(null)
const [headerHeight, setHeaderHeight] = useState(0)
if (!IS_LIQUID_GLASS) {
return children
}
return (
<ScrollEdgeContext.Provider
value={{setNodeHandle, setScrollViewTag, setHeaderHeight}}>
<ScrollEdgeHeightContext.Provider value={headerHeight}>
<ExpoScrollEdgeInteractionView
nodeHandle={nodeHandle}
scrollViewTag={scrollViewTag}
edge={edge}
style={a.flex_1}>
{children}
</ExpoScrollEdgeInteractionView>
</ScrollEdgeHeightContext.Provider>
</ScrollEdgeContext.Provider>
)
}
/**
* Returns a ref callback to attach to the header view. The native module
* will add a UIScrollEdgeElementContainerInteraction to this view.
* Returns undefined when there's no provider (non-Liquid Glass).
*/
export function useTransparentHeaderProps() {
const ctx = useContext(ScrollEdgeContext)
// Track which handle this specific hook instance set, so that
// unmounting one header doesn't clobber another's handle.
const ourHandle = useRef<number | null>(null)
if (!ctx) return undefined
const {setNodeHandle, setHeaderHeight} = ctx
const refCallback = (node: View | null) => {
const handle = node ? findNodeHandle(node) : null
if (handle !== null) {
ourHandle.current = handle
setNodeHandle(handle)
} else {
// Only clear if we were the last one to set it
const prev = ourHandle.current
ourHandle.current = null
setNodeHandle(cur => (cur === prev ? null : cur))
}
}
const onLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
return {ref: refCallback, onLayout}
}
/**
* Registers a scroll view with the scroll edge interaction provider.
* Called automatically by List.
* Returns a ref callback, or undefined if no provider.
*/
export function useScrollEdgeScrollView() {
const ctx = useContext(ScrollEdgeContext)
const ourTag = useRef<number | null>(null)
if (!ctx) return undefined
const {setScrollViewTag} = ctx
return (node: any) => {
const tag = node ? findNodeHandle(node) : null
if (tag !== null) {
ourTag.current = tag
setScrollViewTag(tag)
} else {
const prev = ourTag.current
ourTag.current = null
setScrollViewTag(cur => (cur === prev ? null : cur))
}
}
}
/**
* Returns the measured header height, or null if not inside a
* transparent header provider.
*/
export function useTransparentHeaderHeight(): number | null {
return useContext(ScrollEdgeHeightContext)
}
+34 -3
View File
@@ -22,32 +22,63 @@ import {
import {useDialogContext} from '#/components/Dialog'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {ScrollEdgeInteractionProvider} from '#/components/Layout/ScrollEdgeInteraction'
import {IS_WEB} from '#/env'
export * from '#/components/Layout/const'
export * as Header from '#/components/Layout/Header'
export {useTransparentHeaderHeight} from '#/components/Layout/ScrollEdgeInteraction'
export type ScreenProps = React.ComponentProps<typeof View> & {
style?: StyleProp<ViewStyle>
noInsetTop?: boolean
/**
* Enables a transparent, absolutely-positioned header with the iOS 26
* Liquid Glass scroll-edge blur effect. When set, wraps screen children
* in a `ScrollEdgeInteractionProvider`, implies `noInsetTop`, and causes
* `Layout.Header.Outer` to render as a transparent overlay.
*
* The blur effect only works with `List` (native FlatList), not with
* `Layout.Content` (Animated.ScrollView). `List` automatically picks up
* the header height and applies `contentInset`.
*
* On non-Liquid-Glass devices this is a no-op.
*
* @platform ios
*/
transparentHeader?: boolean
}
/**
* Outermost component of every screen
*/
export const Screen = memo(function Screen({
children,
style,
noInsetTop,
transparentHeader,
...props
}: ScreenProps) {
const {top} = useSafeAreaInsets()
const skipInsetTop = noInsetTop || transparentHeader
return (
<>
{IS_WEB && <WebCenterBorders />}
<View
style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]}
{...props}
/>
style={[
a.util_screen_outer,
{paddingTop: skipInsetTop ? 0 : top},
style,
]}
{...props}>
{transparentHeader ? (
<ScrollEdgeInteractionProvider>
{children}
</ScrollEdgeInteractionProvider>
) : (
children
)}
</View>
</>
)
})
+66 -88
View File
@@ -12,16 +12,18 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {type ConvoItem, type ConvoState} from '#/state/messages/convo/types'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {
type SimpleVerificationState,
useSimpleVerificationState,
} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB} from '#/env'
@@ -35,6 +37,10 @@ export function MessagesListHeader({
moderation?: ModerationDecision
}) {
const t = useTheme()
const convoState = useConvo()
const verification = useSimpleVerificationState({
profile,
})
const blockInfo = useMemo(() => {
if (!moderation) return
@@ -50,7 +56,7 @@ export function MessagesListHeader({
return (
<Layout.Header.Outer>
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[a.flex_1, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
</View>
@@ -59,6 +65,8 @@ export function MessagesListHeader({
profile={profile}
moderation={moderation}
blockInfo={blockInfo}
convoState={convoState}
verification={verification}
/>
) : (
<>
@@ -101,6 +109,8 @@ function HeaderReady({
profile,
moderation,
blockInfo,
convoState,
verification,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision
@@ -108,13 +118,11 @@ function HeaderReady({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
convoState: ConvoState
verification: SimpleVerificationState
}) {
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const verification = useSimpleVerificationState({
profile,
})
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -136,90 +144,60 @@ function HeaderReady({
: undefined
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</View>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
<View style={[a.flex_1, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
{convoState.convo?.muted && (
<>
<Text> &middot; </Text>
<BellStroke size="xs" style={t.atoms.text_contrast_medium} />
</>
)}
</View>
</Link>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</Layout.Header.Slot>
</View>
</View>
</Link>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
</Layout.Header.Slot>
</View>
</View>
)
+5 -2
View File
@@ -42,7 +42,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,
@@ -86,7 +86,10 @@ export function MessagesConversationScreenInner({route}: Props) {
)
return (
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
<Layout.Screen
testID="convoScreen"
style={web([{minHeight: 0}, a.flex_1])}
transparentHeader={IS_LIQUID_GLASS}>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
</ConvoProvider>
@@ -50,8 +50,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {IS_WEB} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
@@ -421,6 +420,8 @@ export function MessagesList({
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}>
<List
contentInsetAdjustmentBehavior="automatic"
automaticallyAdjustsScrollIndicatorInsets
ref={flatListRef}
data={convoState.items}
renderItem={renderItem}
+26 -2
View File
@@ -6,14 +6,20 @@ import {
useAnimatedScrollHandler,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {mergeRefs} from '#/lib/merge-refs'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {
useScrollEdgeScrollView,
useTransparentHeaderHeight,
} from '#/components/Layout/ScrollEdgeInteraction'
import {IS_IOS} from '#/env'
import {FlatList_INTERNAL} from './Views'
@@ -54,6 +60,7 @@ let List = forwardRef<ListMethods, ListProps>(
headerOffset,
style,
progressViewOffset,
contentInset,
automaticallyAdjustsScrollIndicatorInsets = false,
...props
},
@@ -63,6 +70,13 @@ let List = forwardRef<ListMethods, ListProps>(
const t = useTheme()
const dedupe = useDedupe(400)
const scrollsToTop = useAllowScrollToTop()
const transparentHeaderRef = useScrollEdgeScrollView()
const insets = useSafeAreaInsets()
const transparentHeaderHeight = useTransparentHeaderHeight()
const isHeaderTransparent = transparentHeaderHeight != null
const headerHeight = isHeaderTransparent
? transparentHeaderHeight - insets.top
: undefined
const handleScrolledDownChange = useNonReactiveCallback(
(didScrollDown: boolean) => {
@@ -163,10 +177,18 @@ let List = forwardRef<ListMethods, ListProps>(
automaticallyAdjustsScrollIndicatorInsets
}
scrollIndicatorInsets={{
top: headerOffset,
top: headerHeight || headerOffset,
right: 1,
...props.scrollIndicatorInsets,
}}
contentInset={
headerHeight
? {
...contentInset,
top: headerHeight,
}
: contentInset
}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
contentOffset={contentOffset}
refreshControl={refreshControl}
@@ -175,7 +197,9 @@ let List = forwardRef<ListMethods, ListProps>(
scrollEventThrottle={1}
style={style}
// @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
ref={ref}
ref={
transparentHeaderRef ? mergeRefs([ref, transparentHeaderRef]) : ref
}
/>
)
},