diff --git a/package.json b/package.json
index 4b3486545e..5e4d896cce 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
"eventemitter3": "^5.0.1",
"expo": "^51.0.8",
"expo-application": "^5.9.1",
+ "expo-blur": "^13.0.2",
"expo-build-properties": "^0.12.1",
"expo-camera": "~15.0.9",
"expo-clipboard": "^6.0.3",
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
new file mode 100644
index 0000000000..bccc1e57e5
--- /dev/null
+++ b/src/screens/Profile/Header/GrowableBanner.tsx
@@ -0,0 +1,212 @@
+import React, {useEffect, useState} from 'react'
+import {View} from 'react-native'
+import {ActivityIndicator} from 'react-native'
+import Animated, {
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ SharedValue,
+ useAnimatedProps,
+ useAnimatedReaction,
+ useAnimatedStyle,
+} from 'react-native-reanimated'
+import {BlurView} from 'expo-blur'
+import {useIsFetching} from '@tanstack/react-query'
+
+import {isIOS} from '#/platform/detection'
+import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs'
+import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed'
+import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens'
+import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists'
+import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
+import {atoms as a} from '#/alf'
+
+const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
+
+export function GrowableBanner({
+ backButton,
+ children,
+}: {
+ backButton?: React.ReactNode
+ children: React.ReactNode
+}) {
+ const pagerContext = usePagerHeaderContext()
+
+ // pagerContext should only be present on iOS, but better safe than sorry
+ if (!pagerContext || !isIOS) {
+ return (
+
+ {backButton}
+ {children}
+
+ )
+ }
+
+ const {scrollY} = pagerContext
+
+ return (
+
+ {children}
+
+ )
+}
+
+function GrowableBannerInner({
+ scrollY,
+ backButton,
+ children,
+}: {
+ scrollY: SharedValue
+ backButton?: React.ReactNode
+ children: React.ReactNode
+}) {
+ const isFetching = useIsProfileFetching()
+ const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scale: interpolate(scrollY.value, [-150, 0], [2, 1], {
+ extrapolateRight: Extrapolation.CLAMP,
+ }),
+ },
+ ],
+ }))
+
+ const animatedBlurViewProps = useAnimatedProps(() => {
+ return {
+ intensity: interpolate(
+ scrollY.value,
+ [-400, -100, -15],
+ [70, 60, 0],
+ Extrapolation.CLAMP,
+ ),
+ }
+ })
+
+ const animatedSpinnerStyle = useAnimatedStyle(() => {
+ return {
+ display: scrollY.value < 0 ? 'flex' : 'none',
+ opacity: interpolate(
+ scrollY.value,
+ [-60, -15],
+ [1, 0],
+ Extrapolation.CLAMP,
+ ),
+ transform: [
+ {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])},
+ {rotate: '90deg'},
+ ],
+ }
+ })
+
+ const animatedBackButtonStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
+ extrapolateRight: Extrapolation.CLAMP,
+ }),
+ },
+ ],
+ }))
+
+ return (
+ <>
+
+ {children}
+
+
+
+
+
+
+
+
+ {backButton}
+
+ >
+ )
+}
+
+function useIsProfileFetching() {
+ // are any of the profile-related queries fetching?
+ return [
+ useIsFetching({queryKey: [FEED_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [LIST_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}),
+ ].some(isFetching => isFetching)
+}
+
+function useShouldAnimateSpinner({
+ isFetching,
+ scrollY,
+}: {
+ isFetching: boolean
+ scrollY: SharedValue
+}) {
+ const [isOverscrolled, setIsOverscrolled] = useState(false)
+ // HACK: it reports a scroll pos of 0 for a tick when fetching finishes
+ // so paper over that by keeping it true for a bit -sfn
+ const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10)
+
+ useAnimatedReaction(
+ () => scrollY.value < -5,
+ (value, prevValue) => {
+ if (value !== prevValue) {
+ runOnJS(setIsOverscrolled)(value)
+ }
+ },
+ [scrollY],
+ )
+
+ const [isAnimating, setIsAnimating] = useState(isFetching)
+
+ if (isFetching && !isAnimating) {
+ setIsAnimating(true)
+ }
+
+ if (!isFetching && isAnimating && !stickyIsOverscrolled) {
+ setIsAnimating(false)
+ }
+
+ return isAnimating
+}
+
+// stayed true for at least `delay` ms before returning to false
+function useStickyToggle(value: boolean, delay: number) {
+ const [prevValue, setPrevValue] = useState(value)
+ const [isSticking, setIsSticking] = useState(false)
+
+ useEffect(() => {
+ if (isSticking) {
+ const timeout = setTimeout(() => setIsSticking(false), delay)
+ return () => clearTimeout(timeout)
+ }
+ }, [isSticking, delay])
+
+ if (value !== prevValue) {
+ setIsSticking(prevValue) // Going true -> false should stick.
+ setPrevValue(value)
+ return prevValue ? true : value
+ }
+
+ return isSticking ? true : value
+}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 90c2830907..d31912ddad 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -6,19 +6,20 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
+import {BACK_HITSLOP} from '#/lib/constants'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {NavigationProp} from '#/lib/routes/types'
+import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
-import {BACK_HITSLOP} from 'lib/constants'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {isIOS} from 'platform/detection'
-import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
-import {UserAvatar} from 'view/com/util/UserAvatar'
-import {UserBanner} from 'view/com/util/UserBanner'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {UserBanner} from '#/view/com/util/UserBanner'
import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
+import {GrowableBanner} from './GrowableBanner'
interface Props {
profile: Shadow
@@ -63,20 +64,45 @@ let ProfileHeaderShell = ({
return (
-
- {isPlaceholderProfile ? (
-
- ) : (
-
- )}
+
+
+ {!isDesktop && !hideBackButton && (
+
+
+
+
+
+ )}
+ >
+ }>
+ {isPlaceholderProfile ? (
+
+ ) : (
+
+ )}
+
{children}
@@ -93,19 +119,6 @@ let ProfileHeaderShell = ({
)}
- {!isDesktop && !hideBackButton && (
-
-
-
-
-
- )}
[RQKEY_ROOT, did]
export function useActorStarterPacksQuery({did}: {did?: string}) {
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 7daf441adb..ae30ef0d69 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -15,24 +15,24 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
+import {AuthorFeedAPI} from '#/lib/api/feed/author'
+import {CustomFeedAPI} from '#/lib/api/feed/custom'
+import {FollowingFeedAPI} from '#/lib/api/feed/following'
import {HomeFeedAPI} from '#/lib/api/feed/home'
+import {LikesFeedAPI} from '#/lib/api/feed/likes'
+import {ListFeedAPI} from '#/lib/api/feed/list'
+import {MergeFeedAPI} from '#/lib/api/feed/merge'
+import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
+import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
+import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
-import {AuthorFeedAPI} from 'lib/api/feed/author'
-import {CustomFeedAPI} from 'lib/api/feed/custom'
-import {FollowingFeedAPI} from 'lib/api/feed/following'
-import {LikesFeedAPI} from 'lib/api/feed/likes'
-import {ListFeedAPI} from 'lib/api/feed/list'
-import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
-import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
-import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
@@ -65,7 +65,7 @@ export interface FeedParams {
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
-const RQKEY_ROOT = 'post-feed'
+export const RQKEY_ROOT = 'post-feed'
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return [RQKEY_ROOT, feedDesc, params || {}]
}
diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts
index b50a2a2890..79d9735c98 100644
--- a/src/state/queries/profile-feedgens.ts
+++ b/src/state/queries/profile-feedgens.ts
@@ -8,7 +8,7 @@ const PAGE_SIZE = 50
type RQPageParam = string | undefined
// TODO refactor invalidate on mutate?
-const RQKEY_ROOT = 'profile-feedgens'
+export const RQKEY_ROOT = 'profile-feedgens'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFeedgensQuery(
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 03c983ff80..5c9f9f0d6f 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
-const RQKEY_ROOT = 'profile-lists'
+export const RQKEY_ROOT = 'profile-lists'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx
new file mode 100644
index 0000000000..fd4cc74632
--- /dev/null
+++ b/src/view/com/pager/PagerHeaderContext.tsx
@@ -0,0 +1,41 @@
+import React, {useContext} from 'react'
+import {SharedValue} from 'react-native-reanimated'
+
+import {isIOS} from '#/platform/detection'
+
+export const PagerHeaderContext =
+ React.createContext | null>(null)
+
+/**
+ * Passes the scrollY value to the pager header's banner, so it can grow on
+ * overscroll on iOS. Not necessary to use this context provider on other platforms.
+ *
+ * @platform ios
+ */
+export function PagerHeaderProvider({
+ scrollY,
+ children,
+}: {
+ scrollY: SharedValue
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function usePagerHeaderContext() {
+ const scrollY = useContext(PagerHeaderContext)
+ if (isIOS) {
+ if (!scrollY) {
+ throw new Error(
+ 'usePagerHeaderContext must be used within a HeaderProvider',
+ )
+ }
+ return {scrollY}
+ } else {
+ return null
+ }
+}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 559bc70f13..528f7fdf2e 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -22,6 +22,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
import {isIOS} from '#/platform/detection'
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {ListMethods} from '../util/List'
+import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
export interface PagerWithHeaderChildParams {
@@ -82,20 +83,22 @@ export const PagerWithHeader = React.forwardRef(
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
-
+
+
+
)
},
[
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 13f4081fce..0e07a57454 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -202,7 +202,7 @@ const styles = StyleSheet.create({
},
bannerImage: {
width: '100%',
- height: 150,
+ height: '100%',
},
defaultBanner: {
backgroundColor: '#0070ff',
diff --git a/yarn.lock b/yarn.lock
index 17fe862372..db8a707a40 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12165,6 +12165,11 @@ expo-asset@~10.0.6:
invariant "^2.2.4"
md5-file "^3.2.3"
+expo-blur@^13.0.2:
+ version "13.0.2"
+ resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958"
+ integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w==
+
expo-build-properties@^0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"