Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Charlotte Som <charlotte@som.codes>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
hailey
2025-06-12 10:46:22 -07:00
committed by GitHub
parent a26b20b56c
commit 477e5f4ecf
31 changed files with 1013 additions and 911 deletions
+7 -7
View File
@@ -1,12 +1,12 @@
import React from 'react'
import {
AccessibilityProps,
type AccessibilityProps,
StyleSheet,
TextInput,
TextInputProps,
TextStyle,
type TextInputProps,
type TextStyle,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
@@ -16,13 +16,13 @@ import {
applyFonts,
atoms as a,
ios,
TextStyleProp,
type TextStyleProp,
useAlf,
useTheme,
web,
} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
const Context = React.createContext<{
@@ -196,7 +196,7 @@ export function createInput(Component: typeof TextInput) {
minWidth: 0,
},
ios({paddingTop: 12, paddingBottom: 13}),
android(a.py_sm),
android(a.py_md),
// fix for autofill styles covering border
web({
paddingTop: 10,
-39
View File
@@ -1,39 +0,0 @@
import {useState} from 'react'
import {AnimatedRef, measure, MeasuredDimensions} from 'react-native-reanimated'
export type HandleRef = {
(node: any): void
current: null | number
}
// This is a lighterweight alternative to `useAnimatedRef()` for imperative UI thread actions.
// Render it like <View ref={ref} />, then pass `ref.current` to `measureHandle()` and such.
export function useHandleRef(): HandleRef {
return useState(() => {
const ref = (node: any) => {
if (node) {
ref.current =
node._nativeTag ??
node.__nativeTag ??
node.canonical?.nativeTag ??
null
} else {
ref.current = null
}
}
ref.current = null
return ref
})[0] as HandleRef
}
// When using this version, you need to read ref.current on the JS thread, and pass it to UI.
export function measureHandle(
current: number | null,
): MeasuredDimensions | null {
'worklet'
if (current !== null) {
return measure((() => current) as AnimatedRef<any>)
} else {
return null
}
}
+37 -50
View File
@@ -1,8 +1,9 @@
import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image as RNImage} from 'react-native'
import uuid from 'react-native-uuid'
import {
cacheDirectory,
copyAsync,
createDownloadResumable,
deleteAsync,
EncodingType,
getInfoAsync,
@@ -14,7 +15,6 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
@@ -68,28 +68,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
return
}
let downloadRes
const path = createPath(appendExt)
try {
const downloadResPromise = RNFetchBlob.config({
fileCache: true,
appendExt,
}).fetch('GET', opts.uri)
const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout)
downloadRes = await downloadResPromise
clearTimeout(to1)
const status = downloadRes.info().status
if (status !== 200) {
return
}
const localUri = normalizePath(downloadRes.path(), true)
return await doResize(localUri, opts)
await downloadImage(opts.uri, path, opts.timeout)
return await doResize(path, opts)
} finally {
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
if (downloadRes) {
downloadRes.flush()
}
safeDeleteAsync(path)
}
}
@@ -98,32 +83,16 @@ export async function shareImageModal({uri}: {uri: string}) {
// TODO might need to give an error to the user in this case -prf
return
}
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
// NOTE
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
// NOTE
// for some reason expo-sharing refuses to work on iOS
// ...and visa versa
// -prf
if (isIOS) {
await RNShare.share({url: imagePath})
} else {
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
safeDeleteAsync(imagePath)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
safeDeleteAsync(imageUri)
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
const ALBUM_NAME = 'Bluesky'
@@ -134,11 +103,8 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
// save
try {
@@ -403,3 +369,24 @@ export function getResizedDimensions(originalDims: {
height: Math.round(originalDims.height * ratio),
}
}
function createPath(ext: string) {
// cacheDirectory will never be null on native, so the null check here is not necessary except for typescript.
// we use a web-only function for downloadAndResize on web
return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}`
}
async function downloadImage(uri: string, path: string, timeout: number) {
const dlResumable = createDownloadResumable(uri, path, {cache: true})
const to1 = setTimeout(() => dlResumable.cancelAsync(), timeout)
const dlRes = await dlResumable.downloadAsync()
clearTimeout(to1)
if (!dlRes?.uri) {
throw new Error('Failed to download image - dlRes is undefined')
}
return normalizePath(dlRes.uri)
}
+12 -12
View File
@@ -1,9 +1,11 @@
import React, {memo, useEffect} from 'react'
import {memo, useCallback, useEffect, useMemo} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {
import Animated, {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
@@ -14,7 +16,6 @@ import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
@@ -59,9 +60,9 @@ let ProfileHeaderShell = ({
const playHaptic = useHaptics()
const liveStatusControl = useDialogControl()
const aviRef = useHandleRef()
const aviRef = useAnimatedRef()
const onPressBack = React.useCallback(() => {
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
@@ -69,7 +70,7 @@ let ProfileHeaderShell = ({
}
}, [navigation])
const _openLightbox = React.useCallback(
const _openLightbox = useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
openLightbox({
images: [
@@ -92,7 +93,7 @@ let ProfileHeaderShell = ({
[openLightbox],
)
const isMe = React.useMemo(
const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
@@ -109,7 +110,7 @@ let ProfileHeaderShell = ({
}
}, [live.isActive, profile.did])
const onPressAvi = React.useCallback(() => {
const onPressAvi = useCallback(() => {
if (live.isActive) {
playHaptic('Light')
logger.metric(
@@ -122,10 +123,9 @@ let ProfileHeaderShell = ({
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
const aviHandle = aviRef.current
runOnUI(() => {
'worklet'
const rect = measureHandle(aviHandle)
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
@@ -223,7 +223,7 @@ let ProfileHeaderShell = ({
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<View ref={aviRef} collapsable={false}>
<Animated.View ref={aviRef} collapsable={false}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={live.isActive ? 88 : 90}
@@ -231,7 +231,7 @@ let ProfileHeaderShell = ({
moderation={moderation.ui('avatar')}
/>
{live.isActive && <LiveIndicator size="large" />}
</View>
</Animated.View>
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>
@@ -1,5 +1,5 @@
import React, {
ComponentProps,
type ComponentProps,
forwardRef,
useCallback,
useMemo,
@@ -7,16 +7,16 @@ import React, {
useState,
} from 'react'
import {
NativeSyntheticEvent,
type NativeSyntheticEvent,
Text as RNText,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
type TextInput as RNTextInput,
type TextInputSelectionChangeEventData,
View,
} from 'react-native'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import PasteInput, {
PastedFile,
PasteInputRef,
type PastedFile,
type PasteInputRef, // @ts-expect-error no types when installing from github
} from '@mattermost/react-native-paste-input'
import {POST_IMG_MAX} from '#/lib/constants'
@@ -27,7 +27,7 @@ import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
import {useTheme} from '#/lib/ThemeContext'
import {isAndroid, isNative} from '#/platform/detection'
import {
LinkFacetMatch,
type LinkFacetMatch,
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
import {atoms as a, useAlf} from '#/alf'
+115 -107
View File
@@ -1,16 +1,22 @@
import React, {forwardRef, useCallback, useContext} from 'react'
import {
useCallback,
useContext,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import PagerView, {
PagerViewOnPageScrollEventData,
PagerViewOnPageSelectedEvent,
PagerViewOnPageSelectedEventData,
PageScrollStateChangedNativeEventData,
type PagerViewOnPageScrollEventData,
type PagerViewOnPageSelectedEvent,
type PagerViewOnPageSelectedEventData,
type PageScrollStateChangedNativeEventData,
} from 'react-native-pager-view'
import Animated, {
runOnJS,
SharedValue,
type SharedValue,
useEvent,
useHandler,
useSharedValue,
@@ -36,8 +42,12 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
// tab pressed, yet to scroll to page
onTabPressed?: (index: number) => void
// scroll settled
onPageSelected?: (index: number) => void
onPageScrollStateChanged?: (
scrollState: 'idle' | 'dragging' | 'settling',
@@ -47,114 +57,112 @@ interface Props {
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
function PagerImpl(
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected: parentOnPageSelected,
onTabPressed: parentOnTabPressed,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
testID,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const pagerView = useRef<PagerView>(null)
const [isIdle, setIsIdle] = useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
},
}))
const onPageSelectedJSThread = useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = useCallback(
(index: number) => {
parentOnTabPressed?.(index)
pagerView.current?.setPage(index)
},
[pagerView, parentOnTabPressed],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
children,
initialPage = 0,
renderTabBar,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
onPageSelected: parentOnPageSelected,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const pagerView = React.useRef<PagerView>(null)
const [isIdle, setIsIdle] = React.useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
React.useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
dragProgress.set(e.offset + e.position)
},
}))
const onPageSelectedJSThread = React.useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
[pagerView],
)
},
[parentOnPageScrollStateChanged],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
dragProgress.set(e.offset + e.position)
},
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
},
[parentOnPageScrollStateChanged],
)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
},
)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
}
function usePagerHandlers(
handlers: {
+31 -18
View File
@@ -1,8 +1,19 @@
import React from 'react'
import {
Children,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {flushSync} from 'react-dom'
import {s} from '#/lib/styles'
import {atoms as a} from '#/alf'
export interface PagerRef {
setPage: (index: number) => void
}
export interface RenderTabBarFnProps {
selectedPage: number
@@ -12,30 +23,30 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = React.forwardRef(function PagerImpl(
{
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const scrollYs = React.useRef<Array<number | null>>([])
const anchorRef = React.useRef(null)
React.useImperativeHandle(ref, () => ({
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const scrollYs = useRef<Array<number | null>>([])
const anchorRef = useRef(null)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
onTabBarSelect(index)
},
}))
const onTabBarSelect = React.useCallback(
const onTabBarSelect = useCallback(
(index: number) => {
const scrollY = window.scrollY
// We want to determine if the tabbar is already "sticking" at the top (in which
@@ -75,11 +86,13 @@ export const Pager = React.forwardRef(function PagerImpl(
tabBarAnchor: <View ref={anchorRef} />,
onSelect: e => onTabBarSelect(e),
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
{Children.map(children, (child, i) => (
<View
style={selectedPage === i ? a.flex_1 : a.hidden}
key={`page-${i}`}>
{child}
</View>
))}
</View>
)
})
}
+155 -160
View File
@@ -1,17 +1,16 @@
import * as React from 'react'
import {memo, useCallback, useEffect, useRef, useState} from 'react'
import {
LayoutChangeEvent,
NativeScrollEvent,
ScrollView,
type LayoutChangeEvent,
type NativeScrollEvent,
type ScrollView,
StyleSheet,
View,
} from 'react-native'
import Animated, {
AnimatedRef,
runOnJS,
type AnimatedRef,
runOnUI,
scrollTo,
SharedValue,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
@@ -20,9 +19,13 @@ import Animated, {
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ScrollProvider} from '#/lib/ScrollContext'
import {isIOS} from '#/platform/detection'
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {
Pager,
type PagerRef,
type RenderTabBarFnProps,
} from '#/view/com/pager/Pager'
import {useTheme} from '#/alf'
import {ListMethods} from '../util/List'
import {type ListMethods} from '../util/List'
import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
@@ -33,6 +36,7 @@ export interface PagerWithHeaderChildParams {
}
export interface PagerWithHeaderProps {
ref?: React.Ref<PagerRef>
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
@@ -49,97 +53,94 @@ export interface PagerWithHeaderProps {
onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
export function PagerWithHeader({
ref,
children,
testID,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
allowHeaderOverScroll,
}: PagerWithHeaderProps) {
const [currentPage, setCurrentPage] = useState(0)
const [tabBarHeight, setTabBarHeight] = useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const renderTabBar = useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider scrollY={scrollY} headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
}: PagerWithHeaderProps,
ref,
) {
const [currentPage, setCurrentPage] = React.useState(0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
],
)
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider
scrollY={scrollY}
headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
],
)
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = React.useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = () => {
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = useCallback(
(scrollState: 'idle' | 'dragging' | 'settling') => {
'worklet'
if (scrollState !== 'dragging') return
const currentScrollY = scrollY.get()
const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
if (lastForcedScrollY.get() !== forcedScrollY) {
@@ -152,75 +153,69 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}
}
}
}
},
[currentPage, headerOnlyHeight, lastForcedScrollY, scrollRefs, scrollY],
)
const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const queueThrottledOnScroll = useNonReactiveCallback(() => {
if (!throttleTimeout.current) {
throttleTimeout.current = setTimeout(() => {
throttleTimeout.current = null
runOnUI(adjustScrollForOtherPages)()
}, 80 /* Sync often enough you're unlikely to catch it unsynced */)
const onScrollWorklet = useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
}
})
},
[scrollY, headerHeight],
)
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
runOnJS(queueThrottledOnScroll)()
}
},
[scrollY, queueThrottledOnScroll, headerHeight],
)
const onPageSelectedInner = useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onTabPressed = useCallback(() => {
runOnUI(adjustScrollForOtherPages)('dragging')
}, [adjustScrollForOtherPages])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
},
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onTabPressed={onTabPressed}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}
onPageScrollStateChanged={adjustScrollForOtherPages}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
}
let PagerTabBar = ({
currentPage,
@@ -258,7 +253,7 @@ let PagerTabBar = ({
dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}): React.ReactNode => {
const t = useTheme()
const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0)
const [minimumHeaderHeight, setMinimumHeaderHeight] = useState(0)
const headerTransform = useAnimatedStyle(() => {
const translateY =
Math.min(
@@ -275,7 +270,7 @@ let PagerTabBar = ({
],
}
})
const headerRef = React.useRef(null)
const headerRef = useRef(null)
return (
<Animated.View
pointerEvents={isIOS ? 'auto' : 'box-none'}
@@ -327,7 +322,7 @@ let PagerTabBar = ({
</Animated.View>
)
}
PagerTabBar = React.memo(PagerTabBar)
PagerTabBar = memo(PagerTabBar)
function PagerItem({
headerHeight,
@@ -348,7 +343,7 @@ function PagerItem({
}) {
const scrollElRef = useAnimatedRef()
React.useEffect(() => {
useEffect(() => {
registerRef(scrollElRef, index)
return () => {
registerRef(null, index)
+14 -10
View File
@@ -1,23 +1,28 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
import {AppBskyGraphDefs} from '@atproto/api'
import Animated, {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {type AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {emitSoftReset} from '#/state/events'
import {useLightboxControls} from '#/state/lightbox'
import {TextLink} from '#/view/com/util/Link'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar, UserAvatarType} from '#/view/com/util/UserAvatar'
import {UserAvatar, type UserAvatarType} from '#/view/com/util/UserAvatar'
import {StarterPack} from '#/components/icons/StarterPack'
import * as Layout from '#/components/Layout'
@@ -52,7 +57,7 @@ export function ProfileSubpageHeader({
const {openLightbox} = useLightboxControls()
const pal = usePalette('default')
const canGoBack = navigation.canGoBack()
const aviRef = useHandleRef()
const aviRef = useAnimatedRef()
const _openLightbox = React.useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
@@ -81,10 +86,9 @@ export function ProfileSubpageHeader({
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
const aviHandle = aviRef.current
runOnUI(() => {
'worklet'
const rect = measureHandle(aviHandle)
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
@@ -111,7 +115,7 @@ export function ProfileSubpageHeader({
paddingBottom: 14,
paddingHorizontal: isMobile ? 12 : 14,
}}>
<View ref={aviRef} collapsable={false}>
<Animated.View ref={aviRef} collapsable={false}>
<Pressable
testID="headerAviButton"
onPress={onPressAvi}
@@ -125,7 +129,7 @@ export function ProfileSubpageHeader({
<UserAvatar type={avatarType} size={58} avatar={avatar} />
)}
</Pressable>
</View>
</Animated.View>
<View style={{flex: 1, gap: 4}}>
{isLoading ? (
<LoadingPlaceholder
-1
View File
@@ -9,7 +9,6 @@ import {
type FontAwesomeIconStyle,
type Props as FontAwesomeProps,
} from '@fortawesome/react-native-fontawesome'
import type React from 'react'
const DURATION = 3500
+14 -8
View File
@@ -1,12 +1,15 @@
import React, {useRef} from 'react'
import {DimensionValue, Pressable, View} from 'react-native'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {type AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
import type {Dimensions} from '#/lib/media/types'
import {type Dimensions} from '#/lib/media/types'
import {isNative} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
@@ -68,14 +71,17 @@ export function AutoSizedImage({
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (containerRef: HandleRef, fetchedDims: Dimensions | null) => void
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
) => void
onLongPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const largeAlt = useLargeAltBadgeEnabled()
const containerRef = useHandleRef()
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
let aspectRatio: number | undefined
@@ -103,7 +109,7 @@ export function AutoSizedImage({
const hasAlt = !!image.alt
const contents = (
<View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Image
contentFit={isContain ? 'contain' : 'cover'}
style={[a.w_full, a.h_full]}
@@ -185,7 +191,7 @@ export function AutoSizedImage({
)}
</View>
) : null}
</View>
</Animated.View>
)
if (cropDisabled) {
+8 -8
View File
@@ -1,12 +1,12 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {Image, ImageStyle} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image, type ImageStyle} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import {HandleRef} from '#/lib/hooks/useHandleRef'
import {Dimensions} from '#/lib/media/types'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
import {atoms as a, useTheme} from '#/alf'
@@ -20,7 +20,7 @@ interface Props {
index: number
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: EventFunction
@@ -28,7 +28,7 @@ interface Props {
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: HandleRef[]
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
}
+10 -10
View File
@@ -1,18 +1,18 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {type AppBskyEmbedImages} from '@atproto/api'
import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
import {atoms as a, useBreakpoints} from '#/alf'
import {Dimensions} from '../../lightbox/ImageViewing/@types'
import {type Dimensions} from '../../lightbox/ImageViewing/@types'
import {GalleryItem} from './Gallery'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
@@ -43,7 +43,7 @@ interface ImageLayoutGridInnerProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: HandleRef[],
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
@@ -56,10 +56,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
const gap = props.gap
const count = props.images.length
const containerRef1 = useHandleRef()
const containerRef2 = useHandleRef()
const containerRef3 = useHandleRef()
const containerRef4 = useHandleRef()
const containerRef1 = useAnimatedRef()
const containerRef2 = useAnimatedRef()
const containerRef3 = useAnimatedRef()
const containerRef4 = useAnimatedRef()
const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
switch (count) {
+7 -4
View File
@@ -7,6 +7,8 @@ import {
type ViewStyle,
} from 'react-native'
import {
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
@@ -25,7 +27,6 @@ import {
type ModerationDecision,
} from '@atproto/api'
import {type HandleRef, measureHandle} from '#/lib/hooks/useHandleRef'
import {usePalette} from '#/lib/hooks/usePalette'
import {useLightboxControls} from '#/state/lightbox'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -162,13 +163,15 @@ export function PostEmbeds({
}
const onPress = (
index: number,
refs: HandleRef[],
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
const handles = refs.map(r => r.current)
runOnUI(() => {
'worklet'
const rects = handles.map(measureHandle)
const rects: (MeasuredDimensions | null)[] = []
for (const r of refs) {
rects.push(measure(r))
}
runOnJS(_openLightbox)(index, rects, fetchedDims)
})()
}