about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/auth/Onboarding.tsx4
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx3
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx204
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx160
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx4
-rw-r--r--src/view/com/composer/Composer.tsx21
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx12
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts121
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts125
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts43
-rw-r--r--src/view/com/post-thread/PostThread.tsx48
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx141
-rw-r--r--src/view/com/profile/FollowButton.tsx10
-rw-r--r--src/view/com/util/forms/Button.tsx45
16 files changed, 758 insertions, 185 deletions
diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx
index 6ea8cd79e..a36544a03 100644
--- a/src/view/com/auth/Onboarding.tsx
+++ b/src/view/com/auth/Onboarding.tsx
@@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {Welcome} from './onboarding/Welcome'
 import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
+import {RecommendedFollows} from './onboarding/RecommendedFollows'
 
 export const Onboarding = observer(function OnboardingImpl() {
   const pal = usePalette('default')
@@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() {
         {store.onboarding.step === 'RecommendedFeeds' && (
           <RecommendedFeeds next={next} />
         )}
+        {store.onboarding.step === 'RecommendedFollows' && (
+          <RecommendedFollows next={next} />
+        )}
       </ErrorBoundary>
     </SafeAreaView>
   )
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index b39714ef2..24fc9eef1 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Done
+              Next
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({
     marginBottom: 16,
     marginHorizontal: 16,
     marginTop: 16,
+    alignItems: 'center',
   },
   buttonText: {
     textAlign: 'center',
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
new file mode 100644
index 000000000..f2710d2ac
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
+import {Text} from 'view/com/util/text/Text'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
+import {Button} from 'view/com/util/forms/Button'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {RecommendedFollowsItem} from './RecommendedFollowsItem'
+
+type Props = {
+  next: () => void
+}
+export const RecommendedFollows = observer(function RecommendedFollowsImpl({
+  next,
+}: Props) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isTabletOrMobile} = useWebMediaQueries()
+
+  React.useEffect(() => {
+    // Load suggested actors if not already loaded
+    // prefetch should happen in the onboarding model
+    if (
+      !store.onboarding.suggestedActors.hasLoaded ||
+      store.onboarding.suggestedActors.isEmpty
+    ) {
+      store.onboarding.suggestedActors.loadMore(true)
+    }
+  }, [store])
+
+  const title = (
+    <>
+      <Text
+        style={[
+          pal.textLight,
+          tdStyles.title1,
+          isTabletOrMobile && tdStyles.title1Small,
+        ]}>
+        Follow some
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Recommended
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Users
+      </Text>
+      <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
+        Follow some users to get started. We can recommend you more users based
+        on who you find interesting.
+      </Text>
+      <View
+        style={{
+          flexDirection: 'row',
+          justifyContent: 'flex-end',
+          marginTop: 20,
+        }}>
+        <Button onPress={next} testID="continueBtn">
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              paddingLeft: 2,
+              gap: 6,
+            }}>
+            <Text
+              type="2xl-medium"
+              style={{color: '#fff', position: 'relative', top: -1}}>
+              Done
+            </Text>
+            <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
+          </View>
+        </Button>
+      </View>
+    </>
+  )
+
+  return (
+    <>
+      <TabletOrDesktop>
+        <TitleColumnLayout
+          testID="recommendedFollowsOnboarding"
+          title={title}
+          horizontal
+          titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
+          contentStyle={{paddingHorizontal: 0}}>
+          {store.onboarding.suggestedActors.isLoading ? (
+            <ActivityIndicator size="large" />
+          ) : (
+            <FlatList
+              data={store.onboarding.suggestedActors.suggestions}
+              renderItem={({item, index}) => (
+                <RecommendedFollowsItem item={item} index={index} />
+              )}
+              keyExtractor={(item, index) => item.did + index.toString()}
+              style={{flex: 1}}
+            />
+          )}
+        </TitleColumnLayout>
+      </TabletOrDesktop>
+
+      <Mobile>
+        <View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
+          <View>
+            <ViewHeader
+              title="Recommended Follows"
+              showBackButton={false}
+              showOnDesktop
+            />
+            <Text type="lg-medium" style={[pal.text, mStyles.header]}>
+              Check out some recommended users. Follow them to see similar
+              users.
+            </Text>
+          </View>
+          {store.onboarding.suggestedActors.isLoading ? (
+            <ActivityIndicator size="large" />
+          ) : (
+            <FlatList
+              data={store.onboarding.suggestedActors.suggestions}
+              renderItem={({item, index}) => (
+                <RecommendedFollowsItem item={item} index={index} />
+              )}
+              keyExtractor={(item, index) => item.did + index.toString()}
+              style={{flex: 1}}
+            />
+          )}
+          <Button
+            onPress={next}
+            label="Continue"
+            testID="continueBtn"
+            style={mStyles.button}
+            labelStyle={mStyles.buttonText}
+          />
+        </View>
+      </Mobile>
+    </>
+  )
+})
+
+const tdStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    marginHorizontal: 16,
+    justifyContent: 'space-between',
+  },
+  title1: {
+    fontSize: 36,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title1Small: {
+    fontSize: 24,
+  },
+  title2: {
+    fontSize: 58,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title2Small: {
+    fontSize: 36,
+  },
+  description: {
+    maxWidth: 400,
+    marginTop: 10,
+    marginLeft: 'auto',
+    textAlign: 'right',
+  },
+})
+
+const mStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    justifyContent: 'space-between',
+  },
+  header: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+  },
+  button: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+    marginTop: 16,
+    alignItems: 'center',
+  },
+  buttonText: {
+    textAlign: 'center',
+    fontSize: 18,
+    paddingVertical: 4,
+  },
+})
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
new file mode 100644
index 000000000..144fdc2e9
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -0,0 +1,160 @@
+import React, {useMemo} from 'react'
+import {View, StyleSheet, ActivityIndicator} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {FollowButton} from 'view/com/profile/FollowButton'
+import {usePalette} from 'lib/hooks/usePalette'
+import {SuggestedActor} from 'state/models/discovery/suggested-actors'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {s} from 'lib/styles'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from 'view/com/util/text/Text'
+import Animated, {FadeInRight} from 'react-native-reanimated'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+
+type Props = {
+  item: SuggestedActor
+  index: number
+}
+export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isMobile} = useWebMediaQueries()
+  const delay = useMemo(() => {
+    return (
+      50 *
+      (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) %
+        5)
+    )
+  }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex])
+
+  return (
+    <Animated.View
+      entering={FadeInRight.delay(delay).springify()}
+      style={[
+        styles.cardContainer,
+        pal.view,
+        pal.border,
+        {
+          maxWidth: isMobile ? undefined : 670,
+          borderRightWidth: isMobile ? undefined : 1,
+        },
+      ]}>
+      <ProfileCard key={item.did} profile={item} index={index} />
+    </Animated.View>
+  )
+}
+
+export const ProfileCard = observer(function ProfileCardImpl({
+  profile,
+  index,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  index: number
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+  const [addingMoreSuggestions, setAddingMoreSuggestions] =
+    React.useState(false)
+
+  return (
+    <View style={styles.card}>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <UserAvatar
+            size={40}
+            avatar={profile.avatar}
+            moderation={moderation.avatar}
+          />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text
+            type="2xl-bold"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
+            )}
+          </Text>
+          <Text type="xl" style={[pal.textLight]} numberOfLines={1}>
+            {sanitizeHandle(profile.handle, '@')}
+          </Text>
+        </View>
+
+        <FollowButton
+          did={profile.did}
+          labelStyle={styles.followButton}
+          onToggleFollow={async isFollow => {
+            if (isFollow) {
+              setAddingMoreSuggestions(true)
+              await store.onboarding.suggestedActors.insertSuggestionsByActor(
+                profile.did,
+                index,
+              )
+              setAddingMoreSuggestions(false)
+            }
+          }}
+        />
+      </View>
+      {profile.description ? (
+        <View style={styles.details}>
+          <Text type="lg" style={pal.text} numberOfLines={4}>
+            {profile.description as string}
+          </Text>
+        </View>
+      ) : undefined}
+      {addingMoreSuggestions ? (
+        <View style={styles.addingMoreContainer}>
+          <ActivityIndicator size="small" color={pal.colors.text} />
+          <Text style={[pal.text]}>Finding similar accounts...</Text>
+        </View>
+      ) : null}
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  cardContainer: {
+    borderTopWidth: 1,
+  },
+  card: {
+    paddingHorizontal: 10,
+  },
+  layout: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  layoutAvi: {
+    width: 54,
+    paddingLeft: 4,
+    paddingTop: 8,
+    paddingBottom: 10,
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  details: {
+    paddingLeft: 54,
+    paddingRight: 10,
+    paddingBottom: 10,
+  },
+  addingMoreContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingLeft: 54,
+    paddingTop: 4,
+    paddingBottom: 12,
+    gap: 4,
+  },
+  followButton: {
+    fontSize: 16,
+  },
+})
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
index 19c8d52d0..1f0a64370 100644
--- a/src/view/com/auth/onboarding/WelcomeMobile.tsx
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
         onPress={next}
         label="Continue"
         testID="continueBtn"
