Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19bb9f9f6a | |||
| a2f7819ec6 | |||
| 9836939728 | |||
| b7283d92bf | |||
| 3a4c267106 | |||
| 43aee8c014 |
@@ -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 +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)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<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> · </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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user