about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2023-09-20 11:03:57 -0500
committerEric Bailey <git@esb.lol>2023-09-20 11:03:57 -0500
commit5665968f729b99509d54769f494bbbfc59b4b630 (patch)
treebfad6f82b613699ba3f206d460f0eac50dee6bd4
parent63527493fd8dfb72d21bd50cd2404a5cf2c6e274 (diff)
parentcd96f8dcc8692aec4b9b165cc9f47d7e0b6257df (diff)
downloadvoidsky-5665968f729b99509d54769f494bbbfc59b4b630.tar.zst
Merge remote-tracking branch 'origin' into bnewbold/bump-api-dep
* origin:
  Allow touch at the top of the lightbox (#1489)
  Bump ios build number
  Feeds tab fixes (#1486)
  Nicer 'post processing status' in the composer (#1472)
  Inline createPanResponder (#1483)
  Tree view threads experiment (#1480)
  Make "double tap to zoom" precise across platforms (#1482)
  Onboarding recommended follows (#1457)
  Add thread sort settings (#1475)
-rw-r--r--app.config.js2
-rw-r--r--src/Navigation.tsx14
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/content/post-thread.ts37
-rw-r--r--src/state/models/discovery/onboarding.ts11
-rw-r--r--src/state/models/discovery/suggested-actors.ts19
-rw-r--r--src/state/models/ui/my-feeds.ts8
-rw-r--r--src/state/models/ui/preferences.ts43
-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
-rw-r--r--src/view/index.ts4
-rw-r--r--src/view/screens/CustomFeed.tsx33
-rw-r--r--src/view/screens/Feeds.tsx41
-rw-r--r--src/view/screens/PostThread.tsx1
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx13
-rw-r--r--src/view/screens/PreferencesThreads.tsx173
-rw-r--r--src/view/screens/Profile.tsx4
-rw-r--r--src/view/screens/Settings.tsx22
33 files changed, 1157 insertions, 213 deletions
diff --git a/app.config.js b/app.config.js
index dd9e05486..a7614e8e5 100644
--- a/app.config.js
+++ b/app.config.js
@@ -19,7 +19,7 @@ module.exports = function () {
         backgroundColor: '#ffffff',
       },
       ios: {
-        buildNumber: '1',
+        buildNumber: '2',
         supportsTablet: false,
         bundleIdentifier: 'xyz.blueskyweb.app',
         config: {
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 9bf6ba981..604fca2b9 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -33,6 +33,10 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {router} from './routes'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from './state'
+import {getRoutingInstrumentation} from 'lib/sentry'
+import {bskyTitle} from 'lib/strings/headings'
+import {JSX} from 'react/jsx-runtime'
+import {timeout} from 'lib/async/timeout'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
@@ -62,11 +66,8 @@ import {AppPasswords} from 'view/screens/AppPasswords'
 import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {SavedFeeds} from 'view/screens/SavedFeeds'
-import {getRoutingInstrumentation} from 'lib/sentry'
-import {bskyTitle} from 'lib/strings/headings'
-import {JSX} from 'react/jsx-runtime'
-import {timeout} from 'lib/async/timeout'
 import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
+import {PreferencesThreads} from 'view/screens/PreferencesThreads'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={PreferencesHomeFeed}
         options={{title: title('Home Feed Preferences')}}
       />
+      <Stack.Screen
+        name="PreferencesThreads"
+        component={PreferencesThreads}
+        options={{title: title('Threads Preferences')}}
+      />
     </>
   )
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index cc7a468e9..e2867a707 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
   AppPasswords: undefined
   SavedFeeds: undefined
   PreferencesHomeFeed: undefined
+  PreferencesThreads: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index 7c356eb1b..35266d85b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -23,6 +23,7 @@ export const router = new Router({
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   PreferencesHomeFeed: '/settings/home-feed',
+  PreferencesThreads: '/settings/threads',
   SavedFeeds: '/settings/saved-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 7e3650948..2d3a3d64a 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -241,7 +241,7 @@ export class PostThreadModel {
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
       thread.uri,
     )
-    sortThread(thread)
+    sortThread(thread, this.rootStore.preferences)
     this.thread = thread
   }
 }
@@ -263,11 +263,16 @@ function pruneReplies(post: MaybePost) {
   }
 }
 
+interface SortSettings {
+  threadDefaultSort: string
+  threadFollowedUsersFirst: boolean
+}
+
 type MaybeThreadItem =
   | PostThreadItemModel
   | AppBskyFeedDefs.NotFoundPost
   | AppBskyFeedDefs.BlockedPost
-function sortThread(item: MaybeThreadItem) {
+function sortThread(item: MaybeThreadItem, opts: SortSettings) {
   if ('notFound' in item) {
     return
   }
@@ -296,13 +301,31 @@ function sortThread(item: MaybeThreadItem) {
       if (modScore(a.moderation) !== modScore(b.moderation)) {
         return modScore(a.moderation) - modScore(b.moderation)
       }
-      if (a.post.likeCount === b.post.likeCount) {
-        return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
-      } else {
-        return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
+      if (opts.threadFollowedUsersFirst) {
+        const af = a.post.author.viewer?.following
+        const bf = b.post.author.viewer?.following
+        if (af && !bf) {
+          return -1
+        } else if (!af && bf) {
+          return 1
+        }
+      }
+      if (opts.threadDefaultSort === 'oldest') {
+        return a.post.indexedAt.localeCompare(b.post.indexedAt)
+      } else if (opts.threadDefaultSort === 'newest') {
+        return b.post.indexedAt.localeCompare(a.post.indexedAt)
+      } else if (opts.threadDefaultSort === 'most-likes') {
+        if (a.post.likeCount === b.post.likeCount) {
+          return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
+        } else {
+          return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
+        }
+      } else if (opts.threadDefaultSort === 'random') {
+        return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
       }
+      return b.post.indexedAt.localeCompare(a.post.indexedAt)
     })
-    item.replies.forEach(reply => sortThread(reply))
+    item.replies.forEach(reply => sortThread(reply, opts))
   }
 }
 
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
index 09c9eac04..8ad321ed9 100644
--- a/src/state/models/discovery/onboarding.ts
+++ b/src/state/models/discovery/onboarding.ts
@@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {hasProp} from 'lib/type-guards'
 import {track} from 'lib/analytics/analytics'
+import {SuggestedActorsModel} from './suggested-actors'
 
 export const OnboardingScreenSteps = {
   Welcome: 'Welcome',
   RecommendedFeeds: 'RecommendedFeeds',
+  RecommendedFollows: 'RecommendedFollows',
   Home: 'Home',
 } as const
 
@@ -16,7 +18,11 @@ export class OnboardingModel {
   // state
   step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
 
+  // data
+  suggestedActors: SuggestedActorsModel
+
   constructor(public rootStore: RootStoreModel) {
+    this.suggestedActors = new SuggestedActorsModel(this.rootStore)
     makeAutoObservable(this, {
       rootStore: false,
       hydrate: false,
@@ -56,6 +62,11 @@ export class OnboardingModel {
       this.step = 'RecommendedFeeds'
       return this.step
     } else if (this.step === 'RecommendedFeeds') {
+      this.step = 'RecommendedFollows'
+      // prefetch recommended follows
+      this.suggestedActors.loadMore(true)
+      return this.step
+    } else if (this.step === 'RecommendedFollows') {
       this.finish()
       return this.step
     } else {
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index 0b3d36952..afa5e74e3 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -19,6 +19,7 @@ export class SuggestedActorsModel {
   loadMoreCursor: string | undefined = undefined
   error = ''
   hasMore = false
+  lastInsertedAtIndex = -1
 
   // data
   suggestions: SuggestedActor[] = []
@@ -110,6 +111,24 @@ export class SuggestedActorsModel {
     }
   })
 
+  async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
+    // fetch suggestions
+    const res =
+      await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
+        actor: actor,
+      })
+    const {suggestions: moreSuggestions} = res.data
+    this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
+    // dedupe
+    const toInsert = moreSuggestions.filter(
+      s => !this.suggestions.find(s2 => s2.did === s.did),
+    )
+    //  insert
+    this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
+    // update index
+    this.lastInsertedAtIndex = indexToInsertAt
+  }
+
   // state transitions
   // =
 
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
index f9ad06f77..6b017709e 100644
--- a/src/state/models/ui/my-feeds.ts
+++ b/src/state/models/ui/my-feeds.ts
@@ -10,6 +10,11 @@ export type MyFeedsItem =
     }
   | {
       _reactKey: string
+      type: 'saved-feeds-loading'
+      numItems: number
+    }
+  | {
+      _reactKey: string
       type: 'discover-feeds-loading'
     }
   | {
@@ -91,7 +96,8 @@ export class MyFeedsUIModel {
     if (this.saved.isLoading) {
       items.push({
         _reactKey: '__saved_feeds_loading__',
-        type: 'spinner',
+        type: 'saved-feeds-loading',
+        numItems: this.rootStore.preferences.savedFeeds.length || 3,
       })
     } else if (this.saved.hasError) {
       items.push({
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 3790b3a92..5c6ea230b 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -25,6 +25,7 @@ const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
 const DEFAULT_LANG_CODES = (deviceLocales || [])
   .concat(['en', 'ja', 'pt', 'de'])
   .slice(0, 6)
+const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -55,6 +56,9 @@ export class PreferencesModel {
   homeFeedRepostsEnabled: boolean = true
   homeFeedQuotePostsEnabled: boolean = true
   homeFeedMergeFeedEnabled: boolean = false
+  threadDefaultSort: string = 'oldest'
+  threadFollowedUsersFirst: boolean = true
+  threadTreeViewEnabled: boolean = false
   requireAltTextEnabled: boolean = false
 
   // used to linearize async modifications to state
@@ -86,6 +90,9 @@ export class PreferencesModel {
       homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
       homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
       homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
+      threadDefaultSort: this.threadDefaultSort,
+      threadFollowedUsersFirst: this.threadFollowedUsersFirst,
+      threadTreeViewEnabled: this.threadTreeViewEnabled,
       requireAltTextEnabled: this.requireAltTextEnabled,
     }
   }
@@ -189,6 +196,28 @@ export class PreferencesModel {
       ) {
         this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
       }
+      // check if thread sort order is set in preferences, then hydrate
+      if (
+        hasProp(v, 'threadDefaultSort') &&
+        typeof v.threadDefaultSort === 'string' &&
+        THREAD_SORT_VALUES.includes(v.threadDefaultSort)
+      ) {
+        this.threadDefaultSort = v.threadDefaultSort
+      }
+      // check if thread followed-users-first is enabled in preferences, then hydrate
+      if (
+        hasProp(v, 'threadFollowedUsersFirst') &&
+        typeof v.threadFollowedUsersFirst === 'boolean'
+      ) {
+        this.threadFollowedUsersFirst = v.threadFollowedUsersFirst
+      }
+      // check if thread treeview is enabled in preferences, then hydrate
+      if (
+        hasProp(v, 'threadTreeViewEnabled') &&
+        typeof v.threadTreeViewEnabled === 'boolean'
+      ) {
+        this.threadTreeViewEnabled = v.threadTreeViewEnabled
+      }
       // check if requiring alt text is enabled in preferences, then hydrate
       if (
         hasProp(v, 'requireAltTextEnabled') &&
@@ -494,6 +523,20 @@ export class PreferencesModel {
     this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
   }
 
+  setThreadDefaultSort(v: string) {
+    if (THREAD_SORT_VALUES.includes(v)) {
+      this.threadDefaultSort = v
+    }
+  }
+
+  toggleThreadFollowedUsersFirst() {
+    this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst
+  }
+
+  toggleThreadTreeViewEnabled() {
+    this.threadTreeViewEnabled = !this.threadTreeViewEnabled
+  }
+
   toggleRequireAltTextEnabled() {
     this.requireAltTextEnabled = !this.requireAltTextEnabled
   }
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,
+  },
 })
diff --git a/src/view/index.ts b/src/view/index.ts
index 2fdc34e7b..07848aa8f 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -36,6 +36,7 @@ import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
 import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
+import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
 import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
@@ -44,6 +45,7 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
 import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
 import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
+import {faFlask} from '@fortawesome/free-solid-svg-icons'
 import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@@ -134,6 +136,7 @@ export function setup() {
     farClone,
     faComment,
     faCommentSlash,
+    faComments,
     faCompass,
     faEllipsis,
     faEnvelope,
@@ -142,6 +145,7 @@ export function setup() {
     farEyeSlash,
     faFaceSmile,
     faFire,
+    faFlask,
     faFloppyDisk,
     faGear,
     faGlobe,
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index eaa21f292..f8ceda940 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -185,6 +185,17 @@ export const CustomFeedScreenInner = observer(
       })
     }, [store, currentFeed])
 
+    const onPressAbout = React.useCallback(() => {
+      store.shell.openModal({
+        name: 'confirm',
+        title: currentFeed?.displayName || '',
+        message:
+          currentFeed?.data.description || 'This feed has no description.',
+        confirmBtnText: 'Close',
+        onPressConfirm() {},
+      })
+    }, [store, currentFeed])
+
     const onPressViewAuthor = React.useCallback(() => {
       navigation.navigate('Profile', {name: handleOrDid})
     }, [handleOrDid, navigation])
@@ -233,7 +244,21 @@ export const CustomFeedScreenInner = observer(
     }, [store, onSoftReset, isScreenFocused])
 
     const dropdownItems: DropdownItem[] = React.useMemo(() => {
-      let items: DropdownItem[] = [
+      return [
+        currentFeed
+          ? {
+              testID: 'feedHeaderDropdownAboutBtn',
+              label: 'About this feed',
+              onPress: onPressAbout,
+              icon: {
+                ios: {
+                  name: 'info.circle',
+                },
+                android: '',
+                web: 'info',
+              },
+            }
+          : undefined,
         {
           testID: 'feedHeaderDropdownViewAuthorBtn',
           label: 'View author',
@@ -292,10 +317,10 @@ export const CustomFeedScreenInner = observer(
             web: 'share',
           },
         },
-      ]
-      return items
+      ].filter(Boolean) as DropdownItem[]
     }, [
-      currentFeed?.isSaved,
+      currentFeed,
+      onPressAbout,
       onToggleSaved,
       onPressReport,
       onPressShare,
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index d2c4a6d2d..6ca24bae9 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -16,7 +16,10 @@ import {ComposeIcon2, CogIcon} from 'lib/icons'
 import {s} from 'lib/styles'
 import {SearchInput} from 'view/com/util/forms/SearchInput'
 import {UserAvatar} from 'view/com/util/UserAvatar'
-import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {
+  LoadingPlaceholder,
+  FeedFeedLoadingPlaceholder,
+} from 'view/com/util/LoadingPlaceholder'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
@@ -42,7 +45,12 @@ export const FeedsScreen = withAuthRequired(
       React.useCallback(() => {
         store.shell.setMinimalShellMode(false)
         myFeeds.setup()
-      }, [store.shell, myFeeds]),
+
+        const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh())
+        return () => {
+          softResetSub.remove()
+        }
+      }, [store, myFeeds]),
     )
 
     const onPressCompose = React.useCallback(() => {
@@ -119,6 +127,14 @@ export const FeedsScreen = withAuthRequired(
             )
           }
           return <View />
+        } else if (item.type === 'saved-feeds-loading') {
+          return (
+            <>
+              {Array.from(Array(item.numItems)).map((_, i) => (
+                <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} />
+              ))}
+            </>
+          )
         } else if (item.type === 'saved-feed') {
           return (
             <SavedFeed
@@ -262,10 +278,7 @@ function SavedFeed({
       asAnchor
       anchorNoUnderline>
       <UserAvatar type="algo" size={28} avatar={avatar} />
-      <Text
-        type={isMobile ? 'lg' : 'lg-medium'}
-        style={[pal.text, s.flex1]}
-        numberOfLines={1}>
+      <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}>
         {displayName}
       </Text>
       {isMobile && (
@@ -279,6 +292,22 @@ function SavedFeed({
   )
 }
 
+function SavedFeedLoadingPlaceholder() {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <View
+      style={[
+        pal.border,
+        styles.savedFeed,
+        isMobile && styles.savedFeedMobile,
+      ]}>
+      <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
+      <LoadingPlaceholder width={140} height={12} />
+    </View>
+  )
+}
+
 const styles = StyleSheet.create({
   container: {
     flex: 1,
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index a6aafa530..90b98d052 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
           uri={uri}
           view={view}
           onPressReply={onPressReply}
+          treeView={store.preferences.threadTreeViewEnabled}
         />
       </View>
       {isMobile && (
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 81bdfc95e..404d006f8 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -1,6 +1,7 @@
 import React, {useState} from 'react'
 import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Slider} from '@miblanchard/react-native-slider'
 import {Text} from '../com/util/text/Text'
 import {useStores} from 'state/index'
@@ -66,7 +67,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
       ]}>
       <ViewHeader title="Home Feed Preferences" showOnDesktop />
       <View
-        style={[styles.titleSection, isTabletOrDesktop && {paddingTop: 20}]}>
+        style={[
+          styles.titleSection,
+          isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
+        ]}>
         <Text type="xl" style={[pal.textLight, styles.description]}>
           Fine-tune the content you see on your home screen.
         </Text>
@@ -155,11 +159,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
 
           <View style={[pal.viewLight, styles.card]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              Show Posts from My Feeds (Experimental)
+              <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show
+              Posts from My Feeds
             </Text>
             <Text style={[pal.text, s.pb10]}>
               Set this setting to "Yes" to show samples of your saved feeds in
-              your following feed.
+              your following feed. This is an experimental feature.
             </Text>
             <ToggleButton
               type="default-light"
@@ -175,7 +180,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
         style={[
           styles.btnContainer,
           !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
-          pal.borderDark,
+          pal.border,
         ]}>
         <TouchableOpacity
           testID="confirmBtn"
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
new file mode 100644
index 000000000..74b28267d
--- /dev/null
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -0,0 +1,173 @@
+import React from 'react'
+import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../com/util/text/Text'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {RadioGroup} from 'view/com/util/forms/RadioGroup'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
+export const PreferencesThreads = observer(function PreferencesThreadsImpl({
+  navigation,
+}: Props) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  return (
+    <CenteredView
+      testID="preferencesThreadsScreen"
+      style={[
+        pal.view,
+        pal.border,
+        styles.container,
+        isTabletOrDesktop && styles.desktopContainer,
+      ]}>
+      <ViewHeader title="Thread Preferences" showOnDesktop />
+      <View
+        style={[
+          styles.titleSection,
+          isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
+        ]}>
+        <Text type="xl" style={[pal.textLight, styles.description]}>
+          Fine-tune the discussion threads.
+        </Text>
+      </View>
+
+      <ScrollView>
+        <View style={styles.cardsContainer}>
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Sort Replies
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Sort replies to the same post by:
+            </Text>
+            <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}>
+              <RadioGroup
+                type="default-light"
+                items={[
+                  {key: 'oldest', label: 'Oldest replies first'},
+                  {key: 'newest', label: 'Newest replies first'},
+                  {key: 'most-likes', label: 'Most-liked replies first'},
+                  {key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
+                ]}
+                onSelect={store.preferences.setThreadDefaultSort}
+                initialSelection={store.preferences.threadDefaultSort}
+              />
+            </View>
+          </View>
+
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Prioritize Your Follows
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Show replies by people you follow before all other replies.
+            </Text>
+            <ToggleButton
+              type="default-light"
+              label={store.preferences.threadFollowedUsersFirst ? 'Yes' : 'No'}
+              isSelected={store.preferences.threadFollowedUsersFirst}
+              onPress={store.preferences.toggleThreadFollowedUsersFirst}
+            />
+          </View>
+
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded
+              Mode
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Set this setting to "Yes" to show replies in a threaded view. This
+              is an experimental feature.
+            </Text>
+            <ToggleButton
+              type="default-light"
+              label={store.preferences.threadTreeViewEnabled ? 'Yes' : 'No'}
+              isSelected={store.preferences.threadTreeViewEnabled}
+              onPress={store.preferences.toggleThreadTreeViewEnabled}
+            />
+          </View>
+        </View>
+      </ScrollView>
+
+      <View
+        style={[
+          styles.btnContainer,
+          !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
+          pal.border,
+        ]}>
+        <TouchableOpacity
+          testID="confirmBtn"
+          onPress={() => {
+            navigation.canGoBack()
+              ? navigation.goBack()
+              : navigation.navigate('Settings')
+          }}
+          style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]}
+          accessibilityRole="button"
+          accessibilityLabel="Confirm"
+          accessibilityHint="">
+          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+        </TouchableOpacity>
+      </View>
+    </CenteredView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: 90,
+  },
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+    paddingBottom: 40,
+  },
+  titleSection: {
+    paddingBottom: 30,
+  },
+  title: {
+    textAlign: 'center',
+    marginBottom: 5,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 32,
+  },
+  cardsContainer: {
+    paddingHorizontal: 20,
+  },
+  card: {
+    padding: 16,
+    borderRadius: 10,
+    marginBottom: 20,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnDesktop: {
+    marginHorizontal: 'auto',
+    paddingHorizontal: 80,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+  dimmed: {
+    opacity: 0.3,
+  },
+})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 241bae1ed..efcb588f6 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -187,7 +187,9 @@ export const ProfileScreen = withAuthRequired(
               />
             )
           } else if (item instanceof CustomFeedModel) {
-            return <CustomFeed item={item} showSaveBtn showLikes />
+            return (
+              <CustomFeed item={item} showSaveBtn showLikes showDescription />
+            )
           }
           // if section is posts or posts & replies
         } else {
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 761f50d0a..1ff5f58ff 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -180,6 +180,10 @@ export const SettingsScreen = withAuthRequired(
       navigation.navigate('PreferencesHomeFeed')
     }, [navigation])
 
+    const openThreadsPreferences = React.useCallback(() => {
+      navigation.navigate('PreferencesThreads')
+    }, [navigation])
+
     const onPressAppPasswords = React.useCallback(() => {
       navigation.navigate('AppPasswords')
     }, [navigation])
@@ -421,6 +425,24 @@ export const SettingsScreen = withAuthRequired(
             </Text>
           </TouchableOpacity>
           <TouchableOpacity
+            testID="preferencesThreadsButton"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            onPress={openThreadsPreferences}
+            accessibilityRole="button"
+            accessibilityHint=""
+            accessibilityLabel="Opens the threads preferences">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon={['far', 'comments']}
+                style={pal.text as FontAwesomeIconStyle}
+                size={18}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Thread Preferences
+            </Text>
+          </TouchableOpacity>
+          <TouchableOpacity
             testID="savedFeedsBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             accessibilityHint="My Saved Feeds"