+        style={[styles.buttonContainer]}
         labelStyle={styles.buttonText}
       />
     </View>
@@ -117,6 +118,9 @@ const styles = StyleSheet.create({
   spacer: {
     height: 20,
   },
+  buttonContainer: {
+    alignItems: 'center',
+  },
   buttonText: {
     textAlign: 'center',
     fontSize: 18,
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 8ed0bb378..6a4215b9b 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -285,11 +285,6 @@ export const ComposePost = observer(function ComposePost({
             </View>
           )}
         </View>
-        {isProcessing ? (
-          <View style={[pal.btn, styles.processingLine]}>
-            <Text style={pal.text}>{processingState}</Text>
-          </View>
-        ) : undefined}
         {store.preferences.requireAltTextEnabled && gallery.needsAltText && (
           <View style={[styles.reminderLine, pal.viewLight]}>
             <View style={styles.errorIcon}>
@@ -374,6 +369,12 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : undefined}
         </ScrollView>
+        {isProcessing ? (
+          <View style={[pal.viewLight, styles.processingLine]}>
+            <ActivityIndicator />
+            <Text style={pal.textLight}>{processingState}</Text>
+          </View>
+        ) : undefined}
         {!extLink && suggestedLinks.size > 0 ? (
           <View style={s.mb5}>
             {Array.from(suggestedLinks)
@@ -435,11 +436,11 @@ const styles = StyleSheet.create({
     paddingVertical: 6,
   },
   processingLine: {
-    borderRadius: 6,
-    paddingHorizontal: 8,
-    paddingVertical: 6,
-    marginHorizontal: 15,
-    marginBottom: 6,
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    paddingHorizontal: 26,
+    paddingVertical: 12,
   },
   errorLine: {
     flexDirection: 'row',
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index c95538c55..bb006d506 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -34,6 +34,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
 const styles = StyleSheet.create({
   root: {
     alignItems: 'flex-end',
+    pointerEvents: 'box-none',
   },
   closeButton: {
     marginRight: 8,
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index a6b98009a..03bf45af1 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -32,6 +32,7 @@ const SWIPE_CLOSE_VELOCITY = 1
 const SCREEN = Dimensions.get('screen')
 const SCREEN_WIDTH = SCREEN.width
 const SCREEN_HEIGHT = SCREEN.height
+const MAX_SCALE = 2
 
 type Props = {
   imageSrc: ImageSource
@@ -58,13 +59,18 @@ const ImageItem = ({
   const [loaded, setLoaded] = useState(false)
   const [scaled, setScaled] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)
+  const handleDoubleTap = useDoubleTapToZoom(
+    scrollViewRef,
+    scaled,
+    SCREEN,
+    imageDimensions,
+  )
 
   const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
   const scrollValueY = new Animated.Value(0)
   const scaleValue = new Animated.Value(scale || 1)
   const translateValue = new Animated.ValueXY(translate)
-  const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1
+  const maxScrollViewZoom = MAX_SCALE / (scale || 1)
 
   const imageOpacity = scrollValueY.interpolate({
     inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
@@ -118,7 +124,7 @@ const ImageItem = ({
         pinchGestureEnabled
         showsHorizontalScrollIndicator={false}
         showsVerticalScrollIndicator={false}
-        maximumZoomScale={maxScale}
+        maximumZoomScale={maxScrollViewZoom}
         contentContainerStyle={styles.imageScrollContainer}
         scrollEnabled={swipeToCloseEnabled}
         onScrollEndDrag={onScrollEndDrag}
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
index 92746e951..ea81d9f1c 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
@@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
 import {Dimensions} from '../@types'
 
 const DOUBLE_TAP_DELAY = 300
+const MIN_ZOOM = 2
+
 let lastTapTS: number | null = null
 
 /**
@@ -22,41 +24,124 @@ function useDoubleTapToZoom(
   scrollViewRef: React.RefObject<ScrollView>,
   scaled: boolean,
   screen: Dimensions,
+  imageDimensions: Dimensions | null,
 ) {
   const handleDoubleTap = useCallback(
     (event: NativeSyntheticEvent<NativeTouchEvent>) => {
       const nowTS = new Date().getTime()
       const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
 
+      const getZoomRectAfterDoubleTap = (
+        touchX: number,
+        touchY: number,
+      ): {
+        x: number
+        y: number
+        width: number
+        height: number
+      } => {
+        if (!imageDimensions) {
+          return {
+            x: 0,
+            y: 0,
+            width: screen.width,
+            height: screen.height,
+          }
+        }
+
+        // First, let's figure out how much we want to zoom in.
+        // We want to try to zoom in at least close enough to get rid of black bars.
+        const imageAspect = imageDimensions.width / imageDimensions.height
+        const screenAspect = screen.width / screen.height
+        const zoom = Math.max(
+          imageAspect / screenAspect,
+          screenAspect / imageAspect,
+          MIN_ZOOM,
+        )
+        // Unlike in the Android version, we don't constrain the *max* zoom level here.
+        // Instead, this is done in the ScrollView props so that it constraints pinch too.
+
+        // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
+        // We already know the zoom level, so this gives us the rectangle size.
+        let rectWidth = screen.width / zoom
+        let rectHeight = screen.height / zoom
+
+        // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
+        // We don't want to introduce new black bars or make existing black bars unbalanced.
+        let minX = 0
+        let minY = 0
+        let maxX = screen.width - rectWidth
+        let maxY = screen.height - rectHeight
+        if (imageAspect >= screenAspect) {
+          // The image has horizontal black bars. Exclude them from the safe area.
+          const renderedHeight = screen.width / imageAspect
+          const horizontalBarHeight = (screen.height - renderedHeight) / 2
+          minY += horizontalBarHeight
+          maxY -= horizontalBarHeight
+        } else {
+          // The image has vertical black bars. Exclude them from the safe area.
+          const renderedWidth = screen.height * imageAspect
+          const verticalBarWidth = (screen.width - renderedWidth) / 2
+          minX += verticalBarWidth
+          maxX -= verticalBarWidth
+        }
+
+        // Finally, we can position the rect according to its size and the safe area.
+        let rectX
+        if (maxX >= minX) {
+          // Content fills the screen horizontally so we have horizontal wiggle room.
+          // Try to keep the tapped point under the finger after zoom.
+          rectX = touchX - touchX / zoom
+          rectX = Math.min(rectX, maxX)
+          rectX = Math.max(rectX, minX)
+        } else {
+          // Keep the rect centered on the screen so that black bars are balanced.
+          rectX = screen.width / 2 - rectWidth / 2
+        }
+        let rectY
+        if (maxY >= minY) {
+          // Content fills the screen vertically so we have vertical wiggle room.
+          // Try to keep the tapped point under the finger after zoom.
+          rectY = touchY - touchY / zoom
+          rectY = Math.min(rectY, maxY)
+          rectY = Math.max(rectY, minY)
+        } else {
+          // Keep the rect centered on the screen so that black bars are balanced.
+          rectY = screen.height / 2 - rectHeight / 2
+        }
+
+        return {
+          x: rectX,
+          y: rectY,
+          height: rectHeight,
+          width: rectWidth,
+        }
+      }
+
       if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
-        const {pageX, pageY} = event.nativeEvent
-        let targetX = 0
-        let targetY = 0
-        let targetWidth = screen.width
-        let targetHeight = screen.height
-
-        // Zooming in
-        // TODO: Add more precise calculation of targetX, targetY based on touch
-        if (!scaled) {
-          targetX = pageX / 2
-          targetY = pageY / 2
-          targetWidth = screen.width / 2
-          targetHeight = screen.height / 2
+        let nextZoomRect = {
+          x: 0,
+          y: 0,
+          width: screen.width,
+          height: screen.height,
+        }
+
+        const willZoom = !scaled
+        if (willZoom) {
+          const {pageX, pageY} = event.nativeEvent
+          nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
         }
 
         // @ts-ignore
         scrollResponderRef?.scrollResponderZoomTo({
-          x: targetX,
-          y: targetY,
-          width: targetWidth,
-          height: targetHeight,
+          ...nextZoomRect, // This rect is in screen coordinates
           animated: true,
         })
       } else {
         lastTapTS = nowTS
       }
     },
-    [scaled, screen.height, screen.width, scrollViewRef],
+    [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
   )
 
   return handleDoubleTap
diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
index 036e7246f..7908504ea 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
@@ -1,4 +1,3 @@
-/* eslint-disable react-hooks/exhaustive-deps */
 /**
  * Copyright (c) JOB TODAY S.A. and its affiliates.
  *
@@ -7,19 +6,19 @@
  *
  */
 
-import {useMemo, useEffect} from 'react'
+import {useEffect} from 'react'
 import {
   Animated,
   Dimensions,
   GestureResponderEvent,
   GestureResponderHandlers,
   NativeTouchEvent,
+  PanResponder,
   PanResponderGestureState,
 } from 'react-native'
 
 import {Position} from '../@types'
 import {
-  createPanResponder,
   getDistanceBetweenTouches,
   getImageTranslate,
   getImageDimensionsByTranslate,
@@ -29,8 +28,10 @@ const SCREEN = Dimensions.get('window')
 const SCREEN_WIDTH = SCREEN.width
 const SCREEN_HEIGHT = SCREEN.height
 const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
+const ANDROID_BAR_HEIGHT = 24
 
-const SCALE_MAX = 2
+const MIN_ZOOM = 2
+const MAX_SCALE = 2
 const DOUBLE_TAP_DELAY = 300
 const OUT_BOUND_MULTIPLIER = 0.75
 
@@ -87,23 +88,56 @@ const usePanResponder = ({
     return [top, left, bottom, right]
   }
 
-  const getTranslateInBounds = (translate: Position, scale: number) => {
-    const inBoundTranslate = {x: translate.x, y: translate.y}
-    const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale)
-
-    if (translate.x > leftBound) {
-      inBoundTranslate.x = leftBound
-    } else if (translate.x < rightBound) {
-      inBoundTranslate.x = rightBound
+  const getTransformAfterDoubleTap = (
+    touchX: number,
+    touchY: number,
+  ): [number, Position] => {
+    let nextScale = initialScale
+    let nextTranslateX = initialTranslate.x
+    let nextTranslateY = initialTranslate.y
+
+    // First, let's figure out how much we want to zoom in.
+    // We want to try to zoom in at least close enough to get rid of black bars.
+    const imageAspect = imageDimensions.width / imageDimensions.height
+    const screenAspect = SCREEN.width / SCREEN.height
+    let zoom = Math.max(
+      imageAspect / screenAspect,
+      screenAspect / imageAspect,
+      MIN_ZOOM,
+    )
+    // Don't zoom so hard that the original image's pixels become blurry.
+    zoom = Math.min(zoom, MAX_SCALE / initialScale)
+    nextScale = initialScale * zoom
+
+    // Next, let's see if we need to adjust the scaled image translation.
+    // Ideally, we want the tapped point to stay under the finger after the scaling.
+    const dx = SCREEN.width / 2 - touchX
+    const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
+    // Before we try to adjust the translation, check how much wiggle room we have.
+    // We don't want to introduce new black bars or make existing black bars unbalanced.
+    const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
+    if (leftBound > rightBound) {
+      // Content fills the screen horizontally so we have horizontal wiggle room.
+      // Try to keep the tapped point under the finger after zoom.
+      nextTranslateX += dx * zoom - dx
+      nextTranslateX = Math.min(nextTranslateX, leftBound)
+      nextTranslateX = Math.max(nextTranslateX, rightBound)
     }
-
-    if (translate.y > topBound) {
-      inBoundTranslate.y = topBound
-    } else if (translate.y < bottomBound) {
-      inBoundTranslate.y = bottomBound
+    if (topBound > bottomBound) {
+      // Content fills the screen vertically so we have vertical wiggle room.
+      // Try to keep the tapped point under the finger after zoom.
+      nextTranslateY += dy * zoom - dy
+      nextTranslateY = Math.min(nextTranslateY, topBound)
+      nextTranslateY = Math.max(nextTranslateY, bottomBound)
     }
 
-    return inBoundTranslate
+    return [
+      nextScale,
+      {
+        x: nextTranslateX,
+        y: nextTranslateY,
+      },
+    ]
   }
 
   const fitsScreenByWidth = () =>
@@ -125,8 +159,12 @@ const usePanResponder = ({
     longPressHandlerRef && clearTimeout(longPressHandlerRef)
   }
 
-  const handlers = {
-    onGrant: (
+  const panResponder = PanResponder.create({
+    onStartShouldSetPanResponder: () => true,
+    onStartShouldSetPanResponderCapture: () => true,
+    onMoveShouldSetPanResponder: () => true,
+    onMoveShouldSetPanResponderCapture: () => true,
+    onPanResponderGrant: (
       _: GestureResponderEvent,
       gestureState: PanResponderGestureState,
     ) => {
@@ -138,7 +176,7 @@ const usePanResponder = ({
 
       longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
     },
-    onStart: (
+    onPanResponderStart: (
       event: GestureResponderEvent,
       gestureState: PanResponderGestureState,
     ) => {
@@ -157,25 +195,18 @@ const usePanResponder = ({
       )
 
       if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
-        const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale;
-        const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
-        const targetScale = SCALE_MAX
-        const nextScale = isScaled ? initialScale : targetScale
-        const nextTranslate = isScaled
-          ? initialTranslate
-          : getTranslateInBounds(
-              {
-                x:
-                  initialTranslate.x +
-                  (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
-                y:
-                  initialTranslate.y +
-                  (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale),
-              },
-              targetScale,
-            )
-
-        onZoom(!isScaled)
+        let nextScale = initialScale
+        let nextTranslate = initialTranslate
+
+        const willZoom = currentScale === initialScale
+        if (willZoom) {
+          const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
+          ;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
+            touchX,
+            touchY,
+          )
+        }
+        onZoom(willZoom)
 
         Animated.parallel(
           [
@@ -206,7 +237,7 @@ const usePanResponder = ({
         lastTapTS = Date.now()
       }
     },
-    onMove: (
+    onPanResponderMove: (
       event: GestureResponderEvent,
       gestureState: PanResponderGestureState,
     ) => {
@@ -328,7 +359,7 @@ const usePanResponder = ({
         tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
       }
     },
-    onRelease: () => {
+    onPanResponderRelease: () => {
       cancelLongPressHandle()
 
       if (isDoubleTapPerformed) {
@@ -336,8 +367,8 @@ const usePanResponder = ({
       }
 
       if (tmpScale > 0) {
-        if (tmpScale < initialScale || tmpScale > SCALE_MAX) {
-          tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX
+        if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
+          tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
           Animated.timing(scaleValue, {
             toValue: tmpScale,
             duration: 100,
@@ -390,9 +421,9 @@ const usePanResponder = ({
         tmpTranslate = null
       }
     },
-  }
-
-  const panResponder = useMemo(() => createPanResponder(handlers), [handlers])
+    onPanResponderTerminationRequest: () => false,
+    onShouldBlockNativeResponder: () => false,
+  })
 
   return [panResponder.panHandlers, scaleValue, translateValue]
 }
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 531df129e..1a64fb3af 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -189,6 +189,7 @@ const styles = StyleSheet.create({
     width: '100%',
     zIndex: 1,
     top: 0,
+    pointerEvents: 'box-none',
   },
   footer: {
     position: 'absolute',
diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts
index 8c9c1b34c..d56eea4f4 100644
--- a/src/view/com/lightbox/ImageViewing/utils.ts
+++ b/src/view/com/lightbox/ImageViewing/utils.ts
@@ -6,14 +6,7 @@
  *
  */
 
-import {
-  Animated,
-  GestureResponderEvent,
-  PanResponder,
-  PanResponderGestureState,
-  PanResponderInstance,
-  NativeTouchEvent,
-} from 'react-native'
+import {Animated, NativeTouchEvent} from 'react-native'
 import {Dimensions, Position} from './@types'
 
 type CacheStorageItem = {key: string; value: any}
@@ -131,40 +124,6 @@ export const getImageTranslateForScale = (
   return getImageTranslate(targetImageDimensions, screen)
 }
 
-type HandlerType = (
-  event: GestureResponderEvent,
-  state: PanResponderGestureState,
-) => void
-
-type PanResponderProps = {
-  onGrant: HandlerType
-  onStart?: HandlerType
-  onMove: HandlerType
-  onRelease?: HandlerType
-  onTerminate?: HandlerType
-}
-
-export const createPanResponder = ({
-  onGrant,
-  onStart,
-  onMove,
-  onRelease,
-  onTerminate,
-}: PanResponderProps): PanResponderInstance =>
-  PanResponder.create({
-    onStartShouldSetPanResponder: () => true,
-    onStartShouldSetPanResponderCapture: () => true,
-    onMoveShouldSetPanResponder: () => true,
-    onMoveShouldSetPanResponderCapture: () => true,
-    onPanResponderGrant: onGrant,
-    onPanResponderStart: onStart,
-    onPanResponderMove: onMove,
-    onPanResponderRelease: onRelease,
-    onPanResponderTerminate: onTerminate,
-    onPanResponderTerminationRequest: () => false,
-    onShouldBlockNativeResponder: () => false,
-  })
-
 export const getDistanceBetweenTouches = (
   touches: NativeTouchEvent[],
 ): number => {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 1cc177d17..373b4499d 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -55,6 +55,7 @@ const LOAD_MORE = {
 const BOTTOM_COMPONENT = {
   _reactKey: '__bottom_component__',
   _isHighlightedPost: false,
+  _showBorder: true,
 }
 type YieldedItem =
   | PostThreadItemModel
@@ -69,10 +70,12 @@ export const PostThread = observer(function PostThread({
   uri,
   view,
   onPressReply,
+  treeView,
 }: {
   uri: string
   view: PostThreadModel
   onPressReply: () => void
+  treeView: boolean
 }) {
   const pal = usePalette('default')
   const {isTablet} = useWebMediaQueries()
@@ -99,6 +102,13 @@ export const PostThread = observer(function PostThread({
     }
     return []
   }, [view.isLoadingFromCache, view.thread, maxVisible])
+  const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  const showBottomBorder =
+    !treeView ||
+    // in the treeview, only show the bottom border
+    // if there are replies under the highlighted posts
+    posts.findLast(v => v instanceof PostThreadItemModel) !==
+      posts[highlightedPostIndex]
   useSetTitle(
     view.thread?.postRecord &&
       `${sanitizeDisplayName(
@@ -135,17 +145,16 @@ export const PostThread = observer(function PostThread({
       return
     }
 
-    const index = posts.findIndex(post => post._isHighlightedPost)
-    if (index !== -1) {
+    if (highlightedPostIndex !== -1) {
       ref.current?.scrollToIndex({
-        index,
+        index: highlightedPostIndex,
         animated: false,
         viewPosition: 0,
       })
       hasScrolledIntoView.current = true
     }
   }, [
-    posts,
+    highlightedPostIndex,
     view.hasContent,
     view.isFromCache,
     view.isLoadingFromCache,
@@ -184,7 +193,14 @@ export const PostThread = observer(function PostThread({
           </View>
         )
       } else if (item === REPLY_PROMPT) {
-        return <ComposePrompt onPressCompose={onPressReply} />
+        return (
+          <View
+            style={
+              treeView && [pal.border, {borderBottomWidth: 1, marginBottom: 6}]
+            }>
+            {isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />}
+          </View>
+        )
       } else if (item === DELETED) {
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
@@ -224,7 +240,18 @@ export const PostThread = observer(function PostThread({
         // due to some complexities with how flatlist works, this is the easiest way
         // I could find to get a border positioned directly under the last item
         // -prf
-        return <View style={[pal.border, styles.bottomSpacer]} />
+        return (
+          <View
+            style={[
+              {height: 400},
+              showBottomBorder && {
+                borderTopWidth: 1,
+                borderColor: pal.colors.border,
+              },
+              treeView && {marginTop: 10},
+            ]}
+          />
+        )
       } else if (item === CHILD_SPINNER) {
         return (
           <View style={styles.childSpinner}>
@@ -240,12 +267,13 @@ export const PostThread = observer(function PostThread({
             item={item}
             onPostReply={onRefresh}
             hasPrecedingItem={prev?._showChildReplyLine}
+            treeView={treeView}
           />
         )
       }
       return <></>
     },
-    [onRefresh, onPressReply, pal, posts, isTablet],
+    [onRefresh, onPressReply, pal, posts, isTablet, treeView, showBottomBorder],
   )
 
   // loading
@@ -377,7 +405,7 @@ function* flattenThread(
     }
   }
   yield post
-  if (isDesktopWeb && post._isHighlightedPost) {
+  if (post._isHighlightedPost) {
     yield REPLY_PROMPT
   }
   if (post.replies?.length) {
@@ -411,8 +439,4 @@ const styles = StyleSheet.create({
     paddingVertical: 10,
   },
   childSpinner: {},
-  bottomSpacer: {
-    height: 400,
-    borderTopWidth: 1,
-  },
 })
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 37c7ece47..1089bfabf 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -35,15 +35,18 @@ import {formatCount} from '../util/numeric/format'
 import {TimeElapsed} from 'view/com/util/TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
 import {isDesktopWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 
 export const PostThreadItem = observer(function PostThreadItem({
   item,
   onPostReply,
   hasPrecedingItem,
+  treeView,
 }: {
   item: PostThreadItemModel
   onPostReply: () => void
   hasPrecedingItem: boolean
+  treeView: boolean
 }) {
   const pal = usePalette('default')
   const store = useStores()
@@ -389,25 +392,28 @@ export const PostThreadItem = observer(function PostThreadItem({
       </>
     )
   } else {
+    const isThreadedChild = treeView && item._depth > 0
     return (
-      <>
+      <PostOuterWrapper
+        item={item}
+        hasPrecedingItem={hasPrecedingItem}
+        treeView={treeView}>
         <PostHider
           testID={`postThreadItem-by-${item.post.author.handle}`}
           href={itemHref}
-          style={[
-            styles.outer,
-            pal.border,
-            pal.view,
-            item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
-            styles.cursor,
-          ]}
+          style={[pal.view]}
           moderation={item.moderation.content}>
           <PostSandboxWarning />
 
           <View
-            style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}>
+            style={{
+              flexDirection: 'row',
+              gap: 10,
+              paddingLeft: 8,
+              height: isThreadedChild ? 8 : 16,
+            }}>
             <View style={{width: 52}}>
-              {item._showParentReplyLine && (
+              {!isThreadedChild && item._showParentReplyLine && (
                 <View
                   style={[
                     styles.replyLine,
@@ -431,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             ]}>
             <View style={styles.layoutAvi}>
               <PreviewableUserAvatar
-                size={52}
+                size={isThreadedChild ? 24 : 52}
                 did={item.post.author.did}
                 handle={item.post.author.handle}
                 avatar={item.post.author.avatar}
@@ -444,7 +450,9 @@ export const PostThreadItem = observer(function PostThreadItem({
                     styles.replyLine,
                     {
                       flexGrow: 1,
-                      backgroundColor: pal.colors.replyLine,
+                      backgroundColor: isThreadedChild
+                        ? pal.colors.border
+                        : pal.colors.replyLine,
                       marginTop: 4,
                     },
                   ]}
@@ -464,7 +472,11 @@ export const PostThreadItem = observer(function PostThreadItem({
                 style={styles.alert}
               />
               {item.richText?.text ? (
-                <View style={styles.postTextContainer}>
+                <View
+                  style={[
+                    styles.postTextContainer,
+                    isThreadedChild && {paddingTop: 2},
+                  ]}>
                   <RichText
                     type="post-text"
                     richText={item.richText}
@@ -508,30 +520,84 @@ export const PostThreadItem = observer(function PostThreadItem({
               />
             </View>
           </View>
+          {item._hasMore ? (
+            <Link
+              style={[
+                styles.loadMore,
+                {
+                  paddingLeft: treeView ? 44 : 70,
+                  paddingTop: 0,
+                  paddingBottom: treeView ? 4 : 12,
+                },
+              ]}
+              href={itemHref}
+              title={itemTitle}
+              noFeedback>
+              <Text type="sm-medium" style={pal.textLight}>
+                More
+              </Text>
+              <FontAwesomeIcon
+                icon="angle-right"
+                color={pal.colors.textLight}
+                size={14}
+              />
+            </Link>
+          ) : undefined}
         </PostHider>
-        {item._hasMore ? (
-          <Link
-            style={[
-              styles.loadMore,
-              {borderTopColor: pal.colors.border},
-              pal.view,
-            ]}
-            href={itemHref}
-            title={itemTitle}
-            noFeedback>
-            <Text style={pal.link}>Continue thread...</Text>
-            <FontAwesomeIcon
-              icon="angle-right"
-              style={pal.link as FontAwesomeIconStyle}
-              size={18}
-            />
-          </Link>
-        ) : undefined}
-      </>
+      </PostOuterWrapper>
     )
   }
 })
 
+function PostOuterWrapper({
+  item,
+  hasPrecedingItem,
+  treeView,
+  children,
+}: React.PropsWithChildren<{
+  item: PostThreadItemModel
+  hasPrecedingItem: boolean
+  treeView: boolean
+}>) {
+  const {isMobile} = useWebMediaQueries()
+  const pal = usePalette('default')
+  if (treeView && item._depth > 0) {
+    return (
+      <View
+        style={[
+          pal.view,
+          styles.cursor,
+          {flexDirection: 'row', paddingLeft: 10},
+        ]}>
+        {Array.from(Array(item._depth - 1)).map((_, n: number) => (
+          <View
+            key={`${item.uri}-padding-${n}`}
+            style={{
+              borderLeftWidth: 2,
+              borderLeftColor: pal.colors.border,
+              marginLeft: 19,
+              paddingLeft: isMobile ? 0 : 4,
+            }}
+          />
+        ))}
+        <View style={{flex: 1}}>{children}</View>
+      </View>
+    )
+  }
+  return (
+    <View
+      style={[
+        styles.outer,
+        pal.view,
+        pal.border,
+        item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
+        styles.cursor,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
 function ExpandedPostDetails({
   post,
   needsTranslation,
@@ -600,7 +666,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     flexWrap: 'wrap',
-    paddingBottom: 8,
+    paddingBottom: 4,
     paddingRight: 10,
   },
   postTextLargeContainer: {
@@ -629,11 +695,10 @@ const styles = StyleSheet.create({
   },
   loadMore: {
     flexDirection: 'row',
-    justifyContent: 'space-between',
-    borderTopWidth: 1,
-    paddingLeft: 80,
-    paddingRight: 20,
-    paddingVertical: 12,
+    alignItems: 'center',
+    justifyContent: 'flex-start',
+    gap: 4,
+    paddingHorizontal: 20,
   },
   replyLine: {
     width: 2,
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 6f6286e69..4b2b944f7 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View} from 'react-native'
+import {StyleProp, TextStyle, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Button, ButtonType} from '../util/forms/Button'
 import {useStores} from 'state/index'
@@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({
   followedType = 'default',
   did,
   onToggleFollow,
+  labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
   did: string
   onToggleFollow?: (v: boolean) => void
+  labelStyle?: StyleProp<TextStyle>
 }) {
   const store = useStores()
   const followState = store.me.follows.getFollowState(did)
@@ -28,18 +30,18 @@ export const FollowButton = observer(function FollowButtonImpl({
     const updatedFollowState = await store.me.follows.fetchFollowState(did)
     if (updatedFollowState === FollowState.Following) {
       try {
+        onToggleFollow?.(false)
         await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
         store.me.follows.removeFollow(did)
-        onToggleFollow?.(false)
       } catch (e: any) {
         store.log.error('Failed to delete follow', e)
         Toast.show('An issue occurred, please try again.')
       }
     } else if (updatedFollowState === FollowState.NotFollowing) {
       try {
+        onToggleFollow?.(true)
         const res = await store.agent.follow(did)
         store.me.follows.addFollow(did, res.uri)
-        onToggleFollow?.(true)
       } catch (e: any) {
         store.log.error('Failed to create follow', e)
         Toast.show('An issue occurred, please try again.')
@@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({
       type={
         followState === FollowState.Following ? followedType : unfollowedType
       }
+      labelStyle={labelStyle}
       onPress={onToggleFollowInner}
       label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
+      withLoading={true}
     />
   )
 })
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 8049d2243..076fa1baa 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -7,6 +7,8 @@ import {
   Pressable,
   ViewStyle,
   PressableStateCallbackType,
+  ActivityIndicator,
+  View,
 } from 'react-native'
 import {Text} from '../text/Text'
 import {useTheme} from 'lib/ThemeContext'
@@ -48,17 +50,19 @@ export function Button({
   accessibilityHint,
   accessibilityLabelledBy,
   onAccessibilityEscape,
+  withLoading = false,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
   labelStyle?: StyleProp<TextStyle>
-  onPress?: () => void
+  onPress?: () => void | Promise<void>
   testID?: string
   accessibilityLabel?: string
   accessibilityHint?: string
   accessibilityLabelledBy?: string
   onAccessibilityEscape?: () => void
+  withLoading?: boolean
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -138,13 +142,16 @@ export function Button({
     },
   )
 
+  const [isLoading, setIsLoading] = React.useState(false)
   const onPressWrapped = React.useCallback(
-    (event: Event) => {
+    async (event: Event) => {
       event.stopPropagation()
       event.preventDefault()
-      onPress?.()
+      withLoading && setIsLoading(true)
+      await onPress?.()
+      withLoading && setIsLoading(false)
     },
-    [onPress],
+    [onPress, withLoading],
   )
 
   const getStyle = React.useCallback(
@@ -160,23 +167,35 @@ export function Button({
     [typeOuterStyle, style],
   )
 
+  const renderChildern = React.useCallback(() => {
+    if (!label) {
+      return children
+    }
+
+    return (
+      <View style={styles.labelContainer}>
+        {label && withLoading && isLoading ? (
+          <ActivityIndicator size={12} color={typeLabelStyle.color} />
+        ) : null}
+        <Text type="button" style={[typeLabelStyle, labelStyle]}>
+          {label}
+        </Text>
+      </View>
+    )
+  }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
+
   return (
     <Pressable
       style={getStyle}
       onPress={onPressWrapped}
+      disabled={isLoading}
       testID={testID}
       accessibilityRole="button"
       accessibilityLabel={accessibilityLabel}
       accessibilityHint={accessibilityHint}
       accessibilityLabelledBy={accessibilityLabelledBy}
       onAccessibilityEscape={onAccessibilityEscape}>
-      {label ? (
-        <Text type="button" style={[typeLabelStyle, labelStyle]}>
-          {label}
-        </Text>
-      ) : (
-        children
-      )}
+      {renderChildern}
     </Pressable>
   )
 }
@@ -187,4 +206,8 @@ const styles = StyleSheet.create({
     paddingVertical: 8,
     borderRadius: 24,
   },
+  labelContainer: {
+    flexDirection: 'row',
+    gap: 8,
+  },
 })