about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-09-25 15:01:25 +0100
committerGitHub <noreply@github.com>2024-09-25 15:01:25 +0100
commitf7a2368100d293c7ddc65bf27ade9fda66ecda95 (patch)
treefa9c3699b61bced293d5e2eddf1d76d2530b9c4d /src
parentbd393b1b387eeddff33a520f60f04387c9105379 (diff)
downloadvoidsky-f7a2368100d293c7ddc65bf27ade9fda66ecda95.tar.zst
Header blurred banner on overscroll (take 2) (#5474)
* grow banner when overscrolling

* add blurview

* make backdrop blur as it scrolls

* add activity indicator

* use rotated spinner instead of arrow

* persist position of back button

* make back button prettier

* make blur less jarring

* Unify effects

* Tweak impl

* determine if should animate based on scroll amount

* sign comment

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/screens/Profile/Header/GrowableBanner.tsx212
-rw-r--r--src/screens/Profile/Header/Shell.tsx84
-rw-r--r--src/state/queries/actor-starter-packs.ts4
-rw-r--r--src/state/queries/post-feed.ts20
-rw-r--r--src/state/queries/profile-feedgens.ts2
-rw-r--r--src/state/queries/profile-lists.ts2
-rw-r--r--src/view/com/pager/PagerHeaderContext.tsx41
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx31
-rw-r--r--src/view/com/util/UserBanner.tsx2
9 files changed, 335 insertions, 63 deletions
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
new file mode 100644
index 000000000..bccc1e57e
--- /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 (
+      <View style={[a.w_full, a.h_full]}>
+        {backButton}
+        {children}
+      </View>
+    )
+  }
+
+  const {scrollY} = pagerContext
+
+  return (
+    <GrowableBannerInner scrollY={scrollY} backButton={backButton}>
+      {children}
+    </GrowableBannerInner>
+  )
+}
+
+function GrowableBannerInner({
+  scrollY,
+  backButton,
+  children,
+}: {
+  scrollY: SharedValue<number>
+  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 (
+    <>
+      <Animated.View
+        style={[
+          a.absolute,
+          {left: 0, right: 0, bottom: 0},
+          {height: 150},
+          {transformOrigin: 'bottom'},
+          animatedStyle,
+        ]}>
+        {children}
+        <AnimatedBlurView
+          style={[a.absolute, a.inset_0]}
+          tint="dark"
+          animatedProps={animatedBlurViewProps}
+        />
+      </Animated.View>
+      <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+        <Animated.View style={[animatedSpinnerStyle]}>
+          <ActivityIndicator
+            key={animateSpinner ? 'spin' : 'stop'}
+            size="large"
+            color="white"
+            animating={animateSpinner}
+            hidesWhenStopped={false}
+          />
+        </Animated.View>
+      </View>
+      <Animated.View style={[animatedBackButtonStyle]}>
+        {backButton}
+      </Animated.View>
+    </>
+  )
+}
+
+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<number>
+}) {
+  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 90c283090..d31912dda 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<AppBskyActorDefs.ProfileViewDetailed>
@@ -63,20 +64,45 @@ let ProfileHeaderShell = ({
 
   return (
     <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
-      <View pointerEvents={isIOS ? 'auto' : 'none'}>
-        {isPlaceholderProfile ? (
-          <LoadingPlaceholder
-            width="100%"
-            height={150}
-            style={{borderRadius: 0}}
-          />
-        ) : (
-          <UserBanner
-            type={profile.associated?.labeler ? 'labeler' : 'default'}
-            banner={profile.banner}
-            moderation={moderation.ui('banner')}
-          />
-        )}
+      <View
+        pointerEvents={isIOS ? 'auto' : 'none'}
+        style={[a.relative, {height: 150}]}>
+        <GrowableBanner
+          backButton={
+            <>
+              {!isDesktop && !hideBackButton && (
+                <TouchableWithoutFeedback
+                  testID="profileHeaderBackBtn"
+                  onPress={onPressBack}
+                  hitSlop={BACK_HITSLOP}
+                  accessibilityRole="button"
+                  accessibilityLabel={_(msg`Back`)}
+                  accessibilityHint="">
+                  <View style={styles.backBtnWrapper}>
+                    <FontAwesomeIcon
+                      size={18}
+                      icon="angle-left"
+                      color="white"
+                    />
+                  </View>
+                </TouchableWithoutFeedback>
+              )}
+            </>
+          }>
+          {isPlaceholderProfile ? (
+            <LoadingPlaceholder
+              width="100%"
+              height="100%"
+              style={{borderRadius: 0}}
+            />
+          ) : (
+            <UserBanner
+              type={profile.associated?.labeler ? 'labeler' : 'default'}
+              banner={profile.banner}
+              moderation={moderation.ui('banner')}
+            />
+          )}
+        </GrowableBanner>
       </View>
 
       {children}
@@ -93,19 +119,6 @@ let ProfileHeaderShell = ({
         </View>
       )}
 
-      {!isDesktop && !hideBackButton && (
-        <TouchableWithoutFeedback
-          testID="profileHeaderBackBtn"
-          onPress={onPressBack}
-          hitSlop={BACK_HITSLOP}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Back`)}
-          accessibilityHint="">
-          <View style={styles.backBtnWrapper}>
-            <FontAwesomeIcon size={18} icon="angle-left" color="white" />
-          </View>
-        </TouchableWithoutFeedback>
-      )}
       <TouchableWithoutFeedback
         testID="profileHeaderAviButton"
         onPress={onPressAvi}
@@ -144,6 +157,9 @@ const styles = StyleSheet.create({
     borderRadius: 15,
     // @ts-ignore web only
     cursor: 'pointer',
+    backgroundColor: 'rgba(0, 0, 0, 0.5)',
+    alignItems: 'center',
+    justifyContent: 'center',
   },
   backBtn: {
     width: 30,
diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts
index 9de80b07d..487bcdfd9 100644
--- a/src/state/queries/actor-starter-packs.ts
+++ b/src/state/queries/actor-starter-packs.ts
@@ -6,9 +6,9 @@ import {
   useInfiniteQuery,
 } from '@tanstack/react-query'
 
-import {useAgent} from 'state/session'
+import {useAgent} from '#/state/session'
 
-const RQKEY_ROOT = 'actor-starter-packs'
+export const RQKEY_ROOT = 'actor-starter-packs'
 export const RQKEY = (did?: string) => [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 7daf441ad..ae30ef0d6 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 b50a2a289..79d9735c9 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 03c983ff8..5c9f9f0d6 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 000000000..fd4cc7463
--- /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<SharedValue<number> | 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<number>
+  children: React.ReactNode
+}) {
+  return (
+    <PagerHeaderContext.Provider value={scrollY}>
+      {children}
+    </PagerHeaderContext.Provider>
+  )
+}
+
+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 559bc70f1..528f7fdf2 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<PagerRef, PagerWithHeaderProps>(
     const renderTabBar = React.useCallback(
       (props: RenderTabBarFnProps) => {
         return (
-          <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}
-          />
+          <PagerHeaderProvider scrollY={scrollY}>
+            <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}
+            />
+          </PagerHeaderProvider>
         )
       },
       [
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 13f4081fc..0e07a5745 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',