about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/auth/LoggedOut.tsx75
-rw-r--r--src/view/com/auth/Onboarding.tsx35
-rw-r--r--src/view/com/auth/SplashScreen.tsx70
-rw-r--r--src/view/com/auth/SplashScreen.web.tsx109
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx140
-rw-r--r--src/view/com/auth/create/Policies.tsx6
-rw-r--r--src/view/com/auth/create/Step1.tsx73
-rw-r--r--src/view/com/auth/create/Step2.tsx82
-rw-r--r--src/view/com/auth/create/Step3.tsx34
-rw-r--r--src/view/com/auth/create/state.ts242
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx158
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx197
-rw-r--r--src/view/com/auth/login/Login.tsx948
-rw-r--r--src/view/com/auth/login/LoginForm.tsx290
-rw-r--r--src/view/com/auth/login/PasswordUpdatedForm.tsx48
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx179
-rw-r--r--src/view/com/auth/login/styles.ts118
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx120
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx55
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx184
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx114
-rw-r--r--src/view/com/auth/onboarding/WelcomeDesktop.tsx7
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx37
-rw-r--r--src/view/com/auth/withAuthRequired.tsx78
-rw-r--r--src/view/com/composer/Composer.tsx102
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx5
-rw-r--r--src/view/com/composer/Prompt.tsx15
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx16
-rw-r--r--src/view/com/composer/photos/Gallery.tsx47
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx11
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx5
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx46
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx21
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx10
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx39
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx11
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts23
-rw-r--r--src/view/com/feeds/FeedPage.tsx194
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx144
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx222
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx6
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx1
-rw-r--r--src/view/com/lightbox/Lightbox.tsx50
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx48
-rw-r--r--src/view/com/lists/ListCard.tsx6
-rw-r--r--src/view/com/lists/ListMembers.tsx (renamed from src/view/com/lists/ListItems.tsx)114
-rw-r--r--src/view/com/lists/MyLists.tsx (renamed from src/view/com/lists/ListsList.tsx)90
-rw-r--r--src/view/com/lists/ProfileLists.tsx226
-rw-r--r--src/view/com/modals/AddAppPasswords.tsx72
-rw-r--r--src/view/com/modals/AltImage.tsx23
-rw-r--r--src/view/com/modals/BirthDateSettings.tsx72
-rw-r--r--src/view/com/modals/ChangeEmail.tsx83
-rw-r--r--src/view/com/modals/ChangeHandle.tsx210
-rw-r--r--src/view/com/modals/Confirm.tsx15
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx322
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx72
-rw-r--r--src/view/com/modals/DeleteAccount.tsx65
-rw-r--r--src/view/com/modals/EditImage.tsx27
-rw-r--r--src/view/com/modals/EditProfile.tsx115
-rw-r--r--src/view/com/modals/InviteCodes.tsx183
-rw-r--r--src/view/com/modals/LinkWarning.tsx36
-rw-r--r--src/view/com/modals/ListAddRemoveUsers.tsx (renamed from src/view/com/modals/ListAddUser.tsx)149
-rw-r--r--src/view/com/modals/Modal.tsx47
-rw-r--r--src/view/com/modals/Modal.web.tsx34
-rw-r--r--src/view/com/modals/ModerationDetails.tsx8
-rw-r--r--src/view/com/modals/ProfilePreview.tsx79
-rw-r--r--src/view/com/modals/Repost.tsx21
-rw-r--r--src/view/com/modals/SelfLabel.tsx42
-rw-r--r--src/view/com/modals/ServerInput.tsx33
-rw-r--r--src/view/com/modals/SwitchAccount.tsx147
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx324
-rw-r--r--src/view/com/modals/VerifyEmail.tsx68
-rw-r--r--src/view/com/modals/Waitlist.tsx31
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx23
-rw-r--r--src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx9
-rw-r--r--src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx35
-rw-r--r--src/view/com/modals/lang-settings/LanguageToggle.tsx21
-rw-r--r--src/view/com/modals/lang-settings/PostLanguagesSettings.tsx44
-rw-r--r--src/view/com/modals/report/InputIssueDetails.tsx11
-rw-r--r--src/view/com/modals/report/Modal.tsx26
-rw-r--r--src/view/com/modals/report/SendReportButton.tsx9
-rw-r--r--src/view/com/notifications/Feed.tsx173
-rw-r--r--src/view/com/notifications/FeedItem.tsx185
-rw-r--r--src/view/com/notifications/InvitedUsers.tsx114
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx110
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx120
-rw-r--r--src/view/com/pager/Pager.tsx11
-rw-r--r--src/view/com/pager/Pager.web.tsx13
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx315
-rw-r--r--src/view/com/pager/TabBar.tsx2
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx96
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx100
-rw-r--r--src/view/com/post-thread/PostThread.tsx545
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx463
-rw-r--r--src/view/com/post/Post.tsx271
-rw-r--r--src/view/com/posts/Feed.tsx182
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx198
-rw-r--r--src/view/com/posts/FeedItem.tsx273
-rw-r--r--src/view/com/posts/FeedSlice.tsx95
-rw-r--r--src/view/com/profile/FollowButton.tsx66
-rw-r--r--src/view/com/profile/ProfileCard.tsx108
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx106
-rw-r--r--src/view/com/profile/ProfileFollows.tsx99
-rw-r--r--src/view/com/profile/ProfileHeader.tsx505
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx83
-rw-r--r--src/view/com/profile/ProfileSubpageHeader.tsx22
-rw-r--r--src/view/com/search/HeaderWithInput.tsx181
-rw-r--r--src/view/com/search/SearchResults.tsx150
-rw-r--r--src/view/com/search/Suggestions.tsx265
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx22
-rw-r--r--src/view/com/util/AccountDropdownBtn.tsx16
-rw-r--r--src/view/com/util/BottomSheetCustomBackdrop.tsx3
-rw-r--r--src/view/com/util/ErrorBoundary.tsx5
-rw-r--r--src/view/com/util/Link.tsx63
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx28
-rw-r--r--src/view/com/util/PostMeta.tsx5
-rw-r--r--src/view/com/util/PostSandboxWarning.tsx6
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx5
-rw-r--r--src/view/com/util/TimeElapsed.tsx12
-rw-r--r--src/view/com/util/Toast.tsx6
-rw-r--r--src/view/com/util/Toast.web.tsx9
-rw-r--r--src/view/com/util/UserAvatar.tsx27
-rw-r--r--src/view/com/util/UserBanner.tsx19
-rw-r--r--src/view/com/util/UserInfoText.tsx33
-rw-r--r--src/view/com/util/UserPreviewLink.tsx6
-rw-r--r--src/view/com/util/ViewHeader.tsx9
-rw-r--r--src/view/com/util/Views.web.tsx4
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx5
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx8
-rw-r--r--src/view/com/util/fab/FABInner.tsx9
-rw-r--r--src/view/com/util/forms/Button.tsx4
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx5
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx153
-rw-r--r--src/view/com/util/forms/SearchInput.tsx7
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx9
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx4
-rw-r--r--src/view/com/util/layouts/Breakpoints.web.tsx6
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx5
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx13
-rw-r--r--src/view/com/util/moderation/PostAlerts.tsx13
-rw-r--r--src/view/com/util/moderation/PostHider.tsx11
-rw-r--r--src/view/com/util/moderation/ProfileHeaderAlerts.tsx13
-rw-r--r--src/view/com/util/moderation/ScreenHider.tsx27
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx204
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx14
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx67
-rw-r--r--src/view/com/util/post-embeds/CustomFeedEmbed.tsx38
-rw-r--r--src/view/com/util/post-embeds/ListEmbed.tsx5
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/com/util/post-embeds/index.tsx30
-rw-r--r--src/view/screens/AppPasswords.tsx211
-rw-r--r--src/view/screens/CommunityGuidelines.tsx19
-rw-r--r--src/view/screens/CopyrightPolicy.tsx19
-rw-r--r--src/view/screens/Feeds.tsx695
-rw-r--r--src/view/screens/Home.tsx284
-rw-r--r--src/view/screens/LanguageSettings.tsx153
-rw-r--r--src/view/screens/Lists.tsx138
-rw-r--r--src/view/screens/Log.tsx10
-rw-r--r--src/view/screens/Moderation.tsx185
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx234
-rw-r--r--src/view/screens/ModerationModlists.tsx137
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx231
-rw-r--r--src/view/screens/NotFound.tsx11
-rw-r--r--src/view/screens/Notifications.tsx241
-rw-r--r--src/view/screens/PostLikedBy.tsx10
-rw-r--r--src/view/screens/PostRepostedBy.tsx10
-rw-r--r--src/view/screens/PostThread.tsx161
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx208
-rw-r--r--src/view/screens/PreferencesThreads.tsx170
-rw-r--r--src/view/screens/PrivacyPolicy.tsx19
-rw-r--r--src/view/screens/Profile.tsx668
-rw-r--r--src/view/screens/ProfileFeed.tsx780
-rw-r--r--src/view/screens/ProfileFeedLikedBy.tsx10
-rw-r--r--src/view/screens/ProfileFollowers.tsx10
-rw-r--r--src/view/screens/ProfileFollows.tsx10
-rw-r--r--src/view/screens/ProfileList.tsx640
-rw-r--r--src/view/screens/SavedFeeds.tsx380
-rw-r--r--src/view/screens/Search.tsx1
-rw-r--r--src/view/screens/Search.web.tsx76
-rw-r--r--src/view/screens/Search/Search.tsx658
-rw-r--r--src/view/screens/Search/index.tsx3
-rw-r--r--src/view/screens/Search/index.web.tsx3
-rw-r--r--src/view/screens/SearchMobile.tsx203
-rw-r--r--src/view/screens/Settings.tsx1315
-rw-r--r--src/view/screens/Support.tsx23
-rw-r--r--src/view/screens/TermsOfService.tsx7
-rw-r--r--src/view/shell/Composer.tsx27
-rw-r--r--src/view/shell/Composer.web.tsx47
-rw-r--r--src/view/shell/Drawer.tsx367
-rw-r--r--src/view/shell/NavSignupCard.tsx61
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx186
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx77
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx150
-rw-r--r--src/view/shell/desktop/Feeds.tsx62
-rw-r--r--src/view/shell/desktop/LeftNav.tsx295
-rw-r--r--src/view/shell/desktop/RightNav.tsx136
-rw-r--r--src/view/shell/desktop/Search.tsx209
-rw-r--r--src/view/shell/index.tsx36
-rw-r--r--src/view/shell/index.web.tsx49
201 files changed, 13045 insertions, 9682 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index 3e2c9c1bf..030ae68b1 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -1,15 +1,19 @@
 import React from 'react'
-import {SafeAreaView} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {View, Pressable} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {isIOS} from 'platform/detection'
 import {Login} from 'view/com/auth/login/Login'
 import {CreateAccount} from 'view/com/auth/create/CreateAccount'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {SplashScreen} from './SplashScreen'
 import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 
 enum ScreenState {
   S_LoginOrCreateAccount,
@@ -17,35 +21,66 @@ enum ScreenState {
   S_CreateAccount,
 }
 
-export const LoggedOut = observer(function LoggedOutImpl() {
+export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
+  const {_} = useLingui()
   const pal = usePalette('default')
-  const store = useStores()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {screen} = useAnalytics()
   const [screenState, setScreenState] = React.useState<ScreenState>(
     ScreenState.S_LoginOrCreateAccount,
   )
+  const {isMobile} = useWebMediaQueries()
 
   React.useEffect(() => {
     screen('Login')
     setMinimalShellMode(true)
   }, [screen, setMinimalShellMode])
 
-  if (
-    store.session.isResumingSession ||
-    screenState === ScreenState.S_LoginOrCreateAccount
-  ) {
-    return (
-      <SplashScreen
-        onPressSignin={() => setScreenState(ScreenState.S_Login)}
-        onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
-      />
-    )
-  }
-
   return (
-    <SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}>
+    <View
+      testID="noSessionView"
+      style={[
+        s.hContentRegion,
+        pal.view,
+        {
+          // only needed if dismiss button is present
+          paddingTop: onDismiss && isMobile ? 40 : 0,
+        },
+      ]}>
       <ErrorBoundary>
+        {onDismiss && (
+          <Pressable
+            accessibilityHint={_(msg`Go back`)}
+            accessibilityLabel={_(msg`Go back`)}
+            accessibilityRole="button"
+            style={{
+              position: 'absolute',
+              top: isIOS ? 0 : 20,
+              right: 20,
+              padding: 10,
+              zIndex: 100,
+              backgroundColor: pal.text.color,
+              borderRadius: 100,
+            }}
+            onPress={onDismiss}>
+            <FontAwesomeIcon
+              icon="x"
+              size={12}
+              style={{
+                color: String(pal.textInverted.color),
+              }}
+            />
+          </Pressable>
+        )}
+
+        {screenState === ScreenState.S_LoginOrCreateAccount ? (
+          <SplashScreen
+            onPressSignin={() => setScreenState(ScreenState.S_Login)}
+            onPressCreateAccount={() =>
+              setScreenState(ScreenState.S_CreateAccount)
+            }
+          />
+        ) : undefined}
         {screenState === ScreenState.S_Login ? (
           <Login
             onPressBack={() =>
@@ -61,6 +96,6 @@ export const LoggedOut = observer(function LoggedOutImpl() {
           />
         ) : undefined}
       </ErrorBoundary>
-    </SafeAreaView>
+    </View>
   )
-})
+}
diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx
index bec1dc236..bdb7f27c8 100644
--- a/src/view/com/auth/Onboarding.tsx
+++ b/src/view/com/auth/Onboarding.tsx
@@ -1,40 +1,51 @@
 import React from 'react'
-import {SafeAreaView} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {SafeAreaView, Platform} from 'react-native'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s} from 'lib/styles'
 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'
 import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
+import {useOnboardingState, useOnboardingDispatch} from '#/state/shell'
 
-export const Onboarding = observer(function OnboardingImpl() {
+export function Onboarding() {
   const pal = usePalette('default')
-  const store = useStores()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const onboardingState = useOnboardingState()
+  const onboardingDispatch = useOnboardingDispatch()
 
   React.useEffect(() => {
     setMinimalShellMode(true)
   }, [setMinimalShellMode])
 
-  const next = () => store.onboarding.next()
-  const skip = () => store.onboarding.skip()
+  const next = () => onboardingDispatch({type: 'next'})
+  const skip = () => onboardingDispatch({type: 'skip'})
 
   return (
-    <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
+    <SafeAreaView
+      testID="onboardingView"
+      style={[
+        s.hContentRegion,
+        pal.view,
+        // @ts-ignore web only -esb
+        Platform.select({
+          web: {
+            height: '100vh',
+          },
+        }),
+      ]}>
       <ErrorBoundary>
-        {store.onboarding.step === 'Welcome' && (
+        {onboardingState.step === 'Welcome' && (
           <Welcome skip={skip} next={next} />
         )}
-        {store.onboarding.step === 'RecommendedFeeds' && (
+        {onboardingState.step === 'RecommendedFeeds' && (
           <RecommendedFeeds next={next} />
         )}
-        {store.onboarding.step === 'RecommendedFollows' && (
+        {onboardingState.step === 'RecommendedFollows' && (
           <RecommendedFollows next={next} />
         )}
       </ErrorBoundary>
     </SafeAreaView>
   )
-})
+}
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
index 67453f111..d88627f65 100644
--- a/src/view/com/auth/SplashScreen.tsx
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
-import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Text} from 'view/com/util/text/Text'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {CenteredView} from '../util/Views'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export const SplashScreen = ({
   onPressSignin,
@@ -14,40 +16,44 @@ export const SplashScreen = ({
   onPressCreateAccount: () => void
 }) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
+
   return (
     <CenteredView style={[styles.container, pal.view]}>
-      <SafeAreaView testID="noSessionView" style={styles.container}>
-        <ErrorBoundary>
-          <View style={styles.hero}>
-            <Text style={[styles.title, pal.link]}>Bluesky</Text>
-            <Text style={[styles.subtitle, pal.textLight]}>
-              See what's next
+      <ErrorBoundary>
+        <View style={styles.hero}>
+          <Text style={[styles.title, pal.link]}>
+            <Trans>Bluesky</Trans>
+          </Text>
+          <Text style={[styles.subtitle, pal.textLight]}>
+            <Trans>See what's next</Trans>
+          </Text>
+        </View>
+        <View testID="signinOrCreateAccount" style={styles.btns}>
+          <TouchableOpacity
+            testID="createAccountButton"
+            style={[styles.btn, {backgroundColor: colors.blue3}]}
+            onPress={onPressCreateAccount}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Create new account`)}
+            accessibilityHint="Opens flow to create a new Bluesky account">
+            <Text style={[s.white, styles.btnLabel]}>
+              <Trans>Create a new account</Trans>
+            </Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            testID="signInButton"
+            style={[styles.btn, pal.btn]}
+            onPress={onPressSignin}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Sign in`)}
+            accessibilityHint="Opens flow to sign into your existing Bluesky account">
+            <Text style={[pal.text, styles.btnLabel]}>
+              <Trans>Sign In</Trans>
             </Text>
-          </View>
-          <View testID="signinOrCreateAccount" style={styles.btns}>
-            <TouchableOpacity
-              testID="createAccountButton"
-              style={[styles.btn, {backgroundColor: colors.blue3}]}
-              onPress={onPressCreateAccount}
-              accessibilityRole="button"
-              accessibilityLabel="Create new account"
-              accessibilityHint="Opens flow to create a new Bluesky account">
-              <Text style={[s.white, styles.btnLabel]}>
-                Create a new account
-              </Text>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="signInButton"
-              style={[styles.btn, pal.btn]}
-              onPress={onPressSignin}
-              accessibilityRole="button"
-              accessibilityLabel="Sign in"
-              accessibilityHint="Opens flow to sign into your existing Bluesky account">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
-            </TouchableOpacity>
-          </View>
-        </ErrorBoundary>
-      </SafeAreaView>
+          </TouchableOpacity>
+        </View>
+      </ErrorBoundary>
     </CenteredView>
   )
 }
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
index cef9618ef..08cf701da 100644
--- a/src/view/com/auth/SplashScreen.web.tsx
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from 'view/com/util/text/Text'
 import {TextLink} from '../util/Link'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -8,11 +9,14 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {CenteredView} from '../util/Views'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans} from '@lingui/macro'
 
 export const SplashScreen = ({
+  onDismiss,
   onPressSignin,
   onPressCreateAccount,
 }: {
+  onDismiss?: () => void
   onPressSignin: () => void
   onPressCreateAccount: () => void
 }) => {
@@ -22,45 +26,70 @@ export const SplashScreen = ({
   const isMobileWeb = isWeb && isTabletOrMobile
 
   return (
-    <CenteredView style={[styles.container, pal.view]}>
-      <View
-        testID="noSessionView"
-        style={[
-          styles.containerInner,
-          isMobileWeb && styles.containerInnerMobile,
-          pal.border,
-        ]}>
-        <ErrorBoundary>
-          <Text style={isMobileWeb ? styles.titleMobile : styles.title}>
-            Bluesky
-          </Text>
-          <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
-            See what's next
-          </Text>
-          <View testID="signinOrCreateAccount" style={styles.btns}>
-            <TouchableOpacity
-              testID="createAccountButton"
-              style={[styles.btn, {backgroundColor: colors.blue3}]}
-              onPress={onPressCreateAccount}
-              // TODO: web accessibility
-              accessibilityRole="button">
-              <Text style={[s.white, styles.btnLabel]}>
-                Create a new account
-              </Text>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="signInButton"
-              style={[styles.btn, pal.btn]}
-              onPress={onPressSignin}
-              // TODO: web accessibility
-              accessibilityRole="button">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
-            </TouchableOpacity>
-          </View>
-        </ErrorBoundary>
-      </View>
-      <Footer styles={styles} />
-    </CenteredView>
+    <>
+      {onDismiss && (
+        <Pressable
+          accessibilityRole="button"
+          style={{
+            position: 'absolute',
+            top: 20,
+            right: 20,
+            padding: 20,
+            zIndex: 100,
+          }}
+          onPress={onDismiss}>
+          <FontAwesomeIcon
+            icon="x"
+            size={24}
+            style={{
+              color: String(pal.text.color),
+            }}
+          />
+        </Pressable>
+      )}
+
+      <CenteredView style={[styles.container, pal.view]}>
+        <View
+          testID="noSessionView"
+          style={[
+            styles.containerInner,
+            isMobileWeb && styles.containerInnerMobile,
+            pal.border,
+          ]}>
+          <ErrorBoundary>
+            <Text style={isMobileWeb ? styles.titleMobile : styles.title}>
+              Bluesky
+            </Text>
+            <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
+              See what's next
+            </Text>
+            <View testID="signinOrCreateAccount" style={styles.btns}>
+              <TouchableOpacity
+                testID="createAccountButton"
+                style={[styles.btn, {backgroundColor: colors.blue3}]}
+                onPress={onPressCreateAccount}
+                // TODO: web accessibility
+                accessibilityRole="button">
+                <Text style={[s.white, styles.btnLabel]}>
+                  Create a new account
+                </Text>
+              </TouchableOpacity>
+              <TouchableOpacity
+                testID="signInButton"
+                style={[styles.btn, pal.btn]}
+                onPress={onPressSignin}
+                // TODO: web accessibility
+                accessibilityRole="button">
+                <Text style={[pal.text, styles.btnLabel]}>
+                  <Trans>Sign In</Trans>
+                </Text>
+              </TouchableOpacity>
+            </View>
+          </ErrorBoundary>
+        </View>
+        <Footer styles={styles} />
+      </CenteredView>
+    </>
   )
 }
 
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 1d64cc067..ab6d34584 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -7,78 +7,134 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
-import {CreateAccountModel} from 'state/models/ui/create-account'
 import {usePalette} from 'lib/hooks/usePalette'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useOnboardingDispatch} from '#/state/shell'
+import {useSessionApi} from '#/state/session'
+import {useCreateAccount, submit} from './state'
+import {useServiceQuery} from '#/state/queries/service'
+import {
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+  DEFAULT_PROD_FEEDS,
+} from '#/state/queries/preferences'
+import {IS_PROD} from '#/lib/constants'
 
 import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
 
-export const CreateAccount = observer(function CreateAccountImpl({
-  onPressBack,
-}: {
-  onPressBack: () => void
-}) {
+export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {track, screen} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const model = React.useMemo(() => new CreateAccountModel(store), [store])
+  const {_} = useLingui()
+  const [uiState, uiDispatch] = useCreateAccount()
+  const onboardingDispatch = useOnboardingDispatch()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
 
   React.useEffect(() => {
     screen('CreateAccount')
   }, [screen])
 
+  // fetch service info
+  // =
+
+  const {
+    data: serviceInfo,
+    isFetching: serviceInfoIsFetching,
+    error: serviceInfoError,
+    refetch: refetchServiceInfo,
+  } = useServiceQuery(uiState.serviceUrl)
+
   React.useEffect(() => {
-    model.fetchServiceDescription()
-  }, [model])
+    if (serviceInfo) {
+      uiDispatch({type: 'set-service-description', value: serviceInfo})
+      uiDispatch({type: 'set-error', value: ''})
+    } else if (serviceInfoError) {
+      uiDispatch({
+        type: 'set-error',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    }
+  }, [_, uiDispatch, serviceInfo, serviceInfoError])
 
-  const onPressRetryConnect = React.useCallback(
-    () => model.fetchServiceDescription(),
-    [model],
-  )
+  // event handlers
+  // =
 
   const onPressBackInner = React.useCallback(() => {
-    if (model.canBack) {
-      model.back()
+    if (uiState.canBack) {
+      uiDispatch({type: 'back'})
     } else {
       onPressBack()
     }
-  }, [model, onPressBack])
+  }, [uiState, uiDispatch, onPressBack])
 
   const onPressNext = React.useCallback(async () => {
-    if (!model.canNext) {
+    if (!uiState.canNext) {
       return
     }
-    if (model.step < 3) {
-      model.next()
+    if (uiState.step < 3) {
+      uiDispatch({type: 'next'})
     } else {
       try {
-        await model.submit()
+        await submit({
+          onboardingDispatch,
+          createAccount,
+          uiState,
+          uiDispatch,
+          _,
+        })
+        track('Create Account')
+        setBirthDate({birthDate: uiState.birthDate})
+        if (IS_PROD(uiState.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
       } catch {
         // dont need to handle here
       } finally {
         track('Try Create Account')
       }
     }
-  }, [model, track])
+  }, [
+    uiState,
+    uiDispatch,
+    track,
+    onboardingDispatch,
+    createAccount,
+    setBirthDate,
+    setSavedFeeds,
+    _,
+  ])
+
+  // rendering
+  // =
 
   return (
     <LoggedOutLayout
-      leadin={`Step ${model.step}`}
-      title="Create Account"
-      description="We're so excited to have you join us!">
+      leadin={`Step ${uiState.step}`}
+      title={_(msg`Create Account`)}
+      description={_(msg`We're so excited to have you join us!`)}>
       <ScrollView testID="createAccount" style={pal.view}>
         <KeyboardAvoidingView behavior="padding">
           <View style={styles.stepContainer}>
-            {model.step === 1 && <Step1 model={model} />}
-            {model.step === 2 && <Step2 model={model} />}
-            {model.step === 3 && <Step3 model={model} />}
+            {uiState.step === 1 && (
+              <Step1 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 2 && (
+              <Step2 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 3 && (
+              <Step3 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
           </View>
           <View style={[s.flexRow, s.pl20, s.pr20]}>
             <TouchableOpacity
@@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({
               testID="backBtn"
               accessibilityRole="button">
               <Text type="xl" style={pal.link}>
-                Back
+                <Trans>Back</Trans>
               </Text>
             </TouchableOpacity>
             <View style={s.flex1} />
-            {model.canNext ? (
+            {uiState.canNext ? (
               <TouchableOpacity
                 testID="nextBtn"
                 onPress={onPressNext}
                 accessibilityRole="button">
-                {model.isProcessing ? (
+                {uiState.isProcessing ? (
                   <ActivityIndicator />
                 ) : (
                   <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                    Next
+                    <Trans>Next</Trans>
                   </Text>
                 )}
               </TouchableOpacity>
-            ) : model.didServiceDescriptionFetchFail ? (
+            ) : serviceInfoError ? (
               <TouchableOpacity
                 testID="retryConnectBtn"
-                onPress={onPressRetryConnect}
+                onPress={() => refetchServiceInfo()}
                 accessibilityRole="button"
-                accessibilityLabel="Retry"
-                accessibilityHint="Retries account creation"
+                accessibilityLabel={_(msg`Retry`)}
+                accessibilityHint=""
                 accessibilityLiveRegion="polite">
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  Retry
+                  <Trans>Retry</Trans>
                 </Text>
               </TouchableOpacity>
-            ) : model.isFetchingServiceDescription ? (
+            ) : serviceInfoIsFetching ? (
               <>
                 <ActivityIndicator color="#fff" />
                 <Text type="xl" style={[pal.text, s.pr5]}>
-                  Connecting...
+                  <Trans>Connecting...</Trans>
                 </Text>
               </>
             ) : undefined}
@@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
       </ScrollView>
     </LoggedOutLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   stepContainer: {
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
index 8eb669bcf..a52f07531 100644
--- a/src/view/com/auth/create/Policies.tsx
+++ b/src/view/com/auth/create/Policies.tsx
@@ -4,12 +4,14 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
 import {TextLink} from '../../util/Link'
 import {Text} from '../../util/text/Text'
 import {s, colors} from 'lib/styles'
-import {ServiceDescription} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
 
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
 export const Policies = ({
   serviceDescription,
   needsGuardian,
@@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined {
 
 const styles = StyleSheet.create({
   policies: {
-    flexDirection: 'row',
+    flexDirection: 'column',
     gap: 8,
   },
   errorIcon: {
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index cdd5cb21d..c9d19e868 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
@@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip'
 import {TextInput} from '../util/TextInput'
 import {Button} from 'view/com/util/forms/Button'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
+import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
 
 /** STEP 1: Your hosting provider
  * @field Bluesky (default)
  * @field Other (staging, local dev, your own PDS, etc.)
  */
-export const Step1 = observer(function Step1Impl({
-  model,
+export function Step1({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
+  const {_} = useLingui()
 
   const onPressDefault = React.useCallback(() => {
     setIsDefaultSelected(true)
-    model.setServiceUrl(PROD_SERVICE)
-    model.fetchServiceDescription()
-  }, [setIsDefaultSelected, model])
+    uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onPressOther = React.useCallback(() => {
     setIsDefaultSelected(false)
-    model.setServiceUrl('https://')
-    model.setServiceDescription(undefined)
-  }, [setIsDefaultSelected, model])
-
-  const fetchServiceDescription = React.useMemo(
-    () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms)
-    [model],
-  )
+    uiDispatch({type: 'set-service-url', value: 'https://'})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onChangeServiceUrl = React.useCallback(
     (v: string) => {
-      model.setServiceUrl(v)
-      fetchServiceDescription()
-    },
-    [model, fetchServiceDescription],
-  )
-
-  const onDebugChangeServiceUrl = React.useCallback(
-    (v: string) => {
-      model.setServiceUrl(v)
-      model.fetchServiceDescription()
+      uiDispatch({type: 'set-service-url', value: v})
     },
-    [model],
+    [uiDispatch],
   )
 
   return (
     <View>
-      <StepHeader step="1" title="Your hosting provider" />
+      <StepHeader step="1" title={_(msg`Your hosting provider`)} />
       <Text style={[pal.text, s.mb10]}>
-        This is the service that keeps you online.
+        <Trans>This is the service that keeps you online.</Trans>
       </Text>
       <Option
         testID="blueskyServerBtn"
@@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({
         onPress={onPressOther}>
         <View style={styles.otherForm}>
           <Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
-            Enter the address of your provider:
+            <Trans>Enter the address of your provider:</Trans>
           </Text>
           <TextInput
             testID="customServerInput"
             icon="globe"
-            placeholder="Hosting provider address"
-            value={model.serviceUrl}
+            placeholder={_(msg`Hosting provider address`)}
+            value={uiState.serviceUrl}
             editable
             onChange={onChangeServiceUrl}
             accessibilityHint="Input hosting provider address"
-            accessibilityLabel="Hosting provider address"
+            accessibilityLabel={_(msg`Hosting provider address`)}
             accessibilityLabelledBy="addressProvider"
           />
           {LOGIN_INCLUDE_DEV_SERVERS && (
@@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({
                 testID="stagingServerBtn"
                 type="default"
                 style={s.mr5}
-                label="Staging"
-                onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
+                label={_(msg`Staging`)}
+                onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
                 testID="localDevServerBtn"
                 type="default"
-                label="Dev Server"
-                onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
+                label={_(msg`Dev Server`)}
+                onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
               />
             </View>
           )}
         </View>
       </Option>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : (
-        <HelpTip text="You can change hosting providers at any time." />
+        <HelpTip text={_(msg`You can change hosting providers at any time.`)} />
       )}
     </View>
   )
-})
+}
 
 function Option({
   children,
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 60e197564..89fd070ad 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch, is18} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
@@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
 import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 /** STEP 2: Your account
  * @field Invite code or waitlist
@@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection'
  * @field Birth date
  * @readonly Terms of service & privacy policy
  */
-export const Step2 = observer(function Step2Impl({
-  model,
+export function Step2({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   const onPressWaitlist = React.useCallback(() => {
-    store.shell.openModal({name: 'waitlist'})
-  }, [store])
+    openModal({name: 'waitlist'})
+  }, [openModal])
 
   return (
     <View>
-      <StepHeader step="2" title="Your account" />
+      <StepHeader step="2" title={_(msg`Your account`)} />
 
-      {model.isInviteCodeRequired && (
+      {uiState.isInviteCodeRequired && (
         <View style={s.pb20}>
           <Text type="md-medium" style={[pal.text, s.mb2]}>
             Invite code
@@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({
           <TextInput
             testID="inviteCodeInput"
             icon="ticket"
-            placeholder="Required for this provider"
-            value={model.inviteCode}
+            placeholder={_(msg`Required for this provider`)}
+            value={uiState.inviteCode}
             editable
-            onChange={model.setInviteCode}
-            accessibilityLabel="Invite code"
+            onChange={value => uiDispatch({type: 'set-invite-code', value})}
+            accessibilityLabel={_(msg`Invite code`)}
             accessibilityHint="Input invite code to proceed"
           />
         </View>
       )}
 
-      {!model.inviteCode && model.isInviteCodeRequired ? (
+      {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
         <Text style={[s.alignBaseline, pal.text]}>
           Don't have an invite code?{' '}
           <TouchableWithoutFeedback
             onPress={onPressWaitlist}
-            accessibilityLabel="Join the waitlist."
+            accessibilityLabel={_(msg`Join the waitlist.`)}
             accessibilityHint="">
             <View style={styles.touchable}>
-              <Text style={pal.link}>Join the waitlist.</Text>
+              <Text style={pal.link}>
+                <Trans>Join the waitlist.</Trans>
+              </Text>
             </View>
           </TouchableWithoutFeedback>
         </Text>
@@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({
         <>
           <View style={s.pb20}>
             <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
-              Email address
+              <Trans>Email address</Trans>
             </Text>
             <TextInput
               testID="emailInput"
               icon="envelope"
-              placeholder="Enter your email address"
-              value={model.email}
+              placeholder={_(msg`Enter your email address`)}
+              value={uiState.email}
               editable
-              onChange={model.setEmail}
-              accessibilityLabel="Email"
+              onChange={value => uiDispatch({type: 'set-email', value})}
+              accessibilityLabel={_(msg`Email`)}
               accessibilityHint="Input email for Bluesky waitlist"
               accessibilityLabelledBy="email"
             />
@@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="password">
-              Password
+              <Trans>Password</Trans>
             </Text>
             <TextInput
               testID="passwordInput"
               icon="lock"
-              placeholder="Choose your password"
-              value={model.password}
+              placeholder={_(msg`Choose your password`)}
+              value={uiState.password}
               editable
               secureTextEntry
-              onChange={model.setPassword}
-              accessibilityLabel="Password"
+              onChange={value => uiDispatch({type: 'set-password', value})}
+              accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Set password"
               accessibilityLabelledBy="password"
             />
@@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="birthDate">
-              Your birth date
+              <Trans>Your birth date</Trans>
             </Text>
             <DateInput
               testID="birthdayInput"
-              value={model.birthDate}
-              onChange={model.setBirthDate}
+              value={uiState.birthDate}
+              onChange={value => uiDispatch({type: 'set-birth-date', value})}
               buttonType="default-light"
               buttonStyle={[pal.border, styles.dateInputButton]}
               buttonLabelType="lg"
-              accessibilityLabel="Birthday"
+              accessibilityLabel={_(msg`Birthday`)}
               accessibilityHint="Enter your birth date"
               accessibilityLabelledBy="birthDate"
             />
           </View>
 
-          {model.serviceDescription && (
+          {uiState.serviceDescription && (
             <Policies
-              serviceDescription={model.serviceDescription}
-              needsGuardian={!model.isAge18}
+              serviceDescription={uiState.serviceDescription}
+              needsGuardian={!is18(uiState)}
             />
           )}
         </>
       )}
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index beb756ac1..3b628b6b6 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
@@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput'
 import {createFullHandle} from 'lib/strings/handles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 /** STEP 3: Your user handle
  * @field User handle
  */
-export const Step3 = observer(function Step3Impl({
-  model,
+export function Step3({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <View>
-      <StepHeader step="3" title="Your user handle" />
+      <StepHeader step="3" title={_(msg`Your user handle`)} />
       <View style={s.pb10}>
         <TextInput
           testID="handleInput"
           icon="at"
           placeholder="e.g. alice"
-          value={model.handle}
+          value={uiState.handle}
           editable
-          onChange={model.setHandle}
+          onChange={value => uiDispatch({type: 'set-handle', value})}
           // TODO: Add explicit text label
-          accessibilityLabel="User handle"
+          accessibilityLabel={_(msg`User handle`)}
           accessibilityHint="Input your user handle"
         />
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
-          Your full handle will be{' '}
-          <Text type="lg-bold" style={pal.text}>
-            @{createFullHandle(model.handle, model.userDomain)}
+          <Trans>Your full handle will be</Trans>
+          <Text type="lg-bold" style={[pal.text, s.ml5]}>
+            @{createFullHandle(uiState.handle, uiState.userDomain)}
           </Text>
         </Text>
       </View>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
new file mode 100644
index 000000000..4df82f8fc
--- /dev/null
+++ b/src/view/com/auth/create/state.ts
@@ -0,0 +1,242 @@
+import {useReducer} from 'react'
+import {
+  ComAtprotoServerDescribeServer,
+  ComAtprotoServerCreateAccount,
+} from '@atproto/api'
+import {I18nContext, useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import * as EmailValidator from 'email-validator'
+import {getAge} from 'lib/strings/time'
+import {logger} from '#/logger'
+import {createFullHandle} from '#/lib/strings/handles'
+import {cleanError} from '#/lib/strings/errors'
+import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
+import {ApiContext as SessionApiContext} from '#/state/session'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export type CreateAccountAction =
+  | {type: 'set-step'; value: number}
+  | {type: 'set-error'; value: string | undefined}
+  | {type: 'set-processing'; value: boolean}
+  | {type: 'set-service-url'; value: string}
+  | {type: 'set-service-description'; value: ServiceDescription | undefined}
+  | {type: 'set-user-domain'; value: string}
+  | {type: 'set-invite-code'; value: string}
+  | {type: 'set-email'; value: string}
+  | {type: 'set-password'; value: string}
+  | {type: 'set-handle'; value: string}
+  | {type: 'set-birth-date'; value: Date}
+  | {type: 'next'}
+  | {type: 'back'}
+
+export interface CreateAccountState {
+  // state
+  step: number
+  error: string | undefined
+  isProcessing: boolean
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  userDomain: string
+  inviteCode: string
+  email: string
+  password: string
+  handle: string
+  birthDate: Date
+
+  // computed
+  canBack: boolean
+  canNext: boolean
+  isInviteCodeRequired: boolean
+}
+
+export type CreateAccountDispatch = (action: CreateAccountAction) => void
+
+export function useCreateAccount() {
+  const {_} = useLingui()
+  return useReducer(createReducer({_}), {
+    step: 1,
+    error: undefined,
+    isProcessing: false,
+    serviceUrl: DEFAULT_SERVICE,
+    serviceDescription: undefined,
+    userDomain: '',
+    inviteCode: '',
+    email: '',
+    password: '',
+    handle: '',
+    birthDate: DEFAULT_DATE,
+
+    canBack: false,
+    canNext: false,
+    isInviteCodeRequired: false,
+  })
+}
+
+export async function submit({
+  createAccount,
+  onboardingDispatch,
+  uiState,
+  uiDispatch,
+  _,
+}: {
+  createAccount: SessionApiContext['createAccount']
+  onboardingDispatch: OnboardingDispatchContext
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
+  _: I18nContext['_']
+}) {
+  if (!uiState.email) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please enter your email.`),
+    })
+  }
+  if (!EmailValidator.validate(uiState.email)) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Your email appears to be invalid.`),
+    })
+  }
+  if (!uiState.password) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your password.`),
+    })
+  }
+  if (!uiState.handle) {
+    uiDispatch({type: 'set-step', value: 3})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your handle.`),
+    })
+  }
+  uiDispatch({type: 'set-error', value: ''})
+  uiDispatch({type: 'set-processing', value: true})
+
+  try {
+    onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+    await createAccount({
+      service: uiState.serviceUrl,
+      email: uiState.email,
+      handle: createFullHandle(uiState.handle, uiState.userDomain),
+      password: uiState.password,
+      inviteCode: uiState.inviteCode.trim(),
+    })
+  } catch (e: any) {
+    onboardingDispatch({type: 'skip'}) // undo starting the onboard
+    let errMsg = e.toString()
+    if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+      errMsg = _(
+        msg`Invite code not accepted. Check that you input it correctly and try again.`,
+      )
+    }
+    logger.error('Failed to create account', {error: e})
+    uiDispatch({type: 'set-processing', value: false})
+    uiDispatch({type: 'set-error', value: cleanError(errMsg)})
+    throw e
+  }
+}
+
+export function is13(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+export function is18(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+function createReducer({_}: {_: I18nContext['_']}) {
+  return function reducer(
+    state: CreateAccountState,
+    action: CreateAccountAction,
+  ): CreateAccountState {
+    switch (action.type) {
+      case 'set-step': {
+        return compute({...state, step: action.value})
+      }
+      case 'set-error': {
+        return compute({...state, error: action.value})
+      }
+      case 'set-processing': {
+        return compute({...state, isProcessing: action.value})
+      }
+      case 'set-service-url': {
+        return compute({
+          ...state,
+          serviceUrl: action.value,
+          serviceDescription:
+            state.serviceUrl !== action.value
+              ? undefined
+              : state.serviceDescription,
+        })
+      }
+      case 'set-service-description': {
+        return compute({
+          ...state,
+          serviceDescription: action.value,
+          userDomain: action.value?.availableUserDomains[0] || '',
+        })
+      }
+      case 'set-user-domain': {
+        return compute({...state, userDomain: action.value})
+      }
+      case 'set-invite-code': {
+        return compute({...state, inviteCode: action.value})
+      }
+      case 'set-email': {
+        return compute({...state, email: action.value})
+      }
+      case 'set-password': {
+        return compute({...state, password: action.value})
+      }
+      case 'set-handle': {
+        return compute({...state, handle: action.value})
+      }
+      case 'set-birth-date': {
+        return compute({...state, birthDate: action.value})
+      }
+      case 'next': {
+        if (state.step === 2) {
+          if (!is13(state)) {
+            return compute({
+              ...state,
+              error: _(
+                msg`Unfortunately, you do not meet the requirements to create an account.`,
+              ),
+            })
+          }
+        }
+        return compute({...state, error: '', step: state.step + 1})
+      }
+      case 'back': {
+        return compute({...state, error: '', step: state.step - 1})
+      }
+    }
+  }
+}
+
+function compute(state: CreateAccountState): CreateAccountState {
+  let canNext = true
+  if (state.step === 1) {
+    canNext = !!state.serviceDescription
+  } else if (state.step === 2) {
+    canNext =
+      (!state.isInviteCodeRequired || !!state.inviteCode) &&
+      !!state.email &&
+      !!state.password
+  } else if (state.step === 3) {
+    canNext = !!state.handle
+  }
+  return {
+    ...state,
+    canBack: state.step > 1,
+    canNext,
+    isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
+  }
+}
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
new file mode 100644
index 000000000..73ddfc9d6
--- /dev/null
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -0,0 +1,158 @@
+import React from 'react'
+import {ScrollView, TouchableOpacity, View} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {UserAvatar} from '../../util/UserAvatar'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {styles} from './styles'
+import {useSession, useSessionApi, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
+
+function AccountItem({
+  account,
+  onSelect,
+  isCurrentAccount,
+}: {
+  account: SessionAccount
+  onSelect: (account: SessionAccount) => void
+  isCurrentAccount: boolean
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  const onPress = React.useCallback(() => {
+    onSelect(account)
+  }, [account, onSelect])
+
+  return (
+    <TouchableOpacity
+      testID={`chooseAccountBtn-${account.handle}`}
+      key={account.did}
+      style={[pal.view, pal.border, styles.account]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
+      accessibilityHint="Double tap to sign in">
+      <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+        <View style={s.p10}>
+          <UserAvatar avatar={profile?.avatar} size={30} />
+        </View>
+        <Text style={styles.accountText}>
+          <Text type="lg-bold" style={pal.text}>
+            {profile?.displayName || account.handle}{' '}
+          </Text>
+          <Text type="lg" style={[pal.textLight]}>
+            {account.handle}
+          </Text>
+        </Text>
+        {isCurrentAccount ? (
+          <FontAwesomeIcon
+            icon="check"
+            size={16}
+            style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]}
+          />
+        ) : (
+          <FontAwesomeIcon
+            icon="angle-right"
+            size={16}
+            style={[pal.text, s.mr10]}
+          />
+        )}
+      </View>
+    </TouchableOpacity>
+  )
+}
+export const ChooseAccountForm = ({
+  onSelectAccount,
+  onPressBack,
+}: {
+  onSelectAccount: (account?: SessionAccount) => void
+  onPressBack: () => void
+}) => {
+  const {track, screen} = useAnalytics()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {accounts, currentAccount} = useSession()
+  const {initSession} = useSessionApi()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+
+  React.useEffect(() => {
+    screen('Choose Account')
+  }, [screen])
+
+  const onSelect = React.useCallback(
+    async (account: SessionAccount) => {
+      if (account.accessJwt) {
+        if (account.did === currentAccount?.did) {
+          setShowLoggedOut(false)
+          Toast.show(`Already signed in as @${account.handle}`)
+        } else {
+          await initSession(account)
+          track('Sign In', {resumedSession: true})
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        }
+      } else {
+        onSelectAccount(account)
+      }
+    },
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
+  )
+
+  return (
+    <ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
+      <Text
+        type="2xl-medium"
+        style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
+        <Trans>Sign in as...</Trans>
+      </Text>
+      {accounts.map(account => (
+        <AccountItem
+          key={account.did}
+          account={account}
+          onSelect={onSelect}
+          isCurrentAccount={account.did === currentAccount?.did}
+        />
+      ))}
+      <TouchableOpacity
+        testID="chooseNewAccountBtn"
+        style={[pal.view, pal.border, styles.account, styles.accountLast]}
+        onPress={() => onSelectAccount(undefined)}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Login to account that is not listed`)}
+        accessibilityHint="">
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <Text style={[styles.accountText, styles.accountTextOther]}>
+            <Text type="lg" style={pal.text}>
+              <Trans>Other account</Trans>
+            </Text>
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            size={16}
+            style={[pal.text, s.mr10]}
+          />
+        </View>
+      </TouchableOpacity>
+      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            <Trans>Back</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
new file mode 100644
index 000000000..215c393d9
--- /dev/null
+++ b/src/view/com/auth/login/ForgotPasswordForm.tsx
@@ -0,0 +1,197 @@
+import React, {useState, useEffect} from 'react'
+import {
+  ActivityIndicator,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import * as EmailValidator from 'email-validator'
+import {BskyAgent} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {styles} from './styles'
+import {useModalControls} from '#/state/modals'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const ForgotPasswordForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  setError,
+  setServiceUrl,
+  onPressBack,
+  onEmailSent,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressBack: () => void
+  onEmailSent: () => void
+}) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [email, setEmail] = useState<string>('')
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+
+  useEffect(() => {
+    screen('Signin:ForgotPassword')
+  }, [screen])
+
+  const onPressSelectService = () => {
+    openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+  }
+
+  const onPressNext = async () => {
+    if (!EmailValidator.validate(email)) {
+      return setError('Your email appears to be invalid.')
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.requestPasswordReset({email})
+      onEmailSent()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to request password reset', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Reset password</Trans>
+        </Text>
+        <Text type="md" style={[pal.text, styles.instructions]}>
+          <Trans>
+            Enter the email you used to create your account. We'll send you a
+            "reset code" so you can set a new password.
+          </Trans>
+        </Text>
+        <View
+          testID="forgotPasswordView"
+          style={[pal.borderDark, pal.view, styles.group]}>
+          <TouchableOpacity
+            testID="forgotPasswordSelectServiceButton"
+            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
+            onPress={onPressSelectService}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Hosting provider`)}
+            accessibilityHint="Sets hosting provider for password reset">
+            <FontAwesomeIcon
+              icon="globe"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <Text style={[pal.text, styles.textInput]} numberOfLines={1}>
+              {toNiceDomain(serviceUrl)}
+            </Text>
+            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
+              <FontAwesomeIcon
+                icon="pen"
+                size={12}
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+          </TouchableOpacity>
+          <View style={[pal.borderDark, styles.groupContent]}>
+            <FontAwesomeIcon
+              icon="envelope"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="forgotPasswordEmail"
+              style={[pal.text, styles.textInput]}
+              placeholder="Email address"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoFocus
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              value={email}
+              onChangeText={setEmail}
+              editable={!isProcessing}
+              accessibilityLabel={_(msg`Email`)}
+              accessibilityHint="Sets email for password reset"
+            />
+          </View>
+        </View>
+        {error ? (
+          <View style={styles.error}>
+            <View style={styles.errorIcon}>
+              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+            </View>
+            <View style={s.flex1}>
+              <Text style={[s.white, s.bold]}>{error}</Text>
+            </View>
+          </View>
+        ) : undefined}
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+            <Text type="xl" style={[pal.link, s.pl5]}>
+              <Trans>Back</Trans>
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {!serviceDescription || isProcessing ? (
+            <ActivityIndicator />
+          ) : !email ? (
+            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
+              <Trans>Next</Trans>
+            </Text>
+          ) : (
+            <TouchableOpacity
+              testID="newPasswordButton"
+              onPress={onPressNext}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Go to next`)}
+              accessibilityHint="Navigates to the next screen">
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                <Trans>Next</Trans>
+              </Text>
+            </TouchableOpacity>
+          )}
+          {!serviceDescription || isProcessing ? (
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Processing...</Trans>
+            </Text>
+          ) : undefined}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index acc05b6ca..67d0afdf1 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -1,36 +1,19 @@
-import React, {useState, useEffect, useRef} from 'react'
-import {
-  ActivityIndicator,
-  Keyboard,
-  KeyboardAvoidingView,
-  ScrollView,
-  StyleSheet,
-  TextInput,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import * as EmailValidator from 'email-validator'
-import {BskyAgent} from '@atproto/api'
+import React, {useState, useEffect} from 'react'
+import {KeyboardAvoidingView} from 'react-native'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {UserAvatar} from '../../util/UserAvatar'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {s, colors} from 'lib/styles'
-import {createFullHandle} from 'lib/strings/handles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
-import {ServiceDescription} from 'state/models/session'
-import {AccountData} from 'state/models/session'
-import {isNetworkError} from 'lib/strings/errors'
+import {DEFAULT_SERVICE} from '#/lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {isWeb} from 'platform/detection'
 import {logger} from '#/logger'
+import {ChooseAccountForm} from './ChooseAccountForm'
+import {LoginForm} from './LoginForm'
+import {ForgotPasswordForm} from './ForgotPasswordForm'
+import {SetNewPasswordForm} from './SetNewPasswordForm'
+import {PasswordUpdatedForm} from './PasswordUpdatedForm'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useSession, SessionAccount} from '#/state/session'
+import {useServiceQuery} from '#/state/queries/service'
 
 enum Forms {
   Login,
@@ -42,20 +25,22 @@ enum Forms {
 
 export const Login = ({onPressBack}: {onPressBack: () => void}) => {
   const pal = usePalette('default')
-  const store = useStores()
+  const {accounts} = useSession()
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const [error, setError] = useState<string>('')
-  const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
   const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
-  const [serviceDescription, setServiceDescription] = useState<
-    ServiceDescription | undefined
-  >(undefined)
   const [initialHandle, setInitialHandle] = useState<string>('')
   const [currentForm, setCurrentForm] = useState<Forms>(
-    store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login,
+    accounts.length ? Forms.ChooseAccount : Forms.Login,
   )
+  const {
+    data: serviceDescription,
+    error: serviceError,
+    refetch: refetchService,
+  } = useServiceQuery(serviceUrl)
 
-  const onSelectAccount = (account?: AccountData) => {
+  const onSelectAccount = (account?: SessionAccount) => {
     if (account?.service) {
       setServiceUrl(account.service)
     }
@@ -69,33 +54,21 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
   }
 
   useEffect(() => {
-    let aborted = false
-    setError('')
-    store.session.describeService(serviceUrl).then(
-      desc => {
-        if (aborted) {
-          return
-        }
-        setServiceDescription(desc)
-      },
-      err => {
-        if (aborted) {
-          return
-        }
-        logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
-          error: err,
-        })
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      },
-    )
-    return () => {
-      aborted = true
+    if (serviceError) {
+      setError(
+        _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      )
+      logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
+        error: String(serviceError),
+      })
+    } else {
+      setError('')
     }
-  }, [store.session, serviceUrl, retryDescribeTrigger])
+  }, [serviceError, serviceUrl, _])
 
-  const onPressRetryConnect = () => setRetryDescribeTrigger({})
+  const onPressRetryConnect = () => refetchService()
   const onPressForgotPassword = () => {
     track('Signin:PressedForgotPassword')
     setCurrentForm(Forms.ForgotPassword)
@@ -106,10 +79,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.Login ? (
         <LoggedOutLayout
           leadin=""
-          title="Sign in"
-          description="Enter your username and password">
+          title={_(msg`Sign in`)}
+          description={_(msg`Enter your username and password`)}>
           <LoginForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             serviceDescription={serviceDescription}
@@ -125,10 +97,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.ChooseAccount ? (
         <LoggedOutLayout
           leadin=""
-          title="Sign in as..."
-          description="Select from an existing account">
+          title={_(msg`Sign in as...`)}
+          description={_(msg`Select from an existing account`)}>
           <ChooseAccountForm
-            store={store}
             onSelectAccount={onSelectAccount}
             onPressBack={onPressBack}
           />
@@ -137,10 +108,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.ForgotPassword ? (
         <LoggedOutLayout
           leadin=""
-          title="Forgot Password"
-          description="Let's get your password reset!">
+          title={_(msg`Forgot Password`)}
+          description={_(msg`Let's get your password reset!`)}>
           <ForgotPasswordForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             serviceDescription={serviceDescription}
@@ -154,10 +124,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.SetNewPassword ? (
         <LoggedOutLayout
           leadin=""
-          title="Forgot Password"
-          description="Let's get your password reset!">
+          title={_(msg`Forgot Password`)}
+          description={_(msg`Let's get your password reset!`)}>
           <SetNewPasswordForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             setError={setError}
@@ -167,834 +136,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
         </LoggedOutLayout>
       ) : undefined}
       {currentForm === Forms.PasswordUpdated ? (
-        <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
+        <LoggedOutLayout
+          leadin=""
+          title={_(msg`Password updated`)}
+          description={_(msg`You can now sign in with your new password.`)}>
+          <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
+        </LoggedOutLayout>
       ) : undefined}
     </KeyboardAvoidingView>
   )
 }
-
-const ChooseAccountForm = ({
-  store,
-  onSelectAccount,
-  onPressBack,
-}: {
-  store: RootStoreModel
-  onSelectAccount: (account?: AccountData) => void
-  onPressBack: () => void
-}) => {
-  const {track, screen} = useAnalytics()
-  const pal = usePalette('default')
-  const [isProcessing, setIsProcessing] = React.useState(false)
-
-  React.useEffect(() => {
-    screen('Choose Account')
-  }, [screen])
-
-  const onTryAccount = async (account: AccountData) => {
-    if (account.accessJwt && account.refreshJwt) {
-      setIsProcessing(true)
-      if (await store.session.resumeSession(account)) {
-        track('Sign In', {resumedSession: true})
-        setIsProcessing(false)
-        return
-      }
-      setIsProcessing(false)
-    }
-    onSelectAccount(account)
-  }
-
-  return (
-    <ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
-      <Text
-        type="2xl-medium"
-        style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
-        Sign in as...
-      </Text>
-      {store.session.accounts.map(account => (
-        <TouchableOpacity
-          testID={`chooseAccountBtn-${account.handle}`}
-          key={account.did}
-          style={[pal.view, pal.border, styles.account]}
-          onPress={() => onTryAccount(account)}
-          accessibilityRole="button"
-          accessibilityLabel={`Sign in as ${account.handle}`}
-          accessibilityHint="Double tap to sign in">
-          <View
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-            <View style={s.p10}>
-              <UserAvatar avatar={account.aviUrl} size={30} />
-            </View>
-            <Text style={styles.accountText}>
-              <Text type="lg-bold" style={pal.text}>
-                {account.displayName || account.handle}{' '}
-              </Text>
-              <Text type="lg" style={[pal.textLight]}>
-                {account.handle}
-              </Text>
-            </Text>
-            <FontAwesomeIcon
-              icon="angle-right"
-              size={16}
-              style={[pal.text, s.mr10]}
-            />
-          </View>
-        </TouchableOpacity>
-      ))}
-      <TouchableOpacity
-        testID="chooseNewAccountBtn"
-        style={[pal.view, pal.border, styles.account, styles.accountLast]}
-        onPress={() => onSelectAccount(undefined)}
-        accessibilityRole="button"
-        accessibilityLabel="Login to account that is not listed"
-        accessibilityHint="">
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <Text style={[styles.accountText, styles.accountTextOther]}>
-            <Text type="lg" style={pal.text}>
-              Other account
-            </Text>
-          </Text>
-          <FontAwesomeIcon
-            icon="angle-right"
-            size={16}
-            style={[pal.text, s.mr10]}
-          />
-        </View>
-      </TouchableOpacity>
-      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-          <Text type="xl" style={[pal.link, s.pl5]}>
-            Back
-          </Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        {isProcessing && <ActivityIndicator />}
-      </View>
-    </ScrollView>
-  )
-}
-
-const LoginForm = ({
-  store,
-  error,
-  serviceUrl,
-  serviceDescription,
-  initialHandle,
-  setError,
-  setServiceUrl,
-  onPressRetryConnect,
-  onPressBack,
-  onPressForgotPassword,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  serviceDescription: ServiceDescription | undefined
-  initialHandle: string
-  setError: (v: string) => void
-  setServiceUrl: (v: string) => void
-  onPressRetryConnect: () => void
-  onPressBack: () => void
-  onPressForgotPassword: () => void
-}) => {
-  const {track} = useAnalytics()
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [identifier, setIdentifier] = useState<string>(initialHandle)
-  const [password, setPassword] = useState<string>('')
-  const passwordInputRef = useRef<TextInput>(null)
-
-  const onPressSelectService = () => {
-    store.shell.openModal({
-      name: 'server-input',
-      initialService: serviceUrl,
-      onSelect: setServiceUrl,
-    })
-    Keyboard.dismiss()
-    track('Signin:PressedSelectService')
-  }
-
-  const onPressNext = async () => {
-    Keyboard.dismiss()
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      // try to guess the handle if the user just gave their own username
-      let fullIdent = identifier
-      if (
-        !identifier.includes('@') && // not an email
-        !identifier.includes('.') && // not a domain
-        serviceDescription &&
-        serviceDescription.availableUserDomains.length > 0
-      ) {
-        let matched = false
-        for (const domain of serviceDescription.availableUserDomains) {
-          if (fullIdent.endsWith(domain)) {
-            matched = true
-          }
-        }
-        if (!matched) {
-          fullIdent = createFullHandle(
-            identifier,
-            serviceDescription.availableUserDomains[0],
-          )
-        }
-      }
-
-      await store.session.login({
-        service: serviceUrl,
-        identifier: fullIdent,
-        password,
-      })
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to login', {error: e})
-      setIsProcessing(false)
-      if (errMsg.includes('Authentication Required')) {
-        setError('Invalid username or password')
-      } else if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    } finally {
-      track('Sign In', {resumedSession: false})
-    }
-  }
-
-  const isReady = !!serviceDescription && !!identifier && !!password
-  return (
-    <View testID="loginForm">
-      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
-        Sign into
-      </Text>
-      <View style={[pal.borderDark, styles.group]}>
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <FontAwesomeIcon
-            icon="globe"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TouchableOpacity
-            testID="loginSelectServiceButton"
-            style={styles.textBtn}
-            onPress={onPressSelectService}
-            accessibilityRole="button"
-            accessibilityLabel="Select service"
-            accessibilityHint="Sets server for the Bluesky client">
-            <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
-              {toNiceDomain(serviceUrl)}
-            </Text>
-            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
-              <FontAwesomeIcon
-                icon="pen"
-                size={12}
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            </View>
-          </TouchableOpacity>
-        </View>
-      </View>
-      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
-        Account
-      </Text>
-      <View style={[pal.borderDark, styles.group]}>
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <FontAwesomeIcon
-            icon="at"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TextInput
-            testID="loginUsernameInput"
-            style={[pal.text, styles.textInput]}
-            placeholder="Username or email address"
-            placeholderTextColor={pal.colors.textLight}
-            autoCapitalize="none"
-            autoFocus
-            autoCorrect={false}
-            autoComplete="username"
-            returnKeyType="next"
-            onSubmitEditing={() => {
-              passwordInputRef.current?.focus()
-            }}
-            blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
-            keyboardAppearance={theme.colorScheme}
-            value={identifier}
-            onChangeText={str =>
-              setIdentifier((str || '').toLowerCase().trim())
-            }
-            editable={!isProcessing}
-            accessibilityLabel="Username or email address"
-            accessibilityHint="Input the username or email address you used at signup"
-          />
-        </View>
-        <View style={[pal.borderDark, styles.groupContent]}>
-          <FontAwesomeIcon
-            icon="lock"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TextInput
-            testID="loginPasswordInput"
-            ref={passwordInputRef}
-            style={[pal.text, styles.textInput]}
-            placeholder="Password"
-            placeholderTextColor={pal.colors.textLight}
-            autoCapitalize="none"
-            autoCorrect={false}
-            autoComplete="password"
-            returnKeyType="done"
-            enablesReturnKeyAutomatically={true}
-            keyboardAppearance={theme.colorScheme}
-            secureTextEntry={true}
-            textContentType="password"
-            clearButtonMode="while-editing"
-            value={password}
-            onChangeText={setPassword}
-            onSubmitEditing={onPressNext}
-            blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
-            editable={!isProcessing}
-            accessibilityLabel="Password"
-            accessibilityHint={
-              identifier === ''
-                ? 'Input your password'
-                : `Input the password tied to ${identifier}`
-            }
-          />
-          <TouchableOpacity
-            testID="forgotPasswordButton"
-            style={styles.textInputInnerBtn}
-            onPress={onPressForgotPassword}
-            accessibilityRole="button"
-            accessibilityLabel="Forgot password"
-            accessibilityHint="Opens password reset form">
-            <Text style={pal.link}>Forgot</Text>
-          </TouchableOpacity>
-        </View>
-      </View>
-      {error ? (
-        <View style={styles.error}>
-          <View style={styles.errorIcon}>
-            <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-          </View>
-          <View style={s.flex1}>
-            <Text style={[s.white, s.bold]}>{error}</Text>
-          </View>
-        </View>
-      ) : undefined}
-      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-          <Text type="xl" style={[pal.link, s.pl5]}>
-            Back
-          </Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        {!serviceDescription && error ? (
-          <TouchableOpacity
-            testID="loginRetryButton"
-            onPress={onPressRetryConnect}
-            accessibilityRole="button"
-            accessibilityLabel="Retry"
-            accessibilityHint="Retries login">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Retry
-            </Text>
-          </TouchableOpacity>
-        ) : !serviceDescription ? (
-          <>
-            <ActivityIndicator />
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Connecting...
-            </Text>
-          </>
-        ) : isProcessing ? (
-          <ActivityIndicator />
-        ) : isReady ? (
-          <TouchableOpacity
-            testID="loginNextButton"
-            onPress={onPressNext}
-            accessibilityRole="button"
-            accessibilityLabel="Go to next"
-            accessibilityHint="Navigates to the next screen">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Next
-            </Text>
-          </TouchableOpacity>
-        ) : undefined}
-      </View>
-    </View>
-  )
-}
-
-const ForgotPasswordForm = ({
-  store,
-  error,
-  serviceUrl,
-  serviceDescription,
-  setError,
-  setServiceUrl,
-  onPressBack,
-  onEmailSent,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  serviceDescription: ServiceDescription | undefined
-  setError: (v: string) => void
-  setServiceUrl: (v: string) => void
-  onPressBack: () => void
-  onEmailSent: () => void
-}) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [email, setEmail] = useState<string>('')
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:ForgotPassword')
-  }, [screen])
-
-  const onPressSelectService = () => {
-    store.shell.openModal({
-      name: 'server-input',
-      initialService: serviceUrl,
-      onSelect: setServiceUrl,
-    })
-  }
-
-  const onPressNext = async () => {
-    if (!EmailValidator.validate(email)) {
-      return setError('Your email appears to be invalid.')
-    }
-
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      const agent = new BskyAgent({service: serviceUrl})
-      await agent.com.atproto.server.requestPasswordReset({email})
-      onEmailSent()
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to request password reset', {error: e})
-      setIsProcessing(false)
-      if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    }
-  }
-
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Reset password
-        </Text>
-        <Text type="md" style={[pal.text, styles.instructions]}>
-          Enter the email you used to create your account. We'll send you a
-          "reset code" so you can set a new password.
-        </Text>
-        <View
-          testID="forgotPasswordView"
-          style={[pal.borderDark, pal.view, styles.group]}>
-          <TouchableOpacity
-            testID="forgotPasswordSelectServiceButton"
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
-            onPress={onPressSelectService}
-            accessibilityRole="button"
-            accessibilityLabel="Hosting provider"
-            accessibilityHint="Sets hosting provider for password reset">
-            <FontAwesomeIcon
-              icon="globe"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <Text style={[pal.text, styles.textInput]} numberOfLines={1}>
-              {toNiceDomain(serviceUrl)}
-            </Text>
-            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
-              <FontAwesomeIcon
-                icon="pen"
-                size={12}
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-          </TouchableOpacity>
-          <View style={[pal.borderDark, styles.groupContent]}>
-            <FontAwesomeIcon
-              icon="envelope"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="forgotPasswordEmail"
-              style={[pal.text, styles.textInput]}
-              placeholder="Email address"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoFocus
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              value={email}
-              onChangeText={setEmail}
-              editable={!isProcessing}
-              accessibilityLabel="Email"
-              accessibilityHint="Sets email for password reset"
-            />
-          </View>
-        </View>
-        {error ? (
-          <View style={styles.error}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-            </View>
-            <View style={s.flex1}>
-              <Text style={[s.white, s.bold]}>{error}</Text>
-            </View>
-          </View>
-        ) : undefined}
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-            <Text type="xl" style={[pal.link, s.pl5]}>
-              Back
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {!serviceDescription || isProcessing ? (
-            <ActivityIndicator />
-          ) : !email ? (
-            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
-              Next
-            </Text>
-          ) : (
-            <TouchableOpacity
-              testID="newPasswordButton"
-              onPress={onPressNext}
-              accessibilityRole="button"
-              accessibilityLabel="Go to next"
-              accessibilityHint="Navigates to the next screen">
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                Next
-              </Text>
-            </TouchableOpacity>
-          )}
-          {!serviceDescription || isProcessing ? (
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Processing...
-            </Text>
-          ) : undefined}
-        </View>
-      </View>
-    </>
-  )
-}
-
-const SetNewPasswordForm = ({
-  error,
-  serviceUrl,
-  setError,
-  onPressBack,
-  onPasswordSet,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  setError: (v: string) => void
-  onPressBack: () => void
-  onPasswordSet: () => void
-}) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:SetNewPasswordForm')
-  }, [screen])
-
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [resetCode, setResetCode] = useState<string>('')
-  const [password, setPassword] = useState<string>('')
-
-  const onPressNext = async () => {
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      const agent = new BskyAgent({service: serviceUrl})
-      const token = resetCode.replace(/\s/g, '')
-      await agent.com.atproto.server.resetPassword({
-        token,
-        password,
-      })
-      onPasswordSet()
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to set new password', {error: e})
-      setIsProcessing(false)
-      if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    }
-  }
-
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Set new password
-        </Text>
-        <Text type="lg" style={[pal.text, styles.instructions]}>
-          You will receive an email with a "reset code." Enter that code here,
-          then enter your new password.
-        </Text>
-        <View
-          testID="newPasswordView"
-          style={[pal.view, pal.borderDark, styles.group]}>
-          <View
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-            <FontAwesomeIcon
-              icon="ticket"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="resetCodeInput"
-              style={[pal.text, styles.textInput]}
-              placeholder="Reset code"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              autoFocus
-              value={resetCode}
-              onChangeText={setResetCode}
-              editable={!isProcessing}
-              accessible={true}
-              accessibilityLabel="Reset code"
-              accessibilityHint="Input code sent to your email for password reset"
-            />
-          </View>
-          <View style={[pal.borderDark, styles.groupContent]}>
-            <FontAwesomeIcon
-              icon="lock"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="newPasswordInput"
-              style={[pal.text, styles.textInput]}
-              placeholder="New password"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              secureTextEntry
-              value={password}
-              onChangeText={setPassword}
-              editable={!isProcessing}
-              accessible={true}
-              accessibilityLabel="Password"
-              accessibilityHint="Input new password"
-            />
-          </View>
-        </View>
-        {error ? (
-          <View style={styles.error}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-            </View>
-            <View style={s.flex1}>
-              <Text style={[s.white, s.bold]}>{error}</Text>
-            </View>
-          </View>
-        ) : undefined}
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-            <Text type="xl" style={[pal.link, s.pl5]}>
-              Back
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {isProcessing ? (
-            <ActivityIndicator />
-          ) : !resetCode || !password ? (
-            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
-              Next
-            </Text>
-          ) : (
-            <TouchableOpacity
-              testID="setNewPasswordButton"
-              onPress={onPressNext}
-              accessibilityRole="button"
-              accessibilityLabel="Go to next"
-              accessibilityHint="Navigates to the next screen">
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                Next
-              </Text>
-            </TouchableOpacity>
-          )}
-          {isProcessing ? (
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Updating...
-            </Text>
-          ) : undefined}
-        </View>
-      </View>
-    </>
-  )
-}
-
-const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:PasswordUpdatedForm')
-  }, [screen])
-
-  const pal = usePalette('default')
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Password updated!
-        </Text>
-        <Text type="lg" style={[pal.text, styles.instructions]}>
-          You can now sign in with your new password.
-        </Text>
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <View style={s.flex1} />
-          <TouchableOpacity
-            onPress={onPressNext}
-            accessibilityRole="button"
-            accessibilityLabel="Close alert"
-            accessibilityHint="Closes password update alert">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Okay
-            </Text>
-          </TouchableOpacity>
-        </View>
-      </View>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  screenTitle: {
-    marginBottom: 10,
-    marginHorizontal: 20,
-  },
-  instructions: {
-    marginBottom: 20,
-    marginHorizontal: 20,
-  },
-  group: {
-    borderWidth: 1,
-    borderRadius: 10,
-    marginBottom: 20,
-    marginHorizontal: 20,
-  },
-  groupLabel: {
-    paddingHorizontal: 20,
-    paddingBottom: 5,
-  },
-  groupContent: {
-    borderTopWidth: 1,
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  noTopBorder: {
-    borderTopWidth: 0,
-  },
-  groupContentIcon: {
-    marginLeft: 10,
-  },
-  account: {
-    borderTopWidth: 1,
-    paddingHorizontal: 20,
-    paddingVertical: 4,
-  },
-  accountLast: {
-    borderBottomWidth: 1,
-    marginBottom: 20,
-    paddingVertical: 8,
-  },
-  textInput: {
-    flex: 1,
-    width: '100%',
-    paddingVertical: 10,
-    paddingHorizontal: 12,
-    fontSize: 17,
-    letterSpacing: 0.25,
-    fontWeight: '400',
-    borderRadius: 10,
-  },
-  textInputInnerBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 6,
-    paddingHorizontal: 8,
-    marginHorizontal: 6,
-  },
-  textBtn: {
-    flexDirection: 'row',
-    flex: 1,
-    alignItems: 'center',
-  },
-  textBtnLabel: {
-    flex: 1,
-    paddingVertical: 10,
-    paddingHorizontal: 12,
-  },
-  textBtnFakeInnerBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 6,
-    paddingVertical: 6,
-    paddingHorizontal: 8,
-    marginHorizontal: 6,
-  },
-  accountText: {
-    flex: 1,
-    flexDirection: 'row',
-    alignItems: 'baseline',
-    paddingVertical: 10,
-  },
-  accountTextOther: {
-    paddingLeft: 12,
-  },
-  error: {
-    backgroundColor: colors.red4,
-    flexDirection: 'row',
-    alignItems: 'center',
-    marginTop: -5,
-    marginHorizontal: 20,
-    marginBottom: 15,
-    borderRadius: 8,
-    paddingHorizontal: 8,
-    paddingVertical: 8,
-  },
-  errorIcon: {
-    borderWidth: 1,
-    borderColor: colors.white,
-    color: colors.white,
-    borderRadius: 30,
-    width: 16,
-    height: 16,
-    alignItems: 'center',
-    justifyContent: 'center',
-    marginRight: 5,
-  },
-  dimmed: {opacity: 0.5},
-
-  maxHeight: {
-    // @ts-ignore web only -prf
-    maxHeight: isWeb ? '100vh' : undefined,
-    height: !isWeb ? '100%' : undefined,
-  },
-})
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
new file mode 100644
index 000000000..365f2e253
--- /dev/null
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -0,0 +1,290 @@
+import React, {useState, useRef} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useSessionApi} from '#/state/session'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {styles} from './styles'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const LoginForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  initialHandle,
+  setError,
+  setServiceUrl,
+  onPressRetryConnect,
+  onPressBack,
+  onPressForgotPassword,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  initialHandle: string
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressRetryConnect: () => void
+  onPressBack: () => void
+  onPressForgotPassword: () => void
+}) => {
+  const {track} = useAnalytics()
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [identifier, setIdentifier] = useState<string>(initialHandle)
+  const [password, setPassword] = useState<string>('')
+  const passwordInputRef = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {login} = useSessionApi()
+
+  const onPressSelectService = () => {
+    openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+    Keyboard.dismiss()
+    track('Signin:PressedSelectService')
+  }
+
+  const onPressNext = async () => {
+    Keyboard.dismiss()
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      // try to guess the handle if the user just gave their own username
+      let fullIdent = identifier
+      if (
+        !identifier.includes('@') && // not an email
+        !identifier.includes('.') && // not a domain
+        serviceDescription &&
+        serviceDescription.availableUserDomains.length > 0
+      ) {
+        let matched = false
+        for (const domain of serviceDescription.availableUserDomains) {
+          if (fullIdent.endsWith(domain)) {
+            matched = true
+          }
+        }
+        if (!matched) {
+          fullIdent = createFullHandle(
+            identifier,
+            serviceDescription.availableUserDomains[0],
+          )
+        }
+      }
+
+      // TODO remove double login
+      await login({
+        service: serviceUrl,
+        identifier: fullIdent,
+        password,
+      })
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to login', {error: e})
+      setIsProcessing(false)
+      if (errMsg.includes('Authentication Required')) {
+        setError(_(msg`Invalid username or password`))
+      } else if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    } finally {
+      track('Sign In', {resumedSession: false})
+    }
+  }
+
+  const isReady = !!serviceDescription && !!identifier && !!password
+  return (
+    <View testID="loginForm">
+      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
+        <Trans>Sign into</Trans>
+      </Text>
+      <View style={[pal.borderDark, styles.group]}>
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <FontAwesomeIcon
+            icon="globe"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TouchableOpacity
+            testID="loginSelectServiceButton"
+            style={styles.textBtn}
+            onPress={onPressSelectService}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Select service`)}
+            accessibilityHint="Sets server for the Bluesky client">
+            <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
+              {toNiceDomain(serviceUrl)}
+            </Text>
+            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
+              <FontAwesomeIcon
+                icon="pen"
+                size={12}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </View>
+          </TouchableOpacity>
+        </View>
+      </View>
+      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
+        <Trans>Account</Trans>
+      </Text>
+      <View style={[pal.borderDark, styles.group]}>
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <FontAwesomeIcon
+            icon="at"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TextInput
+            testID="loginUsernameInput"
+            style={[pal.text, styles.textInput]}
+            placeholder={_(msg`Username or email address`)}
+            placeholderTextColor={pal.colors.textLight}
+            autoCapitalize="none"
+            autoFocus
+            autoCorrect={false}
+            autoComplete="username"
+            returnKeyType="next"
+            onSubmitEditing={() => {
+              passwordInputRef.current?.focus()
+            }}
+            blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+            keyboardAppearance={theme.colorScheme}
+            value={identifier}
+            onChangeText={str =>
+              setIdentifier((str || '').toLowerCase().trim())
+            }
+            editable={!isProcessing}
+            accessibilityLabel={_(msg`Username or email address`)}
+            accessibilityHint="Input the username or email address you used at signup"
+          />
+        </View>
+        <View style={[pal.borderDark, styles.groupContent]}>
+          <FontAwesomeIcon
+            icon="lock"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TextInput
+            testID="loginPasswordInput"
+            ref={passwordInputRef}
+            style={[pal.text, styles.textInput]}
+            placeholder="Password"
+            placeholderTextColor={pal.colors.textLight}
+            autoCapitalize="none"
+            autoCorrect={false}
+            autoComplete="password"
+            returnKeyType="done"
+            enablesReturnKeyAutomatically={true}
+            keyboardAppearance={theme.colorScheme}
+            secureTextEntry={true}
+            textContentType="password"
+            clearButtonMode="while-editing"
+            value={password}
+            onChangeText={setPassword}
+            onSubmitEditing={onPressNext}
+            blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
+            editable={!isProcessing}
+            accessibilityLabel={_(msg`Password`)}
+            accessibilityHint={
+              identifier === ''
+                ? 'Input your password'
+                : `Input the password tied to ${identifier}`
+            }
+          />
+          <TouchableOpacity
+            testID="forgotPasswordButton"
+            style={styles.textInputInnerBtn}
+            onPress={onPressForgotPassword}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Forgot password`)}
+            accessibilityHint="Opens password reset form">
+            <Text style={pal.link}>
+              <Trans>Forgot</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </View>
+      {error ? (
+        <View style={styles.error}>
+          <View style={styles.errorIcon}>
+            <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+          </View>
+          <View style={s.flex1}>
+            <Text style={[s.white, s.bold]}>{error}</Text>
+          </View>
+        </View>
+      ) : undefined}
+      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            <Trans>Back</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        {!serviceDescription && error ? (
+          <TouchableOpacity
+            testID="loginRetryButton"
+            onPress={onPressRetryConnect}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Retry`)}
+            accessibilityHint="Retries login">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Retry</Trans>
+            </Text>
+          </TouchableOpacity>
+        ) : !serviceDescription ? (
+          <>
+            <ActivityIndicator />
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Connecting...</Trans>
+            </Text>
+          </>
+        ) : isProcessing ? (
+          <ActivityIndicator />
+        ) : isReady ? (
+          <TouchableOpacity
+            testID="loginNextButton"
+            onPress={onPressNext}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Go to next`)}
+            accessibilityHint="Navigates to the next screen">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Next</Trans>
+            </Text>
+          </TouchableOpacity>
+        ) : undefined}
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx
new file mode 100644
index 000000000..1e07588a9
--- /dev/null
+++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx
@@ -0,0 +1,48 @@
+import React, {useEffect} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {styles} from './styles'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export const PasswordUpdatedForm = ({
+  onPressNext,
+}: {
+  onPressNext: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:PasswordUpdatedForm')
+  }, [screen])
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Password updated!</Trans>
+        </Text>
+        <Text type="lg" style={[pal.text, styles.instructions]}>
+          <Trans>You can now sign in with your new password.</Trans>
+        </Text>
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <View style={s.flex1} />
+          <TouchableOpacity
+            onPress={onPressNext}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Close alert`)}
+            accessibilityHint="Closes password update alert">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Okay</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
new file mode 100644
index 000000000..2bb614df2
--- /dev/null
+++ b/src/view/com/auth/login/SetNewPasswordForm.tsx
@@ -0,0 +1,179 @@
+import React, {useState, useEffect} from 'react'
+import {
+  ActivityIndicator,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {BskyAgent} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {styles} from './styles'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export const SetNewPasswordForm = ({
+  error,
+  serviceUrl,
+  setError,
+  onPressBack,
+  onPasswordSet,
+}: {
+  error: string
+  serviceUrl: string
+  setError: (v: string) => void
+  onPressBack: () => void
+  onPasswordSet: () => void
+}) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:SetNewPasswordForm')
+  }, [screen])
+
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [resetCode, setResetCode] = useState<string>('')
+  const [password, setPassword] = useState<string>('')
+
+  const onPressNext = async () => {
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      const token = resetCode.replace(/\s/g, '')
+      await agent.com.atproto.server.resetPassword({
+        token,
+        password,
+      })
+      onPasswordSet()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to set new password', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Set new password</Trans>
+        </Text>
+        <Text type="lg" style={[pal.text, styles.instructions]}>
+          <Trans>
+            You will receive an email with a "reset code." Enter that code here,
+            then enter your new password.
+          </Trans>
+        </Text>
+        <View
+          testID="newPasswordView"
+          style={[pal.view, pal.borderDark, styles.group]}>
+          <View
+            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+            <FontAwesomeIcon
+              icon="ticket"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="resetCodeInput"
+              style={[pal.text, styles.textInput]}
+              placeholder="Reset code"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              autoFocus
+              value={resetCode}
+              onChangeText={setResetCode}
+              editable={!isProcessing}
+              accessible={true}
+              accessibilityLabel={_(msg`Reset code`)}
+              accessibilityHint="Input code sent to your email for password reset"
+            />
+          </View>
+          <View style={[pal.borderDark, styles.groupContent]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="newPasswordInput"
+              style={[pal.text, styles.textInput]}
+              placeholder="New password"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              secureTextEntry
+              value={password}
+              onChangeText={setPassword}
+              editable={!isProcessing}
+              accessible={true}
+              accessibilityLabel={_(msg`Password`)}
+              accessibilityHint="Input new password"
+            />
+          </View>
+        </View>
+        {error ? (
+          <View style={styles.error}>
+            <View style={styles.errorIcon}>
+              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+            </View>
+            <View style={s.flex1}>
+              <Text style={[s.white, s.bold]}>{error}</Text>
+            </View>
+          </View>
+        ) : undefined}
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+            <Text type="xl" style={[pal.link, s.pl5]}>
+              <Trans>Back</Trans>
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {isProcessing ? (
+            <ActivityIndicator />
+          ) : !resetCode || !password ? (
+            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
+              <Trans>Next</Trans>
+            </Text>
+          ) : (
+            <TouchableOpacity
+              testID="setNewPasswordButton"
+              onPress={onPressNext}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Go to next`)}
+              accessibilityHint="Navigates to the next screen">
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                <Trans>Next</Trans>
+              </Text>
+            </TouchableOpacity>
+          )}
+          {isProcessing ? (
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Updating...</Trans>
+            </Text>
+          ) : undefined}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts
new file mode 100644
index 000000000..9dccc2803
--- /dev/null
+++ b/src/view/com/auth/login/styles.ts
@@ -0,0 +1,118 @@
+import {StyleSheet} from 'react-native'
+import {colors} from 'lib/styles'
+import {isWeb} from '#/platform/detection'
+
+export const styles = StyleSheet.create({
+  screenTitle: {
+    marginBottom: 10,
+    marginHorizontal: 20,
+  },
+  instructions: {
+    marginBottom: 20,
+    marginHorizontal: 20,
+  },
+  group: {
+    borderWidth: 1,
+    borderRadius: 10,
+    marginBottom: 20,
+    marginHorizontal: 20,
+  },
+  groupLabel: {
+    paddingHorizontal: 20,
+    paddingBottom: 5,
+  },
+  groupContent: {
+    borderTopWidth: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  noTopBorder: {
+    borderTopWidth: 0,
+  },
+  groupContentIcon: {
+    marginLeft: 10,
+  },
+  account: {
+    borderTopWidth: 1,
+    paddingHorizontal: 20,
+    paddingVertical: 4,
+  },
+  accountLast: {
+    borderBottomWidth: 1,
+    marginBottom: 20,
+    paddingVertical: 8,
+  },
+  textInput: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+    fontSize: 17,
+    letterSpacing: 0.25,
+    fontWeight: '400',
+    borderRadius: 10,
+  },
+  textInputInnerBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 6,
+    paddingHorizontal: 8,
+    marginHorizontal: 6,
+  },
+  textBtn: {
+    flexDirection: 'row',
+    flex: 1,
+    alignItems: 'center',
+  },
+  textBtnLabel: {
+    flex: 1,
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+  },
+  textBtnFakeInnerBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 6,
+    paddingVertical: 6,
+    paddingHorizontal: 8,
+    marginHorizontal: 6,
+  },
+  accountText: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'baseline',
+    paddingVertical: 10,
+  },
+  accountTextOther: {
+    paddingLeft: 12,
+  },
+  error: {
+    backgroundColor: colors.red4,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: -5,
+    marginHorizontal: 20,
+    marginBottom: 15,
+    borderRadius: 8,
+    paddingHorizontal: 8,
+    paddingVertical: 8,
+  },
+  errorIcon: {
+    borderWidth: 1,
+    borderColor: colors.white,
+    color: colors.white,
+    borderRadius: 30,
+    width: 16,
+    height: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 5,
+  },
+  dimmed: {opacity: 0.5},
+
+  maxHeight: {
+    // @ts-ignore web only -prf
+    maxHeight: isWeb ? '100vh' : undefined,
+    height: !isWeb ? '100%' : undefined,
+  },
+})
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index 400b836d0..d3318bffd 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -1,6 +1,5 @@
 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'
@@ -10,76 +9,55 @@ import {Button} from 'view/com/util/forms/Button'
 import {RecommendedFeedsItem} from './RecommendedFeedsItem'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useQuery} from '@tanstack/react-query'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
 
 type Props = {
   next: () => void
 }
-export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
-  next,
-}: Props) {
-  const store = useStores()
+export function RecommendedFeeds({next}: Props) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isTabletOrMobile} = useWebMediaQueries()
-  const {isLoading, data: recommendedFeeds} = useQuery({
-    staleTime: Infinity, // fixed list rn, never refetch
-    queryKey: ['onboarding', 'recommended_feeds'],
-    async queryFn() {
-      try {
-        const {
-          data: {feeds},
-          success,
-        } = await store.agent.app.bsky.feed.getSuggestedFeeds()
+  const {isLoading, data} = useSuggestedFeedsQuery()
 
-        if (!success) {
-          return []
-        }
-
-        return (feeds.length ? feeds : []).map(feed => {
-          const model = new FeedSourceModel(store, feed.uri)
-          model.hydrateFeedGenerator(feed)
-          return model
-        })
-      } catch (e) {
-        return []
-      }
-    },
-  })
-
-  const hasFeeds = recommendedFeeds && recommendedFeeds.length
+  const hasFeeds = data && data.pages[0].feeds.length
 
   const title = (
     <>
-      <Text
-        style={[
-          pal.textLight,
-          tdStyles.title1,
-          isTabletOrMobile && tdStyles.title1Small,
-        ]}>
-        Choose your
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Recommended
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Feeds
-      </Text>
+      <Trans>
+        <Text
+          style={[
+            pal.textLight,
+            tdStyles.title1,
+            isTabletOrMobile && tdStyles.title1Small,
+          ]}>
+          Choose your
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Recommended
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Feeds
+        </Text>
+      </Trans>
       <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
-        Feeds are created by users to curate content. Choose some feeds that you
-        find interesting.
+        <Trans>
+          Feeds are created by users to curate content. Choose some feeds that
+          you find interesting.
+        </Trans>
       </Text>
       <View
         style={{
@@ -98,7 +76,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Next
+              <Trans>Next</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -118,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
           contentStyle={{paddingHorizontal: 0}}>
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
@@ -128,25 +106,27 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
               <ActivityIndicator size="large" />
             </View>
           ) : (
-            <ErrorMessage message="Failed to load recommended feeds" />
+            <ErrorMessage message={_(msg`Failed to load recommended feeds`)} />
           )}
         </TitleColumnLayout>
       </TabletOrDesktop>
       <Mobile>
         <View style={[mStyles.container]} testID="recommendedFeedsOnboarding">
           <ViewHeader
-            title="Recommended Feeds"
+            title={_(msg`Recommended Feeds`)}
             showBackButton={false}
             showOnDesktop
           />
           <Text type="lg-medium" style={[pal.text, mStyles.header]}>
-            Check out some recommended feeds. Tap + to add them to your list of
-            pinned feeds.
+            <Trans>
+              Check out some recommended feeds. Tap + to add them to your list
+              of pinned feeds.
+            </Trans>
           </Text>
 
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
@@ -157,13 +137,15 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             </View>
           ) : (
             <View style={{flex: 1}}>
-              <ErrorMessage message="Failed to load recommended feeds" />
+              <ErrorMessage
+                message={_(msg`Failed to load recommended feeds`)}
+              />
             </View>
           )}
 
           <Button
             onPress={next}
-            label="Continue"
+            label={_(msg`Continue`)}
             testID="continueBtn"
             style={mStyles.button}
             labelStyle={mStyles.buttonText}
@@ -172,7 +154,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
       </Mobile>
     </>
   )
-})
+}
 
 const tdStyles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index bee23c953..7417e5b06 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
 import {Text} from 'view/com/util/text/Text'
 import {RichText} from 'view/com/util/text/RichText'
 import {Button} from 'view/com/util/forms/Button'
@@ -11,33 +11,58 @@ import {HeartIcon} from 'lib/icons'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {FeedSourceModel} from 'state/models/content/feed-source'
+import {
+  usePreferencesQuery,
+  usePinFeedMutation,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {logger} from '#/logger'
 
-export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
+export function RecommendedFeedsItem({
   item,
 }: {
-  item: FeedSourceModel
+  item: AppBskyFeedDefs.GeneratorView
 }) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
-  if (!item) return null
+  const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: pinFeed,
+    variables: pinnedFeed,
+    reset: resetPinFeed,
+  } = usePinFeedMutation()
+  const {
+    mutateAsync: removeFeed,
+    variables: removedFeed,
+    reset: resetRemoveFeed,
+  } = useRemoveFeedMutation()
+
+  if (!item || !preferences) return null
+
+  const isPinned =
+    !removedFeed?.uri &&
+    (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
+
   const onToggle = async () => {
-    if (item.isSaved) {
+    if (isPinned) {
       try {
-        await item.unsave()
+        await removeFeed({uri: item.uri})
+        resetRemoveFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to unsave feed', {e})
+        logger.error('Failed to unsave feed', {error: e})
       }
     } else {
       try {
-        await item.pin()
+        await pinFeed({uri: item.uri})
+        resetPinFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to pin feed', {e})
+        logger.error('Failed to pin feed', {error: e})
       }
     }
   }
+
   return (
     <View testID={`feed-${item.displayName}`}>
       <View
@@ -66,10 +91,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
           </Text>
 
           <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
+            by {sanitizeHandle(item.creator.handle, '@')}
           </Text>
 
-          {item.descriptionRT ? (
+          {item.description ? (
             <RichText
               type="xl"
               style={[
@@ -80,7 +105,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   marginBottom: 18,
                 },
               ]}
-              richText={item.descriptionRT}
+              richText={new BskRichText({text: item.description || ''})}
               numberOfLines={6}
             />
           ) : null}
@@ -97,7 +122,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   paddingRight: 2,
                   gap: 6,
                 }}>
-                {item.isSaved ? (
+                {isPinned ? (
                   <>
                     <FontAwesomeIcon
                       icon="check"
@@ -138,4 +163,4 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
       </View>
     </View>
   )
-})
+}
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
index f2710d2ac..372bbec6a 100644
--- a/src/view/com/auth/onboarding/RecommendedFollows.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -1,7 +1,7 @@
 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 {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
 import {Text} from 'view/com/util/text/Text'
 import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -9,59 +9,62 @@ 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'
+import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = {
   next: () => void
 }
-export const RecommendedFollows = observer(function RecommendedFollowsImpl({
-  next,
-}: Props) {
-  const store = useStores()
+export function RecommendedFollows({next}: Props) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   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 {data: suggestedFollows} = useSuggestedFollowsQuery()
+  const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
+  const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
+    [did: string]: AppBskyActorDefs.ProfileView[]
+  }>({})
+  const existingDids = React.useRef<string[]>([])
+  const moderationOpts = useModerationOpts()
 
   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>
+      <Trans>
+        <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>
+      </Trans>
       <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.
+        <Trans>
+          Follow some users to get started. We can recommend you more users
+          based on who you find interesting.
+        </Trans>
       </Text>
       <View
         style={{
@@ -80,7 +83,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Done
+              <Trans>Done</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -89,6 +92,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
     </>
   )
 
+  const suggestions = React.useMemo(() => {
+    if (!suggestedFollows) return []
+
+    const additional = Object.entries(additionalSuggestions)
+    const items = suggestedFollows.pages.flatMap(page => page.actors)
+
+    outer: while (additional.length) {
+      const additionalAccount = additional.shift()
+
+      if (!additionalAccount) break
+
+      const [followedUser, relatedAccounts] = additionalAccount
+
+      for (let i = 0; i < items.length; i++) {
+        if (items[i].did === followedUser) {
+          items.splice(i + 1, 0, ...relatedAccounts)
+          continue outer
+        }
+      }
+    }
+
+    existingDids.current = items.map(i => i.did)
+
+    return items
+  }, [suggestedFollows, additionalSuggestions])
+
+  const onFollowStateChange = React.useCallback(
+    async ({following, did}: {following: boolean; did: string}) => {
+      if (following) {
+        try {
+          const {suggestions: results} = await getSuggestedFollowsByActor(did)
+
+          if (results.length) {
+            const deduped = results.filter(
+              r => !existingDids.current.find(did => did === r.did),
+            )
+            setAdditionalSuggestions(s => ({
+              ...s,
+              [did]: deduped.slice(0, 3),
+            }))
+          }
+        } catch (e) {
+          logger.error('RecommendedFollows: failed to get suggestions', {
+            error: e,
+          })
+        }
+      }
+
+      // not handling the unfollow case
+    },
+    [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions],
+  )
+
   return (
     <>
       <TabletOrDesktop>
@@ -98,15 +154,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
           horizontal
           titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
           contentStyle={{paddingHorizontal: 0}}>
-          {store.onboarding.suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={store.onboarding.suggestedActors.suggestions}
-              renderItem={({item, index}) => (
-                <RecommendedFollowsItem item={item} index={index} />
+              data={suggestions}
+              renderItem={({item}) => (
+                <RecommendedFollowsItem
+                  profile={item}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
@@ -117,30 +177,36 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
         <View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
           <View>
             <ViewHeader
-              title="Recommended Follows"
+              title={_(msg`Recommended Users`)}
               showBackButton={false}
               showOnDesktop
             />
             <Text type="lg-medium" style={[pal.text, mStyles.header]}>
-              Check out some recommended users. Follow them to see similar
-              users.
+              <Trans>
+                Check out some recommended users. Follow them to see similar
+                users.
+              </Trans>
             </Text>
           </View>
-          {store.onboarding.suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={store.onboarding.suggestedActors.suggestions}
-              renderItem={({item, index}) => (
-                <RecommendedFollowsItem item={item} index={index} />
+              data={suggestions}
+              renderItem={({item}) => (
+                <RecommendedFollowsItem
+                  profile={item}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
           <Button
             onPress={next}
-            label="Continue"
+            label={_(msg`Continue`)}
             testID="continueBtn"
             style={mStyles.button}
             labelStyle={mStyles.buttonText}
@@ -149,7 +215,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
       </Mobile>
     </>
   )
-})
+}
 
 const tdStyles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 2b26918d0..93c515f38 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -1,11 +1,8 @@
-import React, {useMemo} from 'react'
+import React 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 {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
+import {Button} from '#/view/com/util/forms/Button'
 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'
@@ -14,26 +11,32 @@ import {Text} from 'view/com/util/text/Text'
 import Animated, {FadeInRight} from 'react-native-reanimated'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
+import {Trans} from '@lingui/macro'
+import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
+import {logger} from '#/logger'
 
 type Props = {
-  item: SuggestedActor
-  index: number
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderation: ProfileModeration
+  onFollowStateChange: (props: {
+    did: string
+    following: boolean
+  }) => Promise<void>
 }
-export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
+
+export function RecommendedFollowsItem({
+  profile,
+  moderation,
+  onFollowStateChange,
+}: React.PropsWithChildren<Props>) {
   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])
+  const shadowedProfile = useProfileShadow(profile)
 
   return (
     <Animated.View
-      entering={FadeInRight.delay(delay).springify()}
+      entering={FadeInRight}
       style={[
         styles.cardContainer,
         pal.view,
@@ -43,24 +46,62 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
           borderRightWidth: isMobile ? undefined : 1,
         },
       ]}>
-      <ProfileCard key={item.did} profile={item} index={index} />
+      <ProfileCard
+        key={profile.did}
+        profile={shadowedProfile}
+        onFollowStateChange={onFollowStateChange}
+        moderation={moderation}
+      />
     </Animated.View>
   )
 }
 
-export const ProfileCard = observer(function ProfileCardImpl({
+export function ProfileCard({
   profile,
-  index,
+  onFollowStateChange,
+  moderation,
 }: {
-  profile: AppBskyActorDefs.ProfileViewBasic
-  index: number
+  profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
+  moderation: ProfileModeration
+  onFollowStateChange: (props: {
+    did: string
+    following: boolean
+  }) => Promise<void>
 }) {
   const {track} = useAnalytics()
-  const store = useStores()
   const pal = usePalette('default')
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
   const [addingMoreSuggestions, setAddingMoreSuggestions] =
     React.useState(false)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+
+  const onToggleFollow = React.useCallback(async () => {
+    try {
+      if (profile.viewer?.following) {
+        await queueUnfollow()
+      } else {
+        setAddingMoreSuggestions(true)
+        await queueFollow()
+        await onFollowStateChange({did: profile.did, following: true})
+        setAddingMoreSuggestions(false)
+        track('Onboarding:SuggestedFollowFollowed')
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('RecommendedFollows: failed to toggle following', {
+          error: e,
+        })
+      }
+    } finally {
+      setAddingMoreSuggestions(false)
+    }
+  }, [
+    profile,
+    queueFollow,
+    queueUnfollow,
+    setAddingMoreSuggestions,
+    track,
+    onFollowStateChange,
+  ])
 
   return (
     <View style={styles.card}>
@@ -88,20 +129,11 @@ export const ProfileCard = observer(function ProfileCardImpl({
           </Text>
         </View>
 
-        <FollowButton
-          profile={profile}
+        <Button
+          type={profile.viewer?.following ? 'default' : 'inverted'}
           labelStyle={styles.followButton}
-          onToggleFollow={async isFollow => {
-            if (isFollow) {
-              setAddingMoreSuggestions(true)
-              await store.onboarding.suggestedActors.insertSuggestionsByActor(
-                profile.did,
-                index,
-              )
-              setAddingMoreSuggestions(false)
-              track('Onboarding:SuggestedFollowFollowed')
-            }
-          }}
+          onPress={onToggleFollow}
+          label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
         />
       </View>
       {profile.description ? (
@@ -114,12 +146,14 @@ export const ProfileCard = observer(function ProfileCardImpl({
       {addingMoreSuggestions ? (
         <View style={styles.addingMoreContainer}>
           <ActivityIndicator size="small" color={pal.colors.text} />
-          <Text style={[pal.text]}>Finding similar accounts...</Text>
+          <Text style={[pal.text]}>
+            <Trans>Finding similar accounts...</Trans>
+          </Text>
         </View>
       ) : null}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   cardContainer: {
diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
index c066e9bd5..1a30c17f9 100644
--- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx
+++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
@@ -7,16 +7,13 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
 import {Button} from 'view/com/util/forms/Button'
-import {observer} from 'mobx-react-lite'
 
 type Props = {
   next: () => void
   skip: () => void
 }
 
-export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
-  next,
-}: Props) {
+export function WelcomeDesktop({next}: Props) {
   const pal = usePalette('default')
   const horizontal = useMediaQuery({minWidth: 1300})
   const title = (
@@ -105,7 +102,7 @@ export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
       </View>
     </TitleColumnLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   row: {
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
index 1f0a64370..5de1a7817 100644
--- a/src/view/com/auth/onboarding/WelcomeMobile.tsx
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -5,18 +5,15 @@ import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Button} from 'view/com/util/forms/Button'
-import {observer} from 'mobx-react-lite'
 import {ViewHeader} from 'view/com/util/ViewHeader'
+import {Trans} from '@lingui/macro'
 
 type Props = {
   next: () => void
   skip: () => void
 }
 
-export const WelcomeMobile = observer(function WelcomeMobileImpl({
-  next,
-  skip,
-}: Props) {
+export function WelcomeMobile({next, skip}: Props) {
   const pal = usePalette('default')
 
   return (
@@ -32,7 +29,9 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
               accessibilityRole="button"
               style={[s.flexRow, s.alignCenter]}
               onPress={skip}>
-              <Text style={[pal.link]}>Skip</Text>
+              <Text style={[pal.link]}>
+                <Trans>Skip</Trans>
+              </Text>
               <FontAwesomeIcon
                 icon={'chevron-right'}
                 size={14}
@@ -44,18 +43,22 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
       />
       <View>
         <Text style={[pal.text, styles.title]}>
-          Welcome to{' '}
-          <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
+          <Trans>
+            Welcome to{' '}
+            <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
+          </Trans>
         </Text>
         <View style={styles.spacer} />
         <View style={[styles.row]}>
           <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is public.
+              <Trans>Bluesky is public.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Your posts, likes, and blocks are public. Mutes are private.
+              <Trans>
+                Your posts, likes, and blocks are public. Mutes are private.
+              </Trans>
             </Text>
           </View>
         </View>
@@ -63,10 +66,10 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
           <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is open.
+              <Trans>Bluesky is open.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Never lose access to your followers and data.
+              <Trans>Never lose access to your followers and data.</Trans>
             </Text>
           </View>
         </View>
@@ -74,11 +77,13 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
           <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is flexible.
+              <Trans>Bluesky is flexible.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Choose the algorithms that power your experience with custom
-              feeds.
+              <Trans>
+                Choose the algorithms that power your experience with custom
+                feeds.
+              </Trans>
             </Text>
           </View>
         </View>
@@ -93,7 +98,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
       />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx
deleted file mode 100644
index 25d12165f..000000000
--- a/src/view/com/auth/withAuthRequired.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  Linking,
-  StyleSheet,
-  TouchableOpacity,
-} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {CenteredView} from '../util/Views'
-import {LoggedOut} from './LoggedOut'
-import {Onboarding} from './Onboarding'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {STATUS_PAGE_URL} from 'lib/constants'
-
-export const withAuthRequired = <P extends object>(
-  Component: React.ComponentType<P>,
-): React.FC<P> =>
-  observer(function AuthRequired(props: P) {
-    const store = useStores()
-    if (store.session.isResumingSession) {
-      return <Loading />
-    }
-    if (!store.session.hasSession) {
-      return <LoggedOut />
-    }
-    if (store.onboarding.isActive) {
-      return <Onboarding />
-    }
-    return <Component {...props} />
-  })
-
-function Loading() {
-  const pal = usePalette('default')
-
-  const [isTakingTooLong, setIsTakingTooLong] = React.useState(false)
-  React.useEffect(() => {
-    const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds
-    return () => clearTimeout(t)
-  }, [setIsTakingTooLong])
-
-  return (
-    <CenteredView style={[styles.loading, pal.view]}>
-      <ActivityIndicator size="large" />
-      <Text type="2xl" style={[styles.loadingText, pal.textLight]}>
-        {isTakingTooLong
-          ? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..."
-          : 'Connecting...'}
-      </Text>
-      {isTakingTooLong ? (
-        <TouchableOpacity
-          onPress={() => {
-            Linking.openURL(STATUS_PAGE_URL)
-          }}
-          accessibilityRole="button">
-          <Text type="2xl" style={[styles.loadingText, pal.link]}>
-            Check Bluesky status page
-          </Text>
-        </TouchableOpacity>
-      ) : null}
-    </CenteredView>
-  )
-}
-
-const styles = StyleSheet.create({
-  loading: {
-    height: '100%',
-    alignContent: 'center',
-    justifyContent: 'center',
-    paddingBottom: 100,
-  },
-  loadingText: {
-    paddingVertical: 20,
-    paddingHorizontal: 20,
-    textAlign: 'center',
-  },
-})
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e44a0ce01..6f058d39e 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -16,7 +16,6 @@ import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {RichText} from '@atproto/api'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
 import {ExternalEmbed} from './ExternalEmbed'
 import {Text} from '../util/text/Text'
@@ -26,9 +25,8 @@ import * as Toast from '../util/Toast'
 import {TextInput, TextInputRef} from './text-input/TextInput'
 import {CharProgress} from './char-progress/CharProgress'
 import {UserAvatar} from '../util/UserAvatar'
-import {useStores} from 'state/index'
 import * as apilib from 'lib/api/index'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {ComposerOpts} from 'state/shell/composer'
 import {s, colors, gradients} from 'lib/styles'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
@@ -49,6 +47,18 @@ import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
 import {insertMentionAt} from 'lib/strings/mention-manip'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModals, useModalControls} from '#/state/modals'
+import {useRequireAltTextEnabled} from '#/state/preferences'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+  toPostLanguages,
+} from '#/state/preferences/languages'
+import {useSession, getAgent} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -57,10 +67,18 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
 }: Props) {
+  const {currentAccount} = useSession()
+  const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
+  const {activeModals} = useModals()
+  const {openModal, closeModal} = useModalControls()
+  const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const {isDesktop, isMobile} = useWebMediaQueries()
-  const store = useStores()
+  const {_} = useLingui()
+  const requireAltTextEnabled = useRequireAltTextEnabled()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
   const textInput = useRef<TextInputRef>(null)
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isProcessing, setIsProcessing] = useState(false)
@@ -86,15 +104,10 @@ export const ComposePost = observer(function ComposePost({
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
   const [labels, setLabels] = useState<string[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const gallery = useMemo(() => new GalleryModel(store), [store])
+  const gallery = useMemo(() => new GalleryModel(), [])
   const onClose = useCallback(() => {
-    store.shell.closeComposer()
-  }, [store])
-
-  const autocompleteView = useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
+    closeComposer()
+  }, [closeComposer])
 
   const insets = useSafeAreaInsets()
   const viewStyles = useMemo(
@@ -108,27 +121,27 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
-      if (store.shell.activeModals.some(modal => modal.name === 'confirm')) {
-        store.shell.closeModal()
+      if (activeModals.some(modal => modal.name === 'confirm')) {
+        closeModal()
       }
       if (Keyboard) {
         Keyboard.dismiss()
       }
-      store.shell.openModal({
+      openModal({
         name: 'confirm',
-        title: 'Discard draft',
+        title: _(msg`Discard draft`),
         onPressConfirm: onClose,
         onPressCancel: () => {
-          store.shell.closeModal()
+          closeModal()
         },
-        message: "Are you sure you'd like to discard this draft?",
-        confirmBtnText: 'Discard',
+        message: _(msg`Are you sure you'd like to discard this draft?`),
+        confirmBtnText: _(msg`Discard`),
         confirmBtnStyle: {backgroundColor: colors.red4},
       })
     } else {
       onClose()
     }
-  }, [store, onClose, graphemeLength, gallery])
+  }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
   // android back button
   useEffect(() => {
     if (!isAndroid) {
@@ -147,11 +160,6 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [onPressCancel])
 
-  // initial setup
-  useEffect(() => {
-    autocompleteView.setup()
-  }, [autocompleteView])
-
   // listen to escape key on desktop web
   const onEscape = useCallback(
     (e: KeyboardEvent) => {
@@ -187,7 +195,7 @@ export const ComposePost = observer(function ComposePost({
     if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
-    if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
+    if (requireAltTextEnabled && gallery.needsAltText) {
       return
     }
 
@@ -201,7 +209,7 @@ export const ComposePost = observer(function ComposePost({
     setIsProcessing(true)
 
     try {
-      await apilib.post(store, {
+      await apilib.post(getAgent(), {
         rawText: richtext.text,
         replyTo: replyTo?.uri,
         images: gallery.images,
@@ -209,8 +217,7 @@ export const ComposePost = observer(function ComposePost({
         extLink,
         labels,
         onStateChange: setProcessingState,
-        knownHandles: autocompleteView.knownHandles,
-        langs: store.preferences.postLanguages,
+        langs: toPostLanguages(langPrefs.postLanguage),
       })
     } catch (e: any) {
       if (extLink) {
@@ -230,9 +237,9 @@ export const ComposePost = observer(function ComposePost({
       if (replyTo && replyTo.uri) track('Post:Reply')
     }
     if (!replyTo) {
-      store.me.mainFeed.onPostCreated()
+      // TODO onPostCreated
     }
-    store.preferences.savePostLanguageToHistory()
+    setLangPrefs.savePostLanguageToHistory()
     onPost?.()
     onClose()
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
@@ -241,12 +248,8 @@ export const ComposePost = observer(function ComposePost({
   const canPost = useMemo(
     () =>
       graphemeLength <= MAX_GRAPHEME_LENGTH &&
-      (!store.preferences.requireAltTextEnabled || !gallery.needsAltText),
-    [
-      graphemeLength,
-      store.preferences.requireAltTextEnabled,
-      gallery.needsAltText,
-    ],
+      (!requireAltTextEnabled || !gallery.needsAltText),
+    [graphemeLength, requireAltTextEnabled, gallery.needsAltText],
   )
   const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
 
@@ -265,9 +268,11 @@ export const ComposePost = observer(function ComposePost({
             onPress={onPressCancel}
             onAccessibilityEscape={onPressCancel}
             accessibilityRole="button"
-            accessibilityLabel="Cancel"
+            accessibilityLabel={_(msg`Cancel`)}
             accessibilityHint="Closes post composer and discards post draft">
-            <Text style={[pal.link, s.f18]}>Cancel</Text>
+            <Text style={[pal.link, s.f18]}>
+              <Trans>Cancel</Trans>
+            </Text>
           </TouchableOpacity>
           <View style={s.flex1} />
           {isProcessing ? (
@@ -308,13 +313,15 @@ export const ComposePost = observer(function ComposePost({
                 </TouchableOpacity>
               ) : (
                 <View style={[styles.postBtn, pal.btn]}>
-                  <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text>
+                  <Text style={[pal.textLight, s.f16, s.bold]}>
+                    <Trans>Post</Trans>
+                  </Text>
                 </View>
               )}
             </>
           )}
         </View>
-        {store.preferences.requireAltTextEnabled && gallery.needsAltText && (
+        {requireAltTextEnabled && gallery.needsAltText && (
           <View style={[styles.reminderLine, pal.viewLight]}>
             <View style={styles.errorIcon}>
               <FontAwesomeIcon
@@ -324,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
               />
             </View>
             <Text style={[pal.text, s.flex1]}>
-              One or more images is missing alt text.
+              <Trans>One or more images is missing alt text.</Trans>
             </Text>
           </View>
         )}
@@ -366,13 +373,12 @@ export const ComposePost = observer(function ComposePost({
               styles.textInputLayout,
               isNative && styles.textInputLayoutMobile,
             ]}>
-            <UserAvatar avatar={store.me.avatar} size={50} />
+            <UserAvatar avatar={currentProfile?.avatar} size={50} />
             <TextInput
               ref={textInput}
               richtext={richtext}
               placeholder={selectTextInputPlaceholder}
               suggestedLinks={suggestedLinks}
-              autocompleteView={autocompleteView}
               autoFocus={true}
               setRichText={setRichText}
               onPhotoPasted={onPhotoPasted}
@@ -380,7 +386,7 @@ export const ComposePost = observer(function ComposePost({
               onSuggestedLinksChanged={setSuggestedLinks}
               onError={setError}
               accessible={true}
-              accessibilityLabel="Write post"
+              accessibilityLabel={_(msg`Write post`)}
               accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
             />
           </View>
@@ -409,11 +415,11 @@ export const ComposePost = observer(function ComposePost({
                   style={[pal.borderDark, styles.addExtLinkBtn]}
                   onPress={() => onPressAddLinkCard(url)}
                   accessibilityRole="button"
-                  accessibilityLabel="Add link card"
+                  accessibilityLabel={_(msg`Add link card`)}
                   accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
                   <Text style={pal.text}>
-                    Add link card:{' '}
-                    <Text style={pal.link}>{toShortUrl(url)}</Text>
+                    <Trans>Add link card:</Trans>
+                    <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
                   </Text>
                 </TouchableOpacity>
               ))}
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index c9200ec63..502e4b4d2 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -11,6 +11,8 @@ import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ExternalEmbedDraft} from 'lib/api/index'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export const ExternalEmbed = ({
   link,
@@ -21,6 +23,7 @@ export const ExternalEmbed = ({
 }) => {
   const pal = usePalette('default')
   const palError = usePalette('error')
+  const {_} = useLingui()
   if (!link) {
     return <View />
   }
@@ -64,7 +67,7 @@ export const ExternalEmbed = ({
         style={styles.removeBtn}
         onPress={onRemove}
         accessibilityRole="button"
-        accessibilityLabel="Remove image preview"
+        accessibilityLabel={_(msg`Remove image preview`)}
         accessibilityHint={`Removes default thumbnail from ${link.uri}`}
         onAccessibilityEscape={onRemove}>
         <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index e54404f52..ae055f9ac 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -3,12 +3,17 @@ import {StyleSheet, TouchableOpacity} from 'react-native'
 import {UserAvatar} from '../util/UserAvatar'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
 
 export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
-  const store = useStores()
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
   return (
     <TouchableOpacity
@@ -16,16 +21,16 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
       style={[pal.view, pal.border, styles.prompt]}
       onPress={() => onPressCompose()}
       accessibilityRole="button"
-      accessibilityLabel="Compose reply"
+      accessibilityLabel={_(msg`Compose reply`)}
       accessibilityHint="Opens composer">
-      <UserAvatar avatar={store.me.avatar} size={38} />
+      <UserAvatar avatar={profile?.avatar} size={38} />
       <Text
         type="xl"
         style={[
           pal.text,
           isDesktop ? styles.labelDesktopWeb : styles.labelMobile,
         ]}>
-        Write your reply
+        <Trans>Write your reply</Trans>
       </Text>
     </TouchableOpacity>
   )
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 96908d47f..a10684691 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -1,15 +1,16 @@
 import React from 'react'
 import {Keyboard, StyleSheet} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {Button} from 'view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {ShieldExclamation} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {isNative} from 'platform/detection'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
 
-export const LabelsBtn = observer(function LabelsBtn({
+export function LabelsBtn({
   labels,
   hasMedia,
   onChange,
@@ -19,14 +20,15 @@ export const LabelsBtn = observer(function LabelsBtn({
   onChange: (v: string[]) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   return (
     <Button
       type="default-light"
       testID="labelsBtn"
       style={[styles.button, !hasMedia && styles.dimmed]}
-      accessibilityLabel="Content warnings"
+      accessibilityLabel={_(msg`Content warnings`)}
       accessibilityHint=""
       onPress={() => {
         if (isNative) {
@@ -34,7 +36,7 @@ export const LabelsBtn = observer(function LabelsBtn({
             Keyboard.dismiss()
           }
         }
-        store.shell.openModal({name: 'self-label', labels, hasMedia, onChange})
+        openModal({name: 'self-label', labels, hasMedia, onChange})
       }}>
       <ShieldExclamation style={pal.link} size={26} />
       {labels.length > 0 ? (
@@ -46,7 +48,7 @@ export const LabelsBtn = observer(function LabelsBtn({
       ) : null}
     </Button>
   )
-})
+}
 
 const styles = StyleSheet.create({
   button: {
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index fcd99842a..69c8debb0 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -7,11 +7,13 @@ import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Image} from 'expo-image'
 import {Text} from 'view/com/util/text/Text'
-import {openAltTextModal} from 'lib/media/alt-text'
 import {Dimensions} from 'lib/media/types'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {isNative} from 'platform/detection'
 
 const IMAGE_GAP = 8
 
@@ -47,9 +49,10 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   let side: number
 
@@ -113,15 +116,18 @@ const GalleryInner = observer(function GalleryImpl({
             <TouchableOpacity
               testID="altTextButton"
               accessibilityRole="button"
-              accessibilityLabel="Add alt text"
+              accessibilityLabel={_(msg`Add alt text`)}
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
               <Text style={styles.altTextControlLabel} accessible={false}>
-                ALT
+                <Trans>ALT</Trans>
               </Text>
               {image.altText.length > 0 ? (
                 <FontAwesomeIcon
@@ -135,9 +141,19 @@ const GalleryInner = observer(function GalleryImpl({
               <TouchableOpacity
                 testID="editPhotoButton"
                 accessibilityRole="button"
-                accessibilityLabel="Edit image"
+                accessibilityLabel={_(msg`Edit image`)}
                 accessibilityHint=""
-                onPress={() => gallery.edit(image)}
+                onPress={() => {
+                  if (isNative) {
+                    gallery.crop(image)
+                  } else {
+                    openModal({
+                      name: 'edit-image',
+                      image,
+                      gallery,
+                    })
+                  }
+                }}
                 style={styles.imageControl}>
                 <FontAwesomeIcon
                   icon="pen"
@@ -148,7 +164,7 @@ const GalleryInner = observer(function GalleryImpl({
               <TouchableOpacity
                 testID="removePhotoButton"
                 accessibilityRole="button"
-                accessibilityLabel="Remove image"
+                accessibilityLabel={_(msg`Remove image`)}
                 accessibilityHint=""
                 onPress={() => gallery.remove(image)}
                 style={styles.imageControl}>
@@ -161,11 +177,14 @@ const GalleryInner = observer(function GalleryImpl({
             </View>
             <TouchableOpacity
               accessibilityRole="button"
-              accessibilityLabel="Add alt text"
+              accessibilityLabel={_(msg`Add alt text`)}
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={styles.altTextHiddenRegion}
             />
@@ -187,8 +206,10 @@ const GalleryInner = observer(function GalleryImpl({
           <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
         </View>
         <Text type="sm" style={[pal.textLight, s.flex1]}>
-          Alt text describes images for blind and low-vision users, and helps
-          give context to everyone.
+          <Trans>
+            Alt text describes images for blind and low-vision users, and helps
+            give context to everyone.
+          </Trans>
         </Text>
       </View>
     </>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 99e820d51..69f63c55f 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -6,13 +6,14 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {useStores} from 'state/index'
 import {openCamera} from 'lib/media/picker'
 import {useCameraPermission} from 'lib/hooks/usePermissions'
 import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
 import {GalleryModel} from 'state/models/media/gallery'
 import {isMobileWeb, isNative} from 'platform/detection'
 import {logger} from '#/logger'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = {
   gallery: GalleryModel
@@ -21,7 +22,7 @@ type Props = {
 export function OpenCameraBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const store = useStores()
+  const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
 
   const onPressTakePicture = useCallback(async () => {
@@ -31,7 +32,7 @@ export function OpenCameraBtn({gallery}: Props) {
         return
       }
 
-      const img = await openCamera(store, {
+      const img = await openCamera({
         width: POST_IMG_MAX.width,
         height: POST_IMG_MAX.height,
         freeStyleCropEnabled: true,
@@ -42,7 +43,7 @@ export function OpenCameraBtn({gallery}: Props) {
       // ignore
       logger.warn('Error using camera', {error: err})
     }
-  }, [gallery, track, store, requestCameraAccessIfNeeded])
+  }, [gallery, track, requestCameraAccessIfNeeded])
 
   const shouldShowCameraButton = isNative || isMobileWeb
   if (!shouldShowCameraButton) {
@@ -56,7 +57,7 @@ export function OpenCameraBtn({gallery}: Props) {
       style={styles.button}
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
-      accessibilityLabel="Camera"
+      accessibilityLabel={_(msg`Camera`)}
       accessibilityHint="Opens camera on device">
       <FontAwesomeIcon
         icon="camera"
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index a6826eb98..af0a22b01 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -10,6 +10,8 @@ import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
 import {GalleryModel} from 'state/models/media/gallery'
 import {HITSLOP_10} from 'lib/constants'
 import {isNative} from 'platform/detection'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = {
   gallery: GalleryModel
@@ -18,6 +20,7 @@ type Props = {
 export function SelectPhotoBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
   const onPressSelectPhotos = useCallback(async () => {
@@ -37,7 +40,7 @@ export function SelectPhotoBtn({gallery}: Props) {
       style={styles.button}
       hitSlop={HITSLOP_10}
       accessibilityRole="button"
-      accessibilityLabel="Gallery"
+      accessibilityLabel={_(msg`Gallery`)}
       accessibilityHint="Opens device photo gallery">
       <FontAwesomeIcon
         icon={['far', 'image']}
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 4faac3750..78b1e9ba2 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -1,6 +1,5 @@
 import React, {useCallback, useMemo} from 'react'
 import {StyleSheet, Keyboard} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -12,13 +11,24 @@ import {
   DropdownItemButton,
 } from 'view/com/util/forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {isNative} from 'platform/detection'
 import {codeToLanguageName} from '../../../../locale/helpers'
+import {useModalControls} from '#/state/modals'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+  toPostLanguages,
+  hasPostLanguage,
+} from '#/state/preferences/languages'
+import {t, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-export const SelectLangBtn = observer(function SelectLangBtn() {
+export function SelectLangBtn() {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
 
   const onPressMore = useCallback(async () => {
     if (isNative) {
@@ -26,11 +36,10 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
         Keyboard.dismiss()
       }
     }
-    store.shell.openModal({name: 'post-languages-settings'})
-  }, [store])
+    openModal({name: 'post-languages-settings'})
+  }, [openModal])
 
-  const postLanguagesPref = store.preferences.postLanguages
-  const postLanguagePref = store.preferences.postLanguage
+  const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
   const items: DropdownItem[] = useMemo(() => {
     let arr: DropdownItemButton[] = []
 
@@ -49,13 +58,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
 
       arr.push({
         icon:
-          langCodes.every(code => store.preferences.hasPostLanguage(code)) &&
-          langCodes.length === postLanguagesPref.length
+          langCodes.every(code =>
+            hasPostLanguage(langPrefs.postLanguage, code),
+          ) && langCodes.length === postLanguagesPref.length
             ? ['fas', 'circle-dot']
             : ['far', 'circle'],
         label: langName,
         onPress() {
-          store.preferences.setPostLanguage(commaSeparatedLangCodes)
+          setLangPrefs.setPostLanguage(commaSeparatedLangCodes)
         },
       })
     }
@@ -65,24 +75,24 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
        * Re-join here after sanitization bc postLanguageHistory is an array of
        * comma-separated strings too
        */
-      add(postLanguagePref)
+      add(langPrefs.postLanguage)
     }
 
     // comma-separted strings of lang codes that have been used in the past
-    for (const lang of store.preferences.postLanguageHistory) {
+    for (const lang of langPrefs.postLanguageHistory) {
       add(lang)
     }
 
     return [
-      {heading: true, label: 'Post language'},
+      {heading: true, label: t`Post language`},
       ...arr.slice(0, 6),
       {sep: true},
       {
-        label: 'Other...',
+        label: t`Other...`,
         onPress: onPressMore,
       },
     ]
-  }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref])
+  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref])
 
   return (
     <DropdownButton
@@ -91,7 +101,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
       items={items}
       openUpwards
       style={styles.button}
-      accessibilityLabel="Language selection"
+      accessibilityLabel={_(msg`Language selection`)}
       accessibilityHint="">
       {postLanguagesPref.length > 0 ? (
         <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}>
@@ -106,7 +116,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
       )}
     </DropdownButton>
   )
-})
+}
 
 const styles = StyleSheet.create({
   button: {
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 2810129f6..13fe3a0b3 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -3,6 +3,7 @@ import React, {
   useCallback,
   useRef,
   useMemo,
+  useState,
   ComponentProps,
 } from 'react'
 import {
@@ -18,7 +19,6 @@ import PasteInput, {
 } from '@mattermost/react-native-paste-input'
 import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import isEqual from 'lodash.isequal'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {Autocomplete} from './mobile/Autocomplete'
 import {Text} from 'view/com/util/text/Text'
 import {cleanError} from 'lib/strings/errors'
@@ -38,7 +38,6 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
   richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
-  autocompleteView: UserAutocompleteModel
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
@@ -56,7 +55,6 @@ export const TextInput = forwardRef(function TextInputImpl(
     richtext,
     placeholder,
     suggestedLinks,
-    autocompleteView,
     setRichText,
     onPhotoPasted,
     onSuggestedLinksChanged,
@@ -69,6 +67,7 @@ export const TextInput = forwardRef(function TextInputImpl(
   const textInput = useRef<PasteInputRef>(null)
   const textInputSelection = useRef<Selection>({start: 0, end: 0})
   const theme = useTheme()
+  const [autocompletePrefix, setAutocompletePrefix] = useState('')
 
   React.useImperativeHandle(ref, () => ({
     focus: () => textInput.current?.focus(),
@@ -99,10 +98,9 @@ export const TextInput = forwardRef(function TextInputImpl(
           textInputSelection.current?.start || 0,
         )
         if (prefix) {
-          autocompleteView.setActive(true)
-          autocompleteView.setPrefix(prefix.value)
-        } else {
-          autocompleteView.setActive(false)
+          setAutocompletePrefix(prefix.value)
+        } else if (autocompletePrefix) {
+          setAutocompletePrefix('')
         }
 
         const set: Set<string> = new Set()
@@ -139,7 +137,8 @@ export const TextInput = forwardRef(function TextInputImpl(
     },
     [
       setRichText,
-      autocompleteView,
+      autocompletePrefix,
+      setAutocompletePrefix,
       suggestedLinks,
       onSuggestedLinksChanged,
       onPhotoPasted,
@@ -179,9 +178,9 @@ export const TextInput = forwardRef(function TextInputImpl(
           item,
         ),
       )
-      autocompleteView.setActive(false)
+      setAutocompletePrefix('')
     },
-    [onChangeText, richtext, autocompleteView],
+    [onChangeText, richtext, setAutocompletePrefix],
   )
 
   const textDecorated = useMemo(() => {
@@ -221,7 +220,7 @@ export const TextInput = forwardRef(function TextInputImpl(
         {textDecorated}
       </PasteInput>
       <Autocomplete
-        view={autocompleteView}
+        prefix={autocompletePrefix}
         onSelect={onSelectAutocompleteItem}
       />
     </View>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 35482bc70..4c31da338 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -11,13 +11,13 @@ import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text} from '@tiptap/extension-text'
 import isEqual from 'lodash.isequal'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {isUriImage, blobToDataUri} from 'lib/media/util'
 import {Emoji} from './web/EmojiPicker.web'
 import {LinkDecorator} from './web/LinkDecorator'
 import {generateJSON} from '@tiptap/html'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 
 export interface TextInputRef {
   focus: () => void
@@ -28,7 +28,6 @@ interface TextInputProps {
   richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
-  autocompleteView: UserAutocompleteModel
   setRichText: (v: RichText | ((v: RichText) => RichText)) => void
   onPhotoPasted: (uri: string) => void
   onPressPublish: (richtext: RichText) => Promise<void>
@@ -43,7 +42,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     richtext,
     placeholder,
     suggestedLinks,
-    autocompleteView,
     setRichText,
     onPhotoPasted,
     onPressPublish,
@@ -52,6 +50,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   TextInputProps,
   ref,
 ) {
+  const autocomplete = useActorAutocompleteFn()
+
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
   const extensions = React.useMemo(
     () => [
@@ -61,7 +61,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         HTMLAttributes: {
           class: 'mention',
         },
-        suggestion: createSuggestion({autocompleteView}),
+        suggestion: createSuggestion({autocomplete}),
       }),
       Paragraph,
       Placeholder.configure({
@@ -71,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       History,
       Hardbreak,
     ],
-    [autocompleteView, placeholder],
+    [autocomplete, placeholder],
   )
 
   React.useEffect(() => {
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index f8335d4b9..c400aa48d 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -1,31 +1,40 @@
-import React, {useEffect} from 'react'
+import React, {useEffect, useRef} from 'react'
 import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {useGrapheme} from '../hooks/useGrapheme'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
+import {Trans} from '@lingui/macro'
+import {AppBskyActorDefs} from '@atproto/api'
 
-export const Autocomplete = observer(function AutocompleteImpl({
-  view,
+export function Autocomplete({
+  prefix,
   onSelect,
 }: {
-  view: UserAutocompleteModel
+  prefix: string
   onSelect: (item: string) => void
 }) {
   const pal = usePalette('default')
   const positionInterp = useAnimatedValue(0)
   const {getGraphemeString} = useGrapheme()
+  const isActive = !!prefix
+  const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
+  const suggestionsRef = useRef<
+    AppBskyActorDefs.ProfileViewBasic[] | undefined
+  >(undefined)
+  if (suggestions) {
+    suggestionsRef.current = suggestions
+  }
 
   useEffect(() => {
     Animated.timing(positionInterp, {
-      toValue: view.isActive ? 1 : 0,
+      toValue: isActive ? 1 : 0,
       duration: 200,
       useNativeDriver: true,
     }).start()
-  }, [positionInterp, view.isActive])
+  }, [positionInterp, isActive])
 
   const topAnimStyle = {
     transform: [
@@ -40,10 +49,10 @@ export const Autocomplete = observer(function AutocompleteImpl({
 
   return (
     <Animated.View style={topAnimStyle}>
-      {view.isActive ? (
+      {isActive ? (
         <View style={[pal.view, styles.container, pal.border]}>
-          {view.suggestions.length > 0 ? (
-            view.suggestions.slice(0, 5).map(item => {
+          {suggestionsRef.current?.length ? (
+            suggestionsRef.current.slice(0, 5).map(item => {
               // Eventually use an average length
               const MAX_CHARS = 40
               const MAX_HANDLE_CHARS = 20
@@ -82,14 +91,18 @@ export const Autocomplete = observer(function AutocompleteImpl({
             })
           ) : (
             <Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
-              No result
+              {isFetching ? (
+                <Trans>Loading...</Trans>
+              ) : (
+                <Trans>No result</Trans>
+              )}
             </Text>
           )}
         </View>
       ) : null}
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index bbed26d48..1f7412561 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -12,7 +12,7 @@ import {
   SuggestionProps,
   SuggestionKeyDownProps,
 } from '@tiptap/suggestion'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
+import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
@@ -23,15 +23,14 @@ interface MentionListRef {
 }
 
 export function createSuggestion({
-  autocompleteView,
+  autocomplete,
 }: {
-  autocompleteView: UserAutocompleteModel
+  autocomplete: ActorAutocompleteFn
 }): Omit<SuggestionOptions, 'editor'> {
   return {
     async items({query}) {
-      autocompleteView.setActive(true)
-      await autocompleteView.setPrefix(query)
-      return autocompleteView.suggestions.slice(0, 8)
+      const suggestions = await autocomplete({query})
+      return suggestions.slice(0, 8)
     },
 
     render: () => {
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index eda1a6704..ef3958c9d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -1,5 +1,4 @@
 import {useState, useEffect} from 'react'
-import {useStores} from 'state/index'
 import {ImageModel} from 'state/models/media/image'
 import * as apilib from 'lib/api/index'
 import {getLinkMeta} from 'lib/link-meta/link-meta'
@@ -14,19 +13,21 @@ import {
   isBskyCustomFeedUrl,
   isBskyListUrl,
 } from 'lib/strings/url-helpers'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {ComposerOpts} from 'state/shell/composer'
 import {POST_IMG_MAX} from 'lib/constants'
 import {logger} from '#/logger'
+import {getAgent} from '#/state/session'
+import {useGetPost} from '#/state/queries/post'
 
 export function useExternalLinkFetch({
   setQuote,
 }: {
   setQuote: (opts: ComposerOpts['quote']) => void
 }) {
-  const store = useStores()
   const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
     undefined,
   )
+  const getPost = useGetPost()
 
   useEffect(() => {
     let aborted = false
@@ -38,7 +39,7 @@ export function useExternalLinkFetch({
     }
     if (!extLink.meta) {
       if (isBskyPostUrl(extLink.uri)) {
-        getPostAsQuote(store, extLink.uri).then(
+        getPostAsQuote(getPost, extLink.uri).then(
           newQuote => {
             if (aborted) {
               return
@@ -48,13 +49,13 @@ export function useExternalLinkFetch({
           },
           err => {
             logger.error('Failed to fetch post for quote embedding', {
-              error: err,
+              error: err.toString(),
             })
             setExtLink(undefined)
           },
         )
       } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(store, extLink.uri).then(
+        getFeedAsEmbed(getAgent(), extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -72,7 +73,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(store, extLink.uri).then(
+        getListAsEmbed(getAgent(), extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -90,7 +91,7 @@ export function useExternalLinkFetch({
           },
         )
       } else {
-        getLinkMeta(store, extLink.uri).then(meta => {
+        getLinkMeta(getAgent(), extLink.uri).then(meta => {
           if (aborted) {
             return
           }
@@ -120,9 +121,7 @@ export function useExternalLinkFetch({
           setExtLink({
             ...extLink,
             isLoading: false, // done
-            localThumb: localThumb
-              ? new ImageModel(store, localThumb)
-              : undefined,
+            localThumb: localThumb ? new ImageModel(localThumb) : undefined,
           })
         })
       return cleanup
@@ -134,7 +133,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [store, extLink, setQuote])
+  }, [extLink, setQuote, getPost])
 
   return {extLink, setExtLink}
 }
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 1037007b7..f3f07a8bd 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -4,74 +4,56 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {useIsFocused} from '@react-navigation/native'
 import {useAnalytics} from '@segment/analytics-react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {ComposeIcon2} from 'lib/icons'
 import {colors, s} from 'lib/styles'
-import {observer} from 'mobx-react-lite'
 import React from 'react'
-import {FlatList, View} from 'react-native'
-import {useStores} from 'state/index'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
+import {FlatList, View, useWindowDimensions} from 'react-native'
 import {Feed} from '../posts/Feed'
 import {TextLink} from '../util/Link'
 import {FAB} from '../util/fab/FAB'
 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
-import useAppState from 'react-native-appstate-hook'
-import {logger} from '#/logger'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
+import {listenSoftReset, emitSoftReset} from '#/state/events'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
-export const FeedPage = observer(function FeedPageImpl({
+const POLL_FREQ = 30e3 // 30sec
+
+export function FeedPage({
   testID,
   isPageFocused,
   feed,
+  feedParams,
   renderEmptyState,
   renderEndOfFeed,
 }: {
   testID?: string
-  feed: PostsFeedModel
+  feed: FeedDescriptor
+  feedParams?: FeedParams
   isPageFocused: boolean
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
 }) {
-  const store = useStores()
+  const {isSandbox, hasSession} = useSession()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
+  const queryClient = useQueryClient()
+  const {openComposer} = useComposerControls()
   const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
   const scrollElRef = React.useRef<FlatList>(null)
-  const {appState} = useAppState({
-    onForeground: () => doPoll(true),
-  })
   const isScreenFocused = useIsFocused()
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
-
-  React.useEffect(() => {
-    // called on first load
-    if (!feed.hasLoaded && isPageFocused) {
-      feed.setup()
-    }
-  }, [isPageFocused, feed])
-
-  const doPoll = React.useCallback(
-    (knownActive = false) => {
-      if (
-        (!knownActive && appState !== 'active') ||
-        !isScreenFocused ||
-        !isPageFocused
-      ) {
-        return
-      }
-      if (feed.isLoading) {
-        return
-      }
-      logger.debug('HomeScreen: Polling for new posts')
-      feed.checkForLatest()
-    },
-    [appState, isScreenFocused, isPageFocused, feed],
-  )
+  const [hasNew, setHasNew] = React.useState(false)
 
   const scrollToTop = React.useCallback(() => {
     scrollElRef.current?.scrollToOffset({offset: -headerOffset})
@@ -81,41 +63,30 @@ export const FeedPage = observer(function FeedPageImpl({
   const onSoftReset = React.useCallback(() => {
     if (isPageFocused) {
       scrollToTop()
-      feed.refresh()
+      truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+      setHasNew(false)
     }
-  }, [isPageFocused, scrollToTop, feed])
+  }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
 
   // fires when page within screen is activated/deactivated
-  // - check for latest
   React.useEffect(() => {
     if (!isPageFocused || !isScreenFocused) {
       return
     }
-
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const feedCleanup = feed.registerListeners()
-    const pollInterval = setInterval(doPoll, POLL_FREQ)
-
     screen('Feed')
-    logger.debug('HomeScreen: Updating feed')
-    feed.checkForLatest()
-
-    return () => {
-      clearInterval(pollInterval)
-      softResetSub.remove()
-      feedCleanup()
-    }
-  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
+    return listenSoftReset(onSoftReset)
+  }, [onSoftReset, screen, isPageFocused, isScreenFocused])
 
   const onPressCompose = React.useCallback(() => {
     track('HomeScreen:PressCompose')
-    store.shell.openComposer({})
-  }, [store, track])
+    openComposer({})
+  }, [openComposer, track])
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    feed.refresh()
-  }, [feed, scrollToTop])
+    truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+    setHasNew(false)
+  }, [scrollToTop, feed, queryClient, setHasNew])
 
   const ListHeaderComponent = React.useCallback(() => {
     if (isDesktop) {
@@ -137,7 +108,7 @@ export const FeedPage = observer(function FeedPageImpl({
             style={[pal.text, {fontWeight: 'bold'}]}
             text={
               <>
-                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
                 {hasNew && (
                   <View
                     style={{
@@ -151,35 +122,50 @@ export const FeedPage = observer(function FeedPageImpl({
                 )}
               </>
             }
-            onPress={() => store.emitScreenSoftReset()}
-          />
-          <TextLink
-            type="title-lg"
-            href="/settings/home-feed"
-            style={{fontWeight: 'bold'}}
-            accessibilityLabel="Feed Preferences"
-            accessibilityHint=""
-            text={
-              <FontAwesomeIcon
-                icon="sliders"
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            }
+            onPress={emitSoftReset}
           />
+          {hasSession && (
+            <TextLink
+              type="title-lg"
+              href="/settings/home-feed"
+              style={{fontWeight: 'bold'}}
+              accessibilityLabel={_(msg`Feed Preferences`)}
+              accessibilityHint=""
+              text={
+                <FontAwesomeIcon
+                  icon="sliders"
+                  style={pal.textLight as FontAwesomeIconStyle}
+                />
+              }
+            />
+          )}
         </View>
       )
     }
     return <></>
-  }, [isDesktop, pal, store, hasNew])
+  }, [
+    isDesktop,
+    pal.view,
+    pal.text,
+    pal.textLight,
+    hasNew,
+    _,
+    isSandbox,
+    hasSession,
+  ])
 
   return (
     <View testID={testID} style={s.h100pct}>
       <Feed
         testID={testID ? `${testID}-feed` : undefined}
+        enabled={isPageFocused}
         feed={feed}
+        feedParams={feedParams}
+        pollInterval={POLL_FREQ}
         scrollElRef={scrollElRef}
         onScroll={onMainScroll}
-        scrollEventThrottle={100}
+        onHasNew={setHasNew}
+        scrollEventThrottle={1}
         renderEmptyState={renderEmptyState}
         renderEndOfFeed={renderEndOfFeed}
         ListHeaderComponent={ListHeaderComponent}
@@ -188,18 +174,52 @@ export const FeedPage = observer(function FeedPageImpl({
       {(isScrolledDown || hasNew) && (
         <LoadLatestBtn
           onPress={onPressLoadLatest}
-          label="Load new posts"
+          label={_(msg`Load new posts`)}
           showIndicator={hasNew}
         />
       )}
-      <FAB
-        testID="composeFAB"
-        onPress={onPressCompose}
-        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-        accessibilityRole="button"
-        accessibilityLabel="New post"
-        accessibilityHint=""
-      />
+
+      {hasSession && (
+        <FAB
+          testID="composeFAB"
+          onPress={onPressCompose}
+          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`New post`)}
+          accessibilityHint=""
+        />
+      )}
     </View>
   )
-})
+}
+
+function useHeaderOffset() {
+  const {isDesktop, isTablet} = useWebMediaQueries()
+  const {fontScale} = useWindowDimensions()
+  const {hasSession} = useSession()
+
+  if (isDesktop) {
+    return 0
+  }
+  if (isTablet) {
+    if (hasSession) {
+      return 50
+    } else {
+      return 0
+    }
+  }
+
+  if (hasSession) {
+    const navBarPad = 16
+    const navBarText = 21 * fontScale
+    const tabBarPad = 20 + 3 // nav bar padding + border
+    const tabBarText = 16 * fontScale
+    const magic = 7 * fontScale
+    return navBarPad + navBarText + tabBarPad + tabBarText + magic
+  } else {
+    const navBarPad = 16
+    const navBarText = 21 * fontScale
+    const magic = 4 * fontScale
+    return navBarPad + navBarText + magic
+  }
+}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 2c4335dc1..1f2af069b 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -6,43 +6,110 @@ import {RichText} from '../util/text/RichText'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {UserAvatar} from '../util/UserAvatar'
-import {observer} from 'mobx-react-lite'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useStores} from 'state/index'
 import {pluralize} from 'lib/strings/helpers'
 import {AtUri} from '@atproto/api'
 import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePinFeedMutation,
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+  useSaveFeedMutation,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
-export const FeedSourceCard = observer(function FeedSourceCardImpl({
-  item,
+export function FeedSourceCard({
+  feedUri,
   style,
   showSaveBtn = false,
   showDescription = false,
   showLikes = false,
+  LoadingComponent,
+  pinOnSave = false,
 }: {
-  item: FeedSourceModel
+  feedUri: string
   style?: StyleProp<ViewStyle>
   showSaveBtn?: boolean
   showDescription?: boolean
   showLikes?: boolean
+  LoadingComponent?: JSX.Element
+  pinOnSave?: boolean
+}) {
+  const {data: preferences} = usePreferencesQuery()
+  const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!feed || !preferences) {
+    return LoadingComponent ? (
+      LoadingComponent
+    ) : (
+      <FeedLoadingPlaceholder style={{flex: 1}} />
+    )
+  }
+
+  return (
+    <FeedSourceCardLoaded
+      feed={feed}
+      preferences={preferences}
+      style={style}
+      showSaveBtn={showSaveBtn}
+      showDescription={showDescription}
+      showLikes={showLikes}
+      pinOnSave={pinOnSave}
+    />
+  )
+}
+
+export function FeedSourceCardLoaded({
+  feed,
+  preferences,
+  style,
+  showSaveBtn = false,
+  showDescription = false,
+  showLikes = false,
+  pinOnSave = false,
+}: {
+  feed: FeedSourceInfo
+  preferences: UsePreferencesQueryResponse
+  style?: StyleProp<ViewStyle>
+  showSaveBtn?: boolean
+  showDescription?: boolean
+  showLikes?: boolean
+  pinOnSave?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
+  const {openModal} = useModalControls()
+
+  const {isPending: isSavePending, mutateAsync: saveFeed} =
+    useSaveFeedMutation()
+  const {isPending: isRemovePending, mutateAsync: removeFeed} =
+    useRemoveFeedMutation()
+  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
+
+  const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri))
 
   const onToggleSaved = React.useCallback(async () => {
-    if (item.isSaved) {
-      store.shell.openModal({
+    // Only feeds can be un/saved, lists are handled elsewhere
+    if (feed?.type !== 'feed') return
+
+    if (isSaved) {
+      openModal({
         name: 'confirm',
-        title: 'Remove from my feeds',
-        message: `Remove ${item.displayName} from my feeds?`,
+        title: _(msg`Remove from my feeds`),
+        message: _(msg`Remove ${feed.displayName} from my feeds?`),
         onPressConfirm: async () => {
           try {
-            await item.unsave()
+            await removeFeed({uri: feed.uri})
+            // await item.unsave()
             Toast.show('Removed from my feeds')
           } catch (e) {
             Toast.show('There was an issue contacting your server')
@@ -52,58 +119,67 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
       })
     } else {
       try {
-        await item.save()
+        if (pinOnSave) {
+          await pinFeed({uri: feed.uri})
+        } else {
+          await saveFeed({uri: feed.uri})
+        }
         Toast.show('Added to my feeds')
       } catch (e) {
         Toast.show('There was an issue contacting your server')
         logger.error('Failed to save feed', {error: e})
       }
     }
-  }, [store, item])
+  }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
+
+  if (!feed || !preferences) return null
 
   return (
     <Pressable
-      testID={`feed-${item.displayName}`}
+      testID={`feed-${feed.displayName}`}
       accessibilityRole="button"
       style={[styles.container, pal.border, style]}
       onPress={() => {
-        if (item.type === 'feed-generator') {
+        if (feed.type === 'feed') {
           navigation.push('ProfileFeed', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
+            name: feed.creatorDid,
+            rkey: new AtUri(feed.uri).rkey,
           })
-        } else if (item.type === 'list') {
+        } else if (feed.type === 'list') {
           navigation.push('ProfileList', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
+            name: feed.creatorDid,
+            rkey: new AtUri(feed.uri).rkey,
           })
         }
       }}
-      key={item.uri}>
+      key={feed.uri}>
       <View style={[styles.headerContainer]}>
         <View style={[s.mr10]}>
-          <UserAvatar type="algo" size={36} avatar={item.avatar} />
+          <UserAvatar type="algo" size={36} avatar={feed.avatar} />
         </View>
         <View style={[styles.headerTextContainer]}>
           <Text style={[pal.text, s.bold]} numberOfLines={3}>
-            {item.displayName}
+            {feed.displayName}
           </Text>
           <Text style={[pal.textLight]} numberOfLines={3}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
+            {feed.type === 'feed' ? 'Feed' : 'List'} by{' '}
+            {sanitizeHandle(feed.creatorHandle, '@')}
           </Text>
         </View>
-        {showSaveBtn && (
+
+        {showSaveBtn && feed.type === 'feed' && (
           <View>
             <Pressable
+              disabled={isSavePending || isPinPending || isRemovePending}
               accessibilityRole="button"
               accessibilityLabel={
-                item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
+                isSaved ? 'Remove from my feeds' : 'Add to my feeds'
               }
               accessibilityHint=""
               onPress={onToggleSaved}
               hitSlop={15}
               style={styles.btn}>
-              {item.isSaved ? (
+              {isSaved ? (
                 <FontAwesomeIcon
                   icon={['far', 'trash-can']}
                   size={19}
@@ -121,23 +197,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
         )}
       </View>
 
-      {showDescription && item.descriptionRT ? (
+      {showDescription && feed.description ? (
         <RichText
           style={[pal.textLight, styles.description]}
-          richText={item.descriptionRT}
+          richText={feed.description}
           numberOfLines={3}
         />
       ) : null}
 
-      {showLikes ? (
+      {showLikes && feed.type === 'feed' ? (
         <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-          Liked by {item.likeCount || 0}{' '}
-          {pluralize(item.likeCount || 0, 'user')}
+          Liked by {feed.likeCount || 0}{' '}
+          {pluralize(feed.likeCount || 0, 'user')}
         </Text>
       ) : null}
     </Pressable>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
new file mode 100644
index 000000000..618f4e5cd
--- /dev/null
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -0,0 +1,222 @@
+import React, {MutableRefObject} from 'react'
+import {
+  Dimensions,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {FlatList} from '../util/Views'
+import {FeedSourceCardLoaded} from './FeedSourceCard'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {logger} from '#/logger'
+import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
+import {useAnimatedScrollHandler} from 'react-native-reanimated'
+import {useTheme} from '#/lib/ThemeContext'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {hydrateFeedGenerator} from '#/state/queries/feed'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+
+const LOADING = {_reactKey: '__loading__'}
+const EMPTY = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+interface ProfileFeedgensProps {
+  did: string
+  scrollElRef: MutableRefObject<FlatList<any> | null>
+  onScroll?: OnScrollHandler
+  scrollEventThrottle?: number
+  headerOffset: number
+  enabled?: boolean
+  style?: StyleProp<ViewStyle>
+  testID?: string
+}
+
+export const ProfileFeedgens = React.forwardRef<
+  SectionRef,
+  ProfileFeedgensProps
+>(function ProfileFeedgensImpl(
+  {
+    did,
+    scrollElRef,
+    onScroll,
+    scrollEventThrottle,
+    headerOffset,
+    enabled,
+    style,
+    testID,
+  },
+  ref,
+) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const opts = React.useMemo(() => ({enabled}), [enabled])
+  const {
+    data,
+    isFetching,
+    isFetched,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFeedgensQuery(did, opts)
+  const isEmpty = !isFetching && !data?.pages[0]?.feeds.length
+  const {data: preferences} = usePreferencesQuery()
+
+  const items = React.useMemo(() => {
+    let items: any[] = []
+    if (isError && isEmpty) {
+      items = items.concat([ERROR_ITEM])
+    }
+    if (!isFetched && isFetching) {
+      items = items.concat([LOADING])
+    } else if (isEmpty) {
+      items = items.concat([EMPTY])
+    } else if (data?.pages) {
+      for (const page of data?.pages) {
+        items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed)))
+      }
+    }
+    if (isError && !isEmpty) {
+      items = items.concat([LOAD_MORE_ERROR_ITEM])
+    }
+    return items
+  }, [isError, isEmpty, isFetched, isFetching, data])
+
+  // events
+  // =
+
+  const queryClient = useQueryClient()
+
+  const onScrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
+    queryClient.invalidateQueries({queryKey: RQKEY(did)})
+  }, [scrollElRef, queryClient, headerOffset, did])
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh feeds', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more feeds', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const onPressRetryLoadMore = React.useCallback(() => {
+    fetchNextPage()
+  }, [fetchNextPage])
+
+  // rendering
+  // =
+
+  const renderItemInner = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === EMPTY) {
+        return (
+          <View
+            testID="listsEmpty"
+            style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
+            <Text style={pal.textLight}>
+              <Trans>You have no feeds.</Trans>
+            </Text>
+          </View>
+        )
+      } else if (item === ERROR_ITEM) {
+        return (
+          <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
+        )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching your lists. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
+      } else if (item === LOADING) {
+        return <FeedLoadingPlaceholder />
+      }
+      if (preferences) {
+        return (
+          <FeedSourceCardLoaded
+            feed={item}
+            preferences={preferences}
+            style={styles.item}
+            showLikes
+          />
+        )
+      }
+      return null
+    },
+    [error, refetch, onPressRetryLoadMore, pal, preferences],
+  )
+
+  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
+  return (
+    <View testID={testID} style={style}>
+      <FlatList
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={(item: any) => item._reactKey || item.uri}
+        renderItem={renderItemInner}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+            progressViewOffset={headerOffset}
+          />
+        }
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
+        style={{paddingTop: headerOffset}}
+        onScroll={onScroll != null ? scrollHandler : undefined}
+        scrollEventThrottle={scrollEventThrottle}
+        indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
+        removeClippedSubviews={true}
+        contentOffset={{x: 0, y: headerOffset * -1}}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+        onEndReached={onEndReached}
+      />
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  item: {
+    paddingHorizontal: 18,
+  },
+})
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index bb006d506..c806bc6a6 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -5,10 +5,10 @@
  * LICENSE file in the root directory of this source tree.
  *
  */
-
-import {createHitslop} from 'lib/constants'
 import React from 'react'
+import {createHitslop} from 'lib/constants'
 import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native'
+import {t} from '@lingui/macro'
 
 type Props = {
   onRequestClose: () => void
@@ -23,7 +23,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
       onPress={onRequestClose}
       hitSlop={HIT_SLOP}
       accessibilityRole="button"
-      accessibilityLabel="Close image"
+      accessibilityLabel={t`Close image`}
       accessibilityHint="Closes viewer for header image"
       onAccessibilityEscape={onRequestClose}>
       <Text style={styles.closeText}>✕</Text>
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index 7c7ad0616..ea740ec91 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -315,7 +315,6 @@ const ImageItem = ({
       <GestureDetector gesture={composedGesture}>
         <AnimatedImage
           contentFit="contain"
-          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
           source={{uri: imageSrc.uri}}
           style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
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 f73f355ac..2b0b0b149 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -139,7 +139,6 @@ const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
         {(!loaded || !imageDimensions) && <ImageLoading />}
         <AnimatedImage
           contentFit="contain"
-          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
           source={{uri: imageSrc.uri}}
           style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 92c30f491..8a18df33f 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,10 +1,7 @@
 import React from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import ImageView from './ImageViewing'
-import {useStores} from 'state/index'
-import * as models from 'state/models/ui/shell'
 import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
 import * as Toast from '../util/Toast'
 import {Text} from '../util/text/Text'
@@ -12,28 +9,35 @@ import {s, colors} from 'lib/styles'
 import {Button} from '../util/forms/Button'
 import {isIOS} from 'platform/detection'
 import * as MediaLibrary from 'expo-media-library'
+import {
+  useLightbox,
+  useLightboxControls,
+  ProfileImageLightbox,
+  ImagesLightbox,
+} from '#/state/lightbox'
 
-export const Lightbox = observer(function Lightbox() {
-  const store = useStores()
+export function Lightbox() {
+  const {activeLightbox} = useLightbox()
+  const {closeLightbox} = useLightboxControls()
   const onClose = React.useCallback(() => {
-    store.shell.closeLightbox()
-  }, [store])
+    closeLightbox()
+  }, [closeLightbox])
 
-  if (!store.shell.activeLightbox) {
+  if (!activeLightbox) {
     return null
-  } else if (store.shell.activeLightbox.name === 'profile-image') {
-    const opts = store.shell.activeLightbox as models.ProfileImageLightbox
+  } else if (activeLightbox.name === 'profile-image') {
+    const opts = activeLightbox as ProfileImageLightbox
     return (
       <ImageView
-        images={[{uri: opts.profileView.avatar || ''}]}
+        images={[{uri: opts.profile.avatar || ''}]}
         initialImageIndex={0}
         visible
         onRequestClose={onClose}
         FooterComponent={LightboxFooter}
       />
     )
-  } else if (store.shell.activeLightbox.name === 'images') {
-    const opts = store.shell.activeLightbox as models.ImagesLightbox
+  } else if (activeLightbox.name === 'images') {
+    const opts = activeLightbox as ImagesLightbox
     return (
       <ImageView
         images={opts.images.map(img => ({...img}))}
@@ -46,14 +50,10 @@ export const Lightbox = observer(function Lightbox() {
   } else {
     return null
   }
-})
+}
 
-const LightboxFooter = observer(function LightboxFooter({
-  imageIndex,
-}: {
-  imageIndex: number
-}) {
-  const store = useStores()
+function LightboxFooter({imageIndex}: {imageIndex: number}) {
+  const {activeLightbox} = useLightbox()
   const [isAltExpanded, setAltExpanded] = React.useState(false)
   const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
 
@@ -81,7 +81,7 @@ const LightboxFooter = observer(function LightboxFooter({
     [permissionResponse, requestPermission],
   )
 
-  const lightbox = store.shell.activeLightbox
+  const lightbox = activeLightbox
   if (!lightbox) {
     return null
   }
@@ -89,12 +89,12 @@ const LightboxFooter = observer(function LightboxFooter({
   let altText = ''
   let uri = ''
   if (lightbox.name === 'images') {
-    const opts = lightbox as models.ImagesLightbox
+    const opts = lightbox as ImagesLightbox
     uri = opts.images[imageIndex].uri
     altText = opts.images[imageIndex].alt || ''
   } else if (lightbox.name === 'profile-image') {
-    const opts = lightbox as models.ProfileImageLightbox
-    uri = opts.profileView.avatar || ''
+    const opts = lightbox as ProfileImageLightbox
+    uri = opts.profile.avatar || ''
   }
 
   return (
@@ -132,7 +132,7 @@ const LightboxFooter = observer(function LightboxFooter({
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index ddf965f42..45e1fa5a3 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -7,39 +7,42 @@ import {
   View,
   Pressable,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useStores} from 'state/index'
-import * as models from 'state/models/ui/shell'
 import {colors, s} from 'lib/styles'
 import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
 import {Text} from '../util/text/Text'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {
+  useLightbox,
+  useLightboxControls,
+  ImagesLightbox,
+  ProfileImageLightbox,
+} from '#/state/lightbox'
 
 interface Img {
   uri: string
   alt?: string
 }
 
-export const Lightbox = observer(function Lightbox() {
-  const store = useStores()
-
-  const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell])
+export function Lightbox() {
+  const {activeLightbox} = useLightbox()
+  const {closeLightbox} = useLightboxControls()
 
-  if (!store.shell.isLightboxActive) {
+  if (!activeLightbox) {
     return null
   }
 
-  const activeLightbox = store.shell.activeLightbox
   const initialIndex =
-    activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0
+    activeLightbox instanceof ImagesLightbox ? activeLightbox.index : 0
 
   let imgs: Img[] | undefined
-  if (activeLightbox instanceof models.ProfileImageLightbox) {
+  if (activeLightbox instanceof ProfileImageLightbox) {
     const opts = activeLightbox
-    if (opts.profileView.avatar) {
-      imgs = [{uri: opts.profileView.avatar}]
+    if (opts.profile.avatar) {
+      imgs = [{uri: opts.profile.avatar}]
     }
-  } else if (activeLightbox instanceof models.ImagesLightbox) {
+  } else if (activeLightbox instanceof ImagesLightbox) {
     const opts = activeLightbox
     imgs = opts.images
   }
@@ -49,9 +52,13 @@ export const Lightbox = observer(function Lightbox() {
   }
 
   return (
-    <LightboxInner imgs={imgs} initialIndex={initialIndex} onClose={onClose} />
+    <LightboxInner
+      imgs={imgs}
+      initialIndex={initialIndex}
+      onClose={closeLightbox}
+    />
   )
-})
+}
 
 function LightboxInner({
   imgs,
@@ -62,6 +69,7 @@ function LightboxInner({
   initialIndex: number
   onClose: () => void
 }) {
+  const {_} = useLingui()
   const [index, setIndex] = useState<number>(initialIndex)
   const [isAltExpanded, setAltExpanded] = useState(false)
 
@@ -101,7 +109,7 @@ function LightboxInner({
       <TouchableWithoutFeedback
         onPress={onClose}
         accessibilityRole="button"
-        accessibilityLabel="Close image viewer"
+        accessibilityLabel={_(msg`Close image viewer`)}
         accessibilityHint="Exits image view"
         onAccessibilityEscape={onClose}>
         <View style={styles.imageCenterer}>
@@ -117,7 +125,7 @@ function LightboxInner({
               onPress={onPressLeft}
               style={[styles.btn, styles.leftBtn]}
               accessibilityRole="button"
-              accessibilityLabel="Previous image"
+              accessibilityLabel={_(msg`Previous image`)}
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-left"
@@ -131,7 +139,7 @@ function LightboxInner({
               onPress={onPressRight}
               style={[styles.btn, styles.rightBtn]}
               accessibilityRole="button"
-              accessibilityLabel="Next image"
+              accessibilityLabel={_(msg`Next image`)}
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-right"
@@ -145,7 +153,7 @@ function LightboxInner({
       {imgs[index].alt ? (
         <View style={styles.footer}>
           <Pressable
-            accessibilityLabel="Expand alt text"
+            accessibilityLabel={_(msg`Expand alt text`)}
             accessibilityHint="If alt text is long, toggles alt text expanded state"
             onPress={() => {
               setAltExpanded(!isAltExpanded)
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
index a481902d8..774e9e916 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -7,7 +7,7 @@ import {RichText as RichTextCom} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
+import {useSession} from '#/state/session'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
@@ -28,7 +28,7 @@ export const ListCard = ({
   style?: StyleProp<ViewStyle>
 }) => {
   const pal = usePalette('default')
-  const store = useStores()
+  const {currentAccount} = useSession()
 
   const rkey = React.useMemo(() => {
     try {
@@ -80,7 +80,7 @@ export const ListCard = ({
             {list.purpose === 'app.bsky.graph.defs#modlist' &&
               'Moderation list '}
             by{' '}
-            {list.creator.did === store.me.did
+            {list.creator.did === currentAccount?.did
               ? 'you'
               : sanitizeHandle(list.creator.handle, '@')}
           </Text>
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx
index 192cdd9d3..e6afb3d3c 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -1,6 +1,7 @@
 import React, {MutableRefObject} from 'react'
 import {
   ActivityIndicator,
+  Dimensions,
   RefreshControl,
   StyleProp,
   View,
@@ -8,27 +9,28 @@ import {
 } from 'react-native'
 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {FlatList} from '../util/Views'
-import {observer} from 'mobx-react-lite'
 import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {ProfileCard} from '../profile/ProfileCard'
 import {Button} from '../util/forms/Button'
-import {ListModel} from 'state/models/content/list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {useListMembersQuery} from '#/state/queries/list-members'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useSession} from '#/state/session'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListItems = observer(function ListItemsImpl({
+export function ListMembers({
   list,
   style,
   scrollElRef,
@@ -41,10 +43,10 @@ export const ListItems = observer(function ListItemsImpl({
   headerOffset = 0,
   desktopFixedHeightOffset,
 }: {
-  list: ListModel
+  list: string
   style?: StyleProp<ViewStyle>
   scrollElRef?: MutableRefObject<FlatList<any> | null>
-  onScroll?: OnScrollCb
+  onScroll: OnScrollHandler
   onPressTryAgain?: () => void
   renderHeader: () => JSX.Element
   renderEmptyState: () => JSX.Element
@@ -54,37 +56,47 @@ export const ListItems = observer(function ListItemsImpl({
   desktopFixedHeightOffset?: number
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
+  const {currentAccount} = useSession()
 
-  const data = React.useMemo(() => {
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useListMembersQuery(list)
+  const isEmpty = !isFetching && !data?.pages[0].items.length
+  const isOwner =
+    currentAccount && data?.pages[0].list.creator.did === currentAccount.did
+
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (list.hasLoaded) {
-      if (list.hasError) {
+    if (isFetched) {
+      if (isEmpty && isError) {
         items = items.concat([ERROR_ITEM])
       }
-      if (list.isEmpty) {
+      if (isEmpty) {
         items = items.concat([EMPTY_ITEM])
-      } else {
-        items = items.concat(list.items)
+      } else if (data) {
+        for (const page of data.pages) {
+          items = items.concat(page.items)
+        }
       }
-      if (list.loadMoreError) {
+      if (!isEmpty && isError) {
         items = items.concat([LOAD_MORE_ERROR_ITEM])
       }
-    } else if (list.isLoading) {
+    } else if (isFetching) {
       items = items.concat([LOADING_ITEM])
     }
     return items
-  }, [
-    list.hasError,
-    list.hasLoaded,
-    list.isLoading,
-    list.isEmpty,
-    list.items,
-    list.loadMoreError,
-  ])
+  }, [isFetched, isEmpty, isError, data, isFetching])
 
   // events
   // =
@@ -93,45 +105,36 @@ export const ListItems = observer(function ListItemsImpl({
     track('Lists:onRefresh')
     setIsRefreshing(true)
     try {
-      await list.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
-  }, [list, track, setIsRefreshing])
+  }, [refetch, track, setIsRefreshing])
 
   const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
     track('Lists:onEndReached')
     try {
-      await list.loadMore()
+      await fetchNextPage()
     } catch (err) {
       logger.error('Failed to load more lists', {error: err})
     }
-  }, [list, track])
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    list.retryLoadMore()
-  }, [list])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   const onPressEditMembership = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      store.shell.openModal({
+      openModal({
         name: 'user-add-remove-lists',
         subject: profile.did,
         displayName: profile.displayName || profile.handle,
-        onAdd(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheAddMember(profile)
-          }
-        },
-        onRemove(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheRemoveMember(profile)
-          }
-        },
       })
     },
-    [store, list],
+    [openModal],
   )
 
   // rendering
@@ -139,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({
 
   const renderMemberButton = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      if (!list.isOwner) {
+      if (!isOwner) {
         return null
       }
       return (
@@ -151,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({
         />
       )
     },
-    [list, onPressEditMembership],
+    [isOwner, onPressEditMembership],
   )
 
   const renderItem = React.useCallback(
@@ -161,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={list.error}
+            message={cleanError(error)}
             onPressTryAgain={onPressTryAgain}
           />
         )
@@ -189,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({
     [
       renderMemberButton,
       renderEmptyState,
-      list.error,
+      error,
       onPressTryAgain,
       onPressRetryLoadMore,
       isMobile,
@@ -199,19 +202,20 @@ export const ListItems = observer(function ListItemsImpl({
   const Footer = React.useCallback(
     () => (
       <View style={{paddingTop: 20, paddingBottom: 200}}>
-        {list.isLoading && <ActivityIndicator />}
+        {isFetching && <ActivityIndicator />}
       </View>
     ),
-    [list.isLoading],
+    [isFetching],
   )
 
+  const scrollHandler = useAnimatedScrollHandler(onScroll)
   return (
     <View testID={testID} style={style}>
       <FlatList
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
-        data={data}
-        keyExtractor={(item: any) => item._reactKey}
+        data={items}
+        keyExtractor={(item: any) => item.subject?.did || item._reactKey}
         renderItem={renderItem}
         ListHeaderComponent={renderHeader}
         ListFooterComponent={Footer}
@@ -224,9 +228,11 @@ export const ListItems = observer(function ListItemsImpl({
             progressViewOffset={headerOffset}
           />
         }
-        contentContainerStyle={s.contentContainer}
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
         style={{paddingTop: headerOffset}}
-        onScroll={onScroll}
+        onScroll={scrollHandler}
         onEndReached={onEndReached}
         onEndReachedThreshold={0.6}
         scrollEventThrottle={scrollEventThrottle}
@@ -237,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({
       />
     </View>
   )
-})
+}
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx
index 8c6510886..2c080582e 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -8,94 +8,71 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {ListCard} from './ListCard'
+import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {Text} from '../util/text/Text'
-import {ListsListModel} from 'state/models/lists/lists-list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FlatList} from '../util/Views'
 import {s} from 'lib/styles'
 import {logger} from '#/logger'
+import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
-const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListsList = observer(function ListsListImpl({
-  listsList,
+export function MyLists({
+  filter,
   inline,
   style,
-  onPressTryAgain,
   renderItem,
   testID,
 }: {
-  listsList: ListsListModel
+  filter: MyListsFilter
   inline?: boolean
   style?: StyleProp<ViewStyle>
-  onPressTryAgain?: () => void
   renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
   testID?: string
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {data, isFetching, isFetched, isError, error, refetch} =
+    useMyListsQuery(filter)
+  const isEmpty = !isFetching && !data?.length
 
-  const data = React.useMemo(() => {
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (listsList.hasError) {
+    if (isError && isEmpty) {
       items = items.concat([ERROR_ITEM])
     }
-    if (!listsList.hasLoaded && listsList.isLoading) {
+    if (!isFetched && isFetching) {
       items = items.concat([LOADING])
-    } else if (listsList.isEmpty) {
+    } else if (isEmpty) {
       items = items.concat([EMPTY])
     } else {
-      items = items.concat(listsList.lists)
-    }
-    if (listsList.loadMoreError) {
-      items = items.concat([LOAD_MORE_ERROR_ITEM])
+      items = items.concat(data)
     }
     return items
-  }, [
-    listsList.hasError,
-    listsList.hasLoaded,
-    listsList.isLoading,
-    listsList.lists,
-    listsList.isEmpty,
-    listsList.loadMoreError,
-  ])
+  }, [isError, isEmpty, isFetched, isFetching, data])
 
   // events
   // =
 
   const onRefresh = React.useCallback(async () => {
     track('Lists:onRefresh')
-    setIsRefreshing(true)
+    setIsPTRing(true)
     try {
-      await listsList.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
-    setIsRefreshing(false)
-  }, [listsList, track, setIsRefreshing])
-
-  const onEndReached = React.useCallback(async () => {
-    track('Lists:onEndReached')
-    try {
-      await listsList.loadMore()
-    } catch (err) {
-      logger.error('Failed to load more lists', {error: err})
-    }
-  }, [listsList, track])
-
-  const onPressRetryLoadMore = React.useCallback(() => {
-    listsList.retryLoadMore()
-  }, [listsList])
+    setIsPTRing(false)
+  }, [refetch, track, setIsPTRing])
 
   // rendering
   // =
@@ -107,21 +84,16 @@ export const ListsList = observer(function ListsListImpl({
           <View
             testID="listsEmpty"
             style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
-            <Text style={pal.textLight}>You have no lists.</Text>
+            <Text style={pal.textLight}>
+              <Trans>You have no lists.</Trans>
+            </Text>
           </View>
         )
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={listsList.error}
-            onPressTryAgain={onPressTryAgain}
-          />
-        )
-      } else if (item === LOAD_MORE_ERROR_ITEM) {
-        return (
-          <LoadMoreRetryBtn
-            label="There was an issue fetching your lists. Tap here to try again."
-            onPress={onPressRetryLoadMore}
+            message={cleanError(error)}
+            onPressTryAgain={onRefresh}
           />
         )
       } else if (item === LOADING) {
@@ -141,29 +113,27 @@ export const ListsList = observer(function ListsListImpl({
         />
       )
     },
-    [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
+    [error, onRefresh, renderItem, pal],
   )
 
   const FlatListCom = inline ? RNFlatList : FlatList
   return (
     <View testID={testID} style={style}>
-      {data.length > 0 && (
+      {items.length > 0 && (
         <FlatListCom
           testID={testID ? `${testID}-flatlist` : undefined}
-          data={data}
+          data={items}
           keyExtractor={(item: any) => item._reactKey}
           renderItem={renderItemInner}
           refreshControl={
             <RefreshControl
-              refreshing={isRefreshing}
+              refreshing={isPTRing}
               onRefresh={onRefresh}
               tintColor={pal.colors.text}
               titleColor={pal.colors.text}
             />
           }
           contentContainerStyle={[s.contentContainer]}
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
           removeClippedSubviews={true}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
@@ -171,7 +141,7 @@ export const ListsList = observer(function ListsListImpl({
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   item: {
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
new file mode 100644
index 000000000..95cf8fde6
--- /dev/null
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -0,0 +1,226 @@
+import React, {MutableRefObject} from 'react'
+import {
+  Dimensions,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {FlatList} from '../util/Views'
+import {ListCard} from './ListCard'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {logger} from '#/logger'
+import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
+import {useAnimatedScrollHandler} from 'react-native-reanimated'
+import {useTheme} from '#/lib/ThemeContext'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+
+const LOADING = {_reactKey: '__loading__'}
+const EMPTY = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+interface ProfileListsProps {
+  did: string
+  scrollElRef: MutableRefObject<FlatList<any> | null>
+  onScroll?: OnScrollHandler
+  scrollEventThrottle?: number
+  headerOffset: number
+  enabled?: boolean
+  style?: StyleProp<ViewStyle>
+  testID?: string
+}
+
+export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
+  function ProfileListsImpl(
+    {
+      did,
+      scrollElRef,
+      onScroll,
+      scrollEventThrottle,
+      headerOffset,
+      enabled,
+      style,
+      testID,
+    },
+    ref,
+  ) {
+    const pal = usePalette('default')
+    const theme = useTheme()
+    const {track} = useAnalytics()
+    const [isPTRing, setIsPTRing] = React.useState(false)
+    const opts = React.useMemo(() => ({enabled}), [enabled])
+    const {
+      data,
+      isFetching,
+      isFetched,
+      hasNextPage,
+      fetchNextPage,
+      isError,
+      error,
+      refetch,
+    } = useProfileListsQuery(did, opts)
+    const isEmpty = !isFetching && !data?.pages[0]?.lists.length
+
+    const items = React.useMemo(() => {
+      let items: any[] = []
+      if (isError && isEmpty) {
+        items = items.concat([ERROR_ITEM])
+      }
+      if (!isFetched && isFetching) {
+        items = items.concat([LOADING])
+      } else if (isEmpty) {
+        items = items.concat([EMPTY])
+      } else if (data?.pages) {
+        for (const page of data?.pages) {
+          items = items.concat(
+            page.lists.map(l => ({
+              ...l,
+              _reactKey: l.uri,
+            })),
+          )
+        }
+      }
+      if (isError && !isEmpty) {
+        items = items.concat([LOAD_MORE_ERROR_ITEM])
+      }
+      return items
+    }, [isError, isEmpty, isFetched, isFetching, data])
+
+    // events
+    // =
+
+    const queryClient = useQueryClient()
+
+    const onScrollToTop = React.useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerOffset})
+      queryClient.invalidateQueries({queryKey: RQKEY(did)})
+    }, [scrollElRef, queryClient, headerOffset, did])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const onRefresh = React.useCallback(async () => {
+      track('Lists:onRefresh')
+      setIsPTRing(true)
+      try {
+        await refetch()
+      } catch (err) {
+        logger.error('Failed to refresh lists', {error: err})
+      }
+      setIsPTRing(false)
+    }, [refetch, track, setIsPTRing])
+
+    const onEndReached = React.useCallback(async () => {
+      if (isFetching || !hasNextPage || isError) return
+
+      track('Lists:onEndReached')
+      try {
+        await fetchNextPage()
+      } catch (err) {
+        logger.error('Failed to load more lists', {error: err})
+      }
+    }, [isFetching, hasNextPage, isError, fetchNextPage, track])
+
+    const onPressRetryLoadMore = React.useCallback(() => {
+      fetchNextPage()
+    }, [fetchNextPage])
+
+    // rendering
+    // =
+
+    const renderItemInner = React.useCallback(
+      ({item}: {item: any}) => {
+        if (item === EMPTY) {
+          return (
+            <View
+              testID="listsEmpty"
+              style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
+              <Text style={pal.textLight}>
+                <Trans>You have no lists.</Trans>
+              </Text>
+            </View>
+          )
+        } else if (item === ERROR_ITEM) {
+          return (
+            <ErrorMessage
+              message={cleanError(error)}
+              onPressTryAgain={refetch}
+            />
+          )
+        } else if (item === LOAD_MORE_ERROR_ITEM) {
+          return (
+            <LoadMoreRetryBtn
+              label="There was an issue fetching your lists. Tap here to try again."
+              onPress={onPressRetryLoadMore}
+            />
+          )
+        } else if (item === LOADING) {
+          return <FeedLoadingPlaceholder />
+        }
+        return (
+          <ListCard
+            list={item}
+            testID={`list-${item.name}`}
+            style={styles.item}
+          />
+        )
+      },
+      [error, refetch, onPressRetryLoadMore, pal],
+    )
+
+    const scrollHandler = useAnimatedScrollHandler(onScroll || {})
+    return (
+      <View testID={testID} style={style}>
+        <FlatList
+          testID={testID ? `${testID}-flatlist` : undefined}
+          ref={scrollElRef}
+          data={items}
+          keyExtractor={(item: any) => item._reactKey}
+          renderItem={renderItemInner}
+          refreshControl={
+            <RefreshControl
+              refreshing={isPTRing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+              progressViewOffset={headerOffset}
+            />
+          }
+          contentContainerStyle={{
+            minHeight: Dimensions.get('window').height * 1.5,
+          }}
+          style={{paddingTop: headerOffset}}
+          onScroll={onScroll != null ? scrollHandler : undefined}
+          scrollEventThrottle={scrollEventThrottle}
+          indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
+          removeClippedSubviews={true}
+          contentOffset={{x: 0, y: headerOffset * -1}}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+          onEndReached={onEndReached}
+        />
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  item: {
+    paddingHorizontal: 18,
+  },
+})
diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx
index 29763620f..812a36f45 100644
--- a/src/view/com/modals/AddAppPasswords.tsx
+++ b/src/view/com/modals/AddAppPasswords.tsx
@@ -3,7 +3,6 @@ import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isNative} from 'platform/detection'
 import {
@@ -13,6 +12,13 @@ import {
 import Clipboard from '@react-native-clipboard/clipboard'
 import * as Toast from '../util/Toast'
 import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useAppPasswordsQuery,
+  useAppPasswordCreateMutation,
+} from '#/state/queries/app-passwords'
 
 export const snapPoints = ['70%']
 
@@ -53,7 +59,10 @@ const shadesOfBlue: string[] = [
 
 export function Component({}: {}) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
+  const {data: passwords} = useAppPasswordsQuery()
+  const createMutation = useAppPasswordCreateMutation()
   const [name, setName] = useState(
     shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
   )
@@ -69,33 +78,42 @@ export function Component({}: {}) {
   }, [appPassword])
 
   const onDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const createAppPassword = async () => {
     // if name is all whitespace, we don't allow it
     if (!name || !name.trim()) {
       Toast.show(
         'Please enter a name for your app password. All spaces is not allowed.',
+        'times',
       )
       return
     }
     // if name is too short (under 4 chars), we don't allow it
     if (name.length < 4) {
-      Toast.show('App Password names must be at least 4 characters long.')
+      Toast.show(
+        'App Password names must be at least 4 characters long.',
+        'times',
+      )
+      return
+    }
+
+    if (passwords?.find(p => p.name === name)) {
+      Toast.show('This name is already in use', 'times')
       return
     }
 
     try {
-      const newPassword = await store.me.createAppPassword(name)
+      const newPassword = await createMutation.mutateAsync({name})
       if (newPassword) {
         setAppPassword(newPassword.password)
       } else {
-        Toast.show('Failed to create app password.')
+        Toast.show('Failed to create app password.', 'times')
         // TODO: better error handling (?)
       }
     } catch (e) {
-      Toast.show('Failed to create app password.')
+      Toast.show('Failed to create app password.', 'times')
       logger.error('Failed to create app password', {error: e})
     }
   }
@@ -119,15 +137,19 @@ export function Component({}: {}) {
       <View>
         {!appPassword ? (
           <Text type="lg" style={[pal.text]}>
-            Please enter a unique name for this App Password or use our randomly
-            generated one.
+            <Trans>
+              Please enter a unique name for this App Password or use our
+              randomly generated one.
+            </Trans>
           </Text>
         ) : (
           <Text type="lg" style={[pal.text]}>
-            <Text type="lg-bold" style={[pal.text]}>
-              Here is your app password.
-            </Text>{' '}
-            Use this to sign into the other app along with your handle.
+            <Text type="lg-bold" style={[pal.text, s.mr5]}>
+              <Trans>Here is your app password.</Trans>
+            </Text>
+            <Trans>
+              Use this to sign into the other app along with your handle.
+            </Trans>
           </Text>
         )}
         {!appPassword ? (
@@ -152,7 +174,7 @@ export function Component({}: {}) {
               returnKeyType="done"
               onEndEditing={createAppPassword}
               accessible={true}
-              accessibilityLabel="Name"
+              accessibilityLabel={_(msg`Name`)}
               accessibilityHint="Input name for app password"
             />
           </View>
@@ -161,13 +183,15 @@ export function Component({}: {}) {
             style={[pal.border, styles.passwordContainer, pal.btn]}
             onPress={onCopy}
             accessibilityRole="button"
-            accessibilityLabel="Copy"
+            accessibilityLabel={_(msg`Copy`)}
             accessibilityHint="Copies app password">
             <Text type="2xl-bold" style={[pal.text]}>
               {appPassword}
             </Text>
             {wasCopied ? (
-              <Text style={[pal.textLight]}>Copied</Text>
+              <Text style={[pal.textLight]}>
+                <Trans>Copied</Trans>
+              </Text>
             ) : (
               <FontAwesomeIcon
                 icon={['far', 'clone']}
@@ -180,14 +204,18 @@ export function Component({}: {}) {
       </View>
       {appPassword ? (
         <Text type="lg" style={[pal.textLight, s.mb10]}>
-          For security reasons, you won't be able to view this again. If you
-          lose this password, you'll need to generate a new one.
+          <Trans>
+            For security reasons, you won't be able to view this again. If you
+            lose this password, you'll need to generate a new one.
+          </Trans>
         </Text>
       ) : (
         <Text type="xs" style={[pal.textLight, s.mb10, s.mt2]}>
-          Can only contain letters, numbers, spaces, dashes, and underscores.
-          Must be at least 4 characters long, but no more than 32 characters
-          long.
+          <Trans>
+            Can only contain letters, numbers, spaces, dashes, and underscores.
+            Must be at least 4 characters long, but no more than 32 characters
+            long.
+          </Trans>
         </Text>
       )}
       <View style={styles.btnContainer}>
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index c084e84a3..80130f43a 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -17,9 +17,11 @@ import {MAX_ALT_TEXT} from 'lib/constants'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {isAndroid, isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['fullscreen']
 
@@ -29,10 +31,11 @@ interface Props {
 
 export function Component({image}: Props) {
   const pal = usePalette('default')
-  const store = useStores()
   const theme = useTheme()
+  const {_} = useLingui()
   const [altText, setAltText] = useState(image.altText)
   const windim = useWindowDimensions()
+  const {closeModal} = useModalControls()
 
   const imageStyles = useMemo<ImageStyle>(() => {
     const maxWidth = isWeb ? 450 : windim.width
@@ -53,11 +56,11 @@ export function Component({image}: Props) {
 
   const onPressSave = useCallback(() => {
     image.setAltText(altText)
-    store.shell.closeModal()
-  }, [store, image, altText])
+    closeModal()
+  }, [closeModal, image, altText])
 
   const onPressCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
@@ -90,7 +93,7 @@ export function Component({image}: Props) {
             placeholderTextColor={pal.colors.textLight}
             value={altText}
             onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-            accessibilityLabel="Image alt text"
+            accessibilityLabel={_(msg`Image alt text`)}
             accessibilityHint=""
             accessibilityLabelledBy="imageAltText"
             autoFocus
@@ -99,7 +102,7 @@ export function Component({image}: Props) {
             <TouchableOpacity
               testID="altTextImageSaveBtn"
               onPress={onPressSave}
-              accessibilityLabel="Save alt text"
+              accessibilityLabel={_(msg`Save alt text`)}
               accessibilityHint={`Saves alt text, which reads: ${altText}`}
               accessibilityRole="button">
               <LinearGradient
@@ -108,7 +111,7 @@ export function Component({image}: Props) {
                 end={{x: 1, y: 1}}
                 style={[styles.button]}>
                 <Text type="button-lg" style={[s.white, s.bold]}>
-                  Save
+                  <Trans>Save</Trans>
                 </Text>
               </LinearGradient>
             </TouchableOpacity>
@@ -116,12 +119,12 @@ export function Component({image}: Props) {
               testID="altTextImageCancelBtn"
               onPress={onPressCancel}
               accessibilityRole="button"
-              accessibilityLabel="Cancel add image alt text"
+              accessibilityLabel={_(msg`Cancel add image alt text`)}
               accessibilityHint=""
               onAccessibilityEscape={onPressCancel}>
               <View style={[styles.button]}>
                 <Text type="button-lg" style={[pal.textLight]}>
-                  Cancel
+                  <Trans>Cancel</Trans>
                 </Text>
               </View>
             </TouchableOpacity>
diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx
index 6927ba8d2..c78f06ed4 100644
--- a/src/view/com/modals/BirthDateSettings.tsx
+++ b/src/view/com/modals/BirthDateSettings.tsx
@@ -5,41 +5,47 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {Text} from '../util/text/Text'
 import {DateInput} from '../util/forms/DateInput'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  usePreferencesQuery,
+  usePreferencesSetBirthDateMutation,
+  UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
+import {logger} from '#/logger'
 
 export const snapPoints = ['50%']
 
-export const Component = observer(function Component({}: {}) {
+function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const [date, setDate] = useState<Date>(
-    store.preferences.birthDate || new Date(),
-  )
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [error, setError] = useState<string>('')
   const {isMobile} = useWebMediaQueries()
+  const {_} = useLingui()
+  const {
+    isPending,
+    isError,
+    error,
+    mutateAsync: setBirthDate,
+  } = usePreferencesSetBirthDateMutation()
+  const [date, setDate] = useState(preferences.birthDate || new Date())
+  const {closeModal} = useModalControls()
 
-  const onSave = async () => {
-    setError('')
-    setIsProcessing(true)
+  const onSave = React.useCallback(async () => {
     try {
-      await store.preferences.setBirthDate(date)
-      store.shell.closeModal()
+      await setBirthDate({birthDate: date})
+      closeModal()
     } catch (e) {
-      setError(cleanError(String(e)))
-    } finally {
-      setIsProcessing(false)
+      logger.error(`setBirthDate failed`, {error: e})
     }
-  }
+  }, [date, setBirthDate, closeModal])
 
   return (
     <View
@@ -47,12 +53,12 @@ export const Component = observer(function Component({}: {}) {
       style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}>
       <View style={styles.titleSection}>
         <Text type="title-lg" style={[pal.text, styles.title]}>
-          My Birthday
+          <Trans>My Birthday</Trans>
         </Text>
       </View>
 
       <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-        This information is not shared with other users.
+        <Trans>This information is not shared with other users.</Trans>
       </Text>
 
       <View>
@@ -63,18 +69,18 @@ export const Component = observer(function Component({}: {}) {
           buttonType="default-light"
           buttonStyle={[pal.border, styles.dateInputButton]}
           buttonLabelType="lg"
-          accessibilityLabel="Birthday"
+          accessibilityLabel={_(msg`Birthday`)}
           accessibilityHint="Enter your birth date"
           accessibilityLabelledBy="birthDate"
         />
       </View>
 
-      {error ? (
-        <ErrorMessage message={error} style={styles.error} />
+      {isError ? (
+        <ErrorMessage message={cleanError(error)} style={styles.error} />
       ) : undefined}
 
       <View style={[styles.btnContainer, pal.borderDark]}>
-        {isProcessing ? (
+        {isPending ? (
           <View style={styles.btn}>
             <ActivityIndicator color="#fff" />
           </View>
@@ -84,15 +90,27 @@ export const Component = observer(function Component({}: {}) {
             onPress={onSave}
             style={styles.btn}
             accessibilityRole="button"
-            accessibilityLabel="Save"
+            accessibilityLabel={_(msg`Save`)}
             accessibilityHint="">
-            <Text style={[s.white, s.bold, s.f18]}>Save</Text>
+            <Text style={[s.white, s.bold, s.f18]}>
+              <Trans>Save</Trans>
+            </Text>
           </TouchableOpacity>
         )}
       </View>
     </View>
   )
-})
+}
+
+export function Component({}: {}) {
+  const {data: preferences} = usePreferencesQuery()
+
+  return !preferences ? (
+    <ActivityIndicator />
+  ) : (
+    <Inner preferences={preferences} />
+  )
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index 012570556..73ab33dd4 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -1,17 +1,19 @@
 import React, {useState} from 'react'
 import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView, TextInput} from './util'
-import {observer} from 'mobx-react-lite'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSession, useSessionApi, getAgent} from '#/state/session'
 
 enum Stages {
   InputEmail,
@@ -21,32 +23,33 @@ enum Stages {
 
 export const snapPoints = ['90%']
 
-export const Component = observer(function Component({}: {}) {
+export function Component() {
   const pal = usePalette('default')
-  const store = useStores()
+  const {currentAccount} = useSession()
+  const {updateCurrentAccount} = useSessionApi()
+  const {_} = useLingui()
   const [stage, setStage] = useState<Stages>(Stages.InputEmail)
-  const [email, setEmail] = useState<string>(
-    store.session.currentSession?.email || '',
-  )
+  const [email, setEmail] = useState<string>(currentAccount?.email || '')
   const [confirmationCode, setConfirmationCode] = useState<string>('')
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const {isMobile} = useWebMediaQueries()
+  const {openModal, closeModal} = useModalControls()
 
   const onRequestChange = async () => {
-    if (email === store.session.currentSession?.email) {
+    if (email === currentAccount?.email) {
       setError('Enter your new email above')
       return
     }
     setError('')
     setIsProcessing(true)
     try {
-      const res = await store.agent.com.atproto.server.requestEmailUpdate()
+      const res = await getAgent().com.atproto.server.requestEmailUpdate()
       if (res.data.tokenRequired) {
         setStage(Stages.ConfirmCode)
       } else {
-        await store.agent.com.atproto.server.updateEmail({email: email.trim()})
-        store.session.updateLocalAccountData({
+        await getAgent().com.atproto.server.updateEmail({email: email.trim()})
+        updateCurrentAccount({
           email: email.trim(),
           emailConfirmed: false,
         })
@@ -60,7 +63,9 @@ export const Component = observer(function Component({}: {}) {
       // you can remove this any time after Oct2023
       // -prf
       if (err === 'email must be confirmed (temporary)') {
-        err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`
+        err = _(
+          msg`Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`,
+        )
       }
       setError(err)
     } finally {
@@ -72,11 +77,11 @@ export const Component = observer(function Component({}: {}) {
     setError('')
     setIsProcessing(true)
     try {
-      await store.agent.com.atproto.server.updateEmail({
+      await getAgent().com.atproto.server.updateEmail({
         email: email.trim(),
         token: confirmationCode.trim(),
       })
-      store.session.updateLocalAccountData({
+      updateCurrentAccount({
         email: email.trim(),
         emailConfirmed: false,
       })
@@ -90,8 +95,8 @@ export const Component = observer(function Component({}: {}) {
   }
 
   const onVerify = async () => {
-    store.shell.closeModal()
-    store.shell.openModal({name: 'verify-email'})
+    closeModal()
+    openModal({name: 'verify-email'})
   }
 
   return (
@@ -101,26 +106,26 @@ export const Component = observer(function Component({}: {}) {
         style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
         <View style={styles.titleSection}>
           <Text type="title-lg" style={[pal.text, styles.title]}>
-            {stage === Stages.InputEmail ? 'Change Your Email' : ''}
-            {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
-            {stage === Stages.Done ? 'Email Updated' : ''}
+            {stage === Stages.InputEmail ? _(msg`Change Your Email`) : ''}
+            {stage === Stages.ConfirmCode ? _(msg`Security Step Required`) : ''}
+            {stage === Stages.Done ? _(msg`Email Updated`) : ''}
           </Text>
         </View>
 
         <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
           {stage === Stages.InputEmail ? (
-            <>Enter your new email address below.</>
+            <Trans>Enter your new email address below.</Trans>
           ) : stage === Stages.ConfirmCode ? (
-            <>
+            <Trans>
               An email has been sent to your previous address,{' '}
-              {store.session.currentSession?.email || ''}. It includes a
-              confirmation code which you can enter below.
-            </>
+              {currentAccount?.email || ''}. It includes a confirmation code
+              which you can enter below.
+            </Trans>
           ) : (
-            <>
+            <Trans>
               Your email has been updated but not verified. As a next step,
               please verify your new email.
-            </>
+            </Trans>
           )}
         </Text>
 
@@ -133,7 +138,7 @@ export const Component = observer(function Component({}: {}) {
             value={email}
             onChangeText={setEmail}
             accessible={true}
-            accessibilityLabel="Email"
+            accessibilityLabel={_(msg`Email`)}
             accessibilityHint=""
             autoCapitalize="none"
             autoComplete="email"
@@ -149,7 +154,7 @@ export const Component = observer(function Component({}: {}) {
             value={confirmationCode}
             onChangeText={setConfirmationCode}
             accessible={true}
-            accessibilityLabel="Confirmation code"
+            accessibilityLabel={_(msg`Confirmation code`)}
             accessibilityHint=""
             autoCapitalize="none"
             autoComplete="off"
@@ -173,9 +178,9 @@ export const Component = observer(function Component({}: {}) {
                   testID="requestChangeBtn"
                   type="primary"
                   onPress={onRequestChange}
-                  accessibilityLabel="Request Change"
+                  accessibilityLabel={_(msg`Request Change`)}
                   accessibilityHint=""
-                  label="Request Change"
+                  label={_(msg`Request Change`)}
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
@@ -185,9 +190,9 @@ export const Component = observer(function Component({}: {}) {
                   testID="confirmBtn"
                   type="primary"
                   onPress={onConfirm}
-                  accessibilityLabel="Confirm Change"
+                  accessibilityLabel={_(msg`Confirm Change`)}
                   accessibilityHint=""
-                  label="Confirm Change"
+                  label={_(msg`Confirm Change`)}
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
@@ -197,9 +202,9 @@ export const Component = observer(function Component({}: {}) {
                   testID="verifyBtn"
                   type="primary"
                   onPress={onVerify}
-                  accessibilityLabel="Verify New Email"
+                  accessibilityLabel={_(msg`Verify New Email`)}
                   accessibilityHint=""
-                  label="Verify New Email"
+                  label={_(msg`Verify New Email`)}
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
@@ -207,10 +212,12 @@ export const Component = observer(function Component({}: {}) {
               <Button
                 testID="cancelBtn"
                 type="default"
-                onPress={() => store.shell.closeModal()}
-                accessibilityLabel="Cancel"
+                onPress={() => {
+                  closeModal()
+                }}
+                accessibilityLabel={_(msg`Cancel`)}
                 accessibilityHint=""
-                label="Cancel"
+                label={_(msg`Cancel`)}
                 labelContainerStyle={{justifyContent: 'center', padding: 4}}
                 labelStyle={[s.f18]}
               />
@@ -220,7 +227,7 @@ export const Component = observer(function Component({}: {}) {
       </ScrollView>
     </SafeAreaView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   titleSection: {
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index c54c1c043..03516d35a 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -1,5 +1,6 @@
 import React, {useState} from 'react'
 import Clipboard from '@react-native-clipboard/clipboard'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
 import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
@@ -13,8 +14,6 @@ import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {SelectableBtn} from '../util/forms/SelectableBtn'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
-import {ServiceDescription} from 'state/models/session'
 import {s} from 'lib/styles'
 import {createFullHandle, makeValidHandle} from 'lib/strings/handles'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -22,75 +21,74 @@ import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {cleanError} from 'lib/strings/errors'
 import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useServiceQuery} from '#/state/queries/service'
+import {useUpdateHandleMutation, useFetchDid} from '#/state/queries/handle'
+import {
+  useSession,
+  useSessionApi,
+  SessionAccount,
+  getAgent,
+} from '#/state/session'
 
 export const snapPoints = ['100%']
 
-export function Component({onChanged}: {onChanged: () => void}) {
-  const store = useStores()
-  const [error, setError] = useState<string>('')
+export type Props = {onChanged: () => void}
+
+export function Component(props: Props) {
+  const {currentAccount} = useSession()
+  const {
+    isLoading,
+    data: serviceInfo,
+    error: serviceInfoError,
+  } = useServiceQuery(getAgent().service.toString())
+
+  return isLoading || !currentAccount ? (
+    <View style={{padding: 18}}>
+      <ActivityIndicator />
+    </View>
+  ) : serviceInfoError || !serviceInfo ? (
+    <ErrorMessage message={cleanError(serviceInfoError)} />
+  ) : (
+    <Inner
+      {...props}
+      currentAccount={currentAccount}
+      serviceInfo={serviceInfo}
+    />
+  )
+}
+
+export function Inner({
+  currentAccount,
+  serviceInfo,
+  onChanged,
+}: Props & {
+  currentAccount: SessionAccount
+  serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
+}) {
+  const {_} = useLingui()
   const pal = usePalette('default')
   const {track} = useAnalytics()
+  const {updateCurrentAccount} = useSessionApi()
+  const {closeModal} = useModalControls()
+  const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
+    useUpdateHandleMutation()
+
+  const [error, setError] = useState<string>('')
 
-  const [isProcessing, setProcessing] = useState<boolean>(false)
-  const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
-    {},
-  )
-  const [serviceDescription, setServiceDescription] = React.useState<
-    ServiceDescription | undefined
-  >(undefined)
-  const [userDomain, setUserDomain] = React.useState<string>('')
   const [isCustom, setCustom] = React.useState<boolean>(false)
   const [handle, setHandle] = React.useState<string>('')
   const [canSave, setCanSave] = React.useState<boolean>(false)
 
-  // init
-  // =
-  React.useEffect(() => {
-    let aborted = false
-    setError('')
-    setServiceDescription(undefined)
-    setProcessing(true)
-
-    // load the service description so we can properly provision handles
-    store.session.describeService(String(store.agent.service)).then(
-      desc => {
-        if (aborted) {
-          return
-        }
-        setServiceDescription(desc)
-        setUserDomain(desc.availableUserDomains[0])
-        setProcessing(false)
-      },
-      err => {
-        if (aborted) {
-          return
-        }
-        setProcessing(false)
-        logger.warn(
-          `Failed to fetch service description for ${String(
-            store.agent.service,
-          )}`,
-          {error: err},
-        )
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      },
-    )
-    return () => {
-      aborted = true
-    }
-  }, [store.agent.service, store.session, retryDescribeTrigger])
+  const userDomain = serviceInfo.availableUserDomains?.[0]
 
   // events
   // =
   const onPressCancel = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
-  const onPressRetryConnect = React.useCallback(
-    () => setRetryDescribeTrigger({}),
-    [setRetryDescribeTrigger],
-  )
+    closeModal()
+  }, [closeModal])
   const onToggleCustom = React.useCallback(() => {
     // toggle between a provided domain vs a custom one
     setHandle('')
@@ -101,32 +99,42 @@ export function Component({onChanged}: {onChanged: () => void}) {
     )
   }, [setCustom, isCustom, track])
   const onPressSave = React.useCallback(async () => {
-    setError('')
-    setProcessing(true)
+    if (!userDomain) {
+      logger.error(`ChangeHandle: userDomain is undefined`, {
+        service: serviceInfo,
+      })
+      setError(`The service you've selected has no domains configured.`)
+      return
+    }
+
     try {
       track('EditHandle:SetNewHandle')
       const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
       logger.debug(`Updating handle to ${newHandle}`)
-      await store.agent.updateHandle({
+      await updateHandle({
+        handle: newHandle,
+      })
+      updateCurrentAccount({
         handle: newHandle,
       })
-      store.shell.closeModal()
+      closeModal()
       onChanged()
     } catch (err: any) {
       setError(cleanError(err))
       logger.error('Failed to update handle', {handle, error: err})
     } finally {
-      setProcessing(false)
     }
   }, [
     setError,
-    setProcessing,
     handle,
     userDomain,
-    store,
     isCustom,
     onChanged,
     track,
+    closeModal,
+    updateCurrentAccount,
+    updateHandle,
+    serviceInfo,
   ])
 
   // rendering
@@ -138,7 +146,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
           <TouchableOpacity
             onPress={onPressCancel}
             accessibilityRole="button"
-            accessibilityLabel="Cancel change handle"
+            accessibilityLabel={_(msg`Cancel change handle`)}
             accessibilityHint="Exits handle change process"
             onAccessibilityEscape={onPressCancel}>
             <Text type="lg" style={pal.textLight}>
@@ -150,30 +158,19 @@ export function Component({onChanged}: {onChanged: () => void}) {
           type="2xl-bold"
           style={[styles.titleMiddle, pal.text]}
           numberOfLines={1}>
-          Change Handle
+          <Trans>Change Handle</Trans>
         </Text>
         <View style={styles.titleRight}>
-          {isProcessing ? (
+          {isUpdateHandlePending ? (
             <ActivityIndicator />
-          ) : error && !serviceDescription ? (
-            <TouchableOpacity
-              testID="retryConnectButton"
-              onPress={onPressRetryConnect}
-              accessibilityRole="button"
-              accessibilityLabel="Retry change handle"
-              accessibilityHint={`Retries handle change to ${handle}`}>
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                Retry
-              </Text>
-            </TouchableOpacity>
           ) : canSave ? (
             <TouchableOpacity
               onPress={onPressSave}
               accessibilityRole="button"
-              accessibilityLabel="Save handle change"
+              accessibilityLabel={_(msg`Save handle change`)}
               accessibilityHint={`Saves handle change to ${handle}`}>
               <Text type="2xl-medium" style={pal.link}>
-                Save
+                <Trans>Save</Trans>
               </Text>
             </TouchableOpacity>
           ) : undefined}
@@ -188,8 +185,9 @@ export function Component({onChanged}: {onChanged: () => void}) {
 
         {isCustom ? (
           <CustomHandleForm
+            currentAccount={currentAccount}
             handle={handle}
-            isProcessing={isProcessing}
+            isProcessing={isUpdateHandlePending}
             canSave={canSave}
             onToggleCustom={onToggleCustom}
             setHandle={setHandle}
@@ -200,7 +198,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
           <ProvidedHandleForm
             handle={handle}
             userDomain={userDomain}
-            isProcessing={isProcessing}
+            isProcessing={isUpdateHandlePending}
             onToggleCustom={onToggleCustom}
             setHandle={setHandle}
             setCanSave={setCanSave}
@@ -231,6 +229,7 @@ function ProvidedHandleForm({
 }) {
   const pal = usePalette('default')
   const theme = useTheme()
+  const {_} = useLingui()
 
   // events
   // =
@@ -263,12 +262,12 @@ function ProvidedHandleForm({
           onChangeText={onChangeHandle}
           editable={!isProcessing}
           accessible={true}
-          accessibilityLabel="Handle"
+          accessibilityLabel={_(msg`Handle`)}
           accessibilityHint="Sets Bluesky username"
         />
       </View>
       <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
-        Your full handle will be{' '}
+        <Trans>Your full handle will be </Trans>
         <Text type="md-bold" style={pal.textLight}>
           @{createFullHandle(handle, userDomain)}
         </Text>
@@ -277,9 +276,9 @@ function ProvidedHandleForm({
         onPress={onToggleCustom}
         accessibilityRole="button"
         accessibilityHint="Hosting provider"
-        accessibilityLabel="Opens modal for using custom domain">
+        accessibilityLabel={_(msg`Opens modal for using custom domain`)}>
         <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
-          I have my own domain
+          <Trans>I have my own domain</Trans>
         </Text>
       </TouchableOpacity>
     </>
@@ -290,6 +289,7 @@ function ProvidedHandleForm({
  * The form for using a custom domain
  */
 function CustomHandleForm({
+  currentAccount,
   handle,
   canSave,
   isProcessing,
@@ -298,6 +298,7 @@ function CustomHandleForm({
   onPressSave,
   setCanSave,
 }: {
+  currentAccount: SessionAccount
   handle: string
   canSave: boolean
   isProcessing: boolean
@@ -306,20 +307,23 @@ function CustomHandleForm({
   onPressSave: () => void
   setCanSave: (v: boolean) => void
 }) {
-  const store = useStores()
   const pal = usePalette('default')
   const palSecondary = usePalette('secondary')
   const palError = usePalette('error')
   const theme = useTheme()
+  const {_} = useLingui()
   const [isVerifying, setIsVerifying] = React.useState(false)
   const [error, setError] = React.useState<string>('')
   const [isDNSForm, setDNSForm] = React.useState<boolean>(true)
+  const fetchDid = useFetchDid()
   // events
   // =
   const onPressCopy = React.useCallback(() => {
-    Clipboard.setString(isDNSForm ? `did=${store.me.did}` : store.me.did)
+    Clipboard.setString(
+      isDNSForm ? `did=${currentAccount.did}` : currentAccount.did,
+    )
     Toast.show('Copied to clipboard')
-  }, [store.me.did, isDNSForm])
+  }, [currentAccount, isDNSForm])
   const onChangeHandle = React.useCallback(
     (v: string) => {
       setHandle(v)
@@ -334,13 +338,11 @@ function CustomHandleForm({
     try {
       setIsVerifying(true)
       setError('')
-      const res = await store.agent.com.atproto.identity.resolveHandle({
-        handle,
-      })
-      if (res.data.did === store.me.did) {
+      const did = await fetchDid(handle)
+      if (did === currentAccount.did) {
         setCanSave(true)
       } else {
-        setError(`Incorrect DID returned (got ${res.data.did})`)
+        setError(`Incorrect DID returned (got ${did})`)
       }
     } catch (err: any) {
       setError(cleanError(err))
@@ -350,13 +352,13 @@ function CustomHandleForm({
     }
   }, [
     handle,
-    store.me.did,
+    currentAccount,
     setIsVerifying,
     setCanSave,
     setError,
     canSave,
     onPressSave,
-    store.agent,
+    fetchDid,
   ])
 
   // rendering
@@ -364,7 +366,7 @@ function CustomHandleForm({
   return (
     <>
       <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain">
-        Enter the domain you want to use
+        <Trans>Enter the domain you want to use</Trans>
       </Text>
       <View style={[pal.btn, styles.textInputWrapper]}>
         <FontAwesomeIcon
@@ -382,7 +384,7 @@ function CustomHandleForm({
           onChangeText={onChangeHandle}
           editable={!isProcessing}
           accessibilityLabelledBy="customDomain"
-          accessibilityLabel="Custom domain"
+          accessibilityLabel={_(msg`Custom domain`)}
           accessibilityHint="Input your preferred hosting provider"
         />
       </View>
@@ -410,7 +412,7 @@ function CustomHandleForm({
       {isDNSForm ? (
         <>
           <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
-            Add the following DNS record to your domain:
+            <Trans>Add the following DNS record to your domain:</Trans>
           </Text>
           <View style={[styles.dnsTable, pal.btn]}>
             <Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
@@ -434,7 +436,7 @@ function CustomHandleForm({
             </Text>
             <View style={[styles.dnsValue]}>
               <Text type="mono" style={[styles.monoText, pal.text]}>
-                did={store.me.did}
+                did={currentAccount.did}
               </Text>
             </View>
           </View>
@@ -448,7 +450,7 @@ function CustomHandleForm({
       ) : (
         <>
           <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
-            Upload a text file to:
+            <Trans>Upload a text file to:</Trans>
           </Text>
           <View style={[styles.valueContainer, pal.btn]}>
             <View style={[styles.dnsValue]}>
@@ -464,7 +466,7 @@ function CustomHandleForm({
           <View style={[styles.valueContainer, pal.btn]}>
             <View style={[styles.dnsValue]}>
               <Text type="mono" style={[styles.monoText, pal.text]}>
-                {store.me.did}
+                {currentAccount.did}
               </Text>
             </View>
           </View>
@@ -480,7 +482,7 @@ function CustomHandleForm({
       {canSave === true && (
         <View style={[styles.message, palSecondary.view]}>
           <Text type="md-medium" style={palSecondary.text}>
-            Domain verified!
+            <Trans>Domain verified!</Trans>
           </Text>
         </View>
       )}
@@ -508,7 +510,7 @@ function CustomHandleForm({
       <View style={styles.spacer} />
       <TouchableOpacity
         onPress={onToggleCustom}
-        accessibilityLabel="Use default provider"
+        accessibilityLabel={_(msg`Use default provider`)}
         accessibilityHint="Use bsky.social as hosting provider">
         <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
           Nevermind, create a handle for me
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index c1324b1cb..5e869f396 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -6,13 +6,15 @@ import {
   View,
 } from 'react-native'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
-import type {ConfirmModal} from 'state/models/ui/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import type {ConfirmModal} from '#/state/modals'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['50%']
 
@@ -26,7 +28,8 @@ export function Component({
   cancelBtnText,
 }: ConfirmModal) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const onPress = async () => {
@@ -34,7 +37,7 @@ export function Component({
     setIsProcessing(true)
     try {
       await onPressConfirm()
-      store.shell.closeModal()
+      closeModal()
       return
     } catch (e: any) {
       setError(cleanError(e))
@@ -69,7 +72,7 @@ export function Component({
           onPress={onPress}
           style={[styles.btn, confirmBtnStyle]}
           accessibilityRole="button"
-          accessibilityLabel="Confirm"
+          accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>
             {confirmBtnText ?? 'Confirm'}
@@ -82,7 +85,7 @@ export function Component({
           onPress={onPressCancel}
           style={[styles.btnCancel, s.mt10]}
           accessibilityRole="button"
-          accessibilityLabel="Cancel"
+          accessibilityLabel={_(msg`Cancel`)}
           accessibilityHint="">
           <Text type="button-lg" style={pal.textLight}>
             {cancelBtnText ?? 'Cancel'}
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 9075d0272..8b42e1b1d 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -1,214 +1,228 @@
 import React from 'react'
+import {LabelPreference} from '@atproto/api'
 import {StyleSheet, Pressable, View} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
-import {observer} from 'mobx-react-lite'
 import {ScrollView} from './util'
-import {useStores} from 'state/index'
-import {LabelPreference} from 'state/models/ui/preferences'
 import {s, colors, gradients} from 'lib/styles'
 import {Text} from '../util/text/Text'
 import {TextLink} from '../util/Link'
 import {ToggleButton} from '../util/forms/ToggleButton'
 import {Button} from '../util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
-import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
 import {isIOS} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import * as Toast from '../util/Toast'
 import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  usePreferencesQuery,
+  usePreferencesSetContentLabelMutation,
+  usePreferencesSetAdultContentMutation,
+  ConfigurableLabelGroup,
+  CONFIGURABLE_LABEL_GROUPS,
+  UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
 
 export const snapPoints = ['90%']
 
-export const Component = observer(
-  function ContentFilteringSettingsImpl({}: {}) {
-    const store = useStores()
-    const {isMobile} = useWebMediaQueries()
-    const pal = usePalette('default')
+export function Component({}: {}) {
+  const {isMobile} = useWebMediaQueries()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
+  const {data: preferences} = usePreferencesQuery()
 
-    React.useEffect(() => {
-      store.preferences.sync()
-    }, [store])
+  const onPressDone = React.useCallback(() => {
+    closeModal()
+  }, [closeModal])
 
-    const onPressDone = React.useCallback(() => {
-      store.shell.closeModal()
-    }, [store])
+  return (
+    <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>Content Filtering</Trans>
+      </Text>
 
-    return (
-      <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
-        <Text style={[pal.text, styles.title]}>Content Filtering</Text>
-        <ScrollView style={styles.scrollContainer}>
-          <AdultContentEnabledPref />
-          <ContentLabelPref
-            group="nsfw"
-            disabled={!store.preferences.adultContentEnabled}
-          />
-          <ContentLabelPref
-            group="nudity"
-            disabled={!store.preferences.adultContentEnabled}
-          />
-          <ContentLabelPref
-            group="suggestive"
-            disabled={!store.preferences.adultContentEnabled}
-          />
-          <ContentLabelPref
-            group="gore"
-            disabled={!store.preferences.adultContentEnabled}
-          />
-          <ContentLabelPref group="hate" />
-          <ContentLabelPref group="spam" />
-          <ContentLabelPref group="impersonation" />
-          <View style={{height: isMobile ? 60 : 0}} />
-        </ScrollView>
-        <View
-          style={[
-            styles.btnContainer,
-            isMobile && styles.btnContainerMobile,
-            pal.borderDark,
-          ]}>
-          <Pressable
-            testID="sendReportBtn"
-            onPress={onPressDone}
-            accessibilityRole="button"
-            accessibilityLabel="Done"
-            accessibilityHint="">
-            <LinearGradient
-              colors={[gradients.blueLight.start, gradients.blueLight.end]}
-              start={{x: 0, y: 0}}
-              end={{x: 1, y: 1}}
-              style={[styles.btn]}>
-              <Text style={[s.white, s.bold, s.f18]}>Done</Text>
-            </LinearGradient>
-          </Pressable>
-        </View>
+      <ScrollView style={styles.scrollContainer}>
+        <AdultContentEnabledPref />
+        <ContentLabelPref
+          preferences={preferences}
+          labelGroup="nsfw"
+          disabled={!preferences?.adultContentEnabled}
+        />
+        <ContentLabelPref
+          preferences={preferences}
+          labelGroup="nudity"
+          disabled={!preferences?.adultContentEnabled}
+        />
+        <ContentLabelPref
+          preferences={preferences}
+          labelGroup="suggestive"
+          disabled={!preferences?.adultContentEnabled}
+        />
+        <ContentLabelPref
+          preferences={preferences}
+          labelGroup="gore"
+          disabled={!preferences?.adultContentEnabled}
+        />
+        <ContentLabelPref preferences={preferences} labelGroup="hate" />
+        <ContentLabelPref preferences={preferences} labelGroup="spam" />
+        <ContentLabelPref
+          preferences={preferences}
+          labelGroup="impersonation"
+        />
+        <View style={{height: isMobile ? 60 : 0}} />
+      </ScrollView>
+
+      <View
+        style={[
+          styles.btnContainer,
+          isMobile && styles.btnContainerMobile,
+          pal.borderDark,
+        ]}>
+        <Pressable
+          testID="sendReportBtn"
+          onPress={onPressDone}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Done`)}
+          accessibilityHint="">
+          <LinearGradient
+            colors={[gradients.blueLight.start, gradients.blueLight.end]}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[styles.btn]}>
+            <Text style={[s.white, s.bold, s.f18]}>
+              <Trans>Done</Trans>
+            </Text>
+          </LinearGradient>
+        </Pressable>
       </View>
-    )
-  },
-)
+    </View>
+  )
+}
 
-const AdultContentEnabledPref = observer(
-  function AdultContentEnabledPrefImpl() {
-    const store = useStores()
-    const pal = usePalette('default')
+function AdultContentEnabledPref() {
+  const pal = usePalette('default')
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetAdultContentMutation()
+  const {openModal} = useModalControls()
 
-    const onSetAge = () => store.shell.openModal({name: 'birth-date-settings'})
+  const onSetAge = React.useCallback(
+    () => openModal({name: 'birth-date-settings'}),
+    [openModal],
+  )
 
-    const onToggleAdultContent = async () => {
-      if (isIOS) {
-        return
-      }
-      try {
-        await store.preferences.setAdultContentEnabled(
-          !store.preferences.adultContentEnabled,
-        )
-      } catch (e) {
-        Toast.show(
-          'There was an issue syncing your preferences with the server',
-        )
-        logger.error('Failed to update preferences with server', {error: e})
-      }
+  const onToggleAdultContent = React.useCallback(async () => {
+    if (isIOS) return
+
+    try {
+      mutate({
+        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
+      })
+    } catch (e) {
+      Toast.show('There was an issue syncing your preferences with the server')
+      logger.error('Failed to update preferences with server', {error: e})
     }
+  }, [variables, preferences, mutate])
 
-    return (
-      <View style={s.mb10}>
-        {isIOS ? (
-          store.preferences.adultContentEnabled ? null : (
-            <Text type="md" style={pal.textLight}>
-              Adult content can only be enabled via the Web at{' '}
-              <TextLink
-                style={pal.link}
-                href="https://bsky.app"
-                text="bsky.app"
-              />
-              .
-            </Text>
-          )
-        ) : typeof store.preferences.birthDate === 'undefined' ? (
-          <View style={[pal.viewLight, styles.agePrompt]}>
-            <Text type="md" style={[pal.text, {flex: 1}]}>
-              Confirm your age to enable adult content.
-            </Text>
-            <Button type="primary" label="Set Age" onPress={onSetAge} />
-          </View>
-        ) : (store.preferences.userAge || 0) >= 18 ? (
-          <ToggleButton
-            type="default-light"
-            label="Enable Adult Content"
-            isSelected={store.preferences.adultContentEnabled}
-            onPress={onToggleAdultContent}
-            style={styles.toggleBtn}
-          />
-        ) : (
-          <View style={[pal.viewLight, styles.agePrompt]}>
-            <Text type="md" style={[pal.text, {flex: 1}]}>
-              You must be 18 or older to enable adult content.
-            </Text>
-            <Button type="primary" label="Set Age" onPress={onSetAge} />
-          </View>
-        )}
-      </View>
-    )
-  },
-)
+  return (
+    <View style={s.mb10}>
+      {isIOS ? (
+        preferences?.adultContentEnabled ? null : (
+          <Text type="md" style={pal.textLight}>
+            Adult content can only be enabled via the Web at{' '}
+            <TextLink
+              style={pal.link}
+              href="https://bsky.app"
+              text="bsky.app"
+            />
+            .
+          </Text>
+        )
+      ) : typeof preferences?.birthDate === 'undefined' ? (
+        <View style={[pal.viewLight, styles.agePrompt]}>
+          <Text type="md" style={[pal.text, {flex: 1}]}>
+            Confirm your age to enable adult content.
+          </Text>
+          <Button type="primary" label="Set Age" onPress={onSetAge} />
+        </View>
+      ) : (preferences.userAge || 0) >= 18 ? (
+        <ToggleButton
+          type="default-light"
+          label="Enable Adult Content"
+          isSelected={variables?.enabled ?? preferences?.adultContentEnabled}
+          onPress={onToggleAdultContent}
+          style={styles.toggleBtn}
+        />
+      ) : (
+        <View style={[pal.viewLight, styles.agePrompt]}>
+          <Text type="md" style={[pal.text, {flex: 1}]}>
+            You must be 18 or older to enable adult content.
+          </Text>
+          <Button type="primary" label="Set Age" onPress={onSetAge} />
+        </View>
+      )}
+    </View>
+  )
+}
 
 // TODO: Refactor this component to pass labels down to each tab
-const ContentLabelPref = observer(function ContentLabelPrefImpl({
-  group,
+function ContentLabelPref({
+  preferences,
+  labelGroup,
   disabled,
 }: {
-  group: keyof typeof CONFIGURABLE_LABEL_GROUPS
+  preferences?: UsePreferencesQueryResponse
+  labelGroup: ConfigurableLabelGroup
   disabled?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const visibility = preferences?.contentLabels?.[labelGroup]
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
 
   const onChange = React.useCallback(
-    async (v: LabelPreference) => {
-      try {
-        await store.preferences.setContentLabelPref(group, v)
-      } catch (e) {
-        Toast.show(
-          'There was an issue syncing your preferences with the server',
-        )
-        logger.error('Failed to update preferences with server', {error: e})
-      }
+    (vis: LabelPreference) => {
+      mutate({labelGroup, visibility: vis})
     },
-    [store, group],
+    [mutate, labelGroup],
   )
 
   return (
     <View style={[styles.contentLabelPref, pal.border]}>
       <View style={s.flex1}>
         <Text type="md-medium" style={[pal.text]}>
-          {CONFIGURABLE_LABEL_GROUPS[group].title}
+          {CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
         </Text>
-        {typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && (
+        {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
           <Text type="sm" style={[pal.textLight]}>
-            {CONFIGURABLE_LABEL_GROUPS[group].subtitle}
+            {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
           </Text>
         )}
       </View>
-      {disabled ? (
+
+      {disabled || !visibility ? (
         <Text type="sm-bold" style={pal.textLight}>
           Hide
         </Text>
       ) : (
         <SelectGroup
-          current={store.preferences.contentLabels[group]}
+          current={variables?.visibility || visibility}
           onChange={onChange}
-          group={group}
+          labelGroup={labelGroup}
         />
       )}
     </View>
   )
-})
+}
 
 interface SelectGroupProps {
   current: LabelPreference
   onChange: (v: LabelPreference) => void
-  group: keyof typeof CONFIGURABLE_LABEL_GROUPS
+  labelGroup: ConfigurableLabelGroup
 }
 
-function SelectGroup({current, onChange, group}: SelectGroupProps) {
+function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
   return (
     <View style={styles.selectableBtns}>
       <SelectableBtn
@@ -217,14 +231,14 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
         label="Hide"
         left
         onChange={onChange}
-        group={group}
+        labelGroup={labelGroup}
       />
       <SelectableBtn
         current={current}
         value="warn"
         label="Warn"
         onChange={onChange}
-        group={group}
+        labelGroup={labelGroup}
       />
       <SelectableBtn
         current={current}
@@ -232,7 +246,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
         label="Show"
         right
         onChange={onChange}
-        group={group}
+        labelGroup={labelGroup}
       />
     </View>
   )
@@ -245,7 +259,7 @@ interface SelectableBtnProps {
   left?: boolean
   right?: boolean
   onChange: (v: LabelPreference) => void
-  group: keyof typeof CONFIGURABLE_LABEL_GROUPS
+  labelGroup: ConfigurableLabelGroup
 }
 
 function SelectableBtn({
@@ -255,7 +269,7 @@ function SelectableBtn({
   left,
   right,
   onChange,
-  group,
+  labelGroup,
 }: SelectableBtnProps) {
   const pal = usePalette('default')
   const palPrimary = usePalette('inverted')
@@ -271,7 +285,7 @@ function SelectableBtn({
       onPress={() => onChange(value)}
       accessibilityRole="button"
       accessibilityLabel={value}
-      accessibilityHint={`Set ${value} for ${group} content moderation policy`}>
+      accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}>
       <Text style={current === value ? palPrimary.text : pal.text}>
         {label}
       </Text>
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 1ea12695f..8d13cdf2f 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -1,5 +1,4 @@
 import React, {useState, useCallback, useMemo} from 'react'
-import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
@@ -9,12 +8,12 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {AppBskyGraphDefs} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
+import * as Toast from '../util/Toast'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {compressIfNeeded} from 'lib/media/manip'
@@ -24,6 +23,13 @@ import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useListCreateMutation,
+  useListMetadataMutation,
+} from '#/state/queries/list'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -37,18 +43,21 @@ export function Component({
 }: {
   purpose?: string
   onSave?: (uri: string) => void
-  list?: ListModel
+  list?: AppBskyGraphDefs.ListView
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [error, setError] = useState<string>('')
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
+  const {_} = useLingui()
+  const listCreateMutation = useListCreateMutation()
+  const listMetadataMutation = useListMetadataMutation()
 
   const activePurpose = useMemo(() => {
-    if (list?.data?.purpose) {
-      return list.data.purpose
+    if (list?.purpose) {
+      return list.purpose
     }
     if (purpose) {
       return purpose
@@ -59,16 +68,16 @@ export function Component({
   const purposeLabel = isCurateList ? 'User' : 'Moderation'
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
-  const [name, setName] = useState<string>(list?.data?.name || '')
+  const [name, setName] = useState<string>(list?.name || '')
   const [description, setDescription] = useState<string>(
-    list?.data?.description || '',
+    list?.description || '',
   )
-  const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
+  const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
   const onPressCancel = useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
@@ -106,7 +115,8 @@ export function Component({
     }
     try {
       if (list) {
-        await list.updateMetadata({
+        await listMetadataMutation.mutateAsync({
+          uri: list.uri,
           name: nameTrimmed,
           description: description.trim(),
           avatar: newAvatar,
@@ -114,7 +124,7 @@ export function Component({
         Toast.show(`${purposeLabel} list updated`)
         onSave?.(list.uri)
       } else {
-        const res = await ListModel.createList(store, {
+        const res = await listCreateMutation.mutateAsync({
           purpose: activePurpose,
           name,
           description,
@@ -123,7 +133,7 @@ export function Component({
         Toast.show(`${purposeLabel} list created`)
         onSave?.(res.uri)
       }
-      store.shell.closeModal()
+      closeModal()
     } catch (e: any) {
       if (isNetworkError(e)) {
         setError(
@@ -140,7 +150,7 @@ export function Component({
     setError,
     error,
     onSave,
-    store,
+    closeModal,
     activePurpose,
     isCurateList,
     purposeLabel,
@@ -148,6 +158,8 @@ export function Component({
     description,
     newAvatar,
     list,
+    listMetadataMutation,
+    listCreateMutation,
   ])
 
   return (
@@ -161,14 +173,18 @@ export function Component({
         ]}
         testID="createOrEditListModal">
         <Text style={[styles.title, pal.text]}>
-          {list ? 'Edit' : 'New'} {purposeLabel} List
+          <Trans>
+            {list ? 'Edit' : 'New'} {purposeLabel} List
+          </Trans>
         </Text>
         {error !== '' && (
           <View style={styles.errorContainer}>
             <ErrorMessage message={error} />
           </View>
         )}
-        <Text style={[styles.label, pal.text]}>List Avatar</Text>
+        <Text style={[styles.label, pal.text]}>
+          <Trans>List Avatar</Trans>
+        </Text>
         <View style={[styles.avi, {borderColor: pal.colors.background}]}>
           <EditableUserAvatar
             type="list"
@@ -180,7 +196,7 @@ export function Component({
         <View style={styles.form}>
           <View>
             <Text style={[styles.label, pal.text]} nativeID="list-name">
-              List Name
+              <Trans>List Name</Trans>
             </Text>
             <TextInput
               testID="editNameInput"
@@ -192,14 +208,14 @@ export function Component({
               value={name}
               onChangeText={v => setName(enforceLen(v, MAX_NAME))}
               accessible={true}
-              accessibilityLabel="Name"
+              accessibilityLabel={_(msg`Name`)}
               accessibilityHint=""
               accessibilityLabelledBy="list-name"
             />
           </View>
           <View style={s.pb10}>
             <Text style={[styles.label, pal.text]} nativeID="list-description">
-              Description
+              <Trans>Description</Trans>
             </Text>
             <TextInput
               testID="editDescriptionInput"
@@ -215,7 +231,7 @@ export function Component({
               value={description}
               onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
               accessible={true}
-              accessibilityLabel="Description"
+              accessibilityLabel={_(msg`Description`)}
               accessibilityHint=""
               accessibilityLabelledBy="list-description"
             />
@@ -230,14 +246,16 @@ export function Component({
               style={s.mt10}
               onPress={onPressSave}
               accessibilityRole="button"
-              accessibilityLabel="Save"
+              accessibilityLabel={_(msg`Save`)}
               accessibilityHint="">
               <LinearGradient
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
                 end={{x: 1, y: 1}}
                 style={[styles.btn]}>
-                <Text style={[s.white, s.bold]}>Save</Text>
+                <Text style={[s.white, s.bold]}>
+                  <Trans>Save</Trans>
+                </Text>
               </LinearGradient>
             </TouchableOpacity>
           )}
@@ -246,11 +264,13 @@ export function Component({
             style={s.mt5}
             onPress={onPressCancel}
             accessibilityRole="button"
-            accessibilityLabel="Cancel"
+            accessibilityLabel={_(msg`Cancel`)}
             accessibilityHint=""
             onAccessibilityEscape={onPressCancel}>
             <View style={[styles.btn]}>
-              <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
+              <Text style={[s.black, s.bold, pal.text]}>
+                <Trans>Cancel</Trans>
+              </Text>
             </View>
           </TouchableOpacity>
         </View>
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index 50a4cd603..ee16d46b3 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -9,7 +9,6 @@ import {TextInput} from './util'
 import LinearGradient from 'react-native-linear-gradient'
 import * as Toast from '../util/Toast'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
@@ -17,13 +16,20 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {resetToTab} from '../../../Navigation'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSession, useSessionApi, getAgent} from '#/state/session'
 
 export const snapPoints = ['60%']
 
 export function Component({}: {}) {
   const pal = usePalette('default')
   const theme = useTheme()
-  const store = useStores()
+  const {currentAccount} = useSession()
+  const {clearCurrentAccount, removeAccount} = useSessionApi()
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
   const [confirmCode, setConfirmCode] = React.useState<string>('')
@@ -34,7 +40,7 @@ export function Component({}: {}) {
     setError('')
     setIsProcessing(true)
     try {
-      await store.agent.com.atproto.server.requestAccountDelete()
+      await getAgent().com.atproto.server.requestAccountDelete()
       setIsEmailSent(true)
     } catch (e: any) {
       setError(cleanError(e))
@@ -42,34 +48,39 @@ export function Component({}: {}) {
     setIsProcessing(false)
   }
   const onPressConfirmDelete = async () => {
+    if (!currentAccount?.did) {
+      throw new Error(`DeleteAccount modal: currentAccount.did is undefined`)
+    }
+
     setError('')
     setIsProcessing(true)
     const token = confirmCode.replace(/\s/g, '')
 
     try {
-      await store.agent.com.atproto.server.deleteAccount({
-        did: store.me.did,
+      await getAgent().com.atproto.server.deleteAccount({
+        did: currentAccount.did,
         password,
         token,
       })
       Toast.show('Your account has been deleted')
       resetToTab('HomeTab')
-      store.session.clear()
-      store.shell.closeModal()
+      removeAccount(currentAccount)
+      clearCurrentAccount()
+      closeModal()
     } catch (e: any) {
       setError(cleanError(e))
     }
     setIsProcessing(false)
   }
   const onCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
   return (
     <View style={[styles.container, pal.view]}>
       <View style={[styles.innerContainer, pal.view]}>
         <View style={[styles.titleContainer, pal.view]}>
           <Text type="title-xl" style={[s.textCenter, pal.text]}>
-            Delete Account
+            <Trans>Delete Account</Trans>
           </Text>
           <View style={[pal.view, s.flexRow]}>
             <Text type="title-xl" style={[pal.text, s.bold]}>
@@ -83,7 +94,7 @@ export function Component({}: {}) {
                 pal.text,
                 s.bold,
               ]}>
-              {store.me.handle}
+              {currentAccount?.handle}
             </Text>
             <Text type="title-xl" style={[pal.text, s.bold]}>
               {'"'}
@@ -93,8 +104,10 @@ export function Component({}: {}) {
         {!isEmailSent ? (
           <>
             <Text type="lg" style={[styles.description, pal.text]}>
-              For security reasons, we'll need to send a confirmation code to
-              your email address.
+              <Trans>
+                For security reasons, we'll need to send a confirmation code to
+                your email address.
+              </Trans>
             </Text>
             {error ? (
               <View style={s.mt10}>
@@ -111,7 +124,7 @@ export function Component({}: {}) {
                   style={styles.mt20}
                   onPress={onPressSendEmail}
                   accessibilityRole="button"
-                  accessibilityLabel="Send email"
+                  accessibilityLabel={_(msg`Send email`)}
                   accessibilityHint="Sends email with confirmation code for account deletion">
                   <LinearGradient
                     colors={[
@@ -122,7 +135,7 @@ export function Component({}: {}) {
                     end={{x: 1, y: 1}}
                     style={[styles.btn]}>
                     <Text type="button-lg" style={[s.white, s.bold]}>
-                      Send Email
+                      <Trans>Send Email</Trans>
                     </Text>
                   </LinearGradient>
                 </TouchableOpacity>
@@ -130,11 +143,11 @@ export function Component({}: {}) {
                   style={[styles.btn, s.mt10]}
                   onPress={onCancel}
                   accessibilityRole="button"
-                  accessibilityLabel="Cancel account deletion"
+                  accessibilityLabel={_(msg`Cancel account deletion`)}
                   accessibilityHint=""
                   onAccessibilityEscape={onCancel}>
                   <Text type="button-lg" style={pal.textLight}>
-                    Cancel
+                    <Trans>Cancel</Trans>
                   </Text>
                 </TouchableOpacity>
               </>
@@ -147,8 +160,10 @@ export function Component({}: {}) {
               type="lg"
               style={styles.description}
               nativeID="confirmationCode">
-              Check your inbox for an email with the confirmation code to enter
-              below:
+              <Trans>
+                Check your inbox for an email with the confirmation code to
+                enter below:
+              </Trans>
             </Text>
             <TextInput
               style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
@@ -158,11 +173,11 @@ export function Component({}: {}) {
               value={confirmCode}
               onChangeText={setConfirmCode}
               accessibilityLabelledBy="confirmationCode"
-              accessibilityLabel="Confirmation code"
+              accessibilityLabel={_(msg`Confirmation code`)}
               accessibilityHint="Input confirmation code for account deletion"
             />
             <Text type="lg" style={styles.description} nativeID="password">
-              Please enter your password as well:
+              <Trans>Please enter your password as well:</Trans>
             </Text>
             <TextInput
               style={[styles.textInput, pal.borderDark, pal.text]}
@@ -173,7 +188,7 @@ export function Component({}: {}) {
               value={password}
               onChangeText={setPassword}
               accessibilityLabelledBy="password"
-              accessibilityLabel="Password"
+              accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Input password for account deletion"
             />
             {error ? (
@@ -191,21 +206,21 @@ export function Component({}: {}) {
                   style={[styles.btn, styles.evilBtn, styles.mt20]}
                   onPress={onPressConfirmDelete}
                   accessibilityRole="button"
-                  accessibilityLabel="Confirm delete account"
+                  accessibilityLabel={_(msg`Confirm delete account`)}
                   accessibilityHint="">
                   <Text type="button-lg" style={[s.white, s.bold]}>
-                    Delete my account
+                    <Trans>Delete my account</Trans>
                   </Text>
                 </TouchableOpacity>
                 <TouchableOpacity
                   style={[styles.btn, s.mt10]}
                   onPress={onCancel}
                   accessibilityRole="button"
-                  accessibilityLabel="Cancel account deletion"
+                  accessibilityLabel={_(msg`Cancel account deletion`)}
                   accessibilityHint="Exits account deletion process"
                   onAccessibilityEscape={onCancel}>
                   <Text type="button-lg" style={pal.textLight}>
-                    Cancel
+                    <Trans>Cancel</Trans>
                   </Text>
                 </TouchableOpacity>
               </>
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
index dcb6668c7..753907472 100644
--- a/src/view/com/modals/EditImage.tsx
+++ b/src/view/com/modals/EditImage.tsx
@@ -6,7 +6,6 @@ import {gradients, s} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import ImageEditor, {Position} from 'react-avatar-editor'
 import {TextInput} from './util'
@@ -19,6 +18,9 @@ import {Slider} from '@miblanchard/react-native-slider'
 import {MaterialIcons} from '@expo/vector-icons'
 import {observer} from 'mobx-react-lite'
 import {getKeys} from 'lib/type-assertions'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
@@ -52,9 +54,10 @@ export const Component = observer(function EditImageImpl({
 }: Props) {
   const pal = usePalette('default')
   const theme = useTheme()
-  const store = useStores()
+  const {_} = useLingui()
   const windowDimensions = useWindowDimensions()
   const {isMobile} = useWebMediaQueries()
+  const {closeModal} = useModalControls()
 
   const {
     aspectRatio,
@@ -128,8 +131,8 @@ export const Component = observer(function EditImageImpl({
   }, [image])
 
   const onCloseModal = useCallback(() => {
-    store.shell.closeModal()
-  }, [store.shell])
+    closeModal()
+  }, [closeModal])
 
   const onPressCancel = useCallback(async () => {
     await gallery.previous(image)
@@ -200,7 +203,9 @@ export const Component = observer(function EditImageImpl({
           paddingHorizontal: isMobile ? 16 : undefined,
         },
       ]}>
-      <Text style={[styles.title, pal.text]}>Edit image</Text>
+      <Text style={[styles.title, pal.text]}>
+        <Trans>Edit image</Trans>
+      </Text>
       <View style={[styles.gap18, s.flexRow]}>
         <View>
           <View
@@ -228,7 +233,7 @@ export const Component = observer(function EditImageImpl({
         <View>
           {!isMobile ? (
             <Text type="sm-bold" style={pal.text}>
-              Ratios
+              <Trans>Ratios</Trans>
             </Text>
           ) : null}
           <View style={imgControlStyles}>
@@ -263,7 +268,7 @@ export const Component = observer(function EditImageImpl({
           </View>
           {!isMobile ? (
             <Text type="sm-bold" style={[pal.text, styles.subsection]}>
-              Transformations
+              <Trans>Transformations</Trans>
             </Text>
           ) : null}
           <View style={imgControlStyles}>
@@ -291,7 +296,7 @@ export const Component = observer(function EditImageImpl({
       </View>
       <View style={[styles.gap18, styles.bottomSection, pal.border]}>
         <Text type="sm-bold" style={pal.text} nativeID="alt-text">
-          Accessibility
+          <Trans>Accessibility</Trans>
         </Text>
         <TextInput
           testID="altTextImageInput"
@@ -307,7 +312,7 @@ export const Component = observer(function EditImageImpl({
           multiline
           value={altText}
           onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-          accessibilityLabel="Alt text"
+          accessibilityLabel={_(msg`Alt text`)}
           accessibilityHint=""
           accessibilityLabelledBy="alt-text"
         />
@@ -315,7 +320,7 @@ export const Component = observer(function EditImageImpl({
       <View style={styles.btns}>
         <Pressable onPress={onPressCancel} accessibilityRole="button">
           <Text type="xl" style={pal.link}>
-            Cancel
+            <Trans>Cancel</Trans>
           </Text>
         </Pressable>
         <Pressable onPress={onPressSave} accessibilityRole="button">
@@ -325,7 +330,7 @@ export const Component = observer(function EditImageImpl({
             end={{x: 1, y: 1}}
             style={[styles.btn]}>
             <Text type="xl-medium" style={s.white}>
-              Done
+              <Trans>Done</Trans>
             </Text>
           </LinearGradient>
         </Pressable>
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index dfd5305f5..e044f8c0e 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -11,10 +11,9 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
-import {ProfileModel} from 'state/models/content/profile'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
@@ -24,9 +23,14 @@ import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {cleanError} from 'lib/strings/errors'
 import Animated, {FadeOut} from 'react-native-reanimated'
 import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useProfileUpdateMutation} from '#/state/queries/profile'
+import {logger} from '#/logger'
 
 const AnimatedTouchableOpacity =
   Animated.createAnimatedComponent(TouchableOpacity)
@@ -34,30 +38,30 @@ const AnimatedTouchableOpacity =
 export const snapPoints = ['fullscreen']
 
 export function Component({
-  profileView,
+  profile,
   onUpdate,
 }: {
-  profileView: ProfileModel
+  profile: AppBskyActorDefs.ProfileViewDetailed
   onUpdate?: () => void
 }) {
-  const store = useStores()
-  const [error, setError] = useState<string>('')
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
-
-  const [isProcessing, setProcessing] = useState<boolean>(false)
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
+  const updateMutation = useProfileUpdateMutation()
+  const [imageError, setImageError] = useState<string>('')
   const [displayName, setDisplayName] = useState<string>(
-    profileView.displayName || '',
+    profile.displayName || '',
   )
   const [description, setDescription] = useState<string>(
-    profileView.description || '',
+    profile.description || '',
   )
   const [userBanner, setUserBanner] = useState<string | undefined | null>(
-    profileView.banner,
+    profile.banner,
   )
   const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
-    profileView.avatar,
+    profile.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
     RNImage | undefined | null
@@ -66,10 +70,11 @@ export function Component({
     RNImage | undefined | null
   >()
   const onPressCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
+      setImageError('')
       if (img === null) {
         setNewUserAvatar(null)
         setUserAvatar(null)
@@ -81,14 +86,15 @@ export function Component({
         setNewUserAvatar(finalImg)
         setUserAvatar(finalImg.path)
       } catch (e: any) {
-        setError(cleanError(e))
+        setImageError(cleanError(e))
       }
     },
-    [track, setNewUserAvatar, setUserAvatar, setError],
+    [track, setNewUserAvatar, setUserAvatar, setImageError],
   )
 
   const onSelectNewBanner = useCallback(
     async (img: RNImage | null) => {
+      setImageError('')
       if (!img) {
         setNewUserBanner(null)
         setUserBanner(null)
@@ -100,58 +106,50 @@ export function Component({
         setNewUserBanner(finalImg)
         setUserBanner(finalImg.path)
       } catch (e: any) {
-        setError(cleanError(e))
+        setImageError(cleanError(e))
       }
     },
-    [track, setNewUserBanner, setUserBanner, setError],
+    [track, setNewUserBanner, setUserBanner, setImageError],
   )
 
   const onPressSave = useCallback(async () => {
     track('EditProfile:Save')
-    setProcessing(true)
-    if (error) {
-      setError('')
-    }
+    setImageError('')
     try {
-      await profileView.updateProfile(
-        {
+      await updateMutation.mutateAsync({
+        profile,
+        updates: {
           displayName,
           description,
         },
         newUserAvatar,
         newUserBanner,
-      )
+      })
       Toast.show('Profile updated')
       onUpdate?.()
-      store.shell.closeModal()
+      closeModal()
     } catch (e: any) {
-      if (isNetworkError(e)) {
-        setError(
-          'Failed to save your profile. Check your internet connection and try again.',
-        )
-      } else {
-        setError(cleanError(e))
-      }
+      logger.error('Failed to update user profile', {error: String(e)})
     }
-    setProcessing(false)
   }, [
     track,
-    setProcessing,
-    setError,
-    error,
-    profileView,
+    updateMutation,
+    profile,
     onUpdate,
-    store,
+    closeModal,
     displayName,
     description,
     newUserAvatar,
     newUserBanner,
+    setImageError,
   ])
 
   return (
     <KeyboardAvoidingView style={s.flex1} behavior="height">
       <ScrollView style={[pal.view]} testID="editProfileModal">
-        <Text style={[styles.title, pal.text]}>Edit my profile</Text>
+        <Text style={[styles.title, pal.text]}>
+          <Trans>Edit my profile</Trans>
+        </Text>
         <View style={styles.photos}>
           <UserBanner
             banner={userBanner}
@@ -165,14 +163,21 @@ export function Component({
             />
           </View>
         </View>
-        {error !== '' && (
+        {updateMutation.isError && (
+          <View style={styles.errorContainer}>
+            <ErrorMessage message={cleanError(updateMutation.error)} />
+          </View>
+        )}
+        {imageError !== '' && (
           <View style={styles.errorContainer}>
-            <ErrorMessage message={error} />
+            <ErrorMessage message={imageError} />
           </View>
         )}
         <View style={styles.form}>
           <View>
-            <Text style={[styles.label, pal.text]}>Display Name</Text>
+            <Text style={[styles.label, pal.text]}>
+              <Trans>Display Name</Trans>
+            </Text>
             <TextInput
               testID="editProfileDisplayNameInput"
               style={[styles.textInput, pal.border, pal.text]}
@@ -183,12 +188,14 @@ export function Component({
                 setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
               }
               accessible={true}
-              accessibilityLabel="Display name"
+              accessibilityLabel={_(msg`Display name`)}
               accessibilityHint="Edit your display name"
             />
           </View>
           <View style={s.pb10}>
-            <Text style={[styles.label, pal.text]}>Description</Text>
+            <Text style={[styles.label, pal.text]}>
+              <Trans>Description</Trans>
+            </Text>
             <TextInput
               testID="editProfileDescriptionInput"
               style={[styles.textArea, pal.border, pal.text]}
@@ -199,11 +206,11 @@ export function Component({
               value={description}
               onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
               accessible={true}
-              accessibilityLabel="Description"
+              accessibilityLabel={_(msg`Description`)}
               accessibilityHint="Edit your profile description"
             />
           </View>
-          {isProcessing ? (
+          {updateMutation.isPending ? (
             <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
               <ActivityIndicator />
             </View>
@@ -213,29 +220,33 @@ export function Component({
               style={s.mt10}
               onPress={onPressSave}
               accessibilityRole="button"
-              accessibilityLabel="Save"
+              accessibilityLabel={_(msg`Save`)}
               accessibilityHint="Saves any changes to your profile">
               <LinearGradient
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
                 end={{x: 1, y: 1}}
                 style={[styles.btn]}>
-                <Text style={[s.white, s.bold]}>Save Changes</Text>
+                <Text style={[s.white, s.bold]}>
+                  <Trans>Save Changes</Trans>
+                </Text>
               </LinearGradient>
             </TouchableOpacity>
           )}
-          {!isProcessing && (
+          {!updateMutation.isPending && (
             <AnimatedTouchableOpacity
               exiting={!isWeb ? FadeOut : undefined}
               testID="editProfileCancelBtn"
               style={s.mt5}
               onPress={onPressCancel}
               accessibilityRole="button"
-              accessibilityLabel="Cancel profile editing"
+              accessibilityLabel={_(msg`Cancel profile editing`)}
               accessibilityHint=""
               onAccessibilityEscape={onPressCancel}>
               <View style={[styles.btn]}>
-                <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
+                <Text style={[s.black, s.bold, pal.text]}>
+                  <Trans>Cancel</Trans>
+                </Text>
               </View>
             </AnimatedTouchableOpacity>
           )}
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index 09cfd4de7..82a826aca 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -1,6 +1,11 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ActivityIndicator,
+} from 'react-native'
+import {ComAtprotoServerDefs} from '@atproto/api'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -9,30 +14,57 @@ import Clipboard from '@react-native-clipboard/clipboard'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {ScrollView} from './util'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans} from '@lingui/macro'
+import {cleanError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
+import {useInvitesState, useInvitesAPI} from '#/state/invites'
+import {UserInfoText} from '../util/UserInfoText'
+import {makeProfileLink} from '#/lib/routes/links'
+import {Link} from '../util/Link'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {
+  useInviteCodesQuery,
+  InviteCodesQueryResponse,
+} from '#/state/queries/invites'
 
 export const snapPoints = ['70%']
 
-export function Component({}: {}) {
+export function Component() {
+  const {isLoading, data: invites, error} = useInviteCodesQuery()
+
+  return error ? (
+    <ErrorMessage message={cleanError(error)} />
+  ) : isLoading || !invites ? (
+    <View style={{padding: 18}}>
+      <ActivityIndicator />
+    </View>
+  ) : (
+    <Inner invites={invites} />
+  )
+}
+
+export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isTabletOrDesktop} = useWebMediaQueries()
 
   const onClose = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
-  if (store.me.invites.length === 0) {
+  if (invites.all.length === 0) {
     return (
       <View style={[styles.container, pal.view]} testID="inviteCodesModal">
         <View style={[styles.empty, pal.viewLight]}>
           <Text type="lg" style={[pal.text, styles.emptyText]}>
-            You don't have any invite codes yet! We'll send you some when you've
-            been on Bluesky for a little longer.
+            <Trans>
+              You don't have any invite codes yet! We'll send you some when
+              you've been on Bluesky for a little longer.
+            </Trans>
           </Text>
         </View>
         <View style={styles.flex1} />
@@ -56,18 +88,29 @@ export function Component({}: {}) {
   return (
     <View style={[styles.container, pal.view]} testID="inviteCodesModal">
       <Text type="title-xl" style={[styles.title, pal.text]}>
-        Invite a Friend
+        <Trans>Invite a Friend</Trans>
       </Text>
       <Text type="lg" style={[styles.description, pal.text]}>
-        Each code works once. You'll receive more invite codes periodically.
+        <Trans>
+          Each code works once. You'll receive more invite codes periodically.
+        </Trans>
       </Text>
       <ScrollView style={[styles.scrollContainer, pal.border]}>
-        {store.me.invites.map((invite, i) => (
+        {invites.available.map((invite, i) => (
           <InviteCode
             testID={`inviteCode-${i}`}
             key={invite.code}
-            code={invite.code}
-            used={invite.available - invite.uses.length <= 0 || invite.disabled}
+            invite={invite}
+            invites={invites}
+          />
+        ))}
+        {invites.used.map((invite, i) => (
+          <InviteCode
+            used
+            testID={`inviteCode-${i}`}
+            key={invite.code}
+            invite={invite}
+            invites={invites}
           />
         ))}
       </ScrollView>
@@ -85,56 +128,89 @@ export function Component({}: {}) {
   )
 }
 
-const InviteCode = observer(function InviteCodeImpl({
+function InviteCode({
   testID,
-  code,
+  invite,
   used,
+  invites,
 }: {
   testID: string
-  code: string
+  invite: ComAtprotoServerDefs.InviteCode
   used?: boolean
+  invites: InviteCodesQueryResponse
 }) {
   const pal = usePalette('default')
-  const store = useStores()
-  const {invitesAvailable} = store.me
+  const invitesState = useInvitesState()
+  const {setInviteCopied} = useInvitesAPI()
 
   const onPress = React.useCallback(() => {
-    Clipboard.setString(code)
+    Clipboard.setString(invite.code)
     Toast.show('Copied to clipboard')
-    store.invitedUsers.setInviteCopied(code)
-  }, [store, code])
+    setInviteCopied(invite.code)
+  }, [setInviteCopied, invite])
 
   return (
-    <TouchableOpacity
-      testID={testID}
-      style={[styles.inviteCode, pal.border]}
-      onPress={onPress}
-      accessibilityRole="button"
-      accessibilityLabel={
-        invitesAvailable === 1
-          ? 'Invite codes: 1 available'
-          : `Invite codes: ${invitesAvailable} available`
-      }
-      accessibilityHint="Opens list of invite codes">
-      <Text
-        testID={`${testID}-code`}
-        type={used ? 'md' : 'md-bold'}
-        style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
-        {code}
-      </Text>
-      <View style={styles.flex1} />
-      {!used && store.invitedUsers.isInviteCopied(code) && (
-        <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text>
-      )}
-      {!used && (
-        <FontAwesomeIcon
-          icon={['far', 'clone']}
-          style={pal.text as FontAwesomeIconStyle}
-        />
-      )}
-    </TouchableOpacity>
+    <View
+      style={[
+        pal.border,
+        {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14},
+      ]}>
+      <TouchableOpacity
+        testID={testID}
+        style={[styles.inviteCode]}
+        onPress={onPress}
+        accessibilityRole="button"
+        accessibilityLabel={
+          invites.available.length === 1
+            ? 'Invite codes: 1 available'
+            : `Invite codes: ${invites.available.length} available`
+        }
+        accessibilityHint="Opens list of invite codes">
+        <Text
+          testID={`${testID}-code`}
+          type={used ? 'md' : 'md-bold'}
+          style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
+          {invite.code}
+        </Text>
+        <View style={styles.flex1} />
+        {!used && invitesState.copiedInvites.includes(invite.code) && (
+          <Text style={[pal.textLight, styles.codeCopied]}>
+            <Trans>Copied</Trans>
+          </Text>
+        )}
+        {!used && (
+          <FontAwesomeIcon
+            icon={['far', 'clone']}
+            style={pal.text as FontAwesomeIconStyle}
+          />
+        )}
+      </TouchableOpacity>
+      {invite.uses.length > 0 ? (
+        <View
+          style={{
+            flexDirection: 'column',
+            gap: 8,
+            paddingTop: 6,
+          }}>
+          <Text style={pal.text}>
+            <Trans>Used by:</Trans>
+          </Text>
+          {invite.uses.map(use => (
+            <Link
+              key={use.usedBy}
+              href={makeProfileLink({handle: use.usedBy, did: ''})}
+              style={{
+                flexDirection: 'row',
+              }}>
+              <Text style={pal.text}>• </Text>
+              <UserInfoText did={use.usedBy} style={pal.link} />
+            </Link>
+          ))}
+        </View>
+      ) : null}
+    </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
@@ -176,9 +252,6 @@ const styles = StyleSheet.create({
   inviteCode: {
     flexDirection: 'row',
     alignItems: 'center',
-    borderBottomWidth: 1,
-    paddingHorizontal: 20,
-    paddingVertical: 14,
   },
   codeCopied: {
     marginRight: 8,
diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx
index 67a156af4..39e6cc3e6 100644
--- a/src/view/com/modals/LinkWarning.tsx
+++ b/src/view/com/modals/LinkWarning.tsx
@@ -1,33 +1,29 @@
 import React from 'react'
 import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView} from './util'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['50%']
 
-export const Component = observer(function Component({
-  text,
-  href,
-}: {
-  text: string
-  href: string
-}) {
+export function Component({text, href}: {text: string; href: string}) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
+  const {_} = useLingui()
   const potentiallyMisleading = isPossiblyAUrl(text)
 
   const onPressVisit = () => {
-    store.shell.closeModal()
+    closeModal()
     Linking.openURL(href)
   }
 
@@ -45,26 +41,26 @@ export const Component = observer(function Component({
                 size={18}
               />
               <Text type="title-lg" style={[pal.text, styles.title]}>
-                Potentially Misleading Link
+                <Trans>Potentially Misleading Link</Trans>
               </Text>
             </>
           ) : (
             <Text type="title-lg" style={[pal.text, styles.title]}>
-              Leaving Bluesky
+              <Trans>Leaving Bluesky</Trans>
             </Text>
           )}
         </View>
 
         <View style={{gap: 10}}>
           <Text type="lg" style={pal.text}>
-            This link is taking you to the following website:
+            <Trans>This link is taking you to the following website:</Trans>
           </Text>
 
           <LinkBox href={href} />
 
           {potentiallyMisleading && (
             <Text type="lg" style={pal.text}>
-              Make sure this is where you intend to go!
+              <Trans>Make sure this is where you intend to go!</Trans>
             </Text>
           )}
         </View>
@@ -74,7 +70,7 @@ export const Component = observer(function Component({
             testID="confirmBtn"
             type="primary"
             onPress={onPressVisit}
-            accessibilityLabel="Visit Site"
+            accessibilityLabel={_(msg`Visit Site`)}
             accessibilityHint=""
             label="Visit Site"
             labelContainerStyle={{justifyContent: 'center', padding: 4}}
@@ -83,8 +79,10 @@ export const Component = observer(function Component({
           <Button
             testID="cancelBtn"
             type="default"
-            onPress={() => store.shell.closeModal()}
-            accessibilityLabel="Cancel"
+            onPress={() => {
+              closeModal()
+            }}
+            accessibilityLabel={_(msg`Cancel`)}
             accessibilityHint=""
             label="Cancel"
             labelContainerStyle={{justifyContent: 'center', padding: 4}}
@@ -94,7 +92,7 @@ export const Component = observer(function Component({
       </ScrollView>
     </SafeAreaView>
   )
-})
+}
 
 function LinkBox({href}: {href: string}) {
   const pal = usePalette('default')
diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
index a04e2d186..14e16d6bf 100644
--- a/src/view/com/modals/ListAddUser.tsx
+++ b/src/view/com/modals/ListAddRemoveUsers.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useCallback, useState, useMemo} from 'react'
+import React, {useCallback, useState} from 'react'
 import {
   ActivityIndicator,
   Pressable,
@@ -6,17 +6,13 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {ScrollView, TextInput} from './util'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {UserAvatar} from '../util/UserAvatar'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
@@ -26,47 +22,40 @@ import {cleanError} from 'lib/strings/errors'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {HITSLOP_20} from '#/lib/constants'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 
 export const snapPoints = ['90%']
 
-export const Component = observer(function Component({
+export function Component({
   list,
-  onAdd,
+  onChange,
 }: {
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [query, setQuery] = useState('')
-  const autocompleteView = useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
+  const autocomplete = useActorAutocompleteQuery(query)
+  const {data: memberships} = useDangerousListMembershipsQuery()
   const [isKeyboardVisible] = useIsKeyboardVisible()
 
-  // initial setup
-  useEffect(() => {
-    autocompleteView.setup().then(() => {
-      autocompleteView.setPrefix('')
-    })
-    autocompleteView.setActive(true)
-    list.loadAll()
-  }, [autocompleteView, list])
-
-  const onChangeQuery = useCallback(
-    (text: string) => {
-      setQuery(text)
-      autocompleteView.setPrefix(text)
-    },
-    [setQuery, autocompleteView],
-  )
-
-  const onPressCancelSearch = useCallback(
-    () => onChangeQuery(''),
-    [onChangeQuery],
-  )
+  const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery])
 
   return (
     <SafeAreaView
@@ -81,9 +70,9 @@ export const Component = observer(function Component({
             placeholder="Search for users"
             placeholderTextColor={pal.colors.textLight}
             value={query}
-            onChangeText={onChangeQuery}
+            onChangeText={setQuery}
             accessible={true}
-            accessibilityLabel="Search"
+            accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
             autoFocus
             autoCapitalize="none"
@@ -95,7 +84,7 @@ export const Component = observer(function Component({
             <Pressable
               onPress={onPressCancelSearch}
               accessibilityRole="button"
-              accessibilityLabel="Cancel search"
+              accessibilityLabel={_(msg`Cancel search`)}
               accessibilityHint="Exits inputting search query"
               onAccessibilityEscape={onPressCancelSearch}
               hitSlop={HITSLOP_20}>
@@ -111,19 +100,20 @@ export const Component = observer(function Component({
           style={[s.flex1]}
           keyboardDismissMode="none"
           keyboardShouldPersistTaps="always">
-          {autocompleteView.isLoading ? (
+          {autocomplete.isLoading ? (
             <View style={{marginVertical: 20}}>
               <ActivityIndicator />
             </View>
-          ) : autocompleteView.suggestions.length ? (
+          ) : autocomplete.data?.length ? (
             <>
-              {autocompleteView.suggestions.slice(0, 40).map((item, i) => (
+              {autocomplete.data.slice(0, 40).map((item, i) => (
                 <UserResult
                   key={item.did}
                   list={list}
                   profile={item}
+                  memberships={memberships}
                   noBorder={i === 0}
-                  onAdd={onAdd}
+                  onChange={onChange}
                 />
               ))}
             </>
@@ -134,7 +124,7 @@ export const Component = observer(function Component({
                 pal.textLight,
                 {paddingHorizontal: 12, paddingVertical: 16},
               ]}>
-              No results found for {autocompleteView.prefix}
+              <Trans>No results found for {query}</Trans>
             </Text>
           )}
         </ScrollView>
@@ -146,8 +136,10 @@ export const Component = observer(function Component({
           <Button
             testID="doneBtn"
             type="default"
-            onPress={() => store.shell.closeModal()}
-            accessibilityLabel="Done"
+            onPress={() => {
+              closeModal()
+            }}
+            accessibilityLabel={_(msg`Done`)}
             accessibilityHint=""
             label="Done"
             labelContainerStyle={{justifyContent: 'center', padding: 4}}
@@ -157,36 +149,71 @@ export const Component = observer(function Component({
       </View>
     </SafeAreaView>
   )
-})
+}
 
 function UserResult({
   profile,
   list,
+  memberships,
   noBorder,
-  onAdd,
+  onChange,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
-  list: ListModel
+  list: AppBskyGraphDefs.ListView
+  memberships: ListMembersip[] | undefined
   noBorder: boolean
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void | undefined
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [isProcessing, setIsProcessing] = useState(false)
-  const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, profile.did),
+    [memberships, list.uri, profile.did],
+  )
+  const listMembershipAddMutation = useListMembershipAddMutation()
+  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
 
-  const onPressAdd = useCallback(async () => {
+  const onToggleMembership = useCallback(async () => {
+    if (typeof membership === 'undefined') {
+      return
+    }
     setIsProcessing(true)
     try {
-      await list.addMember(profile)
-      Toast.show('Added to list')
-      setIsAdded(true)
-      onAdd?.(profile)
+      if (membership === false) {
+        await listMembershipAddMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: profile.did,
+        })
+        Toast.show(_(msg`Added to list`))
+        onChange?.('add', profile)
+      } else {
+        await listMembershipRemoveMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: profile.did,
+          membershipUri: membership,
+        })
+        Toast.show(_(msg`Removed from list`))
+        onChange?.('remove', profile)
+      }
     } catch (e) {
       Toast.show(cleanError(e))
     } finally {
       setIsProcessing(false)
     }
-  }, [list, profile, setIsProcessing, setIsAdded, onAdd])
+  }, [
+    _,
+    list,
+    profile,
+    membership,
+    setIsProcessing,
+    onChange,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
 
   return (
     <View
@@ -228,16 +255,14 @@ function UserResult({
         {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
       </View>
       <View>
-        {isAdded ? (
-          <FontAwesomeIcon icon="check" />
-        ) : isProcessing ? (
+        {isProcessing || typeof membership === 'undefined' ? (
           <ActivityIndicator />
         ) : (
           <Button
             testID={`user-${profile.handle}-addBtn`}
             type="default"
-            label="Add"
-            onPress={onPressAdd}
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
       </View>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 5aaa09e87..a3e6fb9e5 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,15 +1,15 @@
 import React, {useRef, useEffect} from 'react'
 import {StyleSheet} from 'react-native'
 import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
-import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
-import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
 import {timeout} from 'lib/async/timeout'
 import {navigate} from '../../../Navigation'
 import once from 'lodash.once'
 
+import {useModals, useModalControls} from '#/state/modals'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
@@ -18,7 +18,7 @@ import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
 import * as ReportModal from './report/Modal'
@@ -40,26 +40,29 @@ import * as LinkWarningModal from './LinkWarning'
 const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
 
-export const ModalsContainer = observer(function ModalsContainer() {
-  const store = useStores()
+export function ModalsContainer() {
+  const {isModalActive, activeModals} = useModals()
+  const {closeModal} = useModalControls()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
   const safeAreaInsets = useSafeAreaInsets()
 
-  const activeModal =
-    store.shell.activeModals[store.shell.activeModals.length - 1]
+  const activeModal = activeModals[activeModals.length - 1]
 
   const navigateOnce = once(navigate)
 
-  const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => {
-    if (activeModal?.name === 'profile-preview' && toIndex === 1) {
-      // begin loading the profile screen behind the scenes
-      navigateOnce('Profile', {name: activeModal.did})
-    }
-  }
+  // It seems like the bottom sheet bugs out when this callback changes.
+  const onBottomSheetAnimate = useNonReactiveCallback(
+    (_fromIndex: number, toIndex: number) => {
+      if (activeModal?.name === 'profile-preview' && toIndex === 1) {
+        // begin loading the profile screen behind the scenes
+        navigateOnce('Profile', {name: activeModal.did})
+      }
+    },
+  )
   const onBottomSheetChange = async (snapPoint: number) => {
     if (snapPoint === -1) {
-      store.shell.closeModal()
+      closeModal()
     } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
       await navigateOnce('Profile', {name: activeModal.did})
       // There is no particular callback for when the view has actually been presented.
@@ -67,21 +70,21 @@ export const ModalsContainer = observer(function ModalsContainer() {
       // It's acceptable because the data is already being fetched + it usually takes longer anyway.
       // TODO: Figure out why avatar/cover don't always show instantly from cache.
       await timeout(200)
-      store.shell.closeModal()
+      closeModal()
     }
   }
   const onClose = () => {
     bottomSheetRef.current?.close()
-    store.shell.closeModal()
+    closeModal()
   }
 
   useEffect(() => {
-    if (store.shell.isModalActive) {
+    if (isModalActive) {
       bottomSheetRef.current?.expand()
     } else {
       bottomSheetRef.current?.close()
     }
-  }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name])
+  }, [isModalActive, bottomSheetRef, activeModal?.name])
 
   let needsSafeTopInset = false
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
@@ -108,7 +111,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'user-add-remove-lists') {
     snapPoints = UserAddRemoveListsModal.snapPoints
     element = <UserAddRemoveListsModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'list-add-user') {
+  } else if (activeModal?.name === 'list-add-remove-users') {
     snapPoints = ListAddUserModal.snapPoints
     element = <ListAddUserModal.Component {...activeModal} />
   } else if (activeModal?.name === 'delete-account') {
@@ -184,12 +187,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
       snapPoints={snapPoints}
       topInset={topInset}
       handleHeight={HANDLE_HEIGHT}
-      index={store.shell.isModalActive ? 0 : -1}
+      index={isModalActive ? 0 : -1}
       enablePanDownToClose
       android_keyboardInputMode="adjustResize"
       keyboardBlurBehavior="restore"
       backdropComponent={
-        store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
+        isModalActive ? createCustomBackdrop(onClose) : undefined
       }
       handleIndicatorStyle={{backgroundColor: pal.text.color}}
       handleStyle={[styles.handle, pal.view]}
@@ -198,7 +201,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
       {element}
     </BottomSheet>
   )
-})
+}
 
 const styles = StyleSheet.create({
   handle: {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index ede845378..c39ba1f51 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
 import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import type {Modal as ModalIface} from 'state/models/ui/shell'
 
+import {useModals, useModalControls} from '#/state/modals'
+import type {Modal as ModalIface} from '#/state/modals'
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
@@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput'
 import * as ReportModal from './report/Modal'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
@@ -33,28 +33,29 @@ import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as LinkWarningModal from './LinkWarning'
 
-export const ModalsContainer = observer(function ModalsContainer() {
-  const store = useStores()
+export function ModalsContainer() {
+  const {isModalActive, activeModals} = useModals()
 
-  if (!store.shell.isModalActive) {
+  if (!isModalActive) {
     return null
   }
 
   return (
     <>
-      {store.shell.activeModals.map((modal, i) => (
+      {activeModals.map((modal, i) => (
         <Modal key={`modal-${i}`} modal={modal} />
       ))}
     </>
   )
-})
+}
 
 function Modal({modal}: {modal: ModalIface}) {
-  const store = useStores()
+  const {isModalActive} = useModals()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
 
-  if (!store.shell.isModalActive) {
+  if (!isModalActive) {
     return null
   }
 
@@ -62,7 +63,7 @@ function Modal({modal}: {modal: ModalIface}) {
     if (modal.name === 'crop-image' || modal.name === 'edit-image') {
       return // dont close on mask presses during crop
     }
-    store.shell.closeModal()
+    closeModal()
   }
   const onInnerPress = () => {
     // TODO: can we use prevent default?
@@ -84,7 +85,7 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
     element = <UserAddRemoveLists.Component {...modal} />
-  } else if (modal.name === 'list-add-user') {
+  } else if (modal.name === 'list-add-remove-users') {
     element = <ListAddUserModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
@@ -129,7 +130,10 @@ function Modal({modal}: {modal: ModalIface}) {
   return (
     // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
     <TouchableWithoutFeedback onPress={onPressMask}>
-      <View style={styles.mask}>
+      <Animated.View
+        style={styles.mask}
+        entering={FadeIn.duration(150)}
+        exiting={FadeOut}>
         {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
         <TouchableWithoutFeedback onPress={onInnerPress}>
           <View
@@ -142,7 +146,7 @@ function Modal({modal}: {modal: ModalIface}) {
             {element}
           </View>
         </TouchableWithoutFeedback>
-      </View>
+      </Animated.View>
     </TouchableWithoutFeedback>
   )
 }
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
index c01312d69..c117023d4 100644
--- a/src/view/com/modals/ModerationDetails.tsx
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {ModerationUI} from '@atproto/api'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s} from 'lib/styles'
 import {Text} from '../util/text/Text'
@@ -10,6 +9,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {listUriToHref} from 'lib/strings/url-helpers'
 import {Button} from '../util/forms/Button'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = [300]
 
@@ -20,7 +20,7 @@ export function Component({
   context: 'account' | 'content'
   moderation: ModerationUI
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
 
@@ -102,7 +102,9 @@ export function Component({
       <Button
         type="primary"
         style={styles.btn}
-        onPress={() => store.shell.closeModal()}>
+        onPress={() => {
+          closeModal()
+        }}>
         <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
           Okay
         </Text>
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index dad02aa5e..edfbf6a82 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -1,27 +1,81 @@
 import React, {useState, useEffect} from 'react'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {AppBskyActorDefs, ModerationOpts, moderateProfile} from '@atproto/api'
 import {ThemedText} from '../util/text/ThemedText'
-import {useStores} from 'state/index'
-import {ProfileModel} from 'state/models/content/profile'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ProfileHeader} from '../profile/ProfileHeader'
 import {InfoCircleIcon} from 'lib/icons'
 import {useNavigationState} from '@react-navigation/native'
 import {s} from 'lib/styles'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useProfileQuery} from '#/state/queries/profile'
+import {ErrorScreen} from '../util/error/ErrorScreen'
+import {CenteredView} from '../util/Views'
+import {cleanError} from '#/lib/strings/errors'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
 
 export const snapPoints = [520, '100%']
 
-export const Component = observer(function ProfilePreviewImpl({
-  did,
+export function Component({did}: {did: string}) {
+  const pal = usePalette('default')
+  const moderationOpts = useModerationOpts()
+  const {
+    data: profile,
+    error: profileError,
+    refetch: refetchProfile,
+    isFetching: isFetchingProfile,
+  } = useProfileQuery({
+    did: did,
+  })
+
+  if (isFetchingProfile || !moderationOpts) {
+    return (
+      <CenteredView style={[pal.view, s.flex1]}>
+        <ProfileHeader
+          profile={null}
+          moderation={null}
+          isProfilePreview={true}
+        />
+      </CenteredView>
+    )
+  }
+  if (profileError) {
+    return (
+      <ErrorScreen
+        title="Oops!"
+        message={cleanError(profileError)}
+        onPressTryAgain={refetchProfile}
+      />
+    )
+  }
+  if (profile && moderationOpts) {
+    return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} />
+  }
+  // should never happen
+  return (
+    <ErrorScreen
+      title="Oops!"
+      message="Something went wrong and we're not sure what."
+      onPressTryAgain={refetchProfile}
+    />
+  )
+}
+
+function ComponentLoaded({
+  profile: profileUnshadowed,
+  moderationOpts,
 }: {
-  did: string
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  const [model] = useState(new ProfileModel(store, {actor: did}))
+  const profile = useProfileShadow(profileUnshadowed)
   const {screen} = useAnalytics()
+  const moderation = React.useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
 
   // track the navigator state to detect if a page-load occurred
   const navState = useNavigationState(state => state)
@@ -30,16 +84,15 @@ export const Component = observer(function ProfilePreviewImpl({
 
   useEffect(() => {
     screen('Profile:Preview')
-    model.setup()
-  }, [model, screen])
+  }, [screen])
 
   return (
     <View testID="profilePreview" style={[pal.view, s.flex1]}>
       <View style={[styles.headerWrapper]}>
         <ProfileHeader
-          view={model}
+          profile={profile}
+          moderation={moderation}
           hideBackButton
-          onRefreshAll={() => {}}
           isProfilePreview
         />
       </View>
@@ -59,7 +112,7 @@ export const Component = observer(function ProfilePreviewImpl({
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   headerWrapper: {
diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx
index b1862ecbd..a72da29b4 100644
--- a/src/view/com/modals/Repost.tsx
+++ b/src/view/com/modals/Repost.tsx
@@ -1,12 +1,14 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {RepostIcon} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = [250]
 
@@ -20,10 +22,11 @@ export function Component({
   isReposted: boolean
   // TODO: Add author into component
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
   const onPress = async () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
@@ -38,7 +41,7 @@ export function Component({
           accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}>
           <RepostIcon strokeWidth={2} size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
-            {!isReposted ? 'Repost' : 'Undo repost'}
+            <Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans>
           </Text>
         </TouchableOpacity>
         <TouchableOpacity
@@ -46,11 +49,11 @@ export function Component({
           style={[styles.actionBtn]}
           onPress={onQuote}
           accessibilityRole="button"
-          accessibilityLabel="Quote post"
+          accessibilityLabel={_(msg`Quote post`)}
           accessibilityHint="">
           <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
-            Quote Post
+            <Trans>Quote Post</Trans>
           </Text>
         </TouchableOpacity>
       </View>
@@ -58,7 +61,7 @@ export function Component({
         testID="cancelBtn"
         onPress={onPress}
         accessibilityRole="button"
-        accessibilityLabel="Cancel quote post"
+        accessibilityLabel={_(msg`Cancel quote post`)}
         accessibilityHint=""
         onAccessibilityEscape={onPress}>
         <LinearGradient
@@ -66,7 +69,9 @@ export function Component({
           start={{x: 0, y: 0}}
           end={{x: 1, y: 1}}
           style={[styles.btn]}>
-          <Text style={[s.white, s.bold, s.f18]}>Cancel</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Cancel</Trans>
+          </Text>
         </LinearGradient>
       </TouchableOpacity>
     </View>
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
index 820f2895b..092dd2d32 100644
--- a/src/view/com/modals/SelfLabel.tsx
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -1,8 +1,6 @@
 import React, {useState} from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {Text} from '../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'
@@ -10,12 +8,15 @@ import {isWeb} from 'platform/detection'
 import {Button} from '../util/forms/Button'
 import {SelectableBtn} from '../util/forms/SelectableBtn'
 import {ScrollView} from 'view/com/modals/util'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
 
 export const snapPoints = ['50%']
 
-export const Component = observer(function Component({
+export function Component({
   labels,
   hasMedia,
   onChange,
@@ -25,9 +26,10 @@ export const Component = observer(function Component({
   onChange: (labels: string[]) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [selected, setSelected] = useState(labels)
+  const {_} = useLingui()
 
   const toggleAdultLabel = (label: string) => {
     const hadLabel = selected.includes(label)
@@ -51,7 +53,7 @@ export const Component = observer(function Component({
     <View testID="selfLabelModal" style={[pal.view, styles.container]}>
       <View style={styles.titleSection}>
         <Text type="title-lg" style={[pal.text, styles.title]}>
-          Add a content warning
+          <Trans>Add a content warning</Trans>
         </Text>
       </View>
 
@@ -70,7 +72,7 @@ export const Component = observer(function Component({
               paddingBottom: 8,
             }}>
             <Text type="title" style={pal.text}>
-              Adult Content
+              <Trans>Adult Content</Trans>
             </Text>
             {hasAdultSelection ? (
               <Button
@@ -78,7 +80,7 @@ export const Component = observer(function Component({
                 onPress={removeAdultLabel}
                 style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}>
                 <Text type="md" style={pal.link}>
-                  Remove
+                  <Trans>Remove</Trans>
                 </Text>
               </Button>
             ) : null}
@@ -116,23 +118,25 @@ export const Component = observer(function Component({
 
               <Text style={[pal.text, styles.adultExplainer]}>
                 {selected.includes('sexual') ? (
-                  <>Pictures meant for adults.</>
+                  <Trans>Pictures meant for adults.</Trans>
                 ) : selected.includes('nudity') ? (
-                  <>Artistic or non-erotic nudity.</>
+                  <Trans>Artistic or non-erotic nudity.</Trans>
                 ) : selected.includes('porn') ? (
-                  <>Sexual activity or erotic nudity.</>
+                  <Trans>Sexual activity or erotic nudity.</Trans>
                 ) : (
-                  <>If none are selected, suitable for all ages.</>
+                  <Trans>If none are selected, suitable for all ages.</Trans>
                 )}
               </Text>
             </>
           ) : (
             <View>
               <Text style={[pal.textLight]}>
-                <Text type="md-bold" style={[pal.textLight]}>
-                  Not Applicable
+                <Text type="md-bold" style={[pal.textLight, s.mr5]}>
+                  <Trans>Not Applicable.</Trans>
                 </Text>
-                . This warning is only available for posts with media attached.
+                <Trans>
+                  This warning is only available for posts with media attached.
+                </Trans>
               </Text>
             </View>
           )}
@@ -143,18 +147,20 @@ export const Component = observer(function Component({
         <TouchableOpacity
           testID="confirmBtn"
           onPress={() => {
-            store.shell.closeModal()
+            closeModal()
           }}
           style={styles.btn}
           accessibilityRole="button"
-          accessibilityLabel="Confirm"
+          accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Done</Trans>
+          </Text>
         </TouchableOpacity>
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx
index 13b21fe22..b30293859 100644
--- a/src/view/com/modals/ServerInput.tsx
+++ b/src/view/com/modals/ServerInput.tsx
@@ -6,33 +6,36 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {ScrollView, TextInput} from './util'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
-import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
+import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
 export function Component({onSelect}: {onSelect: (url: string) => void}) {
   const theme = useTheme()
   const pal = usePalette('default')
-  const store = useStores()
   const [customUrl, setCustomUrl] = useState<string>('')
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
 
   const doSelect = (url: string) => {
     if (!url.startsWith('http://') && !url.startsWith('https://')) {
       url = `https://${url}`
     }
-    store.shell.closeModal()
+    closeModal()
     onSelect(url)
   }
 
   return (
     <View style={[pal.view, s.flex1]} testID="serverInputModal">
       <Text type="2xl-bold" style={[pal.text, s.textCenter]}>
-        Choose Service
+        <Trans>Choose Service</Trans>
       </Text>
       <ScrollView style={styles.inner}>
         <View style={styles.group}>
@@ -43,7 +46,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
                 style={styles.btn}
                 onPress={() => doSelect(LOCAL_DEV_SERVICE)}
                 accessibilityRole="button">
-                <Text style={styles.btnText}>Local dev server</Text>
+                <Text style={styles.btnText}>
+                  <Trans>Local dev server</Trans>
+                </Text>
                 <FontAwesomeIcon
                   icon="arrow-right"
                   style={s.white as FontAwesomeIconStyle}
@@ -53,7 +58,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
                 style={styles.btn}
                 onPress={() => doSelect(STAGING_SERVICE)}
                 accessibilityRole="button">
-                <Text style={styles.btnText}>Staging</Text>
+                <Text style={styles.btnText}>
+                  <Trans>Staging</Trans>
+                </Text>
                 <FontAwesomeIcon
                   icon="arrow-right"
                   style={s.white as FontAwesomeIconStyle}
@@ -65,9 +72,11 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
             style={styles.btn}
             onPress={() => doSelect(PROD_SERVICE)}
             accessibilityRole="button"
-            accessibilityLabel="Select Bluesky Social"
+            accessibilityLabel={_(msg`Select Bluesky Social`)}
             accessibilityHint="Sets Bluesky Social as your service provider">
-            <Text style={styles.btnText}>Bluesky.Social</Text>
+            <Text style={styles.btnText}>
+              <Trans>Bluesky.Social</Trans>
+            </Text>
             <FontAwesomeIcon
               icon="arrow-right"
               style={s.white as FontAwesomeIconStyle}
@@ -75,7 +84,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
           </TouchableOpacity>
         </View>
         <View style={styles.group}>
-          <Text style={[pal.text, styles.label]}>Other service</Text>
+          <Text style={[pal.text, styles.label]}>
+            <Trans>Other service</Trans>
+          </Text>
           <View style={s.flexRow}>
             <TextInput
               testID="customServerTextInput"
@@ -88,7 +99,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
               keyboardAppearance={theme.colorScheme}
               value={customUrl}
               onChangeText={setCustomUrl}
-              accessibilityLabel="Custom domain"
+              accessibilityLabel={_(msg`Custom domain`)}
               // TODO: Simplify this wording further to be understandable by everyone
               accessibilityHint="Use your domain as your Bluesky client service provider"
             />
diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index d5fa32692..38e1ce1e0 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -6,7 +6,6 @@ import {
   View,
 } from 'react-native'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -17,88 +16,114 @@ import {Link} from '../util/Link'
 import {makeProfileLink} from 'lib/routes/links'
 import {BottomSheetScrollView} from '@gorhom/bottom-sheet'
 import {Haptics} from 'lib/haptics'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession, useSessionApi, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
 
 export const snapPoints = ['40%', '90%']
 
-export function Component({}: {}) {
+function SwitchAccountCard({account}: {account: SessionAccount}) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {track} = useAnalytics()
+  const {isSwitchingAccounts, currentAccount} = useSession()
+  const {logout} = useSessionApi()
+  const {data: profile} = useProfileQuery({did: account.did})
+  const isCurrentAccount = account.did === currentAccount?.did
+  const {onPressSwitchAccount} = useAccountSwitcher()
+
+  const onPressSignout = React.useCallback(() => {
+    track('Settings:SignOutButtonClicked')
+    logout()
+  }, [track, logout])
 
-  const store = useStores()
-  const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher()
+  const contents = (
+    <View style={[pal.view, styles.linkCard]}>
+      <View style={styles.avi}>
+        <UserAvatar size={40} avatar={profile?.avatar} />
+      </View>
+      <View style={[s.flex1]}>
+        <Text type="md-bold" style={pal.text} numberOfLines={1}>
+          {profile?.displayName || account?.handle}
+        </Text>
+        <Text type="sm" style={pal.textLight} numberOfLines={1}>
+          {account?.handle}
+        </Text>
+      </View>
+
+      {isCurrentAccount ? (
+        <TouchableOpacity
+          testID="signOutBtn"
+          onPress={isSwitchingAccounts ? undefined : onPressSignout}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Sign out`)}
+          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
+          <Text type="lg" style={pal.link}>
+            <Trans>Sign out</Trans>
+          </Text>
+        </TouchableOpacity>
+      ) : (
+        <AccountDropdownBtn account={account} />
+      )}
+    </View>
+  )
+
+  return isCurrentAccount ? (
+    <Link
+      href={makeProfileLink({
+        did: currentAccount.did,
+        handle: currentAccount.handle,
+      })}
+      title={_(msg`Your profile`)}
+      noFeedback>
+      {contents}
+    </Link>
+  ) : (
+    <TouchableOpacity
+      testID={`switchToAccountBtn-${account.handle}`}
+      key={account.did}
+      style={[isSwitchingAccounts && styles.dimmed]}
+      onPress={
+        isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
+      }
+      accessibilityRole="button"
+      accessibilityLabel={`Switch to ${account.handle}`}
+      accessibilityHint="Switches the account you are logged in to">
+      {contents}
+    </TouchableOpacity>
+  )
+}
+
+export function Component({}: {}) {
+  const pal = usePalette('default')
+  const {isSwitchingAccounts, currentAccount, accounts} = useSession()
 
   React.useEffect(() => {
     Haptics.default()
   })
 
-  const onPressSignout = React.useCallback(() => {
-    track('Settings:SignOutButtonClicked')
-    store.session.logout()
-  }, [track, store])
-
   return (
     <BottomSheetScrollView
       style={[styles.container, pal.view]}
       contentContainerStyle={[styles.innerContainer, pal.view]}>
       <Text type="title-xl" style={[styles.title, pal.text]}>
-        Switch Account
+        <Trans>Switch Account</Trans>
       </Text>
-      {isSwitching ? (
+
+      {isSwitchingAccounts || !currentAccount ? (
         <View style={[pal.view, styles.linkCard]}>
           <ActivityIndicator />
         </View>
       ) : (
-        <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback>
-          <View style={[pal.view, styles.linkCard]}>
-            <View style={styles.avi}>
-              <UserAvatar size={40} avatar={store.me.avatar} />
-            </View>
-            <View style={[s.flex1]}>
-              <Text type="md-bold" style={pal.text} numberOfLines={1}>
-                {store.me.displayName || store.me.handle}
-              </Text>
-              <Text type="sm" style={pal.textLight} numberOfLines={1}>
-                {store.me.handle}
-              </Text>
-            </View>
-            <TouchableOpacity
-              testID="signOutBtn"
-              onPress={isSwitching ? undefined : onPressSignout}
-              accessibilityRole="button"
-              accessibilityLabel="Sign out"
-              accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
-              <Text type="lg" style={pal.link}>
-                Sign out
-              </Text>
-            </TouchableOpacity>
-          </View>
-        </Link>
+        <SwitchAccountCard account={currentAccount} />
       )}
-      {store.session.switchableAccounts.map(account => (
-        <TouchableOpacity
-          testID={`switchToAccountBtn-${account.handle}`}
-          key={account.did}
-          style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
-          onPress={
-            isSwitching ? undefined : () => onPressSwitchAccount(account)
-          }
-          accessibilityRole="button"
-          accessibilityLabel={`Switch to ${account.handle}`}
-          accessibilityHint="Switches the account you are logged in to">
-          <View style={styles.avi}>
-            <UserAvatar size={40} avatar={account.aviUrl} />
-          </View>
-          <View style={[s.flex1]}>
-            <Text type="md-bold" style={pal.text}>
-              {account.displayName || account.handle}
-            </Text>
-            <Text type="sm" style={pal.textLight}>
-              {account.handle}
-            </Text>
-          </View>
-          <AccountDropdownBtn handle={account.handle} />
-        </TouchableOpacity>
-      ))}
+
+      {accounts
+        .filter(a => a.did !== currentAccount?.did)
+        .map(account => (
+          <SwitchAccountCard key={account.did} account={account} />
+        ))}
     </BottomSheetScrollView>
   )
 }
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index aeec2e87f..8c3dc8bb7 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -1,30 +1,32 @@
 import React, {useCallback} from 'react'
-import {observer} from 'mobx-react-lite'
-import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
-import {ListsList} from '../lists/ListsList'
-import {ListsListModel} from 'state/models/lists/lists-list'
-import {ListMembershipModel} from 'state/models/content/list-membership'
+import {MyLists} from '../lists/MyLists'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
-import isEqual from 'lodash.isequal'
-import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {cleanError} from '#/lib/strings/errors'
+import {useSession} from '#/state/session'
 
 export const snapPoints = ['fullscreen']
 
-export const Component = observer(function UserAddRemoveListsImpl({
+export function Component({
   subject,
   displayName,
   onAdd,
@@ -35,191 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({
   onAdd?: (listUri: string) => void
   onRemove?: (listUri: string) => void
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
-  const palPrimary = usePalette('primary')
-  const palInverted = usePalette('inverted')
-  const [originalSelections, setOriginalSelections] = React.useState<string[]>(
-    [],
-  )
-  const [selected, setSelected] = React.useState<string[]>([])
-  const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
+  const {_} = useLingui()
+  const {data: memberships} = useDangerousListMembershipsQuery()
 
-  const listsList: ListsListModel = React.useMemo(
-    () => new ListsListModel(store, store.me.did),
-    [store],
-  )
-  const memberships: ListMembershipModel = React.useMemo(
-    () => new ListMembershipModel(store, subject),
-    [store, subject],
-  )
-  React.useEffect(() => {
-    listsList.refresh()
-    memberships.fetch().then(
-      () => {
-        const ids = memberships.memberships.map(m => m.value.list)
-        setOriginalSelections(ids)
-        setSelected(ids)
-        setMembershipsLoaded(true)
-      },
-      err => {
-        logger.error('Failed to fetch memberships', {error: err})
-      },
-    )
-  }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
-
-  const onPressCancel = useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
-
-  const onPressSave = useCallback(async () => {
-    let changes
-    try {
-      changes = await memberships.updateTo(selected)
-    } catch (err) {
-      logger.error('Failed to update memberships', {error: err})
-      return
-    }
-    Toast.show('Lists updated')
-    for (const uri of changes.added) {
-      onAdd?.(uri)
-    }
-    for (const uri of changes.removed) {
-      onRemove?.(uri)
-    }
-    store.shell.closeModal()
-  }, [store, selected, memberships, onAdd, onRemove])
-
-  const onToggleSelected = useCallback(
-    (uri: string) => {
-      if (selected.includes(uri)) {
-        setSelected(selected.filter(uri2 => uri2 !== uri))
-      } else {
-        setSelected([...selected, uri])
-      }
-    },
-    [selected, setSelected],
-  )
-
-  const renderItem = useCallback(
-    (list: GraphDefs.ListView, index: number) => {
-      const isSelected = selected.includes(list.uri)
-      return (
-        <Pressable
-          testID={`toggleBtn-${list.name}`}
-          style={[
-            styles.listItem,
-            pal.border,
-            {
-              opacity: membershipsLoaded ? 1 : 0.5,
-              borderTopWidth: index === 0 ? 0 : 1,
-            },
-          ]}
-          accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
-            list.name
-          }`}
-          accessibilityHint=""
-          disabled={!membershipsLoaded}
-          onPress={() => onToggleSelected(list.uri)}>
-          <View style={styles.listItemAvi}>
-            <UserAvatar size={40} avatar={list.avatar} />
-          </View>
-          <View style={styles.listItemContent}>
-            <Text
-              type="lg"
-              style={[s.bold, pal.text]}
-              numberOfLines={1}
-              lineHeight={1.2}>
-              {sanitizeDisplayName(list.name)}
-            </Text>
-            <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-              {list.purpose === 'app.bsky.graph.defs#curatelist' &&
-                'User list '}
-              {list.purpose === 'app.bsky.graph.defs#modlist' &&
-                'Moderation list '}
-              by{' '}
-              {list.creator.did === store.me.did
-                ? 'you'
-                : sanitizeHandle(list.creator.handle, '@')}
-            </Text>
-          </View>
-          {membershipsLoaded && (
-            <View
-              style={
-                isSelected
-                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
-                  : [styles.checkbox, pal.borderDark]
-              }>
-              {isSelected && (
-                <FontAwesomeIcon
-                  icon="check"
-                  style={palInverted.text as FontAwesomeIconStyle}
-                />
-              )}
-            </View>
-          )}
-        </Pressable>
-      )
-    },
-    [
-      pal,
-      palPrimary,
-      palInverted,
-      onToggleSelected,
-      selected,
-      store.me.did,
-      membershipsLoaded,
-    ],
-  )
-
-  // Only show changes button if there are some items on the list to choose from AND user has made changes in selection
-  const canSaveChanges =
-    !listsList.isEmpty && !isEqual(selected, originalSelections)
+  const onPressDone = useCallback(() => {
+    closeModal()
+  }, [closeModal])
 
   return (
     <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
       <Text style={[styles.title, pal.text]}>
-        Update {displayName} in Lists
+        <Trans>Update {displayName} in Lists</Trans>
       </Text>
-      <ListsList
-        listsList={listsList}
+      <MyLists
+        filter="all"
         inline
-        renderItem={renderItem}
+        renderItem={(list, index) => (
+          <ListItem
+            index={index}
+            list={list}
+            memberships={memberships}
+            subject={subject}
+            onAdd={onAdd}
+            onRemove={onRemove}
+          />
+        )}
         style={[styles.list, pal.border]}
       />
       <View style={[styles.btns, pal.border]}>
         <Button
-          testID="cancelBtn"
+          testID="doneBtn"
           type="default"
-          onPress={onPressCancel}
+          onPress={onPressDone}
           style={styles.footerBtn}
-          accessibilityLabel="Cancel"
+          accessibilityLabel={_(msg`Done`)}
           accessibilityHint=""
-          onAccessibilityEscape={onPressCancel}
-          label="Cancel"
+          onAccessibilityEscape={onPressDone}
+          label="Done"
         />
-        {canSaveChanges && (
+      </View>
+    </View>
+  )
+}
+
+function ListItem({
+  index,
+  list,
+  memberships,
+  subject,
+  onAdd,
+  onRemove,
+}: {
+  index: number
+  list: GraphDefs.ListView
+  memberships: ListMembersip[] | undefined
+  subject: string
+  onAdd?: (listUri: string) => void
+  onRemove?: (listUri: string) => void
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, subject),
+    [memberships, list.uri, subject],
+  )
+  const listMembershipAddMutation = useListMembershipAddMutation()
+  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
+
+  const onToggleMembership = useCallback(async () => {
+    if (typeof membership === 'undefined') {
+      return
+    }
+    setIsProcessing(true)
+    try {
+      if (membership === false) {
+        await listMembershipAddMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: subject,
+        })
+        Toast.show(_(msg`Added to list`))
+        onAdd?.(list.uri)
+      } else {
+        await listMembershipRemoveMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: subject,
+          membershipUri: membership,
+        })
+        Toast.show(_(msg`Removed from list`))
+        onRemove?.(list.uri)
+      }
+    } catch (e) {
+      Toast.show(cleanError(e))
+    } finally {
+      setIsProcessing(false)
+    }
+  }, [
+    _,
+    list,
+    subject,
+    membership,
+    setIsProcessing,
+    onAdd,
+    onRemove,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
+
+  return (
+    <View
+      testID={`toggleBtn-${list.name}`}
+      style={[
+        styles.listItem,
+        pal.border,
+        {
+          borderTopWidth: index === 0 ? 0 : 1,
+        },
+      ]}>
+      <View style={styles.listItemAvi}>
+        <UserAvatar size={40} avatar={list.avatar} />
+      </View>
+      <View style={styles.listItemContent}>
+        <Text
+          type="lg"
+          style={[s.bold, pal.text]}
+          numberOfLines={1}
+          lineHeight={1.2}>
+          {sanitizeDisplayName(list.name)}
+        </Text>
+        <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+          {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
+          {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '}
+          by{' '}
+          {list.creator.did === currentAccount?.did
+            ? 'you'
+            : sanitizeHandle(list.creator.handle, '@')}
+        </Text>
+      </View>
+      <View>
+        {isProcessing || typeof membership === 'undefined' ? (
+          <ActivityIndicator />
+        ) : (
           <Button
-            testID="saveBtn"
-            type="primary"
-            onPress={onPressSave}
-            style={styles.footerBtn}
-            accessibilityLabel="Save changes"
-            accessibilityHint=""
-            onAccessibilityEscape={onPressSave}
-            label="Save Changes"
+            testID={`user-${subject}-addBtn`}
+            type="default"
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
-
-        {(listsList.isLoading || !membershipsLoaded) && (
-          <View style={styles.loadingContainer}>
-            <ActivityIndicator />
-          </View>
-        )}
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 9fe8811b0..4376a3e45 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -8,18 +8,20 @@ import {
 } from 'react-native'
 import {Svg, Circle, Path} from 'react-native-svg'
 import {ScrollView, TextInput} from './util'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSession, useSessionApi, getAgent} from '#/state/session'
 
 export const snapPoints = ['90%']
 
@@ -29,13 +31,11 @@ enum Stages {
   ConfirmCode,
 }
 
-export const Component = observer(function Component({
-  showReminder,
-}: {
-  showReminder?: boolean
-}) {
+export function Component({showReminder}: {showReminder?: boolean}) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {currentAccount} = useSession()
+  const {updateCurrentAccount} = useSessionApi()
+  const {_} = useLingui()
   const [stage, setStage] = useState<Stages>(
     showReminder ? Stages.Reminder : Stages.Email,
   )
@@ -43,12 +43,13 @@ export const Component = observer(function Component({
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const {isMobile} = useWebMediaQueries()
+  const {openModal, closeModal} = useModalControls()
 
   const onSendEmail = async () => {
     setError('')
     setIsProcessing(true)
     try {
-      await store.agent.com.atproto.server.requestEmailConfirmation()
+      await getAgent().com.atproto.server.requestEmailConfirmation()
       setStage(Stages.ConfirmCode)
     } catch (e) {
       setError(cleanError(String(e)))
@@ -61,13 +62,13 @@ export const Component = observer(function Component({
     setError('')
     setIsProcessing(true)
     try {
-      await store.agent.com.atproto.server.confirmEmail({
-        email: (store.session.currentSession?.email || '').trim(),
+      await getAgent().com.atproto.server.confirmEmail({
+        email: (currentAccount?.email || '').trim(),
         token: confirmationCode.trim(),
       })
-      store.session.updateLocalAccountData({emailConfirmed: true})
+      updateCurrentAccount({emailConfirmed: true})
       Toast.show('Email verified')
-      store.shell.closeModal()
+      closeModal()
     } catch (e) {
       setError(cleanError(String(e)))
     } finally {
@@ -76,8 +77,8 @@ export const Component = observer(function Component({
   }
 
   const onEmailIncorrect = () => {
-    store.shell.closeModal()
-    store.shell.openModal({name: 'change-email'})
+    closeModal()
+    openModal({name: 'change-email'})
   }
 
   return (
@@ -96,21 +97,20 @@ export const Component = observer(function Component({
 
         <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
           {stage === Stages.Reminder ? (
-            <>
+            <Trans>
               Your email has not yet been verified. This is an important
               security step which we recommend.
-            </>
+            </Trans>
           ) : stage === Stages.Email ? (
-            <>
+            <Trans>
               This is important in case you ever need to change your email or
               reset your password.
-            </>
+            </Trans>
           ) : stage === Stages.ConfirmCode ? (
-            <>
-              An email has been sent to{' '}
-              {store.session.currentSession?.email || ''}. It includes a
-              confirmation code which you can enter below.
-            </>
+            <Trans>
+              An email has been sent to {currentAccount?.email || ''}. It
+              includes a confirmation code which you can enter below.
+            </Trans>
           ) : (
             ''
           )}
@@ -125,12 +125,12 @@ export const Component = observer(function Component({
                 size={16}
               />
               <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
-                {store.session.currentSession?.email || ''}
+                {currentAccount?.email || ''}
               </Text>
             </View>
             <Pressable
               accessibilityRole="link"
-              accessibilityLabel="Change my email"
+              accessibilityLabel={_(msg`Change my email`)}
               accessibilityHint=""
               onPress={onEmailIncorrect}
               style={styles.changeEmailLink}>
@@ -148,7 +148,7 @@ export const Component = observer(function Component({
             value={confirmationCode}
             onChangeText={setConfirmationCode}
             accessible={true}
-            accessibilityLabel="Confirmation code"
+            accessibilityLabel={_(msg`Confirmation code`)}
             accessibilityHint=""
             autoCapitalize="none"
             autoComplete="off"
@@ -172,7 +172,7 @@ export const Component = observer(function Component({
                   testID="getStartedBtn"
                   type="primary"
                   onPress={() => setStage(Stages.Email)}
-                  accessibilityLabel="Get Started"
+                  accessibilityLabel={_(msg`Get Started`)}
                   accessibilityHint=""
                   label="Get Started"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
@@ -185,7 +185,7 @@ export const Component = observer(function Component({
                     testID="sendEmailBtn"
                     type="primary"
                     onPress={onSendEmail}
-                    accessibilityLabel="Send Confirmation Email"
+                    accessibilityLabel={_(msg`Send Confirmation Email`)}
                     accessibilityHint=""
                     label="Send Confirmation Email"
                     labelContainerStyle={{
@@ -197,7 +197,7 @@ export const Component = observer(function Component({
                   <Button
                     testID="haveCodeBtn"
                     type="default"
-                    accessibilityLabel="I have a code"
+                    accessibilityLabel={_(msg`I have a code`)}
                     accessibilityHint=""
                     label="I have a confirmation code"
                     labelContainerStyle={{
@@ -214,7 +214,7 @@ export const Component = observer(function Component({
                   testID="confirmBtn"
                   type="primary"
                   onPress={onConfirm}
-                  accessibilityLabel="Confirm"
+                  accessibilityLabel={_(msg`Confirm`)}
                   accessibilityHint=""
                   label="Confirm"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
@@ -224,7 +224,9 @@ export const Component = observer(function Component({
               <Button
                 testID="cancelBtn"
                 type="default"
-                onPress={() => store.shell.closeModal()}
+                onPress={() => {
+                  closeModal()
+                }}
                 accessibilityLabel={
                   stage === Stages.Reminder ? 'Not right now' : 'Cancel'
                 }
@@ -239,7 +241,7 @@ export const Component = observer(function Component({
       </ScrollView>
     </SafeAreaView>
   )
-})
+}
 
 function ReminderIllustration() {
   const pal = usePalette('default')
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
index 0fb371fe4..a31545c0a 100644
--- a/src/view/com/modals/Waitlist.tsx
+++ b/src/view/com/modals/Waitlist.tsx
@@ -12,19 +12,22 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, gradients} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
 export function Component({}: {}) {
   const pal = usePalette('default')
   const theme = useTheme()
-  const store = useStores()
+  const {_} = useLingui()
+  const {closeModal} = useModalControls()
   const [email, setEmail] = React.useState<string>('')
   const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
   const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
@@ -54,19 +57,21 @@ export function Component({}: {}) {
     setIsProcessing(false)
   }
   const onCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
     <View style={[styles.container, pal.view]}>
       <View style={[styles.innerContainer, pal.view]}>
         <Text type="title-xl" style={[styles.title, pal.text]}>
-          Join the waitlist
+          <Trans>Join the waitlist</Trans>
         </Text>
         <Text type="lg" style={[styles.description, pal.text]}>
-          Bluesky uses invites to build a healthier community. If you don't know
-          anybody with an invite, you can sign up for the waitlist and we'll
-          send one soon.
+          <Trans>
+            Bluesky uses invites to build a healthier community. If you don't
+            know anybody with an invite, you can sign up for the waitlist and
+            we'll send one soon.
+          </Trans>
         </Text>
         <TextInput
           style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
@@ -80,7 +85,7 @@ export function Component({}: {}) {
           onSubmitEditing={onPressSignup}
           enterKeyHint="done"
           accessible={true}
-          accessibilityLabel="Email"
+          accessibilityLabel={_(msg`Email`)}
           accessibilityHint="Input your email to get on the Bluesky waitlist"
         />
         {error ? (
@@ -99,7 +104,9 @@ export function Component({}: {}) {
               style={pal.text as FontAwesomeIconStyle}
             />
             <Text style={[s.ml10, pal.text]}>
-              Your email has been saved! We&apos;ll be in touch soon.
+              <Trans>
+                Your email has been saved! We&apos;ll be in touch soon.
+              </Trans>
             </Text>
           </View>
         ) : (
@@ -114,7 +121,7 @@ export function Component({}: {}) {
                 end={{x: 1, y: 1}}
                 style={[styles.btn]}>
                 <Text type="button-lg" style={[s.white, s.bold]}>
-                  Join Waitlist
+                  <Trans>Join Waitlist</Trans>
                 </Text>
               </LinearGradient>
             </TouchableOpacity>
@@ -122,11 +129,11 @@ export function Component({}: {}) {
               style={[styles.btn, s.mt10]}
               onPress={onCancel}
               accessibilityRole="button"
-              accessibilityLabel="Cancel waitlist signup"
+              accessibilityLabel={_(msg`Cancel waitlist signup`)}
               accessibilityHint={`Exits signing up for waitlist with ${email}`}
               onAccessibilityEscape={onCancel}>
               <Text type="button-lg" style={pal.textLight}>
-                Cancel
+                <Trans>Cancel</Trans>
               </Text>
             </TouchableOpacity>
           </>
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 8e35201d1..6f094a1fd 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -7,10 +7,12 @@ import {Text} from 'view/com/util/text/Text'
 import {Dimensions} from 'lib/media/types'
 import {getDataUriSize} from 'lib/media/util'
 import {s, gradients} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
 import {Image as RNImage} from 'react-native-image-crop-picker'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 enum AspectRatio {
   Square = 'square',
@@ -33,8 +35,9 @@ export function Component({
   uri: string
   onSelect: (img?: RNImage) => void
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
   const [scale, setScale] = React.useState<number>(1)
   const editorRef = React.useRef<ImageEditor>(null)
@@ -43,7 +46,7 @@ export function Component({
 
   const onPressCancel = () => {
     onSelect(undefined)
-    store.shell.closeModal()
+    closeModal()
   }
   const onPressDone = () => {
     const canvas = editorRef.current?.getImageScaledToCanvas()
@@ -59,7 +62,7 @@ export function Component({
     } else {
       onSelect(undefined)
     }
-    store.shell.closeModal()
+    closeModal()
   }
 
   let cropperStyle
@@ -96,7 +99,7 @@ export function Component({
         <TouchableOpacity
           onPress={doSetAs(AspectRatio.Wide)}
           accessibilityRole="button"
-          accessibilityLabel="Wide"
+          accessibilityLabel={_(msg`Wide`)}
           accessibilityHint="Sets image aspect ratio to wide">
           <RectWideIcon
             size={24}
@@ -106,7 +109,7 @@ export function Component({
         <TouchableOpacity
           onPress={doSetAs(AspectRatio.Tall)}
           accessibilityRole="button"
-          accessibilityLabel="Tall"
+          accessibilityLabel={_(msg`Tall`)}
           accessibilityHint="Sets image aspect ratio to tall">
           <RectTallIcon
             size={24}
@@ -116,7 +119,7 @@ export function Component({
         <TouchableOpacity
           onPress={doSetAs(AspectRatio.Square)}
           accessibilityRole="button"
-          accessibilityLabel="Square"
+          accessibilityLabel={_(msg`Square`)}
           accessibilityHint="Sets image aspect ratio to square">
           <SquareIcon
             size={24}
@@ -128,7 +131,7 @@ export function Component({
         <TouchableOpacity
           onPress={onPressCancel}
           accessibilityRole="button"
-          accessibilityLabel="Cancel image crop"
+          accessibilityLabel={_(msg`Cancel image crop`)}
           accessibilityHint="Exits image cropping process">
           <Text type="xl" style={pal.link}>
             Cancel
@@ -138,7 +141,7 @@ export function Component({
         <TouchableOpacity
           onPress={onPressDone}
           accessibilityRole="button"
-          accessibilityLabel="Save image crop"
+          accessibilityLabel={_(msg`Save image crop`)}
           accessibilityHint="Saves image crop settings">
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
@@ -146,7 +149,7 @@ export function Component({
             end={{x: 1, y: 1}}
             style={[styles.btn]}>
             <Text type="xl-medium" style={s.white}>
-              Done
+              <Trans>Done</Trans>
             </Text>
           </LinearGradient>
         </TouchableOpacity>
diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
index c2d0c222a..91e11a19c 100644
--- a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
+++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
@@ -4,6 +4,8 @@ import LinearGradient from 'react-native-linear-gradient'
 import {s, colors, gradients} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export const ConfirmLanguagesButton = ({
   onPress,
@@ -13,6 +15,7 @@ export const ConfirmLanguagesButton = ({
   extraText?: string
 }) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   return (
     <View
@@ -28,14 +31,16 @@ export const ConfirmLanguagesButton = ({
         testID="confirmContentLanguagesBtn"
         onPress={onPress}
         accessibilityRole="button"
-        accessibilityLabel="Confirm content language settings"
+        accessibilityLabel={_(msg`Confirm content language settings`)}
         accessibilityHint="">
         <LinearGradient
           colors={[gradients.blueLight.start, gradients.blueLight.end]}
           start={{x: 0, y: 0}}
           end={{x: 1, y: 1}}
           style={[styles.btn]}>
-          <Text style={[s.white, s.bold, s.f18]}>Done{extraText}</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Done{extraText}</Trans>
+          </Text>
         </LinearGradient>
       </Pressable>
     </View>
diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
index 910522f90..b8c125b65 100644
--- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {ScrollView} from '../util'
-import {useStores} from 'state/index'
 import {Text} from '../../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -9,16 +8,24 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {LanguageToggle} from './LanguageToggle'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+import {Trans} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+} from '#/state/preferences/languages'
 
 export const snapPoints = ['100%']
 
 export function Component({}: {}) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const languages = React.useMemo(() => {
     const langs = LANGUAGES.filter(
@@ -29,23 +36,23 @@ export function Component({}: {}) {
     // sort so that device & selected languages are on top, then alphabetically
     langs.sort((a, b) => {
       const hasA =
-        store.preferences.hasContentLanguage(a.code2) ||
+        langPrefs.contentLanguages.includes(a.code2) ||
         deviceLocales.includes(a.code2)
       const hasB =
-        store.preferences.hasContentLanguage(b.code2) ||
+        langPrefs.contentLanguages.includes(b.code2) ||
         deviceLocales.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
     })
     return langs
-  }, [store])
+  }, [langPrefs])
 
   const onPress = React.useCallback(
     (code2: string) => {
-      store.preferences.toggleContentLanguage(code2)
+      setLangPrefs.toggleContentLanguage(code2)
     },
-    [store],
+    [setLangPrefs],
   )
 
   return (
@@ -63,12 +70,16 @@ export function Component({}: {}) {
               maxHeight: '90vh',
             },
       ]}>
-      <Text style={[pal.text, styles.title]}>Content Languages</Text>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>Content Languages</Trans>
+      </Text>
       <Text style={[pal.text, styles.description]}>
-        Which languages would you like to see in your algorithmic feeds?
+        <Trans>
+          Which languages would you like to see in your algorithmic feeds?
+        </Trans>
       </Text>
       <Text style={[pal.textLight, styles.description]}>
-        Leave them all unchecked to see any language.
+        <Trans>Leave them all unchecked to see any language.</Trans>
       </Text>
       <ScrollView style={styles.scrollContainer}>
         {languages.map(lang => (
diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx
index 187b46e8c..45b100f20 100644
--- a/src/view/com/modals/lang-settings/LanguageToggle.tsx
+++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx
@@ -1,11 +1,10 @@
 import React from 'react'
 import {StyleSheet} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
-import {observer} from 'mobx-react-lite'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
-import {useStores} from 'state/index'
+import {useLanguagePrefs, toPostLanguages} from '#/state/preferences/languages'
 
-export const LanguageToggle = observer(function LanguageToggleImpl({
+export function LanguageToggle({
   code2,
   name,
   onPress,
@@ -17,17 +16,17 @@ export const LanguageToggle = observer(function LanguageToggleImpl({
   langType: 'contentLanguages' | 'postLanguages'
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const langPrefs = useLanguagePrefs()
 
-  const isSelected = store.preferences[langType].includes(code2)
+  const values =
+    langType === 'contentLanguages'
+      ? langPrefs.contentLanguages
+      : toPostLanguages(langPrefs.postLanguage)
+  const isSelected = values.includes(code2)
 
   // enforce a max of 3 selections for post languages
   let isDisabled = false
-  if (
-    langType === 'postLanguages' &&
-    store.preferences[langType].length >= 3 &&
-    !isSelected
-  ) {
+  if (langType === 'postLanguages' && values.length >= 3 && !isSelected) {
     isDisabled = true
   }
 
@@ -39,7 +38,7 @@ export const LanguageToggle = observer(function LanguageToggleImpl({
       style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   languageToggle: {
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
index d74d884cc..05cfb8115 100644
--- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
@@ -1,8 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {ScrollView} from '../util'
-import {useStores} from 'state/index'
 import {Text} from '../../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -10,16 +8,25 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {Trans} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
+import {
+  useLanguagePrefs,
+  useLanguagePrefsApi,
+  hasPostLanguage,
+} from '#/state/preferences/languages'
 
 export const snapPoints = ['100%']
 
-export const Component = observer(function PostLanguagesSettingsImpl() {
-  const store = useStores()
+export function Component() {
+  const {closeModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const languages = React.useMemo(() => {
     const langs = LANGUAGES.filter(
@@ -30,23 +37,23 @@ export const Component = observer(function PostLanguagesSettingsImpl() {
     // sort so that device & selected languages are on top, then alphabetically
     langs.sort((a, b) => {
       const hasA =
-        store.preferences.hasPostLanguage(a.code2) ||
+        hasPostLanguage(langPrefs.postLanguage, a.code2) ||
         deviceLocales.includes(a.code2)
       const hasB =
-        store.preferences.hasPostLanguage(b.code2) ||
+        hasPostLanguage(langPrefs.postLanguage, b.code2) ||
         deviceLocales.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
     })
     return langs
-  }, [store])
+  }, [langPrefs])
 
   const onPress = React.useCallback(
     (code2: string) => {
-      store.preferences.togglePostLanguage(code2)
+      setLangPrefs.togglePostLanguage(code2)
     },
-    [store],
+    [setLangPrefs],
   )
 
   return (
@@ -64,20 +71,19 @@ export const Component = observer(function PostLanguagesSettingsImpl() {
               maxHeight: '90vh',
             },
       ]}>
-      <Text style={[pal.text, styles.title]}>Post Languages</Text>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>Post Languages</Trans>
+      </Text>
       <Text style={[pal.text, styles.description]}>
-        Which languages are used in this post?
+        <Trans>Which languages are used in this post?</Trans>
       </Text>
       <ScrollView style={styles.scrollContainer}>
         {languages.map(lang => {
-          const isSelected = store.preferences.hasPostLanguage(lang.code2)
+          const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2)
 
           // enforce a max of 3 selections for post languages
           let isDisabled = false
-          if (
-            store.preferences.postLanguage.split(',').length >= 3 &&
-            !isSelected
-          ) {
+          if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) {
             isDisabled = true
           }
 
@@ -104,7 +110,7 @@ export const Component = observer(function PostLanguagesSettingsImpl() {
       <ConfirmLanguagesButton onPress={onPressDone} />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx
index 70a8f7b24..2f701b799 100644
--- a/src/view/com/modals/report/InputIssueDetails.tsx
+++ b/src/view/com/modals/report/InputIssueDetails.tsx
@@ -8,6 +8,8 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s} from 'lib/styles'
 import {SendReportButton} from './SendReportButton'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function InputIssueDetails({
   details,
@@ -23,6 +25,7 @@ export function InputIssueDetails({
   isProcessing: boolean
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
 
   return (
@@ -35,14 +38,16 @@ export function InputIssueDetails({
         style={[s.mb10, styles.backBtn]}
         onPress={goBack}
         accessibilityRole="button"
-        accessibilityLabel="Add details"
+        accessibilityLabel={_(msg`Add details`)}
         accessibilityHint="Add more details to your report">
         <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
-        <Text style={[pal.text, s.f18, pal.link]}> Back</Text>
+        <Text style={[pal.text, s.f18, pal.link]}>
+          <Trans> Back</Trans>
+        </Text>
       </TouchableOpacity>
       <View style={[pal.btn, styles.detailsInputContainer]}>
         <TextInput
-          accessibilityLabel="Text input field"
+          accessibilityLabel={_(msg`Text input field`)}
           accessibilityHint="Enter a reason for reporting this post."
           placeholder="Enter a reason or any other details here."
           placeholderTextColor={pal.textLight.color}
diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx
index 98aa2d471..60c3f06b7 100644
--- a/src/view/com/modals/report/Modal.tsx
+++ b/src/view/com/modals/report/Modal.tsx
@@ -2,7 +2,6 @@ import React, {useState, useMemo} from 'react'
 import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {ScrollView} from 'react-native-gesture-handler'
 import {AtUri} from '@atproto/api'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s} from 'lib/styles'
 import {Text} from '../../util/text/Text'
@@ -14,6 +13,10 @@ import {SendReportButton} from './SendReportButton'
 import {InputIssueDetails} from './InputIssueDetails'
 import {ReportReasonOptions} from './ReasonOptions'
 import {CollectionId} from './types'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {getAgent} from '#/state/session'
 
 const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
 
@@ -36,7 +39,7 @@ type ReportComponentProps =
     }
 
 export function Component(content: ReportComponentProps) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const [isProcessing, setIsProcessing] = useState(false)
@@ -60,13 +63,13 @@ export function Component(content: ReportComponentProps) {
     try {
       if (issue === '__copyright__') {
         Linking.openURL(DMCA_LINK)
-        store.shell.closeModal()
+        closeModal()
         return
       }
       const $type = !isAccountReport
         ? 'com.atproto.repo.strongRef'
         : 'com.atproto.admin.defs#repoRef'
-      await store.agent.createModerationReport({
+      await getAgent().createModerationReport({
         reasonType: issue,
         subject: {
           $type,
@@ -76,7 +79,7 @@ export function Component(content: ReportComponentProps) {
       })
       Toast.show("Thank you for your report! We'll look into it promptly.")
 
-      store.shell.closeModal()
+      closeModal()
       return
     } catch (e: any) {
       setError(cleanError(e))
@@ -146,6 +149,7 @@ const SelectIssue = ({
   atUri: AtUri | null
 }) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const collectionName = getCollectionNameForReport(atUri)
   const onSelectIssue = (v: string) => setIssue(v)
   const goToDetails = () => {
@@ -158,9 +162,11 @@ const SelectIssue = ({
 
   return (
     <>
-      <Text style={[pal.text, styles.title]}>Report {collectionName}</Text>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>Report {collectionName}</Trans>
+      </Text>
       <Text style={[pal.textLight, styles.description]}>
-        What is the issue with this {collectionName}?
+        <Trans>What is the issue with this {collectionName}?</Trans>
       </Text>
       <View style={{marginBottom: 10}}>
         <ReportReasonOptions
@@ -182,9 +188,11 @@ const SelectIssue = ({
             style={styles.addDetailsBtn}
             onPress={goToDetails}
             accessibilityRole="button"
-            accessibilityLabel="Add details"
+            accessibilityLabel={_(msg`Add details`)}
             accessibilityHint="Add more details to your report">
-            <Text style={[s.f18, pal.link]}>Add details to report</Text>
+            <Text style={[s.f18, pal.link]}>
+              <Trans>Add details to report</Trans>
+            </Text>
           </TouchableOpacity>
         </>
       ) : undefined}
diff --git a/src/view/com/modals/report/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx
index 82fb65f20..40c239bff 100644
--- a/src/view/com/modals/report/SendReportButton.tsx
+++ b/src/view/com/modals/report/SendReportButton.tsx
@@ -8,6 +8,8 @@ import {
 } from 'react-native'
 import {Text} from '../../util/text/Text'
 import {s, gradients, colors} from 'lib/styles'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function SendReportButton({
   onPress,
@@ -16,6 +18,7 @@ export function SendReportButton({
   onPress: () => void
   isProcessing: boolean
 }) {
+  const {_} = useLingui()
   // loading state
   // =
   if (isProcessing) {
@@ -31,14 +34,16 @@ export function SendReportButton({
       style={s.mt10}
       onPress={onPress}
       accessibilityRole="button"
-      accessibilityLabel="Report post"
+      accessibilityLabel={_(msg`Report post`)}
       accessibilityHint={`Reports post with reason and details`}>
       <LinearGradient
         colors={[gradients.blueLight.start, gradients.blueLight.end]}
         start={{x: 0, y: 0}}
         end={{x: 1, y: 1}}
         style={[styles.btn]}>
-        <Text style={[s.white, s.bold, s.f18]}>Send Report</Text>
+        <Text style={[s.white, s.bold, s.f18]}>
+          <Trans>Send Report</Trans>
+        </Text>
       </LinearGradient>
     </TouchableOpacity>
   )
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 74769bc76..260c9bbd5 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,66 +1,76 @@
 import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
 import {CenteredView, FlatList} from '../util/Views'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
-import {NotificationsFeedModel} from 'state/models/feeds/notifications'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {EmptyState} from '../util/EmptyState'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
+import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
+import {useModerationOpts} from '#/state/queries/preferences'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
-const LOADING_SPINNER = {_reactKey: '__loading_spinner__'}
+const LOADING_ITEM = {_reactKey: '__loading__'}
 
-export const Feed = observer(function Feed({
-  view,
+export function Feed({
   scrollElRef,
   onPressTryAgain,
   onScroll,
   ListHeaderComponent,
 }: {
-  view: NotificationsFeedModel
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
-  onScroll?: OnScrollCb
+  onScroll?: OnScrollHandler
   ListHeaderComponent?: () => JSX.Element
 }) {
   const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
-  const data = React.useMemo(() => {
-    let feedItems: any[] = []
-    if (view.isRefreshing && !isPTRing) {
-      feedItems = [LOADING_SPINNER]
-    }
-    if (view.hasLoaded) {
-      if (view.isEmpty) {
-        feedItems = feedItems.concat([EMPTY_FEED_ITEM])
-      } else {
-        feedItems = feedItems.concat(view.notifications)
+
+  const moderationOpts = useModerationOpts()
+  const {checkUnread} = useUnreadNotificationsApi()
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+  } = useNotificationFeedQuery({enabled: !!moderationOpts})
+  const isEmpty = !isFetching && !data?.pages[0]?.items.length
+
+  const items = React.useMemo(() => {
+    let arr: any[] = []
+    if (isFetched) {
+      if (isEmpty) {
+        arr = arr.concat([EMPTY_FEED_ITEM])
+      } else if (data) {
+        for (const page of data?.pages) {
+          arr = arr.concat(page.items)
+        }
       }
+      if (isError && !isEmpty) {
+        arr = arr.concat([LOAD_MORE_ERROR_ITEM])
+      }
+    } else {
+      arr.push(LOADING_ITEM)
     }
-    if (view.loadMoreError) {
-      feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
-    }
-    return feedItems
-  }, [
-    view.hasLoaded,
-    view.isEmpty,
-    view.notifications,
-    view.loadMoreError,
-    view.isRefreshing,
-    isPTRing,
-  ])
+    return arr
+  }, [isFetched, isError, isEmpty, data])
 
   const onRefresh = React.useCallback(async () => {
     try {
       setIsPTRing(true)
-      await view.refresh()
+      await checkUnread({invalidate: true})
     } catch (err) {
       logger.error('Failed to refresh notifications feed', {
         error: err,
@@ -68,21 +78,21 @@ export const Feed = observer(function Feed({
     } finally {
       setIsPTRing(false)
     }
-  }, [view, setIsPTRing])
+  }, [checkUnread, setIsPTRing])
 
   const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
     try {
-      await view.loadMore()
+      await fetchNextPage()
     } catch (err) {
-      logger.error('Failed to load more notifications', {
-        error: err,
-      })
+      logger.error('Failed to load more notifications', {error: err})
     }
-  }, [view])
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    view.retryLoadMore()
-  }, [view])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
   //   VirtualizedList: You have a large list that is slow to update - make sure your
@@ -105,77 +115,66 @@ export const Feed = observer(function Feed({
             onPress={onPressRetryLoadMore}
           />
         )
-      } else if (item === LOADING_SPINNER) {
-        return (
-          <View style={styles.loading}>
-            <ActivityIndicator size="small" />
-          </View>
-        )
+      } else if (item === LOADING_ITEM) {
+        return <NotificationFeedLoadingPlaceholder />
       }
-      return <FeedItem item={item} />
+      return <FeedItem item={item} moderationOpts={moderationOpts!} />
     },
-    [onPressRetryLoadMore],
+    [onPressRetryLoadMore, moderationOpts],
   )
 
   const FeedFooter = React.useCallback(
     () =>
-      view.isLoading ? (
+      isFetchingNextPage ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
       ) : (
         <View />
       ),
-    [view],
+    [isFetchingNextPage],
   )
 
+  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View style={s.hContentRegion}>
-      <CenteredView>
-        {view.isLoading && !data.length && (
-          <NotificationFeedLoadingPlaceholder />
-        )}
-        {view.hasError && (
+      {error && (
+        <CenteredView>
           <ErrorMessage
-            message={view.error}
+            message={cleanError(error)}
             onPressTryAgain={onPressTryAgain}
           />
-        )}
-      </CenteredView>
-      {data.length ? (
-        <FlatList
-          testID="notifsFeed"
-          ref={scrollElRef}
-          data={data}
-          keyExtractor={item => item._reactKey}
-          renderItem={renderItem}
-          ListHeaderComponent={ListHeaderComponent}
-          ListFooterComponent={FeedFooter}
-          refreshControl={
-            <RefreshControl
-              refreshing={isPTRing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
-          onScroll={onScroll}
-          scrollEventThrottle={100}
-          contentContainerStyle={s.contentContainer}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      ) : null}
+        </CenteredView>
+      )}
+      <FlatList
+        testID="notifsFeed"
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItem}
+        ListHeaderComponent={ListHeaderComponent}
+        ListFooterComponent={FeedFooter}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+          />
+        }
+        onEndReached={onEndReached}
+        onEndReachedThreshold={0.6}
+        onScroll={scrollHandler}
+        scrollEventThrottle={1}
+        contentContainerStyle={s.contentContainer}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+      />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
-  loading: {
-    paddingVertical: 20,
-  },
   feedFooter: {paddingTop: 20},
   emptyState: {paddingVertical: 40},
 })
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index c38ab3fd5..aaa2ea2c6 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -1,5 +1,4 @@
-import React, {useMemo, useState, useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {memo, useMemo, useState, useEffect} from 'react'
 import {
   Animated,
   TouchableOpacity,
@@ -9,6 +8,9 @@ import {
 } from 'react-native'
 import {
   AppBskyEmbedImages,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ModerationOpts,
   ProfileModeration,
   moderateProfile,
   AppBskyEmbedRecordWithMedia,
@@ -19,8 +21,7 @@ import {
   FontAwesomeIconStyle,
   Props,
 } from '@fortawesome/react-native-fontawesome'
-import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
-import {PostThreadModel} from 'state/models/content/post-thread'
+import {FeedNotification} from '#/state/queries/notifications/feed'
 import {s, colors} from 'lib/styles'
 import {niceDate} from 'lib/strings/time'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -33,13 +34,14 @@ import {UserPreviewLink} from '../util/UserPreviewLink'
 import {ImageHorzList} from '../util/images/ImageHorzList'
 import {Post} from '../post/Post'
 import {Link, TextLink} from '../util/Link'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {formatCount} from '../util/numeric/format'
 import {makeProfileLink} from 'lib/routes/links'
 import {TimeElapsed} from '../util/TimeElapsed'
 import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 const MAX_AUTHORS = 5
 
@@ -54,40 +56,34 @@ interface Author {
   moderation: ProfileModeration
 }
 
-export const FeedItem = observer(function FeedItemImpl({
+let FeedItem = ({
   item,
+  moderationOpts,
 }: {
-  item: NotificationsFeedItemModel
-}) {
-  const store = useStores()
+  item: FeedNotification
+  moderationOpts: ModerationOpts
+}): React.ReactNode => {
   const pal = usePalette('default')
   const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
   const itemHref = useMemo(() => {
-    if (item.isLike || item.isRepost) {
-      const urip = new AtUri(item.subjectUri)
-      return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.isFollow) {
-      return makeProfileLink(item.author)
-    } else if (item.isReply) {
-      const urip = new AtUri(item.uri)
+    if (item.type === 'post-like' || item.type === 'repost') {
+      if (item.subjectUri) {
+        const urip = new AtUri(item.subjectUri)
+        return `/profile/${urip.host}/post/${urip.rkey}`
+      }
+    } else if (item.type === 'follow') {
+      return makeProfileLink(item.notification.author)
+    } else if (item.type === 'reply') {
+      const urip = new AtUri(item.notification.uri)
       return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.isCustomFeedLike) {
-      const urip = new AtUri(item.subjectUri)
-      return `/profile/${urip.host}/feed/${urip.rkey}`
+    } else if (item.type === 'feedgen-like') {
+      if (item.subjectUri) {
+        const urip = new AtUri(item.subjectUri)
+        return `/profile/${urip.host}/feed/${urip.rkey}`
+      }
     }
     return ''
   }, [item])
-  const itemTitle = useMemo(() => {
-    if (item.isLike || item.isRepost) {
-      return 'Post'
-    } else if (item.isFollow) {
-      return item.author.handle
-    } else if (item.isReply) {
-      return 'Post'
-    } else if (item.isCustomFeedLike) {
-      return 'Custom Feed'
-    }
-  }, [item])
 
   const onToggleAuthorsExpanded = () => {
     setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
@@ -96,15 +92,12 @@ export const FeedItem = observer(function FeedItemImpl({
   const authors: Author[] = useMemo(() => {
     return [
       {
-        href: makeProfileLink(item.author),
-        did: item.author.did,
-        handle: item.author.handle,
-        displayName: item.author.displayName,
-        avatar: item.author.avatar,
-        moderation: moderateProfile(
-          item.author,
-          store.preferences.moderationOpts,
-        ),
+        href: makeProfileLink(item.notification.author),
+        did: item.notification.author.did,
+        handle: item.notification.author.handle,
+        displayName: item.notification.author.displayName,
+        avatar: item.notification.author.avatar,
+        moderation: moderateProfile(item.notification.author, moderationOpts),
       },
       ...(item.additional?.map(({author}) => {
         return {
@@ -113,33 +106,35 @@ export const FeedItem = observer(function FeedItemImpl({
           handle: author.handle,
           displayName: author.displayName,
           avatar: author.avatar,
-          moderation: moderateProfile(author, store.preferences.moderationOpts),
+          moderation: moderateProfile(author, moderationOpts),
         }
       }) || []),
     ]
-  }, [store, item.additional, item.author])
+  }, [item, moderationOpts])
 
-  if (item.additionalPost?.notFound) {
+  if (item.subjectUri && !item.subject) {
     // don't render anything if the target post was deleted or unfindable
     return <View />
   }
 
-  if (item.isReply || item.isMention || item.isQuote) {
-    if (!item.additionalPost || item.additionalPost?.error) {
-      // hide errors - it doesnt help the user to show them
-      return <View />
+  if (
+    item.type === 'reply' ||
+    item.type === 'mention' ||
+    item.type === 'quote'
+  ) {
+    if (!item.subject) {
+      return null
     }
     return (
       <Link
-        testID={`feedItem-by-${item.author.handle}`}
+        testID={`feedItem-by-${item.notification.author.handle}`}
         href={itemHref}
-        title={itemTitle}
         noFeedback
         accessible={false}>
         <Post
-          view={item.additionalPost}
+          post={item.subject}
           style={
-            item.isRead
+            item.notification.isRead
               ? undefined
               : {
                   backgroundColor: pal.colors.unreadNotifBg,
@@ -154,23 +149,25 @@ export const FeedItem = observer(function FeedItemImpl({
   let action = ''
   let icon: Props['icon'] | 'HeartIconSolid'
   let iconStyle: Props['style'] = []
-  if (item.isLike) {
+  if (item.type === 'post-like') {
     action = 'liked your post'
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
       {position: 'relative', top: -4},
     ]
-  } else if (item.isRepost) {
+  } else if (item.type === 'repost') {
     action = 'reposted your post'
     icon = 'retweet'
     iconStyle = [s.green3 as FontAwesomeIconStyle]
-  } else if (item.isFollow) {
+  } else if (item.type === 'follow') {
     action = 'followed you'
     icon = 'user-plus'
     iconStyle = [s.blue3 as FontAwesomeIconStyle]
-  } else if (item.isCustomFeedLike) {
-    action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
+  } else if (item.type === 'feedgen-like') {
+    action = `liked your custom feed${
+      item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
+    }`
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
@@ -182,12 +179,12 @@ export const FeedItem = observer(function FeedItemImpl({
 
   return (
     <Link
-      testID={`feedItem-by-${item.author.handle}`}
+      testID={`feedItem-by-${item.notification.author.handle}`}
       style={[
         styles.outer,
         pal.view,
         pal.border,
-        item.isRead
+        item.notification.isRead
           ? undefined
           : {
               backgroundColor: pal.colors.unreadNotifBg,
@@ -195,9 +192,11 @@ export const FeedItem = observer(function FeedItemImpl({
             },
       ]}
       href={itemHref}
-      title={itemTitle}
       noFeedback
-      accessible={(item.isLike && authors.length === 1) || item.isRepost}>
+      accessible={
+        (item.type === 'post-like' && authors.length === 1) ||
+        item.type === 'repost'
+      }>
       <View style={styles.layoutIcon}>
         {/* TODO: Prevent conditional rendering and move toward composable
         notifications for clearer accessibility labeling */}
@@ -232,7 +231,10 @@ export const FeedItem = observer(function FeedItemImpl({
             />
             {authors.length > 1 ? (
               <>
-                <Text style={[pal.text]}> and </Text>
+                <Text style={[pal.text, s.mr5, s.ml5]}>
+                  {' '}
+                  <Trans>and</Trans>{' '}
+                </Text>
                 <Text style={[pal.text, s.bold]}>
                   {formatCount(authors.length - 1)}{' '}
                   {pluralize(authors.length - 1, 'other')}
@@ -240,24 +242,26 @@ export const FeedItem = observer(function FeedItemImpl({
               </>
             ) : undefined}
             <Text style={[pal.text]}> {action}</Text>
-            <TimeElapsed timestamp={item.indexedAt}>
+            <TimeElapsed timestamp={item.notification.indexedAt}>
               {({timeElapsed}) => (
                 <Text
                   style={[pal.textLight, styles.pointer]}
-                  title={niceDate(item.indexedAt)}>
+                  title={niceDate(item.notification.indexedAt)}>
                   {' ' + timeElapsed}
                 </Text>
               )}
             </TimeElapsed>
           </Text>
         </ExpandListPressable>
-        {item.isLike || item.isRepost || item.isQuote ? (
-          <AdditionalPostText additionalPost={item.additionalPost} />
+        {item.type === 'post-like' || item.type === 'repost' ? (
+          <AdditionalPostText post={item.subject} />
         ) : null}
       </View>
     </Link>
   )
-})
+}
+FeedItem = memo(FeedItem)
+export {FeedItem}
 
 function ExpandListPressable({
   hasMultipleAuthors,
@@ -292,6 +296,8 @@ function CondensedAuthorsList({
   onToggleAuthorsExpanded: () => void
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
+
   if (!visible) {
     return (
       <View style={styles.avis}>
@@ -299,7 +305,7 @@ function CondensedAuthorsList({
           style={styles.expandedAuthorsCloseBtn}
           onPress={onToggleAuthorsExpanded}
           accessibilityRole="button"
-          accessibilityLabel="Hide user list"
+          accessibilityLabel={_(msg`Hide user list`)}
           accessibilityHint="Collapses list of users for a given notification">
           <FontAwesomeIcon
             icon="angle-up"
@@ -307,7 +313,7 @@ function CondensedAuthorsList({
             style={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
           />
           <Text type="sm-medium" style={pal.text}>
-            Hide
+            <Trans>Hide</Trans>
           </Text>
         </TouchableOpacity>
       </View>
@@ -328,7 +334,7 @@ function CondensedAuthorsList({
   }
   return (
     <TouchableOpacity
-      accessibilityLabel="Show users"
+      accessibilityLabel={_(msg`Show users`)}
       accessibilityHint="Opens an expanded list of users in this notification"
       onPress={onToggleAuthorsExpanded}>
       <View style={styles.avis}>
@@ -417,34 +423,25 @@ function ExpandedAuthorsList({
   )
 }
 
-function AdditionalPostText({
-  additionalPost,
-}: {
-  additionalPost?: PostThreadModel
-}) {
+function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
   const pal = usePalette('default')
-  if (
-    !additionalPost ||
-    !additionalPost.thread?.postRecord ||
-    additionalPost.error
-  ) {
-    return <View />
+  if (post && AppBskyFeedPost.isRecord(post?.record)) {
+    const text = post.record.text
+    const images = AppBskyEmbedImages.isView(post.embed)
+      ? post.embed.images
+      : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+        AppBskyEmbedImages.isView(post.embed.media)
+      ? post.embed.media.images
+      : undefined
+    return (
+      <>
+        {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
+        {images && images?.length > 0 && (
+          <ImageHorzList images={images} style={styles.additionalPostImages} />
+        )}
+      </>
+    )
   }
-  const text = additionalPost.thread?.postRecord.text
-  const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
-    ? additionalPost.thread.post.embed.images
-    : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) &&
-      AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media)
-    ? additionalPost.thread.post.embed.media.images
-    : undefined
-  return (
-    <>
-      {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
-      {images && images?.length > 0 && (
-        <ImageHorzList images={images} style={styles.additionalPostImages} />
-      )}
-    </>
-  )
 }
 
 const styles = StyleSheet.create({
diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx
deleted file mode 100644
index aaf358b87..000000000
--- a/src/view/com/notifications/InvitedUsers.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import React from 'react'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs} from '@atproto/api'
-import {UserAvatar} from '../util/UserAvatar'
-import {Text} from '../util/text/Text'
-import {Link, TextLink} from '../util/Link'
-import {Button} from '../util/forms/Button'
-import {FollowButton} from '../profile/FollowButton'
-import {CenteredView} from '../util/Views.web'
-import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s} from 'lib/styles'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {makeProfileLink} from 'lib/routes/links'
-
-export const InvitedUsers = observer(function InvitedUsersImpl() {
-  const store = useStores()
-  return (
-    <CenteredView>
-      {store.invitedUsers.profiles.map(profile => (
-        <InvitedUser key={profile.did} profile={profile} />
-      ))}
-    </CenteredView>
-  )
-})
-
-function InvitedUser({
-  profile,
-}: {
-  profile: AppBskyActorDefs.ProfileViewDetailed
-}) {
-  const pal = usePalette('default')
-  const store = useStores()
-
-  const onPressDismiss = React.useCallback(() => {
-    store.invitedUsers.markSeen(profile.did)
-  }, [store, profile])
-
-  return (
-    <View
-      testID="invitedUser"
-      style={[
-        styles.layout,
-        {
-          backgroundColor: pal.colors.unreadNotifBg,
-          borderColor: pal.colors.unreadNotifBorder,
-        },
-      ]}>
-      <View style={styles.layoutIcon}>
-        <FontAwesomeIcon
-          icon="user-plus"
-          size={24}
-          style={[styles.icon, s.blue3 as FontAwesomeIconStyle]}
-        />
-      </View>
-      <View style={s.flex1}>
-        <Link href={makeProfileLink(profile)}>
-          <UserAvatar avatar={profile.avatar} size={35} />
-        </Link>
-        <Text style={[styles.desc, pal.text]}>
-          <TextLink
-            type="md-bold"
-            style={pal.text}
-            href={makeProfileLink(profile)}
-            text={sanitizeDisplayName(profile.displayName || profile.handle)}
-          />{' '}
-          joined using your invite code!
-        </Text>
-        <View style={styles.btns}>
-          <FollowButton
-            unfollowedType="primary"
-            followedType="primary-light"
-            profile={profile}
-          />
-          <Button
-            testID="dismissBtn"
-            type="primary-light"
-            label="Dismiss"
-            onPress={onPressDismiss}
-          />
-        </View>
-      </View>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  layout: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    padding: 10,
-  },
-  layoutIcon: {
-    width: 70,
-    alignItems: 'flex-end',
-    paddingTop: 2,
-  },
-  icon: {
-    marginRight: 10,
-    marginTop: 4,
-  },
-  desc: {
-    paddingVertical: 6,
-  },
-  btns: {
-    flexDirection: 'row',
-    gap: 10,
-  },
-})
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 25755bafe..57c83f17c 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -1,50 +1,136 @@
 import React from 'react'
-import {StyleSheet} from 'react-native'
+import {View, StyleSheet} from 'react-native'
 import Animated from 'react-native-reanimated'
-import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {useStores} from 'state/index'
-import {useHomeTabs} from 'lib/hooks/useHomeTabs'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {usePinnedFeedsInfos} from '#/state/queries/feed'
+import {useSession} from '#/state/session'
+import {TextLink} from '#/view/com/util/Link'
+import {CenteredView} from '../util/Views'
+import {isWeb} from 'platform/detection'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
 
-export const FeedsTabBar = observer(function FeedsTabBarImpl(
+export function FeedsTabBar(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const {isMobile, isTablet} = useWebMediaQueries()
+  const {hasSession} = useSession()
+
   if (isMobile) {
     return <FeedsTabBarMobile {...props} />
   } else if (isTablet) {
-    return <FeedsTabBarTablet {...props} />
+    if (hasSession) {
+      return <FeedsTabBarTablet {...props} />
+    } else {
+      return <FeedsTabBarPublic />
+    }
   } else {
     return null
   }
-})
+}
+
+function FeedsTabBarPublic() {
+  const pal = usePalette('default')
+  const {isSandbox} = useSession()
 
-const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
+  return (
+    <CenteredView sideBorders>
+      <View
+        style={[
+          pal.view,
+          {
+            flexDirection: 'row',
+            alignItems: 'center',
+            justifyContent: 'space-between',
+            paddingHorizontal: 18,
+            paddingVertical: 12,
+          },
+        ]}>
+        <TextLink
+          type="title-lg"
+          href="/"
+          style={[pal.text, {fontWeight: 'bold'}]}
+          text={
+            <>
+              {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+              {/*hasNew && (
+                <View
+                  style={{
+                    top: -8,
+                    backgroundColor: colors.blue3,
+                    width: 8,
+                    height: 8,
+                    borderRadius: 4,
+                  }}
+                />
+              )*/}
+            </>
+          }
+          // onPress={emitSoftReset}
+        />
+      </View>
+    </CenteredView>
+  )
+}
+
+function FeedsTabBarTablet(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
-  const store = useStores()
-  const items = useHomeTabs(store.preferences.pinnedFeeds)
+  const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
   const pal = usePalette('default')
+  const {hasSession} = useSession()
+  const navigation = useNavigation<NavigationProp>()
   const {headerMinimalShellTransform} = useMinimalShellMode()
+  const {headerHeight} = useShellLayout()
+  const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : []
+  const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom
+  const items = showFeedsLinkInTabBar
+    ? pinnedDisplayNames.concat('Feeds ✨')
+    : pinnedDisplayNames
+
+  const onPressDiscoverFeeds = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  const onSelect = React.useCallback(
+    (index: number) => {
+      if (showFeedsLinkInTabBar && index === items.length - 1) {
+        onPressDiscoverFeeds()
+      } else if (props.onSelect) {
+        props.onSelect(index)
+      }
+    },
+    [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar],
+  )
 
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
     <Animated.View
-      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}>
+      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}
+      onLayout={e => {
+        headerHeight.value = e.nativeEvent.layout.height
+      }}>
       <TabBar
         key={items.join(',')}
         {...props}
+        onSelect={onSelect}
         items={items}
         indicatorColor={pal.colors.link}
       />
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   tabBar: {
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 9848ce2d5..882b6cfc5 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -1,10 +1,7 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {useStores} from 'state/index'
-import {useHomeTabs} from 'lib/hooks/useHomeTabs'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Link} from '../util/Link'
@@ -14,18 +11,54 @@ import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 import {HITSLOP_10} from 'lib/constants'
 import Animated from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useSetDrawerOpen} from '#/state/shell/drawer-open'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {useSession} from '#/state/session'
+import {usePinnedFeedsInfos} from '#/state/queries/feed'
+import {isWeb} from 'platform/detection'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
 
-export const FeedsTabBar = observer(function FeedsTabBarImpl(
+export function FeedsTabBar(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {isSandbox, hasSession} = useSession()
+  const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
-  const items = useHomeTabs(store.preferences.pinnedFeeds)
+  const navigation = useNavigation<NavigationProp>()
+  const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
   const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
-  const {minimalShellMode, headerMinimalShellTransform} = useMinimalShellMode()
+  const {headerHeight} = useShellLayout()
+  const {headerMinimalShellTransform} = useMinimalShellMode()
+  const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : []
+  const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom
+  const items = showFeedsLinkInTabBar
+    ? pinnedDisplayNames.concat('Feeds ✨')
+    : pinnedDisplayNames
+
+  const onPressFeedsLink = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  const onSelect = React.useCallback(
+    (index: number) => {
+      if (showFeedsLinkInTabBar && index === items.length - 1) {
+        onPressFeedsLink()
+      } else if (props.onSelect) {
+        props.onSelect(index)
+      }
+    },
+    [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar],
+  )
 
   const onPressAvi = React.useCallback(() => {
     setDrawerOpen(true)
@@ -33,20 +66,17 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
 
   return (
     <Animated.View
-      style={[
-        pal.view,
-        pal.border,
-        styles.tabBar,
-        headerMinimalShellTransform,
-        minimalShellMode && styles.disabled,
-      ]}>
+      style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
+      onLayout={e => {
+        headerHeight.value = e.nativeEvent.layout.height
+      }}>
       <View style={[pal.view, styles.topBar]}>
         <View style={[pal.view]}>
           <TouchableOpacity
             testID="viewHeaderDrawerBtn"
             onPress={onPressAvi}
             accessibilityRole="button"
-            accessibilityLabel="Open navigation"
+            accessibilityLabel={_(msg`Open navigation`)}
             accessibilityHint="Access profile and other navigation links"
             hitSlop={HITSLOP_10}>
             <FontAwesomeIcon
@@ -57,35 +87,40 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
           </TouchableOpacity>
         </View>
         <Text style={[brandBlue, s.bold, styles.title]}>
-          {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}
+          {isSandbox ? 'SANDBOX' : 'Bluesky'}
         </Text>
-        <View style={[pal.view]}>
-          <Link
-            testID="viewHeaderHomeFeedPrefsBtn"
-            href="/settings/home-feed"
-            hitSlop={HITSLOP_10}
-            accessibilityRole="button"
-            accessibilityLabel="Home Feed Preferences"
-            accessibilityHint="">
-            <FontAwesomeIcon
-              icon="sliders"
-              style={pal.textLight as FontAwesomeIconStyle}
-            />
-          </Link>
+        <View style={[pal.view, {width: 18}]}>
+          {hasSession && (
+            <Link
+              testID="viewHeaderHomeFeedPrefsBtn"
+              href="/settings/home-feed"
+              hitSlop={HITSLOP_10}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Home Feed Preferences`)}
+              accessibilityHint="">
+              <FontAwesomeIcon
+                icon="sliders"
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </Link>
+          )}
         </View>
       </View>
-      <TabBar
-        key={items.join(',')}
-        onPressSelected={props.onPressSelected}
-        selectedPage={props.selectedPage}
-        onSelect={props.onSelect}
-        testID={props.testID}
-        items={items}
-        indicatorColor={pal.colors.link}
-      />
+
+      {items.length > 0 && (
+        <TabBar
+          key={items.join(',')}
+          onPressSelected={props.onPressSelected}
+          selectedPage={props.selectedPage}
+          onSelect={onSelect}
+          testID={props.testID}
+          items={items}
+          indicatorColor={pal.colors.link}
+        />
+      )}
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   tabBar: {
@@ -95,7 +130,6 @@ const styles = StyleSheet.create({
     right: 0,
     top: 0,
     flexDirection: 'column',
-    alignItems: 'center',
     borderBottomWidth: 1,
   },
   topBar: {
@@ -103,14 +137,10 @@ const styles = StyleSheet.create({
     justifyContent: 'space-between',
     alignItems: 'center',
     paddingHorizontal: 18,
-    paddingTop: 8,
-    paddingBottom: 2,
+    paddingVertical: 8,
     width: '100%',
   },
   title: {
     fontSize: 21,
   },
-  disabled: {
-    pointerEvents: 'none',
-  },
 })
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 531a41ee2..d70087504 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -26,6 +26,9 @@ interface Props {
   renderTabBar: RenderTabBarFn
   onPageSelected?: (index: number) => void
   onPageSelecting?: (index: number) => void
+  onPageScrollStateChanged?: (
+    scrollState: 'idle' | 'dragging' | 'settling',
+  ) => void
   testID?: string
 }
 export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
@@ -35,6 +38,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
       tabBarPosition = 'top',
       initialPage = 0,
       renderTabBar,
+      onPageScrollStateChanged,
       onPageSelected,
       onPageSelecting,
       testID,
@@ -97,11 +101,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
       [lastOffset, lastDirection, onPageSelecting],
     )
 
-    const onPageScrollStateChanged = React.useCallback(
+    const handlePageScrollStateChanged = React.useCallback(
       (e: PageScrollStateChangedNativeEvent) => {
         scrollState.current = e.nativeEvent.pageScrollState
+        onPageScrollStateChanged?.(e.nativeEvent.pageScrollState)
       },
-      [scrollState],
+      [scrollState, onPageScrollStateChanged],
     )
 
     const onTabBarSelect = React.useCallback(
@@ -123,7 +128,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
           ref={pagerView}
           style={s.flex1}
           initialPage={initialPage}
-          onPageScrollStateChanged={onPageScrollStateChanged}
+          onPageScrollStateChanged={handlePageScrollStateChanged}
           onPageSelected={onPageSelectedInner}
           onPageScroll={onPageScroll}>
           {children}
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 7ec292667..3b5e9164a 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl(
           onSelect: onTabBarSelect,
         })}
       {React.Children.map(children, (child, i) => (
-        <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
+        <View
+          style={
+            selectedPage === i
+              ? s.flex1
+              : {
+                  position: 'absolute',
+                  pointerEvents: 'none',
+                  // @ts-ignore web-only
+                  visibility: 'hidden',
+                }
+          }
+          key={`page-${i}`}>
           {child}
         </View>
       ))}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 701b52871..2d3b0cece 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -1,28 +1,36 @@
 import * as React from 'react'
 import {
   LayoutChangeEvent,
-  NativeScrollEvent,
+  FlatList,
+  ScrollView,
   StyleSheet,
   View,
+  NativeScrollEvent,
 } from 'react-native'
 import Animated, {
-  Easing,
-  useAnimatedReaction,
   useAnimatedStyle,
   useSharedValue,
-  withTiming,
   runOnJS,
+  runOnUI,
+  scrollTo,
+  useAnimatedRef,
+  AnimatedRef,
+  SharedValue,
 } from 'react-native-reanimated'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {TabBar} from './TabBar'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 
 const SCROLLED_DOWN_LIMIT = 200
 
-interface PagerWithHeaderChildParams {
+export interface PagerWithHeaderChildParams {
   headerHeight: number
-  onScroll: (e: NativeScrollEvent) => void
+  isFocused: boolean
+  onScroll: OnScrollHandler
   isScrolledDown: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
 }
 
 export interface PagerWithHeaderProps {
@@ -51,117 +59,120 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
     }: PagerWithHeaderProps,
     ref,
   ) {
-    const {isMobile} = useWebMediaQueries()
     const [currentPage, setCurrentPage] = React.useState(0)
-    const scrollYs = React.useRef<Record<number, number>>({})
-    const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
     const [tabBarHeight, setTabBarHeight] = React.useState(0)
     const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
-    const [isScrolledDown, setIsScrolledDown] = React.useState(
-      scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
-    )
-
+    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+    const scrollY = useSharedValue(0)
     const headerHeight = headerOnlyHeight + tabBarHeight
 
-    // react to scroll updates
-    function onScrollUpdate(v: number) {
-      // track each page's current scroll position
-      scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight)
-      // update the 'is scrolled down' value
-      setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
-    }
-    useAnimatedReaction(
-      () => scrollY.value,
-      v => runOnJS(onScrollUpdate)(v),
-    )
-
     // capture the header bar sizing
     const onTabBarLayout = React.useCallback(
       (evt: LayoutChangeEvent) => {
-        setTabBarHeight(evt.nativeEvent.layout.height)
+        const height = evt.nativeEvent.layout.height
+        if (height > 0) {
+          setTabBarHeight(height)
+        }
       },
       [setTabBarHeight],
     )
     const onHeaderOnlyLayout = React.useCallback(
       (evt: LayoutChangeEvent) => {
-        setHeaderOnlyHeight(evt.nativeEvent.layout.height)
+        const height = evt.nativeEvent.layout.height
+        if (height > 0) {
+          setHeaderOnlyHeight(height)
+        }
       },
       [setHeaderOnlyHeight],
     )
 
-    // render the the header and tab bar
-    const headerTransform = useAnimatedStyle(
-      () => ({
-        transform: [
-          {
-            translateY: Math.min(
-              Math.min(scrollY.value, headerOnlyHeight) * -1,
-              0,
-            ),
-          },
-        ],
-      }),
-      [scrollY, headerHeight, tabBarHeight],
-    )
     const renderTabBar = React.useCallback(
       (props: RenderTabBarFnProps) => {
         return (
-          <Animated.View
-            style={[
-              isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
-              headerTransform,
-            ]}>
-            <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
-            <View
-              onLayout={onTabBarLayout}
-              style={{
-                // Render it immediately to measure it early since its size doesn't depend on the content.
-                // However, keep it invisible until the header above stabilizes in order to prevent jumps.
-                opacity: isHeaderReady ? 1 : 0,
-                pointerEvents: isHeaderReady ? 'auto' : 'none',
-              }}>
-              <TabBar
-                items={items}
-                selectedPage={currentPage}
-                onSelect={props.onSelect}
-                onPressSelected={onCurrentPageSelected}
-              />
-            </View>
-          </Animated.View>
+          <PagerTabBar
+            headerOnlyHeight={headerOnlyHeight}
+            items={items}
+            isHeaderReady={isHeaderReady}
+            renderHeader={renderHeader}
+            currentPage={currentPage}
+            onCurrentPageSelected={onCurrentPageSelected}
+            onTabBarLayout={onTabBarLayout}
+            onHeaderOnlyLayout={onHeaderOnlyLayout}
+            onSelect={props.onSelect}
+            scrollY={scrollY}
+            testID={testID}
+          />
         )
       },
       [
+        headerOnlyHeight,
         items,
         isHeaderReady,
         renderHeader,
-        headerTransform,
         currentPage,
         onCurrentPageSelected,
-        isMobile,
         onTabBarLayout,
         onHeaderOnlyLayout,
+        scrollY,
+        testID,
       ],
     )
 
-    // Ideally we'd call useAnimatedScrollHandler here but we can't safely do that
-    // due to https://github.com/software-mansion/react-native-reanimated/issues/5345.
-    // So instead we pass down a worklet, and individual pages will have to call it.
-    const onScroll = React.useCallback(
-      (e: NativeScrollEvent) => {
+    const scrollRefs = useSharedValue<AnimatedRef<any>[]>([])
+    const registerRef = (scrollRef: AnimatedRef<any>, index: number) => {
+      scrollRefs.modify(refs => {
         'worklet'
-        scrollY.value = e.contentOffset.y
-      },
-      [scrollY],
+        refs[index] = scrollRef
+        return refs
+      })
+    }
+
+    const lastForcedScrollY = useSharedValue(0)
+    const adjustScrollForOtherPages = () => {
+      'worklet'
+      const currentScrollY = scrollY.value
+      const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
+      if (lastForcedScrollY.value !== forcedScrollY) {
+        lastForcedScrollY.value = forcedScrollY
+        const refs = scrollRefs.value
+        for (let i = 0; i < refs.length; i++) {
+          if (i !== currentPage) {
+            // This needs to run on the UI thread.
+            scrollTo(refs[i], 0, forcedScrollY, false)
+          }
+        }
+      }
+    }
+
+    const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
+      null,
     )
+    const queueThrottledOnScroll = useNonReactiveCallback(() => {
+      if (!throttleTimeout.current) {
+        throttleTimeout.current = setTimeout(() => {
+          throttleTimeout.current = null
 
-    // props to pass into children render functions
-    const childProps = React.useMemo<PagerWithHeaderChildParams>(() => {
-      return {
-        headerHeight,
-        onScroll,
-        isScrolledDown,
+          runOnUI(adjustScrollForOtherPages)()
+
+          const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT
+          if (isScrolledDown !== nextIsScrolledDown) {
+            React.startTransition(() => {
+              setIsScrolledDown(nextIsScrolledDown)
+            })
+          }
+        }, 80 /* Sync often enough you're unlikely to catch it unsynced */)
       }
-    }, [headerHeight, onScroll, isScrolledDown])
+    })
+
+    const onScrollWorklet = React.useCallback(
+      (e: NativeScrollEvent) => {
+        'worklet'
+        const nextScrollY = e.contentOffset.y
+        scrollY.value = nextScrollY
+        runOnJS(queueThrottledOnScroll)()
+      },
+      [scrollY, queueThrottledOnScroll],
+    )
 
     const onPageSelectedInner = React.useCallback(
       (index: number) => {
@@ -171,19 +182,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
       [onPageSelected, setCurrentPage],
     )
 
-    const onPageSelecting = React.useCallback(
-      (index: number) => {
-        setCurrentPage(index)
-        if (scrollY.value > headerHeight) {
-          scrollY.value = headerHeight
-        }
-        scrollY.value = withTiming(scrollYs.current[index] || 0, {
-          duration: 170,
-          easing: Easing.inOut(Easing.quad),
-        })
-      },
-      [scrollY, setCurrentPage, scrollYs, headerHeight],
-    )
+    const onPageSelecting = React.useCallback((index: number) => {
+      setCurrentPage(index)
+    }, [])
 
     return (
       <Pager
@@ -197,20 +198,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
         {toArray(children)
           .filter(Boolean)
           .map((child, i) => {
-            let output = null
-            if (
-              child != null &&
-              // Defer showing content until we know it won't jump.
-              isHeaderReady &&
-              headerOnlyHeight > 0 &&
-              tabBarHeight > 0
-            ) {
-              output = child(childProps)
-            }
-            // Pager children must be noncollapsible plain <View>s.
+            const isReady =
+              isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
             return (
               <View key={i} collapsable={false}>
-                {output}
+                <PagerItem
+                  headerHeight={headerHeight}
+                  isReady={isReady}
+                  isFocused={i === currentPage}
+                  isScrolledDown={isScrolledDown}
+                  onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
+                  registerRef={(r: AnimatedRef<any>) => registerRef(r, i)}
+                  renderTab={child}
+                />
               </View>
             )
           })}
@@ -219,6 +219,107 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
   },
 )
 
+let PagerTabBar = ({
+  currentPage,
+  headerOnlyHeight,
+  isHeaderReady,
+  items,
+  scrollY,
+  testID,
+  renderHeader,
+  onHeaderOnlyLayout,
+  onTabBarLayout,
+  onCurrentPageSelected,
+  onSelect,
+}: {
+  currentPage: number
+  headerOnlyHeight: number
+  isHeaderReady: boolean
+  items: string[]
+  testID?: string
+  scrollY: SharedValue<number>
+  renderHeader?: () => JSX.Element
+  onHeaderOnlyLayout: (e: LayoutChangeEvent) => void
+  onTabBarLayout: (e: LayoutChangeEvent) => void
+  onCurrentPageSelected?: (index: number) => void
+  onSelect?: (index: number) => void
+}): React.ReactNode => {
+  const {isMobile} = useWebMediaQueries()
+  const headerTransform = useAnimatedStyle(() => ({
+    transform: [
+      {
+        translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0),
+      },
+    ],
+  }))
+  return (
+    <Animated.View
+      style={[
+        isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
+        headerTransform,
+      ]}>
+      <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
+      <View
+        onLayout={onTabBarLayout}
+        style={{
+          // Render it immediately to measure it early since its size doesn't depend on the content.
+          // However, keep it invisible until the header above stabilizes in order to prevent jumps.
+          opacity: isHeaderReady ? 1 : 0,
+          pointerEvents: isHeaderReady ? 'auto' : 'none',
+        }}>
+        <TabBar
+          testID={testID}
+          items={items}
+          selectedPage={currentPage}
+          onSelect={onSelect}
+          onPressSelected={onCurrentPageSelected}
+        />
+      </View>
+    </Animated.View>
+  )
+}
+PagerTabBar = React.memo(PagerTabBar)
+
+function PagerItem({
+  headerHeight,
+  isReady,
+  isFocused,
+  isScrolledDown,
+  onScrollWorklet,
+  renderTab,
+  registerRef,
+}: {
+  headerHeight: number
+  isFocused: boolean
+  isReady: boolean
+  isScrolledDown: boolean
+  registerRef: (scrollRef: AnimatedRef<any>) => void
+  onScrollWorklet: (e: NativeScrollEvent) => void
+  renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
+}) {
+  const scrollElRef = useAnimatedRef()
+  registerRef(scrollElRef)
+
+  const scrollHandler = React.useMemo(
+    () => ({onScroll: onScrollWorklet}),
+    [onScrollWorklet],
+  )
+
+  if (!isReady || renderTab == null) {
+    return null
+  }
+
+  return renderTab({
+    headerHeight,
+    isFocused,
+    isScrolledDown,
+    onScroll: scrollHandler,
+    scrollElRef: scrollElRef as React.MutableRefObject<
+      FlatList<any> | ScrollView | null
+    >,
+  })
+}
+
 const styles = StyleSheet.create({
   tabBarMobile: {
     position: 'absolute',
@@ -237,6 +338,10 @@ const styles = StyleSheet.create({
   },
 })
 
+function noop() {
+  'worklet'
+}
+
 function toArray<T>(v: T | T[]): T[] {
   if (Array.isArray(v)) {
     return v
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 0e08b22d8..c3a95c5c0 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -68,6 +68,7 @@ export function TabBar({
   return (
     <View testID={testID} style={[pal.view, styles.outer]}>
       <DraggableScrollView
+        testID={`${testID}-selector`}
         horizontal={true}
         showsHorizontalScrollIndicator={false}
         ref={scrollElRef}
@@ -76,6 +77,7 @@ export function TabBar({
           const selected = i === selectedPage
           return (
             <PressableWithHover
+              testID={`${testID}-selector-${i}`}
               key={item}
               onLayout={e => onItemLayout(e, i)}
               style={[styles.item, selected && indicatorStyle]}
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 22ff035d0..60afe1f9c 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,39 +1,66 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {useCallback, useMemo, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {LikesModel, LikeItem} from 'state/models/lists/likes'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
 
-export const PostLikedBy = observer(function PostLikedByImpl({
-  uri,
-}: {
-  uri: string
-}) {
+export function PostLikedBy({uri}: {uri: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
+  const [isPTRing, setIsPTRing] = useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = usePostLikedByQuery(resolvedUri?.uri)
+  const likes = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+  }, [data])
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch likes', {error: err}))
-  }, [view])
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh likes', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more likes', {error: err}))
-  }
+  const onEndReached = useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
+    return (
+      <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
+    )
+  }, [])
 
-  if (!view.hasLoaded) {
+  if (isFetchingResolvedUri || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -43,26 +70,26 @@ export const PostLikedBy = observer(function PostLikedByImpl({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: LikeItem}) => (
-    <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
-  )
   return (
     <FlatList
-      data={view.likes}
+      data={likes}
       keyExtractor={item => item.actor.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -75,15 +102,14 @@ export const PostLikedBy = observer(function PostLikedByImpl({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 29a795302..1162fec40 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,42 +1,67 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {useMemo, useCallback, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {RepostedByModel, RepostedByItem} from 'state/models/lists/reposted-by'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
+import {cleanError} from '#/lib/strings/errors'
 
-export const PostRepostedBy = observer(function PostRepostedByImpl({
-  uri,
-}: {
-  uri: string
-}) {
+export function PostRepostedBy({uri}: {uri: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new RepostedByModel(store, {uri}),
-    [store, uri],
-  )
+  const [isPTRing, setIsPTRing] = useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = usePostRepostedByQuery(resolvedUri?.uri)
+  const repostedBy = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.repostedBy)
+    }
+  }, [data])
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch reposts', {error: err}))
-  }, [view])
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh reposts', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more reposts', {error: err}))
-  }
+  const onEndReached = useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more reposts', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => {
+      return <ProfileCardWithFollowBtn key={item.did} profile={item} />
+    },
+    [],
+  )
 
-  if (!view.hasLoaded) {
+  if (isFetchingResolvedUri || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -46,26 +71,26 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: RepostedByItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.repostedBy}
+      data={repostedBy}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -78,15 +103,14 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 4eb47b0a3..edf02e9c5 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,6 +1,4 @@
 import React, {useRef} from 'react'
-import {runInAction} from 'mobx'
-import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   Pressable,
@@ -11,8 +9,6 @@ import {
 } from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -23,43 +19,42 @@ import {ViewHeader} from '../util/ViewHeader'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
-import {isNative} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {
+  ThreadNode,
+  ThreadPost,
+  usePostThreadQuery,
+  sortThread,
+} from '#/state/queries/post-thread'
 import {useNavigation} from '@react-navigation/native'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {NavigationProp} from 'lib/routes/types'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {cleanError} from '#/lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {isNative} from '#/platform/detection'
 import {logger} from '#/logger'
 
 const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
 
-const TOP_COMPONENT = {
-  _reactKey: '__top_component__',
-  _isHighlightedPost: false,
-}
-const PARENT_SPINNER = {
-  _reactKey: '__parent_spinner__',
-  _isHighlightedPost: false,
-}
-const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
-const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
-const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
-const CHILD_SPINNER = {
-  _reactKey: '__child_spinner__',
-  _isHighlightedPost: false,
-}
-const LOAD_MORE = {
-  _reactKey: '__load_more__',
-  _isHighlightedPost: false,
-}
-const BOTTOM_COMPONENT = {
-  _reactKey: '__bottom_component__',
-  _isHighlightedPost: false,
-  _showBorder: true,
-}
+const TOP_COMPONENT = {_reactKey: '__top_component__'}
+const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
+const REPLY_PROMPT = {_reactKey: '__reply__'}
+const DELETED = {_reactKey: '__deleted__'}
+const BLOCKED = {_reactKey: '__blocked__'}
+const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
+const LOAD_MORE = {_reactKey: '__load_more__'}
+const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
+
 type YieldedItem =
-  | PostThreadItemModel
+  | ThreadPost
   | typeof TOP_COMPONENT
   | typeof PARENT_SPINNER
   | typeof REPLY_PROMPT
@@ -67,127 +62,161 @@ type YieldedItem =
   | typeof BLOCKED
   | typeof PARENT_SPINNER
 
-export const PostThread = observer(function PostThread({
+export function PostThread({
   uri,
-  view,
   onPressReply,
-  treeView,
 }: {
-  uri: string
-  view: PostThreadModel
+  uri: string | undefined
+  onPressReply: () => void
+}) {
+  const {
+    isLoading,
+    isError,
+    error,
+    refetch,
+    data: thread,
+  } = usePostThreadQuery(uri)
+  const {data: preferences} = usePreferencesQuery()
+  const rootPost = thread?.type === 'post' ? thread.post : undefined
+  const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+
+  useSetTitle(
+    rootPost &&
+      `${sanitizeDisplayName(
+        rootPost.author.displayName || `@${rootPost.author.handle}`,
+      )}: "${rootPostRecord?.text}"`,
+  )
+
+  if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
+    return (
+      <PostThreadError
+        error={error}
+        notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
+        onRefresh={refetch}
+      />
+    )
+  }
+  if (AppBskyFeedDefs.isBlockedPost(thread)) {
+    return <PostThreadBlocked />
+  }
+  if (!thread || isLoading || !preferences) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }
+  return (
+    <PostThreadLoaded
+      thread={thread}
+      threadViewPrefs={preferences.threadViewPrefs}
+      onRefresh={refetch}
+      onPressReply={onPressReply}
+    />
+  )
+}
+
+function PostThreadLoaded({
+  thread,
+  threadViewPrefs,
+  onRefresh,
+  onPressReply,
+}: {
+  thread: ThreadNode
+  threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
+  onRefresh: () => void
   onPressReply: () => void
-  treeView: boolean
 }) {
+  const {hasSession} = useSession()
+  const {_} = useLingui()
   const pal = usePalette('default')
   const {isTablet, isDesktop} = useWebMediaQueries()
   const ref = useRef<FlatList>(null)
-  const hasScrolledIntoView = useRef<boolean>(false)
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const highlightedPostRef = useRef<View | null>(null)
+  const needsScrollAdjustment = useRef<boolean>(
+    !isNative || // web always uses scroll adjustment
+      (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
+  )
   const [maxVisible, setMaxVisible] = React.useState(100)
-  const navigation = useNavigation<NavigationProp>()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+
+  // construct content
   const posts = React.useMemo(() => {
-    if (view.thread) {
-      let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
-      if (arr.length > maxVisible) {
-        arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-      }
-      if (view.isLoadingFromCache) {
-        if (view.thread?.postRecord?.reply) {
-          arr.unshift(PARENT_SPINNER)
-        }
-        arr.push(CHILD_SPINNER)
-      } else {
-        arr.push(BOTTOM_COMPONENT)
-      }
-      return arr
+    let arr = [TOP_COMPONENT].concat(
+      Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
+    )
+    if (arr.length > maxVisible) {
+      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
-    return []
-  }, [view.isLoadingFromCache, view.thread, maxVisible])
-  const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
-  useSetTitle(
-    view.thread?.postRecord &&
-      `${sanitizeDisplayName(
-        view.thread.post.author.displayName ||
-          `@${view.thread.post.author.handle}`,
-      )}: "${view.thread?.postRecord?.text}"`,
-  )
-
-  // events
-  // =
-
-  const onRefresh = React.useCallback(async () => {
-    setIsRefreshing(true)
-    try {
-      view?.refresh()
-    } catch (err) {
-      logger.error('Failed to refresh posts thread', {error: err})
+    if (arr.indexOf(CHILD_SPINNER) === -1) {
+      arr.push(BOTTOM_COMPONENT)
     }
-    setIsRefreshing(false)
-  }, [view, setIsRefreshing])
+    return arr
+  }, [thread, maxVisible, threadViewPrefs])
 
+  /**
+   * NOTE
+   * Scroll positioning
+   *
+   * This callback is run if needsScrollAdjustment.current == true, which is...
+   *  - On web: always
+   *  - On native: when the placeholder cache is not being used
+   *
+   * It then only runs when viewing a reply, and the goal is to scroll the
+   * reply into view.
+   *
+   * On native, if the placeholder cache is being used then maintainVisibleContentPosition
+   * is a more effective solution, so we use that. Otherwise, typically we're loading from
+   * the react-query cache, so we just need to immediately scroll down to the post.
+   *
+   * On desktop, maintainVisibleContentPosition isn't supported so we just always use
+   * this technique.
+   *
+   * -prf
+   */
   const onContentSizeChange = React.useCallback(() => {
     // only run once
-    if (hasScrolledIntoView.current) {
+    if (!needsScrollAdjustment.current) {
       return
     }
 
     // wait for loading to finish
-    if (
-      !view.hasContent ||
-      (view.isFromCache && view.isLoadingFromCache) ||
-      view.isLoading
-    ) {
-      return
+    if (thread.type === 'post' && !!thread.parent) {
+      highlightedPostRef.current?.measure(
+        (_x, _y, _width, _height, _pageX, pageY) => {
+          ref.current?.scrollToOffset({
+            animated: false,
+            offset: pageY - (isDesktop ? 0 : 50),
+          })
+        },
+      )
+      needsScrollAdjustment.current = false
     }
+  }, [thread, isDesktop])
 
-    if (highlightedPostIndex !== -1) {
-      ref.current?.scrollToIndex({
-        index: highlightedPostIndex,
-        animated: false,
-        viewPosition: 0,
-      })
-      hasScrolledIntoView.current = true
-    }
-  }, [
-    highlightedPostIndex,
-    view.hasContent,
-    view.isFromCache,
-    view.isLoadingFromCache,
-    view.isLoading,
-  ])
-  const onScrollToIndexFailed = React.useCallback(
-    (info: {
-      index: number
-      highestMeasuredFrameIndex: number
-      averageItemLength: number
-    }) => {
-      ref.current?.scrollToOffset({
-        animated: false,
-        offset: info.averageItemLength * info.index,
-      })
-    },
-    [ref],
-  )
-
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
+  const onPTR = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await onRefresh()
+    } catch (err) {
+      logger.error('Failed to refresh posts thread', {error: err})
     }
-  }, [navigation])
+    setIsPTRing(false)
+  }, [setIsPTRing, onRefresh])
 
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
       if (item === TOP_COMPONENT) {
-        return isTablet ? <ViewHeader title="Post" /> : null
+        return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
       } else if (item === PARENT_SPINNER) {
         return (
           <View style={styles.parentSpinner}>
             <ActivityIndicator />
           </View>
         )
-      } else if (item === REPLY_PROMPT) {
+      } else if (item === REPLY_PROMPT && hasSession) {
         return (
           <View>
             {isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
@@ -197,7 +226,7 @@ export const PostThread = observer(function PostThread({
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
-              Deleted post.
+              <Trans>Deleted post.</Trans>
             </Text>
           </View>
         )
@@ -205,7 +234,7 @@ export const PostThread = observer(function PostThread({
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
-              Blocked post.
+              <Trans>Blocked post.</Trans>
             </Text>
           </View>
         )
@@ -214,7 +243,7 @@ export const PostThread = observer(function PostThread({
           <Pressable
             onPress={() => setMaxVisible(n => n + 50)}
             style={[pal.border, pal.view, styles.itemContainer]}
-            accessibilityLabel="Load more posts"
+            accessibilityLabel={_(msg`Load more posts`)}
             accessibilityHint="">
             <View
               style={[
@@ -222,7 +251,7 @@ export const PostThread = observer(function PostThread({
                 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
               ]}>
               <Text type="lg-medium" style={pal.text}>
-                Load more posts
+                <Trans>Load more posts</Trans>
               </Text>
             </View>
           </Pressable>
@@ -247,22 +276,32 @@ export const PostThread = observer(function PostThread({
             <ActivityIndicator />
           </View>
         )
-      } else if (item instanceof PostThreadItemModel) {
-        const prev = (
-          index - 1 >= 0 ? posts[index - 1] : undefined
-        ) as PostThreadItemModel
+      } else if (isThreadPost(item)) {
+        const prev = isThreadPost(posts[index - 1])
+          ? (posts[index - 1] as ThreadPost)
+          : undefined
         return (
-          <PostThreadItem
-            item={item}
-            onPostReply={onRefresh}
-            hasPrecedingItem={prev?._showChildReplyLine}
-            treeView={treeView}
-          />
+          <View
+            ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
+            <PostThreadItem
+              post={item.post}
+              record={item.record}
+              treeView={threadViewPrefs.lab_treeViewEnabled || false}
+              depth={item.ctx.depth}
+              isHighlightedPost={item.ctx.isHighlightedPost}
+              hasMore={item.ctx.hasMore}
+              showChildReplyLine={item.ctx.showChildReplyLine}
+              showParentReplyLine={item.ctx.showParentReplyLine}
+              hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
+              onPostReply={onRefresh}
+            />
+          </View>
         )
       }
-      return <></>
+      return null
     },
     [
+      hasSession,
       isTablet,
       isDesktop,
       onPressReply,
@@ -274,77 +313,117 @@ export const PostThread = observer(function PostThread({
       pal.colors.border,
       posts,
       onRefresh,
-      treeView,
+      threadViewPrefs.lab_treeViewEnabled,
+      _,
     ],
   )
 
-  // loading
-  // =
-  if (
-    !view.hasLoaded ||
-    (view.isLoading && !view.isRefreshing) ||
-    view.params.uri !== uri
-  ) {
-    return (
-      <CenteredView>
-        <View style={s.p20}>
-          <ActivityIndicator size="large" />
-        </View>
-      </CenteredView>
-    )
-  }
+  return (
+    <FlatList
+      ref={ref}
+      data={posts}
+      initialNumToRender={posts.length}
+      maintainVisibleContentPosition={
+        !needsScrollAdjustment.current
+          ? MAINTAIN_VISIBLE_CONTENT_POSITION
+          : undefined
+      }
+      keyExtractor={item => item._reactKey}
+      renderItem={renderItem}
+      refreshControl={
+        <RefreshControl
+          refreshing={isPTRing}
+          onRefresh={onPTR}
+          tintColor={pal.colors.text}
+          titleColor={pal.colors.text}
+        />
+      }
+      onContentSizeChange={onContentSizeChange}
+      style={s.hContentRegion}
+      // @ts-ignore our .web version only -prf
+      desktopFixedHeight
+    />
+  )
+}
 
-  // error
-  // =
-  if (view.hasError) {
-    if (view.notFound) {
-      return (
-        <CenteredView>
-          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-            <Text type="title-lg" style={[pal.text, s.mb5]}>
-              Post not found
-            </Text>
-            <Text type="md" style={[pal.text, s.mb10]}>
-              The post may have been deleted.
-            </Text>
-            <TouchableOpacity
-              onPress={onPressBack}
-              accessibilityRole="button"
-              accessibilityLabel="Back"
-              accessibilityHint="">
-              <Text type="2xl" style={pal.link}>
-                <FontAwesomeIcon
-                  icon="angle-left"
-                  style={[pal.link as FontAwesomeIconStyle, s.mr5]}
-                  size={14}
-                />
-                Back
-              </Text>
-            </TouchableOpacity>
-          </View>
-        </CenteredView>
-      )
+function PostThreadBlocked() {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
     }
-    return (
-      <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
-      </CenteredView>
-    )
-  }
-  if (view.isBlocked) {
+  }, [navigation])
+
+  return (
+    <CenteredView>
+      <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+        <Text type="title-lg" style={[pal.text, s.mb5]}>
+          <Trans>Post hidden</Trans>
+        </Text>
+        <Text type="md" style={[pal.text, s.mb10]}>
+          <Trans>
+            You have blocked the author or you have been blocked by the author.
+          </Trans>
+        </Text>
+        <TouchableOpacity
+          onPress={onPressBack}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            <FontAwesomeIcon
+              icon="angle-left"
+              style={[pal.link as FontAwesomeIconStyle, s.mr5]}
+              size={14}
+            />
+            Back
+          </Text>
+        </TouchableOpacity>
+      </View>
+    </CenteredView>
+  )
+}
+
+function PostThreadError({
+  onRefresh,
+  notFound,
+  error,
+}: {
+  onRefresh: () => void
+  notFound: boolean
+  error: Error | null
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (notFound) {
     return (
       <CenteredView>
         <View style={[pal.view, pal.border, styles.notFoundContainer]}>
           <Text type="title-lg" style={[pal.text, s.mb5]}>
-            Post hidden
+            <Trans>Post not found</Trans>
           </Text>
           <Text type="md" style={[pal.text, s.mb10]}>
-            You have blocked the author or you have been blocked by the author.
+            <Trans>The post may have been deleted.</Trans>
           </Text>
           <TouchableOpacity
             onPress={onPressBack}
             accessibilityRole="button"
-            accessibilityLabel="Back"
+            accessibilityLabel={_(msg`Back`)}
             accessibilityHint="">
             <Text type="2xl" style={pal.link}>
               <FontAwesomeIcon
@@ -352,76 +431,48 @@ export const PostThread = observer(function PostThread({
                 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
                 size={14}
               />
-              Back
+              <Trans>Back</Trans>
             </Text>
           </TouchableOpacity>
         </View>
       </CenteredView>
     )
   }
-
-  // loaded
-  // =
   return (
-    <FlatList
-      ref={ref}
-      data={posts}
-      initialNumToRender={posts.length}
-      maintainVisibleContentPosition={
-        isNative && view.isFromCache && view.isCachedPostAReply
-          ? MAINTAIN_VISIBLE_CONTENT_POSITION
-          : undefined
-      }
-      keyExtractor={item => item._reactKey}
-      renderItem={renderItem}
-      refreshControl={
-        <RefreshControl
-          refreshing={isRefreshing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
-      onContentSizeChange={
-        isNative && view.isFromCache ? undefined : onContentSizeChange
-      }
-      onScrollToIndexFailed={onScrollToIndexFailed}
-      style={s.hContentRegion}
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-    />
+    <CenteredView>
+      <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
+    </CenteredView>
   )
-})
+}
+
+function isThreadPost(v: unknown): v is ThreadPost {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
+}
 
-function* flattenThread(
-  post: PostThreadItemModel,
-  isAscending = false,
+function* flattenThreadSkeleton(
+  node: ThreadNode,
 ): Generator<YieldedItem, void> {
-  if (post.parent) {
-    if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
-      yield DELETED
-    } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
-      yield BLOCKED
-    } else {
-      yield* flattenThread(post.parent as PostThreadItemModel, true)
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* flattenThreadSkeleton(node.parent)
+    } else if (node.ctx.isParentLoading) {
+      yield PARENT_SPINNER
     }
-  }
-  yield post
-  if (post._isHighlightedPost) {
-    yield REPLY_PROMPT
-  }
-  if (post.replies?.length) {
-    for (const reply of post.replies) {
-      if (AppBskyFeedDefs.isNotFoundPost(reply)) {
-        yield DELETED
-      } else {
-        yield* flattenThread(reply as PostThreadItemModel)
+    yield node
+    if (node.ctx.isHighlightedPost) {
+      yield REPLY_PROMPT
+    }
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        yield* flattenThreadSkeleton(reply)
       }
+    } else if (node.ctx.isChildLoading) {
+      yield CHILD_SPINNER
     }
-  } else if (!isAscending && !post.parent && post.post.replyCount) {
-    runInAction(() => {
-      post._hasMore = true
-    })
+  } else if (node.type === 'not-found') {
+    yield DELETED
+  } else if (node.type === 'blocked') {
+    yield BLOCKED
   }
 }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 351a46706..a4b7a4a9c 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -1,18 +1,17 @@
-import React, {useMemo} from 'react'
-import {observer} from 'mobx-react-lite'
-import {Linking, StyleSheet, View} from 'react-native'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri, AppBskyFeedDefs} from '@atproto/api'
+import React, {memo, useMemo} from 'react'
+import {StyleSheet, View} from 'react-native'
 import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
+  AtUri,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  RichText as RichTextAPI,
+  moderatePost,
+  PostModeration,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {Text} from '../util/text/Text'
-import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {niceDate} from 'lib/strings/time'
@@ -21,10 +20,10 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines, pluralize} from 'lib/strings/helpers'
 import {isEmbedByEmbedder} from 'lib/embeds'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
-import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
+import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
@@ -36,125 +35,172 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {MAX_POST_LINES} from 'lib/constants'
-import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useComposerControls} from '#/state/shell/composer'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const PostThreadItem = observer(function PostThreadItem({
-  item,
-  onPostReply,
-  hasPrecedingItem,
+export function PostThreadItem({
+  post,
+  record,
   treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
 }: {
-  item: PostThreadItemModel
-  onPostReply: () => void
-  hasPrecedingItem: boolean
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
   treeView: boolean
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
+  hasPrecedingItem: boolean
+  onPostReply: () => void
 }) {
+  const moderationOpts = useModerationOpts()
+  const postShadowed = usePostShadow(post)
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  const moderation = useMemo(
+    () =>
+      post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
+    [post, moderationOpts],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return <PostThreadItemDeleted />
+  }
+  if (richText && moderation) {
+    return (
+      <PostThreadItemLoaded
+        post={postShadowed}
+        record={record}
+        richText={richText}
+        moderation={moderation}
+        treeView={treeView}
+        depth={depth}
+        isHighlightedPost={isHighlightedPost}
+        hasMore={hasMore}
+        showChildReplyLine={showChildReplyLine}
+        showParentReplyLine={showParentReplyLine}
+        hasPrecedingItem={hasPrecedingItem}
+        onPostReply={onPostReply}
+      />
+    )
+  }
+  return null
+}
+
+function PostThreadItemDeleted() {
+  const styles = useStyles()
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
+      <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
+      <Text style={[pal.textLight, s.ml10]}>
+        <Trans>This post has been deleted.</Trans>
+      </Text>
+    </View>
+  )
+}
+
+let PostThreadItemLoaded = ({
+  post,
+  record,
+  richText,
+  moderation,
+  treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
+}: {
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  moderation: PostModeration
+  treeView: boolean
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
+  hasPrecedingItem: boolean
+  onPostReply: () => void
+}): React.ReactNode => {
   const pal = usePalette('default')
-  const store = useStores()
-  const [deleted, setDeleted] = React.useState(false)
+  const langPrefs = useLanguagePrefs()
+  const {openComposer} = useComposerControls()
   const [limitLines, setLimitLines] = React.useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+    () => countLines(richText?.text) >= MAX_POST_LINES,
   )
   const styles = useStyles()
-  const record = item.postRecord
-  const hasEngagement = item.post.likeCount || item.post.repostCount
+  const hasEngagement = post.likeCount || post.repostCount
 
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey)
-  }, [item.post.uri, item.post.author])
-  const itemTitle = `Post by ${item.post.author.handle}`
-  const authorHref = makeProfileLink(item.post.author)
-  const authorTitle = item.post.author.handle
-  const isAuthorMuted = item.post.author.viewer?.muted
+  const rootUri = record.reply?.root?.uri || post.uri
+  const postHref = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+  const itemTitle = `Post by ${post.author.handle}`
+  const authorHref = makeProfileLink(post.author)
+  const authorTitle = post.author.handle
+  const isAuthorMuted = post.author.viewer?.muted
   const likesHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by')
-  }, [item.post.uri, item.post.author])
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
+  }, [post.uri, post.author])
   const likesTitle = 'Likes on this post'
   const repostsHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by')
-  }, [item.post.uri, item.post.author])
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
+  }, [post.uri, post.author])
   const repostsTitle = 'Reposts of this post'
 
   const translatorUrl = getTranslatorLink(
     record?.text || '',
-    store.preferences.primaryLanguage,
+    langPrefs.primaryLanguage,
   )
   const needsTranslation = useMemo(
     () =>
       Boolean(
-        store.preferences.primaryLanguage &&
-          !isPostInLanguage(item.post, [store.preferences.primaryLanguage]),
+        langPrefs.primaryLanguage &&
+          !isPostInLanguage(post, [langPrefs.primaryLanguage]),
       ),
-    [item.post, store.preferences.primaryLanguage],
+    [post, langPrefs.primaryLanguage],
   )
 
   const onPressReply = React.useCallback(() => {
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record?.text as string,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
       onPost: onPostReply,
     })
-  }, [store, item, record, onPostReply])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(async () => {
-    try {
-      await item.toggleThreadMute()
-      if (item.isThreadMuted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [item])
-
-  const onDeletePost = React.useCallback(() => {
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [item])
+  }, [openComposer, post, record, onPostReply])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -164,22 +210,10 @@ export const PostThreadItem = observer(function PostThreadItem({
     return <ErrorMessage message="Invalid or unsupported post record" />
   }
 
-  if (deleted) {
-    return (
-      <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
-        <FontAwesomeIcon
-          icon={['far', 'trash-can']}
-          style={pal.icon as FontAwesomeIconStyle}
-        />
-        <Text style={[pal.textLight, s.ml10]}>This post has been deleted.</Text>
-      </View>
-    )
-  }
-
-  if (item._isHighlightedPost) {
+  if (isHighlightedPost) {
     return (
       <>
-        {item.rootUri !== item.uri && (
+        {rootUri !== post.uri && (
           <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
             <View style={{width: 38}}>
               <View
@@ -196,7 +230,7 @@ export const PostThreadItem = observer(function PostThreadItem({
         )}
 
         <Link
-          testID={`postThreadItem-by-${item.post.author.handle}`}
+          testID={`postThreadItem-by-${post.author.handle}`}
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
           noFeedback
           accessible={false}>
@@ -205,10 +239,10 @@ export const PostThreadItem = observer(function PostThreadItem({
             <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
               <PreviewableUserAvatar
                 size={52}
-                did={item.post.author.did}
-                handle={item.post.author.handle}
-                avatar={item.post.author.avatar}
-                moderation={item.moderation.avatar}
+                did={post.author.did}
+                handle={post.author.handle}
+                avatar={post.author.avatar}
+                moderation={moderation.avatar}
               />
             </View>
             <View style={styles.layoutContent}>
@@ -225,17 +259,17 @@ export const PostThreadItem = observer(function PostThreadItem({
                       numberOfLines={1}
                       lineHeight={1.2}>
                       {sanitizeDisplayName(
-                        item.post.author.displayName ||
-                          sanitizeHandle(item.post.author.handle),
+                        post.author.displayName ||
+                          sanitizeHandle(post.author.handle),
                       )}
                     </Text>
                   </Link>
-                  <TimeElapsed timestamp={item.post.indexedAt}>
+                  <TimeElapsed timestamp={post.indexedAt}>
                     {({timeElapsed}) => (
                       <Text
                         type="md"
                         style={[styles.metaItem, pal.textLight]}
-                        title={niceDate(item.post.indexedAt)}>
+                        title={niceDate(post.indexedAt)}>
                         &middot;&nbsp;{timeElapsed}
                       </Text>
                     )}
@@ -272,23 +306,15 @@ export const PostThreadItem = observer(function PostThreadItem({
                   href={authorHref}
                   title={authorTitle}>
                   <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-                    {sanitizeHandle(item.post.author.handle, '@')}
+                    {sanitizeHandle(post.author.handle, '@')}
                   </Text>
                 </Link>
               </View>
             </View>
             <PostDropdownBtn
               testID="postDropdownBtn"
-              itemUri={itemUri}
-              itemCid={itemCid}
-              itemHref={itemHref}
-              itemTitle={itemTitle}
-              isAuthor={item.post.author.did === store.me.did}
-              isThreadMuted={item.isThreadMuted}
-              onCopyPostText={onCopyPostText}
-              onOpenTranslate={onOpenTranslate}
-              onToggleThreadMute={onToggleThreadMute}
-              onDeletePost={onDeletePost}
+              post={post}
+              record={record}
               style={{
                 paddingVertical: 6,
                 paddingHorizontal: 10,
@@ -299,16 +325,16 @@ export const PostThreadItem = observer(function PostThreadItem({
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
             <ContentHider
-              moderation={item.moderation.content}
+              moderation={moderation.content}
               ignoreMute
               style={styles.contentHider}
               childContainerStyle={styles.contentHiderChild}>
               <PostAlerts
-                moderation={item.moderation.content}
+                moderation={moderation.content}
                 includeMute
                 style={styles.alert}
               />
-              {item.richText?.text ? (
+              {richText?.text ? (
                 <View
                   style={[
                     styles.postTextContainer,
@@ -316,59 +342,56 @@ export const PostThreadItem = observer(function PostThreadItem({
                   ]}>
                   <RichText
                     type="post-text-lg"
-                    richText={item.richText}
+                    richText={richText}
                     lineHeight={1.3}
                     style={s.flex1}
                   />
                 </View>
               ) : undefined}
-              {item.post.embed && (
+              {post.embed && (
                 <ContentHider
-                  moderation={item.moderation.embed}
-                  ignoreMute={isEmbedByEmbedder(
-                    item.post.embed,
-                    item.post.author.did,
-                  )}
+                  moderation={moderation.embed}
+                  ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
                   style={s.mb10}>
                   <PostEmbeds
-                    embed={item.post.embed}
-                    moderation={item.moderation.embed}
+                    embed={post.embed}
+                    moderation={moderation.embed}
                   />
                 </ContentHider>
               )}
             </ContentHider>
             <ExpandedPostDetails
-              post={item.post}
+              post={post}
               translatorUrl={translatorUrl}
               needsTranslation={needsTranslation}
             />
             {hasEngagement ? (
               <View style={[styles.expandedInfo, pal.border]}>
-                {item.post.repostCount ? (
+                {post.repostCount ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={repostsHref}
                     title={repostsTitle}>
                     <Text testID="repostCount" type="lg" style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(item.post.repostCount)}
+                        {formatCount(post.repostCount)}
                       </Text>{' '}
-                      {pluralize(item.post.repostCount, 'repost')}
+                      {pluralize(post.repostCount, 'repost')}
                     </Text>
                   </Link>
                 ) : (
                   <></>
                 )}
-                {item.post.likeCount ? (
+                {post.likeCount ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={likesHref}
                     title={likesTitle}>
                     <Text testID="likeCount" type="lg" style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(item.post.likeCount)}
+                        {formatCount(post.likeCount)}
                       </Text>{' '}
-                      {pluralize(item.post.likeCount, 'like')}
+                      {pluralize(post.likeCount, 'like')}
                     </Text>
                   </Link>
                 ) : (
@@ -381,24 +404,9 @@ export const PostThreadItem = observer(function PostThreadItem({
             <View style={[s.pl10, s.pb5]}>
               <PostCtrls
                 big
-                itemUri={itemUri}
-                itemCid={itemCid}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                author={item.post.author}
-                text={item.richText?.text || record.text}
-                indexedAt={item.post.indexedAt}
-                isAuthor={item.post.author.did === store.me.did}
-                isReposted={!!item.post.viewer?.repost}
-                isLiked={!!item.post.viewer?.like}
-                isThreadMuted={item.isThreadMuted}
+                post={post}
+                record={record}
                 onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleLike={onPressToggleLike}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
               />
             </View>
           </View>
@@ -406,17 +414,19 @@ export const PostThreadItem = observer(function PostThreadItem({
       </>
     )
   } else {
-    const isThreadedChild = treeView && item._depth > 1
+    const isThreadedChild = treeView && depth > 1
     return (
       <PostOuterWrapper
-        item={item}
-        hasPrecedingItem={hasPrecedingItem}
-        treeView={treeView}>
+        post={post}
+        depth={depth}
+        showParentReplyLine={!!showParentReplyLine}
+        treeView={treeView}
+        hasPrecedingItem={hasPrecedingItem}>
         <PostHider
-          testID={`postThreadItem-by-${item.post.author.handle}`}
-          href={itemHref}
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
           style={[pal.view]}
-          moderation={item.moderation.content}>
+          moderation={moderation.content}>
           <PostSandboxWarning />
 
           <View
@@ -427,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
               height: isThreadedChild ? 8 : 16,
             }}>
             <View style={{width: 38}}>
-              {!isThreadedChild && item._showParentReplyLine && (
+              {!isThreadedChild && showParentReplyLine && (
                 <View
                   style={[
                     styles.replyLine,
@@ -446,21 +456,20 @@ export const PostThreadItem = observer(function PostThreadItem({
             style={[
               styles.layout,
               {
-                paddingBottom:
-                  item._showChildReplyLine && !isThreadedChild ? 0 : 8,
+                paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8,
               },
             ]}>
             {!isThreadedChild && (
               <View style={styles.layoutAvi}>
                 <PreviewableUserAvatar
                   size={38}
-                  did={item.post.author.did}
-                  handle={item.post.author.handle}
-                  avatar={item.post.author.avatar}
-                  moderation={item.moderation.avatar}
+                  did={post.author.did}
+                  handle={post.author.handle}
+                  avatar={post.author.avatar}
+                  moderation={moderation.avatar}
                 />
 
-                {item._showChildReplyLine && (
+                {showChildReplyLine && (
                   <View
                     style={[
                       styles.replyLine,
@@ -477,10 +486,10 @@ export const PostThreadItem = observer(function PostThreadItem({
 
             <View style={styles.layoutContent}>
               <PostMeta
-                author={item.post.author}
-                authorHasWarning={!!item.post.author.labels?.length}
-                timestamp={item.post.indexedAt}
-                postHref={itemHref}
+                author={post.author}
+                authorHasWarning={!!post.author.labels?.length}
+                timestamp={post.indexedAt}
+                postHref={postHref}
                 showAvatar={isThreadedChild}
                 avatarSize={26}
                 displayNameType="md-bold"
@@ -488,14 +497,14 @@ export const PostThreadItem = observer(function PostThreadItem({
                 style={isThreadedChild && s.mb5}
               />
               <PostAlerts
-                moderation={item.moderation.content}
+                moderation={moderation.content}
                 style={styles.alert}
               />
-              {item.richText?.text ? (
+              {richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
                     type="post-text"
-                    richText={item.richText}
+                    richText={richText}
                     style={[pal.text, s.flex1]}
                     lineHeight={1.3}
                     numberOfLines={limitLines ? MAX_POST_LINES : undefined}
@@ -510,42 +519,24 @@ export const PostThreadItem = observer(function PostThreadItem({
                   href="#"
                 />
               ) : undefined}
-              {item.post.embed && (
+              {post.embed && (
                 <ContentHider
                   style={styles.contentHider}
-                  moderation={item.moderation.embed}>
+                  moderation={moderation.embed}>
                   <PostEmbeds
-                    embed={item.post.embed}
-                    moderation={item.moderation.embed}
+                    embed={post.embed}
+                    moderation={moderation.embed}
                   />
                 </ContentHider>
               )}
               <PostCtrls
-                itemUri={itemUri}
-                itemCid={itemCid}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                author={item.post.author}
-                text={item.richText?.text || record.text}
-                indexedAt={item.post.indexedAt}
-                isAuthor={item.post.author.did === store.me.did}
-                replyCount={item.post.replyCount}
-                repostCount={item.post.repostCount}
-                likeCount={item.post.likeCount}
-                isReposted={!!item.post.viewer?.repost}
-                isLiked={!!item.post.viewer?.like}
-                isThreadMuted={item.isThreadMuted}
+                post={post}
+                record={record}
                 onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleLike={onPressToggleLike}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
               />
             </View>
           </View>
-          {item._hasMore ? (
+          {hasMore ? (
             <Link
               style={[
                 styles.loadMore,
@@ -555,7 +546,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                   paddingBottom: treeView ? 4 : 12,
                 },
               ]}
-              href={itemHref}
+              href={postHref}
               title={itemTitle}
               noFeedback>
               <Text type="sm-medium" style={pal.textLight}>
@@ -572,22 +563,27 @@ export const PostThreadItem = observer(function PostThreadItem({
       </PostOuterWrapper>
     )
   }
-})
+}
+PostThreadItemLoaded = memo(PostThreadItemLoaded)
 
 function PostOuterWrapper({
-  item,
-  hasPrecedingItem,
+  post,
   treeView,
+  depth,
+  showParentReplyLine,
+  hasPrecedingItem,
   children,
 }: React.PropsWithChildren<{
-  item: PostThreadItemModel
-  hasPrecedingItem: boolean
+  post: AppBskyFeedDefs.PostView
   treeView: boolean
+  depth: number
+  showParentReplyLine: boolean
+  hasPrecedingItem: boolean
 }>) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
   const styles = useStyles()
-  if (treeView && item._depth > 1) {
+  if (treeView && depth > 1) {
     return (
       <View
         style={[
@@ -597,13 +593,13 @@ function PostOuterWrapper({
           {
             flexDirection: 'row',
             paddingLeft: 20,
-            borderTopWidth: item._depth === 1 ? 1 : 0,
-            paddingTop: item._depth === 1 ? 8 : 0,
+            borderTopWidth: depth === 1 ? 1 : 0,
+            paddingTop: depth === 1 ? 8 : 0,
           },
         ]}>
-        {Array.from(Array(item._depth - 1)).map((_, n: number) => (
+        {Array.from(Array(depth - 1)).map((_, n: number) => (
           <View
-            key={`${item.uri}-padding-${n}`}
+            key={`${post.uri}-padding-${n}`}
             style={{
               borderLeftWidth: 2,
               borderLeftColor: pal.colors.border,
@@ -622,7 +618,7 @@ function PostOuterWrapper({
         styles.outer,
         pal.view,
         pal.border,
-        item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
+        showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
         styles.cursor,
       ]}>
       {children}
@@ -640,14 +636,17 @@ function ExpandedPostDetails({
   translatorUrl: string
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <View style={[s.flexRow, s.mt2, s.mb10]}>
       <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
       {needsTranslation && (
         <>
-          <Text style={pal.textLight}> • </Text>
-          <Link href={translatorUrl} title="Translate">
-            <Text style={pal.link}>Translate</Text>
+          <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text>
+          <Link href={translatorUrl} title={_(msg`Translate`)}>
+            <Text style={pal.link}>
+              <Trans>Translate</Trans>
+            </Text>
           </Link>
         </>
       )}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 4ec9db77f..2e8019e71 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -1,19 +1,14 @@
-import React, {useState} from 'react'
+import React, {useState, useMemo} from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {
-  ActivityIndicator,
-  Linking,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {AppBskyFeedPost as FeedPost} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri} from '@atproto/api'
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  moderatePost,
+  PostModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {Link, TextLink} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
@@ -23,169 +18,109 @@ import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
-import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useComposerControls} from '#/state/shell/composer'
+import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const Post = observer(function PostImpl({
-  view,
+export function Post({
+  post,
   showReplyLine,
-  hideError,
   style,
 }: {
-  view: PostThreadModel
+  post: AppBskyFeedDefs.PostView
   showReplyLine?: boolean
-  hideError?: boolean
   style?: StyleProp<ViewStyle>
 }) {
-  const pal = usePalette('default')
-  const [deleted, setDeleted] = useState(false)
-
-  // deleted
-  // =
-  if (deleted) {
-    return <View />
-  }
-
-  // loading
-  // =
-  if (!view.hasContent && view.isLoading) {
-    return (
-      <View style={pal.view}>
-        <ActivityIndicator />
-      </View>
-    )
+  const moderationOpts = useModerationOpts()
+  const record = useMemo<AppBskyFeedPost.Record | undefined>(
+    () =>
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+        ? post.record
+        : undefined,
+    [post],
+  )
+  const postShadowed = usePostShadow(post)
+  const richText = useMemo(
+    () =>
+      record
+        ? new RichTextAPI({
+            text: record.text,
+            facets: record.facets,
+          })
+        : undefined,
+    [record],
+  )
+  const moderation = useMemo(
+    () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined),
+    [moderationOpts, post],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return null
   }
-
-  // error
-  // =
-  if (view.hasError || !view.thread || !view.thread?.postRecord) {
-    if (hideError) {
-      return <View />
-    }
+  if (record && richText && moderation) {
     return (
-      <View style={pal.view}>
-        <Text>{view.error || 'Thread not found'}</Text>
-      </View>
+      <PostInner
+        post={postShadowed}
+        record={record}
+        richText={richText}
+        moderation={moderation}
+        showReplyLine={showReplyLine}
+        style={style}
+      />
     )
   }
+  return null
+}
 
-  // loaded
-  // =
-
-  return (
-    <PostLoaded
-      item={view.thread}
-      record={view.thread.postRecord}
-      setDeleted={setDeleted}
-      showReplyLine={showReplyLine}
-      style={style}
-    />
-  )
-})
-
-const PostLoaded = observer(function PostLoadedImpl({
-  item,
+function PostInner({
+  post,
   record,
-  setDeleted,
+  richText,
+  moderation,
   showReplyLine,
   style,
 }: {
-  item: PostThreadItemModel
-  record: FeedPost.Record
-  setDeleted: (v: boolean) => void
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  moderation: PostModeration
   showReplyLine?: boolean
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
-  const store = useStores()
-  const [limitLines, setLimitLines] = React.useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+  const {openComposer} = useComposerControls()
+  const [limitLines, setLimitLines] = useState(
+    () => countLines(richText?.text) >= MAX_POST_LINES,
   )
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemUrip = new AtUri(item.post.uri)
-  const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
-  const itemTitle = `Post by ${item.post.author.handle}`
+  const itemUrip = new AtUri(post.uri)
+  const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
   let replyAuthorDid = ''
   if (record.reply) {
     const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
     replyAuthorDid = urip.hostname
   }
 
-  const translatorUrl = getTranslatorLink(
-    record?.text || '',
-    store.preferences.primaryLanguage,
-  )
-
   const onPressReply = React.useCallback(() => {
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record.text as string,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
     })
-  }, [store, item, record])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record.text)
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(async () => {
-    try {
-      await item.toggleThreadMute()
-      if (item.isThreadMuted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [item])
-
-  const onDeletePost = React.useCallback(() => {
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [item, setDeleted])
+  }, [openComposer, post, record])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -198,17 +133,17 @@ const PostLoaded = observer(function PostLoadedImpl({
         <View style={styles.layoutAvi}>
           <PreviewableUserAvatar
             size={52}
-            did={item.post.author.did}
-            handle={item.post.author.handle}
-            avatar={item.post.author.avatar}
-            moderation={item.moderation.avatar}
+            did={post.author.did}
+            handle={post.author.handle}
+            avatar={post.author.avatar}
+            moderation={moderation.avatar}
           />
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
-            author={item.post.author}
-            authorHasWarning={!!item.post.author.labels?.length}
-            timestamp={item.post.indexedAt}
+            author={post.author}
+            authorHasWarning={!!post.author.labels?.length}
+            timestamp={post.indexedAt}
             postHref={itemHref}
           />
           {replyAuthorDid !== '' && (
@@ -234,19 +169,16 @@ const PostLoaded = observer(function PostLoadedImpl({
             </View>
           )}
           <ContentHider
-            moderation={item.moderation.content}
+            moderation={moderation.content}
             style={styles.contentHider}
             childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts
-              moderation={item.moderation.content}
-              style={styles.alert}
-            />
-            {item.richText?.text ? (
+            <PostAlerts moderation={moderation.content} style={styles.alert} />
+            {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
                   testID="postText"
                   type="post-text"
-                  richText={item.richText}
+                  richText={richText}
                   lineHeight={1.3}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={s.flex1}
@@ -261,45 +193,20 @@ const PostLoaded = observer(function PostLoadedImpl({
                 href="#"
               />
             ) : undefined}
-            {item.post.embed ? (
+            {post.embed ? (
               <ContentHider
-                moderation={item.moderation.embed}
+                moderation={moderation.embed}
                 style={styles.contentHider}>
-                <PostEmbeds
-                  embed={item.post.embed}
-                  moderation={item.moderation.embed}
-                />
+                <PostEmbeds embed={post.embed} moderation={moderation.embed} />
               </ContentHider>
             ) : null}
           </ContentHider>
-          <PostCtrls
-            itemUri={itemUri}
-            itemCid={itemCid}
-            itemHref={itemHref}
-            itemTitle={itemTitle}
-            author={item.post.author}
-            indexedAt={item.post.indexedAt}
-            text={item.richText?.text || record.text}
-            isAuthor={item.post.author.did === store.me.did}
-            replyCount={item.post.replyCount}
-            repostCount={item.post.repostCount}
-            likeCount={item.post.likeCount}
-            isReposted={!!item.post.viewer?.repost}
-            isLiked={!!item.post.viewer?.like}
-            isThreadMuted={item.isThreadMuted}
-            onPressReply={onPressReply}
-            onPressToggleRepost={onPressToggleRepost}
-            onPressToggleLike={onPressToggleLike}
-            onCopyPostText={onCopyPostText}
-            onOpenTranslate={onOpenTranslate}
-            onToggleThreadMute={onToggleThreadMute}
-            onDeletePost={onDeletePost}
-          />
+          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
         </View>
       </View>
     </Link>
   )
-})
+}
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 1ecb14912..f0f7cd919 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -1,7 +1,7 @@
-import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {memo, MutableRefObject} from 'react'
 import {
   ActivityIndicator,
+  Dimensions,
   RefreshControl,
   StyleProp,
   StyleSheet,
@@ -11,26 +11,36 @@ import {
 import {FlatList} from '../util/Views'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {FeedErrorMessage} from './FeedErrorMessage'
-import {PostsFeedModel} from 'state/models/feeds/posts'
 import {FeedSlice} from './FeedSlice'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
-import {s} from 'lib/styles'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useTheme} from 'lib/ThemeContext'
 import {logger} from '#/logger'
+import {
+  FeedDescriptor,
+  FeedParams,
+  usePostFeedQuery,
+  pollLatest,
+} from '#/state/queries/post-feed'
+import {useModerationOpts} from '#/state/queries/preferences'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const Feed = observer(function Feed({
+let Feed = ({
   feed,
+  feedParams,
   style,
+  enabled,
+  pollInterval,
   scrollElRef,
   onScroll,
+  onHasNew,
   scrollEventThrottle,
   renderEmptyState,
   renderEndOfFeed,
@@ -40,10 +50,14 @@ export const Feed = observer(function Feed({
   ListHeaderComponent,
   extraData,
 }: {
-  feed: PostsFeedModel
+  feed: FeedDescriptor
+  feedParams?: FeedParams
   style?: StyleProp<ViewStyle>
+  enabled?: boolean
+  pollInterval?: number
   scrollElRef?: MutableRefObject<FlatList<any> | null>
-  onScroll?: OnScrollCb
+  onHasNew?: (v: boolean) => void
+  onScroll?: OnScrollHandler
   scrollEventThrottle?: number
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
@@ -52,70 +66,110 @@ export const Feed = observer(function Feed({
   desktopFixedHeightOffset?: number
   ListHeaderComponent?: () => JSX.Element
   extraData?: any
-}) {
+}): React.ReactNode => {
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const checkForNewRef = React.useRef<(() => void) | null>(null)
+
+  const moderationOpts = useModerationOpts()
+  const opts = React.useMemo(() => ({enabled}), [enabled])
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+  } = usePostFeedQuery(feed, feedParams, opts)
+  const isEmpty = !isFetching && !data?.pages[0]?.slices.length
+
+  const checkForNew = React.useCallback(async () => {
+    if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
+      return
+    }
+    try {
+      if (await pollLatest(data.pages[0])) {
+        onHasNew(true)
+      }
+    } catch (e) {
+      logger.error('Poll latest failed', {feed, error: String(e)})
+    }
+  }, [feed, data, isFetching, onHasNew, enabled])
+
+  React.useEffect(() => {
+    // we store the interval handler in a ref to avoid needless
+    // reassignments of the interval
+    checkForNewRef.current = checkForNew
+  }, [checkForNew])
+  React.useEffect(() => {
+    if (!pollInterval) {
+      return
+    }
+    const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
+    return () => clearInterval(i)
+  }, [pollInterval])
 
-  const data = React.useMemo(() => {
-    let feedItems: any[] = []
-    if (feed.hasLoaded) {
-      if (feed.hasError) {
-        feedItems = feedItems.concat([ERROR_ITEM])
+  const feedItems = React.useMemo(() => {
+    let arr: any[] = []
+    if (isFetched && moderationOpts) {
+      if (isError && isEmpty) {
+        arr = arr.concat([ERROR_ITEM])
       }
-      if (feed.isEmpty) {
-        feedItems = feedItems.concat([EMPTY_FEED_ITEM])
-      } else {
-        feedItems = feedItems.concat(feed.slices)
+      if (isEmpty) {
+        arr = arr.concat([EMPTY_FEED_ITEM])
+      } else if (data) {
+        for (const page of data?.pages) {
+          arr = arr.concat(page.slices)
+        }
       }
-      if (feed.loadMoreError) {
-        feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
+      if (isError && !isEmpty) {
+        arr = arr.concat([LOAD_MORE_ERROR_ITEM])
       }
     } else {
-      feedItems.push(LOADING_ITEM)
+      arr.push(LOADING_ITEM)
     }
-    return feedItems
-  }, [
-    feed.hasError,
-    feed.hasLoaded,
-    feed.isEmpty,
-    feed.slices,
-    feed.loadMoreError,
-  ])
+    return arr
+  }, [isFetched, isError, isEmpty, data, moderationOpts])
 
   // events
   // =
 
   const onRefresh = React.useCallback(async () => {
     track('Feed:onRefresh')
-    setIsRefreshing(true)
+    setIsPTRing(true)
     try {
-      await feed.refresh()
+      await refetch()
+      onHasNew?.(false)
     } catch (err) {
       logger.error('Failed to refresh posts feed', {error: err})
     }
-    setIsRefreshing(false)
-  }, [feed, track, setIsRefreshing])
+    setIsPTRing(false)
+  }, [refetch, track, setIsPTRing, onHasNew])
 
   const onEndReached = React.useCallback(async () => {
-    if (!feed.hasLoaded || !feed.hasMore) return
+    if (isFetching || !hasNextPage || isError) return
 
     track('Feed:onEndReached')
     try {
-      await feed.loadMore()
+      await fetchNextPage()
     } catch (err) {
       logger.error('Failed to load more posts', {error: err})
     }
-  }, [feed, track])
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
 
   const onPressTryAgain = React.useCallback(() => {
-    feed.refresh()
-  }, [feed])
+    refetch()
+    onHasNew?.(false)
+  }, [refetch, onHasNew])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    feed.retryLoadMore()
-  }, [feed])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   // rendering
   // =
@@ -126,7 +180,11 @@ export const Feed = observer(function Feed({
         return renderEmptyState()
       } else if (item === ERROR_ITEM) {
         return (
-          <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} />
+          <FeedErrorMessage
+            feedDesc={feed}
+            error={error}
+            onPressTryAgain={onPressTryAgain}
+          />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
@@ -138,47 +196,65 @@ export const Feed = observer(function Feed({
       } else if (item === LOADING_ITEM) {
         return <PostFeedLoadingPlaceholder />
       }
-      return <FeedSlice slice={item} />
+      return (
+        <FeedSlice
+          slice={item}
+          // we check for this before creating the feedItems array
+          moderationOpts={moderationOpts!}
+        />
+      )
     },
-    [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
+    [
+      feed,
+      error,
+      onPressTryAgain,
+      onPressRetryLoadMore,
+      renderEmptyState,
+      moderationOpts,
+    ],
   )
 
+  const shouldRenderEndOfFeed =
+    !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
   const FeedFooter = React.useCallback(
     () =>
-      feed.isLoadingMore ? (
+      isFetchingNextPage ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
-      ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
+      ) : shouldRenderEndOfFeed ? (
         renderEndOfFeed()
       ) : (
         <View />
       ),
-    [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
+    [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed],
   )
 
+  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View testID={testID} style={style}>
       <FlatList
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
-        data={data}
+        data={feedItems}
         keyExtractor={item => item._reactKey}
         renderItem={renderItem}
         ListFooterComponent={FeedFooter}
         ListHeaderComponent={ListHeaderComponent}
         refreshControl={
           <RefreshControl
-            refreshing={isRefreshing}
+            refreshing={isPTRing}
             onRefresh={onRefresh}
             tintColor={pal.colors.text}
             titleColor={pal.colors.text}
             progressViewOffset={headerOffset}
           />
         }
-        contentContainerStyle={s.contentContainer}
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
         style={{paddingTop: headerOffset}}
-        onScroll={onScroll}
+        onScroll={onScroll != null ? scrollHandler : undefined}
         scrollEventThrottle={scrollEventThrottle}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         onEndReached={onEndReached}
@@ -193,7 +269,9 @@ export const Feed = observer(function Feed({
       />
     </View>
   )
-})
+}
+Feed = memo(Feed)
+export {Feed}
 
 const styles = StyleSheet.create({
   feedFooter: {paddingTop: 20},
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index 9e75d9507..63d9d5956 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
-import {PostsFeedModel, KnownError} from 'state/models/feeds/posts'
+import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
@@ -9,67 +8,118 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useStores} from 'state/index'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {msg as msgLingui} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {EmptyState} from '../util/EmptyState'
+import {cleanError} from '#/lib/strings/errors'
+import {useRemoveFeedMutation} from '#/state/queries/preferences'
 
-const MESSAGES = {
-  [KnownError.Unknown]: '',
-  [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`,
-  [KnownError.FeedgenMisconfigured]:
-    'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.',
-  [KnownError.FeedgenBadResponse]:
-    'Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.',
-  [KnownError.FeedgenOffline]:
-    'Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.',
-  [KnownError.FeedgenUnknown]:
-    'Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.',
+export enum KnownError {
+  Block = 'Block',
+  FeedgenDoesNotExist = 'FeedgenDoesNotExist',
+  FeedgenMisconfigured = 'FeedgenMisconfigured',
+  FeedgenBadResponse = 'FeedgenBadResponse',
+  FeedgenOffline = 'FeedgenOffline',
+  FeedgenUnknown = 'FeedgenUnknown',
+  FeedNSFPublic = 'FeedNSFPublic',
+  Unknown = 'Unknown',
 }
 
 export function FeedErrorMessage({
-  feed,
+  feedDesc,
+  error,
   onPressTryAgain,
 }: {
-  feed: PostsFeedModel
+  feedDesc: FeedDescriptor
+  error: any
   onPressTryAgain: () => void
 }) {
+  const knownError = React.useMemo(
+    () => detectKnownError(feedDesc, error),
+    [feedDesc, error],
+  )
   if (
-    typeof feed.knownError === 'undefined' ||
-    feed.knownError === KnownError.Unknown
+    typeof knownError !== 'undefined' &&
+    knownError !== KnownError.Unknown &&
+    feedDesc.startsWith('feedgen')
   ) {
+    return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} />
+  }
+
+  if (knownError === KnownError.Block) {
     return (
-      <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} />
+      <EmptyState
+        icon="ban"
+        message="Posts hidden"
+        style={{paddingVertical: 40}}
+      />
     )
   }
 
-  return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} />
+  return (
+    <ErrorMessage
+      message={cleanError(error)}
+      onPressTryAgain={onPressTryAgain}
+    />
+  )
 }
 
 function FeedgenErrorMessage({
-  feed,
+  feedDesc,
   knownError,
 }: {
-  feed: PostsFeedModel
+  feedDesc: FeedDescriptor
   knownError: KnownError
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_: _l} = useLingui()
   const navigation = useNavigation<NavigationProp>()
-  const msg = MESSAGES[knownError]
-  const uri = (feed.params as GetCustomFeed.QueryParams).feed
+  const msg = React.useMemo(
+    () =>
+      ({
+        [KnownError.Unknown]: '',
+        [KnownError.Block]: '',
+        [KnownError.FeedgenDoesNotExist]: _l(
+          msgLingui`Hmmm, we're having trouble finding this feed. It may have been deleted.`,
+        ),
+        [KnownError.FeedgenMisconfigured]: _l(
+          msgLingui`Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.`,
+        ),
+        [KnownError.FeedgenBadResponse]: _l(
+          msgLingui`Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.`,
+        ),
+        [KnownError.FeedgenOffline]: _l(
+          msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`,
+        ),
+        [KnownError.FeedNSFPublic]: _l(
+          msgLingui`We're sorry, but this content is not viewable without a Bluesky account.`,
+        ),
+        [KnownError.FeedgenUnknown]: _l(
+          msgLingui`Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.`,
+        ),
+      }[knownError]),
+    [_l, knownError],
+  )
+  const [_, uri] = feedDesc.split('|')
   const [ownerDid] = safeParseFeedgenUri(uri)
+  const {openModal, closeModal} = useModalControls()
+  const {mutateAsync: removeFeed} = useRemoveFeedMutation()
 
   const onViewProfile = React.useCallback(() => {
     navigation.navigate('Profile', {name: ownerDid})
   }, [navigation, ownerDid])
 
   const onRemoveFeed = React.useCallback(async () => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Remove feed',
-      message: 'Remove this feed from your saved feeds?',
+      title: _l(msgLingui`Remove feed`),
+      message: _l(msgLingui`Remove this feed from your saved feeds?`),
       async onPressConfirm() {
         try {
-          await store.preferences.removeSavedFeed(uri)
+          await removeFeed({uri})
         } catch (err) {
           Toast.show(
             'There was an an issue removing this feed. Please check your internet connection and try again.',
@@ -78,10 +128,40 @@ function FeedgenErrorMessage({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, uri])
+  }, [openModal, closeModal, uri, removeFeed, _l])
+
+  const cta = React.useMemo(() => {
+    switch (knownError) {
+      case KnownError.FeedNSFPublic: {
+        return null
+      }
+      case KnownError.FeedgenDoesNotExist:
+      case KnownError.FeedgenMisconfigured:
+      case KnownError.FeedgenBadResponse:
+      case KnownError.FeedgenOffline:
+      case KnownError.FeedgenUnknown: {
+        return (
+          <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
+            {knownError === KnownError.FeedgenDoesNotExist && (
+              <Button
+                type="inverted"
+                label="Remove feed"
+                onPress={onRemoveFeed}
+              />
+            )}
+            <Button
+              type="default-light"
+              label="View profile"
+              onPress={onViewProfile}
+            />
+          </View>
+        )
+      }
+    }
+  }, [knownError, onViewProfile, onRemoveFeed])
 
   return (
     <View
@@ -96,16 +176,7 @@ function FeedgenErrorMessage({
         },
       ]}>
       <Text style={pal.text}>{msg}</Text>
-      <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
-        {knownError === KnownError.FeedgenDoesNotExist && (
-          <Button type="inverted" label="Remove feed" onPress={onRemoveFeed} />
-        )}
-        <Button
-          type="default-light"
-          label="View profile"
-          onPress={onViewProfile}
-        />
-      </View>
+      {cta}
     </View>
   )
 }
@@ -118,3 +189,48 @@ function safeParseFeedgenUri(uri: string): [string, string] {
     return ['', '']
   }
 }
+
+function detectKnownError(
+  feedDesc: FeedDescriptor,
+  error: any,
+): KnownError | undefined {
+  if (!error) {
+    return undefined
+  }
+  if (
+    error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError ||
+    error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError
+  ) {
+    return KnownError.Block
+  }
+  if (typeof error !== 'string') {
+    error = error.toString()
+  }
+  if (!feedDesc.startsWith('feedgen')) {
+    return KnownError.Unknown
+  }
+  if (error.includes('could not find feed')) {
+    return KnownError.FeedgenDoesNotExist
+  }
+  if (error.includes('feed unavailable')) {
+    return KnownError.FeedgenOffline
+  }
+  if (error.includes('invalid did document')) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (error.includes('could not resolve did document')) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (
+    error.includes('invalid feed generator service details in did document')
+  ) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (error.includes('feed provided an invalid response')) {
+    return KnownError.FeedgenBadResponse
+  }
+  if (error.includes(KnownError.FeedNSFPublic)) {
+    return KnownError.FeedNSFPublic
+  }
+  return KnownError.FeedgenUnknown
+}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index aeee3e20a..dfb0cfcf6 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -1,14 +1,17 @@
-import React, {useMemo, useState} from 'react'
-import {observer} from 'mobx-react-lite'
-import {Linking, StyleSheet, View} from 'react-native'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri} from '@atproto/api'
+import React, {memo, useMemo, useState} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  PostModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {PostsFeedItemModel} from 'state/models/feeds/post'
-import {FeedSourceInfo} from 'lib/api/feed/types'
+import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types'
 import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
@@ -19,50 +22,96 @@ import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
 import {RichText} from '../util/text/RichText'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {isEmbedByEmbedder} from 'lib/embeds'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
-import {logger} from '#/logger'
+import {useComposerControls} from '#/state/shell/composer'
+import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const FeedItem = observer(function FeedItemImpl({
-  item,
-  source,
+export function FeedItem({
+  post,
+  record,
+  reason,
+  moderation,
   isThreadChild,
   isThreadLastChild,
   isThreadParent,
 }: {
-  item: PostsFeedItemModel
-  source?: FeedSourceInfo
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  moderation: PostModeration
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
-  showReplyLine?: boolean
 }) {
-  const store = useStores()
+  const postShadowed = usePostShadow(post)
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return null
+  }
+  if (richText && moderation) {
+    return (
+      <FeedItemInner
+        post={postShadowed}
+        record={record}
+        reason={reason}
+        richText={richText}
+        moderation={moderation}
+        isThreadChild={isThreadChild}
+        isThreadLastChild={isThreadLastChild}
+        isThreadParent={isThreadParent}
+      />
+    )
+  }
+  return null
+}
+
+let FeedItemInner = ({
+  post,
+  record,
+  reason,
+  richText,
+  moderation,
+  isThreadChild,
+  isThreadLastChild,
+  isThreadParent,
+}: {
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
+  reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  richText: RichTextAPI
+  moderation: PostModeration
+  isThreadChild?: boolean
+  isThreadLastChild?: boolean
+  isThreadParent?: boolean
+}): React.ReactNode => {
+  const {openComposer} = useComposerControls()
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const [deleted, setDeleted] = useState(false)
   const [limitLines, setLimitLines] = useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+    () => countLines(richText.text) >= MAX_POST_LINES,
   )
-  const record = item.postRecord
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemHref = useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey)
-  }, [item.post.uri, item.post.author])
-  const itemTitle = `Post by ${item.post.author.handle}`
+
+  const href = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+
   const replyAuthorDid = useMemo(() => {
     if (!record?.reply) {
       return ''
@@ -70,77 +119,22 @@ export const FeedItem = observer(function FeedItemImpl({
     const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
     return urip.hostname
   }, [record?.reply])
-  const translatorUrl = getTranslatorLink(
-    record?.text || '',
-    store.preferences.primaryLanguage,
-  )
 
   const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
-    store.shell.openComposer({
+    openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record?.text || '',
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text || '',
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
     })
-  }, [item, track, record, store])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    track('FeedItem:PostRepost')
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [track, item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    track('FeedItem:PostLike')
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [track, item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(async () => {
-    track('FeedItem:ThreadMute')
-    try {
-      await item.toggleThreadMute()
-      if (item.isThreadMuted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [track, item])
-
-  const onDeletePost = React.useCallback(() => {
-    track('FeedItem:PostDelete')
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [track, item, setDeleted])
+  }, [post, record, track, openComposer])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -159,15 +153,11 @@ export const FeedItem = observer(function FeedItemImpl({
     isThreadChild ? styles.outerSmallTop : undefined,
   ]
 
-  if (!record || deleted) {
-    return <View />
-  }
-
   return (
     <Link
-      testID={`feedItem-by-${item.post.author.handle}`}
+      testID={`feedItem-by-${post.author.handle}`}
       style={outerStyles}
-      href={itemHref}
+      href={href}
       noFeedback
       accessible={false}>
       <PostSandboxWarning />
@@ -189,10 +179,10 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
 
         <View style={{paddingTop: 12, flexShrink: 1}}>
-          {source ? (
+          {isReasonFeedSource(reason) ? (
             <Link
-              title={sanitizeDisplayName(source.displayName)}
-              href={source.uri}>
+              title={sanitizeDisplayName(reason.displayName)}
+              href={reason.uri}>
               <Text
                 type="sm-bold"
                 style={pal.textLight}
@@ -204,17 +194,17 @@ export const FeedItem = observer(function FeedItemImpl({
                   style={pal.textLight}
                   lineHeight={1.2}
                   numberOfLines={1}
-                  text={sanitizeDisplayName(source.displayName)}
-                  href={source.uri}
+                  text={sanitizeDisplayName(reason.displayName)}
+                  href={reason.uri}
                 />
               </Text>
             </Link>
-          ) : item.reasonRepost ? (
+          ) : AppBskyFeedDefs.isReasonRepost(reason) ? (
             <Link
               style={styles.includeReason}
-              href={makeProfileLink(item.reasonRepost.by)}
+              href={makeProfileLink(reason.by)}
               title={`Reposted by ${sanitizeDisplayName(
-                item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
+                reason.by.displayName || reason.by.handle,
               )}`}>
               <FontAwesomeIcon
                 icon="retweet"
@@ -236,10 +226,9 @@ export const FeedItem = observer(function FeedItemImpl({
                   lineHeight={1.2}
                   numberOfLines={1}
                   text={sanitizeDisplayName(
-                    item.reasonRepost.by.displayName ||
-                      sanitizeHandle(item.reasonRepost.by.handle),
+                    reason.by.displayName || sanitizeHandle(reason.by.handle),
                   )}
-                  href={makeProfileLink(item.reasonRepost.by)}
+                  href={makeProfileLink(reason.by)}
                 />
               </Text>
             </Link>
@@ -251,10 +240,10 @@ export const FeedItem = observer(function FeedItemImpl({
         <View style={styles.layoutAvi}>
           <PreviewableUserAvatar
             size={52}
-            did={item.post.author.did}
-            handle={item.post.author.handle}
-            avatar={item.post.author.avatar}
-            moderation={item.moderation.avatar}
+            did={post.author.did}
+            handle={post.author.handle}
+            avatar={post.author.avatar}
+            moderation={moderation.avatar}
           />
           {isThreadParent && (
             <View
@@ -271,10 +260,10 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
-            author={item.post.author}
-            authorHasWarning={!!item.post.author.labels?.length}
-            timestamp={item.post.indexedAt}
-            postHref={itemHref}
+            author={post.author}
+            authorHasWarning={!!post.author.labels?.length}
+            timestamp={post.indexedAt}
+            postHref={href}
           />
           {!isThreadChild && replyAuthorDid !== '' && (
             <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -303,19 +292,16 @@ export const FeedItem = observer(function FeedItemImpl({
           )}
           <ContentHider
             testID="contentHider-post"
-            moderation={item.moderation.content}
+            moderation={moderation.content}
             ignoreMute
             childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts
-              moderation={item.moderation.content}
-              style={styles.alert}
-            />
-            {item.richText?.text ? (
+            <PostAlerts moderation={moderation.content} style={styles.alert} />
+            {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
                   testID="postText"
                   type="post-text"
-                  richText={item.richText}
+                  richText={richText}
                   lineHeight={1.3}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={s.flex1}
@@ -330,50 +316,23 @@ export const FeedItem = observer(function FeedItemImpl({
                 href="#"
               />
             ) : undefined}
-            {item.post.embed ? (
+            {post.embed ? (
               <ContentHider
                 testID="contentHider-embed"
-                moderation={item.moderation.embed}
-                ignoreMute={isEmbedByEmbedder(
-                  item.post.embed,
-                  item.post.author.did,
-                )}
+                moderation={moderation.embed}
+                ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
                 style={styles.embed}>
-                <PostEmbeds
-                  embed={item.post.embed}
-                  moderation={item.moderation.embed}
-                />
+                <PostEmbeds embed={post.embed} moderation={moderation.embed} />
               </ContentHider>
             ) : null}
           </ContentHider>
-          <PostCtrls
-            itemUri={itemUri}
-            itemCid={itemCid}
-            itemHref={itemHref}
-            itemTitle={itemTitle}
-            author={item.post.author}
-            text={item.richText?.text || record.text}
-            indexedAt={item.post.indexedAt}
-            isAuthor={item.post.author.did === store.me.did}
-            replyCount={item.post.replyCount}
-            repostCount={item.post.repostCount}
-            likeCount={item.post.likeCount}
-            isReposted={!!item.post.viewer?.repost}
-            isLiked={!!item.post.viewer?.like}
-            isThreadMuted={item.isThreadMuted}
-            onPressReply={onPressReply}
-            onPressToggleRepost={onPressToggleRepost}
-            onPressToggleLike={onPressToggleLike}
-            onCopyPostText={onCopyPostText}
-            onOpenTranslate={onOpenTranslate}
-            onToggleThreadMute={onToggleThreadMute}
-            onDeletePost={onDeletePost}
-          />
+          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
         </View>
       </View>
     </Link>
   )
-})
+}
+FeedItemInner = memo(FeedItemInner)
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 1d26f6cbd..a3bacdc1e 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,8 +1,7 @@
-import React from 'react'
+import React, {memo} from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
-import {AtUri} from '@atproto/api'
+import {FeedPostSlice} from '#/state/queries/post-feed'
+import {AtUri, moderatePost, ModerationOpts} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import Svg, {Circle, Line} from 'react-native-svg'
@@ -10,15 +9,27 @@ import {FeedItem} from './FeedItem'
 import {usePalette} from 'lib/hooks/usePalette'
 import {makeProfileLink} from 'lib/routes/links'
 
-export const FeedSlice = observer(function FeedSliceImpl({
+let FeedSlice = ({
   slice,
   ignoreFilterFor,
+  moderationOpts,
 }: {
-  slice: PostsFeedSliceModel
+  slice: FeedPostSlice
   ignoreFilterFor?: string
-}) {
-  if (slice.shouldFilter(ignoreFilterFor)) {
-    return null
+  moderationOpts: ModerationOpts
+}): React.ReactNode => {
+  const moderations = React.useMemo(() => {
+    return slice.items.map(item => moderatePost(item.post, moderationOpts))
+  }, [slice, moderationOpts])
+
+  // apply moderation filter
+  for (let i = 0; i < slice.items.length; i++) {
+    if (
+      moderations[i]?.content.filter &&
+      slice.items[i].post.author.did !== ignoreFilterFor
+    ) {
+      return null
+    }
   }
 
   if (slice.isThread && slice.items.length > 3) {
@@ -27,23 +38,31 @@ export const FeedSlice = observer(function FeedSliceImpl({
       <>
         <FeedItem
           key={slice.items[0]._reactKey}
-          item={slice.items[0]}
-          source={slice.source}
-          isThreadParent={slice.isThreadParentAt(0)}
-          isThreadChild={slice.isThreadChildAt(0)}
+          post={slice.items[0].post}
+          record={slice.items[0].record}
+          reason={slice.items[0].reason}
+          moderation={moderations[0]}
+          isThreadParent={isThreadParentAt(slice.items, 0)}
+          isThreadChild={isThreadChildAt(slice.items, 0)}
         />
         <FeedItem
           key={slice.items[1]._reactKey}
-          item={slice.items[1]}
-          isThreadParent={slice.isThreadParentAt(1)}
-          isThreadChild={slice.isThreadChildAt(1)}
+          post={slice.items[1].post}
+          record={slice.items[1].record}
+          reason={slice.items[1].reason}
+          moderation={moderations[1]}
+          isThreadParent={isThreadParentAt(slice.items, 1)}
+          isThreadChild={isThreadChildAt(slice.items, 1)}
         />
         <ViewFullThread slice={slice} />
         <FeedItem
           key={slice.items[last]._reactKey}
-          item={slice.items[last]}
-          isThreadParent={slice.isThreadParentAt(last)}
-          isThreadChild={slice.isThreadChildAt(last)}
+          post={slice.items[last].post}
+          record={slice.items[last].record}
+          reason={slice.items[last].reason}
+          moderation={moderations[last]}
+          isThreadParent={isThreadParentAt(slice.items, last)}
+          isThreadChild={isThreadChildAt(slice.items, last)}
           isThreadLastChild
         />
       </>
@@ -55,25 +74,29 @@ export const FeedSlice = observer(function FeedSliceImpl({
       {slice.items.map((item, i) => (
         <FeedItem
           key={item._reactKey}
-          item={item}
-          source={i === 0 ? slice.source : undefined}
-          isThreadParent={slice.isThreadParentAt(i)}
-          isThreadChild={slice.isThreadChildAt(i)}
+          post={slice.items[i].post}
+          record={slice.items[i].record}
+          reason={slice.items[i].reason}
+          moderation={moderations[i]}
+          isThreadParent={isThreadParentAt(slice.items, i)}
+          isThreadChild={isThreadChildAt(slice.items, i)}
           isThreadLastChild={
-            slice.isThreadChildAt(i) && slice.items.length === i + 1
+            isThreadChildAt(slice.items, i) && slice.items.length === i + 1
           }
         />
       ))}
     </>
   )
-})
+}
+FeedSlice = memo(FeedSlice)
+export {FeedSlice}
 
-function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
+function ViewFullThread({slice}: {slice: FeedPostSlice}) {
   const pal = usePalette('default')
   const itemHref = React.useMemo(() => {
-    const urip = new AtUri(slice.rootItem.post.uri)
-    return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey)
-  }, [slice.rootItem.post.uri, slice.rootItem.post.author])
+    const urip = new AtUri(slice.rootUri)
+    return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
+  }, [slice.rootUri])
 
   return (
     <Link
@@ -115,3 +138,17 @@ const styles = StyleSheet.create({
     alignItems: 'center',
   },
 })
+
+function isThreadParentAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i < arr.length - 1
+}
+
+function isThreadChildAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i > 0
+}
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index adb496f6d..1252f8ca8 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,47 +1,65 @@
 import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {AppBskyActorDefs} from '@atproto/api'
 import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {FollowState} from 'state/models/cache/my-follows'
-import {useFollowProfile} from 'lib/hooks/useFollowProfile'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
+import {Shadow} from '#/state/cache/types'
 
-export const FollowButton = observer(function FollowButtonImpl({
+export function FollowButton({
   unfollowedType = 'inverted',
   followedType = 'default',
   profile,
-  onToggleFollow,
   labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
-  profile: AppBskyActorDefs.ProfileViewBasic
-  onToggleFollow?: (v: boolean) => void
+  profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
   labelStyle?: StyleProp<TextStyle>
 }) {
-  const {state, following, toggle} = useFollowProfile(profile)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
 
-  const onPress = React.useCallback(async () => {
+  const onPressFollow = async () => {
     try {
-      const {following} = await toggle()
-      onToggleFollow?.(following)
+      await queueFollow()
     } catch (e: any) {
-      Toast.show('An issue occurred, please try again.')
+      if (e?.name !== 'AbortError') {
+        Toast.show(`An issue occurred, please try again.`)
+      }
     }
-  }, [toggle, onToggleFollow])
+  }
 
-  if (state === FollowState.Unknown) {
+  const onPressUnfollow = async () => {
+    try {
+      await queueUnfollow()
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        Toast.show(`An issue occurred, please try again.`)
+      }
+    }
+  }
+
+  if (!profile.viewer) {
     return <View />
   }
 
-  return (
-    <Button
-      type={following ? followedType : unfollowedType}
-      labelStyle={labelStyle}
-      onPress={onPress}
-      label={following ? 'Unfollow' : 'Follow'}
-      withLoading={true}
-    />
-  )
-})
+  if (profile.viewer.following) {
+    return (
+      <Button
+        type={followedType}
+        labelStyle={labelStyle}
+        onPress={onPressUnfollow}
+        label="Unfollow"
+      />
+    )
+  } else {
+    return (
+      <Button
+        type={unfollowedType}
+        labelStyle={labelStyle}
+        onPress={onPressFollow}
+        label="Follow"
+      />
+    )
+  }
+}
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 60dda6798..b14f2833b 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,6 +1,5 @@
 import * as React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {
   AppBskyActorDefs,
   moderateProfile,
@@ -11,7 +10,6 @@ import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {FollowButton} from './FollowButton'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
@@ -21,10 +19,14 @@ import {
   getProfileModerationCauses,
   getModerationCauseKey,
 } from 'lib/moderation'
+import {Shadow} from '#/state/cache/types'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
 
-export const ProfileCard = observer(function ProfileCardImpl({
+export function ProfileCard({
   testID,
-  profile,
+  profile: profileUnshadowed,
   noBg,
   noBorder,
   followers,
@@ -36,13 +38,18 @@ export const ProfileCard = observer(function ProfileCardImpl({
   noBg?: boolean
   noBorder?: boolean
   followers?: AppBskyActorDefs.ProfileView[] | undefined
-  renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
+  renderButton?: (
+    profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
+  ) => React.ReactNode
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+  const profile = useProfileShadow(profileUnshadowed)
+  const moderationOpts = useModerationOpts()
+  if (!moderationOpts) {
+    return null
+  }
+  const moderation = moderateProfile(profile, moderationOpts)
 
   return (
     <Link
@@ -100,7 +107,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
       <FollowersList followers={followers} />
     </Link>
   )
-})
+}
 
 function ProfileCardPills({
   followedBy,
@@ -142,24 +149,31 @@ function ProfileCardPills({
   )
 }
 
-const FollowersList = observer(function FollowersListImpl({
+function FollowersList({
   followers,
 }: {
   followers?: AppBskyActorDefs.ProfileView[] | undefined
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  if (!followers?.length) {
+  const moderationOpts = useModerationOpts()
+
+  const followersWithMods = React.useMemo(() => {
+    if (!followers || !moderationOpts) {
+      return []
+    }
+
+    return followers
+      .map(f => ({
+        f,
+        mod: moderateProfile(f, moderationOpts),
+      }))
+      .filter(({mod}) => !mod.account.filter)
+  }, [followers, moderationOpts])
+
+  if (!followersWithMods?.length) {
     return null
   }
 
-  const followersWithMods = followers
-    .map(f => ({
-      f,
-      mod: moderateProfile(f, store.preferences.moderationOpts),
-    }))
-    .filter(({mod}) => !mod.account.filter)
-
   return (
     <View style={styles.followedBy}>
       <Text
@@ -179,36 +193,36 @@ const FollowersList = observer(function FollowersListImpl({
       ))}
     </View>
   )
-})
+}
 
-export const ProfileCardWithFollowBtn = observer(
-  function ProfileCardWithFollowBtnImpl({
-    profile,
-    noBg,
-    noBorder,
-    followers,
-  }: {
-    profile: AppBskyActorDefs.ProfileViewBasic
-    noBg?: boolean
-    noBorder?: boolean
-    followers?: AppBskyActorDefs.ProfileView[] | undefined
-  }) {
-    const store = useStores()
-    const isMe = store.me.did === profile.did
+export function ProfileCardWithFollowBtn({
+  profile,
+  noBg,
+  noBorder,
+  followers,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  noBg?: boolean
+  noBorder?: boolean
+  followers?: AppBskyActorDefs.ProfileView[] | undefined
+}) {
+  const {currentAccount} = useSession()
+  const isMe = profile.did === currentAccount?.did
 
-    return (
-      <ProfileCard
-        profile={profile}
-        noBg={noBg}
-        noBorder={noBorder}
-        followers={followers}
-        renderButton={
-          isMe ? undefined : () => <FollowButton profile={profile} />
-        }
-      />
-    )
-  },
-)
+  return (
+    <ProfileCard
+      profile={profile}
+      noBg={noBg}
+      noBorder={noBorder}
+      followers={followers}
+      renderButton={
+        isMe
+          ? undefined
+          : profileShadow => <FollowButton profile={profileShadow} />
+      }
+    />
+  )
+}
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 00ea48ed6..d94f5103e 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -1,49 +1,68 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
-import {
-  UserFollowersModel,
-  FollowerItem,
-} from 'state/models/lists/user-followers'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
 
-export const ProfileFollowers = observer(function ProfileFollowers({
-  name,
-}: {
-  name: string
-}) {
+export function ProfileFollowers({name}: {name: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new UserFollowersModel(store, {actor: name}),
-    [store, name],
-  )
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    isFetching: isFetchingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFollowersQuery(resolvedDid)
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err =>
-        logger.error('Failed to fetch user followers', {error: err}),
-      )
-  }, [view])
+  const followers = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.followers)
+    }
+  }, [data])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view.loadMore().catch(err =>
-      logger.error('Failed to load more followers', {
-        error: err,
-      }),
-    )
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh followers', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more followers', {error: err})
+    }
   }
 
-  if (!view.hasLoaded) {
+  const renderItem = React.useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => (
+      <ProfileCardWithFollowBtn key={item.did} profile={item} />
+    ),
+    [],
+  )
+
+  if (isFetchingDid || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -53,26 +72,26 @@ export const ProfileFollowers = observer(function ProfileFollowers({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: FollowerItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.followers}
+      data={followers}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -85,15 +104,14 @@ export const ProfileFollowers = observer(function ProfileFollowers({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index abc35398a..890c13eb2 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -1,42 +1,68 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
 
-export const ProfileFollows = observer(function ProfileFollows({
-  name,
-}: {
-  name: string
-}) {
+export function ProfileFollows({name}: {name: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new UserFollowsModel(store, {actor: name}),
-    [store, name],
-  )
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    isFetching: isFetchingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFollowsQuery(resolvedDid)
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch user follows', err))
-  }, [view])
+  const follows = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.follows)
+    }
+  }, [data])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more follows', err))
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh follows', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more follows', {error: err})
+    }
   }
 
-  if (!view.hasLoaded) {
+  const renderItem = React.useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => (
+      <ProfileCardWithFollowBtn key={item.did} profile={item} />
+    ),
+    [],
+  )
+
+  if (isFetchingDid || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -46,26 +72,26 @@ export const ProfileFollows = observer(function ProfileFollows({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: FollowItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.follows}
+      data={follows}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -78,15 +104,14 @@ export const ProfileFollows = observer(function ProfileFollows({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 1a1d38e4b..8058551c2 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -1,5 +1,4 @@
-import React from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {memo} from 'react'
 import {
   StyleSheet,
   TouchableOpacity,
@@ -8,15 +7,17 @@ import {
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+import {
+  AppBskyActorDefs,
+  ProfileModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NavigationProp} from 'lib/routes/types'
+import {isNative, isWeb} from 'platform/detection'
 import {BlurView} from '../util/BlurView'
-import {ProfileModel} from 'state/models/content/profile'
-import {useStores} from 'state/index'
-import {ProfileImageLightbox} from 'state/models/ui/shell'
-import {pluralize} from 'lib/strings/helpers'
-import {toShareUrl} from 'lib/strings/url-helpers'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s, colors} from 'lib/styles'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
@@ -25,32 +26,45 @@ import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
 import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
+import {formatCount} from '../util/numeric/format'
+import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
+import {Link} from '../util/Link'
+import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
+import {useModalControls} from '#/state/modals'
+import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
+import {
+  RQKEY as profileQueryKey,
+  useProfileMuteMutationQueue,
+  useProfileBlockMutationQueue,
+  useProfileFollowMutationQueue,
+} from '#/state/queries/profile'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {isNative} from 'platform/detection'
-import {FollowState} from 'state/models/cache/my-follows'
-import {shareUrl} from 'lib/sharing'
-import {formatCount} from '../util/numeric/format'
-import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
 import {BACK_HITSLOP} from 'lib/constants'
 import {isInvalidHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
-import {Link} from '../util/Link'
-import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
+import {pluralize} from 'lib/strings/helpers'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {shareUrl} from 'lib/sharing'
+import {s, colors} from 'lib/styles'
 import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useRequireAuth} from '#/state/session'
 
 interface Props {
-  view: ProfileModel
-  onRefreshAll: () => void
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null
+  moderation: ProfileModeration | null
   hideBackButton?: boolean
   isProfilePreview?: boolean
 }
 
-export const ProfileHeader = observer(function ProfileHeaderImpl({
-  view,
-  onRefreshAll,
+export function ProfileHeader({
+  profile,
+  moderation,
   hideBackButton = false,
   isProfilePreview,
 }: Props) {
@@ -58,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
 
   // loading
   // =
-  if (!view || !view.hasLoaded) {
+  if (!profile || !moderation) {
     return (
       <View style={pal.view}>
         <LoadingPlaceholder width="100%" height={153} />
@@ -70,54 +84,65 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
           <View style={[styles.buttonsLine]}>
             <LoadingPlaceholder width={167} height={31} style={styles.br50} />
           </View>
-          <View>
-            <Text type="title-2xl" style={[pal.text, styles.title]}>
-              {sanitizeDisplayName(
-                view.displayName || sanitizeHandle(view.handle),
-              )}
-            </Text>
-          </View>
         </View>
       </View>
     )
   }
 
-  // error
-  // =
-  if (view.hasError) {
-    return (
-      <View testID="profileHeaderHasError">
-        <Text>{view.error}</Text>
-      </View>
-    )
-  }
-
   // loaded
   // =
   return (
     <ProfileHeaderLoaded
-      view={view}
-      onRefreshAll={onRefreshAll}
+      profile={profile}
+      moderation={moderation}
       hideBackButton={hideBackButton}
       isProfilePreview={isProfilePreview}
     />
   )
-})
+}
+
+interface LoadedProps {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  moderation: ProfileModeration
+  hideBackButton?: boolean
+  isProfilePreview?: boolean
+}
 
-const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
-  view,
-  onRefreshAll,
+let ProfileHeaderLoaded = ({
+  profile,
+  moderation,
   hideBackButton = false,
   isProfilePreview,
-}: Props) {
+}: LoadedProps): React.ReactNode => {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
-  const store = useStores()
+  const {currentAccount, hasSession} = useSession()
+  const requireAuth = useRequireAuth()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {openLightbox} = useLightboxControls()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const invalidHandle = isInvalidHandle(view.handle)
+  const invalidHandle = isInvalidHandle(profile.handle)
   const {isDesktop} = useWebMediaQueries()
   const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
+  const descriptionRT = React.useMemo(
+    () =>
+      profile.description
+        ? new RichTextAPI({text: profile.description})
+        : undefined,
+    [profile],
+  )
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+  const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
+  const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+  const queryClient = useQueryClient()
+
+  const invalidateProfileQuery = React.useCallback(() => {
+    queryClient.invalidateQueries({
+      queryKey: profileQueryKey(profile.did),
+    })
+  }, [queryClient, profile.did])
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -129,144 +154,162 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
 
   const onPressAvi = React.useCallback(() => {
     if (
-      view.avatar &&
-      !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+      profile.avatar &&
+      !(moderation.avatar.blur && moderation.avatar.noOverride)
     ) {
-      store.shell.openLightbox(new ProfileImageLightbox(view))
+      openLightbox(new ProfileImageLightbox(profile))
     }
-  }, [store, view])
+  }, [openLightbox, profile, moderation])
 
-  const onPressToggleFollow = React.useCallback(() => {
-    view?.toggleFollowing().then(
-      () => {
-        setShowSuggestedFollows(Boolean(view.viewer.following))
+  const onPressFollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:FollowButtonClicked')
+        await queueFollow()
         Toast.show(
-          `${
-            view.viewer.following ? 'Following' : 'No longer following'
-          } ${sanitizeDisplayName(view.displayName || view.handle)}`,
+          `Following ${sanitizeDisplayName(
+            profile.displayName || profile.handle,
+          )}`,
         )
-        track(
-          view.viewer.following
-            ? 'ProfileHeader:FollowButtonClicked'
-            : 'ProfileHeader:UnfollowButtonClicked',
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to follow', {error: String(e)})
+          Toast.show(`There was an issue! ${e.toString()}`)
+        }
+      }
+    })
+  }
+
+  const onPressUnfollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:UnfollowButtonClicked')
+        await queueUnfollow()
+        Toast.show(
+          `No longer following ${sanitizeDisplayName(
+            profile.displayName || profile.handle,
+          )}`,
         )
-      },
-      err => logger.error('Failed to toggle follow', {error: err}),
-    )
-  }, [track, view, setShowSuggestedFollows])
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to unfollow', {error: String(e)})
+          Toast.show(`There was an issue! ${e.toString()}`)
+        }
+      }
+    })
+  }
 
   const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'edit-profile',
-      profileView: view,
-      onUpdate: onRefreshAll,
+      profile,
     })
-  }, [track, store, view, onRefreshAll])
-
-  const trackPress = React.useCallback(
-    (f: 'Followers' | 'Follows') => {
-      track(`ProfileHeader:${f}ButtonClicked`, {
-        handle: view.handle,
-      })
-    },
-    [track, view],
-  )
+  }, [track, openModal, profile])
 
   const onPressShare = React.useCallback(() => {
     track('ProfileHeader:ShareButtonClicked')
-    const url = toShareUrl(makeProfileLink(view))
-    shareUrl(url)
-  }, [track, view])
+    shareUrl(toShareUrl(makeProfileLink(profile)))
+  }, [track, profile])
 
   const onPressAddRemoveLists = React.useCallback(() => {
     track('ProfileHeader:AddToListsButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'user-add-remove-lists',
-      subject: view.did,
-      displayName: view.displayName || view.handle,
+      subject: profile.did,
+      displayName: profile.displayName || profile.handle,
+      onAdd: invalidateProfileQuery,
+      onRemove: invalidateProfileQuery,
     })
-  }, [track, view, store])
+  }, [track, profile, openModal, invalidateProfileQuery])
 
   const onPressMuteAccount = React.useCallback(async () => {
     track('ProfileHeader:MuteAccountButtonClicked')
     try {
-      await view.muteAccount()
+      await queueMute()
       Toast.show('Account muted')
     } catch (e: any) {
-      logger.error('Failed to mute account', {error: e})
-      Toast.show(`There was an issue! ${e.toString()}`)
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to mute account', {error: e})
+        Toast.show(`There was an issue! ${e.toString()}`)
+      }
     }
-  }, [track, view])
+  }, [track, queueMute])
 
   const onPressUnmuteAccount = React.useCallback(async () => {
     track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
-      await view.unmuteAccount()
+      await queueUnmute()
       Toast.show('Account unmuted')
     } catch (e: any) {
-      logger.error('Failed to unmute account', {error: e})
-      Toast.show(`There was an issue! ${e.toString()}`)
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to unmute account', {error: e})
+        Toast.show(`There was an issue! ${e.toString()}`)
+      }
     }
-  }, [track, view])
+  }, [track, queueUnmute])
 
   const onPressBlockAccount = React.useCallback(async () => {
     track('ProfileHeader:BlockAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Block Account',
-      message:
-        'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
+      title: _(msg`Block Account`),
+      message: _(
+        msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+      ),
       onPressConfirm: async () => {
         try {
-          await view.blockAccount()
-          onRefreshAll()
+          await queueBlock()
           Toast.show('Account blocked')
         } catch (e: any) {
-          logger.error('Failed to block account', {error: e})
-          Toast.show(`There was an issue! ${e.toString()}`)
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to block account', {error: e})
+            Toast.show(`There was an issue! ${e.toString()}`)
+          }
         }
       },
     })
-  }, [track, view, store, onRefreshAll])
+  }, [track, queueBlock, openModal, _])
 
   const onPressUnblockAccount = React.useCallback(async () => {
     track('ProfileHeader:UnblockAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Unblock Account',
-      message:
-        'The account will be able to interact with you after unblocking.',
+      title: _(msg`Unblock Account`),
+      message: _(
+        msg`The account will be able to interact with you after unblocking.`,
+      ),
       onPressConfirm: async () => {
         try {
-          await view.unblockAccount()
-          onRefreshAll()
+          await queueUnblock()
           Toast.show('Account unblocked')
         } catch (e: any) {
-          logger.error('Failed to unblock account', {error: e})
-          Toast.show(`There was an issue! ${e.toString()}`)
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to unblock account', {error: e})
+            Toast.show(`There was an issue! ${e.toString()}`)
+          }
         }
       },
     })
-  }, [track, view, store, onRefreshAll])
+  }, [track, queueUnblock, openModal, _])
 
   const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'report',
-      did: view.did,
+      did: profile.did,
     })
-  }, [track, store, view])
+  }, [track, openModal, profile])
 
   const isMe = React.useMemo(
-    () => store.me.did === view.did,
-    [store.me.did, view.did],
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
   )
   const dropdownItems: DropdownItem[] = React.useMemo(() => {
     let items: DropdownItem[] = [
       {
         testID: 'profileHeaderDropdownShareBtn',
-        label: 'Share',
+        label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
         onPress: onPressShare,
         icon: {
           ios: {
@@ -277,71 +320,81 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         },
       },
     ]
-    items.push({label: 'separator'})
-    items.push({
-      testID: 'profileHeaderDropdownListAddRemoveBtn',
-      label: 'Add to Lists',
-      onPress: onPressAddRemoveLists,
-      icon: {
-        ios: {
-          name: 'list.bullet',
+    if (hasSession) {
+      items.push({label: 'separator'})
+      items.push({
+        testID: 'profileHeaderDropdownListAddRemoveBtn',
+        label: _(msg`Add to Lists`),
+        onPress: onPressAddRemoveLists,
+        icon: {
+          ios: {
+            name: 'list.bullet',
+          },
+          android: 'ic_menu_add',
+          web: 'list',
         },
-        android: 'ic_menu_add',
-        web: 'list',
-      },
-    })
-    if (!isMe) {
-      if (!view.viewer.blocking) {
-        items.push({
-          testID: 'profileHeaderDropdownMuteBtn',
-          label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
-          onPress: view.viewer.muted
-            ? onPressUnmuteAccount
-            : onPressMuteAccount,
-          icon: {
-            ios: {
-              name: 'speaker.slash',
+      })
+      if (!isMe) {
+        if (!profile.viewer?.blocking) {
+          if (!profile.viewer?.mutedByList) {
+            items.push({
+              testID: 'profileHeaderDropdownMuteBtn',
+              label: profile.viewer?.muted
+                ? _(msg`Unmute Account`)
+                : _(msg`Mute Account`),
+              onPress: profile.viewer?.muted
+                ? onPressUnmuteAccount
+                : onPressMuteAccount,
+              icon: {
+                ios: {
+                  name: 'speaker.slash',
+                },
+                android: 'ic_lock_silent_mode',
+                web: 'comment-slash',
+              },
+            })
+          }
+        }
+        if (!profile.viewer?.blockingByList) {
+          items.push({
+            testID: 'profileHeaderDropdownBlockBtn',
+            label: profile.viewer?.blocking
+              ? _(msg`Unblock Account`)
+              : _(msg`Block Account`),
+            onPress: profile.viewer?.blocking
+              ? onPressUnblockAccount
+              : onPressBlockAccount,
+            icon: {
+              ios: {
+                name: 'person.fill.xmark',
+              },
+              android: 'ic_menu_close_clear_cancel',
+              web: 'user-slash',
             },
-            android: 'ic_lock_silent_mode',
-            web: 'comment-slash',
-          },
-        })
-      }
-      if (!view.viewer.blockingByList) {
+          })
+        }
         items.push({
-          testID: 'profileHeaderDropdownBlockBtn',
-          label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
-          onPress: view.viewer.blocking
-            ? onPressUnblockAccount
-            : onPressBlockAccount,
+          testID: 'profileHeaderDropdownReportBtn',
+          label: _(msg`Report Account`),
+          onPress: onPressReportAccount,
           icon: {
             ios: {
-              name: 'person.fill.xmark',
+              name: 'exclamationmark.triangle',
             },
-            android: 'ic_menu_close_clear_cancel',
-            web: 'user-slash',
+            android: 'ic_menu_report_image',
+            web: 'circle-exclamation',
           },
         })
       }
-      items.push({
-        testID: 'profileHeaderDropdownReportBtn',
-        label: 'Report Account',
-        onPress: onPressReportAccount,
-        icon: {
-          ios: {
-            name: 'exclamationmark.triangle',
-          },
-          android: 'ic_menu_report_image',
-          web: 'circle-exclamation',
-        },
-      })
     }
     return items
   }, [
     isMe,
-    view.viewer.muted,
-    view.viewer.blocking,
-    view.viewer.blockingByList,
+    hasSession,
+    profile.viewer?.muted,
+    profile.viewer?.mutedByList,
+    profile.viewer?.blocking,
+    profile.viewer?.blockingByList,
     onPressShare,
     onPressUnmuteAccount,
     onPressMuteAccount,
@@ -349,16 +402,18 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
     onPressBlockAccount,
     onPressReportAccount,
     onPressAddRemoveLists,
+    _,
   ])
 
-  const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
-  const following = formatCount(view.followsCount)
-  const followers = formatCount(view.followersCount)
-  const pluralizedFollowers = pluralize(view.followersCount, 'follower')
+  const blockHide =
+    !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
+  const following = formatCount(profile.followsCount || 0)
+  const followers = formatCount(profile.followersCount || 0)
+  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
 
   return (
     <View style={pal.view}>
-      <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
+      <UserBanner banner={profile.banner} moderation={moderation.avatar} />
       <View style={styles.content}>
         <View style={[styles.buttonsLine]}>
           {isMe ? (
@@ -367,29 +422,29 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               onPress={onPressEditProfile}
               style={[styles.btn, styles.mainBtn, pal.btn]}
               accessibilityRole="button"
-              accessibilityLabel="Edit profile"
+              accessibilityLabel={_(msg`Edit profile`)}
               accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
               <Text type="button" style={pal.text}>
-                Edit Profile
+                <Trans>Edit Profile</Trans>
               </Text>
             </TouchableOpacity>
-          ) : view.viewer.blocking ? (
-            view.viewer.blockingByList ? null : (
+          ) : profile.viewer?.blocking ? (
+            profile.viewer?.blockingByList ? null : (
               <TouchableOpacity
                 testID="unblockBtn"
                 onPress={onPressUnblockAccount}
                 style={[styles.btn, styles.mainBtn, pal.btn]}
                 accessibilityRole="button"
-                accessibilityLabel="Unblock"
+                accessibilityLabel={_(msg`Unblock`)}
                 accessibilityHint="">
                 <Text type="button" style={[pal.text, s.bold]}>
-                  Unblock
+                  <Trans>Unblock</Trans>
                 </Text>
               </TouchableOpacity>
             )
-          ) : !view.viewer.blockedBy ? (
+          ) : !profile.viewer?.blockedBy ? (
             <>
-              {!isProfilePreview && (
+              {!isProfilePreview && hasSession && (
                 <TouchableOpacity
                   testID="suggestedFollowsBtn"
                   onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
@@ -405,7 +460,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                     },
                   ]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Show follows similar to ${view.handle}`}
+                  accessibilityLabel={`Show follows similar to ${profile.handle}`}
                   accessibilityHint={`Shows a list of users similar to this user.`}>
                   <FontAwesomeIcon
                     icon="user-plus"
@@ -413,7 +468,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                       pal.text,
                       {
                         color: showSuggestedFollows
-                          ? colors.white
+                          ? pal.textInverted.color
                           : pal.text.color,
                       },
                     ]}
@@ -422,38 +477,37 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                 </TouchableOpacity>
               )}
 
-              {store.me.follows.getFollowState(view.did) ===
-              FollowState.Following ? (
+              {profile.viewer?.following ? (
                 <TouchableOpacity
                   testID="unfollowBtn"
-                  onPress={onPressToggleFollow}
+                  onPress={onPressUnfollow}
                   style={[styles.btn, styles.mainBtn, pal.btn]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Unfollow ${view.handle}`}
-                  accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
+                  accessibilityLabel={`Unfollow ${profile.handle}`}
+                  accessibilityHint={`Hides posts from ${profile.handle} in your feed`}>
                   <FontAwesomeIcon
                     icon="check"
                     style={[pal.text, s.mr5]}
                     size={14}
                   />
                   <Text type="button" style={pal.text}>
-                    Following
+                    <Trans>Following</Trans>
                   </Text>
                 </TouchableOpacity>
               ) : (
                 <TouchableOpacity
                   testID="followBtn"
-                  onPress={onPressToggleFollow}
+                  onPress={onPressFollow}
                   style={[styles.btn, styles.mainBtn, palInverted.view]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Follow ${view.handle}`}
-                  accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
+                  accessibilityLabel={`Follow ${profile.handle}`}
+                  accessibilityHint={`Shows posts from ${profile.handle} in your feed`}>
                   <FontAwesomeIcon
                     icon="plus"
                     style={[palInverted.text, s.mr5]}
                   />
                   <Text type="button" style={[palInverted.text, s.bold]}>
-                    Follow
+                    <Trans>Follow</Trans>
                   </Text>
                 </TouchableOpacity>
               )}
@@ -463,7 +517,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
             <NativeDropdown
               testID="profileHeaderDropdownBtn"
               items={dropdownItems}
-              accessibilityLabel="More options"
+              accessibilityLabel={_(msg`More options`)}
               accessibilityHint="">
               <View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
                 <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
@@ -477,16 +531,16 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
             type="title-2xl"
             style={[pal.text, styles.title]}>
             {sanitizeDisplayName(
-              view.displayName || sanitizeHandle(view.handle),
-              view.moderation.profile,
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
             )}
           </Text>
         </View>
         <View style={styles.handleLine}>
-          {view.viewer.followedBy && !blockHide ? (
+          {profile.viewer?.followedBy && !blockHide ? (
             <View style={[styles.pill, pal.btn, s.mr5]}>
               <Text type="xs" style={[pal.text]}>
-                Follows you
+                <Trans>Follows you</Trans>
               </Text>
             </View>
           ) : undefined}
@@ -498,7 +552,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               invalidHandle ? styles.invalidHandle : undefined,
               styles.handle,
             ]}>
-            {invalidHandle ? 'âš Invalid Handle' : `@${view.handle}`}
+            {invalidHandle ? 'âš Invalid Handle' : `@${profile.handle}`}
           </ThemedText>
         </View>
         {!blockHide && (
@@ -507,8 +561,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               <Link
                 testID="profileHeaderFollowersButton"
                 style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(view, 'followers')}
-                onPressOut={() => trackPress('Followers')}
+                href={makeProfileLink(profile, 'followers')}
+                onPressOut={() =>
+                  track(`ProfileHeader:FollowersButtonClicked`, {
+                    handle: profile.handle,
+                  })
+                }
                 asAnchor
                 accessibilityLabel={`${followers} ${pluralizedFollowers}`}
                 accessibilityHint={'Opens followers list'}>
@@ -522,8 +580,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               <Link
                 testID="profileHeaderFollowsButton"
                 style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(view, 'follows')}
-                onPressOut={() => trackPress('Follows')}
+                href={makeProfileLink(profile, 'follows')}
+                onPressOut={() =>
+                  track(`ProfileHeader:FollowsButtonClicked`, {
+                    handle: profile.handle,
+                  })
+                }
                 asAnchor
                 accessibilityLabel={`${following} following`}
                 accessibilityHint={'Opens following list'}>
@@ -531,34 +593,32 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                   {following}{' '}
                 </Text>
                 <Text type="md" style={[pal.textLight]}>
-                  following
+                  <Trans>following</Trans>
                 </Text>
               </Link>
               <Text type="md" style={[s.bold, pal.text]}>
-                {formatCount(view.postsCount)}{' '}
+                {formatCount(profile.postsCount || 0)}{' '}
                 <Text type="md" style={[pal.textLight]}>
-                  {pluralize(view.postsCount, 'post')}
+                  {pluralize(profile.postsCount || 0, 'post')}
                 </Text>
               </Text>
             </View>
-            {view.description &&
-            view.descriptionRichText &&
-            !view.moderation.profile.blur ? (
+            {descriptionRT && !moderation.profile.blur ? (
               <RichText
                 testID="profileHeaderDescription"
                 style={[styles.description, pal.text]}
                 numberOfLines={15}
-                richText={view.descriptionRichText}
+                richText={descriptionRT}
               />
             ) : undefined}
           </>
         )}
-        <ProfileHeaderAlerts moderation={view.moderation} />
+        <ProfileHeaderAlerts moderation={moderation} />
       </View>
 
       {!isProfilePreview && (
         <ProfileHeaderSuggestedFollows
-          actorDid={view.did}
+          actorDid={profile.did}
           active={showSuggestedFollows}
           requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)}
         />
@@ -570,7 +630,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
           onPress={onPressBack}
           hitSlop={BACK_HITSLOP}
           accessibilityRole="button"
-          accessibilityLabel="Back"
+          accessibilityLabel={_(msg`Back`)}
           accessibilityHint="">
           <View style={styles.backBtnWrapper}>
             <BlurView style={styles.backBtn} blurType="dark">
@@ -583,20 +643,21 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         testID="profileHeaderAviButton"
         onPress={onPressAvi}
         accessibilityRole="image"
-        accessibilityLabel={`View ${view.handle}'s avatar`}
+        accessibilityLabel={`View ${profile.handle}'s avatar`}
         accessibilityHint="">
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
           <UserAvatar
             size={80}
-            avatar={view.avatar}
-            moderation={view.moderation.avatar}
+            avatar={profile.avatar}
+            moderation={moderation.avatar}
           />
         </View>
       </TouchableWithoutFeedback>
     </View>
   )
-})
+}
+ProfileHeaderLoaded = memo(ProfileHeaderLoaded)
 
 const styles = StyleSheet.create({
   banner: {
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index cf759ddd1..f648c9801 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -6,20 +6,16 @@ import Animated, {
   useAnimatedStyle,
   Easing,
 } from 'react-native-reanimated'
-import {useQuery} from '@tanstack/react-query'
 import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
-import {useFollowProfile} from 'lib/hooks/useFollowProfile'
 import {Button} from 'view/com/util/forms/Button'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
@@ -27,6 +23,10 @@ import {makeProfileLink} from 'lib/routes/links'
 import {Link} from 'view/com/util/Link'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {isWeb} from 'platform/detection'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 
 const OUTER_PADDING = 10
 const INNER_PADDING = 14
@@ -43,7 +43,6 @@ export function ProfileHeaderSuggestedFollows({
 }) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
   const animatedHeight = useSharedValue(0)
   const animatedStyles = useAnimatedStyle(() => ({
     opacity: animatedHeight.value / TOTAL_HEIGHT,
@@ -66,31 +65,8 @@ export function ProfileHeaderSuggestedFollows({
     }
   }, [active, animatedHeight, track])
 
-  const {isLoading, data: suggestedFollows} = useQuery({
-    enabled: active,
-    cacheTime: 0,
-    staleTime: 0,
-    queryKey: ['suggested_follows_by_actor', actorDid],
-    async queryFn() {
-      try {
-        const {
-          data: {suggestions},
-          success,
-        } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({
-          actor: actorDid,
-        })
-
-        if (!success) {
-          return []
-        }
-
-        store.me.follows.hydrateMany(suggestions)
-
-        return suggestions
-      } catch (e) {
-        return []
-      }
-    },
+  const {isLoading, data} = useSuggestedFollowsByActorQuery({
+    did: actorDid,
   })
 
   return (
@@ -149,8 +125,8 @@ export function ProfileHeaderSuggestedFollows({
                 <SuggestedFollowSkeleton />
                 <SuggestedFollowSkeleton />
               </>
-            ) : suggestedFollows ? (
-              suggestedFollows.map(profile => (
+            ) : data ? (
+              data.suggestions.map(profile => (
                 <SuggestedFollow key={profile.did} profile={profile} />
               ))
             ) : (
@@ -214,29 +190,43 @@ function SuggestedFollowSkeleton() {
   )
 }
 
-const SuggestedFollow = observer(function SuggestedFollowImpl({
-  profile,
+function SuggestedFollow({
+  profile: profileUnshadowed,
 }: {
   profile: AppBskyActorDefs.ProfileView
 }) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const {following, toggle} = useFollowProfile(profile)
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+  const moderationOpts = useModerationOpts()
+  const profile = useProfileShadow(profileUnshadowed)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
 
-  const onPress = React.useCallback(async () => {
+  const onPressFollow = React.useCallback(async () => {
     try {
-      const {following: isFollowing} = await toggle()
-
-      if (isFollowing) {
-        track('ProfileHeader:SuggestedFollowFollowed')
+      track('ProfileHeader:SuggestedFollowFollowed')
+      await queueFollow()
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        Toast.show('An issue occurred, please try again.')
       }
+    }
+  }, [queueFollow, track])
+
+  const onPressUnfollow = React.useCallback(async () => {
+    try {
+      await queueUnfollow()
     } catch (e: any) {
-      Toast.show('An issue occurred, please try again.')
+      if (e?.name !== 'AbortError') {
+        Toast.show('An issue occurred, please try again.')
+      }
     }
-  }, [toggle, track])
+  }, [queueUnfollow])
 
+  if (!moderationOpts) {
+    return null
+  }
+  const moderation = moderateProfile(profile, moderationOpts)
+  const following = profile.viewer?.following
   return (
     <Link
       href={makeProfileLink(profile)}
@@ -278,13 +268,12 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
           label={following ? 'Unfollow' : 'Follow'}
           type="inverted"
           labelStyle={{textAlign: 'center'}}
-          onPress={onPress}
-          withLoading
+          onPress={following ? onPressUnfollow : onPressFollow}
         />
       </View>
     </Link>
   )
-})
+}
 
 const styles = StyleSheet.create({
   suggestedFollowCardOuter: {
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index 0b8015aa9..0e245f0f4 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -12,14 +11,16 @@ import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {CenteredView} from '../util/Views'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
-import {useStores} from 'state/index'
 import {NavigationProp} from 'lib/routes/types'
 import {BACK_HITSLOP} from 'lib/constants'
 import {isNative} from 'platform/detection'
-import {ImagesLightbox} from 'state/models/ui/shell'
+import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 import {useSetDrawerOpen} from '#/state/shell'
+import {emitSoftReset} from '#/state/events'
 
-export const ProfileSubpageHeader = observer(function HeaderImpl({
+export function ProfileSubpageHeader({
   isLoading,
   href,
   title,
@@ -42,10 +43,11 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
     | undefined
   avatarType: UserAvatarType
 }>) {
-  const store = useStores()
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  const {openLightbox} = useLightboxControls()
   const pal = usePalette('default')
   const canGoBack = navigation.canGoBack()
 
@@ -65,9 +67,9 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
     if (
       avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
     ) {
-      store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0))
+      openLightbox(new ImagesLightbox([{uri: avatar}], 0))
     }
-  }, [store, avatar])
+  }, [openLightbox, avatar])
 
   return (
     <CenteredView style={pal.view}>
@@ -123,7 +125,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
           testID="headerAviButton"
           onPress={onPressAvi}
           accessibilityRole="image"
-          accessibilityLabel="View the avatar"
+          accessibilityLabel={_(msg`View the avatar`)}
           accessibilityHint=""
           style={{width: 58}}>
           <UserAvatar type={avatarType} size={58} avatar={avatar} />
@@ -142,7 +144,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
               href={href}
               style={[pal.text, {fontWeight: 'bold'}]}
               text={title || ''}
-              onPress={() => store.emitScreenSoftReset()}
+              onPress={emitSoftReset}
               numberOfLines={4}
             />
           )}
@@ -178,7 +180,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
       </View>
     </CenteredView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   backBtn: {
diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx
deleted file mode 100644
index 1a6b427c6..000000000
--- a/src/view/com/search/HeaderWithInput.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import React from 'react'
-import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from 'view/com/util/text/Text'
-import {MagnifyingGlassIcon} from 'lib/icons'
-import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {HITSLOP_10} from 'lib/constants'
-import {useSetDrawerOpen} from '#/state/shell'
-
-interface Props {
-  isInputFocused: boolean
-  query: string
-  setIsInputFocused: (v: boolean) => void
-  onChangeQuery: (v: string) => void
-  onPressClearQuery: () => void
-  onPressCancelSearch: () => void
-  onSubmitQuery: () => void
-  showMenu?: boolean
-}
-export function HeaderWithInput({
-  isInputFocused,
-  query,
-  setIsInputFocused,
-  onChangeQuery,
-  onPressClearQuery,
-  onPressCancelSearch,
-  onSubmitQuery,
-  showMenu = true,
-}: Props) {
-  const setDrawerOpen = useSetDrawerOpen()
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const {track} = useAnalytics()
-  const textInput = React.useRef<TextInput>(null)
-  const {isMobile} = useWebMediaQueries()
-
-  const onPressMenu = React.useCallback(() => {
-    track('ViewHeader:MenuButtonClicked')
-    setDrawerOpen(true)
-  }, [track, setDrawerOpen])
-
-  const onPressCancelSearchInner = React.useCallback(() => {
-    onPressCancelSearch()
-    textInput.current?.blur()
-  }, [onPressCancelSearch, textInput])
-
-  return (
-    <View
-      style={[
-        pal.view,
-        pal.border,
-        styles.header,
-        !isMobile && styles.headerDesktop,
-      ]}>
-      {showMenu && isMobile ? (
-        <TouchableOpacity
-          testID="viewHeaderBackOrMenuBtn"
-          onPress={onPressMenu}
-          hitSlop={HITSLOP_10}
-          style={styles.headerMenuBtn}
-          accessibilityRole="button"
-          accessibilityLabel="Menu"
-          accessibilityHint="Access navigation links and settings">
-          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
-        </TouchableOpacity>
-      ) : null}
-      <View
-        style={[
-          {backgroundColor: pal.colors.backgroundLight},
-          styles.headerSearchContainer,
-        ]}>
-        <MagnifyingGlassIcon
-          style={[pal.icon, styles.headerSearchIcon]}
-          size={21}
-        />
-        <TextInput
-          testID="searchTextInput"
-          ref={textInput}
-          placeholder="Search"
-          placeholderTextColor={pal.colors.textLight}
-          selectTextOnFocus
-          returnKeyType="search"
-          value={query}
-          style={[pal.text, styles.headerSearchInput]}
-          keyboardAppearance={theme.colorScheme}
-          onFocus={() => setIsInputFocused(true)}
-          onBlur={() => setIsInputFocused(false)}
-          onChangeText={onChangeQuery}
-          onSubmitEditing={onSubmitQuery}
-          autoFocus={false}
-          accessibilityRole="search"
-          accessibilityLabel="Search"
-          accessibilityHint=""
-          autoCorrect={false}
-          autoCapitalize="none"
-        />
-        {query ? (
-          <TouchableOpacity
-            testID="searchTextInputClearBtn"
-            onPress={onPressClearQuery}
-            accessibilityRole="button"
-            accessibilityLabel="Clear search query"
-            accessibilityHint="">
-            <FontAwesomeIcon
-              icon="xmark"
-              size={16}
-              style={pal.textLight as FontAwesomeIconStyle}
-            />
-          </TouchableOpacity>
-        ) : undefined}
-      </View>
-      {query || isInputFocused ? (
-        <View style={styles.headerCancelBtn}>
-          <TouchableOpacity
-            onPress={onPressCancelSearchInner}
-            accessibilityRole="button">
-            <Text style={pal.text}>Cancel</Text>
-          </TouchableOpacity>
-        </View>
-      ) : undefined}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  header: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingHorizontal: 12,
-    paddingVertical: 4,
-  },
-  headerDesktop: {
-    borderWidth: 1,
-    borderTopWidth: 0,
-    paddingVertical: 10,
-  },
-  headerMenuBtn: {
-    width: 30,
-    height: 30,
-    borderRadius: 30,
-    marginRight: 6,
-    paddingBottom: 2,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  headerSearchContainer: {
-    flex: 1,
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 30,
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-  },
-  headerSearchIcon: {
-    marginRight: 6,
-    alignSelf: 'center',
-  },
-  headerSearchInput: {
-    flex: 1,
-    fontSize: 17,
-  },
-  headerCancelBtn: {
-    paddingLeft: 10,
-  },
-
-  searchPrompt: {
-    textAlign: 'center',
-    paddingTop: 10,
-  },
-
-  suggestions: {
-    marginBottom: 8,
-  },
-})
diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx
deleted file mode 100644
index 87378bba7..000000000
--- a/src/view/com/search/SearchResults.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {SearchUIModel} from 'state/models/ui/search'
-import {CenteredView, ScrollView} from '../util/Views'
-import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {TabBar} from 'view/com/pager/TabBar'
-import {Post} from 'view/com/post/Post'
-import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard'
-import {
-  PostFeedLoadingPlaceholder,
-  ProfileCardFeedLoadingPlaceholder,
-} from 'view/com/util/LoadingPlaceholder'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-
-const SECTIONS = ['Posts', 'Users']
-
-export const SearchResults = observer(function SearchResultsImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-
-  const renderTabBar = React.useCallback(
-    (props: RenderTabBarFnProps) => {
-      return (
-        <CenteredView style={[pal.border, pal.view, styles.tabBar]}>
-          <TabBar
-            items={SECTIONS}
-            {...props}
-            key={SECTIONS.join()}
-            indicatorColor={pal.colors.link}
-          />
-        </CenteredView>
-      )
-    },
-    [pal],
-  )
-
-  return (
-    <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}>
-      <View
-        style={{
-          paddingTop: isMobile ? 42 : 50,
-        }}>
-        <PostResults key="0" model={model} />
-      </View>
-      <View
-        style={{
-          paddingTop: isMobile ? 42 : 50,
-        }}>
-        <Profiles key="1" model={model} />
-      </View>
-    </Pager>
-  )
-})
-
-const PostResults = observer(function PostResultsImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  if (model.isPostsLoading) {
-    return (
-      <CenteredView>
-        <PostFeedLoadingPlaceholder />
-      </CenteredView>
-    )
-  }
-
-  if (model.posts.length === 0) {
-    return (
-      <CenteredView>
-        <Text type="xl" style={[styles.empty, pal.text]}>
-          No posts found for "{model.query}"
-        </Text>
-      </CenteredView>
-    )
-  }
-
-  return (
-    <ScrollView style={[pal.view]}>
-      {model.posts.map(post => (
-        <Post key={post.resolvedUri} view={post} hideError />
-      ))}
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-    </ScrollView>
-  )
-})
-
-const Profiles = observer(function ProfilesImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  if (model.isProfilesLoading) {
-    return (
-      <CenteredView>
-        <ProfileCardFeedLoadingPlaceholder />
-      </CenteredView>
-    )
-  }
-
-  if (model.profiles.length === 0) {
-    return (
-      <CenteredView>
-        <Text type="xl" style={[styles.empty, pal.text]}>
-          No users found for "{model.query}"
-        </Text>
-      </CenteredView>
-    )
-  }
-
-  return (
-    <ScrollView style={pal.view}>
-      {model.profiles.map(item => (
-        <ProfileCardWithFollowBtn key={item.did} profile={item} />
-      ))}
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-    </ScrollView>
-  )
-})
-
-const styles = StyleSheet.create({
-  tabBar: {
-    borderBottomWidth: 1,
-    position: 'absolute',
-    zIndex: 1,
-    left: 0,
-    right: 0,
-    top: 0,
-    flexDirection: 'column',
-    alignItems: 'center',
-  },
-  empty: {
-    paddingHorizontal: 14,
-    paddingVertical: 16,
-  },
-})
diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx
deleted file mode 100644
index 2a80d10ae..000000000
--- a/src/view/com/search/Suggestions.tsx
+++ /dev/null
@@ -1,265 +0,0 @@
-import React, {forwardRef, ForwardedRef} from 'react'
-import {RefreshControl, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs} from '@atproto/api'
-import {FlatList} from '../util/Views'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {
-  SuggestedActorsModel,
-  SuggestedActor,
-} from 'state/models/discovery/suggested-actors'
-import {Text} from '../util/text/Text'
-import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s} from 'lib/styles'
-
-interface Heading {
-  _reactKey: string
-  type: 'heading'
-  title: string
-}
-interface RefWrapper {
-  _reactKey: string
-  type: 'ref'
-  ref: RefWithInfoAndFollowers
-}
-interface SuggestWrapper {
-  _reactKey: string
-  type: 'suggested'
-  suggested: SuggestedActor
-}
-interface ProfileView {
-  _reactKey: string
-  type: 'profile-view'
-  view: AppBskyActorDefs.ProfileViewBasic
-}
-interface LoadingPlaceholder {
-  _reactKey: string
-  type: 'loading-placeholder'
-}
-type Item =
-  | Heading
-  | RefWrapper
-  | SuggestWrapper
-  | ProfileView
-  | LoadingPlaceholder
-
-// FIXME(dan): Figure out why the false positives
-/* eslint-disable react/prop-types */
-
-export const Suggestions = observer(
-  forwardRef(function SuggestionsImpl(
-    {
-      foafs,
-      suggestedActors,
-    }: {
-      foafs: FoafsModel
-      suggestedActors: SuggestedActorsModel
-    },
-    flatListRef: ForwardedRef<FlatList>,
-  ) {
-    const pal = usePalette('default')
-    const [refreshing, setRefreshing] = React.useState(false)
-    const data = React.useMemo(() => {
-      let items: Item[] = []
-
-      if (suggestedActors.hasContent) {
-        items = items
-          .concat([
-            {
-              _reactKey: '__suggested_heading__',
-              type: 'heading',
-              title: 'Suggested Follows',
-            },
-          ])
-          .concat(
-            suggestedActors.suggestions.map(suggested => ({
-              _reactKey: `suggested-${suggested.did}`,
-              type: 'suggested',
-              suggested,
-            })),
-          )
-      } else if (suggestedActors.isLoading) {
-        items = items.concat([
-          {
-            _reactKey: '__suggested_heading__',
-            type: 'heading',
-            title: 'Suggested Follows',
-          },
-          {_reactKey: '__suggested_loading__', type: 'loading-placeholder'},
-        ])
-      }
-      if (foafs.isLoading) {
-        items = items.concat([
-          {
-            _reactKey: '__popular_heading__',
-            type: 'heading',
-            title: 'In Your Network',
-          },
-          {_reactKey: '__foafs_loading__', type: 'loading-placeholder'},
-        ])
-      } else {
-        if (foafs.popular.length > 0) {
-          items = items
-            .concat([
-              {
-                _reactKey: '__popular_heading__',
-                type: 'heading',
-                title: 'In Your Network',
-              },
-            ])
-            .concat(
-              foafs.popular.map(ref => ({
-                _reactKey: `popular-${ref.did}`,
-                type: 'ref',
-                ref,
-              })),
-            )
-        }
-        for (const source of foafs.sources) {
-          const item = foafs.foafs.get(source)
-          if (!item || item.follows.length === 0) {
-            continue
-          }
-          items = items
-            .concat([
-              {
-                _reactKey: `__${item.did}_heading__`,
-                type: 'heading',
-                title: `Followed by ${sanitizeDisplayName(
-                  item.displayName || sanitizeHandle(item.handle),
-                )}`,
-              },
-            ])
-            .concat(
-              item.follows.slice(0, 10).map(view => ({
-                _reactKey: `${item.did}-${view.did}`,
-                type: 'profile-view',
-                view,
-              })),
-            )
-        }
-      }
-
-      return items
-    }, [
-      foafs.isLoading,
-      foafs.popular,
-      suggestedActors.isLoading,
-      suggestedActors.hasContent,
-      suggestedActors.suggestions,
-      foafs.sources,
-      foafs.foafs,
-    ])
-
-    const onRefresh = React.useCallback(async () => {
-      setRefreshing(true)
-      try {
-        await foafs.fetch()
-      } finally {
-        setRefreshing(false)
-      }
-    }, [foafs, setRefreshing])
-
-    const renderItem = React.useCallback(
-      ({item}: {item: Item}) => {
-        if (item.type === 'heading') {
-          return (
-            <Text type="title" style={[styles.heading, pal.text]}>
-              {item.title}
-            </Text>
-          )
-        }
-        if (item.type === 'ref') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.ref.did}
-                profile={item.ref}
-                noBg
-                noBorder
-                followers={
-                  item.ref.followers
-                    ? (item.ref.followers as AppBskyActorDefs.ProfileView[])
-                    : undefined
-                }
-              />
-            </View>
-          )
-        }
-        if (item.type === 'profile-view') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.view.did}
-                profile={item.view}
-                noBg
-                noBorder
-              />
-            </View>
-          )
-        }
-        if (item.type === 'suggested') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.suggested.did}
-                profile={item.suggested}
-                noBg
-                noBorder
-              />
-            </View>
-          )
-        }
-        if (item.type === 'loading-placeholder') {
-          return (
-            <View>
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-            </View>
-          )
-        }
-        return null
-      },
-      [pal],
-    )
-
-    return (
-      <FlatList
-        ref={flatListRef}
-        data={data}
-        keyExtractor={item => item._reactKey}
-        refreshControl={
-          <RefreshControl
-            refreshing={refreshing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-          />
-        }
-        renderItem={renderItem}
-        initialNumToRender={15}
-        contentContainerStyle={s.contentContainer}
-      />
-    )
-  }),
-)
-
-const styles = StyleSheet.create({
-  heading: {
-    fontWeight: 'bold',
-    paddingHorizontal: 12,
-    paddingBottom: 8,
-    paddingTop: 16,
-  },
-
-  card: {
-    borderTopWidth: 1,
-  },
-})
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index db9b6b4bf..41abc25d3 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -1,7 +1,10 @@
 import React from 'react'
 import {Pressable, View} from 'react-native'
-import {useStores} from 'state/index'
 import {navigate} from '../../../Navigation'
+import {useModalControls} from '#/state/modals'
+import {useQueryClient} from '@tanstack/react-query'
+import {useSessionApi} from '#/state/session'
+import {useSetFeedViewPreferencesMutation} from '#/state/queries/preferences'
 
 /**
  * This utility component is only included in the test simulator
@@ -12,16 +15,19 @@ import {navigate} from '../../../Navigation'
 const BTN = {height: 1, width: 1, backgroundColor: 'red'}
 
 export function TestCtrls() {
-  const store = useStores()
+  const queryClient = useQueryClient()
+  const {logout, login} = useSessionApi()
+  const {openModal} = useModalControls()
+  const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
   const onPressSignInAlice = async () => {
-    await store.session.login({
+    await login({
       service: 'http://localhost:3000',
       identifier: 'alice.test',
       password: 'hunter2',
     })
   }
   const onPressSignInBob = async () => {
-    await store.session.login({
+    await login({
       service: 'http://localhost:3000',
       identifier: 'bob.test',
       password: 'hunter2',
@@ -43,7 +49,7 @@ export function TestCtrls() {
       />
       <Pressable
         testID="e2eSignOut"
-        onPress={() => store.session.logout()}
+        onPress={() => logout()}
         accessibilityRole="button"
         style={BTN}
       />
@@ -73,19 +79,19 @@ export function TestCtrls() {
       />
       <Pressable
         testID="e2eToggleMergefeed"
-        onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()}
+        onPress={() => setFeedViewPref({lab_mergeFeedEnabled: true})}
         accessibilityRole="button"
         style={BTN}
       />
       <Pressable
         testID="e2eRefreshHome"
-        onPress={() => store.me.mainFeed.refresh()}
+        onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})}
         accessibilityRole="button"
         style={BTN}
       />
       <Pressable
         testID="e2eOpenInviteCodesModal"
-        onPress={() => store.shell.openModal({name: 'invite-codes'})}
+        onPress={() => openModal({name: 'invite-codes'})}
         accessibilityRole="button"
         style={BTN}
       />
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 29571696b..76d493886 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -5,19 +5,23 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
 import * as Toast from '../../com/util/Toast'
+import {useSessionApi, SessionAccount} from '#/state/session'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
-export function AccountDropdownBtn({handle}: {handle: string}) {
-  const store = useStores()
+export function AccountDropdownBtn({account}: {account: SessionAccount}) {
   const pal = usePalette('default')
+  const {removeAccount} = useSessionApi()
+  const {_} = useLingui()
+
   const items: DropdownItem[] = [
     {
-      label: 'Remove account',
+      label: _(msg`Remove account`),
       onPress: () => {
-        store.session.removeAccount(handle)
+        removeAccount(account)
         Toast.show('Account removed from quick access')
       },
       icon: {
@@ -34,7 +38,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) {
       <NativeDropdown
         testID="accountSettingsDropdownBtn"
         items={items}
-        accessibilityLabel="Account options"
+        accessibilityLabel={_(msg`Account options`)}
         accessibilityHint="">
         <FontAwesomeIcon
           icon="ellipsis-h"
diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx
index 91379f1c9..ed5a2f165 100644
--- a/src/view/com/util/BottomSheetCustomBackdrop.tsx
+++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx
@@ -6,6 +6,7 @@ import Animated, {
   interpolate,
   useAnimatedStyle,
 } from 'react-native-reanimated'
+import {t} from '@lingui/macro'
 
 export function createCustomBackdrop(
   onClose?: (() => void) | undefined,
@@ -29,7 +30,7 @@ export function createCustomBackdrop(
     return (
       <TouchableWithoutFeedback
         onPress={onClose}
-        accessibilityLabel="Close bottom drawer"
+        accessibilityLabel={t`Close bottom drawer`}
         accessibilityHint=""
         onAccessibilityEscape={() => {
           if (onClose !== undefined) {
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 529435cf1..397588cfb 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -1,6 +1,7 @@
 import React, {Component, ErrorInfo, ReactNode} from 'react'
 import {ErrorScreen} from './error/ErrorScreen'
 import {CenteredView} from './Views'
+import {t} from '@lingui/macro'
 
 interface Props {
   children?: ReactNode
@@ -30,8 +31,8 @@ export class ErrorBoundary extends Component<Props, State> {
       return (
         <CenteredView style={{height: '100%', flex: 1}}>
           <ErrorScreen
-            title="Oh no!"
-            message="There was an unexpected issue in the application. Please let us know if this happened to you!"
+            title={t`Oh no!`}
+            message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`}
             details={this.state.error.toString()}
           />
         </CenteredView>
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 1777f6659..dcbec7cb4 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -21,7 +21,6 @@ import {Text} from './text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {NavigationProp} from 'lib/routes/types'
 import {router} from '../../../routes'
-import {useStores, RootStoreModel} from 'state/index'
 import {
   convertBskyAppUrlIfNeeded,
   isExternalUrl,
@@ -31,6 +30,7 @@ import {isAndroid, isWeb} from 'platform/detection'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
+import {useModalControls} from '#/state/modals'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -46,6 +46,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   noFeedback?: boolean
   asAnchor?: boolean
   anchorNoUnderline?: boolean
+  navigationAction?: 'push' | 'replace' | 'navigate'
 }
 
 export const Link = memo(function Link({
@@ -58,19 +59,26 @@ export const Link = memo(function Link({
   asAnchor,
   accessible,
   anchorNoUnderline,
+  navigationAction,
   ...props
 }: Props) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
 
   const onPress = React.useCallback(
     (e?: Event) => {
       if (typeof href === 'string') {
-        return onPressInner(store, navigation, sanitizeUrl(href), e)
+        return onPressInner(
+          closeModal,
+          navigation,
+          sanitizeUrl(href),
+          navigationAction,
+          e,
+        )
       }
     },
-    [store, navigation, href],
+    [closeModal, navigation, navigationAction, href],
   )
 
   if (noFeedback) {
@@ -146,6 +154,7 @@ export const TextLink = memo(function TextLink({
   title,
   onPress,
   warnOnMismatchingLabel,
+  navigationAction,
   ...orgProps
 }: {
   testID?: string
@@ -158,10 +167,11 @@ export const TextLink = memo(function TextLink({
   dataSet?: any
   title?: string
   warnOnMismatchingLabel?: boolean
+  navigationAction?: 'push' | 'replace' | 'navigate'
 } & TextProps) {
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
-  const store = useStores()
   const navigation = useNavigation<NavigationProp>()
+  const {openModal, closeModal} = useModalControls()
 
   if (warnOnMismatchingLabel && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
@@ -174,7 +184,7 @@ export const TextLink = memo(function TextLink({
         linkRequiresWarning(href, typeof text === 'string' ? text : '')
       if (requiresWarning) {
         e?.preventDefault?.()
-        store.shell.openModal({
+        openModal({
           name: 'link-warning',
           text: typeof text === 'string' ? text : '',
           href,
@@ -185,9 +195,24 @@ export const TextLink = memo(function TextLink({
         // @ts-ignore function signature differs by platform -prf
         return onPress()
       }
-      return onPressInner(store, navigation, sanitizeUrl(href), e)
+      return onPressInner(
+        closeModal,
+        navigation,
+        sanitizeUrl(href),
+        navigationAction,
+        e,
+      )
     },
-    [onPress, store, navigation, href, text, warnOnMismatchingLabel],
+    [
+      onPress,
+      closeModal,
+      openModal,
+      navigation,
+      href,
+      text,
+      warnOnMismatchingLabel,
+      navigationAction,
+    ],
   )
   const hrefAttrs = useMemo(() => {
     const isExternal = isExternalUrl(href)
@@ -233,6 +258,7 @@ interface TextLinkOnWebOnlyProps extends TextProps {
   accessibilityLabel?: string
   accessibilityHint?: string
   title?: string
+  navigationAction?: 'push' | 'replace' | 'navigate'
 }
 export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
   testID,
@@ -242,6 +268,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
   text,
   numberOfLines,
   lineHeight,
+  navigationAction,
   ...props
 }: TextLinkOnWebOnlyProps) {
   if (isWeb) {
@@ -255,6 +282,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
         numberOfLines={numberOfLines}
         lineHeight={lineHeight}
         title={props.title}
+        navigationAction={navigationAction}
         {...props}
       />
     )
@@ -285,9 +313,10 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
 // needed customizations
 // -prf
 function onPressInner(
-  store: RootStoreModel,
+  closeModal = () => {},
   navigation: NavigationProp,
   href: string,
+  navigationAction: 'push' | 'replace' | 'navigate' = 'push',
   e?: Event,
 ) {
   let shouldHandle = false
@@ -318,10 +347,20 @@ function onPressInner(
     if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
       Linking.openURL(href)
     } else {
-      store.shell.closeModal() // close any active modals
+      closeModal() // close any active modals
 
-      // @ts-ignore we're not able to type check on this one -prf
-      navigation.dispatch(StackActions.push(...router.matchPath(href)))
+      if (navigationAction === 'push') {
+        // @ts-ignore we're not able to type check on this one -prf
+        navigation.dispatch(StackActions.push(...router.matchPath(href)))
+      } else if (navigationAction === 'replace') {
+        // @ts-ignore we're not able to type check on this one -prf
+        navigation.dispatch(StackActions.replace(...router.matchPath(href)))
+      } else if (navigationAction === 'navigate') {
+        // @ts-ignore we're not able to type check on this one -prf
+        navigation.navigate(...router.matchPath(href))
+      } else {
+        throw Error('Unsupported navigator action.')
+      }
     }
   }
 }
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 461cbcbe5..74e36ff7b 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -171,14 +171,22 @@ export function ProfileCardFeedLoadingPlaceholder() {
 
 export function FeedLoadingPlaceholder({
   style,
+  showLowerPlaceholder = true,
+  showTopBorder = true,
 }: {
   style?: StyleProp<ViewStyle>
+  showTopBorder?: boolean
+  showLowerPlaceholder?: boolean
 }) {
   const pal = usePalette('default')
   return (
     <View
       style={[
-        {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1},
+        {
+          paddingHorizontal: 12,
+          paddingVertical: 18,
+          borderTopWidth: showTopBorder ? 1 : 0,
+        },
         pal.border,
         style,
       ]}>
@@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({
           <LoadingPlaceholder width={120} height={8} />
         </View>
       </View>
-      <View style={{paddingHorizontal: 5}}>
-        <LoadingPlaceholder
-          width={260}
-          height={8}
-          style={{marginVertical: 12}}
-        />
-        <LoadingPlaceholder width={120} height={8} />
-      </View>
+      {showLowerPlaceholder && (
+        <View style={{paddingHorizontal: 5}}>
+          <LoadingPlaceholder
+            width={260}
+            height={8}
+            style={{marginVertical: 12}}
+          />
+          <LoadingPlaceholder width={120} height={8} />
+        </View>
+      )}
     </View>
   )
 }
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index c5e438f8d..fa5f12f6b 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -6,7 +6,6 @@ import {niceDate} from 'lib/strings/time'
 import {usePalette} from 'lib/hooks/usePalette'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {UserAvatar} from './UserAvatar'
-import {observer} from 'mobx-react-lite'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {isAndroid} from 'platform/detection'
@@ -30,7 +29,7 @@ interface PostMetaOpts {
   style?: StyleProp<ViewStyle>
 }
 
-export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) {
+export function PostMeta(opts: PostMetaOpts) {
   const pal = usePalette('default')
   const displayName = opts.author.displayName || opts.author.handle
   const handle = opts.author.handle
@@ -92,7 +91,7 @@ export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) {
       </TimeElapsed>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/util/PostSandboxWarning.tsx b/src/view/com/util/PostSandboxWarning.tsx
index 21f5f7b90..b2375c703 100644
--- a/src/view/com/util/PostSandboxWarning.tsx
+++ b/src/view/com/util/PostSandboxWarning.tsx
@@ -1,13 +1,13 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useSession} from '#/state/session'
 
 export function PostSandboxWarning() {
-  const store = useStores()
+  const {isSandbox} = useSession()
   const pal = usePalette('default')
-  if (store.session.isSandbox) {
+  if (isSandbox) {
     return (
       <View style={styles.container}>
         <Text
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
index c871d9404..e86e37565 100644
--- a/src/view/com/util/SimpleViewHeader.tsx
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {
   StyleProp,
   StyleSheet,
@@ -18,7 +17,7 @@ import {useSetDrawerOpen} from '#/state/shell'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
-export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
+export function SimpleViewHeader({
   showBackButton = true,
   style,
   children,
@@ -76,7 +75,7 @@ export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
       {children}
     </Container>
   )
-})
+}
 
 const styles = StyleSheet.create({
   header: {
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index 0765f65b2..aa3a09223 100644
--- a/src/view/com/util/TimeElapsed.tsx
+++ b/src/view/com/util/TimeElapsed.tsx
@@ -1,24 +1,22 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {ago} from 'lib/strings/time'
-import {useStores} from 'state/index'
+import {useTickEveryMinute} from '#/state/shell'
 
 // FIXME(dan): Figure out why the false positives
-/* eslint-disable react/prop-types */
 
-export const TimeElapsed = observer(function TimeElapsed({
+export function TimeElapsed({
   timestamp,
   children,
 }: {
   timestamp: string
   children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
 }) {
-  const stores = useStores()
+  const tick = useTickEveryMinute()
   const [timeElapsed, setTimeAgo] = React.useState(ago(timestamp))
 
   React.useEffect(() => {
     setTimeAgo(ago(timestamp))
-  }, [timestamp, setTimeAgo, stores.shell.tickEveryMinute])
+  }, [timestamp, setTimeAgo, tick])
 
   return children({timeElapsed})
-})
+}
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 4c9045d1e..c7134febe 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -1,6 +1,7 @@
 import RootSiblings from 'react-native-root-siblings'
 import React from 'react'
 import {Animated, StyleSheet, View} from 'react-native'
+import {Props as FontAwesomeProps} from '@fortawesome/react-native-fontawesome'
 import {Text} from './text/Text'
 import {colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
@@ -9,7 +10,10 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
 const TIMEOUT = 4e3
 
-export function show(message: string) {
+export function show(
+  message: string,
+  _icon: FontAwesomeProps['icon'] = 'check',
+) {
   const item = new RootSiblings(<Toast message={message} />)
   setTimeout(() => {
     item.destroy()
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index c295bad69..beb67c30c 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -7,12 +7,14 @@ import {StyleSheet, Text, View} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
+  Props as FontAwesomeProps,
 } from '@fortawesome/react-native-fontawesome'
 
 const DURATION = 3500
 
 interface ActiveToast {
   text: string
+  icon: FontAwesomeProps['icon']
 }
 type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
 
@@ -36,7 +38,7 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
       {activeToast && (
         <View style={styles.container}>
           <FontAwesomeIcon
-            icon="check"
+            icon={activeToast.icon}
             size={24}
             style={styles.icon as FontAwesomeIconStyle}
           />
@@ -49,11 +51,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
 
 // methods
 // =
-export function show(text: string) {
+
+export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
   if (toastTimeout) {
     clearTimeout(toastTimeout)
   }
-  globalSetActiveToast?.({text})
+  globalSetActiveToast?.({text, icon})
   toastTimeout = setTimeout(() => {
     globalSetActiveToast?.(undefined)
   }, DURATION)
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 9db457325..395e9eb3a 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -9,13 +9,14 @@ import {
   usePhotoLibraryPermission,
   useCameraPermission,
 } from 'lib/hooks/usePermissions'
-import {useStores} from 'state/index'
 import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {UserPreviewLink} from './UserPreviewLink'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export type UserAvatarType = 'user' | 'algo' | 'list'
 
@@ -42,7 +43,13 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
 
 const BLUR_AMOUNT = isWeb ? 5 : 100
 
-function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) {
+export function DefaultAvatar({
+  type,
+  size,
+}: {
+  type: UserAvatarType
+  size: number
+}) {
   if (type === 'algo') {
     // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
     return (
@@ -182,8 +189,8 @@ export function EditableUserAvatar({
   avatar,
   onSelectNewAvatar,
 }: EditableUserAvatarProps) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
@@ -207,7 +214,7 @@ export function EditableUserAvatar({
       [
         !isWeb && {
           testID: 'changeAvatarCameraBtn',
-          label: 'Camera',
+          label: _(msg`Camera`),
           icon: {
             ios: {
               name: 'camera',
@@ -221,7 +228,7 @@ export function EditableUserAvatar({
             }
 
             onSelectNewAvatar(
-              await openCamera(store, {
+              await openCamera({
                 width: 1000,
                 height: 1000,
                 cropperCircleOverlay: true,
@@ -231,7 +238,7 @@ export function EditableUserAvatar({
         },
         {
           testID: 'changeAvatarLibraryBtn',
-          label: 'Library',
+          label: _(msg`Library`),
           icon: {
             ios: {
               name: 'photo.on.rectangle.angled',
@@ -252,7 +259,7 @@ export function EditableUserAvatar({
               return
             }
 
-            const croppedImage = await openCropper(store, {
+            const croppedImage = await openCropper({
               mediaType: 'photo',
               cropperCircleOverlay: true,
               height: item.height,
@@ -268,7 +275,7 @@ export function EditableUserAvatar({
         },
         !!avatar && {
           testID: 'changeAvatarRemoveBtn',
-          label: 'Remove',
+          label: _(msg`Remove`),
           icon: {
             ios: {
               name: 'trash',
@@ -286,7 +293,7 @@ export function EditableUserAvatar({
       onSelectNewAvatar,
       requestCameraAccessIfNeeded,
       requestPhotoAccessIfNeeded,
-      store,
+      _,
     ],
   )
 
@@ -294,7 +301,7 @@ export function EditableUserAvatar({
     <NativeDropdown
       testID="changeAvatarBtn"
       items={dropdownItems}
-      accessibilityLabel="Image options"
+      accessibilityLabel={_(msg`Image options`)}
       accessibilityHint="">
       {avatar ? (
         <HighPriorityImage
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 4bdfad06c..b31d7e551 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -5,7 +5,6 @@ import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
-import {useStores} from 'state/index'
 import {
   usePhotoLibraryPermission,
   useCameraPermission,
@@ -14,6 +13,8 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export function UserBanner({
   banner,
@@ -24,8 +25,8 @@ export function UserBanner({
   moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
@@ -34,7 +35,7 @@ export function UserBanner({
       [
         !isWeb && {
           testID: 'changeBannerCameraBtn',
-          label: 'Camera',
+          label: _(msg`Camera`),
           icon: {
             ios: {
               name: 'camera',
@@ -47,7 +48,7 @@ export function UserBanner({
               return
             }
             onSelectNewBanner?.(
-              await openCamera(store, {
+              await openCamera({
                 width: 3000,
                 height: 1000,
               }),
@@ -56,7 +57,7 @@ export function UserBanner({
         },
         {
           testID: 'changeBannerLibraryBtn',
-          label: 'Library',
+          label: _(msg`Library`),
           icon: {
             ios: {
               name: 'photo.on.rectangle.angled',
@@ -74,7 +75,7 @@ export function UserBanner({
             }
 
             onSelectNewBanner?.(
-              await openCropper(store, {
+              await openCropper({
                 mediaType: 'photo',
                 path: items[0].path,
                 width: 3000,
@@ -85,7 +86,7 @@ export function UserBanner({
         },
         !!banner && {
           testID: 'changeBannerRemoveBtn',
-          label: 'Remove',
+          label: _(msg`Remove`),
           icon: {
             ios: {
               name: 'trash',
@@ -103,7 +104,7 @@ export function UserBanner({
       onSelectNewBanner,
       requestCameraAccessIfNeeded,
       requestPhotoAccessIfNeeded,
-      store,
+      _,
     ],
   )
 
@@ -112,7 +113,7 @@ export function UserBanner({
     <NativeDropdown
       testID="changeBannerBtn"
       items={dropdownItems}
-      accessibilityLabel="Image options"
+      accessibilityLabel={_(msg`Image options`)}
       accessibilityHint="">
       {banner ? (
         <Image
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index e4ca981d9..e5d2ceb03 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -1,14 +1,14 @@
-import React, {useState, useEffect} from 'react'
+import React from 'react'
 import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {TextLinkOnWebOnly} from './Link'
 import {Text} from './text/Text'
 import {LoadingPlaceholder} from './LoadingPlaceholder'
-import {useStores} from 'state/index'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
+import {useProfileQuery} from '#/state/queries/profile'
 
 export function UserInfoText({
   type = 'md',
@@ -29,35 +29,10 @@ export function UserInfoText({
   attr = attr || 'handle'
   failed = failed || 'user'
 
-  const store = useStores()
-  const [profile, setProfile] = useState<undefined | GetProfile.OutputSchema>(
-    undefined,
-  )
-  const [didFail, setFailed] = useState<boolean>(false)
-
-  useEffect(() => {
-    let aborted = false
-    store.profiles.getProfile(did).then(
-      v => {
-        if (aborted) {
-          return
-        }
-        setProfile(v.data)
-      },
-      _err => {
-        if (aborted) {
-          return
-        }
-        setFailed(true)
-      },
-    )
-    return () => {
-      aborted = true
-    }
-  }, [did, store.profiles])
+  const {data: profile, isError} = useProfileQuery({did})
 
   let inner
-  if (didFail) {
+  if (isError) {
     inner = (
       <Text type={type} style={style} numberOfLines={1}>
         {failed}
diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx
index f43f9e80b..9c5efe55e 100644
--- a/src/view/com/util/UserPreviewLink.tsx
+++ b/src/view/com/util/UserPreviewLink.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
 import {Pressable, StyleProp, ViewStyle} from 'react-native'
-import {useStores} from 'state/index'
 import {Link} from './Link'
 import {isWeb} from 'platform/detection'
 import {makeProfileLink} from 'lib/routes/links'
+import {useModalControls} from '#/state/modals'
 
 interface UserPreviewLinkProps {
   did: string
@@ -13,7 +13,7 @@ interface UserPreviewLinkProps {
 export function UserPreviewLink(
   props: React.PropsWithChildren<UserPreviewLinkProps>,
 ) {
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   if (isWeb) {
     return (
@@ -29,7 +29,7 @@ export function UserPreviewLink(
   return (
     <Pressable
       onPress={() =>
-        store.shell.openModal({
+        openModal({
           name: 'profile-preview',
           did: props.did,
         })
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index adf2e4f08..082cae59c 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
@@ -15,7 +14,7 @@ import {useSetDrawerOpen} from '#/state/shell'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
-export const ViewHeader = observer(function ViewHeaderImpl({
+export function ViewHeader({
   title,
   canGoBack,
   showBackButton = true,
@@ -108,7 +107,7 @@ export const ViewHeader = observer(function ViewHeaderImpl({
       </Container>
     )
   }
-})
+}
 
 function DesktopWebHeader({
   title,
@@ -140,7 +139,7 @@ function DesktopWebHeader({
   )
 }
 
-const Container = observer(function ContainerImpl({
+function Container({
   children,
   hideOnScroll,
   showBorder,
@@ -178,7 +177,7 @@ const Container = observer(function ContainerImpl({
       {children}
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   header: {
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 1c2edc0cc..5a4f266fd 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -108,9 +108,9 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
     <Animated.FlatList
       ref={ref}
       contentContainerStyle={[
+        styles.contentContainer,
         contentContainerStyle,
         pal.border,
-        styles.contentContainer,
       ]}
       style={style}
       contentOffset={contentOffset}
@@ -135,9 +135,9 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl(
   return (
     <Animated.ScrollView
       contentContainerStyle={[
+        styles.contentContainer,
         contentContainerStyle,
         pal.border,
-        styles.contentContainer,
       ]}
       // @ts-ignore something is wrong with the reanimated types -prf
       ref={ref}
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index 370f10ae3..b4adbb557 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -13,6 +13,8 @@ import {
 import {Text} from '../text/Text'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 export function ErrorMessage({
   message,
@@ -27,6 +29,7 @@ export function ErrorMessage({
 }) {
   const theme = useTheme()
   const pal = usePalette('error')
+  const {_} = useLingui()
   return (
     <View testID="errorMessageView" style={[styles.outer, pal.view, style]}>
       <View
@@ -49,7 +52,7 @@ export function ErrorMessage({
           style={styles.btn}
           onPress={onPressTryAgain}
           accessibilityRole="button"
-          accessibilityLabel="Retry"
+          accessibilityLabel={_(msg`Retry`)}
           accessibilityHint="Retries the last action, which errored out">
           <FontAwesomeIcon
             icon="arrows-rotate"
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index a5deeb18f..4cd6dd4b4 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -9,6 +9,8 @@ import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Button} from '../forms/Button'
 import {CenteredView} from '../Views'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export function ErrorScreen({
   title,
@@ -25,6 +27,8 @@ export function ErrorScreen({
 }) {
   const theme = useTheme()
   const pal = usePalette('default')
+  const {_} = useLingui()
+
   return (
     <CenteredView testID={testID} style={[styles.outer, pal.view]}>
       <View style={styles.errorIconContainer}>
@@ -58,7 +62,7 @@ export function ErrorScreen({
             type="default"
             style={[styles.btn]}
             onPress={onPressTryAgain}
-            accessibilityLabel="Retry"
+            accessibilityLabel={_(msg`Retry`)}
             accessibilityHint="Retries the last action, which errored out">
             <FontAwesomeIcon
               icon="arrows-rotate"
@@ -66,7 +70,7 @@ export function ErrorScreen({
               size={16}
             />
             <Text type="button" style={[styles.btnText, pal.link]}>
-              Try again
+              <Trans>Try again</Trans>
             </Text>
           </Button>
         </View>
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 5b1d5d888..9787d92fb 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,5 +1,4 @@
 import React, {ComponentProps} from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, TouchableWithoutFeedback} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {gradients} from 'lib/styles'
@@ -15,11 +14,7 @@ export interface FABProps
   icon: JSX.Element
 }
 
-export const FABInner = observer(function FABInnerImpl({
-  testID,
-  icon,
-  ...props
-}: FABProps) {
+export function FABInner({testID, icon, ...props}: FABProps) {
   const insets = useSafeAreaInsets()
   const {isMobile, isTablet} = useWebMediaQueries()
   const {fabMinimalShellTransform} = useMinimalShellMode()
@@ -55,7 +50,7 @@ export const FABInner = observer(function FABInnerImpl({
       </Animated.View>
     </TouchableWithoutFeedback>
   )
-})
+}
 
 const styles = StyleSheet.create({
   sizeRegular: {
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 270d98317..8f24f8288 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -52,6 +52,7 @@ export function Button({
   accessibilityLabelledBy,
   onAccessibilityEscape,
   withLoading = false,
+  disabled = false,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
@@ -65,6 +66,7 @@ export function Button({
   accessibilityLabelledBy?: string
   onAccessibilityEscape?: () => void
   withLoading?: boolean
+  disabled?: boolean
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -198,7 +200,7 @@ export function Button({
     <Pressable
       style={getStyle}
       onPress={onPressWrapped}
-      disabled={isLoading}
+      disabled={disabled || isLoading}
       testID={testID}
       accessibilityRole="button"
       accessibilityLabel={accessibilityLabel}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index 1bed60b5d..ad8f50f5e 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -17,6 +17,8 @@ import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {HITSLOP_10} from 'lib/constants'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 const ESTIMATED_BTN_HEIGHT = 50
 const ESTIMATED_SEP_HEIGHT = 16
@@ -207,6 +209,7 @@ const DropdownItems = ({
 }: DropDownItemProps) => {
   const pal = usePalette('default')
   const theme = useTheme()
+  const {_} = useLingui()
   const dropDownBackgroundColor =
     theme.colorScheme === 'dark' ? pal.btn : pal.view
   const separatorColor =
@@ -224,7 +227,7 @@ const DropdownItems = ({
       {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
       <TouchableWithoutFeedback
         onPress={onOuterPress}
-        accessibilityLabel="Toggle dropdown"
+        accessibilityLabel={_(msg`Toggle dropdown`)}
         accessibilityHint="">
         <View style={[styles.bg]} />
       </TouchableWithoutFeedback>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1fffa3123..1ba5ae8ae 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,49 +1,101 @@
 import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {Linking, StyleProp, View, ViewStyle} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
 import {toShareUrl} from 'lib/strings/url-helpers'
-import {useStores} from 'state/index'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
 import {
   NativeDropdown,
   DropdownItem as NativeDropdownItem,
 } from './NativeDropdown'
+import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
+import {useModalControls} from '#/state/modals'
+import {makeProfileLink} from '#/lib/routes/links'
+import {getTranslatorLink} from '#/locale/helpers'
+import {usePostDeleteMutation} from '#/state/queries/post'
+import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
+import {logger} from '#/logger'
+import {Shadow} from '#/state/cache/types'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+import {isWeb} from '#/platform/detection'
 
 export function PostDropdownBtn({
   testID,
-  itemUri,
-  itemCid,
-  itemHref,
-  isAuthor,
-  isThreadMuted,
-  onCopyPostText,
-  onOpenTranslate,
-  onToggleThreadMute,
-  onDeletePost,
+  post,
+  record,
   style,
 }: {
   testID: string
-  itemUri: string
-  itemCid: string
-  itemHref: string
-  itemTitle: string
-  isAuthor: boolean
-  isThreadMuted: boolean
-  onCopyPostText: () => void
-  onOpenTranslate: () => void
-  onToggleThreadMute: () => void
-  onDeletePost: () => void
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
+  const {hasSession, currentAccount} = useSession()
   const theme = useTheme()
+  const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
+  const {openModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const mutedThreads = useMutedThreads()
+  const toggleThreadMute = useToggleThreadMute()
+  const postDeleteMutation = usePostDeleteMutation()
+
+  const rootUri = record.reply?.root?.uri || post.uri
+  const isThreadMuted = mutedThreads.includes(rootUri)
+  const isAuthor = post.author.did === currentAccount?.did
+  const href = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+
+  const translatorUrl = getTranslatorLink(
+    record.text,
+    langPrefs.primaryLanguage,
+  )
+
+  const onDeletePost = React.useCallback(() => {
+    postDeleteMutation.mutateAsync({uri: post.uri}).then(
+      () => {
+        Toast.show('Post deleted')
+      },
+      e => {
+        logger.error('Failed to delete post', {error: e})
+        Toast.show('Failed to delete post, please try again')
+      },
+    )
+  }, [post, postDeleteMutation])
+
+  const onToggleThreadMute = React.useCallback(() => {
+    try {
+      const muted = toggleThreadMute(rootUri)
+      if (muted) {
+        Toast.show('You will no longer receive notifications for this thread')
+      } else {
+        Toast.show('You will now receive notifications for this thread')
+      }
+    } catch (e) {
+      logger.error('Failed to toggle thread mute', {error: e})
+    }
+  }, [rootUri, toggleThreadMute])
+
+  const onCopyPostText = React.useCallback(() => {
+    Clipboard.setString(record?.text || '')
+    Toast.show('Copied to clipboard')
+  }, [record])
+
+  const onOpenTranslate = React.useCallback(() => {
+    Linking.openURL(translatorUrl)
+  }, [translatorUrl])
 
   const dropdownItems: NativeDropdownItem[] = [
     {
-      label: 'Translate',
+      label: _(msg`Translate`),
       onPress() {
         onOpenTranslate()
       },
@@ -57,7 +109,7 @@ export function PostDropdownBtn({
       },
     },
     {
-      label: 'Copy post text',
+      label: _(msg`Copy post text`),
       onPress() {
         onCopyPostText()
       },
@@ -71,9 +123,9 @@ export function PostDropdownBtn({
       },
     },
     {
-      label: 'Share',
+      label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`),
       onPress() {
-        const url = toShareUrl(itemHref)
+        const url = toShareUrl(href)
         shareUrl(url)
       },
       testID: 'postDropdownShareBtn',
@@ -85,11 +137,11 @@ export function PostDropdownBtn({
         web: 'share',
       },
     },
-    {
+    hasSession && {
       label: 'separator',
     },
-    {
-      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
+    hasSession && {
+      label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
       onPress() {
         onToggleThreadMute()
       },
@@ -102,37 +154,38 @@ export function PostDropdownBtn({
         web: 'comment-slash',
       },
     },
-    {
+    hasSession && {
       label: 'separator',
     },
-    !isAuthor && {
-      label: 'Report post',
-      onPress() {
-        store.shell.openModal({
-          name: 'report',
-          uri: itemUri,
-          cid: itemCid,
-        })
-      },
-      testID: 'postDropdownReportBtn',
-      icon: {
-        ios: {
-          name: 'exclamationmark.triangle',
+    !isAuthor &&
+      hasSession && {
+        label: _(msg`Report post`),
+        onPress() {
+          openModal({
+            name: 'report',
+            uri: post.uri,
+            cid: post.cid,
+          })
+        },
+        testID: 'postDropdownReportBtn',
+        icon: {
+          ios: {
+            name: 'exclamationmark.triangle',
+          },
+          android: 'ic_menu_report_image',
+          web: 'circle-exclamation',
         },
-        android: 'ic_menu_report_image',
-        web: 'circle-exclamation',
       },
-    },
     isAuthor && {
       label: 'separator',
     },
     isAuthor && {
-      label: 'Delete post',
+      label: _(msg`Delete post`),
       onPress() {
-        store.shell.openModal({
+        openModal({
           name: 'confirm',
-          title: 'Delete this post?',
-          message: 'Are you sure? This can not be undone.',
+          title: _(msg`Delete this post?`),
+          message: _(msg`Are you sure? This cannot be undone.`),
           onPressConfirm: onDeletePost,
         })
       },
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
index c1eb82bd4..02b462b55 100644
--- a/src/view/com/util/forms/SearchInput.tsx
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -14,6 +14,8 @@ import {
 import {MagnifyingGlassIcon} from 'lib/icons'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 interface Props {
   query: string
@@ -33,6 +35,7 @@ export function SearchInput({
 }: Props) {
   const theme = useTheme()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const textInput = React.useRef<TextInput>(null)
 
   const onPressCancelSearchInner = React.useCallback(() => {
@@ -58,7 +61,7 @@ export function SearchInput({
         onChangeText={onChangeQuery}
         onSubmitEditing={onSubmitQuery}
         accessibilityRole="search"
-        accessibilityLabel="Search"
+        accessibilityLabel={_(msg`Search`)}
         accessibilityHint=""
         autoCorrect={false}
         autoCapitalize="none"
@@ -67,7 +70,7 @@ export function SearchInput({
         <TouchableOpacity
           onPress={onPressCancelSearchInner}
           accessibilityRole="button"
-          accessibilityLabel="Clear search query"
+          accessibilityLabel={_(msg`Clear search query`)}
           accessibilityHint="">
           <FontAwesomeIcon
             icon="xmark"
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 6cbcddc32..b5b6c1b52 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
 import {Image} from 'expo-image'
 import {clamp} from 'lib/numbers'
-import {useStores} from 'state/index'
 import {Dimensions} from 'lib/media/types'
+import * as imageSizes from 'lib/media/image-sizes'
 
 const MIN_ASPECT_RATIO = 0.33 // 1/3
 const MAX_ASPECT_RATIO = 5 // 5/1
@@ -29,9 +29,8 @@ export function AutoSizedImage({
   style,
   children = null,
 }: Props) {
-  const store = useStores()
   const [dim, setDim] = React.useState<Dimensions | undefined>(
-    dimensionsHint || store.imageSizes.get(uri),
+    dimensionsHint || imageSizes.get(uri),
   )
   const [aspectRatio, setAspectRatio] = React.useState<number>(
     dim ? calc(dim) : 1,
@@ -41,14 +40,14 @@ export function AutoSizedImage({
     if (dim) {
       return
     }
-    store.imageSizes.fetch(uri).then(newDim => {
+    imageSizes.fetch(uri).then(newDim => {
       if (aborted) {
         return
       }
       setDim(newDim)
       setAspectRatio(calc(newDim))
     })
-  }, [dim, setDim, setAspectRatio, store, uri])
+  }, [dim, setDim, setAspectRatio, uri])
 
   if (onPress || onLongPress || onPressIn) {
     return (
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 4aa6f28de..23e807b6a 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -69,12 +69,12 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
               <GalleryItem {...props} index={0} imageStyle={styles.image} />
             </View>
             <View style={styles.smallItem}>
-              <GalleryItem {...props} index={2} imageStyle={styles.image} />
+              <GalleryItem {...props} index={1} imageStyle={styles.image} />
             </View>
           </View>
           <View style={styles.flexRow}>
             <View style={styles.smallItem}>
-              <GalleryItem {...props} index={1} imageStyle={styles.image} />
+              <GalleryItem {...props} index={2} imageStyle={styles.image} />
             </View>
             <View style={styles.smallItem}>
               <GalleryItem {...props} index={3} imageStyle={styles.image} />
diff --git a/src/view/com/util/layouts/Breakpoints.web.tsx b/src/view/com/util/layouts/Breakpoints.web.tsx
index 5cf73df0c..5106e3e1f 100644
--- a/src/view/com/util/layouts/Breakpoints.web.tsx
+++ b/src/view/com/util/layouts/Breakpoints.web.tsx
@@ -8,13 +8,13 @@ export const TabletOrDesktop = ({children}: React.PropsWithChildren<{}>) => (
   <MediaQuery minWidth={800}>{children}</MediaQuery>
 )
 export const Tablet = ({children}: React.PropsWithChildren<{}>) => (
-  <MediaQuery minWidth={800} maxWidth={1300}>
+  <MediaQuery minWidth={800} maxWidth={1300 - 1}>
     {children}
   </MediaQuery>
 )
 export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) => (
-  <MediaQuery maxWidth={1300}>{children}</MediaQuery>
+  <MediaQuery maxWidth={1300 - 1}>{children}</MediaQuery>
 )
 export const Mobile = ({children}: React.PropsWithChildren<{}>) => (
-  <MediaQuery maxWidth={800}>{children}</MediaQuery>
+  <MediaQuery maxWidth={800 - 1}>{children}</MediaQuery>
 )
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index f9a9387bb..970d3a73a 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -12,7 +11,7 @@ const AnimatedTouchableOpacity =
   Animated.createAnimatedComponent(TouchableOpacity)
 import {isWeb} from 'platform/detection'
 
-export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
+export function LoadLatestBtn({
   onPress,
   label,
   showIndicator,
@@ -44,7 +43,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
       {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
     </AnimatedTouchableOpacity>
   )
-})
+}
 
 const styles = StyleSheet.create({
   loadLatest: {
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 4f917844a..a13aae2b5 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -6,7 +6,9 @@ import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
 import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
 
 export function ContentHider({
   testID,
@@ -22,10 +24,11 @@ export function ContentHider({
   style?: StyleProp<ViewStyle>
   childContainerStyle?: StyleProp<ViewStyle>
 }>) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   const [override, setOverride] = React.useState(false)
+  const {openModal} = useModalControls()
 
   if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
     return (
@@ -43,7 +46,7 @@ export function ContentHider({
           if (!moderation.noOverride) {
             setOverride(v => !v)
           } else {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
@@ -62,14 +65,14 @@ export function ContentHider({
         ]}>
         <Pressable
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
             })
           }}
           accessibilityRole="button"
-          accessibilityLabel="Learn more about this warning"
+          accessibilityLabel={_(msg`Learn more about this warning`)}
           accessibilityHint="">
           <ShieldExclamation size={18} style={pal.text} />
         </Pressable>
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
index 0dba367fc..bc5bf9b32 100644
--- a/src/view/com/util/moderation/PostAlerts.tsx
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -5,7 +5,9 @@ import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export function PostAlerts({
   moderation,
@@ -15,8 +17,9 @@ export function PostAlerts({
   includeMute?: boolean
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   const shouldAlert = !!moderation.cause && moderation.alert
   if (!shouldAlert) {
@@ -27,21 +30,21 @@ export function PostAlerts({
   return (
     <Pressable
       onPress={() => {
-        store.shell.openModal({
+        openModal({
           name: 'moderation-details',
           context: 'content',
           moderation,
         })
       }}
       accessibilityRole="button"
-      accessibilityLabel="Learn more about this warning"
+      accessibilityLabel={_(msg`Learn more about this warning`)}
       accessibilityHint=""
       style={[styles.container, pal.viewLight, style]}>
       <ShieldExclamation style={pal.text} size={16} />
       <Text type="lg" style={[pal.text]}>
         {desc.name}{' '}
         <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
-          Learn More
+          <Trans>Learn More</Trans>
         </Text>
       </Text>
     </Pressable>
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index d224286b0..c2b857f54 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -8,7 +8,9 @@ import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
 import {describeModerationCause} from 'lib/moderation'
 import {ShieldExclamation} from 'lib/icons'
-import {useStores} from 'state/index'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
 
 interface Props extends ComponentProps<typeof Link> {
   // testID?: string
@@ -25,10 +27,11 @@ export function PostHider({
   children,
   ...props
 }: Props) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   const [override, setOverride] = React.useState(false)
+  const {openModal} = useModalControls()
 
   if (!moderation.blur) {
     return (
@@ -63,14 +66,14 @@ export function PostHider({
         ]}>
         <Pressable
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
             })
           }}
           accessibilityRole="button"
-          accessibilityLabel="Learn more about this warning"
+          accessibilityLabel={_(msg`Learn more about this warning`)}
           accessibilityHint="">
           <ShieldExclamation size={18} style={pal.text} />
         </Pressable>
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
index 6b7f4e7ec..d2675ca54 100644
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -8,7 +8,9 @@ import {
   describeModerationCause,
   getProfileModerationCauses,
 } from 'lib/moderation'
-import {useStores} from 'state/index'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 export function ProfileHeaderAlerts({
   moderation,
@@ -17,8 +19,9 @@ export function ProfileHeaderAlerts({
   moderation: ProfileModeration
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   const causes = getProfileModerationCauses(moderation)
   if (!causes.length) {
@@ -34,14 +37,14 @@ export function ProfileHeaderAlerts({
             testID="profileHeaderAlert"
             key={desc.name}
             onPress={() => {
-              store.shell.openModal({
+              openModal({
                 name: 'moderation-details',
                 context: 'content',
                 moderation: {cause},
               })
             }}
             accessibilityRole="button"
-            accessibilityLabel="Learn more about this warning"
+            accessibilityLabel={_(msg`Learn more about this warning`)}
             accessibilityHint=""
             style={[styles.container, pal.viewLight, style]}>
             <ShieldExclamation style={pal.text} size={24} />
@@ -49,7 +52,7 @@ export function ProfileHeaderAlerts({
               {desc.name}
             </Text>
             <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
-              Learn More
+              <Trans>Learn More</Trans>
             </Text>
           </Pressable>
         )
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
index 0224b9fee..946f937e9 100644
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -18,7 +18,10 @@ import {NavigationProp} from 'lib/routes/types'
 import {Text} from '../text/Text'
 import {Button} from '../forms/Button'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {s} from '#/lib/styles'
 
 export function ScreenHider({
   testID,
@@ -34,12 +37,13 @@ export function ScreenHider({
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
-  const store = useStores()
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
+  const {_} = useLingui()
   const [override, setOverride] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   if (!moderation.blur || override) {
     return (
@@ -62,27 +66,26 @@ export function ScreenHider({
         </View>
       </View>
       <Text type="title-2xl" style={[styles.title, pal.text]}>
-        Content Warning
+        <Trans>Content Warning</Trans>
       </Text>
       <Text type="2xl" style={[styles.description, pal.textLight]}>
-        This {screenDescription} has been flagged:{' '}
-        <Text type="2xl-medium" style={pal.text}>
-          {desc.name}
+        <Trans>This {screenDescription} has been flagged:</Trans>
+        <Text type="2xl-medium" style={[pal.text, s.ml5]}>
+          {desc.name}.
         </Text>
-        .{' '}
         <TouchableWithoutFeedback
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'account',
               moderation,
             })
           }}
           accessibilityRole="button"
-          accessibilityLabel="Learn more about this warning"
+          accessibilityLabel={_(msg`Learn more about this warning`)}
           accessibilityHint="">
           <Text type="2xl" style={pal.link}>
-            Learn More
+            <Trans>Learn More</Trans>
           </Text>
         </TouchableWithoutFeedback>
       </Text>
@@ -99,7 +102,7 @@ export function ScreenHider({
           }}
           style={styles.btn}>
           <Text type="button-lg" style={pal.textInverted}>
-            Go back
+            <Trans>Go back</Trans>
           </Text>
         </Button>
         {!moderation.noOverride && (
@@ -108,7 +111,7 @@ export function ScreenHider({
             onPress={() => setOverride(v => !v)}
             style={styles.btn}>
             <Text type="button-lg" style={pal.text}>
-              Show anyway
+              <Trans>Show anyway</Trans>
             </Text>
           </Button>
         )}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 5769a478b..e548c45f7 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,168 +6,174 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
 import {s, colors} from 'lib/styles'
 import {pluralize} from 'lib/strings/helpers'
 import {useTheme} from 'lib/ThemeContext'
-import {useStores} from 'state/index'
 import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
+import {useModalControls} from '#/state/modals'
+import {
+  usePostLikeMutation,
+  usePostUnlikeMutation,
+  usePostRepostMutation,
+  usePostUnrepostMutation,
+} from '#/state/queries/post'
+import {useComposerControls} from '#/state/shell/composer'
+import {Shadow} from '#/state/cache/types'
+import {useRequireAuth} from '#/state/session'
 
-interface PostCtrlsOpts {
-  itemUri: string
-  itemCid: string
-  itemHref: string
-  itemTitle: string
-  isAuthor: boolean
-  author: {
-    did: string
-    handle: string
-    displayName?: string | undefined
-    avatar?: string | undefined
-  }
-  text: string
-  indexedAt: string
+export function PostCtrls({
+  big,
+  post,
+  record,
+  style,
+  onPressReply,
+}: {
   big?: boolean
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
   style?: StyleProp<ViewStyle>
-  replyCount?: number
-  repostCount?: number
-  likeCount?: number
-  isReposted: boolean
-  isLiked: boolean
-  isThreadMuted: boolean
   onPressReply: () => void
-  onPressToggleRepost: () => Promise<void>
-  onPressToggleLike: () => Promise<void>
-  onCopyPostText: () => void
-  onOpenTranslate: () => void
-  onToggleThreadMute: () => void
-  onDeletePost: () => void
-}
-
-export function PostCtrls(opts: PostCtrlsOpts) {
-  const store = useStores()
+}) {
   const theme = useTheme()
+  const {openComposer} = useComposerControls()
+  const {closeModal} = useModalControls()
+  const postLikeMutation = usePostLikeMutation()
+  const postUnlikeMutation = usePostUnlikeMutation()
+  const postRepostMutation = usePostRepostMutation()
+  const postUnrepostMutation = usePostUnrepostMutation()
+  const requireAuth = useRequireAuth()
+
   const defaultCtrlColor = React.useMemo(
     () => ({
       color: theme.palette.default.postCtrl,
     }),
     [theme],
   ) as StyleProp<ViewStyle>
+
+  const onPressToggleLike = React.useCallback(async () => {
+    if (!post.viewer?.like) {
+      Haptics.default()
+      postLikeMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        likeCount: post.likeCount || 0,
+      })
+    } else {
+      postUnlikeMutation.mutate({
+        postUri: post.uri,
+        likeUri: post.viewer.like,
+        likeCount: post.likeCount || 0,
+      })
+    }
+  }, [post, postLikeMutation, postUnlikeMutation])
+
   const onRepost = useCallback(() => {
-    store.shell.closeModal()
-    if (!opts.isReposted) {
+    closeModal()
+    if (!post.viewer?.repost) {
       Haptics.default()
-      opts.onPressToggleRepost().catch(_e => undefined)
+      postRepostMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        repostCount: post.repostCount || 0,
+      })
     } else {
-      opts.onPressToggleRepost().catch(_e => undefined)
+      postUnrepostMutation.mutate({
+        postUri: post.uri,
+        repostUri: post.viewer.repost,
+        repostCount: post.repostCount || 0,
+      })
     }
-  }, [opts, store.shell])
+  }, [post, closeModal, postRepostMutation, postUnrepostMutation])
 
   const onQuote = useCallback(() => {
-    store.shell.closeModal()
-    store.shell.openComposer({
+    closeModal()
+    openComposer({
       quote: {
-        uri: opts.itemUri,
-        cid: opts.itemCid,
-        text: opts.text,
-        author: opts.author,
-        indexedAt: opts.indexedAt,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        indexedAt: post.indexedAt,
       },
     })
     Haptics.default()
-  }, [
-    opts.author,
-    opts.indexedAt,
-    opts.itemCid,
-    opts.itemUri,
-    opts.text,
-    store.shell,
-  ])
-
-  const onPressToggleLikeWrapper = async () => {
-    if (!opts.isLiked) {
-      Haptics.default()
-      await opts.onPressToggleLike().catch(_e => undefined)
-    } else {
-      await opts.onPressToggleLike().catch(_e => undefined)
-    }
-  }
-
+  }, [post, record, openComposer, closeModal])
   return (
-    <View style={[styles.ctrls, opts.style]}>
+    <View style={[styles.ctrls, style]}>
       <TouchableOpacity
         testID="replyBtn"
-        style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
-        onPress={opts.onPressReply}
+        style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
+        onPress={() => {
+          requireAuth(() => onPressReply())
+        }}
         accessibilityRole="button"
-        accessibilityLabel={`Reply (${opts.replyCount} ${
-          opts.replyCount === 1 ? 'reply' : 'replies'
+        accessibilityLabel={`Reply (${post.replyCount} ${
+          post.replyCount === 1 ? 'reply' : 'replies'
         })`}
         accessibilityHint=""
-        hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
         <CommentBottomArrow
-          style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
+          style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
           strokeWidth={3}
-          size={opts.big ? 20 : 15}
+          size={big ? 20 : 15}
         />
-        {typeof opts.replyCount !== 'undefined' ? (
+        {typeof post.replyCount !== 'undefined' ? (
           <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
-            {opts.replyCount}
+            {post.replyCount}
           </Text>
         ) : undefined}
       </TouchableOpacity>
-      <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
+      <RepostButton
+        big={big}
+        isReposted={!!post.viewer?.repost}
+        repostCount={post.repostCount}
+        onRepost={onRepost}
+        onQuote={onQuote}
+      />
       <TouchableOpacity
         testID="likeBtn"
-        style={[styles.ctrl, !opts.big && styles.ctrlPad]}
-        onPress={onPressToggleLikeWrapper}
+        style={[styles.ctrl, !big && styles.ctrlPad]}
+        onPress={() => {
+          requireAuth(() => onPressToggleLike())
+        }}
         accessibilityRole="button"
-        accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
-          opts.likeCount
-        } ${pluralize(opts.likeCount || 0, 'like')})`}
+        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
+          post.likeCount
+        } ${pluralize(post.likeCount || 0, 'like')})`}
         accessibilityHint=""
-        hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
-        {opts.isLiked ? (
-          <HeartIconSolid
-            style={styles.ctrlIconLiked}
-            size={opts.big ? 22 : 16}
-          />
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+        {post.viewer?.like ? (
+          <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
         ) : (
           <HeartIcon
-            style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
+            style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
             strokeWidth={3}
-            size={opts.big ? 20 : 16}
+            size={big ? 20 : 16}
           />
         )}
-        {typeof opts.likeCount !== 'undefined' ? (
+        {typeof post.likeCount !== 'undefined' ? (
           <Text
             testID="likeCount"
             style={
-              opts.isLiked
+              post.viewer?.like
                 ? [s.bold, s.red3, s.f15, s.ml5]
                 : [defaultCtrlColor, s.f15, s.ml5]
             }>
-            {opts.likeCount}
+            {post.likeCount}
           </Text>
         ) : undefined}
       </TouchableOpacity>
-      {opts.big ? undefined : (
+      {big ? undefined : (
         <PostDropdownBtn
           testID="postDropdownBtn"
-          itemUri={opts.itemUri}
-          itemCid={opts.itemCid}
-          itemHref={opts.itemHref}
-          itemTitle={opts.itemTitle}
-          isAuthor={opts.isAuthor}
-          isThreadMuted={opts.isThreadMuted}
-          onCopyPostText={opts.onCopyPostText}
-          onOpenTranslate={opts.onOpenTranslate}
-          onToggleThreadMute={opts.onToggleThreadMute}
-          onDeletePost={opts.onDeletePost}
+          post={post}
+          record={record}
           style={styles.ctrlPad}
         />
       )}
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 9c4ed8e5d..1d34a88ab 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -5,8 +5,9 @@ import {s, colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
 import {pluralize} from 'lib/strings/helpers'
-import {useStores} from 'state/index'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
+import {useModalControls} from '#/state/modals'
+import {useRequireAuth} from '#/state/session'
 
 interface Props {
   isReposted: boolean
@@ -23,8 +24,9 @@ export const RepostButton = ({
   onRepost,
   onQuote,
 }: Props) => {
-  const store = useStores()
   const theme = useTheme()
+  const {openModal} = useModalControls()
+  const requireAuth = useRequireAuth()
 
   const defaultControlColor = React.useMemo(
     () => ({
@@ -34,18 +36,20 @@ export const RepostButton = ({
   )
 
   const onPressToggleRepostWrapper = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'repost',
       onRepost: onRepost,
       onQuote: onQuote,
       isReposted,
     })
-  }, [onRepost, onQuote, isReposted, store.shell])
+  }, [onRepost, onQuote, isReposted, openModal])
 
   return (
     <TouchableOpacity
       testID="repostBtn"
-      onPress={onPressToggleRepostWrapper}
+      onPress={() => {
+        requireAuth(() => onPressToggleRepostWrapper())
+      }}
       style={[styles.control, !big && styles.controlPad]}
       accessibilityRole="button"
       accessibilityLabel={`${
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 57f544d41..329382132 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, StyleSheet, View, ViewStyle, Pressable} from 'react-native'
 import {RepostIcon} from 'lib/icons'
 import {colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
@@ -10,6 +10,10 @@ import {
   DropdownItem as NativeDropdownItem,
 } from '../forms/NativeDropdown'
 import {EventStopper} from '../EventStopper'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useRequireAuth} from '#/state/session'
+import {useSession} from '#/state/session'
 
 interface Props {
   isReposted: boolean
@@ -28,6 +32,9 @@ export const RepostButton = ({
   onQuote,
 }: Props) => {
   const theme = useTheme()
+  const {_} = useLingui()
+  const {hasSession} = useSession()
+  const requireAuth = useRequireAuth()
 
   const defaultControlColor = React.useMemo(
     () => ({
@@ -38,7 +45,7 @@ export const RepostButton = ({
 
   const dropdownItems: NativeDropdownItem[] = [
     {
-      label: isReposted ? 'Undo repost' : 'Repost',
+      label: isReposted ? _(msg`Undo repost`) : _(msg`Repost`),
       testID: 'repostDropdownRepostBtn',
       icon: {
         ios: {name: 'repeat'},
@@ -48,7 +55,7 @@ export const RepostButton = ({
       onPress: onRepost,
     },
     {
-      label: 'Quote post',
+      label: _(msg`Quote post`),
       testID: 'repostDropdownQuoteBtn',
       icon: {
         ios: {name: 'quote.bubble'},
@@ -59,32 +66,46 @@ export const RepostButton = ({
     },
   ]
 
-  return (
+  const inner = (
+    <View
+      style={[
+        styles.control,
+        !big && styles.controlPad,
+        (isReposted
+          ? styles.reposted
+          : defaultControlColor) as StyleProp<ViewStyle>,
+      ]}>
+      <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} />
+      {typeof repostCount !== 'undefined' ? (
+        <Text
+          testID="repostCount"
+          type={isReposted ? 'md-bold' : 'md'}
+          style={styles.repostCount}>
+          {repostCount ?? 0}
+        </Text>
+      ) : undefined}
+    </View>
+  )
+
+  return hasSession ? (
     <EventStopper>
       <NativeDropdown
         items={dropdownItems}
-        accessibilityLabel="Repost or quote post"
+        accessibilityLabel={_(msg`Repost or quote post`)}
         accessibilityHint="">
-        <View
-          style={[
-            styles.control,
-            !big && styles.controlPad,
-            (isReposted
-              ? styles.reposted
-              : defaultControlColor) as StyleProp<ViewStyle>,
-          ]}>
-          <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} />
-          {typeof repostCount !== 'undefined' ? (
-            <Text
-              testID="repostCount"
-              type={isReposted ? 'md-bold' : 'md'}
-              style={styles.repostCount}>
-              {repostCount ?? 0}
-            </Text>
-          ) : undefined}
-        </View>
+        {inner}
       </NativeDropdown>
     </EventStopper>
+  ) : (
+    <Pressable
+      accessibilityRole="button"
+      onPress={() => {
+        requireAuth(() => {})
+      }}
+      accessibilityLabel={_(msg`Repost or quote post`)}
+      accessibilityHint="">
+      {inner}
+    </Pressable>
   )
 }
 
diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
deleted file mode 100644
index 624157436..000000000
--- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React, {useMemo} from 'react'
-import {AppBskyFeedDefs} from '@atproto/api'
-import {usePalette} from 'lib/hooks/usePalette'
-import {StyleSheet} from 'react-native'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-
-export function CustomFeedEmbed({
-  record,
-}: {
-  record: AppBskyFeedDefs.GeneratorView
-}) {
-  const pal = usePalette('default')
-  const store = useStores()
-  const item = useMemo(() => {
-    const model = new FeedSourceModel(store, record.uri)
-    model.hydrateFeedGenerator(record)
-    return model
-  }, [store, record])
-  return (
-    <FeedSourceCard
-      item={item}
-      style={[pal.view, pal.border, styles.customFeedOuter]}
-      showLikes
-    />
-  )
-}
-
-const styles = StyleSheet.create({
-  customFeedOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-    marginTop: 4,
-    paddingHorizontal: 12,
-    paddingVertical: 12,
-  },
-})
diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx
index dbf350039..fc5ad270f 100644
--- a/src/view/com/util/post-embeds/ListEmbed.tsx
+++ b/src/view/com/util/post-embeds/ListEmbed.tsx
@@ -1,12 +1,11 @@
 import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
-import {observer} from 'mobx-react-lite'
 import {ListCard} from 'view/com/lists/ListCard'
 import {AppBskyGraphDefs} from '@atproto/api'
 import {s} from 'lib/styles'
 
-export const ListEmbed = observer(function ListEmbedImpl({
+export function ListEmbed({
   item,
   style,
 }: {
@@ -20,7 +19,7 @@ export const ListEmbed = observer(function ListEmbedImpl({
       <ListCard list={item} style={[style, styles.card]} />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index f82b5b7df..e793f983e 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -12,7 +12,7 @@ import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {ComposerOptsQuote} from 'state/shell/composer'
 import {PostEmbeds} from '.'
 import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 6c13bc2bb..ca3bf1104 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -19,8 +19,7 @@ import {
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
-import {ImagesLightbox} from 'state/models/ui/shell'
-import {useStores} from 'state/index'
+import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {YoutubeEmbed} from './YoutubeEmbed'
@@ -28,9 +27,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
-import {CustomFeedEmbed} from './CustomFeedEmbed'
 import {ListEmbed} from './ListEmbed'
 import {isCauseALabelOnUri} from 'lib/moderation'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -49,7 +48,7 @@ export function PostEmbeds({
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {openLightbox} = useLightboxControls()
   const {isMobile} = useWebMediaQueries()
 
   // quote post with media
@@ -72,7 +71,13 @@ export function PostEmbeds({
     // custom feed embed (i.e. generator view)
     // =
     if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
-      return <CustomFeedEmbed record={embed.record} />
+      return (
+        <FeedSourceCard
+          feedUri={embed.record.uri}
+          style={[pal.view, pal.border, styles.customFeedOuter]}
+          showLikes
+        />
+      )
     }
 
     // list embed
@@ -98,8 +103,8 @@ export function PostEmbeds({
         alt: img.alt,
         aspectRatio: img.aspectRatio,
       }))
-      const openLightbox = (index: number) => {
-        store.shell.openLightbox(new ImagesLightbox(items, index))
+      const _openLightbox = (index: number) => {
+        openLightbox(new ImagesLightbox(items, index))
       }
       const onPressIn = (_: number) => {
         InteractionManager.runAfterInteractions(() => {
@@ -115,7 +120,7 @@ export function PostEmbeds({
               alt={alt}
               uri={thumb}
               dimensionsHint={aspectRatio}
-              onPress={() => openLightbox(0)}
+              onPress={() => _openLightbox(0)}
               onPressIn={() => onPressIn(0)}
               style={[
                 styles.singleImage,
@@ -137,7 +142,7 @@ export function PostEmbeds({
         <View style={[styles.imagesContainer, style]}>
           <ImageLayoutGrid
             images={embed.images}
-            onPress={openLightbox}
+            onPress={_openLightbox}
             onPressIn={onPressIn}
             style={
               embed.images.length === 1
@@ -206,4 +211,11 @@ const styles = StyleSheet.create({
     fontSize: 10,
     fontWeight: 'bold',
   },
+  customFeedOuter: {
+    borderWidth: 1,
+    borderRadius: 8,
+    marginTop: 4,
+    paddingHorizontal: 12,
+    paddingVertical: 12,
+  },
 })
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 74d293ef4..154035f22 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -1,80 +1,114 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {ScrollView} from 'react-native-gesture-handler'
 import {Text} from '../com/util/text/Text'
 import {Button} from '../com/util/forms/Button'
 import * as Toast from '../com/util/Toast'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {CommonNavigatorParams} from 'lib/routes/types'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useFocusEffect} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {useLanguagePrefs} from '#/state/preferences'
+import {
+  useAppPasswordsQuery,
+  useAppPasswordDeleteMutation,
+} from '#/state/queries/app-passwords'
+import {ErrorScreen} from '../com/util/error/ErrorScreen'
+import {cleanError} from '#/lib/strings/errors'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
-export const AppPasswords = withAuthRequired(
-  observer(function AppPasswordsImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {screen} = useAnalytics()
-    const {isTabletOrDesktop} = useWebMediaQueries()
+export function AppPasswords({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {openModal} = useModalControls()
+  const {data: appPasswords, error} = useAppPasswordsQuery()
 
-    useFocusEffect(
-      React.useCallback(() => {
-        screen('AppPasswords')
-        setMinimalShellMode(false)
-      }, [screen, setMinimalShellMode]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('AppPasswords')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    const onAdd = React.useCallback(async () => {
-      store.shell.openModal({name: 'add-app-password'})
-    }, [store])
+  const onAdd = React.useCallback(async () => {
+    openModal({name: 'add-app-password'})
+  }, [openModal])
 
-    // no app passwords (empty) state
-    if (store.me.appPasswords.length === 0) {
-      return (
-        <CenteredView
-          style={[
-            styles.container,
-            isTabletOrDesktop && styles.containerDesktop,
-            pal.view,
-            pal.border,
-          ]}
-          testID="appPasswordsScreen">
-          <AppPasswordsHeader />
-          <View style={[styles.empty, pal.viewLight]}>
-            <Text type="lg" style={[pal.text, styles.emptyText]}>
+  if (error) {
+    return (
+      <CenteredView
+        style={[
+          styles.container,
+          isTabletOrDesktop && styles.containerDesktop,
+          pal.view,
+          pal.border,
+        ]}
+        testID="appPasswordsScreen">
+        <ErrorScreen
+          title="Oops!"
+          message="There was an issue with fetching your app passwords"
+          details={cleanError(error)}
+        />
+      </CenteredView>
+    )
+  }
+
+  // no app passwords (empty) state
+  if (appPasswords?.length === 0) {
+    return (
+      <CenteredView
+        style={[
+          styles.container,
+          isTabletOrDesktop && styles.containerDesktop,
+          pal.view,
+          pal.border,
+        ]}
+        testID="appPasswordsScreen">
+        <AppPasswordsHeader />
+        <View style={[styles.empty, pal.viewLight]}>
+          <Text type="lg" style={[pal.text, styles.emptyText]}>
+            <Trans>
               You have not created any app passwords yet. You can create one by
               pressing the button below.
-            </Text>
-          </View>
-          {!isTabletOrDesktop && <View style={styles.flex1} />}
-          <View
-            style={[
-              styles.btnContainer,
-              isTabletOrDesktop && styles.btnContainerDesktop,
-            ]}>
-            <Button
-              testID="appPasswordBtn"
-              type="primary"
-              label="Add App Password"
-              style={styles.btn}
-              labelStyle={styles.btnLabel}
-              onPress={onAdd}
-            />
-          </View>
-        </CenteredView>
-      )
-    }
+            </Trans>
+          </Text>
+        </View>
+        {!isTabletOrDesktop && <View style={styles.flex1} />}
+        <View
+          style={[
+            styles.btnContainer,
+            isTabletOrDesktop && styles.btnContainerDesktop,
+          ]}>
+          <Button
+            testID="appPasswordBtn"
+            type="primary"
+            label="Add App Password"
+            style={styles.btn}
+            labelStyle={styles.btnLabel}
+            onPress={onAdd}
+          />
+        </View>
+      </CenteredView>
+    )
+  }
 
+  if (appPasswords?.length) {
     // has app passwords
     return (
       <CenteredView
@@ -92,7 +126,7 @@ export const AppPasswords = withAuthRequired(
             pal.border,
             !isTabletOrDesktop && styles.flex1,
           ]}>
-          {store.me.appPasswords.map((password, i) => (
+          {appPasswords.map((password, i) => (
             <AppPassword
               key={password.name}
               testID={`appPassword-${i}`}
@@ -127,15 +161,29 @@ export const AppPasswords = withAuthRequired(
         )}
       </CenteredView>
     )
-  }),
-)
+  }
+
+  return (
+    <CenteredView
+      style={[
+        styles.container,
+        isTabletOrDesktop && styles.containerDesktop,
+        pal.view,
+        pal.border,
+      ]}
+      testID="appPasswordsScreen">
+      <ActivityIndicator />
+    </CenteredView>
+  )
+}
 
 function AppPasswordsHeader() {
   const {isTabletOrDesktop} = useWebMediaQueries()
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <>
-      <ViewHeader title="App Passwords" showOnDesktop />
+      <ViewHeader title={_(msg`App Passwords`)} showOnDesktop />
       <Text
         type="sm"
         style={[
@@ -143,8 +191,10 @@ function AppPasswordsHeader() {
           pal.text,
           isTabletOrDesktop && styles.descriptionDesktop,
         ]}>
-        Use app passwords to login to other Bluesky clients without giving full
-        access to your account or password.
+        <Trans>
+          Use app passwords to login to other Bluesky clients without giving
+          full access to your account or password.
+        </Trans>
       </Text>
     </>
   )
@@ -160,21 +210,24 @@ function AppPassword({
   createdAt: string
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {contentLanguages} = useLanguagePrefs()
+  const deleteMutation = useAppPasswordDeleteMutation()
 
   const onDelete = React.useCallback(async () => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Delete App Password',
-      message: `Are you sure you want to delete the app password "${name}"?`,
+      title: _(msg`Delete app password`),
+      message: _(
+        msg`Are you sure you want to delete the app password "${name}"?`,
+      ),
       async onPressConfirm() {
-        await store.me.deleteAppPassword(name)
+        await deleteMutation.mutateAsync({name})
         Toast.show('App password deleted')
       },
     })
-  }, [store, name])
-
-  const {contentLanguages} = store.preferences
+  }, [deleteMutation, openModal, name, _])
 
   const primaryLocale =
     contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
@@ -185,22 +238,24 @@ function AppPassword({
       style={[styles.item, pal.border]}
       onPress={onDelete}
       accessibilityRole="button"
-      accessibilityLabel="Delete app password"
+      accessibilityLabel={_(msg`Delete app password`)}
       accessibilityHint="">
       <View>
         <Text type="md-bold" style={pal.text}>
           {name}
         </Text>
         <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
-          Created{' '}
-          {Intl.DateTimeFormat(primaryLocale, {
-            year: 'numeric',
-            month: 'numeric',
-            day: 'numeric',
-            hour: '2-digit',
-            minute: '2-digit',
-            second: '2-digit',
-          }).format(new Date(createdAt))}
+          <Trans>
+            Created{' '}
+            {Intl.DateTimeFormat(primaryLocale, {
+              year: 'numeric',
+              month: 'numeric',
+              day: 'numeric',
+              hour: '2-digit',
+              minute: '2-digit',
+              second: '2-digit',
+            }).format(new Date(createdAt))}
+          </Trans>
         </Text>
       </View>
       <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
diff --git a/src/view/screens/CommunityGuidelines.tsx b/src/view/screens/CommunityGuidelines.tsx
index 712172c3b..1931c6f13 100644
--- a/src/view/screens/CommunityGuidelines.tsx
+++ b/src/view/screens/CommunityGuidelines.tsx
@@ -9,6 +9,8 @@ import {ScrollView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<
   CommonNavigatorParams,
@@ -16,6 +18,7 @@ type Props = NativeStackScreenProps<
 >
 export const CommunityGuidelinesScreen = (_props: Props) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
 
   useFocusEffect(
@@ -26,16 +29,18 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Community Guidelines" />
+      <ViewHeader title={_(msg`Community Guidelines`)} />
       <ScrollView style={[s.hContentRegion, pal.view]}>
         <View style={[s.p20]}>
           <Text style={pal.text}>
-            The Community Guidelines have been moved to{' '}
-            <TextLink
-              style={pal.link}
-              href="https://blueskyweb.xyz/support/community-guidelines"
-              text="blueskyweb.xyz/support/community-guidelines"
-            />
+            <Trans>
+              The Community Guidelines have been moved to{' '}
+              <TextLink
+                style={pal.link}
+                href="https://blueskyweb.xyz/support/community-guidelines"
+                text="blueskyweb.xyz/support/community-guidelines"
+              />
+            </Trans>
           </Text>
         </View>
         <View style={s.footerSpacer} />
diff --git a/src/view/screens/CopyrightPolicy.tsx b/src/view/screens/CopyrightPolicy.tsx
index 816c1c1ee..2026f28c6 100644
--- a/src/view/screens/CopyrightPolicy.tsx
+++ b/src/view/screens/CopyrightPolicy.tsx
@@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'CopyrightPolicy'>
 export const CopyrightPolicyScreen = (_props: Props) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
 
   useFocusEffect(
@@ -23,16 +26,18 @@ export const CopyrightPolicyScreen = (_props: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Copyright Policy" />
+      <ViewHeader title={_(msg`Copyright Policy`)} />
       <ScrollView style={[s.hContentRegion, pal.view]}>
         <View style={[s.p20]}>
           <Text style={pal.text}>
-            The Copyright Policy has been moved to{' '}
-            <TextLink
-              style={pal.link}
-              href="https://blueskyweb.xyz/support/community-guidelines"
-              text="blueskyweb.xyz/support/community-guidelines"
-            />
+            <Trans>
+              The Copyright Policy has been moved to{' '}
+              <TextLink
+                style={pal.link}
+                href="https://blueskyweb.xyz/support/community-guidelines"
+                text="blueskyweb.xyz/support/community-guidelines"
+              />
+            </Trans>
           </Text>
         </View>
         <View style={s.footerSpacer} />
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 169660a8f..f319fbc39 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,15 +1,12 @@
 import React from 'react'
-import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Link} from 'view/com/util/Link'
 import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ComposeIcon2, CogIcon} from 'lib/icons'
 import {s} from 'lib/styles'
@@ -22,255 +19,525 @@ import {
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
-import {MyFeedsItem} from 'state/models/ui/my-feeds'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {FlatList} from 'view/com/util/Views'
 import {useFocusEffect} from '@react-navigation/native'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {
+  useFeedSourceInfoQuery,
+  useGetPopularFeedsQuery,
+  useSearchPopularFeedsMutation,
+} from '#/state/queries/feed'
+import {cleanError} from 'lib/strings/errors'
+import {useComposerControls} from '#/state/shell/composer'
+import {useSession} from '#/state/session'
 
 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
-export const FeedsScreen = withAuthRequired(
-  observer<Props>(function FeedsScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
-    const myFeeds = store.me.myFeeds
-    const [query, setQuery] = React.useState<string>('')
-    const debouncedSearchFeeds = React.useMemo(
-      () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms
-      [myFeeds],
-    )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        myFeeds.setup()
+type FlatlistSlice =
+  | {
+      type: 'error'
+      key: string
+      error: string
+    }
+  | {
+      type: 'savedFeedsHeader'
+      key: string
+    }
+  | {
+      type: 'savedFeedsLoading'
+      key: string
+      // pendingItems: number,
+    }
+  | {
+      type: 'savedFeedNoResults'
+      key: string
+    }
+  | {
+      type: 'savedFeed'
+      key: string
+      feedUri: string
+    }
+  | {
+      type: 'savedFeedsLoadMore'
+      key: string
+    }
+  | {
+      type: 'popularFeedsHeader'
+      key: string
+    }
+  | {
+      type: 'popularFeedsLoading'
+      key: string
+    }
+  | {
+      type: 'popularFeedsNoResults'
+      key: string
+    }
+  | {
+      type: 'popularFeed'
+      key: string
+      feedUri: string
+    }
+  | {
+      type: 'popularFeedsLoadingMore'
+      key: string
+    }
 
-        const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh())
-        return () => {
-          softResetSub.remove()
-        }
-      }, [store, myFeeds, setMinimalShellMode]),
+export function FeedsScreen(_props: Props) {
+  const pal = usePalette('default')
+  const {openComposer} = useComposerControls()
+  const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+  const [query, setQuery] = React.useState('')
+  const [isPTR, setIsPTR] = React.useState(false)
+  const {
+    data: preferences,
+    isLoading: isPreferencesLoading,
+    error: preferencesError,
+  } = usePreferencesQuery()
+  const {
+    data: popularFeeds,
+    isFetching: isPopularFeedsFetching,
+    error: popularFeedsError,
+    refetch: refetchPopularFeeds,
+    fetchNextPage: fetchNextPopularFeedsPage,
+    isFetchingNextPage: isPopularFeedsFetchingNextPage,
+    hasNextPage: hasNextPopularFeedsPage,
+  } = useGetPopularFeedsQuery()
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {
+    data: searchResults,
+    mutate: search,
+    reset: resetSearch,
+    isPending: isSearchPending,
+    error: searchError,
+  } = useSearchPopularFeedsMutation()
+  const {hasSession} = useSession()
+
+  /**
+   * A search query is present. We may not have search results yet.
+   */
+  const isUserSearching = query.length > 1
+  const debouncedSearch = React.useMemo(
+    () => debounce(q => search(q), 500), // debounce for 500ms
+    [search],
+  )
+  const onPressCompose = React.useCallback(() => {
+    openComposer({})
+  }, [openComposer])
+  const onChangeQuery = React.useCallback(
+    (text: string) => {
+      setQuery(text)
+      if (text.length > 1) {
+        debouncedSearch(text)
+      } else {
+        refetchPopularFeeds()
+        resetSearch()
+      }
+    },
+    [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch],
+  )
+  const onPressCancelSearch = React.useCallback(() => {
+    setQuery('')
+    refetchPopularFeeds()
+    resetSearch()
+  }, [refetchPopularFeeds, setQuery, resetSearch])
+  const onSubmitQuery = React.useCallback(() => {
+    debouncedSearch(query)
+  }, [query, debouncedSearch])
+  const onPullToRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetchPopularFeeds()
+    setIsPTR(false)
+  }, [setIsPTR, refetchPopularFeeds])
+  const onEndReached = React.useCallback(() => {
+    if (
+      isPopularFeedsFetching ||
+      isUserSearching ||
+      !hasNextPopularFeedsPage ||
+      popularFeedsError
     )
-    React.useEffect(() => {
-      // watch for changes to saved/pinned feeds
-      return myFeeds.registerListeners()
-    }, [myFeeds])
+      return
+    fetchNextPopularFeedsPage()
+  }, [
+    isPopularFeedsFetching,
+    isUserSearching,
+    popularFeedsError,
+    hasNextPopularFeedsPage,
+    fetchNextPopularFeedsPage,
+  ])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  const items = React.useMemo(() => {
+    let slices: FlatlistSlice[] = []
 
-    const onPressCompose = React.useCallback(() => {
-      store.shell.openComposer({})
-    }, [store])
-    const onChangeQuery = React.useCallback(
-      (text: string) => {
-        setQuery(text)
-        if (text.length > 1) {
-          debouncedSearchFeeds(text)
+    if (hasSession) {
+      slices.push({
+        key: 'savedFeedsHeader',
+        type: 'savedFeedsHeader',
+      })
+
+      if (preferencesError) {
+        slices.push({
+          key: 'savedFeedsError',
+          type: 'error',
+          error: cleanError(preferencesError.toString()),
+        })
+      } else {
+        if (isPreferencesLoading || !preferences?.feeds?.saved) {
+          slices.push({
+            key: 'savedFeedsLoading',
+            type: 'savedFeedsLoading',
+            // pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
+          })
         } else {
-          myFeeds.discovery.refresh()
-        }
-      },
-      [debouncedSearchFeeds, myFeeds.discovery],
-    )
-    const onPressCancelSearch = React.useCallback(() => {
-      setQuery('')
-      myFeeds.discovery.refresh()
-    }, [myFeeds])
-    const onSubmitQuery = React.useCallback(() => {
-      debouncedSearchFeeds(query)
-      debouncedSearchFeeds.flush()
-    }, [debouncedSearchFeeds, query])
+          if (preferences?.feeds?.saved.length === 0) {
+            slices.push({
+              key: 'savedFeedNoResults',
+              type: 'savedFeedNoResults',
+            })
+          } else {
+            const {saved, pinned} = preferences.feeds
 
-    const renderHeaderBtn = React.useCallback(() => {
-      return (
-        <Link
-          href="/settings/saved-feeds"
-          hitSlop={10}
-          accessibilityRole="button"
-          accessibilityLabel="Edit Saved Feeds"
-          accessibilityHint="Opens screen to edit Saved Feeds">
-          <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
-        </Link>
-      )
-    }, [pal])
+            slices = slices.concat(
+              pinned.map(uri => ({
+                key: `savedFeed:${uri}`,
+                type: 'savedFeed',
+                feedUri: uri,
+              })),
+            )
 
-    const onRefresh = React.useCallback(() => {
-      myFeeds.refresh()
-    }, [myFeeds])
+            slices = slices.concat(
+              saved
+                .filter(uri => !pinned.includes(uri))
+                .map(uri => ({
+                  key: `savedFeed:${uri}`,
+                  type: 'savedFeed',
+                  feedUri: uri,
+                })),
+            )
+          }
+        }
+      }
+    }
 
-    const renderItem = React.useCallback(
-      ({item}: {item: MyFeedsItem}) => {
-        if (item.type === 'discover-feeds-loading') {
-          return <FeedFeedLoadingPlaceholder />
-        } else if (item.type === 'spinner') {
-          return (
-            <View style={s.p10}>
-              <ActivityIndicator />
-            </View>
-          )
-        } else if (item.type === 'error') {
-          return <ErrorMessage message={item.error} />
-        } else if (item.type === 'saved-feeds-header') {
-          if (!isMobile) {
-            return (
-              <View
-                style={[
-                  pal.view,
-                  styles.header,
-                  pal.border,
-                  {
-                    borderBottomWidth: 1,
-                  },
-                ]}>
-                <Text type="title-lg" style={[pal.text, s.bold]}>
-                  My Feeds
-                </Text>
-                <Link
-                  href="/settings/saved-feeds"
-                  accessibilityLabel="Edit My Feeds"
-                  accessibilityHint="">
-                  <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
-                </Link>
-              </View>
+    slices.push({
+      key: 'popularFeedsHeader',
+      type: 'popularFeedsHeader',
+    })
+
+    if (popularFeedsError || searchError) {
+      slices.push({
+        key: 'popularFeedsError',
+        type: 'error',
+        error: cleanError(
+          popularFeedsError?.toString() ?? searchError?.toString() ?? '',
+        ),
+      })
+    } else {
+      if (isUserSearching) {
+        if (isSearchPending || !searchResults) {
+          slices.push({
+            key: 'popularFeedsLoading',
+            type: 'popularFeedsLoading',
+          })
+        } else {
+          if (!searchResults || searchResults?.length === 0) {
+            slices.push({
+              key: 'popularFeedsNoResults',
+              type: 'popularFeedsNoResults',
+            })
+          } else {
+            slices = slices.concat(
+              searchResults.map(feed => ({
+                key: `popularFeed:${feed.uri}`,
+                type: 'popularFeed',
+                feedUri: feed.uri,
+              })),
             )
           }
-          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 feed={item.feed} />
-        } else if (item.type === 'discover-feeds-header') {
-          return (
-            <>
-              <View
-                style={[
-                  pal.view,
-                  styles.header,
-                  {
-                    marginTop: 16,
-                    paddingLeft: isMobile ? 12 : undefined,
-                    paddingRight: 10,
-                    paddingBottom: isMobile ? 6 : undefined,
-                  },
-                ]}>
-                <Text type="title-lg" style={[pal.text, s.bold]}>
-                  Discover new feeds
-                </Text>
-                {!isMobile && (
-                  <SearchInput
-                    query={query}
-                    onChangeQuery={onChangeQuery}
-                    onPressCancelSearch={onPressCancelSearch}
-                    onSubmitQuery={onSubmitQuery}
-                    style={{flex: 1, maxWidth: 250}}
-                  />
-                )}
-              </View>
-              {isMobile && (
-                <View style={{paddingHorizontal: 8, paddingBottom: 10}}>
-                  <SearchInput
-                    query={query}
-                    onChangeQuery={onChangeQuery}
-                    onPressCancelSearch={onPressCancelSearch}
-                    onSubmitQuery={onSubmitQuery}
-                  />
-                </View>
-              )}
-            </>
-          )
-        } else if (item.type === 'discover-feed') {
-          return (
-            <FeedSourceCard
-              item={item.feed}
-              showSaveBtn
-              showDescription
-              showLikes
-            />
-          )
-        } else if (item.type === 'discover-feeds-no-results') {
+        }
+      } else {
+        if (isPopularFeedsFetching && !popularFeeds?.pages) {
+          slices.push({
+            key: 'popularFeedsLoading',
+            type: 'popularFeedsLoading',
+          })
+        } else {
+          if (
+            !popularFeeds?.pages ||
+            popularFeeds?.pages[0]?.feeds?.length === 0
+          ) {
+            slices.push({
+              key: 'popularFeedsNoResults',
+              type: 'popularFeedsNoResults',
+            })
+          } else {
+            for (const page of popularFeeds.pages || []) {
+              slices = slices.concat(
+                page.feeds
+                  .filter(feed => !preferences?.feeds?.saved.includes(feed.uri))
+                  .map(feed => ({
+                    key: `popularFeed:${feed.uri}`,
+                    type: 'popularFeed',
+                    feedUri: feed.uri,
+                  })),
+              )
+            }
+
+            if (isPopularFeedsFetchingNextPage) {
+              slices.push({
+                key: 'popularFeedsLoadingMore',
+                type: 'popularFeedsLoadingMore',
+              })
+            }
+          }
+        }
+      }
+    }
+
+    return slices
+  }, [
+    hasSession,
+    preferences,
+    isPreferencesLoading,
+    preferencesError,
+    popularFeeds,
+    isPopularFeedsFetching,
+    popularFeedsError,
+    isPopularFeedsFetchingNextPage,
+    searchResults,
+    isSearchPending,
+    searchError,
+    isUserSearching,
+  ])
+
+  const renderHeaderBtn = React.useCallback(() => {
+    return (
+      <Link
+        href="/settings/saved-feeds"
+        hitSlop={10}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Edit Saved Feeds`)}
+        accessibilityHint="Opens screen to edit Saved Feeds">
+        <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
+      </Link>
+    )
+  }, [pal, _])
+
+  const renderItem = React.useCallback(
+    ({item}: {item: FlatlistSlice}) => {
+      if (item.type === 'error') {
+        return <ErrorMessage message={item.error} />
+      } else if (
+        item.type === 'popularFeedsLoadingMore' ||
+        item.type === 'savedFeedsLoading'
+      ) {
+        return (
+          <View style={s.p10}>
+            <ActivityIndicator />
+          </View>
+        )
+      } else if (item.type === 'savedFeedsHeader') {
+        if (!isMobile) {
           return (
             <View
-              style={{
-                paddingHorizontal: 16,
-                paddingTop: 10,
-                paddingBottom: '150%',
-              }}>
-              <Text type="lg" style={pal.textLight}>
-                No results found for "{query}"
+              style={[
+                pal.view,
+                styles.header,
+                pal.border,
+                {
+                  borderBottomWidth: 1,
+                },
+              ]}>
+              <Text type="title-lg" style={[pal.text, s.bold]}>
+                <Trans>My Feeds</Trans>
               </Text>
+              <Link
+                href="/settings/saved-feeds"
+                accessibilityLabel={_(msg`Edit My Feeds`)}
+                accessibilityHint="">
+                <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
+              </Link>
             </View>
           )
         }
-        return null
-      },
-      [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery],
-    )
+        return <View />
+      } else if (item.type === 'savedFeedNoResults') {
+        return (
+          <View
+            style={{
+              paddingHorizontal: 16,
+              paddingTop: 10,
+            }}>
+            <Text type="lg" style={pal.textLight}>
+              <Trans>You don't have any saved feeds!</Trans>
+            </Text>
+          </View>
+        )
+      } else if (item.type === 'savedFeed') {
+        return <SavedFeed feedUri={item.feedUri} />
+      } else if (item.type === 'popularFeedsHeader') {
+        return (
+          <>
+            <View
+              style={[
+                pal.view,
+                styles.header,
+                {
+                  // This is first in the flatlist without a session -esb
+                  marginTop: hasSession ? 16 : 0,
+                  paddingLeft: isMobile ? 12 : undefined,
+                  paddingRight: 10,
+                  paddingBottom: isMobile ? 6 : undefined,
+                },
+              ]}>
+              <Text type="title-lg" style={[pal.text, s.bold]}>
+                <Trans>Discover new feeds</Trans>
+              </Text>
 
-    return (
-      <View style={[pal.view, styles.container]}>
-        {isMobile && (
-          <ViewHeader
-            title="Feeds"
-            canGoBack={false}
-            renderButton={renderHeaderBtn}
-            showBorder
+              {!isMobile && (
+                <SearchInput
+                  query={query}
+                  onChangeQuery={onChangeQuery}
+                  onPressCancelSearch={onPressCancelSearch}
+                  onSubmitQuery={onSubmitQuery}
+                  style={{flex: 1, maxWidth: 250}}
+                />
+              )}
+            </View>
+
+            {isMobile && (
+              <View style={{paddingHorizontal: 8, paddingBottom: 10}}>
+                <SearchInput
+                  query={query}
+                  onChangeQuery={onChangeQuery}
+                  onPressCancelSearch={onPressCancelSearch}
+                  onSubmitQuery={onSubmitQuery}
+                />
+              </View>
+            )}
+          </>
+        )
+      } else if (item.type === 'popularFeedsLoading') {
+        return <FeedFeedLoadingPlaceholder />
+      } else if (item.type === 'popularFeed') {
+        return (
+          <FeedSourceCard
+            feedUri={item.feedUri}
+            showSaveBtn={hasSession}
+            showDescription
+            showLikes
+            pinOnSave
           />
-        )}
+        )
+      } else if (item.type === 'popularFeedsNoResults') {
+        return (
+          <View
+            style={{
+              paddingHorizontal: 16,
+              paddingTop: 10,
+              paddingBottom: '150%',
+            }}>
+            <Text type="lg" style={pal.textLight}>
+              <Trans>No results found for "{query}"</Trans>
+            </Text>
+          </View>
+        )
+      }
+      return null
+    },
+    [
+      _,
+      hasSession,
+      isMobile,
+      pal,
+      query,
+      onChangeQuery,
+      onPressCancelSearch,
+      onSubmitQuery,
+    ],
+  )
 
-        <FlatList
-          style={[!isTabletOrDesktop && s.flex1, styles.list]}
-          data={myFeeds.items}
-          keyExtractor={item => item._reactKey}
-          contentContainerStyle={styles.contentContainer}
-          refreshControl={
-            <RefreshControl
-              refreshing={myFeeds.isRefreshing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          renderItem={renderItem}
-          initialNumToRender={10}
-          onEndReached={() => myFeeds.loadMore()}
-          extraData={myFeeds.isLoading}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
+  return (
+    <View style={[pal.view, styles.container]}>
+      {isMobile && (
+        <ViewHeader
+          title={_(msg`Feeds`)}
+          canGoBack={false}
+          renderButton={renderHeaderBtn}
+          showBorder
         />
+      )}
+
+      {preferences ? <View /> : <ActivityIndicator />}
+
+      <FlatList
+        style={[!isTabletOrDesktop && s.flex1, styles.list]}
+        data={items}
+        keyExtractor={item => item.key}
+        contentContainerStyle={styles.contentContainer}
+        renderItem={renderItem}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTR}
+            onRefresh={isUserSearching ? undefined : onPullToRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+          />
+        }
+        initialNumToRender={10}
+        onEndReached={onEndReached}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+      />
+
+      {hasSession && (
         <FAB
           testID="composeFAB"
           onPress={onPressCompose}
           icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
           accessibilityRole="button"
-          accessibilityLabel="New post"
+          accessibilityLabel={_(msg`New post`)}
           accessibilityHint=""
         />
-      </View>
-    )
-  }),
-)
+      )}
+    </View>
+  )
+}
 
-function SavedFeed({feed}: {feed: FeedSourceModel}) {
+function SavedFeed({feedUri}: {feedUri: string}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!info)
+    return (
+      <SavedFeedLoadingPlaceholder
+        key={`savedFeedLoadingPlaceholder:${feedUri}`}
+      />
+    )
+
   return (
     <Link
-      testID={`saved-feed-${feed.displayName}`}
-      href={feed.href}
+      testID={`saved-feed-${info.displayName}`}
+      href={info.route.href}
       style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
       hoverStyle={pal.viewLight}
-      accessibilityLabel={feed.displayName}
+      accessibilityLabel={info.displayName}
       accessibilityHint=""
       asAnchor
       anchorNoUnderline>
-      {feed.error ? (
+      {error ? (
         <View
           style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
           <FontAwesomeIcon
@@ -279,17 +546,17 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) {
           />
         </View>
       ) : (
-        <UserAvatar type="algo" size={28} avatar={feed.avatar} />
+        <UserAvatar type="algo" size={28} avatar={info.avatar} />
       )}
       <View
         style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
         <Text type="lg-medium" style={pal.text} numberOfLines={1}>
-          {feed.displayName}
+          {info.displayName}
         </Text>
-        {feed.error ? (
+        {error ? (
           <View style={[styles.offlineSlug, pal.borderDark]}>
             <Text type="xs" style={pal.textLight}>
-              Feed offline
+              <Trans>Feed offline</Trans>
             </Text>
           </View>
         ) : null}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index c58175327..e8001e973 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,154 +1,178 @@
 import React from 'react'
-import {useWindowDimensions} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
-import isEqual from 'lodash.isequal'
+import {View, ActivityIndicator, StyleSheet} from 'react-native'
+import {useFocusEffect, useIsFocused} from '@react-navigation/native'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
 import {FeedsTabBar} from '../com/pager/FeedsTabBar'
-import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {useStores} from 'state/index'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {FeedPage} from 'view/com/feeds/FeedPage'
 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
-
-export const POLL_FREQ = 30e3 // 30sec
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
+import {emitSoftReset} from '#/state/events'
+import {useSession} from '#/state/session'
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
-export const HomeScreen = withAuthRequired(
-  observer(function HomeScreenImpl({}: Props) {
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-    const pagerRef = React.useRef<PagerRef>(null)
-    const [selectedPage, setSelectedPage] = React.useState(0)
-    const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([])
-    const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState<
-      string[]
-    >([])
+export function HomeScreen(props: Props) {
+  const {data: preferences} = usePreferencesQuery()
+
+  if (preferences) {
+    return <HomeScreenReady {...props} preferences={preferences} />
+  } else {
+    return (
+      <View style={styles.loading}>
+        <ActivityIndicator size="large" />
+      </View>
+    )
+  }
+}
+
+function HomeScreenReady({
+  preferences,
+}: Props & {
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {hasSession} = useSession()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+  const [selectedPage, setSelectedPage] = React.useState(0)
+  const isPageFocused = useIsFocused()
 
-    React.useEffect(() => {
-      const pinned = store.preferences.pinnedFeeds
+  /**
+   * Used to ensure that we re-compute `customFeeds` AND force a re-render of
+   * the pager with the new order of feeds.
+   */
+  const pinnedFeedOrderKey = JSON.stringify(preferences.feeds.pinned)
 
-      if (isEqual(pinned, requestedCustomFeeds)) {
-        // no changes
-        return
+  const customFeeds = React.useMemo(() => {
+    const pinned = preferences.feeds.pinned
+    const feeds: FeedDescriptor[] = []
+    for (const uri of pinned) {
+      if (uri.includes('app.bsky.feed.generator')) {
+        feeds.push(`feedgen|${uri}`)
+      } else if (uri.includes('app.bsky.graph.list')) {
+        feeds.push(`list|${uri}`)
       }
+    }
+    return feeds
+  }, [preferences.feeds.pinned])
 
-      const feeds = []
-      for (const uri of pinned) {
-        if (uri.includes('app.bsky.feed.generator')) {
-          const model = new PostsFeedModel(store, 'custom', {feed: uri})
-          feeds.push(model)
-        } else if (uri.includes('app.bsky.graph.list')) {
-          const model = new PostsFeedModel(store, 'list', {list: uri})
-          feeds.push(model)
-        }
+  const homeFeedParams = React.useMemo<FeedParams>(() => {
+    return {
+      mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
+      mergeFeedSources: preferences.feeds.saved,
+    }
+  }, [preferences])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+      setDrawerSwipeDisabled(selectedPage > 0)
+      return () => {
+        setDrawerSwipeDisabled(false)
       }
-      pagerRef.current?.setPage(0)
-      setCustomFeeds(feeds)
-      setRequestedCustomFeeds(pinned)
-    }, [
-      store,
-      store.preferences.pinnedFeeds,
-      customFeeds,
-      setCustomFeeds,
-      pagerRef,
-      requestedCustomFeeds,
-      setRequestedCustomFeeds,
-    ])
+    }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]),
+  )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        setDrawerSwipeDisabled(selectedPage > 0)
-        return () => {
-          setDrawerSwipeDisabled(false)
-        }
-      }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]),
-    )
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setSelectedPage(index)
+      setDrawerSwipeDisabled(index > 0)
+    },
+    [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode],
+  )
+
+  const onPressSelected = React.useCallback(() => {
+    emitSoftReset()
+  }, [])
 
-    const onPageSelected = React.useCallback(
-      (index: number) => {
+  const onPageScrollStateChanged = React.useCallback(
+    (state: 'idle' | 'dragging' | 'settling') => {
+      if (state === 'dragging') {
         setMinimalShellMode(false)
-        setSelectedPage(index)
-        setDrawerSwipeDisabled(index > 0)
-      },
-      [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode],
-    )
+      }
+    },
+    [setMinimalShellMode],
+  )
 
-    const onPressSelected = React.useCallback(() => {
-      store.emitScreenSoftReset()
-    }, [store])
+  const renderTabBar = React.useCallback(
+    (props: RenderTabBarFnProps) => {
+      return (
+        <FeedsTabBar
+          key="FEEDS_TAB_BAR"
+          selectedPage={props.selectedPage}
+          onSelect={props.onSelect}
+          testID="homeScreenFeedTabs"
+          onPressSelected={onPressSelected}
+        />
+      )
+    },
+    [onPressSelected],
+  )
 
-    const renderTabBar = React.useCallback(
-      (props: RenderTabBarFnProps) => {
+  const renderFollowingEmptyState = React.useCallback(() => {
+    return <FollowingEmptyState />
+  }, [])
+
+  const renderCustomFeedEmptyState = React.useCallback(() => {
+    return <CustomFeedEmptyState />
+  }, [])
+
+  return hasSession ? (
+    <Pager
+      key={pinnedFeedOrderKey}
+      testID="homeScreen"
+      onPageSelected={onPageSelected}
+      onPageScrollStateChanged={onPageScrollStateChanged}
+      renderTabBar={renderTabBar}
+      tabBarPosition="top">
+      <FeedPage
+        key="1"
+        testID="followingFeedPage"
+        isPageFocused={selectedPage === 0 && isPageFocused}
+        feed={homeFeedParams.mergeFeedEnabled ? 'home' : 'following'}
+        feedParams={homeFeedParams}
+        renderEmptyState={renderFollowingEmptyState}
+        renderEndOfFeed={FollowingEndOfFeed}
+      />
+      {customFeeds.map((f, index) => {
         return (
-          <FeedsTabBar
-            key="FEEDS_TAB_BAR"
-            selectedPage={props.selectedPage}
-            onSelect={props.onSelect}
-            testID="homeScreenFeedTabs"
-            onPressSelected={onPressSelected}
+          <FeedPage
+            key={f}
+            testID="customFeedPage"
+            isPageFocused={selectedPage === 1 + index && isPageFocused}
+            feed={f}
+            renderEmptyState={renderCustomFeedEmptyState}
           />
         )
-      },
-      [onPressSelected],
-    )
-
-    const renderFollowingEmptyState = React.useCallback(() => {
-      return <FollowingEmptyState />
-    }, [])
-
-    const renderCustomFeedEmptyState = React.useCallback(() => {
-      return <CustomFeedEmptyState />
-    }, [])
-
-    return (
-      <Pager
-        ref={pagerRef}
-        testID="homeScreen"
-        onPageSelected={onPageSelected}
-        renderTabBar={renderTabBar}
-        tabBarPosition="top">
-        <FeedPage
-          key="1"
-          testID="followingFeedPage"
-          isPageFocused={selectedPage === 0}
-          feed={store.me.mainFeed}
-          renderEmptyState={renderFollowingEmptyState}
-          renderEndOfFeed={FollowingEndOfFeed}
-        />
-        {customFeeds.map((f, index) => {
-          return (
-            <FeedPage
-              key={f.reactKey}
-              testID="customFeedPage"
-              isPageFocused={selectedPage === 1 + index}
-              feed={f}
-              renderEmptyState={renderCustomFeedEmptyState}
-            />
-          )
-        })}
-      </Pager>
-    )
-  }),
-)
-
-export function useHeaderOffset() {
-  const {isDesktop, isTablet} = useWebMediaQueries()
-  const {fontScale} = useWindowDimensions()
-  if (isDesktop) {
-    return 0
-  }
-  if (isTablet) {
-    return 50
-  }
-  // default text takes 44px, plus 34px of pad
-  // scale the 44px by the font scale
-  return 34 + 44 * fontScale
+      })}
+    </Pager>
+  ) : (
+    <Pager
+      testID="homeScreen"
+      onPageSelected={onPageSelected}
+      onPageScrollStateChanged={onPageScrollStateChanged}
+      renderTabBar={renderTabBar}
+      tabBarPosition="top">
+      <FeedPage
+        testID="customFeedPage"
+        isPageFocused={isPageFocused}
+        feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`}
+        renderEmptyState={renderCustomFeedEmptyState}
+      />
+    </Pager>
+  )
 }
+
+const styles = StyleSheet.create({
+  loading: {
+    height: '100%',
+    alignContent: 'center',
+    justifyContent: 'center',
+    paddingBottom: 100,
+  },
+})
diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx
index a68a3b5e3..7a2e54dc8 100644
--- a/src/view/screens/LanguageSettings.tsx
+++ b/src/view/screens/LanguageSettings.tsx
@@ -1,8 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {Text} from '../com/util/text/Text'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -16,20 +14,25 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useFocusEffect} from '@react-navigation/native'
-import {LANGUAGES} from 'lib/../locale/languages'
+import {APP_LANGUAGES, LANGUAGES} from 'lib/../locale/languages'
 import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
 
-export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
-  _: Props,
-) {
+export function LanguageSettingsScreen(_props: Props) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useLanguagePrefsApi()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {screen, track} = useAnalytics()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {openModal} = useModalControls()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -40,26 +43,37 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
 
   const onPressContentLanguages = React.useCallback(() => {
     track('Settings:ContentlanguagesButtonClicked')
-    store.shell.openModal({name: 'content-languages-settings'})
-  }, [track, store])
+    openModal({name: 'content-languages-settings'})
+  }, [track, openModal])
 
   const onChangePrimaryLanguage = React.useCallback(
     (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
-      store.preferences.setPrimaryLanguage(value)
+      if (langPrefs.primaryLanguage !== value) {
+        setLangPrefs.setPrimaryLanguage(value)
+      }
     },
-    [store.preferences],
+    [langPrefs, setLangPrefs],
+  )
+
+  const onChangeAppLanguage = React.useCallback(
+    (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
+      if (langPrefs.appLanguage !== value) {
+        setLangPrefs.setAppLanguage(value)
+      }
+    },
+    [langPrefs, setLangPrefs],
   )
 
   const myLanguages = React.useMemo(() => {
     return (
-      store.preferences.contentLanguages
+      langPrefs.contentLanguages
         .map(lang => LANGUAGES.find(l => l.code2 === lang))
         .filter(Boolean)
         // @ts-ignore
         .map(l => l.name)
         .join(', ')
     )
-  }, [store.preferences.contentLanguages])
+  }, [langPrefs.contentLanguages])
 
   return (
     <CenteredView
@@ -69,20 +83,114 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
         styles.container,
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
-      <ViewHeader title="Language Settings" showOnDesktop />
+      <ViewHeader title={_(msg`Language Settings`)} showOnDesktop />
 
       <View style={{paddingTop: 20, paddingHorizontal: 20}}>
+        {/* APP LANGUAGE */}
+        <View style={{paddingBottom: 20}}>
+          <Text type="title-sm" style={[pal.text, s.pb5]}>
+            <Trans>App Language</Trans>
+          </Text>
+          <Text style={[pal.text, s.pb10]}>
+            <Trans>
+              Select your app language for the default text to display in the
+              app
+            </Trans>
+          </Text>
+
+          <View style={{position: 'relative'}}>
+            <RNPickerSelect
+              value={langPrefs.appLanguage}
+              onValueChange={onChangeAppLanguage}
+              items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
+                label: l.name,
+                value: l.code2,
+                key: l.code2,
+              }))}
+              style={{
+                inputAndroid: {
+                  backgroundColor: pal.viewLight.backgroundColor,
+                  color: pal.text.color,
+                  fontSize: 14,
+                  letterSpacing: 0.5,
+                  fontWeight: '500',
+                  paddingHorizontal: 14,
+                  paddingVertical: 8,
+                  borderRadius: 24,
+                },
+                inputIOS: {
+                  backgroundColor: pal.viewLight.backgroundColor,
+                  color: pal.text.color,
+                  fontSize: 14,
+                  letterSpacing: 0.5,
+                  fontWeight: '500',
+                  paddingHorizontal: 14,
+                  paddingVertical: 8,
+                  borderRadius: 24,
+                },
+                inputWeb: {
+                  // @ts-ignore web only
+                  cursor: 'pointer',
+                  '-moz-appearance': 'none',
+                  '-webkit-appearance': 'none',
+                  appearance: 'none',
+                  outline: 0,
+                  borderWidth: 0,
+                  backgroundColor: pal.viewLight.backgroundColor,
+                  color: pal.text.color,
+                  fontSize: 14,
+                  letterSpacing: 0.5,
+                  fontWeight: '500',
+                  paddingHorizontal: 14,
+                  paddingVertical: 8,
+                  borderRadius: 24,
+                },
+              }}
+            />
+
+            <View
+              style={{
+                position: 'absolute',
+                top: 1,
+                right: 1,
+                bottom: 1,
+                width: 40,
+                backgroundColor: pal.viewLight.backgroundColor,
+                borderRadius: 24,
+                pointerEvents: 'none',
+                alignItems: 'center',
+                justifyContent: 'center',
+              }}>
+              <FontAwesomeIcon
+                icon="chevron-down"
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+          </View>
+        </View>
+
+        <View
+          style={{
+            height: 1,
+            backgroundColor: pal.border.borderColor,
+            marginBottom: 20,
+          }}
+        />
+
+        {/* PRIMARY LANGUAGE */}
         <View style={{paddingBottom: 20}}>
           <Text type="title-sm" style={[pal.text, s.pb5]}>
-            Primary Language
+            <Trans>Primary Language</Trans>
           </Text>
           <Text style={[pal.text, s.pb10]}>
-            Select your preferred language for translations in your feed.
+            <Trans>
+              Select your preferred language for translations in your feed.
+            </Trans>
           </Text>
 
           <View style={{position: 'relative'}}>
             <RNPickerSelect
-              value={store.preferences.primaryLanguage}
+              value={langPrefs.primaryLanguage}
               onValueChange={onChangePrimaryLanguage}
               items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
                 label: l.name,
@@ -159,13 +267,16 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
           }}
         />
 
+        {/* CONTENT LANGUAGES */}
         <View style={{paddingBottom: 20}}>
           <Text type="title-sm" style={[pal.text, s.pb5]}>
-            Content Languages
+            <Trans>Content Languages</Trans>
           </Text>
           <Text style={[pal.text, s.pb10]}>
-            Select which languages you want your subscribed feeds to include. If
-            none are selected, all languages will be shown.
+            <Trans>
+              Select which languages you want your subscribed feeds to include.
+              If none are selected, all languages will be shown.
+            </Trans>
           </Text>
 
           <Button
@@ -187,7 +298,7 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
       </View>
     </CenteredView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index a64b0ca3b..d28db7c6c 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -3,12 +3,8 @@ import {View} from 'react-native'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
-import {ListsListModel} from 'state/models/lists/lists-list'
-import {ListsList} from 'view/com/lists/ListsList'
+import {MyLists} from '#/view/com/lists/MyLists'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
 import {NavigationProp} from 'lib/routes/types'
@@ -17,78 +13,72 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {Trans} from '@lingui/macro'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
-export const ListsScreen = withAuthRequired(
-  observer(function ListsScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isMobile} = useWebMediaQueries()
-    const navigation = useNavigation<NavigationProp>()
+export function ListsScreen({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {isMobile} = useWebMediaQueries()
+  const navigation = useNavigation<NavigationProp>()
+  const {openModal} = useModalControls()
 
-    const listsLists: ListsListModel = React.useMemo(
-      () => new ListsListModel(store, 'my-curatelists'),
-      [store],
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        listsLists.refresh()
-      }, [listsLists, setMinimalShellMode]),
-    )
+  const onPressNewList = React.useCallback(() => {
+    openModal({
+      name: 'create-or-edit-list',
+      purpose: 'app.bsky.graph.defs#curatelist',
+      onSave: (uri: string) => {
+        try {
+          const urip = new AtUri(uri)
+          navigation.navigate('ProfileList', {
+            name: urip.hostname,
+            rkey: urip.rkey,
+          })
+        } catch {}
+      },
+    })
+  }, [openModal, navigation])
 
-    const onPressNewList = React.useCallback(() => {
-      store.shell.openModal({
-        name: 'create-or-edit-list',
-        purpose: 'app.bsky.graph.defs#curatelist',
-        onSave: (uri: string) => {
-          try {
-            const urip = new AtUri(uri)
-            navigation.navigate('ProfileList', {
-              name: urip.hostname,
-              rkey: urip.rkey,
-            })
-          } catch {}
-        },
-      })
-    }, [store, navigation])
-
-    return (
-      <View style={s.hContentRegion} testID="listsScreen">
-        <SimpleViewHeader
-          showBackButton={isMobile}
-          style={
-            !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
-          }>
-          <View style={{flex: 1}}>
-            <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
-              User Lists
-            </Text>
-            <Text style={pal.textLight}>
-              Public, shareable lists which can drive feeds.
+  return (
+    <View style={s.hContentRegion} testID="listsScreen">
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={
+          !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
+        }>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            <Trans>User Lists</Trans>
+          </Text>
+          <Text style={pal.textLight}>
+            <Trans>Public, shareable lists which can drive feeds.</Trans>
+          </Text>
+        </View>
+        <View>
+          <Button
+            testID="newUserListBtn"
+            type="default"
+            onPress={onPressNewList}
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              gap: 8,
+            }}>
+            <FontAwesomeIcon icon="plus" color={pal.colors.text} />
+            <Text type="button" style={pal.text}>
+              <Trans>New</Trans>
             </Text>
-          </View>
-          <View>
-            <Button
-              testID="newUserListBtn"
-              type="default"
-              onPress={onPressNewList}
-              style={{
-                flexDirection: 'row',
-                alignItems: 'center',
-                gap: 8,
-              }}>
-              <FontAwesomeIcon icon="plus" color={pal.colors.text} />
-              <Text type="button" style={pal.text}>
-                New
-              </Text>
-            </Button>
-          </View>
-        </SimpleViewHeader>
-        <ListsList listsList={listsLists} style={s.flexGrow1} />
-      </View>
-    )
-  }),
-)
+          </Button>
+        </View>
+      </SimpleViewHeader>
+      <MyLists filter="curate" style={s.flexGrow1} />
+    </View>
+  )
+}
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index f524279a5..8680b851b 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ScrollView} from '../com/util/Views'
@@ -11,13 +10,16 @@ import {Text} from '../com/util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {getEntries} from '#/logger/logDump'
 import {ago} from 'lib/strings/time'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 import {useSetMinimalShellMode} from '#/state/shell'
 
-export const LogScreen = observer(function Log({}: NativeStackScreenProps<
+export function LogScreen({}: NativeStackScreenProps<
   CommonNavigatorParams,
   'Log'
 >) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
   const [expanded, setExpanded] = React.useState<string[]>([])
 
@@ -47,7 +49,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
                 <TouchableOpacity
                   style={[styles.entry, pal.border, pal.view]}
                   onPress={toggler(entry.id)}
-                  accessibilityLabel="View debug entry"
+                  accessibilityLabel={_(msg`View debug entry`)}
                   accessibilityHint="Opens additional details for a debug entry">
                   {entry.level === 'debug' ? (
                     <FontAwesomeIcon icon="info" />
@@ -85,7 +87,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
       </ScrollView>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   entry: {
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 142f3bce8..4d8d8cad7 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -5,10 +5,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {CenteredView} from '../com/util/Views'
 import {ViewHeader} from '../com/util/ViewHeader'
@@ -18,101 +15,103 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
-export const ModerationScreen = withAuthRequired(
-  observer(function Moderation({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {screen, track} = useAnalytics()
-    const {isTabletOrDesktop} = useWebMediaQueries()
+export function ModerationScreen({}: Props) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen, track} = useAnalytics()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
-    useFocusEffect(
-      React.useCallback(() => {
-        screen('Moderation')
-        setMinimalShellMode(false)
-      }, [screen, setMinimalShellMode]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Moderation')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    const onPressContentFiltering = React.useCallback(() => {
-      track('Moderation:ContentfilteringButtonClicked')
-      store.shell.openModal({name: 'content-filtering-settings'})
-    }, [track, store])
+  const onPressContentFiltering = React.useCallback(() => {
+    track('Moderation:ContentfilteringButtonClicked')
+    openModal({name: 'content-filtering-settings'})
+  }, [track, openModal])
 
-    return (
-      <CenteredView
-        style={[
-          s.hContentRegion,
-          pal.border,
-          isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
-        ]}
-        testID="moderationScreen">
-        <ViewHeader title="Moderation" showOnDesktop />
-        <View style={styles.spacer} />
-        <TouchableOpacity
-          testID="contentFilteringBtn"
-          style={[styles.linkCard, pal.view]}
-          onPress={onPressContentFiltering}
-          accessibilityRole="tab"
-          accessibilityHint="Content filtering"
-          accessibilityLabel="">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="eye"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            Content filtering
-          </Text>
-        </TouchableOpacity>
-        <Link
-          testID="moderationlistsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/modlists">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="users-slash"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            Moderation lists
-          </Text>
-        </Link>
-        <Link
-          testID="mutedAccountsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/muted-accounts">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="user-slash"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            Muted accounts
-          </Text>
-        </Link>
-        <Link
-          testID="blockedAccountsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/blocked-accounts">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="ban"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            Blocked accounts
-          </Text>
-        </Link>
-      </CenteredView>
-    )
-  }),
-)
+  return (
+    <CenteredView
+      style={[
+        s.hContentRegion,
+        pal.border,
+        isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
+      ]}
+      testID="moderationScreen">
+      <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
+      <View style={styles.spacer} />
+      <TouchableOpacity
+        testID="contentFilteringBtn"
+        style={[styles.linkCard, pal.view]}
+        onPress={onPressContentFiltering}
+        accessibilityRole="tab"
+        accessibilityHint="Content filtering"
+        accessibilityLabel="">
+        <View style={[styles.iconContainer, pal.btn]}>
+          <FontAwesomeIcon
+            icon="eye"
+            style={pal.text as FontAwesomeIconStyle}
+          />
+        </View>
+        <Text type="lg" style={pal.text}>
+          <Trans>Content filtering</Trans>
+        </Text>
+      </TouchableOpacity>
+      <Link
+        testID="moderationlistsBtn"
+        style={[styles.linkCard, pal.view]}
+        href="/moderation/modlists">
+        <View style={[styles.iconContainer, pal.btn]}>
+          <FontAwesomeIcon
+            icon="users-slash"
+            style={pal.text as FontAwesomeIconStyle}
+          />
+        </View>
+        <Text type="lg" style={pal.text}>
+          <Trans>Moderation lists</Trans>
+        </Text>
+      </Link>
+      <Link
+        testID="mutedAccountsBtn"
+        style={[styles.linkCard, pal.view]}
+        href="/moderation/muted-accounts">
+        <View style={[styles.iconContainer, pal.btn]}>
+          <FontAwesomeIcon
+            icon="user-slash"
+            style={pal.text as FontAwesomeIconStyle}
+          />
+        </View>
+        <Text type="lg" style={pal.text}>
+          <Trans>Muted accounts</Trans>
+        </Text>
+      </Link>
+      <Link
+        testID="blockedAccountsBtn"
+        style={[styles.linkCard, pal.view]}
+        href="/moderation/blocked-accounts">
+        <View style={[styles.iconContainer, pal.btn]}>
+          <FontAwesomeIcon
+            icon="ban"
+            style={pal.text as FontAwesomeIconStyle}
+          />
+        </View>
+        <Text type="lg" style={pal.text}>
+          <Trans>Blocked accounts</Trans>
+        </Text>
+      </Link>
+    </CenteredView>
+  )
+}
 
 const styles = StyleSheet.create({
   desktopContainer: {
diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index 0dc3b706b..8f6e2f729 100644
--- a/src/view/screens/ModerationBlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -1,4 +1,4 @@
-import React, {useMemo} from 'react'
+import React from 'react'
 import {
   ActivityIndicator,
   FlatList,
@@ -8,133 +8,165 @@ import {
 } from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {Text} from '../com/util/text/Text'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useFocusEffect} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
+import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts'
+import {cleanError} from '#/lib/strings/errors'
 
 type Props = NativeStackScreenProps<
   CommonNavigatorParams,
   'ModerationBlockedAccounts'
 >
-export const ModerationBlockedAccounts = withAuthRequired(
-  observer(function ModerationBlockedAccountsImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isTabletOrDesktop} = useWebMediaQueries()
-    const {screen} = useAnalytics()
-    const blockedAccounts = useMemo(
-      () => new BlockedAccountsModel(store),
-      [store],
-    )
+export function ModerationBlockedAccounts({}: Props) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {screen} = useAnalytics()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data,
+    isFetching,
+    isError,
+    error,
+    refetch,
+    hasNextPage,
+    fetchNextPage,
+    isFetchingNextPage,
+  } = useMyBlockedAccountsQuery()
+  const isEmpty = !isFetching && !data?.pages[0]?.blocks.length
+  const profiles = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.blocks)
+    }
+    return []
+  }, [data])
 
-    useFocusEffect(
-      React.useCallback(() => {
-        screen('BlockedAccounts')
-        setMinimalShellMode(false)
-        blockedAccounts.refresh()
-      }, [screen, setMinimalShellMode, blockedAccounts]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('BlockedAccounts')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    const onRefresh = React.useCallback(() => {
-      blockedAccounts.refresh()
-    }, [blockedAccounts])
-    const onEndReached = React.useCallback(() => {
-      blockedAccounts
-        .loadMore()
-        .catch(err =>
-          logger.error('Failed to load more blocked accounts', {error: err}),
-        )
-    }, [blockedAccounts])
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh my muted accounts', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-    const renderItem = ({
-      item,
-      index,
-    }: {
-      item: ActorDefs.ProfileView
-      index: number
-    }) => (
-      <ProfileCard
-        testID={`blockedAccount-${index}`}
-        key={item.did}
-        profile={item}
-      />
-    )
-    return (
-      <CenteredView
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more of my muted accounts', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = ({
+    item,
+    index,
+  }: {
+    item: ActorDefs.ProfileView
+    index: number
+  }) => (
+    <ProfileCard
+      testID={`blockedAccount-${index}`}
+      key={item.did}
+      profile={item}
+    />
+  )
+  return (
+    <CenteredView
+      style={[
+        styles.container,
+        isTabletOrDesktop && styles.containerDesktop,
+        pal.view,
+        pal.border,
+      ]}
+      testID="blockedAccountsScreen">
+      <ViewHeader title={_(msg`Blocked Accounts`)} showOnDesktop />
+      <Text
+        type="sm"
         style={[
-          styles.container,
-          isTabletOrDesktop && styles.containerDesktop,
-          pal.view,
-          pal.border,
-        ]}
-        testID="blockedAccountsScreen">
-        <ViewHeader title="Blocked Accounts" showOnDesktop />
-        <Text
-          type="sm"
-          style={[
-            styles.description,
-            pal.text,
-            isTabletOrDesktop && styles.descriptionDesktop,
-          ]}>
+          styles.description,
+          pal.text,
+          isTabletOrDesktop && styles.descriptionDesktop,
+        ]}>
+        <Trans>
           Blocked accounts cannot reply in your threads, mention you, or
           otherwise interact with you. You will not see their content and they
           will be prevented from seeing yours.
-        </Text>
-        {!blockedAccounts.hasContent ? (
-          <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
+        </Trans>
+      </Text>
+      {isEmpty ? (
+        <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
+          {isError ? (
+            <ErrorScreen
+              title="Oops!"
+              message={cleanError(error)}
+              onPressTryAgain={refetch}
+            />
+          ) : (
             <View style={[styles.empty, pal.viewLight]}>
               <Text type="lg" style={[pal.text, styles.emptyText]}>
-                You have not blocked any accounts yet. To block an account, go
-                to their profile and selected "Block account" from the menu on
-                their account.
+                <Trans>
+                  You have not blocked any accounts yet. To block an account, go
+                  to their profile and selected "Block account" from the menu on
+                  their account.
+                </Trans>
               </Text>
             </View>
-          </View>
-        ) : (
-          <FlatList
-            style={[!isTabletOrDesktop && styles.flex1]}
-            data={blockedAccounts.blocks}
-            keyExtractor={(item: ActorDefs.ProfileView) => item.did}
-            refreshControl={
-              <RefreshControl
-                refreshing={blockedAccounts.isRefreshing}
-                onRefresh={onRefresh}
-                tintColor={pal.colors.text}
-                titleColor={pal.colors.text}
-              />
-            }
-            onEndReached={onEndReached}
-            renderItem={renderItem}
-            initialNumToRender={15}
-            // FIXME(dan)
-            // eslint-disable-next-line react/no-unstable-nested-components
-            ListFooterComponent={() => (
-              <View style={styles.footer}>
-                {blockedAccounts.isLoading && <ActivityIndicator />}
-              </View>
-            )}
-            extraData={blockedAccounts.isLoading}
-            // @ts-ignore our .web version only -prf
-            desktopFixedHeight
-          />
-        )}
-      </CenteredView>
-    )
-  }),
-)
+          )}
+        </View>
+      ) : (
+        <FlatList
+          style={[!isTabletOrDesktop && styles.flex1]}
+          data={profiles}
+          keyExtractor={(item: ActorDefs.ProfileView) => item.did}
+          refreshControl={
+            <RefreshControl
+              refreshing={isPTRing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          onEndReached={onEndReached}
+          renderItem={renderItem}
+          initialNumToRender={15}
+          // FIXME(dan)
+
+          ListFooterComponent={() => (
+            <View style={styles.footer}>
+              {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
+            </View>
+          )}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      )}
+    </CenteredView>
+  )
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 8794c6d17..145b35a42 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -3,12 +3,8 @@ import {View} from 'react-native'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
-import {ListsListModel} from 'state/models/lists/lists-list'
-import {ListsList} from 'view/com/lists/ListsList'
+import {MyLists} from '#/view/com/lists/MyLists'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
 import {NavigationProp} from 'lib/routes/types'
@@ -17,78 +13,71 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
-export const ModerationModlistsScreen = withAuthRequired(
-  observer(function ModerationModlistsScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isMobile} = useWebMediaQueries()
-    const navigation = useNavigation<NavigationProp>()
+export function ModerationModlistsScreen({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {isMobile} = useWebMediaQueries()
+  const navigation = useNavigation<NavigationProp>()
+  const {openModal} = useModalControls()
 
-    const mutelists: ListsListModel = React.useMemo(
-      () => new ListsListModel(store, 'my-modlists'),
-      [store],
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        mutelists.refresh()
-      }, [mutelists, setMinimalShellMode]),
-    )
+  const onPressNewList = React.useCallback(() => {
+    openModal({
+      name: 'create-or-edit-list',
+      purpose: 'app.bsky.graph.defs#modlist',
+      onSave: (uri: string) => {
+        try {
+          const urip = new AtUri(uri)
+          navigation.navigate('ProfileList', {
+            name: urip.hostname,
+            rkey: urip.rkey,
+          })
+        } catch {}
+      },
+    })
+  }, [openModal, navigation])
 
-    const onPressNewList = React.useCallback(() => {
-      store.shell.openModal({
-        name: 'create-or-edit-list',
-        purpose: 'app.bsky.graph.defs#modlist',
-        onSave: (uri: string) => {
-          try {
-            const urip = new AtUri(uri)
-            navigation.navigate('ProfileList', {
-              name: urip.hostname,
-              rkey: urip.rkey,
-            })
-          } catch {}
-        },
-      })
-    }, [store, navigation])
-
-    return (
-      <View style={s.hContentRegion} testID="moderationModlistsScreen">
-        <SimpleViewHeader
-          showBackButton={isMobile}
-          style={
-            !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
-          }>
-          <View style={{flex: 1}}>
-            <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
-              Moderation Lists
-            </Text>
-            <Text style={pal.textLight}>
-              Public, shareable lists of users to mute or block in bulk.
+  return (
+    <View style={s.hContentRegion} testID="moderationModlistsScreen">
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={
+          !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
+        }>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            Moderation Lists
+          </Text>
+          <Text style={pal.textLight}>
+            Public, shareable lists of users to mute or block in bulk.
+          </Text>
+        </View>
+        <View>
+          <Button
+            testID="newModListBtn"
+            type="default"
+            onPress={onPressNewList}
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              gap: 8,
+            }}>
+            <FontAwesomeIcon icon="plus" color={pal.colors.text} />
+            <Text type="button" style={pal.text}>
+              New
             </Text>
-          </View>
-          <View>
-            <Button
-              testID="newModListBtn"
-              type="default"
-              onPress={onPressNewList}
-              style={{
-                flexDirection: 'row',
-                alignItems: 'center',
-                gap: 8,
-              }}>
-              <FontAwesomeIcon icon="plus" color={pal.colors.text} />
-              <Text type="button" style={pal.text}>
-                New
-              </Text>
-            </Button>
-          </View>
-        </SimpleViewHeader>
-        <ListsList listsList={mutelists} style={s.flexGrow1} />
-      </View>
-    )
-  }),
-)
+          </Button>
+        </View>
+      </SimpleViewHeader>
+      <MyLists filter="mod" style={s.flexGrow1} />
+    </View>
+  )
+}
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 2fa27ee54..41aee9f2f 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -1,4 +1,4 @@
-import React, {useMemo} from 'react'
+import React from 'react'
 import {
   ActivityIndicator,
   FlatList,
@@ -8,129 +8,164 @@ import {
 } from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {Text} from '../com/util/text/Text'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {MutedAccountsModel} from 'state/models/lists/muted-accounts'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useFocusEffect} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
+import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts'
+import {cleanError} from '#/lib/strings/errors'
 
 type Props = NativeStackScreenProps<
   CommonNavigatorParams,
   'ModerationMutedAccounts'
 >
-export const ModerationMutedAccounts = withAuthRequired(
-  observer(function ModerationMutedAccountsImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isTabletOrDesktop} = useWebMediaQueries()
-    const {screen} = useAnalytics()
-    const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store])
+export function ModerationMutedAccounts({}: Props) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {screen} = useAnalytics()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data,
+    isFetching,
+    isError,
+    error,
+    refetch,
+    hasNextPage,
+    fetchNextPage,
+    isFetchingNextPage,
+  } = useMyMutedAccountsQuery()
+  const isEmpty = !isFetching && !data?.pages[0]?.mutes.length
+  const profiles = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.mutes)
+    }
+    return []
+  }, [data])
 
-    useFocusEffect(
-      React.useCallback(() => {
-        screen('MutedAccounts')
-        setMinimalShellMode(false)
-        mutedAccounts.refresh()
-      }, [screen, setMinimalShellMode, mutedAccounts]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('MutedAccounts')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    const onRefresh = React.useCallback(() => {
-      mutedAccounts.refresh()
-    }, [mutedAccounts])
-    const onEndReached = React.useCallback(() => {
-      mutedAccounts
-        .loadMore()
-        .catch(err =>
-          logger.error('Failed to load more muted accounts', {error: err}),
-        )
-    }, [mutedAccounts])
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh my muted accounts', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-    const renderItem = ({
-      item,
-      index,
-    }: {
-      item: ActorDefs.ProfileView
-      index: number
-    }) => (
-      <ProfileCard
-        testID={`mutedAccount-${index}`}
-        key={item.did}
-        profile={item}
-      />
-    )
-    return (
-      <CenteredView
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more of my muted accounts', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = ({
+    item,
+    index,
+  }: {
+    item: ActorDefs.ProfileView
+    index: number
+  }) => (
+    <ProfileCard
+      testID={`mutedAccount-${index}`}
+      key={item.did}
+      profile={item}
+    />
+  )
+  return (
+    <CenteredView
+      style={[
+        styles.container,
+        isTabletOrDesktop && styles.containerDesktop,
+        pal.view,
+        pal.border,
+      ]}
+      testID="mutedAccountsScreen">
+      <ViewHeader title={_(msg`Muted Accounts`)} showOnDesktop />
+      <Text
+        type="sm"
         style={[
-          styles.container,
-          isTabletOrDesktop && styles.containerDesktop,
-          pal.view,
-          pal.border,
-        ]}
-        testID="mutedAccountsScreen">
-        <ViewHeader title="Muted Accounts" showOnDesktop />
-        <Text
-          type="sm"
-          style={[
-            styles.description,
-            pal.text,
-            isTabletOrDesktop && styles.descriptionDesktop,
-          ]}>
+          styles.description,
+          pal.text,
+          isTabletOrDesktop && styles.descriptionDesktop,
+        ]}>
+        <Trans>
           Muted accounts have their posts removed from your feed and from your
           notifications. Mutes are completely private.
-        </Text>
-        {!mutedAccounts.hasContent ? (
-          <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
+        </Trans>
+      </Text>
+      {isEmpty ? (
+        <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
+          {isError ? (
+            <ErrorScreen
+              title="Oops!"
+              message={cleanError(error)}
+              onPressTryAgain={refetch}
+            />
+          ) : (
             <View style={[styles.empty, pal.viewLight]}>
               <Text type="lg" style={[pal.text, styles.emptyText]}>
-                You have not muted any accounts yet. To mute an account, go to
-                their profile and selected "Mute account" from the menu on their
-                account.
+                <Trans>
+                  You have not muted any accounts yet. To mute an account, go to
+                  their profile and selected "Mute account" from the menu on
+                  their account.
+                </Trans>
               </Text>
             </View>
-          </View>
-        ) : (
-          <FlatList
-            style={[!isTabletOrDesktop && styles.flex1]}
-            data={mutedAccounts.mutes}
-            keyExtractor={item => item.did}
-            refreshControl={
-              <RefreshControl
-                refreshing={mutedAccounts.isRefreshing}
-                onRefresh={onRefresh}
-                tintColor={pal.colors.text}
-                titleColor={pal.colors.text}
-              />
-            }
-            onEndReached={onEndReached}
-            renderItem={renderItem}
-            initialNumToRender={15}
-            // FIXME(dan)
-            // eslint-disable-next-line react/no-unstable-nested-components
-            ListFooterComponent={() => (
-              <View style={styles.footer}>
-                {mutedAccounts.isLoading && <ActivityIndicator />}
-              </View>
-            )}
-            extraData={mutedAccounts.isLoading}
-            // @ts-ignore our .web version only -prf
-            desktopFixedHeight
-          />
-        )}
-      </CenteredView>
-    )
-  }),
-)
+          )}
+        </View>
+      ) : (
+        <FlatList
+          style={[!isTabletOrDesktop && styles.flex1]}
+          data={profiles}
+          keyExtractor={item => item.did}
+          refreshControl={
+            <RefreshControl
+              refreshing={isPTRing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          onEndReached={onEndReached}
+          renderItem={renderItem}
+          initialNumToRender={15}
+          // FIXME(dan)
+
+          ListFooterComponent={() => (
+            <View style={styles.footer}>
+              {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
+            </View>
+          )}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      )}
+    </CenteredView>
+  )
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx
index c2125756c..2508a9ed2 100644
--- a/src/view/screens/NotFound.tsx
+++ b/src/view/screens/NotFound.tsx
@@ -12,9 +12,12 @@ import {NavigationProp} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export const NotFoundScreen = () => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
   const setMinimalShellMode = useSetMinimalShellMode()
 
@@ -36,13 +39,15 @@ export const NotFoundScreen = () => {
 
   return (
     <View testID="notFoundView" style={pal.view}>
-      <ViewHeader title="Page not found" />
+      <ViewHeader title={_(msg`Page not found`)} />
       <View style={styles.container}>
         <Text type="title-2xl" style={[pal.text, s.mb10]}>
-          Page not found
+          <Trans>Page not found</Trans>
         </Text>
         <Text type="md" style={[pal.text, s.mb10]}>
-          We're sorry! We can't find the page you were looking for.
+          <Trans>
+            We're sorry! We can't find the page you were looking for.
+          </Trans>
         </Text>
         <Button
           type="primary"
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index cd482bd1c..3ce1128a6 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,166 +1,135 @@
 import React from 'react'
 import {FlatList, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
+import {useQueryClient} from '@tanstack/react-query'
 import {
   NativeStackScreenProps,
   NotificationsTabNavigatorParams,
 } from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
 import {TextLink} from 'view/com/util/Link'
-import {InvitedUsers} from '../com/notifications/InvitedUsers'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
-import {useStores} from 'state/index'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {isWeb} from 'platform/detection'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  useUnreadNotifications,
+  useUnreadNotificationsApi,
+} from '#/state/queries/notifications/unread'
+import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
+import {listenSoftReset, emitSoftReset} from '#/state/events'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
 type Props = NativeStackScreenProps<
   NotificationsTabNavigatorParams,
   'Notifications'
 >
-export const NotificationsScreen = withAuthRequired(
-  observer(function NotificationsScreenImpl({}: Props) {
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
-    const scrollElRef = React.useRef<FlatList>(null)
-    const {screen} = useAnalytics()
-    const pal = usePalette('default')
-    const {isDesktop} = useWebMediaQueries()
-
-    const hasNew =
-      store.me.notifications.hasNewLatest &&
-      !store.me.notifications.isRefreshing
-
-    // event handlers
-    // =
-    const onPressTryAgain = React.useCallback(() => {
-      store.me.notifications.refresh()
-    }, [store])
-
-    const scrollToTop = React.useCallback(() => {
-      scrollElRef.current?.scrollToOffset({offset: 0})
-      resetMainScroll()
-    }, [scrollElRef, resetMainScroll])
+export function NotificationsScreen({}: Props) {
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
+  const scrollElRef = React.useRef<FlatList>(null)
+  const {screen} = useAnalytics()
+  const pal = usePalette('default')
+  const {isDesktop} = useWebMediaQueries()
+  const queryClient = useQueryClient()
+  const unreadNotifs = useUnreadNotifications()
+  const unreadApi = useUnreadNotificationsApi()
+  const hasNew = !!unreadNotifs
 
-    const onPressLoadLatest = React.useCallback(() => {
-      scrollToTop()
-      store.me.notifications.refresh()
-    }, [store, scrollToTop])
+  // event handlers
+  // =
+  const scrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: 0})
+    resetMainScroll()
+  }, [scrollElRef, resetMainScroll])
 
-    // on-visible setup
-    // =
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        logger.debug('NotificationsScreen: Updating feed')
-        const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
-        store.me.notifications.update()
-        screen('Notifications')
+  const onPressLoadLatest = React.useCallback(() => {
+    scrollToTop()
+    if (hasNew) {
+      // render what we have now
+      truncateAndInvalidate(queryClient, NOTIFS_RQKEY())
+    } else {
+      // check with the server
+      unreadApi.checkUnread({invalidate: true})
+    }
+  }, [scrollToTop, queryClient, unreadApi, hasNew])
 
-        return () => {
-          softResetSub.remove()
-          store.me.notifications.markAllRead()
-        }
-      }, [store, screen, onPressLoadLatest, setMinimalShellMode]),
-    )
+  // on-visible setup
+  // =
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+      logger.debug('NotificationsScreen: Updating feed')
+      screen('Notifications')
+      return listenSoftReset(onPressLoadLatest)
+    }, [screen, onPressLoadLatest, setMinimalShellMode]),
+  )
 
-    useTabFocusEffect(
-      'Notifications',
-      React.useCallback(
-        isInside => {
-          // on mobile:
-          // fires with `isInside=true` when the user navigates to the root tab
-          // but not when the user goes back to the screen by pressing back
-          // on web:
-          // essentially equivalent to useFocusEffect because we dont used tabbed
-          // navigation
-          if (isInside) {
-            if (isWeb) {
-              store.me.notifications.syncQueue()
-            } else {
-              if (store.me.notifications.unreadCount > 0) {
-                store.me.notifications.refresh()
-              } else {
-                store.me.notifications.syncQueue()
-              }
+  const ListHeaderComponent = React.useCallback(() => {
+    if (isDesktop) {
+      return (
+        <View
+          style={[
+            pal.view,
+            {
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingHorizontal: 18,
+              paddingVertical: 12,
+            },
+          ]}>
+          <TextLink
+            type="title-lg"
+            href="/notifications"
+            style={[pal.text, {fontWeight: 'bold'}]}
+            text={
+              <>
+                <Trans>Notifications</Trans>{' '}
+                {hasNew && (
+                  <View
+                    style={{
+                      top: -8,
+                      backgroundColor: colors.blue3,
+                      width: 8,
+                      height: 8,
+                      borderRadius: 4,
+                    }}
+                  />
+                )}
+              </>
             }
-          }
-        },
-        [store],
-      ),
-    )
-
-    const ListHeaderComponent = React.useCallback(() => {
-      if (isDesktop) {
-        return (
-          <View
-            style={[
-              pal.view,
-              {
-                flexDirection: 'row',
-                alignItems: 'center',
-                justifyContent: 'space-between',
-                paddingHorizontal: 18,
-                paddingVertical: 12,
-              },
-            ]}>
-            <TextLink
-              type="title-lg"
-              href="/notifications"
-              style={[pal.text, {fontWeight: 'bold'}]}
-              text={
-                <>
-                  Notifications{' '}
-                  {hasNew && (
-                    <View
-                      style={{
-                        top: -8,
-                        backgroundColor: colors.blue3,
-                        width: 8,
-                        height: 8,
-                        borderRadius: 4,
-                      }}
-                    />
-                  )}
-                </>
-              }
-              onPress={() => store.emitScreenSoftReset()}
-            />
-          </View>
-        )
-      }
-      return <></>
-    }, [isDesktop, pal, store, hasNew])
+            onPress={emitSoftReset}
+          />
+        </View>
+      )
+    }
+    return <></>
+  }, [isDesktop, pal, hasNew])
 
-    return (
-      <View testID="notificationsScreen" style={s.hContentRegion}>
-        <ViewHeader title="Notifications" canGoBack={false} />
-        <InvitedUsers />
-        <Feed
-          view={store.me.notifications}
-          onPressTryAgain={onPressTryAgain}
-          onScroll={onMainScroll}
-          scrollElRef={scrollElRef}
-          ListHeaderComponent={ListHeaderComponent}
+  return (
+    <View testID="notificationsScreen" style={s.hContentRegion}>
+      <ViewHeader title={_(msg`Notifications`)} canGoBack={false} />
+      <Feed
+        onScroll={onMainScroll}
+        scrollElRef={scrollElRef}
+        ListHeaderComponent={ListHeaderComponent}
+      />
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onPressLoadLatest}
+          label={_(msg`Load new notifications`)}
+          showIndicator={hasNew}
         />
-        {(isScrolledDown || hasNew) && (
-          <LoadLatestBtn
-            onPress={onPressLoadLatest}
-            label="Load new notifications"
-            showIndicator={hasNew}
-          />
-        )}
-      </View>
-    )
-  }),
-)
+      )}
+    </View>
+  )
+}
diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx
index 2f45908b3..7cbb81102 100644
--- a/src/view/screens/PostLikedBy.tsx
+++ b/src/view/screens/PostLikedBy.tsx
@@ -2,17 +2,19 @@ import React from 'react'
 import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
-export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
+export const PostLikedByScreen = ({route}: Props) => {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -22,8 +24,8 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Liked by" />
+      <ViewHeader title={_(msg`Liked by`)} />
       <PostLikedByComponent uri={uri} />
     </View>
   )
-})
+}
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx
index abe03467a..de95f33bf 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/view/screens/PostRepostedBy.tsx
@@ -1,18 +1,20 @@
 import React from 'react'
 import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
-export const PostRepostedByScreen = withAuthRequired(({route}: Props) => {
+export const PostRepostedByScreen = ({route}: Props) => {
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -22,8 +24,8 @@ export const PostRepostedByScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Reposted by" />
+      <ViewHeader title={_(msg`Reposted by`)} />
       <PostRepostedByComponent uri={uri} />
     </View>
   )
-})
+}
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 0bdd06269..4b1f51748 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,104 +1,107 @@
-import React, {useMemo} from 'react'
-import {InteractionManager, StyleSheet, View} from 'react-native'
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {useFocusEffect} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
+import {useQueryClient} from '@tanstack/react-query'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
 import {ComposePrompt} from 'view/com/composer/Prompt'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {
+  RQKEY as POST_THREAD_RQKEY,
+  ThreadNode,
+} from '#/state/queries/post-thread'
 import {clamp} from 'lodash'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {logger} from '#/logger'
-import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
-
-const SHELL_FOOTER_HEIGHT = 44
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {ErrorMessage} from '../com/util/error/ErrorMessage'
+import {CenteredView} from '../com/util/Views'
+import {useComposerControls} from '#/state/shell/composer'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
-export const PostThreadScreen = withAuthRequired(
-  observer(function PostThreadScreenImpl({route}: Props) {
-    const store = useStores()
-    const minimalShellMode = useMinimalShellMode()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const safeAreaInsets = useSafeAreaInsets()
-    const {name, rkey} = route.params
-    const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
-    const view = useMemo<PostThreadModel>(
-      () => new PostThreadModel(store, {uri}),
-      [store, uri],
-    )
-    const {isMobile} = useWebMediaQueries()
-
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        const threadCleanup = view.registerListeners()
+export function PostThreadScreen({route}: Props) {
+  const queryClient = useQueryClient()
+  const {_} = useLingui()
+  const {fabMinimalShellTransform} = useMinimalShellMode()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {openComposer} = useComposerControls()
+  const safeAreaInsets = useSafeAreaInsets()
+  const {name, rkey} = route.params
+  const {isMobile} = useWebMediaQueries()
+  const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
+  const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
 
-        InteractionManager.runAfterInteractions(() => {
-          if (!view.hasLoaded && !view.isLoading) {
-            view.setup().catch(err => {
-              logger.error('Failed to fetch thread', {error: err})
-            })
-          }
-        })
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-        return () => {
-          threadCleanup()
-        }
-      }, [view, setMinimalShellMode]),
+  const onPressReply = React.useCallback(() => {
+    if (!resolvedUri) {
+      return
+    }
+    const thread = queryClient.getQueryData<ThreadNode>(
+      POST_THREAD_RQKEY(resolvedUri.uri),
     )
-
-    const onPressReply = React.useCallback(() => {
-      if (!view.thread) {
-        return
-      }
-      store.shell.openComposer({
-        replyTo: {
-          uri: view.thread.post.uri,
-          cid: view.thread.post.cid,
-          text: view.thread.postRecord?.text as string,
-          author: {
-            handle: view.thread.post.author.handle,
-            displayName: view.thread.post.author.displayName,
-            avatar: view.thread.post.author.avatar,
-          },
+    if (thread?.type !== 'post') {
+      return
+    }
+    openComposer({
+      replyTo: {
+        uri: thread.post.uri,
+        cid: thread.post.cid,
+        text: thread.record.text,
+        author: {
+          handle: thread.post.author.handle,
+          displayName: thread.post.author.displayName,
+          avatar: thread.post.author.avatar,
         },
-        onPost: () => view.refresh(),
-      })
-    }, [view, store])
+      },
+      onPost: () =>
+        queryClient.invalidateQueries({
+          queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''),
+        }),
+    })
+  }, [openComposer, queryClient, resolvedUri])
 
-    return (
-      <View style={s.hContentRegion}>
-        {isMobile && <ViewHeader title="Post" />}
-        <View style={s.flex1}>
+  return (
+    <View style={s.hContentRegion}>
+      {isMobile && <ViewHeader title={_(msg`Post`)} />}
+      <View style={s.flex1}>
+        {uriError ? (
+          <CenteredView>
+            <ErrorMessage message={String(uriError)} />
+          </CenteredView>
+        ) : (
           <PostThreadComponent
-            uri={uri}
-            view={view}
+            uri={resolvedUri?.uri}
             onPressReply={onPressReply}
-            treeView={!!store.preferences.thread.lab_treeViewEnabled}
           />
-        </View>
-        {isMobile && !minimalShellMode && (
-          <View
-            style={[
-              styles.prompt,
-              {
-                bottom:
-                  SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30),
-              },
-            ]}>
-            <ComposePrompt onPressCompose={onPressReply} />
-          </View>
         )}
       </View>
-    )
-  }),
-)
+      {isMobile && (
+        <Animated.View
+          style={[
+            styles.prompt,
+            fabMinimalShellTransform,
+            {
+              bottom: clamp(safeAreaInsets.bottom, 15, 30),
+            },
+          ]}>
+          <ComposePrompt onPressCompose={onPressReply} />
+        </Animated.View>
+      )}
+    </View>
+  )
+}
 
 const styles = StyleSheet.create({
   prompt: {
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 21c15931f..fe17be5e8 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -1,10 +1,8 @@
 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'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -14,21 +12,33 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
 import debounce from 'lodash.debounce'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePreferencesQuery,
+  useSetFeedViewPreferencesMutation,
+} from '#/state/queries/preferences'
 
-function RepliesThresholdInput({enabled}: {enabled: boolean}) {
-  const store = useStores()
+function RepliesThresholdInput({
+  enabled,
+  initialValue,
+}: {
+  enabled: boolean
+  initialValue: number
+}) {
   const pal = usePalette('default')
-  const [value, setValue] = useState(
-    store.preferences.homeFeed.hideRepliesByLikeCount,
-  )
+  const [value, setValue] = useState(initialValue)
+  const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
   const save = React.useMemo(
     () =>
       debounce(
         threshold =>
-          store.preferences.setHomeFeedHideRepliesByLikeCount(threshold),
+          setFeedViewPref({
+            hideRepliesByLikeCount: threshold,
+          }),
         500,
       ), // debouce for 500ms
-    [store],
+    [setFeedViewPref],
   )
 
   return (
@@ -61,12 +71,17 @@ type Props = NativeStackScreenProps<
   CommonNavigatorParams,
   'PreferencesHomeFeed'
 >
-export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
-  navigation,
-}: Props) {
+export function PreferencesHomeFeed({navigation}: Props) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
   const {isTabletOrDesktop} = useWebMediaQueries()
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate: setFeedViewPref, variables} =
+    useSetFeedViewPreferencesMutation()
+
+  const showReplies = !(
+    variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies
+  )
 
   return (
     <CenteredView
@@ -77,14 +92,14 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
         styles.container,
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
-      <ViewHeader title="Home Feed Preferences" showOnDesktop />
+      <ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop />
       <View
         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.
+          <Trans>Fine-tune the content you see on your home screen.</Trans>
         </Text>
       </View>
 
@@ -92,98 +107,175 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
         <View style={styles.cardsContainer}>
           <View style={[pal.viewLight, styles.card]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              Show Replies
+              <Trans>Show Replies</Trans>
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Set this setting to "No" to hide all replies from your feed.
+              <Trans>
+                Set this setting to "No" to hide all replies from your feed.
+              </Trans>
             </Text>
             <ToggleButton
               testID="toggleRepliesBtn"
               type="default-light"
-              label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'}
-              isSelected={!store.preferences.homeFeed.hideReplies}
-              onPress={store.preferences.toggleHomeFeedHideReplies}
+              label={showReplies ? 'Yes' : 'No'}
+              isSelected={showReplies}
+              onPress={() =>
+                setFeedViewPref({
+                  hideReplies: !(
+                    variables?.hideReplies ??
+                    preferences?.feedViewPrefs?.hideReplies
+                  ),
+                })
+              }
             />
           </View>
           <View
-            style={[
-              pal.viewLight,
-              styles.card,
-              store.preferences.homeFeed.hideReplies && styles.dimmed,
-            ]}>
+            style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              Reply Filters
+              <Trans>Reply Filters</Trans>
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Enable this setting to only see replies between people you follow.
+              <Trans>
+                Enable this setting to only see replies between people you
+                follow.
+              </Trans>
             </Text>
             <ToggleButton
               type="default-light"
-              label="Followed users only"
-              isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed}
+              label={_(msg`Followed users only`)}
+              isSelected={Boolean(
+                variables?.hideRepliesByUnfollowed ??
+                  preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
+              )}
               onPress={
-                !store.preferences.homeFeed.hideReplies
-                  ? store.preferences.toggleHomeFeedHideRepliesByUnfollowed
+                showReplies
+                  ? () =>
+                      setFeedViewPref({
+                        hideRepliesByUnfollowed: !(
+                          variables?.hideRepliesByUnfollowed ??
+                          preferences?.feedViewPrefs?.hideRepliesByUnfollowed
+                        ),
+                      })
                   : undefined
               }
               style={[s.mb10]}
             />
             <Text style={[pal.text]}>
-              Adjust the number of likes a reply must have to be shown in your
-              feed.
+              <Trans>
+                Adjust the number of likes a reply must have to be shown in your
+                feed.
+              </Trans>
             </Text>
-            <RepliesThresholdInput
-              enabled={!store.preferences.homeFeed.hideReplies}
-            />
+            {preferences && (
+              <RepliesThresholdInput
+                enabled={showReplies}
+                initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
+              />
+            )}
           </View>
 
           <View style={[pal.viewLight, styles.card]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              Show Reposts
+              <Trans>Show Reposts</Trans>
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Set this setting to "No" to hide all reposts from your feed.
+              <Trans>
+                Set this setting to "No" to hide all reposts from your feed.
+              </Trans>
             </Text>
             <ToggleButton
               type="default-light"
-              label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'}
-              isSelected={!store.preferences.homeFeed.hideReposts}
-              onPress={store.preferences.toggleHomeFeedHideReposts}
+              label={
+                variables?.hideReposts ??
+                preferences?.feedViewPrefs?.hideReposts
+                  ? _(msg`No`)
+                  : _(msg`Yes`)
+              }
+              isSelected={
+                !(
+                  variables?.hideReposts ??
+                  preferences?.feedViewPrefs?.hideReposts
+                )
+              }
+              onPress={() =>
+                setFeedViewPref({
+                  hideReposts: !(
+                    variables?.hideReposts ??
+                    preferences?.feedViewPrefs?.hideReposts
+                  ),
+                })
+              }
             />
           </View>
 
           <View style={[pal.viewLight, styles.card]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              Show Quote Posts
+              <Trans>Show Quote Posts</Trans>
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Set this setting to "No" to hide all quote posts from your feed.
-              Reposts will still be visible.
+              <Trans>
+                Set this setting to "No" to hide all quote posts from your feed.
+                Reposts will still be visible.
+              </Trans>
             </Text>
             <ToggleButton
               type="default-light"
-              label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'}
-              isSelected={!store.preferences.homeFeed.hideQuotePosts}
-              onPress={store.preferences.toggleHomeFeedHideQuotePosts}
+              label={
+                variables?.hideQuotePosts ??
+                preferences?.feedViewPrefs?.hideQuotePosts
+                  ? _(msg`No`)
+                  : _(msg`Yes`)
+              }
+              isSelected={
+                !(
+                  variables?.hideQuotePosts ??
+                  preferences?.feedViewPrefs?.hideQuotePosts
+                )
+              }
+              onPress={() =>
+                setFeedViewPref({
+                  hideQuotePosts: !(
+                    variables?.hideQuotePosts ??
+                    preferences?.feedViewPrefs?.hideQuotePosts
+                  ),
+                })
+              }
             />
           </View>
 
           <View style={[pal.viewLight, styles.card]}>
             <Text type="title-sm" style={[pal.text, s.pb5]}>
-              <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show
-              Posts from My Feeds
+              <FontAwesomeIcon icon="flask" color={pal.colors.text} />
+              <Trans>Show Posts from My Feeds</Trans>
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Set this setting to "Yes" to show samples of your saved feeds in
-              your following feed. This is an experimental feature.
+              <Trans>
+                Set this setting to "Yes" to show samples of your saved feeds in
+                your following feed. This is an experimental feature.
+              </Trans>
             </Text>
             <ToggleButton
               type="default-light"
               label={
-                store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No'
+                variables?.lab_mergeFeedEnabled ??
+                preferences?.feedViewPrefs?.lab_mergeFeedEnabled
+                  ? _(msg`Yes`)
+                  : _(msg`No`)
+              }
+              isSelected={
+                !!(
+                  variables?.lab_mergeFeedEnabled ??
+                  preferences?.feedViewPrefs?.lab_mergeFeedEnabled
+                )
+              }
+              onPress={() =>
+                setFeedViewPref({
+                  lab_mergeFeedEnabled: !(
+                    variables?.lab_mergeFeedEnabled ??
+                    preferences?.feedViewPrefs?.lab_mergeFeedEnabled
+                  ),
+                })
               }
-              isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled}
-              onPress={store.preferences.toggleHomeFeedMergeFeedEnabled}
             />
           </View>
         </View>
@@ -204,14 +296,16 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
           }}
           style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]}
           accessibilityRole="button"
-          accessibilityLabel="Confirm"
+          accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Done</Trans>
+          </Text>
         </TouchableOpacity>
       </View>
     </CenteredView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
index af98a1833..73d941932 100644
--- a/src/view/screens/PreferencesThreads.tsx
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -1,9 +1,13 @@
 import React from 'react'
-import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  ScrollView,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
 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'
@@ -12,14 +16,30 @@ 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'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePreferencesQuery,
+  useSetThreadViewPreferencesMutation,
+} from '#/state/queries/preferences'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
-export const PreferencesThreads = observer(function PreferencesThreadsImpl({
-  navigation,
-}: Props) {
+export function PreferencesThreads({navigation}: Props) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
   const {isTabletOrDesktop} = useWebMediaQueries()
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate: setThreadViewPrefs, variables} =
+    useSetThreadViewPreferencesMutation()
+
+  const prioritizeFollowedUsers = Boolean(
+    variables?.prioritizeFollowedUsers ??
+      preferences?.threadViewPrefs?.prioritizeFollowedUsers,
+  )
+  const treeViewEnabled = Boolean(
+    variables?.lab_treeViewEnabled ??
+      preferences?.threadViewPrefs?.lab_treeViewEnabled,
+  )
 
   return (
     <CenteredView
@@ -30,78 +50,90 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
         styles.container,
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
-      <ViewHeader title="Thread Preferences" showOnDesktop />
+      <ViewHeader title={_(msg`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.
+          <Trans>Fine-tune the discussion threads.</Trans>
         </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
+      {preferences ? (
+        <ScrollView>
+          <View style={styles.cardsContainer}>
+            <View style={[pal.viewLight, styles.card]}>
+              <Text type="title-sm" style={[pal.text, s.pb5]}>
+                <Trans>Sort Replies</Trans>
+              </Text>
+              <Text style={[pal.text, s.pb10]}>
+                <Trans>Sort replies to the same post by:</Trans>
+              </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={key => setThreadViewPrefs({sort: key})}
+                  initialSelection={preferences?.threadViewPrefs?.sort}
+                />
+              </View>
+            </View>
+
+            <View style={[pal.viewLight, styles.card]}>
+              <Text type="title-sm" style={[pal.text, s.pb5]}>
+                <Trans>Prioritize Your Follows</Trans>
+              </Text>
+              <Text style={[pal.text, s.pb10]}>
+                <Trans>
+                  Show replies by people you follow before all other replies.
+                </Trans>
+              </Text>
+              <ToggleButton
                 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.setThreadSort}
-                initialSelection={store.preferences.thread.sort}
+                label={prioritizeFollowedUsers ? 'Yes' : 'No'}
+                isSelected={prioritizeFollowedUsers}
+                onPress={() =>
+                  setThreadViewPrefs({
+                    prioritizeFollowedUsers: !prioritizeFollowedUsers,
+                  })
+                }
               />
             </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.thread.prioritizeFollowedUsers ? 'Yes' : 'No'
-              }
-              isSelected={store.preferences.thread.prioritizeFollowedUsers}
-              onPress={store.preferences.togglePrioritizedFollowedUsers}
-            />
-          </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.thread.lab_treeViewEnabled ? 'Yes' : 'No'
-              }
-              isSelected={!!store.preferences.thread.lab_treeViewEnabled}
-              onPress={store.preferences.toggleThreadTreeViewEnabled}
-            />
+            <View style={[pal.viewLight, styles.card]}>
+              <Text type="title-sm" style={[pal.text, s.pb5]}>
+                <FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '}
+                <Trans>Threaded Mode</Trans>
+              </Text>
+              <Text style={[pal.text, s.pb10]}>
+                <Trans>
+                  Set this setting to "Yes" to show replies in a threaded view.
+                  This is an experimental feature.
+                </Trans>
+              </Text>
+              <ToggleButton
+                type="default-light"
+                label={treeViewEnabled ? 'Yes' : 'No'}
+                isSelected={treeViewEnabled}
+                onPress={() =>
+                  setThreadViewPrefs({
+                    lab_treeViewEnabled: !treeViewEnabled,
+                  })
+                }
+              />
+            </View>
           </View>
-        </View>
-      </ScrollView>
+        </ScrollView>
+      ) : (
+        <ActivityIndicator />
+      )}
 
       <View
         style={[
@@ -118,14 +150,16 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
           }}
           style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]}
           accessibilityRole="button"
-          accessibilityLabel="Confirm"
+          accessibilityLabel={_(msg`Confirm`)}
           accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Done</Trans>
+          </Text>
         </TouchableOpacity>
       </View>
     </CenteredView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx
index f709c9fda..247afc316 100644
--- a/src/view/screens/PrivacyPolicy.tsx
+++ b/src/view/screens/PrivacyPolicy.tsx
@@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PrivacyPolicy'>
 export const PrivacyPolicyScreen = (_props: Props) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
 
   useFocusEffect(
@@ -23,16 +26,18 @@ export const PrivacyPolicyScreen = (_props: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Privacy Policy" />
+      <ViewHeader title={_(msg`Privacy Policy`)} />
       <ScrollView style={[s.hContentRegion, pal.view]}>
         <View style={[s.p20]}>
           <Text style={pal.text}>
-            The Privacy Policy has been moved to{' '}
-            <TextLink
-              style={pal.link}
-              href="https://blueskyweb.xyz/support/privacy-policy"
-              text="blueskyweb.xyz/support/privacy-policy"
-            />
+            <Trans>
+              The Privacy Policy has been moved to{' '}
+              <TextLink
+                style={pal.link}
+                href="https://blueskyweb.xyz/support/privacy-policy"
+                text="blueskyweb.xyz/support/privacy-policy"
+              />
+            </Trans>
           </Text>
         </View>
         <View style={s.footerSpacer} />
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 9a25612ad..4af1b650e 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,317 +1,447 @@
-import React, {useEffect, useState} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import React, {useMemo} from 'react'
+import {StyleSheet, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector'
-import {CenteredView} from '../com/util/Views'
+import {CenteredView, FlatList} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
-import {ProfileUiModel, Sections} from 'state/models/ui/profile'
-import {useStores} from 'state/index'
-import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
+import {Feed} from 'view/com/posts/Feed'
+import {ProfileLists} from '../com/lists/ProfileLists'
+import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
-import {FeedSlice} from '../com/posts/FeedSlice'
-import {ListCard} from 'view/com/lists/ListCard'
-import {
-  PostFeedLoadingPlaceholder,
-  ProfileCardFeedLoadingPlaceholder,
-} from '../com/util/LoadingPlaceholder'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
-import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {EmptyState} from '../com/util/EmptyState'
-import {Text} from '../com/util/text/Text'
 import {FAB} from '../com/util/fab/FAB'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ComposeIcon2} from 'lib/icons'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
-import {logger} from '#/logger'
-import {useSetMinimalShellMode} from '#/state/shell'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
+import {cleanError} from '#/lib/strings/errors'
+import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
+import {useQueryClient} from '@tanstack/react-query'
+import {useComposerControls} from '#/state/shell/composer'
+import {listenSoftReset} from '#/state/events'
+import {truncateAndInvalidate} from '#/state/queries/util'
+
+interface SectionRef {
+  scrollToTop: () => void
+}
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
-export const ProfileScreen = withAuthRequired(
-  observer(function ProfileScreenImpl({route}: Props) {
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {screen, track} = useAnalytics()
-    const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
-    const name = route.params.name === 'me' ? store.me.did : route.params.name
+export function ProfileScreen({route}: Props) {
+  const {currentAccount} = useSession()
+  const name =
+    route.params.name === 'me' ? currentAccount?.did : route.params.name
+  const moderationOpts = useModerationOpts()
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    refetch: refetchDid,
+    isInitialLoading: isInitialLoadingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data: profile,
+    error: profileError,
+    refetch: refetchProfile,
+    isInitialLoading: isInitialLoadingProfile,
+  } = useProfileQuery({
+    did: resolvedDid,
+  })
 
-    useEffect(() => {
-      screen('Profile')
-    }, [screen])
+  const onPressTryAgain = React.useCallback(() => {
+    if (resolveError) {
+      refetchDid()
+    } else {
+      refetchProfile()
+    }
+  }, [resolveError, refetchDid, refetchProfile])
 
-    const [hasSetup, setHasSetup] = useState<boolean>(false)
-    const uiState = React.useMemo(
-      () => new ProfileUiModel(store, {user: name}),
-      [name, store],
+  if (isInitialLoadingDid || isInitialLoadingProfile || !moderationOpts) {
+    return (
+      <CenteredView>
+        <ProfileHeader
+          profile={null}
+          moderation={null}
+          isProfilePreview={true}
+        />
+      </CenteredView>
     )
-    useSetTitle(combinedDisplayName(uiState.profile))
+  }
+  if (resolveError || profileError) {
+    return (
+      <CenteredView>
+        <ErrorScreen
+          testID="profileErrorScreen"
+          title="Oops!"
+          message={cleanError(resolveError || profileError)}
+          onPressTryAgain={onPressTryAgain}
+        />
+      </CenteredView>
+    )
+  }
+  if (profile && moderationOpts) {
+    return (
+      <ProfileScreenLoaded
+        profile={profile}
+        moderationOpts={moderationOpts}
+        hideBackButton={!!route.params.hideBackButton}
+      />
+    )
+  }
+  // should never happen
+  return (
+    <CenteredView>
+      <ErrorScreen
+        testID="profileErrorScreen"
+        title="Oops!"
+        message="Something went wrong and we're not sure what."
+        onPressTryAgain={onPressTryAgain}
+      />
+    </CenteredView>
+  )
+}
 
-    const onSoftReset = React.useCallback(() => {
-      viewSelectorRef.current?.scrollToTop()
-    }, [])
+function ProfileScreenLoaded({
+  profile: profileUnshadowed,
+  moderationOpts,
+  hideBackButton,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+  hideBackButton: boolean
+}) {
+  const profile = useProfileShadow(profileUnshadowed)
+  const {hasSession, currentAccount} = useSession()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {openComposer} = useComposerControls()
+  const {screen, track} = useAnalytics()
+  const [currentPage, setCurrentPage] = React.useState(0)
+  const {_} = useLingui()
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+  const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
+  const postsSectionRef = React.useRef<SectionRef>(null)
+  const repliesSectionRef = React.useRef<SectionRef>(null)
+  const mediaSectionRef = React.useRef<SectionRef>(null)
+  const likesSectionRef = React.useRef<SectionRef>(null)
+  const feedsSectionRef = React.useRef<SectionRef>(null)
+  const listsSectionRef = React.useRef<SectionRef>(null)
 
-    useEffect(() => {
-      setHasSetup(false)
-    }, [name])
+  useSetTitle(combinedDisplayName(profile))
 
-    // We don't need this to be reactive, so we can just register the listeners once
-    useEffect(() => {
-      const listCleanup = uiState.lists.registerListeners()
-      return () => listCleanup()
-      // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [])
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        const softResetSub = store.onScreenSoftReset(onSoftReset)
-        let aborted = false
-        setMinimalShellMode(false)
-        const feedCleanup = uiState.feed.registerListeners()
-        if (!hasSetup) {
-          uiState.setup().then(() => {
-            if (aborted) {
-              return
-            }
-            setHasSetup(true)
-          })
-        }
-        return () => {
-          aborted = true
-          feedCleanup()
-          softResetSub.remove()
-        }
-      }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]),
-    )
+  const isMe = profile.did === currentAccount?.did
+  const showRepliesTab = hasSession
+  const showLikesTab = isMe
+  const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens
+  const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists)
+  const sectionTitles = useMemo<string[]>(() => {
+    return [
+      'Posts',
+      showRepliesTab ? 'Posts & Replies' : undefined,
+      'Media',
+      showLikesTab ? 'Likes' : undefined,
+      showFeedsTab ? 'Feeds' : undefined,
+      showListsTab ? 'Lists' : undefined,
+    ].filter(Boolean) as string[]
+  }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab])
 
-    // events
-    // =
+  let nextIndex = 0
+  const postsIndex = nextIndex++
+  let repliesIndex: number | null = null
+  if (showRepliesTab) {
+    repliesIndex = nextIndex++
+  }
+  const mediaIndex = nextIndex++
+  let likesIndex: number | null = null
+  if (showLikesTab) {
+    likesIndex = nextIndex++
+  }
+  let feedsIndex: number | null = null
+  if (showFeedsTab) {
+    feedsIndex = nextIndex++
+  }
+  let listsIndex: number | null = null
+  if (showListsTab) {
+    listsIndex = nextIndex++
+  }
 
-    const onPressCompose = React.useCallback(() => {
-      track('ProfileScreen:PressCompose')
-      const mention =
-        uiState.profile.handle === store.me.handle ||
-        uiState.profile.handle === 'handle.invalid'
-          ? undefined
-          : uiState.profile.handle
-      store.shell.openComposer({mention})
-    }, [store, track, uiState])
-    const onSelectView = React.useCallback(
-      (index: number) => {
-        uiState.setSelectedViewIndex(index)
-      },
-      [uiState],
-    )
-    const onRefresh = React.useCallback(() => {
-      uiState
-        .refresh()
-        .catch((err: any) =>
-          logger.error('Failed to refresh user profile', {error: err}),
-        )
-    }, [uiState])
-    const onEndReached = React.useCallback(() => {
-      uiState.loadMore().catch((err: any) =>
-        logger.error('Failed to load more entries in user profile', {
-          error: err,
-        }),
-      )
-    }, [uiState])
-    const onPressTryAgain = React.useCallback(() => {
-      uiState.setup()
-    }, [uiState])
+  const scrollSectionToTop = React.useCallback(
+    (index: number) => {
+      if (index === postsIndex) {
+        postsSectionRef.current?.scrollToTop()
+      } else if (index === repliesIndex) {
+        repliesSectionRef.current?.scrollToTop()
+      } else if (index === mediaIndex) {
+        mediaSectionRef.current?.scrollToTop()
+      } else if (index === likesIndex) {
+        likesSectionRef.current?.scrollToTop()
+      } else if (index === feedsIndex) {
+        feedsSectionRef.current?.scrollToTop()
+      } else if (index === listsIndex) {
+        listsSectionRef.current?.scrollToTop()
+      }
+    },
+    [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex],
+  )
 
-    // rendering
-    // =
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+      screen('Profile')
+      return listenSoftReset(() => {
+        scrollSectionToTop(currentPage)
+      })
+    }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]),
+  )
 
-    const renderHeader = React.useCallback(() => {
-      if (!uiState) {
-        return <View />
+  useFocusEffect(
+    React.useCallback(() => {
+      setDrawerSwipeDisabled(currentPage > 0)
+      return () => {
+        setDrawerSwipeDisabled(false)
       }
-      return (
-        <ProfileHeader
-          view={uiState.profile}
-          onRefreshAll={onRefresh}
-          hideBackButton={route.params.hideBackButton}
-        />
-      )
-    }, [uiState, onRefresh, route.params.hideBackButton])
+    }, [setDrawerSwipeDisabled, currentPage]),
+  )
 
-    const Footer = React.useMemo(() => {
-      return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined
-    }, [uiState.showLoadingMoreFooter])
-    const renderItem = React.useCallback(
-      (item: any) => {
-        // if section is lists
-        if (uiState.selectedView === Sections.Lists) {
-          if (item === ProfileUiModel.LOADING_ITEM) {
-            return <ProfileCardFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                testID="listsEmpty"
-                icon="list-ul"
-                message="No lists yet!"
-                style={styles.emptyState}
+  // events
+  // =
+
+  const onPressCompose = React.useCallback(() => {
+    track('ProfileScreen:PressCompose')
+    const mention =
+      profile.handle === currentAccount?.handle ||
+      profile.handle === 'handle.invalid'
+        ? undefined
+        : profile.handle
+    openComposer({mention})
+  }, [openComposer, currentAccount, track, profile])
+
+  const onPageSelected = React.useCallback(
+    (i: number) => {
+      setCurrentPage(i)
+    },
+    [setCurrentPage],
+  )
+
+  const onCurrentPageSelected = React.useCallback(
+    (index: number) => {
+      scrollSectionToTop(index)
+    },
+    [scrollSectionToTop],
+  )
+
+  // rendering
+  // =
+
+  const renderHeader = React.useCallback(() => {
+    return (
+      <ProfileHeader
+        profile={profile}
+        moderation={moderation}
+        hideBackButton={hideBackButton}
+      />
+    )
+  }, [profile, moderation, hideBackButton])
+
+  return (
+    <ScreenHider
+      testID="profileView"
+      style={styles.container}
+      screenDescription="profile"
+      moderation={moderation.account}>
+      <PagerWithHeader
+        testID="profilePager"
+        isHeaderReady={true}
+        items={sectionTitles}
+        onPageSelected={onPageSelected}
+        onCurrentPageSelected={onCurrentPageSelected}
+        renderHeader={renderHeader}>
+        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={postsSectionRef}
+            feed={`author|${profile.did}|posts_no_replies`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isFocused={isFocused}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<FlatList<any> | null>
+            }
+          />
+        )}
+        {showRepliesTab
+          ? ({
+              onScroll,
+              headerHeight,
+              isFocused,
+              isScrolledDown,
+              scrollElRef,
+            }) => (
+              <FeedSection
+                ref={repliesSectionRef}
+                feed={`author|${profile.did}|posts_with_replies`}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isFocused={isFocused}
+                isScrolledDown={isScrolledDown}
+                scrollElRef={
+                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
+                }
               />
             )
-          } else {
-            return <ListCard testID={`list-${item.name}`} list={item} />
-          }
-          // if section is custom algorithms
-        } else if (uiState.selectedView === Sections.CustomAlgorithms) {
-          if (item === ProfileUiModel.LOADING_ITEM) {
-            return <ProfileCardFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                testID="customAlgorithmsEmpty"
-                icon="list-ul"
-                message="No custom algorithms yet!"
-                style={styles.emptyState}
+          : null}
+        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={mediaSectionRef}
+            feed={`author|${profile.did}|posts_with_media`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isFocused={isFocused}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<FlatList<any> | null>
+            }
+          />
+        )}
+        {showLikesTab
+          ? ({
+              onScroll,
+              headerHeight,
+              isFocused,
+              isScrolledDown,
+              scrollElRef,
+            }) => (
+              <FeedSection
+                ref={likesSectionRef}
+                feed={`likes|${profile.did}`}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isFocused={isFocused}
+                isScrolledDown={isScrolledDown}
+                scrollElRef={
+                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
+                }
               />
             )
-          } else if (item instanceof FeedSourceModel) {
-            return (
-              <FeedSourceCard
-                item={item}
-                showSaveBtn
-                showLikes
-                showDescription
+          : null}
+        {showFeedsTab
+          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
+              <ProfileFeedgens
+                ref={feedsSectionRef}
+                did={profile.did}
+                scrollElRef={
+                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
+                }
+                onScroll={onScroll}
+                scrollEventThrottle={1}
+                headerOffset={headerHeight}
+                enabled={isFocused}
               />
             )
-          }
-          // if section is posts or posts & replies
-        } else {
-          if (item === ProfileUiModel.END_ITEM) {
-            return <Text style={styles.endItem}>- end of feed -</Text>
-          } else if (item === ProfileUiModel.LOADING_ITEM) {
-            return <PostFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            if (uiState.feed.isBlocking) {
-              return (
-                <EmptyState
-                  icon="ban"
-                  message="Posts hidden"
-                  style={styles.emptyState}
-                />
-              )
-            }
-            if (uiState.feed.isBlockedBy) {
-              return (
-                <EmptyState
-                  icon="ban"
-                  message="Posts hidden"
-                  style={styles.emptyState}
-                />
-              )
-            }
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                icon={['far', 'message']}
-                message="No posts yet!"
-                style={styles.emptyState}
+          : null}
+        {showListsTab
+          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
+              <ProfileLists
+                ref={listsSectionRef}
+                did={profile.did}
+                scrollElRef={
+                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
+                }
+                onScroll={onScroll}
+                scrollEventThrottle={1}
+                headerOffset={headerHeight}
+                enabled={isFocused}
               />
             )
-          } else if (item instanceof PostsFeedSliceModel) {
-            return (
-              <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} />
-            )
-          }
-        }
-        return <View />
-      },
-      [
-        onPressTryAgain,
-        uiState.selectedView,
-        uiState.profile.did,
-        uiState.feed.isBlocking,
-        uiState.feed.isBlockedBy,
-      ],
-    )
-
-    return (
-      <ScreenHider
-        testID="profileView"
-        style={styles.container}
-        screenDescription="profile"
-        moderation={uiState.profile.moderation.account}>
-        {uiState.profile.hasError ? (
-          <ErrorScreen
-            testID="profileErrorScreen"
-            title="Failed to load profile"
-            message={uiState.profile.error}
-            onPressTryAgain={onPressTryAgain}
-          />
-        ) : uiState.profile.hasLoaded ? (
-          <ViewSelector
-            ref={viewSelectorRef}
-            swipeEnabled={false}
-            sections={uiState.selectorItems}
-            items={uiState.uiItems}
-            renderHeader={renderHeader}
-            renderItem={renderItem}
-            ListFooterComponent={Footer}
-            refreshing={uiState.isRefreshing || false}
-            onSelectView={onSelectView}
-            onRefresh={onRefresh}
-            onEndReached={onEndReached}
-          />
-        ) : (
-          <CenteredView>{renderHeader()}</CenteredView>
-        )}
+          : null}
+      </PagerWithHeader>
+      {hasSession && (
         <FAB
           testID="composeFAB"
           onPress={onPressCompose}
           icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
           accessibilityRole="button"
-          accessibilityLabel="New post"
+          accessibilityLabel={_(msg`New post`)}
           accessibilityHint=""
         />
-      </ScreenHider>
-    )
-  }),
-)
-
-function LoadingMoreFooter() {
-  return (
-    <View style={styles.loadingMoreFooter}>
-      <ActivityIndicator />
-    </View>
+      )}
+    </ScreenHider>
   )
 }
 
+interface FeedSectionProps {
+  feed: FeedDescriptor
+  onScroll: OnScrollHandler
+  headerHeight: number
+  isFocused: boolean
+  isScrolledDown: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+}
+const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
+  function FeedSectionImpl(
+    {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef},
+    ref,
+  ) {
+    const queryClient = useQueryClient()
+    const [hasNew, setHasNew] = React.useState(false)
+
+    const onScrollToTop = React.useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+      truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderPostsEmpty = React.useCallback(() => {
+      return <EmptyState icon="feed" message="This feed is empty!" />
+    }, [])
+
+    return (
+      <View>
+        <Feed
+          testID="postsFeed"
+          enabled={isFocused}
+          feed={feed}
+          pollInterval={30e3}
+          scrollElRef={scrollElRef}
+          onHasNew={setHasNew}
+          onScroll={onScroll}
+          scrollEventThrottle={1}
+          renderEmptyState={renderPostsEmpty}
+          headerOffset={headerHeight}
+        />
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onScrollToTop}
+            label="Load new posts"
+            showIndicator={hasNew}
+          />
+        )}
+      </View>
+    )
+  },
+)
+
 const styles = StyleSheet.create({
   container: {
     flexDirection: 'column',
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index a4d146d66..3a0bdcc0f 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -1,25 +1,21 @@
 import React, {useMemo, useCallback} from 'react'
 import {
-  FlatList,
-  NativeScrollEvent,
+  Dimensions,
   StyleSheet,
   View,
   ActivityIndicator,
+  FlatList,
 } from 'react-native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useNavigation} from '@react-navigation/native'
-import {useAnimatedScrollHandler} from 'react-native-reanimated'
+import {useQueryClient} from '@tanstack/react-query'
 import {usePalette} from 'lib/hooks/usePalette'
 import {HeartIcon, HeartIconSolid} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {colors, s} from 'lib/styles'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
 import {Feed} from 'view/com/posts/Feed'
@@ -32,13 +28,13 @@ import {FAB} from 'view/com/util/fab/FAB'
 import {EmptyState} from 'view/com/util/EmptyState'
 import * as Toast from 'view/com/util/Toast'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
-import {useCustomFeed} from 'lib/hooks/useCustomFeed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {shareUrl} from 'lib/sharing'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {Haptics} from 'lib/haptics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {resolveName} from 'lib/api'
 import {makeCustomFeedLink} from 'lib/routes/links'
 import {pluralize} from 'lib/strings/helpers'
 import {CenteredView, ScrollView} from 'view/com/util/Views'
@@ -47,6 +43,28 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {ComposeIcon2} from 'lib/icons'
 import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {
+  useFeedSourceInfoQuery,
+  FeedSourceFeedInfo,
+  useIsFeedPublicQuery,
+} from '#/state/queries/feed'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+  useSaveFeedMutation,
+  useRemoveFeedMutation,
+  usePinFeedMutation,
+  useUnpinFeedMutation,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {useComposerControls} from '#/state/shell/composer'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
 const SECTION_TITLES = ['Posts', 'About']
 
@@ -55,315 +73,372 @@ interface SectionRef {
 }
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
-export const ProfileFeedScreen = withAuthRequired(
-  observer(function ProfileFeedScreenImpl(props: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const navigation = useNavigation<NavigationProp>()
+export function ProfileFeedScreen(props: Props) {
+  const {rkey, name: handleOrDid} = props.route.params
 
-    const {name: handleOrDid} = props.route.params
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
 
-    const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
-    const [error, setError] = React.useState<string | undefined>()
+  const uri = useMemo(
+    () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
+    [rkey, handleOrDid],
+  )
+  const {error, data: resolvedUri} = useResolveUriQuery(uri)
 
-    const onPressBack = React.useCallback(() => {
-      if (navigation.canGoBack()) {
-        navigation.goBack()
-      } else {
-        navigation.navigate('Home')
-      }
-    }, [navigation])
-
-    React.useEffect(() => {
-      /*
-       * We must resolve the DID of the feed owner before we can fetch the feed.
-       */
-      async function fetchDid() {
-        try {
-          const did = await resolveName(store, handleOrDid)
-          setFeedOwnerDid(did)
-        } catch (e) {
-          setError(
-            `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
-          )
-        }
-      }
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (error) {
+    return (
+      <CenteredView>
+        <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+          <Text type="title-lg" style={[pal.text, s.mb10]}>
+            <Trans>Could not load feed</Trans>
+          </Text>
+          <Text type="md" style={[pal.text, s.mb20]}>
+            {error.toString()}
+          </Text>
 
-      fetchDid()
-    }, [store, handleOrDid, setFeedOwnerDid])
-
-    if (error) {
-      return (
-        <CenteredView>
-          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-            <Text type="title-lg" style={[pal.text, s.mb10]}>
-              Could not load feed
-            </Text>
-            <Text type="md" style={[pal.text, s.mb20]}>
-              {error}
-            </Text>
-
-            <View style={{flexDirection: 'row'}}>
-              <Button
-                type="default"
-                accessibilityLabel="Go Back"
-                accessibilityHint="Return to previous page"
-                onPress={onPressBack}
-                style={{flexShrink: 1}}>
-                <Text type="button" style={pal.text}>
-                  Go Back
-                </Text>
-              </Button>
-            </View>
+          <View style={{flexDirection: 'row'}}>
+            <Button
+              type="default"
+              accessibilityLabel={_(msg`Go Back`)}
+              accessibilityHint="Return to previous page"
+              onPress={onPressBack}
+              style={{flexShrink: 1}}>
+              <Text type="button" style={pal.text}>
+                <Trans>Go Back</Trans>
+              </Text>
+            </Button>
           </View>
-        </CenteredView>
-      )
-    }
+        </View>
+      </CenteredView>
+    )
+  }
 
-    return feedOwnerDid ? (
-      <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
-    ) : (
+  return resolvedUri ? (
+    <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
+  ) : (
+    <CenteredView>
+      <View style={s.p20}>
+        <ActivityIndicator size="large" />
+      </View>
+    </CenteredView>
+  )
+}
+
+function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
+  const {data: preferences} = usePreferencesQuery()
+  const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
+  const {isLoading: isPublicStatusLoading, data: isPublic} =
+    useIsFeedPublicQuery({uri: feedUri})
+
+  if (!preferences || !info || isPublicStatusLoading) {
+    return (
       <CenteredView>
         <View style={s.p20}>
           <ActivityIndicator size="large" />
         </View>
       </CenteredView>
     )
-  }),
-)
+  }
 
-export const ProfileFeedScreenInner = observer(
-  function ProfileFeedScreenInnerImpl({
-    route,
-    feedOwnerDid,
-  }: Props & {feedOwnerDid: string}) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const {track} = useAnalytics()
-    const feedSectionRef = React.useRef<SectionRef>(null)
-    const {rkey, name: handleOrDid} = route.params
-    const uri = useMemo(
-      () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
-      [rkey, feedOwnerDid],
-    )
-    const feedInfo = useCustomFeed(uri)
-    const feed: PostsFeedModel = useMemo(() => {
-      const model = new PostsFeedModel(store, 'custom', {
-        feed: uri,
-      })
-      model.setup()
-      return model
-    }, [store, uri])
-    const isPinned = store.preferences.isPinnedFeed(uri)
-    useSetTitle(feedInfo?.displayName)
-
-    // events
-    // =
-
-    const onToggleSaved = React.useCallback(async () => {
-      try {
-        Haptics.default()
-        if (feedInfo?.isSaved) {
-          await feedInfo?.unsave()
-        } else {
-          await feedInfo?.save()
-        }
-      } catch (err) {
-        Toast.show(
-          'There was an an issue updating your feeds, please check your internet connection and try again.',
-        )
-        logger.error('Failed up update feeds', {error: err})
-      }
-    }, [feedInfo])
+  return (
+    <ProfileFeedScreenInner
+      preferences={preferences}
+      feedInfo={info as FeedSourceFeedInfo}
+      isPublic={Boolean(isPublic)}
+    />
+  )
+}
 
-    const onToggleLiked = React.useCallback(async () => {
+export function ProfileFeedScreenInner({
+  preferences,
+  feedInfo,
+  isPublic,
+}: {
+  preferences: UsePreferencesQueryResponse
+  feedInfo: FeedSourceFeedInfo
+  isPublic: boolean
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {hasSession, currentAccount} = useSession()
+  const {openModal} = useModalControls()
+  const {openComposer} = useComposerControls()
+  const {track} = useAnalytics()
+  const feedSectionRef = React.useRef<SectionRef>(null)
+
+  const {
+    mutateAsync: saveFeed,
+    variables: savedFeed,
+    reset: resetSaveFeed,
+    isPending: isSavePending,
+  } = useSaveFeedMutation()
+  const {
+    mutateAsync: removeFeed,
+    variables: removedFeed,
+    reset: resetRemoveFeed,
+    isPending: isRemovePending,
+  } = useRemoveFeedMutation()
+  const {
+    mutateAsync: pinFeed,
+    variables: pinnedFeed,
+    reset: resetPinFeed,
+    isPending: isPinPending,
+  } = usePinFeedMutation()
+  const {
+    mutateAsync: unpinFeed,
+    variables: unpinnedFeed,
+    reset: resetUnpinFeed,
+    isPending: isUnpinPending,
+  } = useUnpinFeedMutation()
+
+  const isSaved =
+    !removedFeed &&
+    (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
+  const isPinned =
+    !unpinnedFeed &&
+    (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
+
+  useSetTitle(feedInfo?.displayName)
+
+  const onToggleSaved = React.useCallback(async () => {
+    try {
       Haptics.default()
-      try {
-        if (feedInfo?.isLiked) {
-          await feedInfo?.unlike()
-        } else {
-          await feedInfo?.like()
-        }
-      } catch (err) {
-        Toast.show(
-          'There was an an issue contacting the server, please check your internet connection and try again.',
-        )
-        logger.error('Failed up toggle like', {error: err})
+
+      if (isSaved) {
+        await removeFeed({uri: feedInfo.uri})
+        resetRemoveFeed()
+      } else {
+        await saveFeed({uri: feedInfo.uri})
+        resetSaveFeed()
       }
-    }, [feedInfo])
+    } catch (err) {
+      Toast.show(
+        'There was an an issue updating your feeds, please check your internet connection and try again.',
+      )
+      logger.error('Failed up update feeds', {error: err})
+    }
+  }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed])
 
-    const onTogglePinned = React.useCallback(async () => {
+  const onTogglePinned = React.useCallback(async () => {
+    try {
       Haptics.default()
-      if (feedInfo) {
-        feedInfo.togglePin().catch(e => {
-          Toast.show('There was an issue contacting the server')
-          logger.error('Failed to toggle pinned feed', {error: e})
-        })
-      }
-    }, [feedInfo])
-
-    const onPressShare = React.useCallback(() => {
-      const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
-      shareUrl(url)
-      track('CustomFeed:Share')
-    }, [handleOrDid, rkey, track])
-
-    const onPressReport = React.useCallback(() => {
-      if (!feedInfo) return
-      store.shell.openModal({
-        name: 'report',
-        uri: feedInfo.uri,
-        cid: feedInfo.cid,
-      })
-    }, [store, feedInfo])
-
-    const onCurrentPageSelected = React.useCallback(
-      (index: number) => {
-        if (index === 0) {
-          feedSectionRef.current?.scrollToTop()
-        }
-      },
-      [feedSectionRef],
-    )
 
-    // render
-    // =
+      if (isPinned) {
+        await unpinFeed({uri: feedInfo.uri})
+        resetUnpinFeed()
+      } else {
+        await pinFeed({uri: feedInfo.uri})
+        resetPinFeed()
+      }
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to toggle pinned feed', {error: e})
+    }
+  }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed])
+
+  const onPressShare = React.useCallback(() => {
+    const url = toShareUrl(feedInfo.route.href)
+    shareUrl(url)
+    track('CustomFeed:Share')
+  }, [feedInfo, track])
+
+  const onPressReport = React.useCallback(() => {
+    if (!feedInfo) return
+    openModal({
+      name: 'report',
+      uri: feedInfo.uri,
+      cid: feedInfo.cid,
+    })
+  }, [openModal, feedInfo])
+
+  const onCurrentPageSelected = React.useCallback(
+    (index: number) => {
+      if (index === 0) {
+        feedSectionRef.current?.scrollToTop()
+      }
+    },
+    [feedSectionRef],
+  )
 
-    const dropdownItems: DropdownItem[] = React.useMemo(() => {
-      return [
-        {
-          testID: 'feedHeaderDropdownToggleSavedBtn',
-          label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds',
-          onPress: onToggleSaved,
-          icon: feedInfo?.isSaved
-            ? {
-                ios: {
-                  name: 'trash',
-                },
-                android: 'ic_delete',
-                web: ['far', 'trash-can'],
-              }
-            : {
-                ios: {
-                  name: 'plus',
-                },
-                android: '',
-                web: 'plus',
+  // render
+  // =
+
+  const dropdownItems: DropdownItem[] = React.useMemo(() => {
+    return [
+      hasSession && {
+        testID: 'feedHeaderDropdownToggleSavedBtn',
+        label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`),
+        onPress: isSavePending || isRemovePending ? undefined : onToggleSaved,
+        icon: isSaved
+          ? {
+              ios: {
+                name: 'trash',
               },
-        },
-        {
-          testID: 'feedHeaderDropdownReportBtn',
-          label: 'Report feed',
-          onPress: onPressReport,
-          icon: {
-            ios: {
-              name: 'exclamationmark.triangle',
+              android: 'ic_delete',
+              web: ['far', 'trash-can'],
+            }
+          : {
+              ios: {
+                name: 'plus',
+              },
+              android: '',
+              web: 'plus',
             },
-            android: 'ic_menu_report_image',
-            web: 'circle-exclamation',
+      },
+      hasSession && {
+        testID: 'feedHeaderDropdownReportBtn',
+        label: _(msg`Report feed`),
+        onPress: onPressReport,
+        icon: {
+          ios: {
+            name: 'exclamationmark.triangle',
           },
+          android: 'ic_menu_report_image',
+          web: 'circle-exclamation',
         },
-        {
-          testID: 'feedHeaderDropdownShareBtn',
-          label: 'Share link',
-          onPress: onPressShare,
-          icon: {
-            ios: {
-              name: 'square.and.arrow.up',
-            },
-            android: 'ic_menu_share',
-            web: 'share',
+      },
+      {
+        testID: 'feedHeaderDropdownShareBtn',
+        label: _(msg`Share feed`),
+        onPress: onPressShare,
+        icon: {
+          ios: {
+            name: 'square.and.arrow.up',
           },
+          android: 'ic_menu_share',
+          web: 'share',
         },
-      ] as DropdownItem[]
-    }, [feedInfo, onToggleSaved, onPressReport, onPressShare])
-
-    const renderHeader = useCallback(() => {
-      return (
-        <ProfileSubpageHeader
-          isLoading={!feedInfo?.hasLoaded}
-          href={makeCustomFeedLink(feedOwnerDid, rkey)}
-          title={feedInfo?.displayName}
-          avatar={feedInfo?.avatar}
-          isOwner={feedInfo?.isOwner}
-          creator={
-            feedInfo
-              ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
-              : undefined
-          }
-          avatarType="algo">
-          {feedInfo && (
-            <>
-              <Button
-                type="default"
-                label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
-                onPress={onToggleSaved}
-                style={styles.btn}
-              />
-              <Button
-                type={isPinned ? 'default' : 'inverted'}
-                label={isPinned ? 'Unpin' : 'Pin to home'}
-                onPress={onTogglePinned}
-                style={styles.btn}
-              />
-            </>
-          )}
-          <NativeDropdown
-            testID="headerDropdownBtn"
-            items={dropdownItems}
-            accessibilityLabel="More options"
-            accessibilityHint="">
-            <View style={[pal.viewLight, styles.btn]}>
-              <FontAwesomeIcon
-                icon="ellipsis"
-                size={20}
-                color={pal.colors.text}
-              />
-            </View>
-          </NativeDropdown>
-        </ProfileSubpageHeader>
-      )
-    }, [
-      pal,
-      feedOwnerDid,
-      rkey,
-      feedInfo,
-      isPinned,
-      onTogglePinned,
-      onToggleSaved,
-      dropdownItems,
-    ])
-
+      },
+    ].filter(Boolean) as DropdownItem[]
+  }, [
+    hasSession,
+    onToggleSaved,
+    onPressReport,
+    onPressShare,
+    isSaved,
+    isSavePending,
+    isRemovePending,
+    _,
+  ])
+
+  const renderHeader = useCallback(() => {
     return (
-      <View style={s.hContentRegion}>
-        <PagerWithHeader
-          items={SECTION_TITLES}
-          isHeaderReady={feedInfo?.hasLoaded ?? false}
-          renderHeader={renderHeader}
-          onCurrentPageSelected={onCurrentPageSelected}>
-          {({onScroll, headerHeight, isScrolledDown}) => (
+      <ProfileSubpageHeader
+        isLoading={false}
+        href={feedInfo.route.href}
+        title={feedInfo?.displayName}
+        avatar={feedInfo?.avatar}
+        isOwner={feedInfo.creatorDid === currentAccount?.did}
+        creator={
+          feedInfo
+            ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
+            : undefined
+        }
+        avatarType="algo">
+        {feedInfo && hasSession && (
+          <>
+            <Button
+              disabled={isSavePending || isRemovePending}
+              type="default"
+              label={isSaved ? 'Unsave' : 'Save'}
+              onPress={onToggleSaved}
+              style={styles.btn}
+            />
+            <Button
+              testID={isPinned ? 'unpinBtn' : 'pinBtn'}
+              disabled={isPinPending || isUnpinPending}
+              type={isPinned ? 'default' : 'inverted'}
+              label={isPinned ? 'Unpin' : 'Pin to home'}
+              onPress={onTogglePinned}
+              style={styles.btn}
+            />
+          </>
+        )}
+        <NativeDropdown
+          testID="headerDropdownBtn"
+          items={dropdownItems}
+          accessibilityLabel={_(msg`More options`)}
+          accessibilityHint="">
+          <View style={[pal.viewLight, styles.btn]}>
+            <FontAwesomeIcon
+              icon="ellipsis"
+              size={20}
+              color={pal.colors.text}
+            />
+          </View>
+        </NativeDropdown>
+      </ProfileSubpageHeader>
+    )
+  }, [
+    _,
+    hasSession,
+    pal,
+    feedInfo,
+    isPinned,
+    onTogglePinned,
+    onToggleSaved,
+    dropdownItems,
+    currentAccount?.did,
+    isPinPending,
+    isRemovePending,
+    isSavePending,
+    isSaved,
+    isUnpinPending,
+  ])
+
+  return (
+    <View style={s.hContentRegion}>
+      <PagerWithHeader
+        items={SECTION_TITLES}
+        isHeaderReady={true}
+        renderHeader={renderHeader}
+        onCurrentPageSelected={onCurrentPageSelected}>
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) =>
+          isPublic ? (
             <FeedSection
               ref={feedSectionRef}
-              feed={feed}
+              feed={`feedgen|${feedInfo.uri}`}
               onScroll={onScroll}
               headerHeight={headerHeight}
               isScrolledDown={isScrolledDown}
+              scrollElRef={
+                scrollElRef as React.MutableRefObject<FlatList<any> | null>
+              }
+              isFocused={isFocused}
             />
-          )}
-          {({onScroll, headerHeight}) => (
-            <AboutSection
-              feedOwnerDid={feedOwnerDid}
-              feedRkey={rkey}
-              feedInfo={feedInfo}
-              headerHeight={headerHeight}
-              onToggleLiked={onToggleLiked}
-              onScroll={onScroll}
-            />
-          )}
-        </PagerWithHeader>
+          ) : (
+            <CenteredView sideBorders style={[{paddingTop: headerHeight}]}>
+              <NonPublicFeedMessage />
+            </CenteredView>
+          )
+        }
+        {({onScroll, headerHeight, scrollElRef}) => (
+          <AboutSection
+            feedOwnerDid={feedInfo.creatorDid}
+            feedRkey={feedInfo.route.params.rkey}
+            feedInfo={feedInfo}
+            headerHeight={headerHeight}
+            onScroll={onScroll}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<ScrollView | null>
+            }
+            isOwner={feedInfo.creatorDid === currentAccount?.did}
+          />
+        )}
+      </PagerWithHeader>
+      {hasSession && (
         <FAB
           testID="composeFAB"
-          onPress={() => store.shell.openComposer({})}
+          onPress={() => openComposer({})}
           icon={
             <ComposeIcon2
               strokeWidth={1.5}
@@ -372,32 +447,67 @@ export const ProfileFeedScreenInner = observer(
             />
           }
           accessibilityRole="button"
-          accessibilityLabel="New post"
+          accessibilityLabel={_(msg`New post`)}
           accessibilityHint=""
         />
+      )}
+    </View>
+  )
+}
+
+function NonPublicFeedMessage() {
+  const pal = usePalette('default')
+
+  return (
+    <View
+      style={[
+        pal.border,
+        {
+          padding: 18,
+          borderTopWidth: 1,
+          minHeight: Dimensions.get('window').height * 1.5,
+        },
+      ]}>
+      <View
+        style={[
+          pal.viewLight,
+          {
+            padding: 12,
+            borderRadius: 8,
+          },
+        ]}>
+        <Text style={[pal.text]}>
+          <Trans>
+            Looks like this feed is only available to users with a Bluesky
+            account. Please sign up or sign in to view this feed!
+          </Trans>
+        </Text>
       </View>
-    )
-  },
-)
+    </View>
+  )
+}
 
 interface FeedSectionProps {
-  feed: PostsFeedModel
-  onScroll: (e: NativeScrollEvent) => void
+  feed: FeedDescriptor
+  onScroll: OnScrollHandler
   headerHeight: number
   isScrolledDown: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  isFocused: boolean
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   function FeedSectionImpl(
-    {feed, onScroll, headerHeight, isScrolledDown},
+    {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused},
     ref,
   ) {
-    const hasNew = feed.hasNewLatest && !feed.isRefreshing
-    const scrollElRef = React.useRef<FlatList>(null)
+    const [hasNew, setHasNew] = React.useState(false)
+    const queryClient = useQueryClient()
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      feed.refresh()
-    }, [feed, scrollElRef, headerHeight])
+      truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
 
     React.useImperativeHandle(ref, () => ({
       scrollToTop: onScrollToTop,
@@ -407,13 +517,15 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
       return <EmptyState icon="feed" message="This feed is empty!" />
     }, [])
 
-    const scrollHandler = useAnimatedScrollHandler({onScroll})
     return (
       <View>
         <Feed
+          enabled={isFocused}
           feed={feed}
+          pollInterval={30e3}
           scrollElRef={scrollElRef}
-          onScroll={scrollHandler}
+          onHasNew={setHasNew}
+          onScroll={onScroll}
           scrollEventThrottle={5}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
@@ -430,32 +542,64 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   },
 )
 
-const AboutSection = observer(function AboutPageImpl({
+function AboutSection({
   feedOwnerDid,
   feedRkey,
   feedInfo,
   headerHeight,
-  onToggleLiked,
   onScroll,
+  scrollElRef,
+  isOwner,
 }: {
   feedOwnerDid: string
   feedRkey: string
-  feedInfo: FeedSourceModel | undefined
+  feedInfo: FeedSourceFeedInfo
   headerHeight: number
-  onToggleLiked: () => void
-  onScroll: (e: NativeScrollEvent) => void
+  onScroll: OnScrollHandler
+  scrollElRef: React.MutableRefObject<ScrollView | null>
+  isOwner: boolean
 }) {
   const pal = usePalette('default')
-  const scrollHandler = useAnimatedScrollHandler({onScroll})
+  const {_} = useLingui()
+  const scrollHandler = useAnimatedScrollHandler(onScroll)
+  const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
+  const {hasSession} = useSession()
 
-  if (!feedInfo) {
-    return <View />
-  }
+  const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
+  const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
+    useUnlikeMutation()
+
+  const isLiked = !!likeUri
+  const likeCount =
+    isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount
+
+  const onToggleLiked = React.useCallback(async () => {
+    try {
+      Haptics.default()
+
+      if (isLiked && likeUri) {
+        await unlikeFeed({uri: likeUri})
+        setLikeUri('')
+      } else {
+        const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid})
+        setLikeUri(res.uri)
+      }
+    } catch (err) {
+      Toast.show(
+        'There was an an issue contacting the server, please check your internet connection and try again.',
+      )
+      logger.error('Failed up toggle like', {error: err})
+    }
+  }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed])
 
   return (
     <ScrollView
+      ref={scrollElRef}
       scrollEventThrottle={1}
-      contentContainerStyle={{paddingTop: headerHeight}}
+      contentContainerStyle={{
+        paddingTop: headerHeight,
+        minHeight: Dimensions.get('window').height * 1.5,
+      }}
       onScroll={scrollHandler}>
       <View
         style={[
@@ -467,46 +611,44 @@ const AboutSection = observer(function AboutPageImpl({
           },
           pal.border,
         ]}>
-        {feedInfo.descriptionRT ? (
+        {feedInfo.description ? (
           <RichText
             testID="listDescription"
             type="lg"
             style={pal.text}
-            richText={feedInfo.descriptionRT}
+            richText={feedInfo.description}
           />
         ) : (
           <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
-            No description
+            <Trans>No description</Trans>
           </Text>
         )}
         <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
           <Button
             type="default"
             testID="toggleLikeBtn"
-            accessibilityLabel="Like this feed"
+            accessibilityLabel={_(msg`Like this feed`)}
             accessibilityHint=""
+            disabled={!hasSession || isLikePending || isUnlikePending}
             onPress={onToggleLiked}
             style={{paddingHorizontal: 10}}>
-            {feedInfo?.isLiked ? (
+            {isLiked ? (
               <HeartIconSolid size={19} style={styles.liked} />
             ) : (
               <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
             )}
           </Button>
-          {typeof feedInfo.likeCount === 'number' && (
+          {typeof likeCount === 'number' && (
             <TextLink
               href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
-              text={`Liked by ${feedInfo.likeCount} ${pluralize(
-                feedInfo.likeCount,
-                'user',
-              )}`}
+              text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
               style={[pal.textLight, s.semiBold]}
             />
           )}
         </View>
         <Text type="md" style={[pal.textLight]} numberOfLines={1}>
           Created by{' '}
-          {feedInfo.isOwner ? (
+          {isOwner ? (
             'you'
           ) : (
             <TextLink
@@ -522,7 +664,7 @@ const AboutSection = observer(function AboutPageImpl({
       </View>
     </ScrollView>
   )
-})
+}
 
 const styles = StyleSheet.create({
   btn: {
diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx
index 4972116f3..0460670e1 100644
--- a/src/view/screens/ProfileFeedLikedBy.tsx
+++ b/src/view/screens/ProfileFeedLikedBy.tsx
@@ -2,17 +2,19 @@ import React from 'react'
 import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
-export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => {
+export const ProfileFeedLikedByScreen = ({route}: Props) => {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -22,8 +24,8 @@ export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Liked by" />
+      <ViewHeader title={_(msg`Liked by`)} />
       <PostLikedByComponent uri={uri} />
     </View>
   )
-})
+}
diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx
index 49f55bf46..2cad08cb5 100644
--- a/src/view/screens/ProfileFollowers.tsx
+++ b/src/view/screens/ProfileFollowers.tsx
@@ -2,15 +2,17 @@ import React from 'react'
 import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'>
-export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => {
+export const ProfileFollowersScreen = ({route}: Props) => {
   const {name} = route.params
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -20,8 +22,8 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Followers" />
+      <ViewHeader title={_(msg`Followers`)} />
       <ProfileFollowersComponent name={name} />
     </View>
   )
-})
+}
diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx
index 4f0ff7d67..80502b98b 100644
--- a/src/view/screens/ProfileFollows.tsx
+++ b/src/view/screens/ProfileFollows.tsx
@@ -2,15 +2,17 @@ import React from 'react'
 import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'>
-export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => {
+export const ProfileFollowsScreen = ({route}: Props) => {
   const {name} = route.params
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -20,8 +22,8 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Following" />
+      <ViewHeader title={_(msg`Following`)} />
       <ProfileFollowsComponent name={name} />
     </View>
   )
-})
+}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index b84732d53..421611764 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -2,7 +2,6 @@ import React, {useCallback, useMemo} from 'react'
 import {
   ActivityIndicator,
   FlatList,
-  NativeScrollEvent,
   Pressable,
   StyleSheet,
   View,
@@ -11,10 +10,8 @@ import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useAnimatedScrollHandler} from 'react-native-reanimated'
-import {observer} from 'mobx-react-lite'
-import {RichText as RichTextAPI} from '@atproto/api'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
+import {useQueryClient} from '@tanstack/react-query'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
 import {Feed} from 'view/com/posts/Feed'
@@ -29,23 +26,36 @@ import * as Toast from 'view/com/util/Toast'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Haptics} from 'lib/haptics'
-import {ListModel} from 'state/models/content/list'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {useStores} from 'state/index'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {NavigationProp} from 'lib/routes/types'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {shareUrl} from 'lib/sharing'
-import {resolveName} from 'lib/api'
 import {s} from 'lib/styles'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink, makeListLink} from 'lib/routes/links'
 import {ComposeIcon2} from 'lib/icons'
-import {ListItems} from 'view/com/lists/ListItems'
-import {logger} from '#/logger'
+import {ListMembers} from '#/view/com/lists/ListMembers'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {
+  useListQuery,
+  useListMuteMutation,
+  useListBlockMutation,
+  useListDeleteMutation,
+} from '#/state/queries/list'
+import {cleanError} from '#/lib/strings/errors'
+import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
+import {isWeb} from '#/platform/detection'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -55,240 +65,220 @@ interface SectionRef {
 }
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
-export const ProfileListScreen = withAuthRequired(
-  observer(function ProfileListScreenImpl(props: Props) {
-    const store = useStores()
-    const {name: handleOrDid} = props.route.params
-    const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
-    const [error, setError] = React.useState<string | undefined>()
-
-    React.useEffect(() => {
-      /*
-       * We must resolve the DID of the list owner before we can fetch the list.
-       */
-      async function fetchDid() {
-        try {
-          const did = await resolveName(store, handleOrDid)
-          setListOwnerDid(did)
-        } catch (e) {
-          setError(
-            `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
-          )
-        }
-      }
-
-      fetchDid()
-    }, [store, handleOrDid, setListOwnerDid])
-
-    if (error) {
-      return (
-        <CenteredView>
-          <ErrorScreen error={error} />
-        </CenteredView>
-      )
-    }
+export function ProfileListScreen(props: Props) {
+  const {name: handleOrDid, rkey} = props.route.params
+  const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
+    AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
+  )
+  const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
 
-    return listOwnerDid ? (
-      <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} />
-    ) : (
+  if (resolveError) {
+    return (
       <CenteredView>
-        <View style={s.p20}>
-          <ActivityIndicator size="large" />
-        </View>
+        <ErrorScreen
+          error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`}
+        />
       </CenteredView>
     )
-  }),
-)
-
-export const ProfileListScreenInner = observer(
-  function ProfileListScreenInnerImpl({
-    route,
-    listOwnerDid,
-  }: Props & {listOwnerDid: string}) {
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {rkey} = route.params
-    const feedSectionRef = React.useRef<SectionRef>(null)
-    const aboutSectionRef = React.useRef<SectionRef>(null)
-
-    const list: ListModel = useMemo(() => {
-      const model = new ListModel(
-        store,
-        `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
-      )
-      return model
-    }, [store, listOwnerDid, rkey])
-    const feed = useMemo(
-      () => new PostsFeedModel(store, 'list', {list: list.uri}),
-      [store, list],
-    )
-    useSetTitle(list.data?.name)
-
-    useFocusEffect(
-      useCallback(() => {
-        setMinimalShellMode(false)
-        list.loadMore(true).then(() => {
-          if (list.isCuratelist) {
-            feed.setup()
-          }
-        })
-      }, [setMinimalShellMode, list, feed]),
+  }
+  if (listError) {
+    return (
+      <CenteredView>
+        <ErrorScreen error={cleanError(listError)} />
+      </CenteredView>
     )
+  }
+
+  return resolvedUri && list ? (
+    <ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} />
+  ) : (
+    <CenteredView>
+      <View style={s.p20}>
+        <ActivityIndicator size="large" />
+      </View>
+    </CenteredView>
+  )
+}
 
-    const onPressAddUser = useCallback(() => {
-      store.shell.openModal({
-        name: 'list-add-user',
-        list,
-        onAdd() {
-          if (list.isCuratelist) {
-            feed.refresh()
-          }
-        },
-      })
-    }, [store, list, feed])
+function ProfileListScreenLoaded({
+  route,
+  uri,
+  list,
+}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const {openComposer} = useComposerControls()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {rkey} = route.params
+  const feedSectionRef = React.useRef<SectionRef>(null)
+  const aboutSectionRef = React.useRef<SectionRef>(null)
+  const {openModal} = useModalControls()
+  const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+
+  useSetTitle(list.name)
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-    const onCurrentPageSelected = React.useCallback(
-      (index: number) => {
-        if (index === 0) {
-          feedSectionRef.current?.scrollToTop()
-        }
-        if (index === 1) {
-          aboutSectionRef.current?.scrollToTop()
+  const onPressAddUser = useCallback(() => {
+    openModal({
+      name: 'list-add-remove-users',
+      list,
+      onChange() {
+        if (isCurateList) {
+          // TODO(eric) should construct these strings with a fn too
+          truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
         }
       },
-      [feedSectionRef],
-    )
+    })
+  }, [openModal, list, isCurateList, queryClient])
+
+  const onCurrentPageSelected = React.useCallback(
+    (index: number) => {
+      if (index === 0) {
+        feedSectionRef.current?.scrollToTop()
+      } else if (index === 1) {
+        aboutSectionRef.current?.scrollToTop()
+      }
+    },
+    [feedSectionRef],
+  )
 
-    const renderHeader = useCallback(() => {
-      return <Header rkey={rkey} list={list} />
-    }, [rkey, list])
+  const renderHeader = useCallback(() => {
+    return <Header rkey={rkey} list={list} />
+  }, [rkey, list])
 
-    if (list.isCuratelist) {
-      return (
-        <View style={s.hContentRegion}>
-          <PagerWithHeader
-            items={SECTION_TITLES_CURATE}
-            isHeaderReady={list.hasLoaded}
-            renderHeader={renderHeader}
-            onCurrentPageSelected={onCurrentPageSelected}>
-            {({onScroll, headerHeight, isScrolledDown}) => (
-              <FeedSection
-                ref={feedSectionRef}
-                feed={feed}
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-            {({onScroll, headerHeight, isScrolledDown}) => (
-              <AboutSection
-                ref={aboutSectionRef}
-                list={list}
-                descriptionRT={list.descriptionRT}
-                creator={list.data ? list.data.creator : undefined}
-                isCurateList={list.isCuratelist}
-                isOwner={list.isOwner}
-                onPressAddUser={onPressAddUser}
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-          </PagerWithHeader>
-          <FAB
-            testID="composeFAB"
-            onPress={() => store.shell.openComposer({})}
-            icon={
-              <ComposeIcon2
-                strokeWidth={1.5}
-                size={29}
-                style={{color: 'white'}}
-              />
-            }
-            accessibilityRole="button"
-            accessibilityLabel="New post"
-            accessibilityHint=""
-          />
-        </View>
-      )
-    }
-    if (list.isModlist) {
-      return (
-        <View style={s.hContentRegion}>
-          <PagerWithHeader
-            items={SECTION_TITLES_MOD}
-            isHeaderReady={list.hasLoaded}
-            renderHeader={renderHeader}>
-            {({onScroll, headerHeight, isScrolledDown}) => (
-              <AboutSection
-                list={list}
-                descriptionRT={list.descriptionRT}
-                creator={list.data ? list.data.creator : undefined}
-                isCurateList={list.isCuratelist}
-                isOwner={list.isOwner}
-                onPressAddUser={onPressAddUser}
-                onScroll={onScroll}
-                headerHeight={headerHeight}
-                isScrolledDown={isScrolledDown}
-              />
-            )}
-          </PagerWithHeader>
-          <FAB
-            testID="composeFAB"
-            onPress={() => store.shell.openComposer({})}
-            icon={
-              <ComposeIcon2
-                strokeWidth={1.5}
-                size={29}
-                style={{color: 'white'}}
-              />
-            }
-            accessibilityRole="button"
-            accessibilityLabel="New post"
-            accessibilityHint=""
-          />
-        </View>
-      )
-    }
+  if (isCurateList) {
     return (
-      <CenteredView sideBorders style={s.hContentRegion}>
-        <Header rkey={rkey} list={list} />
-        {list.error ? <ErrorScreen error={list.error} /> : null}
-      </CenteredView>
+      <View style={s.hContentRegion}>
+        <PagerWithHeader
+          items={SECTION_TITLES_CURATE}
+          isHeaderReady={true}
+          renderHeader={renderHeader}
+          onCurrentPageSelected={onCurrentPageSelected}>
+          {({
+            onScroll,
+            headerHeight,
+            isScrolledDown,
+            scrollElRef,
+            isFocused,
+          }) => (
+            <FeedSection
+              ref={feedSectionRef}
+              feed={`list|${uri}`}
+              scrollElRef={
+                scrollElRef as React.MutableRefObject<FlatList<any> | null>
+              }
+              onScroll={onScroll}
+              headerHeight={headerHeight}
+              isScrolledDown={isScrolledDown}
+              isFocused={isFocused}
+            />
+          )}
+          {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+            <AboutSection
+              ref={aboutSectionRef}
+              scrollElRef={
+                scrollElRef as React.MutableRefObject<FlatList<any> | null>
+              }
+              list={list}
+              onPressAddUser={onPressAddUser}
+              onScroll={onScroll}
+              headerHeight={headerHeight}
+              isScrolledDown={isScrolledDown}
+            />
+          )}
+        </PagerWithHeader>
+        <FAB
+          testID="composeFAB"
+          onPress={() => openComposer({})}
+          icon={
+            <ComposeIcon2
+              strokeWidth={1.5}
+              size={29}
+              style={{color: 'white'}}
+            />
+          }
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`New post`)}
+          accessibilityHint=""
+        />
+      </View>
     )
-  },
-)
+  }
+  return (
+    <View style={s.hContentRegion}>
+      <PagerWithHeader
+        items={SECTION_TITLES_MOD}
+        isHeaderReady={true}
+        renderHeader={renderHeader}>
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <AboutSection
+            list={list}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<FlatList<any> | null>
+            }
+            onPressAddUser={onPressAddUser}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+          />
+        )}
+      </PagerWithHeader>
+      <FAB
+        testID="composeFAB"
+        onPress={() => openComposer({})}
+        icon={
+          <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
+        }
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </View>
+  )
+}
 
-const Header = observer(function HeaderImpl({
-  rkey,
-  list,
-}: {
-  rkey: string
-  list: ListModel
-}) {
+function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
-  const store = useStores()
+  const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
+  const {currentAccount} = useSession()
+  const {openModal, closeModal} = useModalControls()
+  const listMuteMutation = useListMuteMutation()
+  const listBlockMutation = useListBlockMutation()
+  const listDeleteMutation = useListDeleteMutation()
+  const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+  const isModList = list.purpose === 'app.bsky.graph.defs#modlist'
+  const isPinned = false // TODO
+  const isBlocking = !!list.viewer?.blocked
+  const isMuting = !!list.viewer?.muted
+  const isOwner = list.creator.did === currentAccount?.did
 
   const onTogglePinned = useCallback(async () => {
     Haptics.default()
-    list.togglePin().catch(e => {
-      Toast.show('There was an issue contacting the server')
-      logger.error('Failed to toggle pinned list', {error: e})
-    })
-  }, [list])
+    // TODO
+    // list.togglePin().catch(e => {
+    //   Toast.show('There was an issue contacting the server')
+    //   logger.error('Failed to toggle pinned list', {error: e})
+    // })
+  }, [])
 
   const onSubscribeMute = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Mute these accounts?',
-      message:
-        'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.',
+      title: _(msg`Mute these accounts?`),
+      message: _(
+        msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
+      ),
       confirmBtnText: 'Mute this List',
       async onPressConfirm() {
         try {
-          await list.mute()
+          await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
           Toast.show('List muted')
         } catch {
           Toast.show(
@@ -297,32 +287,33 @@ const Header = observer(function HeaderImpl({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, list])
+  }, [openModal, closeModal, list, listMuteMutation, _])
 
   const onUnsubscribeMute = useCallback(async () => {
     try {
-      await list.unmute()
+      await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
       Toast.show('List unmuted')
     } catch {
       Toast.show(
         'There was an issue. Please check your internet connection and try again.',
       )
     }
-  }, [list])
+  }, [list, listMuteMutation])
 
   const onSubscribeBlock = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Block these accounts?',
-      message:
-        'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
+      title: _(msg`Block these accounts?`),
+      message: _(
+        msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+      ),
       confirmBtnText: 'Block this List',
       async onPressConfirm() {
         try {
-          await list.block()
+          await listBlockMutation.mutateAsync({uri: list.uri, block: true})
           Toast.show('List blocked')
         } catch {
           Toast.show(
@@ -331,39 +322,36 @@ const Header = observer(function HeaderImpl({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, list])
+  }, [openModal, closeModal, list, listBlockMutation, _])
 
   const onUnsubscribeBlock = useCallback(async () => {
     try {
-      await list.unblock()
+      await listBlockMutation.mutateAsync({uri: list.uri, block: false})
       Toast.show('List unblocked')
     } catch {
       Toast.show(
         'There was an issue. Please check your internet connection and try again.',
       )
     }
-  }, [list])
+  }, [list, listBlockMutation])
 
   const onPressEdit = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'create-or-edit-list',
       list,
-      onSave() {
-        list.refresh()
-      },
     })
-  }, [store, list])
+  }, [openModal, list])
 
   const onPressDelete = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Delete List',
-      message: 'Are you sure?',
+      title: _(msg`Delete List`),
+      message: _(msg`Are you sure?`),
       async onPressConfirm() {
-        await list.delete()
+        await listDeleteMutation.mutateAsync({uri: list.uri})
         Toast.show('List deleted')
         if (navigation.canGoBack()) {
           navigation.goBack()
@@ -372,30 +360,26 @@ const Header = observer(function HeaderImpl({
         }
       },
     })
-  }, [store, list, navigation])
+  }, [openModal, list, listDeleteMutation, navigation, _])
 
   const onPressReport = useCallback(() => {
-    if (!list.data) return
-    store.shell.openModal({
+    openModal({
       name: 'report',
       uri: list.uri,
-      cid: list.data.cid,
+      cid: list.cid,
     })
-  }, [store, list])
+  }, [openModal, list])
 
   const onPressShare = useCallback(() => {
-    const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
+    const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
     shareUrl(url)
-  }, [list.creatorDid, rkey])
+  }, [list, rkey])
 
   const dropdownItems: DropdownItem[] = useMemo(() => {
-    if (!list.hasLoaded) {
-      return []
-    }
     let items: DropdownItem[] = [
       {
         testID: 'listHeaderDropdownShareBtn',
-        label: 'Share',
+        label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`),
         onPress: onPressShare,
         icon: {
           ios: {
@@ -406,11 +390,11 @@ const Header = observer(function HeaderImpl({
         },
       },
     ]
-    if (list.isOwner) {
+    if (isOwner) {
       items.push({label: 'separator'})
       items.push({
         testID: 'listHeaderDropdownEditBtn',
-        label: 'Edit List Details',
+        label: _(msg`Edit list details`),
         onPress: onPressEdit,
         icon: {
           ios: {
@@ -422,7 +406,7 @@ const Header = observer(function HeaderImpl({
       })
       items.push({
         testID: 'listHeaderDropdownDeleteBtn',
-        label: 'Delete List',
+        label: _(msg`Delete List`),
         onPress: onPressDelete,
         icon: {
           ios: {
@@ -436,7 +420,7 @@ const Header = observer(function HeaderImpl({
       items.push({label: 'separator'})
       items.push({
         testID: 'listHeaderDropdownReportBtn',
-        label: 'Report List',
+        label: _(msg`Report List`),
         onPress: onPressReport,
         icon: {
           ios: {
@@ -448,20 +432,13 @@ const Header = observer(function HeaderImpl({
       })
     }
     return items
-  }, [
-    list.hasLoaded,
-    list.isOwner,
-    onPressShare,
-    onPressEdit,
-    onPressDelete,
-    onPressReport,
-  ])
+  }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport, _])
 
   const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
     return [
       {
         testID: 'subscribeDropdownMuteBtn',
-        label: 'Mute accounts',
+        label: _(msg`Mute accounts`),
         onPress: onSubscribeMute,
         icon: {
           ios: {
@@ -473,7 +450,7 @@ const Header = observer(function HeaderImpl({
       },
       {
         testID: 'subscribeDropdownBlockBtn',
-        label: 'Block accounts',
+        label: _(msg`Block accounts`),
         onPress: onSubscribeBlock,
         icon: {
           ios: {
@@ -484,36 +461,32 @@ const Header = observer(function HeaderImpl({
         },
       },
     ]
-  }, [onSubscribeMute, onSubscribeBlock])
+  }, [onSubscribeMute, onSubscribeBlock, _])
 
   return (
     <ProfileSubpageHeader
-      isLoading={!list.hasLoaded}
-      href={makeListLink(
-        list.data?.creator.handle || list.data?.creator.did || '',
-        rkey,
-      )}
-      title={list.data?.name || 'User list'}
-      avatar={list.data?.avatar}
-      isOwner={list.isOwner}
-      creator={list.data?.creator}
+      href={makeListLink(list.creator.handle || list.creator.did || '', rkey)}
+      title={list.name}
+      avatar={list.avatar}
+      isOwner={list.creator.did === currentAccount?.did}
+      creator={list.creator}
       avatarType="list">
-      {list.isCuratelist || list.isPinned ? (
+      {isCurateList || isPinned ? (
         <Button
           testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
           type={list.isPinned ? 'default' : 'inverted'}
           label={list.isPinned ? 'Unpin' : 'Pin to home'}
           onPress={onTogglePinned}
         />
-      ) : list.isModlist ? (
-        list.isBlocking ? (
+      ) : isModList ? (
+        isBlocking ? (
           <Button
             testID="unblockBtn"
             type="default"
             label="Unblock"
             onPress={onUnsubscribeBlock}
           />
-        ) : list.isMuting ? (
+        ) : isMuting ? (
           <Button
             testID="unmuteBtn"
             type="default"
@@ -524,10 +497,12 @@ const Header = observer(function HeaderImpl({
           <NativeDropdown
             testID="subscribeBtn"
             items={subscribeDropdownItems}
-            accessibilityLabel="Subscribe to this list"
+            accessibilityLabel={_(msg`Subscribe to this list`)}
             accessibilityHint="">
             <View style={[palInverted.view, styles.btn]}>
-              <Text style={palInverted.text}>Subscribe</Text>
+              <Text style={palInverted.text}>
+                <Trans>Subscribe</Trans>
+              </Text>
             </View>
           </NativeDropdown>
         )
@@ -535,7 +510,7 @@ const Header = observer(function HeaderImpl({
       <NativeDropdown
         testID="headerDropdownBtn"
         items={dropdownItems}
-        accessibilityLabel="More options"
+        accessibilityLabel={_(msg`More options`)}
         accessibilityHint="">
         <View style={[pal.viewLight, styles.btn]}>
           <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
@@ -543,26 +518,29 @@ const Header = observer(function HeaderImpl({
       </NativeDropdown>
     </ProfileSubpageHeader>
   )
-})
+}
 
 interface FeedSectionProps {
-  feed: PostsFeedModel
-  onScroll: (e: NativeScrollEvent) => void
+  feed: FeedDescriptor
+  onScroll: OnScrollHandler
   headerHeight: number
   isScrolledDown: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  isFocused: boolean
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   function FeedSectionImpl(
-    {feed, onScroll, headerHeight, isScrolledDown},
+    {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused},
     ref,
   ) {
-    const hasNew = feed.hasNewLatest && !feed.isRefreshing
-    const scrollElRef = React.useRef<FlatList>(null)
+    const queryClient = useQueryClient()
+    const [hasNew, setHasNew] = React.useState(false)
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      feed.refresh()
-    }, [feed, scrollElRef, headerHeight])
+      queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
     React.useImperativeHandle(ref, () => ({
       scrollToTop: onScrollToTop,
     }))
@@ -571,14 +549,16 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
       return <EmptyState icon="feed" message="This feed is empty!" />
     }, [])
 
-    const scrollHandler = useAnimatedScrollHandler({onScroll})
     return (
       <View>
         <Feed
           testID="listFeed"
+          enabled={isFocused}
           feed={feed}
+          pollInterval={30e3}
           scrollElRef={scrollElRef}
-          onScroll={scrollHandler}
+          onHasNew={setHasNew}
+          onScroll={onScroll}
           scrollEventThrottle={1}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
@@ -596,34 +576,35 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 )
 
 interface AboutSectionProps {
-  list: ListModel
-  descriptionRT: RichTextAPI | null
-  creator: {did: string; handle: string} | undefined
-  isCurateList: boolean | undefined
-  isOwner: boolean | undefined
+  list: AppBskyGraphDefs.ListView
   onPressAddUser: () => void
-  onScroll: (e: NativeScrollEvent) => void
+  onScroll: OnScrollHandler
   headerHeight: number
   isScrolledDown: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | null>
 }
 const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
   function AboutSectionImpl(
-    {
-      list,
-      descriptionRT,
-      creator,
-      isCurateList,
-      isOwner,
-      onPressAddUser,
-      onScroll,
-      headerHeight,
-      isScrolledDown,
-    },
+    {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef},
     ref,
   ) {
     const pal = usePalette('default')
+    const {_} = useLingui()
     const {isMobile} = useWebMediaQueries()
-    const scrollElRef = React.useRef<FlatList>(null)
+    const {currentAccount} = useSession()
+    const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+    const isOwner = list.creator.did === currentAccount?.did
+
+    const descriptionRT = useMemo(
+      () =>
+        list.description
+          ? new RichTextAPI({
+              text: list.description,
+              facets: list.descriptionFacets,
+            })
+          : undefined,
+      [list],
+    )
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@@ -634,9 +615,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
     }))
 
     const renderHeader = React.useCallback(() => {
-      if (!list.data) {
-        return <View />
-      }
       return (
         <View>
           <View
@@ -660,7 +638,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                 testID="listDescriptionEmpty"
                 type="lg"
                 style={[{fontStyle: 'italic'}, pal.textLight]}>
-                No description
+                <Trans>No description</Trans>
               </Text>
             )}
             <Text type="md" style={[pal.textLight]} numberOfLines={1}>
@@ -669,8 +647,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                 'you'
               ) : (
                 <TextLink
-                  text={sanitizeHandle(creator?.handle || '', '@')}
-                  href={creator ? makeProfileLink(creator) : ''}
+                  text={sanitizeHandle(list.creator.handle || '', '@')}
+                  href={makeProfileLink(list.creator)}
                   style={pal.textLight}
                 />
               )}
@@ -686,12 +664,14 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                 paddingBottom: isMobile ? 14 : 18,
               },
             ]}>
-            <Text type="lg-bold">Users</Text>
+            <Text type="lg-bold">
+              <Trans>Users</Trans>
+            </Text>
             {isOwner && (
               <Pressable
                 testID="addUserBtn"
                 accessibilityRole="button"
-                accessibilityLabel="Add a user to this list"
+                accessibilityLabel={_(msg`Add a user to this list`)}
                 accessibilityHint=""
                 onPress={onPressAddUser}
                 style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
@@ -700,7 +680,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                   color={pal.colors.link}
                   size={16}
                 />
-                <Text style={pal.link}>Add</Text>
+                <Text style={pal.link}>
+                  <Trans>Add</Trans>
+                </Text>
               </Pressable>
             )}
           </View>
@@ -708,13 +690,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
       )
     }, [
       pal,
-      list.data,
+      list,
       isMobile,
       descriptionRT,
-      creator,
       isCurateList,
       isOwner,
       onPressAddUser,
+      _,
     ])
 
     const renderEmptyState = useCallback(() => {
@@ -727,17 +709,16 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
       )
     }, [])
 
-    const scrollHandler = useAnimatedScrollHandler({onScroll})
     return (
       <View>
-        <ListItems
+        <ListMembers
           testID="listItems"
+          list={list.uri}
           scrollElRef={scrollElRef}
           renderHeader={renderHeader}
           renderEmptyState={renderEmptyState}
-          list={list}
           headerOffset={headerHeight}
-          onScroll={scrollHandler}
+          onScroll={onScroll}
           scrollEventThrottle={1}
         />
         {isScrolledDown && (
@@ -755,6 +736,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
 function ErrorScreen({error}: {error: string}) {
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
   const onPressBack = useCallback(() => {
     if (navigation.canGoBack()) {
       navigation.goBack()
@@ -776,7 +758,7 @@ function ErrorScreen({error}: {error: string}) {
         },
       ]}>
       <Text type="title-lg" style={[pal.text, s.mb10]}>
-        Could not load list
+        <Trans>Could not load list</Trans>
       </Text>
       <Text type="md" style={[pal.text, s.mb20]}>
         {error}
@@ -785,12 +767,12 @@ function ErrorScreen({error}: {error: string}) {
       <View style={{flexDirection: 'row'}}>
         <Button
           type="default"
-          accessibilityLabel="Go Back"
+          accessibilityLabel={_(msg`Go Back`)}
           accessibilityHint="Return to previous page"
           onPress={onPressBack}
           style={{flexShrink: 1}}>
           <Text type="button" style={pal.text}>
-            Go Back
+            <Trans>Go Back</Trans>
           </Text>
         </Button>
       </View>
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 487f56643..858a58a3c 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,33 +1,32 @@
-import React, {useCallback, useMemo} from 'react'
-import {
-  StyleSheet,
-  View,
-  ActivityIndicator,
-  Pressable,
-  TouchableOpacity,
-} from 'react-native'
+import React from 'react'
+import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {track} from '#/lib/analytics/analytics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {ScrollView, CenteredView} from 'view/com/util/Views'
 import {Text} from 'view/com/util/text/Text'
 import {s, colors} from 'lib/styles'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as Toast from 'view/com/util/Toast'
 import {Haptics} from 'lib/haptics'
 import {TextLink} from 'view/com/util/Link'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePreferencesQuery,
+  usePinFeedMutation,
+  useUnpinFeedMutation,
+  useSetSaveFeedsMutation,
+} from '#/state/queries/preferences'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
 const HITSLOP_TOP = {
   top: 20,
@@ -43,99 +42,118 @@ const HITSLOP_BOTTOM = {
 }
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
-export const SavedFeeds = withAuthRequired(
-  observer(function SavedFeedsImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
-    const {screen} = useAnalytics()
-    const setMinimalShellMode = useSetMinimalShellMode()
+export function SavedFeeds({}: Props) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+  const {screen} = useAnalytics()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: setSavedFeeds,
+    variables: optimisticSavedFeedsResponse,
+    reset: resetSaveFeedsMutationState,
+    error: setSavedFeedsError,
+  } = useSetSaveFeedsMutation()
+
+  /*
+   * Use optimistic data if exists and no error, otherwise fallback to remote
+   * data
+   */
+  const currentFeeds =
+    optimisticSavedFeedsResponse && !setSavedFeedsError
+      ? optimisticSavedFeedsResponse
+      : preferences?.feeds || {saved: [], pinned: []}
+  const unpinned = currentFeeds.saved.filter(f => {
+    return !currentFeeds.pinned?.includes(f)
+  })
 
-    const savedFeeds = useMemo(() => {
-      const model = new SavedFeedsModel(store)
-      model.refresh()
-      return model
-    }, [store])
-    useFocusEffect(
-      useCallback(() => {
-        screen('SavedFeeds')
-        setMinimalShellMode(false)
-        savedFeeds.refresh()
-      }, [screen, setMinimalShellMode, savedFeeds]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('SavedFeeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    return (
-      <CenteredView
-        style={[
-          s.hContentRegion,
-          pal.border,
-          isTabletOrDesktop && styles.desktopContainer,
-        ]}>
-        <ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
-        <ScrollView style={s.flex1}>
-          <View style={[pal.text, pal.border, styles.title]}>
-            <Text type="title" style={pal.text}>
-              Pinned Feeds
-            </Text>
-          </View>
-          {savedFeeds.hasLoaded ? (
-            !savedFeeds.pinned.length ? (
-              <View
-                style={[
-                  pal.border,
-                  isMobile && s.flex1,
-                  pal.viewLight,
-                  styles.empty,
-                ]}>
-                <Text type="lg" style={[pal.text]}>
-                  You don't have any pinned feeds.
-                </Text>
-              </View>
-            ) : (
-              savedFeeds.pinned.map(feed => (
-                <ListItem
-                  key={feed._reactKey}
-                  savedFeeds={savedFeeds}
-                  item={feed}
-                />
-              ))
-            )
+  return (
+    <CenteredView
+      style={[
+        s.hContentRegion,
+        pal.border,
+        isTabletOrDesktop && styles.desktopContainer,
+      ]}>
+      <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
+      <ScrollView style={s.flex1}>
+        <View style={[pal.text, pal.border, styles.title]}>
+          <Text type="title" style={pal.text}>
+            <Trans>Pinned Feeds</Trans>
+          </Text>
+        </View>
+        {preferences?.feeds ? (
+          !currentFeeds.pinned.length ? (
+            <View
+              style={[
+                pal.border,
+                isMobile && s.flex1,
+                pal.viewLight,
+                styles.empty,
+              ]}>
+              <Text type="lg" style={[pal.text]}>
+                <Trans>You don't have any pinned feeds.</Trans>
+              </Text>
+            </View>
           ) : (
-            <ActivityIndicator style={{marginTop: 20}} />
-          )}
-          <View style={[pal.text, pal.border, styles.title]}>
-            <Text type="title" style={pal.text}>
-              Saved Feeds
-            </Text>
-          </View>
-          {savedFeeds.hasLoaded ? (
-            !savedFeeds.unpinned.length ? (
-              <View
-                style={[
-                  pal.border,
-                  isMobile && s.flex1,
-                  pal.viewLight,
-                  styles.empty,
-                ]}>
-                <Text type="lg" style={[pal.text]}>
-                  You don't have any saved feeds.
-                </Text>
-              </View>
-            ) : (
-              savedFeeds.unpinned.map(feed => (
-                <ListItem
-                  key={feed._reactKey}
-                  savedFeeds={savedFeeds}
-                  item={feed}
-                />
-              ))
-            )
+            currentFeeds.pinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
+            ))
+          )
+        ) : (
+          <ActivityIndicator style={{marginTop: 20}} />
+        )}
+        <View style={[pal.text, pal.border, styles.title]}>
+          <Text type="title" style={pal.text}>
+            <Trans>Saved Feeds</Trans>
+          </Text>
+        </View>
+        {preferences?.feeds ? (
+          !unpinned.length ? (
+            <View
+              style={[
+                pal.border,
+                isMobile && s.flex1,
+                pal.viewLight,
+                styles.empty,
+              ]}>
+              <Text type="lg" style={[pal.text]}>
+                <Trans>You don't have any saved feeds.</Trans>
+              </Text>
+            </View>
           ) : (
-            <ActivityIndicator style={{marginTop: 20}} />
-          )}
+            unpinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned={false}
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
+            ))
+          )
+        ) : (
+          <ActivityIndicator style={{marginTop: 20}} />
+        )}
 
-          <View style={styles.footerText}>
-            <Text type="sm" style={pal.textLight}>
+        <View style={styles.footerText}>
+          <Text type="sm" style={pal.textLight}>
+            <Trans>
               Feeds are custom algorithms that users build with a little coding
               expertise.{' '}
               <TextLink
@@ -145,48 +163,95 @@ export const SavedFeeds = withAuthRequired(
                 text="See this guide"
               />{' '}
               for more information.
-            </Text>
-          </View>
-          <View style={{height: 100}} />
-        </ScrollView>
-      </CenteredView>
-    )
-  }),
-)
+            </Trans>
+          </Text>
+        </View>
+        <View style={{height: 100}} />
+      </ScrollView>
+    </CenteredView>
+  )
+}
 
-const ListItem = observer(function ListItemImpl({
-  savedFeeds,
-  item,
+function ListItem({
+  feedUri,
+  isPinned,
+  currentFeeds,
+  setSavedFeeds,
+  resetSaveFeedsMutationState,
 }: {
-  savedFeeds: SavedFeedsModel
-  item: FeedSourceModel
+  feedUri: string // uri
+  isPinned: boolean
+  currentFeeds: {saved: string[]; pinned: string[]}
+  setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync']
+  resetSaveFeedsMutationState: ReturnType<
+    typeof useSetSaveFeedsMutation
+  >['reset']
 }) {
   const pal = usePalette('default')
-  const isPinned = item.isPinned
+  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
+  const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
+    useUnpinFeedMutation()
+  const isPending = isPinPending || isUnpinPending
 
-  const onTogglePinned = useCallback(() => {
+  const onTogglePinned = React.useCallback(async () => {
     Haptics.default()
-    item.togglePin().catch(e => {
+
+    try {
+      resetSaveFeedsMutationState()
+
+      if (isPinned) {
+        await unpinFeed({uri: feedUri})
+      } else {
+        await pinFeed({uri: feedUri})
+      }
+    } catch (e) {
       Toast.show('There was an issue contacting the server')
       logger.error('Failed to toggle pinned feed', {error: e})
-    })
-  }, [item])
-  const onPressUp = useCallback(
-    () =>
-      savedFeeds.movePinnedFeed(item, 'up').catch(e => {
-        Toast.show('There was an issue contacting the server')
-        logger.error('Failed to set pinned feed order', {error: e})
-      }),
-    [savedFeeds, item],
-  )
-  const onPressDown = useCallback(
-    () =>
-      savedFeeds.movePinnedFeed(item, 'down').catch(e => {
-        Toast.show('There was an issue contacting the server')
-        logger.error('Failed to set pinned feed order', {error: e})
-      }),
-    [savedFeeds, item],
-  )
+    }
+  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState])
+
+  const onPressUp = React.useCallback(async () => {
+    if (!isPinned) return
+
+    // create new array, do not mutate
+    const pinned = [...currentFeeds.pinned]
+    const index = pinned.indexOf(feedUri)
+
+    if (index === -1 || index === 0) return
+    ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
+
+    try {
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      track('CustomFeed:Reorder', {
+        uri: feedUri,
+        index: pinned.indexOf(feedUri),
+      })
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to set pinned feed order', {error: e})
+    }
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
+
+  const onPressDown = React.useCallback(async () => {
+    if (!isPinned) return
+
+    const pinned = [...currentFeeds.pinned]
+    const index = pinned.indexOf(feedUri)
+
+    if (index === -1 || index >= pinned.length - 1) return
+    ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
+
+    try {
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      track('CustomFeed:Reorder', {
+        uri: feedUri,
+        index: pinned.indexOf(feedUri),
+      })
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to set pinned feed order', {error: e})
+    }
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
 
   return (
     <Pressable
@@ -194,43 +259,62 @@ const ListItem = observer(function ListItemImpl({
       style={[styles.itemContainer, pal.border]}>
       {isPinned ? (
         <View style={styles.webArrowButtonsContainer}>
-          <TouchableOpacity
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressUp}
-            hitSlop={HITSLOP_TOP}>
+            hitSlop={HITSLOP_TOP}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon
               icon="arrow-up"
               size={12}
               style={[pal.text, styles.webArrowUpButton]}
             />
-          </TouchableOpacity>
-          <TouchableOpacity
+          </Pressable>
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressDown}
-            hitSlop={HITSLOP_BOTTOM}>
+            hitSlop={HITSLOP_BOTTOM}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} />
-          </TouchableOpacity>
+          </Pressable>
         </View>
       ) : null}
       <FeedSourceCard
-        key={item.uri}
-        item={item}
-        showSaveBtn
+        key={feedUri}
+        feedUri={feedUri}
         style={styles.noBorder}
+        showSaveBtn
+        LoadingComponent={
+          <FeedLoadingPlaceholder
+            style={{flex: 1}}
+            showLowerPlaceholder={false}
+            showTopBorder={false}
+          />
+        }
       />
-      <TouchableOpacity
+      <Pressable
+        disabled={isPending}
         accessibilityRole="button"
         hitSlop={10}
-        onPress={onTogglePinned}>
+        onPress={onTogglePinned}
+        style={state => ({
+          opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+        })}>
         <FontAwesomeIcon
           icon="thumb-tack"
           size={20}
           color={isPinned ? colors.blue3 : pal.colors.icon}
         />
-      </TouchableOpacity>
+      </Pressable>
     </Pressable>
   )
-})
+}
 
 const styles = StyleSheet.create({
   desktopContainer: {
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
deleted file mode 100644
index bf9857df4..000000000
--- a/src/view/screens/Search.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './SearchMobile'
diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx
deleted file mode 100644
index 2d0c0288a..000000000
--- a/src/view/screens/Search.web.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react'
-import {View, StyleSheet} from 'react-native'
-import {SearchUIModel} from 'state/models/ui/search'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {Suggestions} from 'view/com/search/Suggestions'
-import {SearchResults} from 'view/com/search/SearchResults'
-import {observer} from 'mobx-react-lite'
-import {
-  NativeStackScreenProps,
-  SearchTabNavigatorParams,
-} from 'lib/routes/types'
-import {useStores} from 'state/index'
-import {CenteredView} from 'view/com/util/Views'
-import * as Mobile from './SearchMobile'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-
-type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
-export const SearchScreen = withAuthRequired(
-  observer(function SearchScreenImpl({navigation, route}: Props) {
-    const store = useStores()
-    const params = route.params || {}
-    const foafs = React.useMemo<FoafsModel>(
-      () => new FoafsModel(store),
-      [store],
-    )
-    const suggestedActors = React.useMemo<SuggestedActorsModel>(
-      () => new SuggestedActorsModel(store),
-      [store],
-    )
-    const searchUIModel = React.useMemo<SearchUIModel | undefined>(
-      () => (params.q ? new SearchUIModel(store) : undefined),
-      [params.q, store],
-    )
-
-    React.useEffect(() => {
-      if (params.q && searchUIModel) {
-        searchUIModel.fetch(params.q)
-      }
-      if (!foafs.hasData) {
-        foafs.fetch()
-      }
-      if (!suggestedActors.hasLoaded) {
-        suggestedActors.loadMore(true)
-      }
-    }, [foafs, suggestedActors, searchUIModel, params.q])
-
-    const {isDesktop} = useWebMediaQueries()
-
-    if (searchUIModel) {
-      return (
-        <View style={styles.scrollContainer}>
-          <SearchResults model={searchUIModel} />
-        </View>
-      )
-    }
-
-    if (!isDesktop) {
-      return (
-        <CenteredView style={styles.scrollContainer}>
-          <Mobile.SearchScreen navigation={navigation} route={route} />
-        </CenteredView>
-      )
-    }
-
-    return <Suggestions foafs={foafs} suggestedActors={suggestedActors} />
-  }),
-)
-
-const styles = StyleSheet.create({
-  scrollContainer: {
-    height: '100%',
-    overflowY: 'auto',
-  },
-})
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
new file mode 100644
index 000000000..f031abcc2
--- /dev/null
+++ b/src/view/screens/Search/Search.tsx
@@ -0,0 +1,658 @@
+import React from 'react'
+import {
+  View,
+  StyleSheet,
+  ActivityIndicator,
+  RefreshControl,
+  TextInput,
+  Pressable,
+  Platform,
+} from 'react-native'
+import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views'
+import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {logger} from '#/logger'
+import {
+  NativeStackScreenProps,
+  SearchTabNavigatorParams,
+} from 'lib/routes/types'
+import {Text} from '#/view/com/util/text/Text'
+import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Post} from '#/view/com/post/Post'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
+import {HITSLOP_10} from '#/lib/constants'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useSession} from '#/state/session'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useSetDrawerOpen} from '#/state/shell'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {MagnifyingGlassIcon} from '#/lib/icons'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {SearchResultCard} from '#/view/shell/desktop/Search'
+import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
+import {isWeb} from '#/platform/detection'
+import {listenSoftReset} from '#/state/events'
+import {s} from '#/lib/styles'
+
+function Loader() {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <CenteredView
+      style={[
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+        pal.border,
+      ]}
+      sideBorders={!isMobile}>
+      <ActivityIndicator />
+    </CenteredView>
+  )
+}
+
+function EmptyState({message, error}: {message: string; error?: string}) {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+
+  return (
+    <CenteredView
+      sideBorders={!isMobile}
+      style={[
+        pal.border,
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+      ]}>
+      <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
+        <Text style={[pal.text]}>
+          <Trans>{message}</Trans>
+        </Text>
+
+        {error && (
+          <>
+            <View
+              style={[
+                {
+                  marginVertical: 12,
+                  height: 1,
+                  width: '100%',
+                  backgroundColor: pal.text.color,
+                  opacity: 0.2,
+                },
+              ]}
+            />
+
+            <Text style={[pal.textLight]}>
+              <Trans>Error:</Trans> {error}
+            </Text>
+          </>
+        )}
+      </View>
+    </CenteredView>
+  )
+}
+
+function SearchScreenSuggestedFollows() {
+  const pal = usePalette('default')
+  const {currentAccount} = useSession()
+  const [suggestions, setSuggestions] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
+
+  React.useEffect(() => {
+    async function getSuggestions() {
+      const friends = await getSuggestedFollowsByActor(
+        currentAccount!.did,
+      ).then(friendsRes => friendsRes.suggestions)
+
+      if (!friends) return // :(
+
+      const friendsOfFriends = new Map<
+        string,
+        AppBskyActorDefs.ProfileViewBasic
+      >()
+
+      await Promise.all(
+        friends.slice(0, 4).map(friend =>
+          getSuggestedFollowsByActor(friend.did).then(foafsRes => {
+            for (const user of foafsRes.suggestions) {
+              friendsOfFriends.set(user.did, user)
+            }
+          }),
+        ),
+      )
+
+      setSuggestions(Array.from(friendsOfFriends.values()))
+    }
+
+    try {
+      getSuggestions()
+    } catch (e) {
+      logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
+        error: e,
+      })
+    }
+  }, [currentAccount, setSuggestions, getSuggestedFollowsByActor])
+
+  return suggestions.length ? (
+    <FlatList
+      data={suggestions}
+      renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
+      keyExtractor={item => item.did}
+      // @ts-ignore web only -prf
+      desktopFixedHeight
+      contentContainerStyle={{paddingBottom: 1200}}
+    />
+  ) : (
+    <CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
+      <ProfileCardFeedLoadingPlaceholder />
+      <ProfileCardFeedLoadingPlaceholder />
+    </CenteredView>
+  )
+}
+
+type SearchResultSlice =
+  | {
+      type: 'post'
+      key: string
+      post: AppBskyFeedDefs.PostView
+    }
+  | {
+      type: 'loadingMore'
+      key: string
+    }
+
+function SearchScreenPostResults({query}: {query: string}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const [isPTR, setIsPTR] = React.useState(false)
+  const {
+    isFetched,
+    data: results,
+    isFetching,
+    error,
+    refetch,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+  } = useSearchPostsQuery({query})
+
+  const onPullToRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [setIsPTR, refetch])
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, error, hasNextPage, fetchNextPage])
+
+  const posts = React.useMemo(() => {
+    return results?.pages.flatMap(page => page.posts) || []
+  }, [results])
+  const items = React.useMemo(() => {
+    let temp: SearchResultSlice[] = []
+
+    for (const post of posts) {
+      temp.push({
+        type: 'post',
+        key: post.uri,
+        post,
+      })
+    }
+
+    if (isFetchingNextPage) {
+      temp.push({
+        type: 'loadingMore',
+        key: 'loadingMore',
+      })
+    }
+
+    return temp
+  }, [posts, isFetchingNextPage])
+
+  return error ? (
+    <EmptyState
+      message={_(
+        msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
+      )}
+      error={error.toString()}
+    />
+  ) : (
+    <>
+      {isFetched ? (
+        <>
+          {posts.length ? (
+            <FlatList
+              data={items}
+              renderItem={({item}) => {
+                if (item.type === 'post') {
+                  return <Post post={item.post} />
+                } else {
+                  return <Loader />
+                }
+              }}
+              keyExtractor={item => item.key}
+              refreshControl={
+                <RefreshControl
+                  refreshing={isPTR}
+                  onRefresh={onPullToRefresh}
+                  tintColor={pal.colors.text}
+                  titleColor={pal.colors.text}
+                />
+              }
+              onEndReached={onEndReached}
+              // @ts-ignore web only -prf
+              desktopFixedHeight
+              contentContainerStyle={{paddingBottom: 100}}
+            />
+          ) : (
+            <EmptyState message={_(msg`No results found for ${query}`)} />
+          )}
+        </>
+      ) : (
+        <Loader />
+      )}
+    </>
+  )
+}
+
+function SearchScreenUserResults({query}: {query: string}) {
+  const {_} = useLingui()
+  const [isFetched, setIsFetched] = React.useState(false)
+  const [results, setResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const search = useActorAutocompleteFn()
+
+  React.useEffect(() => {
+    async function getResults() {
+      try {
+        const searchResults = await search({query, limit: 30})
+
+        if (searchResults) {
+          setResults(searchResults)
+        }
+      } catch (e: any) {
+        logger.error(`SearchScreenUserResults: failed to get results`, {
+          error: e.toString(),
+        })
+      } finally {
+        setIsFetched(true)
+      }
+    }
+
+    if (query) {
+      getResults()
+    } else {
+      setResults([])
+      setIsFetched(false)
+    }
+  }, [query, search, setResults])
+
+  return isFetched ? (
+    <>
+      {results.length ? (
+        <FlatList
+          data={results}
+          renderItem={({item}) => (
+            <ProfileCardWithFollowBtn profile={item} noBg />
+          )}
+          keyExtractor={item => item.did}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={_(msg`No results found for ${query}`)} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+
+const SECTIONS = ['Posts', 'Users']
+export function SearchScreenInner({query}: {query?: string}) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+  const {hasSession} = useSession()
+  const {isDesktop} = useWebMediaQueries()
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setDrawerSwipeDisabled(index > 0)
+    },
+    [setDrawerSwipeDisabled, setMinimalShellMode],
+  )
+
+  return query ? (
+    <Pager
+      tabBarPosition="top"
+      onPageSelected={onPageSelected}
+      renderTabBar={props => (
+        <CenteredView sideBorders style={pal.border}>
+          <TabBar items={SECTIONS} {...props} />
+        </CenteredView>
+      )}
+      initialPage={0}>
+      <View>
+        <SearchScreenPostResults query={query} />
+      </View>
+      <View>
+        <SearchScreenUserResults query={query} />
+      </View>
+    </Pager>
+  ) : hasSession ? (
+    <View>
+      <CenteredView sideBorders style={pal.border}>
+        <Text
+          type="title"
+          style={[
+            pal.text,
+            pal.border,
+            {
+              display: 'flex',
+              paddingVertical: 12,
+              paddingHorizontal: 18,
+              fontWeight: 'bold',
+            },
+          ]}>
+          <Trans>Suggested Follows</Trans>
+        </Text>
+      </CenteredView>
+
+      <SearchScreenSuggestedFollows />
+    </View>
+  ) : (
+    <CenteredView sideBorders style={pal.border}>
+      <View
+        // @ts-ignore web only -esb
+        style={{
+          height: Platform.select({web: '100vh'}),
+        }}>
+        {isDesktop && (
+          <Text
+            type="title"
+            style={[
+              pal.text,
+              pal.border,
+              {
+                display: 'flex',
+                paddingVertical: 12,
+                paddingHorizontal: 18,
+                fontWeight: 'bold',
+                borderBottomWidth: 1,
+              },
+            ]}>
+            <Trans>Search</Trans>
+          </Text>
+        )}
+
+        <Text
+          style={[
+            pal.textLight,
+            {textAlign: 'center', paddingVertical: 12, paddingHorizontal: 18},
+          ]}>
+          <Trans>Search for posts and users.</Trans>
+        </Text>
+      </View>
+    </CenteredView>
+  )
+}
+
+export function SearchScreenDesktop(
+  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const {isDesktop} = useWebMediaQueries()
+
+  return isDesktop ? (
+    <SearchScreenInner query={props.route.params?.q} />
+  ) : (
+    <SearchScreenMobile {...props} />
+  )
+}
+
+export function SearchScreenMobile(
+  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const theme = useTheme()
+  const textInput = React.useRef<TextInput>(null)
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const setDrawerOpen = useSetDrawerOpen()
+  const moderationOpts = useModerationOpts()
+  const search = useActorAutocompleteFn()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {isTablet} = useWebMediaQueries()
+
+  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
+    undefined,
+  )
+  const [isFetching, setIsFetching] = React.useState<boolean>(false)
+  const [query, setQuery] = React.useState<string>(props.route?.params?.q || '')
+  const [searchResults, setSearchResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const [inputIsFocused, setInputIsFocused] = React.useState(false)
+  const [showAutocompleteResults, setShowAutocompleteResults] =
+    React.useState(false)
+
+  const onPressMenu = React.useCallback(() => {
+    track('ViewHeader:MenuButtonClicked')
+    setDrawerOpen(true)
+  }, [track, setDrawerOpen])
+  const onPressCancelSearch = React.useCallback(() => {
+    textInput.current?.blur()
+    setQuery('')
+    setShowAutocompleteResults(false)
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
+  }, [textInput])
+  const onPressClearQuery = React.useCallback(() => {
+    setQuery('')
+    setShowAutocompleteResults(false)
+  }, [setQuery])
+  const onChangeText = React.useCallback(
+    async (text: string) => {
+      setQuery(text)
+
+      if (text.length > 0) {
+        setIsFetching(true)
+        setShowAutocompleteResults(true)
+
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+
+        searchDebounceTimeout.current = setTimeout(async () => {
+          const results = await search({query: text, limit: 30})
+
+          if (results) {
+            setSearchResults(results)
+            setIsFetching(false)
+          }
+        }, 300)
+      } else {
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
+        setIsFetching(false)
+        setShowAutocompleteResults(false)
+      }
+    },
+    [setQuery, search, setSearchResults],
+  )
+  const onSubmit = React.useCallback(() => {
+    setShowAutocompleteResults(false)
+  }, [setShowAutocompleteResults])
+
+  const onSoftReset = React.useCallback(() => {
+    onPressCancelSearch()
+  }, [onPressCancelSearch])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+      return listenSoftReset(onSoftReset)
+    }, [onSoftReset, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={{flex: 1}}>
+      <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}>
+        <Pressable
+          testID="viewHeaderBackOrMenuBtn"
+          onPress={onPressMenu}
+          hitSlop={HITSLOP_10}
+          style={styles.headerMenuBtn}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Menu`)}
+          accessibilityHint="Access navigation links and settings">
+          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
+        </Pressable>
+
+        <View
+          style={[
+            {backgroundColor: pal.colors.backgroundLight},
+            styles.headerSearchContainer,
+          ]}>
+          <MagnifyingGlassIcon
+            style={[pal.icon, styles.headerSearchIcon]}
+            size={21}
+          />
+          <TextInput
+            testID="searchTextInput"
+            ref={textInput}
+            placeholder="Search"
+            placeholderTextColor={pal.colors.textLight}
+            selectTextOnFocus
+            returnKeyType="search"
+            value={query}
+            style={[pal.text, styles.headerSearchInput]}
+            keyboardAppearance={theme.colorScheme}
+            onFocus={() => setInputIsFocused(true)}
+            onBlur={() => setInputIsFocused(false)}
+            onChangeText={onChangeText}
+            onSubmitEditing={onSubmit}
+            autoFocus={false}
+            accessibilityRole="search"
+            accessibilityLabel={_(msg`Search`)}
+            accessibilityHint=""
+            autoCorrect={false}
+            autoCapitalize="none"
+          />
+          {query ? (
+            <Pressable
+              testID="searchTextInputClearBtn"
+              onPress={onPressClearQuery}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Clear search query`)}
+              accessibilityHint="">
+              <FontAwesomeIcon
+                icon="xmark"
+                size={16}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </Pressable>
+          ) : undefined}
+        </View>
+
+        {query || inputIsFocused ? (
+          <View style={styles.headerCancelBtn}>
+            <Pressable onPress={onPressCancelSearch} accessibilityRole="button">
+              <Text style={[pal.text]}>
+                <Trans>Cancel</Trans>
+              </Text>
+            </Pressable>
+          </View>
+        ) : undefined}
+      </CenteredView>
+
+      {showAutocompleteResults && moderationOpts ? (
+        <>
+          {isFetching ? (
+            <Loader />
+          ) : (
+            <ScrollView style={{height: '100%'}}>
+              {searchResults.length ? (
+                searchResults.map((item, i) => (
+                  <SearchResultCard
+                    key={item.did}
+                    profile={item}
+                    moderation={moderateProfile(item, moderationOpts)}
+                    style={i === 0 ? {borderTopWidth: 0} : {}}
+                  />
+                ))
+              ) : (
+                <EmptyState message={_(msg`No results found for ${query}`)} />
+              )}
+
+              <View style={{height: 200}} />
+            </ScrollView>
+          )}
+        </>
+      ) : (
+        <SearchScreenInner query={query} />
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 12,
+    paddingVertical: 4,
+  },
+  headerMenuBtn: {
+    width: 30,
+    height: 30,
+    borderRadius: 30,
+    marginRight: 6,
+    paddingBottom: 2,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  headerSearchContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+  },
+  headerSearchIcon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  headerSearchInput: {
+    flex: 1,
+    fontSize: 17,
+  },
+  headerCancelBtn: {
+    paddingLeft: 10,
+  },
+})
diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx
new file mode 100644
index 000000000..a65149bf7
--- /dev/null
+++ b/src/view/screens/Search/index.tsx
@@ -0,0 +1,3 @@
+import {SearchScreenMobile} from '#/view/screens/Search/Search'
+
+export const SearchScreen = SearchScreenMobile
diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx
new file mode 100644
index 000000000..8e039e3cd
--- /dev/null
+++ b/src/view/screens/Search/index.web.tsx
@@ -0,0 +1,3 @@
+import {SearchScreenDesktop} from '#/view/screens/Search/Search'
+
+export const SearchScreen = SearchScreenDesktop
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
deleted file mode 100644
index c1df58ffd..000000000
--- a/src/view/screens/SearchMobile.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import React, {useCallback} from 'react'
-import {
-  StyleSheet,
-  TouchableWithoutFeedback,
-  Keyboard,
-  View,
-} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {FlatList, ScrollView} from 'view/com/util/Views'
-import {
-  NativeStackScreenProps,
-  SearchTabNavigatorParams,
-} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
-import {Text} from 'view/com/util/text/Text'
-import {useStores} from 'state/index'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
-import {SearchUIModel} from 'state/models/ui/search'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
-import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
-import {Suggestions} from 'view/com/search/Suggestions'
-import {SearchResults} from 'view/com/search/SearchResults'
-import {s} from 'lib/styles'
-import {ProfileCard} from 'view/com/profile/ProfileCard'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {isAndroid, isIOS} from 'platform/detection'
-import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
-
-type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
-export const SearchScreen = withAuthRequired(
-  observer<Props>(function SearchScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-    const scrollViewRef = React.useRef<ScrollView>(null)
-    const flatListRef = React.useRef<FlatList>(null)
-    const [onMainScroll] = useOnMainScroll()
-    const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
-    const [query, setQuery] = React.useState<string>('')
-    const autocompleteView = React.useMemo<UserAutocompleteModel>(
-      () => new UserAutocompleteModel(store),
-      [store],
-    )
-    const foafs = React.useMemo<FoafsModel>(
-      () => new FoafsModel(store),
-      [store],
-    )
-    const suggestedActors = React.useMemo<SuggestedActorsModel>(
-      () => new SuggestedActorsModel(store),
-      [store],
-    )
-    const [searchUIModel, setSearchUIModel] = React.useState<
-      SearchUIModel | undefined
-    >()
-
-    const onChangeQuery = React.useCallback(
-      (text: string) => {
-        setQuery(text)
-        if (text.length > 0) {
-          autocompleteView.setActive(true)
-          autocompleteView.setPrefix(text)
-        } else {
-          autocompleteView.setActive(false)
-        }
-      },
-      [setQuery, autocompleteView],
-    )
-
-    const onPressClearQuery = React.useCallback(() => {
-      setQuery('')
-    }, [setQuery])
-
-    const onPressCancelSearch = React.useCallback(() => {
-      setQuery('')
-      autocompleteView.setActive(false)
-      setSearchUIModel(undefined)
-      setIsDrawerSwipeDisabled(false)
-    }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled])
-
-    const onSubmitQuery = React.useCallback(() => {
-      if (query.length === 0) {
-        return
-      }
-
-      const model = new SearchUIModel(store)
-      model.fetch(query)
-      setSearchUIModel(model)
-      setIsDrawerSwipeDisabled(true)
-    }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled])
-
-    const onSoftReset = React.useCallback(() => {
-      scrollViewRef.current?.scrollTo({x: 0, y: 0})
-      flatListRef.current?.scrollToOffset({offset: 0})
-      onPressCancelSearch()
-    }, [scrollViewRef, flatListRef, onPressCancelSearch])
-
-    useFocusEffect(
-      React.useCallback(() => {
-        const softResetSub = store.onScreenSoftReset(onSoftReset)
-        const cleanup = () => {
-          softResetSub.remove()
-        }
-
-        setMinimalShellMode(false)
-        autocompleteView.setup()
-        if (!foafs.hasData) {
-          foafs.fetch()
-        }
-        if (!suggestedActors.hasLoaded) {
-          suggestedActors.loadMore(true)
-        }
-
-        return cleanup
-      }, [
-        store,
-        autocompleteView,
-        foafs,
-        suggestedActors,
-        onSoftReset,
-        setMinimalShellMode,
-      ]),
-    )
-
-    const onPress = useCallback(() => {
-      if (isIOS || isAndroid) {
-        Keyboard.dismiss()
-      }
-    }, [])
-
-    return (
-      <TouchableWithoutFeedback onPress={onPress} accessible={false}>
-        <View style={[pal.view, styles.container]}>
-          <HeaderWithInput
-            isInputFocused={isInputFocused}
-            query={query}
-            setIsInputFocused={setIsInputFocused}
-            onChangeQuery={onChangeQuery}
-            onPressClearQuery={onPressClearQuery}
-            onPressCancelSearch={onPressCancelSearch}
-            onSubmitQuery={onSubmitQuery}
-          />
-          {searchUIModel ? (
-            <SearchResults model={searchUIModel} />
-          ) : !isInputFocused && !query ? (
-            <Suggestions
-              ref={flatListRef}
-              foafs={foafs}
-              suggestedActors={suggestedActors}
-            />
-          ) : (
-            <ScrollView
-              ref={scrollViewRef}
-              testID="searchScrollView"
-              style={pal.view}
-              onScroll={onMainScroll}
-              scrollEventThrottle={100}>
-              {query && autocompleteView.suggestions.length ? (
-                <>
-                  {autocompleteView.suggestions.map((suggestion, index) => (
-                    <ProfileCard
-                      key={suggestion.did}
-                      testID={`searchAutoCompleteResult-${suggestion.handle}`}
-                      profile={suggestion}
-                      noBorder={index === 0}
-                    />
-                  ))}
-                </>
-              ) : query && !autocompleteView.suggestions.length ? (
-                <View>
-                  <Text style={[pal.textLight, styles.searchPrompt]}>
-                    No results found for {autocompleteView.prefix}
-                  </Text>
-                </View>
-              ) : isInputFocused ? (
-                <View>
-                  <Text style={[pal.textLight, styles.searchPrompt]}>
-                    Search for users and posts on the network
-                  </Text>
-                </View>
-              ) : null}
-              <View style={s.footerSpacer} />
-            </ScrollView>
-          )}
-        </View>
-      </TouchableWithoutFeedback>
-    )
-  }),
-)
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-
-  searchPrompt: {
-    textAlign: 'center',
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index ca4ef2a40..388a5d954 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -10,20 +10,13 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {
-  useFocusEffect,
-  useNavigation,
-  StackActions,
-} from '@react-navigation/native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import * as AppInfo from 'lib/app-info'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {ScrollView} from '../com/util/Views'
 import {ViewHeader} from '../com/util/ViewHeader'
@@ -39,662 +32,766 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
-import {pluralize} from 'lib/strings/helpers'
 import {HandIcon, HashtagIcon} from 'lib/icons'
-import {formatCount} from 'view/com/util/numeric/format'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {makeProfileLink} from 'lib/routes/links'
 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
-import {logger} from '#/logger'
+import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
+import {useModalControls} from '#/state/modals'
 import {
   useSetMinimalShellMode,
   useColorMode,
   useSetColorMode,
+  useOnboardingDispatch,
 } from '#/state/shell'
+import {
+  useRequireAltTextEnabled,
+  useSetRequireAltTextEnabled,
+} from '#/state/preferences'
+import {
+  useSession,
+  useSessionApi,
+  SessionAccount,
+  getAgent,
+} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useClearPreferencesMutation} from '#/state/queries/preferences'
+import {useInviteCodesQuery} from '#/state/queries/invites'
+import {clear as clearStorage} from '#/state/persisted/store'
+import {clearLegacyStorage} from '#/state/persisted/legacy'
 
 // TEMPORARY (APP-700)
 // remove after backend testing finishes
 // -prf
 import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
 import {STATUS_PAGE_URL} from 'lib/constants'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
+
+function SettingsAccountCard({account}: {account: SessionAccount}) {
+  const pal = usePalette('default')
+  const {isSwitchingAccounts, currentAccount} = useSession()
+  const {logout} = useSessionApi()
+  const {data: profile} = useProfileQuery({did: account.did})
+  const isCurrentAccount = account.did === currentAccount?.did
+  const {onPressSwitchAccount} = useAccountSwitcher()
+
+  const contents = (
+    <View style={[pal.view, styles.linkCard]}>
+      <View style={styles.avi}>
+        <UserAvatar size={40} avatar={profile?.avatar} />
+      </View>
+      <View style={[s.flex1]}>
+        <Text type="md-bold" style={pal.text}>
+          {profile?.displayName || account.handle}
+        </Text>
+        <Text type="sm" style={pal.textLight}>
+          {account.handle}
+        </Text>
+      </View>
 
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
-export const SettingsScreen = withAuthRequired(
-  observer(function Settings({}: Props) {
-    const colorMode = useColorMode()
-    const setColorMode = useSetColorMode()
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const navigation = useNavigation<NavigationProp>()
-    const {isMobile} = useWebMediaQueries()
-    const {screen, track} = useAnalytics()
-    const [isSwitching, setIsSwitching, onPressSwitchAccount] =
-      useAccountSwitcher()
-    const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
-      store.agent,
-    )
+      {isCurrentAccount ? (
+        <TouchableOpacity
+          testID="signOutBtn"
+          onPress={logout}
+          accessibilityRole="button"
+          accessibilityLabel="Sign out"
+          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
+          <Text type="lg" style={pal.link}>
+            Sign out
+          </Text>
+        </TouchableOpacity>
+      ) : (
+        <AccountDropdownBtn account={account} />
+      )}
+    </View>
+  )
+
+  return isCurrentAccount ? (
+    <Link
+      href={makeProfileLink({
+        did: currentAccount?.did,
+        handle: currentAccount?.handle,
+      })}
+      title="Your profile"
+      noFeedback>
+      {contents}
+    </Link>
+  ) : (
+    <TouchableOpacity
+      testID={`switchToAccountBtn-${account.handle}`}
+      key={account.did}
+      onPress={
+        isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
+      }
+      accessibilityRole="button"
+      accessibilityLabel={`Switch to ${account.handle}`}
+      accessibilityHint="Switches the account you are logged in to">
+      {contents}
+    </TouchableOpacity>
+  )
+}
 
-    const primaryBg = useCustomPalette<ViewStyle>({
-      light: {backgroundColor: colors.blue0},
-      dark: {backgroundColor: colors.blue6},
-    })
-    const primaryText = useCustomPalette<TextStyle>({
-      light: {color: colors.blue3},
-      dark: {color: colors.blue2},
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
+export function SettingsScreen({}: Props) {
+  const queryClient = useQueryClient()
+  const colorMode = useColorMode()
+  const setColorMode = useSetColorMode()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const requireAltTextEnabled = useRequireAltTextEnabled()
+  const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
+  const onboardingDispatch = useOnboardingDispatch()
+  const navigation = useNavigation<NavigationProp>()
+  const {isMobile} = useWebMediaQueries()
+  const {screen, track} = useAnalytics()
+  const {openModal} = useModalControls()
+  const {isSwitchingAccounts, accounts, currentAccount} = useSession()
+  const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
+    getAgent(),
+  )
+  const {mutate: clearPreferences} = useClearPreferencesMutation()
+  const {data: invites} = useInviteCodesQuery()
+  const invitesAvailable = invites?.available?.length ?? 0
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeAllActiveElements = useCloseAllActiveElements()
+
+  const primaryBg = useCustomPalette<ViewStyle>({
+    light: {backgroundColor: colors.blue0},
+    dark: {backgroundColor: colors.blue6},
+  })
+  const primaryText = useCustomPalette<TextStyle>({
+    light: {color: colors.blue3},
+    dark: {color: colors.blue2},
+  })
+
+  const dangerBg = useCustomPalette<ViewStyle>({
+    light: {backgroundColor: colors.red1},
+    dark: {backgroundColor: colors.red7},
+  })
+  const dangerText = useCustomPalette<TextStyle>({
+    light: {color: colors.red4},
+    dark: {color: colors.red2},
+  })
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Settings')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  const onPressAddAccount = React.useCallback(() => {
+    track('Settings:AddAccountButtonClicked')
+    setShowLoggedOut(true)
+    closeAllActiveElements()
+  }, [track, setShowLoggedOut, closeAllActiveElements])
+
+  const onPressChangeHandle = React.useCallback(() => {
+    track('Settings:ChangeHandleButtonClicked')
+    openModal({
+      name: 'change-handle',
+      onChanged() {
+        if (currentAccount) {
+          // refresh my profile
+          queryClient.invalidateQueries({
+            queryKey: RQKEY_PROFILE(currentAccount.did),
+          })
+        }
+      },
     })
+  }, [track, queryClient, openModal, currentAccount])
 
-    const dangerBg = useCustomPalette<ViewStyle>({
-      light: {backgroundColor: colors.red1},
-      dark: {backgroundColor: colors.red7},
-    })
-    const dangerText = useCustomPalette<TextStyle>({
-      light: {color: colors.red4},
-      dark: {color: colors.red2},
-    })
+  const onPressInviteCodes = React.useCallback(() => {
+    track('Settings:InvitecodesButtonClicked')
+    openModal({name: 'invite-codes'})
+  }, [track, openModal])
 
-    useFocusEffect(
-      React.useCallback(() => {
-        screen('Settings')
-        setMinimalShellMode(false)
-      }, [screen, setMinimalShellMode]),
-    )
+  const onPressLanguageSettings = React.useCallback(() => {
+    navigation.navigate('LanguageSettings')
+  }, [navigation])
+
+  const onPressDeleteAccount = React.useCallback(() => {
+    openModal({name: 'delete-account'})
+  }, [openModal])
 
-    const onPressAddAccount = React.useCallback(() => {
-      track('Settings:AddAccountButtonClicked')
-      navigation.navigate('HomeTab')
-      navigation.dispatch(StackActions.popToTop())
-      store.session.clear()
-    }, [track, navigation, store])
-
-    const onPressChangeHandle = React.useCallback(() => {
-      track('Settings:ChangeHandleButtonClicked')
-      store.shell.openModal({
-        name: 'change-handle',
-        onChanged() {
-          setIsSwitching(true)
-          store.session.reloadFromServer().then(
-            () => {
-              setIsSwitching(false)
-              Toast.show('Your handle has been updated')
-            },
-            err => {
-              logger.error('Failed to reload from server after handle update', {
-                error: err,
-              })
-              setIsSwitching(false)
-            },
-          )
-        },
-      })
-    }, [track, store, setIsSwitching])
-
-    const onPressInviteCodes = React.useCallback(() => {
-      track('Settings:InvitecodesButtonClicked')
-      store.shell.openModal({name: 'invite-codes'})
-    }, [track, store])
-
-    const onPressLanguageSettings = React.useCallback(() => {
-      navigation.navigate('LanguageSettings')
-    }, [navigation])
-
-    const onPressSignout = React.useCallback(() => {
-      track('Settings:SignOutButtonClicked')
-      store.session.logout()
-    }, [track, store])
-
-    const onPressDeleteAccount = React.useCallback(() => {
-      store.shell.openModal({name: 'delete-account'})
-    }, [store])
-
-    const onPressResetPreferences = React.useCallback(async () => {
-      await store.preferences.reset()
-      Toast.show('Preferences reset')
-    }, [store])
-
-    const onPressResetOnboarding = React.useCallback(async () => {
-      store.onboarding.reset()
-      Toast.show('Onboarding reset')
-    }, [store])
-
-    const onPressBuildInfo = React.useCallback(() => {
-      Clipboard.setString(
-        `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
-      )
-      Toast.show('Copied build version to clipboard')
-    }, [])
-
-    const openHomeFeedPreferences = React.useCallback(() => {
-      navigation.navigate('PreferencesHomeFeed')
-    }, [navigation])
-
-    const openThreadsPreferences = React.useCallback(() => {
-      navigation.navigate('PreferencesThreads')
-    }, [navigation])
-
-    const onPressAppPasswords = React.useCallback(() => {
-      navigation.navigate('AppPasswords')
-    }, [navigation])
-
-    const onPressSystemLog = React.useCallback(() => {
-      navigation.navigate('Log')
-    }, [navigation])
-
-    const onPressStorybook = React.useCallback(() => {
-      navigation.navigate('Debug')
-    }, [navigation])
-
-    const onPressSavedFeeds = React.useCallback(() => {
-      navigation.navigate('SavedFeeds')
-    }, [navigation])
-
-    const onPressStatusPage = React.useCallback(() => {
-      Linking.openURL(STATUS_PAGE_URL)
-    }, [])
-
-    return (
-      <View style={[s.hContentRegion]} testID="settingsScreen">
-        <ViewHeader title="Settings" />
-        <ScrollView
-          style={[s.hContentRegion]}
-          contentContainerStyle={isMobile && pal.viewLight}
-          scrollIndicatorInsets={{right: 1}}>
-          <View style={styles.spacer20} />
-          {store.session.currentSession !== undefined ? (
-            <>
-              <Text type="xl-bold" style={[pal.text, styles.heading]}>
-                Account
+  const onPressResetPreferences = React.useCallback(async () => {
+    clearPreferences()
+  }, [clearPreferences])
+
+  const onPressResetOnboarding = React.useCallback(async () => {
+    onboardingDispatch({type: 'start'})
+    Toast.show('Onboarding reset')
+  }, [onboardingDispatch])
+
+  const onPressBuildInfo = React.useCallback(() => {
+    Clipboard.setString(
+      `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
+    )
+    Toast.show('Copied build version to clipboard')
+  }, [])
+
+  const openHomeFeedPreferences = React.useCallback(() => {
+    navigation.navigate('PreferencesHomeFeed')
+  }, [navigation])
+
+  const openThreadsPreferences = React.useCallback(() => {
+    navigation.navigate('PreferencesThreads')
+  }, [navigation])
+
+  const onPressAppPasswords = React.useCallback(() => {
+    navigation.navigate('AppPasswords')
+  }, [navigation])
+
+  const onPressSystemLog = React.useCallback(() => {
+    navigation.navigate('Log')
+  }, [navigation])
+
+  const onPressStorybook = React.useCallback(() => {
+    navigation.navigate('Debug')
+  }, [navigation])
+
+  const onPressSavedFeeds = React.useCallback(() => {
+    navigation.navigate('SavedFeeds')
+  }, [navigation])
+
+  const onPressStatusPage = React.useCallback(() => {
+    Linking.openURL(STATUS_PAGE_URL)
+  }, [])
+
+  const clearAllStorage = React.useCallback(async () => {
+    await clearStorage()
+    Toast.show(`Storage cleared, you need to restart the app now.`)
+  }, [])
+  const clearAllLegacyStorage = React.useCallback(async () => {
+    await clearLegacyStorage()
+    Toast.show(`Legacy storage cleared, you need to restart the app now.`)
+  }, [])
+
+  return (
+    <View style={[s.hContentRegion]} testID="settingsScreen">
+      <ViewHeader title={_(msg`Settings`)} />
+      <ScrollView
+        style={[s.hContentRegion]}
+        contentContainerStyle={isMobile && pal.viewLight}
+        scrollIndicatorInsets={{right: 1}}>
+        <View style={styles.spacer20} />
+        {currentAccount ? (
+          <>
+            <Text type="xl-bold" style={[pal.text, styles.heading]}>
+              <Trans>Account</Trans>
+            </Text>
+            <View style={[styles.infoLine]}>
+              <Text type="lg-medium" style={pal.text}>
+                <Trans>Email:</Trans>{' '}
               </Text>
-              <View style={[styles.infoLine]}>
-                <Text type="lg-medium" style={pal.text}>
-                  Email:{' '}
-                </Text>
-                {!store.session.emailNeedsConfirmation && (
-                  <>
-                    <FontAwesomeIcon
-                      icon="check"
-                      size={10}
-                      style={{color: colors.green3, marginRight: 2}}
-                    />
-                  </>
-                )}
-                <Text type="lg" style={pal.text}>
-                  {store.session.currentSession?.email}{' '}
-                </Text>
-                <Link
-                  onPress={() => store.shell.openModal({name: 'change-email'})}>
-                  <Text type="lg" style={pal.link}>
-                    Change
-                  </Text>
-                </Link>
-              </View>
-              <View style={[styles.infoLine]}>
-                <Text type="lg-medium" style={pal.text}>
-                  Birthday:{' '}
+              {currentAccount.emailConfirmed && (
+                <>
+                  <FontAwesomeIcon
+                    icon="check"
+                    size={10}
+                    style={{color: colors.green3, marginRight: 2}}
+                  />
+                </>
+              )}
+              <Text type="lg" style={pal.text}>
+                {currentAccount.email}{' '}
+              </Text>
+              <Link onPress={() => openModal({name: 'change-email'})}>
+                <Text type="lg" style={pal.link}>
+                  <Trans>Change</Trans>
                 </Text>
-                <Link
-                  onPress={() =>
-                    store.shell.openModal({name: 'birth-date-settings'})
-                  }>
-                  <Text type="lg" style={pal.link}>
-                    Show
-                  </Text>
-                </Link>
-              </View>
-              <View style={styles.spacer20} />
-              <EmailConfirmationNotice />
-            </>
-          ) : null}
-          <View style={[s.flexRow, styles.heading]}>
-            <Text type="xl-bold" style={pal.text}>
-              Signed in as
-            </Text>
-            <View style={s.flex1} />
-          </View>
-          {isSwitching ? (
-            <View style={[pal.view, styles.linkCard]}>
-              <ActivityIndicator />
+              </Link>
             </View>
-          ) : (
-            <Link
-              href={makeProfileLink(store.me)}
-              title="Your profile"
-              noFeedback>
-              <View style={[pal.view, styles.linkCard]}>
-                <View style={styles.avi}>
-                  <UserAvatar size={40} avatar={store.me.avatar} />
-                </View>
-                <View style={[s.flex1]}>
-                  <Text type="md-bold" style={pal.text} numberOfLines={1}>
-                    {store.me.displayName || store.me.handle}
-                  </Text>
-                  <Text type="sm" style={pal.textLight} numberOfLines={1}>
-                    {store.me.handle}
-                  </Text>
-                </View>
-                <TouchableOpacity
-                  testID="signOutBtn"
-                  onPress={isSwitching ? undefined : onPressSignout}
-                  accessibilityRole="button"
-                  accessibilityLabel="Sign out"
-                  accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
-                  <Text type="lg" style={pal.link}>
-                    Sign out
-                  </Text>
-                </TouchableOpacity>
-              </View>
-            </Link>
-          )}
-          {store.session.switchableAccounts.map(account => (
-            <TouchableOpacity
-              testID={`switchToAccountBtn-${account.handle}`}
-              key={account.did}
-              style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
-              onPress={
-                isSwitching ? undefined : () => onPressSwitchAccount(account)
-              }
-              accessibilityRole="button"
-              accessibilityLabel={`Switch to ${account.handle}`}
-              accessibilityHint="Switches the account you are logged in to">
-              <View style={styles.avi}>
-                <UserAvatar size={40} avatar={account.aviUrl} />
-              </View>
-              <View style={[s.flex1]}>
-                <Text type="md-bold" style={pal.text}>
-                  {account.displayName || account.handle}
-                </Text>
-                <Text type="sm" style={pal.textLight}>
-                  {account.handle}
+            <View style={[styles.infoLine]}>
+              <Text type="lg-medium" style={pal.text}>
+                <Trans>Birthday:</Trans>{' '}
+              </Text>
+              <Link onPress={() => openModal({name: 'birth-date-settings'})}>
+                <Text type="lg" style={pal.link}>
+                  <Trans>Show</Trans>
                 </Text>
-              </View>
-              <AccountDropdownBtn handle={account.handle} />
-            </TouchableOpacity>
-          ))}
-          <TouchableOpacity
-            testID="switchToNewAccountBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={isSwitching ? undefined : onPressAddAccount}
-            accessibilityRole="button"
-            accessibilityLabel="Add account"
-            accessibilityHint="Create a new Bluesky account">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="plus"
-                style={pal.text as FontAwesomeIconStyle}
-              />
+              </Link>
             </View>
-            <Text type="lg" style={pal.text}>
-              Add account
-            </Text>
-          </TouchableOpacity>
+            <View style={styles.spacer20} />
+
+            {!currentAccount.emailConfirmed && <EmailConfirmationNotice />}
+          </>
+        ) : null}
+        <View style={[s.flexRow, styles.heading]}>
+          <Text type="xl-bold" style={pal.text}>
+            <Trans>Signed in as</Trans>
+          </Text>
+          <View style={s.flex1} />
+        </View>
 
-          <View style={styles.spacer20} />
+        {isSwitchingAccounts ? (
+          <View style={[pal.view, styles.linkCard]}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <SettingsAccountCard account={currentAccount!} />
+        )}
+
+        {accounts
+          .filter(a => a.did !== currentAccount?.did)
+          .map(account => (
+            <SettingsAccountCard key={account.did} account={account} />
+          ))}
 
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Invite a Friend
+        <TouchableOpacity
+          testID="switchToNewAccountBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Add account`)}
+          accessibilityHint="Create a new Bluesky account">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="plus"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Add account</Trans>
           </Text>
-          <TouchableOpacity
-            testID="inviteFriendBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={isSwitching ? undefined : onPressInviteCodes}
-            accessibilityRole="button"
-            accessibilityLabel="Invite"
-            accessibilityHint="Opens invite code list">
-            <View
-              style={[
-                styles.iconContainer,
-                store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
-              ]}>
-              <FontAwesomeIcon
-                icon="ticket"
-                style={
-                  (store.me.invitesAvailable > 0
-                    ? primaryText
-                    : pal.text) as FontAwesomeIconStyle
-                }
-              />
-            </View>
-            <Text
-              type="lg"
-              style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
-              {formatCount(store.me.invitesAvailable)} invite{' '}
-              {pluralize(store.me.invitesAvailable, 'code')} available
-            </Text>
-          </TouchableOpacity>
+        </TouchableOpacity>
 
-          <View style={styles.spacer20} />
+        <View style={styles.spacer20} />
 
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Accessibility
-          </Text>
-          <View style={[pal.view, styles.toggleCard]}>
-            <ToggleButton
-              type="default-light"
-              label="Require alt text before posting"
-              labelType="lg"
-              isSelected={store.preferences.requireAltTextEnabled}
-              onPress={store.preferences.toggleRequireAltTextEnabled}
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Invite a Friend</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="inviteFriendBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Invite`)}
+          accessibilityHint="Opens invite code list"
+          disabled={invites?.disabled}>
+          <View
+            style={[
+              styles.iconContainer,
+              invitesAvailable > 0 ? primaryBg : pal.btn,
+            ]}>
+            <FontAwesomeIcon
+              icon="ticket"
+              style={
+                (invitesAvailable > 0
+                  ? primaryText
+                  : pal.text) as FontAwesomeIconStyle
+              }
             />
           </View>
+          <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}>
+            {invites?.disabled ? (
+              <Trans>
+                Your invite codes are hidden when logged in using an App
+                Password
+              </Trans>
+            ) : invitesAvailable === 1 ? (
+              <Trans>{invitesAvailable} invite code available</Trans>
+            ) : (
+              <Trans>{invitesAvailable} invite codes available</Trans>
+            )}
+          </Text>
+        </TouchableOpacity>
 
-          <View style={styles.spacer20} />
+        <View style={styles.spacer20} />
 
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Appearance
-          </Text>
-          <View>
-            <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
-              <SelectableBtn
-                selected={colorMode === 'system'}
-                label="System"
-                left
-                onSelect={() => setColorMode('system')}
-                accessibilityHint="Set color theme to system setting"
-              />
-              <SelectableBtn
-                selected={colorMode === 'light'}
-                label="Light"
-                onSelect={() => setColorMode('light')}
-                accessibilityHint="Set color theme to light"
-              />
-              <SelectableBtn
-                selected={colorMode === 'dark'}
-                label="Dark"
-                right
-                onSelect={() => setColorMode('dark')}
-                accessibilityHint="Set color theme to dark"
-              />
-            </View>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Accessibility</Trans>
+        </Text>
+        <View style={[pal.view, styles.toggleCard]}>
+          <ToggleButton
+            type="default-light"
+            label="Require alt text before posting"
+            labelType="lg"
+            isSelected={requireAltTextEnabled}
+            onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
+          />
+        </View>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Appearance</Trans>
+        </Text>
+        <View>
+          <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
+            <SelectableBtn
+              selected={colorMode === 'system'}
+              label="System"
+              left
+              onSelect={() => setColorMode('system')}
+              accessibilityHint="Set color theme to system setting"
+            />
+            <SelectableBtn
+              selected={colorMode === 'light'}
+              label="Light"
+              onSelect={() => setColorMode('light')}
+              accessibilityHint="Set color theme to light"
+            />
+            <SelectableBtn
+              selected={colorMode === 'dark'}
+              label="Dark"
+              right
+              onSelect={() => setColorMode('dark')}
+              accessibilityHint="Set color theme to dark"
+            />
           </View>
-          <View style={styles.spacer20} />
+        </View>
+        <View style={styles.spacer20} />
 
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Basics
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Basics</Trans>
+        </Text>
+        <TouchableOpacity
+          testID="preferencesHomeFeedButton"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={openHomeFeedPreferences}
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens the home feed preferences`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="sliders"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Home Feed Preferences</Trans>
           </Text>
-          <TouchableOpacity
-            testID="preferencesHomeFeedButton"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={openHomeFeedPreferences}
-            accessibilityRole="button"
-            accessibilityHint=""
-            accessibilityLabel="Opens the home feed preferences">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="sliders"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Home Feed Preferences
-            </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"
-            accessibilityLabel="Opens screen with all saved feeds"
-            onPress={onPressSavedFeeds}>
-            <View style={[styles.iconContainer, pal.btn]}>
-              <HashtagIcon style={pal.text} size={18} strokeWidth={3} />
-            </View>
-            <Text type="lg" style={pal.text}>
-              My Saved Feeds
-            </Text>
-          </TouchableOpacity>
-          <TouchableOpacity
-            testID="languageSettingsBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={isSwitching ? undefined : onPressLanguageSettings}
-            accessibilityRole="button"
-            accessibilityHint="Language settings"
-            accessibilityLabel="Opens configurable language settings">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="language"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Languages
-            </Text>
-          </TouchableOpacity>
-          <TouchableOpacity
-            testID="moderationBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={
-              isSwitching ? undefined : () => navigation.navigate('Moderation')
-            }
-            accessibilityRole="button"
-            accessibilityHint=""
-            accessibilityLabel="Opens moderation settings">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <HandIcon style={pal.text} size={18} strokeWidth={6} />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Moderation
-            </Text>
-          </TouchableOpacity>
-          <View style={styles.spacer20} />
-
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Advanced
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="preferencesThreadsButton"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={openThreadsPreferences}
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`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}>
+            <Trans>Thread Preferences</Trans>
           </Text>
-          <TouchableOpacity
-            testID="appPasswordBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={onPressAppPasswords}
-            accessibilityRole="button"
-            accessibilityHint="Open app password settings"
-            accessibilityLabel="Opens the app password settings page">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="lock"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              App passwords
-            </Text>
-          </TouchableOpacity>
-          <TouchableOpacity
-            testID="changeHandleBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={isSwitching ? undefined : onPressChangeHandle}
-            accessibilityRole="button"
-            accessibilityLabel="Change handle"
-            accessibilityHint="Choose a new Bluesky username or create">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="at"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text} numberOfLines={1}>
-              Change handle
-            </Text>
-          </TouchableOpacity>
-          <View style={styles.spacer20} />
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Danger Zone
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="savedFeedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          accessibilityHint="My Saved Feeds"
+          accessibilityLabel={_(msg`Opens screen with all saved feeds`)}
+          onPress={onPressSavedFeeds}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <HashtagIcon style={pal.text} size={18} strokeWidth={3} />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>My Saved Feeds</Trans>
           </Text>
-          <TouchableOpacity
-            style={[pal.view, styles.linkCard]}
-            onPress={onPressDeleteAccount}
-            accessible={true}
-            accessibilityRole="button"
-            accessibilityLabel="Delete account"
-            accessibilityHint="Opens modal for account deletion confirmation. Requires email code.">
-            <View style={[styles.iconContainer, dangerBg]}>
-              <FontAwesomeIcon
-                icon={['far', 'trash-can']}
-                style={dangerText as FontAwesomeIconStyle}
-                size={18}
-              />
-            </View>
-            <Text type="lg" style={dangerText}>
-              Delete my account…
-            </Text>
-          </TouchableOpacity>
-          <View style={styles.spacer20} />
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Developer Tools
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="languageSettingsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings}
+          accessibilityRole="button"
+          accessibilityHint="Language settings"
+          accessibilityLabel={_(msg`Opens configurable language settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="language"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Languages</Trans>
           </Text>
-          <TouchableOpacity
-            style={[pal.view, styles.linkCardNoIcon]}
-            onPress={onPressSystemLog}
-            accessibilityRole="button"
-            accessibilityHint="Open system log"
-            accessibilityLabel="Opens the system log page">
-            <Text type="lg" style={pal.text}>
-              System log
-            </Text>
-          </TouchableOpacity>
-          {__DEV__ ? (
-            <ToggleButton
-              type="default-light"
-              label="Experiment: Use AppView Proxy"
-              isSelected={debugHeaderEnabled}
-              onPress={toggleDebugHeader}
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="moderationBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('Moderation')
+          }
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens moderation settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <HandIcon style={pal.text} size={18} strokeWidth={6} />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>Moderation</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Advanced</Trans>
+        </Text>
+        <TouchableOpacity
+          testID="appPasswordBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={onPressAppPasswords}
+          accessibilityRole="button"
+          accessibilityHint="Open app password settings"
+          accessibilityLabel={_(msg`Opens the app password settings page`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={pal.text as FontAwesomeIconStyle}
             />
-          ) : null}
-          {__DEV__ ? (
-            <>
-              <TouchableOpacity
-                style={[pal.view, styles.linkCardNoIcon]}
-                onPress={onPressStorybook}
-                accessibilityRole="button"
-                accessibilityHint="Open storybook page"
-                accessibilityLabel="Opens the storybook page">
-                <Text type="lg" style={pal.text}>
-                  Storybook
-                </Text>
-              </TouchableOpacity>
-              <TouchableOpacity
-                style={[pal.view, styles.linkCardNoIcon]}
-                onPress={onPressResetPreferences}
-                accessibilityRole="button"
-                accessibilityHint="Reset preferences"
-                accessibilityLabel="Resets the preferences state">
-                <Text type="lg" style={pal.text}>
-                  Reset preferences state
-                </Text>
-              </TouchableOpacity>
-              <TouchableOpacity
-                style={[pal.view, styles.linkCardNoIcon]}
-                onPress={onPressResetOnboarding}
-                accessibilityRole="button"
-                accessibilityHint="Reset onboarding"
-                accessibilityLabel="Resets the onboarding state">
-                <Text type="lg" style={pal.text}>
-                  Reset onboarding state
-                </Text>
-              </TouchableOpacity>
-            </>
-          ) : null}
-          <View style={[styles.footer]}>
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>App passwords</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="changeHandleBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={isSwitchingAccounts ? undefined : onPressChangeHandle}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Change handle`)}
+          accessibilityHint="Choose a new Bluesky username or create">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="at"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text} numberOfLines={1}>
+            <Trans>Change handle</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={styles.spacer20} />
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Danger Zone</Trans>
+        </Text>
+        <TouchableOpacity
+          style={[pal.view, styles.linkCard]}
+          onPress={onPressDeleteAccount}
+          accessible={true}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Delete account`)}
+          accessibilityHint="Opens modal for account deletion confirmation. Requires email code.">
+          <View style={[styles.iconContainer, dangerBg]}>
+            <FontAwesomeIcon
+              icon={['far', 'trash-can']}
+              style={dangerText as FontAwesomeIconStyle}
+              size={18}
+            />
+          </View>
+          <Text type="lg" style={dangerText}>
+            <Trans>Delete my account…</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={styles.spacer20} />
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Developer Tools</Trans>
+        </Text>
+        <TouchableOpacity
+          style={[pal.view, styles.linkCardNoIcon]}
+          onPress={onPressSystemLog}
+          accessibilityRole="button"
+          accessibilityHint="Open system log"
+          accessibilityLabel={_(msg`Opens the system log page`)}>
+          <Text type="lg" style={pal.text}>
+            <Trans>System log</Trans>
+          </Text>
+        </TouchableOpacity>
+        {__DEV__ ? (
+          <ToggleButton
+            type="default-light"
+            label="Experiment: Use AppView Proxy"
+            isSelected={debugHeaderEnabled}
+            onPress={toggleDebugHeader}
+          />
+        ) : null}
+        {__DEV__ ? (
+          <>
             <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressStorybook}
               accessibilityRole="button"
-              onPress={onPressBuildInfo}>
-              <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
-                Build version {AppInfo.appVersion} {AppInfo.updateChannel}
+              accessibilityHint="Open storybook page"
+              accessibilityLabel={_(msg`Opens the storybook page`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Storybook</Trans>
               </Text>
             </TouchableOpacity>
-            <Text type="sm" style={[pal.textLight]}>
-              &middot; &nbsp;
-            </Text>
             <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressResetPreferences}
               accessibilityRole="button"
-              onPress={onPressStatusPage}>
-              <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
-                Status page
+              accessibilityHint="Reset preferences"
+              accessibilityLabel={_(msg`Resets the preferences state`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Reset preferences state</Trans>
               </Text>
             </TouchableOpacity>
-          </View>
-          <View style={s.footerSpacer} />
-        </ScrollView>
-      </View>
-    )
-  }),
-)
-
-const EmailConfirmationNotice = observer(
-  function EmailConfirmationNoticeImpl() {
-    const pal = usePalette('default')
-    const palInverted = usePalette('inverted')
-    const store = useStores()
-    const {isMobile} = useWebMediaQueries()
-
-    if (!store.session.emailNeedsConfirmation) {
-      return null
-    }
-
-    return (
-      <View style={{marginBottom: 20}}>
-        <Text type="xl-bold" style={[pal.text, styles.heading]}>
-          Verify email
-        </Text>
-        <View
-          style={[
-            {
-              paddingVertical: isMobile ? 12 : 0,
-              paddingHorizontal: 18,
-            },
-            pal.view,
-          ]}>
-          <View style={{flexDirection: 'row', marginBottom: 8}}>
-            <Pressable
-              style={[
-                palInverted.view,
-                {
-                  flexDirection: 'row',
-                  gap: 6,
-                  borderRadius: 6,
-                  paddingHorizontal: 12,
-                  paddingVertical: 10,
-                  alignItems: 'center',
-                },
-                isMobile && {flex: 1},
-              ]}
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressResetOnboarding}
               accessibilityRole="button"
-              accessibilityLabel="Verify my email"
-              accessibilityHint=""
-              onPress={() => store.shell.openModal({name: 'verify-email'})}>
-              <FontAwesomeIcon
-                icon="envelope"
-                color={palInverted.colors.text}
-                size={16}
-              />
-              <Text type="button" style={palInverted.text}>
-                Verify My Email
+              accessibilityHint="Reset onboarding"
+              accessibilityLabel={_(msg`Resets the onboarding state`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Reset onboarding state</Trans>
               </Text>
-            </Pressable>
-          </View>
-          <Text style={pal.textLight}>
-            Protect your account by verifying your email.
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={clearAllLegacyStorage}
+              accessibilityRole="button"
+              accessibilityHint="Clear all legacy storage data"
+              accessibilityLabel={_(msg`Clear all legacy storage data`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>
+                  Clear all legacy storage data (restart after this)
+                </Trans>
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={clearAllStorage}
+              accessibilityRole="button"
+              accessibilityHint="Clear all storage data"
+              accessibilityLabel={_(msg`Clear all storage data`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Clear all storage data (restart after this)</Trans>
+              </Text>
+            </TouchableOpacity>
+          </>
+        ) : null}
+        <View style={[styles.footer]}>
+          <TouchableOpacity
+            accessibilityRole="button"
+            onPress={onPressBuildInfo}>
+            <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
+              <Trans>
+                Build version {AppInfo.appVersion} {AppInfo.updateChannel}
+              </Trans>
+            </Text>
+          </TouchableOpacity>
+          <Text type="sm" style={[pal.textLight]}>
+            &middot; &nbsp;
           </Text>
+          <TouchableOpacity
+            accessibilityRole="button"
+            onPress={onPressStatusPage}>
+            <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
+              <Trans>Status page</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+        <View style={s.footerSpacer} />
+      </ScrollView>
+    </View>
+  )
+}
+
+function EmailConfirmationNotice() {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const {_} = useLingui()
+  const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
+
+  return (
+    <View style={{marginBottom: 20}}>
+      <Text type="xl-bold" style={[pal.text, styles.heading]}>
+        <Trans>Verify email</Trans>
+      </Text>
+      <View
+        style={[
+          {
+            paddingVertical: isMobile ? 12 : 0,
+            paddingHorizontal: 18,
+          },
+          pal.view,
+        ]}>
+        <View style={{flexDirection: 'row', marginBottom: 8}}>
+          <Pressable
+            style={[
+              palInverted.view,
+              {
+                flexDirection: 'row',
+                gap: 6,
+                borderRadius: 6,
+                paddingHorizontal: 12,
+                paddingVertical: 10,
+                alignItems: 'center',
+              },
+              isMobile && {flex: 1},
+            ]}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Verify my email`)}
+            accessibilityHint=""
+            onPress={() => openModal({name: 'verify-email'})}>
+            <FontAwesomeIcon
+              icon="envelope"
+              color={palInverted.colors.text}
+              size={16}
+            />
+            <Text type="button" style={palInverted.text}>
+              <Trans>Verify My Email</Trans>
+            </Text>
+          </Pressable>
         </View>
+        <Text style={pal.textLight}>
+          <Trans>Protect your account by verifying your email.</Trans>
+        </Text>
       </View>
-    )
-  },
-)
+    </View>
+  )
+}
 
 const styles = StyleSheet.create({
   dimmed: {
diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx
index 7106b4136..6856f6759 100644
--- a/src/view/screens/Support.tsx
+++ b/src/view/screens/Support.tsx
@@ -10,11 +10,14 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {HELP_DESK_URL} from 'lib/constants'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'>
 export const SupportScreen = (_props: Props) => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -24,19 +27,21 @@ export const SupportScreen = (_props: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Support" />
+      <ViewHeader title={_(msg`Support`)} />
       <CenteredView>
         <Text type="title-xl" style={[pal.text, s.p20, s.pb5]}>
-          Support
+          <Trans>Support</Trans>
         </Text>
         <Text style={[pal.text, s.p20]}>
-          The support form has been moved. If you need help, please
-          <TextLink
-            href={HELP_DESK_URL}
-            text=" click here"
-            style={pal.link}
-          />{' '}
-          or visit {HELP_DESK_URL} to get in touch with us.
+          <Trans>
+            The support form has been moved. If you need help, please
+            <TextLink
+              href={HELP_DESK_URL}
+              text=" click here"
+              style={pal.link}
+            />{' '}
+            or visit {HELP_DESK_URL} to get in touch with us.
+          </Trans>
         </Text>
       </CenteredView>
     </View>
diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx
index b7a388b65..c20890e29 100644
--- a/src/view/screens/TermsOfService.tsx
+++ b/src/view/screens/TermsOfService.tsx
@@ -9,11 +9,14 @@ import {ScrollView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'TermsOfService'>
 export const TermsOfServiceScreen = (_props: Props) => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -23,11 +26,11 @@ export const TermsOfServiceScreen = (_props: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Terms of Service" />
+      <ViewHeader title={_(msg`Terms of Service`)} />
       <ScrollView style={[s.hContentRegion, pal.view]}>
         <View style={[s.p20]}>
           <Text style={pal.text}>
-            The Terms of Service have been moved to{' '}
+            <Trans>The Terms of Service have been moved to</Trans>{' '}
             <TextLink
               style={pal.link}
               href="https://blueskyweb.xyz/support/tos"
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index 219a594ed..d37ff4fb7 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -2,30 +2,21 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
 import {ComposePost} from '../com/composer/Composer'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {useComposerState} from 'state/shell/composer'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {usePalette} from 'lib/hooks/usePalette'
 
 export const Composer = observer(function ComposerImpl({
-  active,
   winHeight,
-  replyTo,
-  onPost,
-  quote,
-  mention,
 }: {
-  active: boolean
   winHeight: number
-  replyTo?: ComposerOpts['replyTo']
-  onPost?: ComposerOpts['onPost']
-  quote?: ComposerOpts['quote']
-  mention?: ComposerOpts['mention']
 }) {
+  const state = useComposerState()
   const pal = usePalette('default')
   const initInterp = useAnimatedValue(0)
 
   useEffect(() => {
-    if (active) {
+    if (state) {
       Animated.timing(initInterp, {
         toValue: 1,
         duration: 300,
@@ -35,7 +26,7 @@ export const Composer = observer(function ComposerImpl({
     } else {
       initInterp.setValue(0)
     }
-  }, [initInterp, active])
+  }, [initInterp, state])
   const wrapperAnimStyle = {
     transform: [
       {
@@ -50,7 +41,7 @@ export const Composer = observer(function ComposerImpl({
   // rendering
   // =
 
-  if (!active) {
+  if (!state) {
     return <View />
   }
 
@@ -60,10 +51,10 @@ export const Composer = observer(function ComposerImpl({
       aria-modal
       accessibilityViewIsModal>
       <ComposePost
-        replyTo={replyTo}
-        onPost={onPost}
-        quote={quote}
-        mention={mention}
+        replyTo={state.replyTo}
+        onPost={state.onPost}
+        quote={state.quote}
+        mention={state.mention}
       />
     </Animated.View>
   )
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index c3ec37e57..73f9f540e 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -1,40 +1,35 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, View} from 'react-native'
+import Animated, {FadeIn, FadeInDown, FadeOut} from 'react-native-reanimated'
 import {ComposePost} from '../com/composer/Composer'
-import {ComposerOpts} from 'state/models/ui/shell'
+import {useComposerState} from 'state/shell/composer'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 
 const BOTTOM_BAR_HEIGHT = 61
 
-export const Composer = observer(function ComposerImpl({
-  active,
-  replyTo,
-  quote,
-  onPost,
-  mention,
-}: {
-  active: boolean
-  winHeight: number
-  replyTo?: ComposerOpts['replyTo']
-  quote: ComposerOpts['quote']
-  onPost?: ComposerOpts['onPost']
-  mention?: ComposerOpts['mention']
-}) {
+export function Composer({}: {winHeight: number}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const state = useComposerState()
 
   // rendering
   // =
 
-  if (!active) {
+  if (!state) {
     return <View />
   }
 
   return (
-    <View style={styles.mask} aria-modal accessibilityViewIsModal>
-      <View
+    <Animated.View
+      style={styles.mask}
+      aria-modal
+      accessibilityViewIsModal
+      entering={FadeIn.duration(100)}
+      exiting={FadeOut}>
+      <Animated.View
+        entering={FadeInDown.duration(150)}
+        exiting={FadeOut}
         style={[
           styles.container,
           isMobile && styles.containerMobile,
@@ -42,15 +37,15 @@ export const Composer = observer(function ComposerImpl({
           pal.border,
         ]}>
         <ComposePost
-          replyTo={replyTo}
-          quote={quote}
-          onPost={onPost}
-          mention={mention}
+          replyTo={state.replyTo}
+          quote={state.quote}
+          onPost={state.onPost}
+          mention={state.mention}
         />
-      </View>
-    </View>
+      </Animated.View>
+    </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   mask: {
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 7f5e6c5e5..459a021c4 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -10,14 +10,13 @@ import {
   ViewStyle,
 } from 'react-native'
 import {useNavigation, StackActions} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {useQueryClient} from '@tanstack/react-query'
 import {s, colors} from 'lib/styles'
 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
-import {useStores} from 'state/index'
 import {
   HomeIcon,
   HomeIconSolid,
@@ -42,20 +41,81 @@ import {getTabState, TabState} from 'lib/routes/helpers'
 import {NavigationProp} from 'lib/routes/types'
 import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
 import {isWeb} from 'platform/detection'
-import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format'
+import {formatCountShortOnly} from 'view/com/util/numeric/format'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useSetDrawerOpen} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
+import {useSession, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {emitSoftReset} from '#/state/events'
+import {useInviteCodesQuery} from '#/state/queries/invites'
+import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
+import {NavSignupCard} from '#/view/shell/NavSignupCard'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
-export const DrawerContent = observer(function DrawerContentImpl() {
+export function DrawerProfileCard({
+  account,
+  onPressProfile,
+}: {
+  account: SessionAccount
+  onPressProfile: () => void
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  return (
+    <TouchableOpacity
+      testID="profileCardButton"
+      accessibilityLabel={_(msg`Profile`)}
+      accessibilityHint="Navigates to your profile"
+      onPress={onPressProfile}>
+      <UserAvatar
+        size={80}
+        avatar={profile?.avatar}
+        // See https://github.com/bluesky-social/social-app/pull/1801:
+        usePlainRNImage={true}
+      />
+      <Text
+        type="title-lg"
+        style={[pal.text, s.bold, styles.profileCardDisplayName]}
+        numberOfLines={1}>
+        {profile?.displayName || account.handle}
+      </Text>
+      <Text
+        type="2xl"
+        style={[pal.textLight, styles.profileCardHandle]}
+        numberOfLines={1}>
+        @{account.handle}
+      </Text>
+      <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
+        <Text type="xl-medium" style={pal.text}>
+          {formatCountShortOnly(profile?.followersCount ?? 0)}
+        </Text>{' '}
+        {pluralize(profile?.followersCount || 0, 'follower')} &middot;{' '}
+        <Text type="xl-medium" style={pal.text}>
+          {formatCountShortOnly(profile?.followsCount ?? 0)}
+        </Text>{' '}
+        following
+      </Text>
+    </TouchableOpacity>
+  )
+}
+
+export function DrawerContent() {
   const theme = useTheme()
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
-
-  const {notifications} = store.me
+  const {hasSession, currentAccount} = useSession()
+  const numUnreadNotifications = useUnreadNotifications()
 
   // events
   // =
@@ -68,7 +128,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
       if (isWeb) {
         // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh
         if (tab === 'MyProfile') {
-          navigation.navigate('Profile', {name: store.me.handle})
+          navigation.navigate('Profile', {name: currentAccount!.handle})
         } else {
           // @ts-ignore must be Home, Search, Notifications, or MyProfile
           navigation.navigate(tab)
@@ -76,16 +136,20 @@ export const DrawerContent = observer(function DrawerContentImpl() {
       } else {
         const tabState = getTabState(state, tab)
         if (tabState === TabState.InsideAtRoot) {
-          store.emitScreenSoftReset()
+          emitSoftReset()
         } else if (tabState === TabState.Inside) {
           navigation.dispatch(StackActions.popToTop())
         } else {
+          if (tab === 'Notifications') {
+            // fetch new notifs on view
+            truncateAndInvalidate(queryClient, NOTIFS_RQKEY())
+          }
           // @ts-ignore must be Home, Search, Notifications, or MyProfile
           navigation.navigate(`${tab}Tab`)
         }
       }
     },
-    [store, track, navigation, setDrawerOpen],
+    [track, navigation, setDrawerOpen, currentAccount, queryClient],
   )
 
   const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
@@ -131,11 +195,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
     track('Menu:FeedbackClicked')
     Linking.openURL(
       FEEDBACK_FORM_URL({
-        email: store.session.currentSession?.email,
-        handle: store.session.currentSession?.handle,
+        email: currentAccount?.email,
+        handle: currentAccount?.handle,
       }),
     )
-  }, [track, store.session.currentSession])
+  }, [track, currentAccount])
 
   const onPressHelp = React.useCallback(() => {
     track('Menu:HelpClicked')
@@ -154,48 +218,20 @@ export const DrawerContent = observer(function DrawerContentImpl() {
       ]}>
       <SafeAreaView style={s.flex1}>
         <ScrollView style={styles.main}>
-          <View style={{}}>
-            <TouchableOpacity
-              testID="profileCardButton"
-              accessibilityLabel="Profile"
-              accessibilityHint="Navigates to your profile"
-              onPress={onPressProfile}>
-              <UserAvatar
-                size={80}
-                avatar={store.me.avatar}
-                // See https://github.com/bluesky-social/social-app/pull/1801:
-                usePlainRNImage={true}
+          {hasSession && currentAccount ? (
+            <View style={{}}>
+              <DrawerProfileCard
+                account={currentAccount}
+                onPressProfile={onPressProfile}
               />
-              <Text
-                type="title-lg"
-                style={[pal.text, s.bold, styles.profileCardDisplayName]}
-                numberOfLines={1}>
-                {store.me.displayName || store.me.handle}
-              </Text>
-              <Text
-                type="2xl"
-                style={[pal.textLight, styles.profileCardHandle]}
-                numberOfLines={1}>
-                @{store.me.handle}
-              </Text>
-              <Text
-                type="xl"
-                style={[pal.textLight, styles.profileCardFollowers]}>
-                <Text type="xl-medium" style={pal.text}>
-                  {formatCountShortOnly(store.me.followersCount ?? 0)}
-                </Text>{' '}
-                {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
-                <Text type="xl-medium" style={pal.text}>
-                  {formatCountShortOnly(store.me.followsCount ?? 0)}
-                </Text>{' '}
-                following
-              </Text>
-            </TouchableOpacity>
-          </View>
+            </View>
+          ) : (
+            <NavSignupCard />
+          )}
 
-          <InviteCodes style={{paddingLeft: 0}} />
+          {hasSession && <InviteCodes style={{paddingLeft: 0}} />}
 
-          <View style={{height: 10}} />
+          {hasSession && <View style={{height: 10}} />}
 
           <MenuItem
             icon={
@@ -213,8 +249,8 @@ export const DrawerContent = observer(function DrawerContentImpl() {
                 />
               )
             }
-            label="Search"
-            accessibilityLabel="Search"
+            label={_(msg`Search`)}
+            accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
             bold={isAtSearch}
             onPress={onPressSearch}
@@ -235,39 +271,43 @@ export const DrawerContent = observer(function DrawerContentImpl() {
                 />
               )
             }
-            label="Home"
-            accessibilityLabel="Home"
+            label={_(msg`Home`)}
+            accessibilityLabel={_(msg`Home`)}
             accessibilityHint=""
             bold={isAtHome}
             onPress={onPressHome}
           />
-          <MenuItem
-            icon={
-              isAtNotifications ? (
-                <BellIconSolid
-                  style={pal.text as StyleProp<ViewStyle>}
-                  size="24"
-                  strokeWidth={1.7}
-                />
-              ) : (
-                <BellIcon
-                  style={pal.text as StyleProp<ViewStyle>}
-                  size="24"
-                  strokeWidth={1.7}
-                />
-              )
-            }
-            label="Notifications"
-            accessibilityLabel="Notifications"
-            accessibilityHint={
-              notifications.unreadCountLabel === ''
-                ? ''
-                : `${notifications.unreadCountLabel} unread`
-            }
-            count={notifications.unreadCountLabel}
-            bold={isAtNotifications}
-            onPress={onPressNotifications}
-          />
+
+          {hasSession && (
+            <MenuItem
+              icon={
+                isAtNotifications ? (
+                  <BellIconSolid
+                    style={pal.text as StyleProp<ViewStyle>}
+                    size="24"
+                    strokeWidth={1.7}
+                  />
+                ) : (
+                  <BellIcon
+                    style={pal.text as StyleProp<ViewStyle>}
+                    size="24"
+                    strokeWidth={1.7}
+                  />
+                )
+              }
+              label={_(msg`Notifications`)}
+              accessibilityLabel={_(msg`Notifications`)}
+              accessibilityHint={
+                numUnreadNotifications === ''
+                  ? ''
+                  : `${numUnreadNotifications} unread`
+              }
+              count={numUnreadNotifications}
+              bold={isAtNotifications}
+              onPress={onPressNotifications}
+            />
+          )}
+
           <MenuItem
             icon={
               isAtFeeds ? (
@@ -284,68 +324,74 @@ export const DrawerContent = observer(function DrawerContentImpl() {
                 />
               )
             }
-            label="Feeds"
-            accessibilityLabel="Feeds"
+            label={_(msg`Feeds`)}
+            accessibilityLabel={_(msg`Feeds`)}
             accessibilityHint=""
             bold={isAtFeeds}
             onPress={onPressMyFeeds}
           />
-          <MenuItem
-            icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />}
-            label="Lists"
-            accessibilityLabel="Lists"
-            accessibilityHint=""
-            onPress={onPressLists}
-          />
-          <MenuItem
-            icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />}
-            label="Moderation"
-            accessibilityLabel="Moderation"
-            accessibilityHint=""
-            onPress={onPressModeration}
-          />
-          <MenuItem
-            icon={
-              isAtMyProfile ? (
-                <UserIconSolid
-                  style={pal.text as StyleProp<ViewStyle>}
-                  size="26"
-                  strokeWidth={1.5}
-                />
-              ) : (
-                <UserIcon
-                  style={pal.text as StyleProp<ViewStyle>}
-                  size="26"
-                  strokeWidth={1.5}
-                />
-              )
-            }
-            label="Profile"
-            accessibilityLabel="Profile"
-            accessibilityHint=""
-            onPress={onPressProfile}
-          />
-          <MenuItem
-            icon={
-              <CogIcon
-                style={pal.text as StyleProp<ViewStyle>}
-                size="26"
-                strokeWidth={1.75}
+
+          {hasSession && (
+            <>
+              <MenuItem
+                icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />}
+                label={_(msg`Lists`)}
+                accessibilityLabel={_(msg`Lists`)}
+                accessibilityHint=""
+                onPress={onPressLists}
               />
-            }
-            label="Settings"
-            accessibilityLabel="Settings"
-            accessibilityHint=""
-            onPress={onPressSettings}
-          />
+              <MenuItem
+                icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />}
+                label={_(msg`Moderation`)}
+                accessibilityLabel={_(msg`Moderation`)}
+                accessibilityHint=""
+                onPress={onPressModeration}
+              />
+              <MenuItem
+                icon={
+                  isAtMyProfile ? (
+                    <UserIconSolid
+                      style={pal.text as StyleProp<ViewStyle>}
+                      size="26"
+                      strokeWidth={1.5}
+                    />
+                  ) : (
+                    <UserIcon
+                      style={pal.text as StyleProp<ViewStyle>}
+                      size="26"
+                      strokeWidth={1.5}
+                    />
+                  )
+                }
+                label={_(msg`Profile`)}
+                accessibilityLabel={_(msg`Profile`)}
+                accessibilityHint=""
+                onPress={onPressProfile}
+              />
+              <MenuItem
+                icon={
+                  <CogIcon
+                    style={pal.text as StyleProp<ViewStyle>}
+                    size="26"
+                    strokeWidth={1.75}
+                  />
+                }
+                label={_(msg`Settings`)}
+                accessibilityLabel={_(msg`Settings`)}
+                accessibilityHint=""
+                onPress={onPressSettings}
+              />
+            </>
+          )}
 
           <View style={styles.smallSpacer} />
           <View style={styles.smallSpacer} />
         </ScrollView>
+
         <View style={styles.footer}>
           <TouchableOpacity
             accessibilityRole="link"
-            accessibilityLabel="Send feedback"
+            accessibilityLabel={_(msg`Send feedback`)}
             accessibilityHint=""
             onPress={onPressFeedback}
             style={[
@@ -361,24 +407,24 @@ export const DrawerContent = observer(function DrawerContentImpl() {
               icon={['far', 'message']}
             />
             <Text type="lg-medium" style={[pal.link, s.pl10]}>
-              Feedback
+              <Trans>Feedback</Trans>
             </Text>
           </TouchableOpacity>
           <TouchableOpacity
             accessibilityRole="link"
-            accessibilityLabel="Send feedback"
+            accessibilityLabel={_(msg`Send feedback`)}
             accessibilityHint=""
             onPress={onPressHelp}
             style={[styles.footerBtn]}>
             <Text type="lg-medium" style={[pal.link, s.pl10]}>
-              Help
+              <Trans>Help</Trans>
             </Text>
           </TouchableOpacity>
         </View>
       </SafeAreaView>
     </View>
   )
-})
+}
 
 interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> {
   icon: JSX.Element
@@ -432,50 +478,54 @@ function MenuItem({
   )
 }
 
-const InviteCodes = observer(function InviteCodesImpl({
-  style,
-}: {
-  style?: StyleProp<ViewStyle>
-}) {
+function InviteCodes({style}: {style?: StyleProp<ViewStyle>}) {
   const {track} = useAnalytics()
-  const store = useStores()
   const setDrawerOpen = useSetDrawerOpen()
   const pal = usePalette('default')
-  const {invitesAvailable} = store.me
+  const {data: invites} = useInviteCodesQuery()
+  const invitesAvailable = invites?.available?.length ?? 0
+  const {openModal} = useModalControls()
+  const {_} = useLingui()
+
   const onPress = React.useCallback(() => {
     track('Menu:ItemClicked', {url: '#invite-codes'})
     setDrawerOpen(false)
-    store.shell.openModal({name: 'invite-codes'})
-  }, [store, track, setDrawerOpen])
+    openModal({name: 'invite-codes'})
+  }, [openModal, track, setDrawerOpen])
+
   return (
     <TouchableOpacity
       testID="menuItemInviteCodes"
       style={[styles.inviteCodes, style]}
       onPress={onPress}
       accessibilityRole="button"
-      accessibilityLabel={
-        invitesAvailable === 1
-          ? 'Invite codes: 1 available'
-          : `Invite codes: ${invitesAvailable} available`
-      }
-      accessibilityHint="Opens list of invite codes">
+      accessibilityLabel={_(msg`Invite codes: ${invitesAvailable} available`)}
+      accessibilityHint={_(msg`Opens list of invite codes`)}
+      disabled={invites?.disabled}>
       <FontAwesomeIcon
         icon="ticket"
         style={[
           styles.inviteCodesIcon,
-          store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
+          invitesAvailable > 0 ? pal.link : pal.textLight,
         ]}
         size={18}
       />
       <Text
         type="lg-medium"
-        style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
-        {formatCount(store.me.invitesAvailable)} invite{' '}
-        {pluralize(store.me.invitesAvailable, 'code')}
+        style={invitesAvailable > 0 ? pal.link : pal.textLight}>
+        {invites?.disabled ? (
+          <Trans>
+            Your invite codes are hidden when logged in using an App Password
+          </Trans>
+        ) : invitesAvailable === 1 ? (
+          <Trans>{invitesAvailable} invite code available</Trans>
+        ) : (
+          <Trans>{invitesAvailable} invite codes available</Trans>
+        )}
       </Text>
     </TouchableOpacity>
   )
-})
+}
 
 const styles = StyleSheet.create({
   view: {
@@ -548,10 +598,11 @@ const styles = StyleSheet.create({
     paddingLeft: 22,
     paddingVertical: 8,
     flexDirection: 'row',
-    alignItems: 'center',
   },
   inviteCodesIcon: {
     marginRight: 6,
+    flexShrink: 0,
+    marginTop: 2,
   },
 
   footer: {
diff --git a/src/view/shell/NavSignupCard.tsx b/src/view/shell/NavSignupCard.tsx
new file mode 100644
index 000000000..7026dd2a6
--- /dev/null
+++ b/src/view/shell/NavSignupCard.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {DefaultAvatar} from '#/view/com/util/UserAvatar'
+import {Text} from '#/view/com/util/text/Text'
+import {Button} from '#/view/com/util/forms/Button'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
+
+export function NavSignupCard() {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeAllActiveElements = useCloseAllActiveElements()
+
+  const showLoggedOut = React.useCallback(() => {
+    closeAllActiveElements()
+    setShowLoggedOut(true)
+  }, [setShowLoggedOut, closeAllActiveElements])
+
+  return (
+    <View
+      style={{
+        alignItems: 'flex-start',
+        paddingTop: 6,
+        marginBottom: 24,
+      }}>
+      <DefaultAvatar type="user" size={48} />
+
+      <View style={{paddingTop: 12}}>
+        <Text type="md" style={[pal.text, s.bold]}>
+          <Trans>Sign up or sign in to join the conversation</Trans>
+        </Text>
+      </View>
+
+      <View style={{flexDirection: 'row', paddingTop: 12, gap: 8}}>
+        <Button
+          onPress={showLoggedOut}
+          accessibilityHint={_(msg`Sign up`)}
+          accessibilityLabel={_(msg`Sign up`)}>
+          <Text type="md" style={[{color: 'white'}, s.bold]}>
+            <Trans>Sign up</Trans>
+          </Text>
+        </Button>
+        <Button
+          type="default"
+          onPress={showLoggedOut}
+          accessibilityHint={_(msg`Sign in`)}
+          accessibilityLabel={_(msg`Sign in`)}>
+          <Text type="md" style={[pal.text, s.bold]}>
+            Sign in
+          </Text>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index d360ceead..746b4d123 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -1,12 +1,11 @@
 import React, {ComponentProps} from 'react'
 import {GestureResponderEvent, TouchableOpacity, View} from 'react-native'
 import Animated from 'react-native-reanimated'
+import {useQueryClient} from '@tanstack/react-query'
 import {StackActions} from '@react-navigation/native'
 import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {observer} from 'mobx-react-lite'
 import {Text} from 'view/com/util/text/Text'
-import {useStores} from 'state/index'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {clamp} from 'lib/numbers'
 import {
@@ -24,21 +23,33 @@ import {styles} from './BottomBarStyles'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
 import {UserAvatar} from 'view/com/util/UserAvatar'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {emitSoftReset} from '#/state/events'
+import {useSession} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
 type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
 
-export const BottomBar = observer(function BottomBarImpl({
-  navigation,
-}: BottomTabBarProps) {
-  const store = useStores()
+export function BottomBar({navigation}: BottomTabBarProps) {
+  const {openModal} = useModalControls()
+  const {hasSession, currentAccount} = useSession()
   const pal = usePalette('default')
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
   const safeAreaInsets = useSafeAreaInsets()
   const {track} = useAnalytics()
+  const {footerHeight} = useShellLayout()
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
-
-  const {minimalShellMode, footerMinimalShellTransform} = useMinimalShellMode()
-  const {notifications} = store.me
+  const numUnreadNotifications = useUnreadNotifications()
+  const {footerMinimalShellTransform} = useMinimalShellMode()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
 
   const onPressTab = React.useCallback(
     (tab: TabOptions) => {
@@ -46,14 +57,18 @@ export const BottomBar = observer(function BottomBarImpl({
       const state = navigation.getState()
       const tabState = getTabState(state, tab)
       if (tabState === TabState.InsideAtRoot) {
-        store.emitScreenSoftReset()
+        emitSoftReset()
       } else if (tabState === TabState.Inside) {
         navigation.dispatch(StackActions.popToTop())
       } else {
+        if (tab === 'Notifications') {
+          // fetch new notifs on view
+          truncateAndInvalidate(queryClient, NOTIFS_RQKEY())
+        }
         navigation.navigate(`${tab}Tab`)
       }
     },
-    [store, track, navigation],
+    [track, navigation, queryClient],
   )
   const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
   const onPressSearch = React.useCallback(
@@ -72,8 +87,8 @@ export const BottomBar = observer(function BottomBarImpl({
     onPressTab('MyProfile')
   }, [onPressTab])
   const onLongPressProfile = React.useCallback(() => {
-    store.shell.openModal({name: 'switch-account'})
-  }, [store])
+    openModal({name: 'switch-account'})
+  }, [openModal])
 
   return (
     <Animated.View
@@ -83,8 +98,10 @@ export const BottomBar = observer(function BottomBarImpl({
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
         footerMinimalShellTransform,
-        minimalShellMode && styles.disabled,
-      ]}>
+      ]}
+      onLayout={e => {
+        footerHeight.value = e.nativeEvent.layout.height
+      }}>
       <Btn
         testID="bottomBarHomeBtn"
         icon={
@@ -104,7 +121,7 @@ export const BottomBar = observer(function BottomBarImpl({
         }
         onPress={onPressHome}
         accessibilityRole="tab"
-        accessibilityLabel="Home"
+        accessibilityLabel={_(msg`Home`)}
         accessibilityHint=""
       />
       <Btn
@@ -126,7 +143,7 @@ export const BottomBar = observer(function BottomBarImpl({
         }
         onPress={onPressSearch}
         accessibilityRole="search"
-        accessibilityLabel="Search"
+        accessibilityLabel={_(msg`Search`)}
         accessibilityHint=""
       />
       <Btn
@@ -148,78 +165,83 @@ export const BottomBar = observer(function BottomBarImpl({
         }
         onPress={onPressFeeds}
         accessibilityRole="tab"
-        accessibilityLabel="Feeds"
+        accessibilityLabel={_(msg`Feeds`)}
         accessibilityHint=""
       />
-      <Btn
-        testID="bottomBarNotificationsBtn"
-        icon={
-          isAtNotifications ? (
-            <BellIconSolid
-              size={24}
-              strokeWidth={1.9}
-              style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
-            />
-          ) : (
-            <BellIcon
-              size={24}
-              strokeWidth={1.9}
-              style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
-            />
-          )
-        }
-        onPress={onPressNotifications}
-        notificationCount={notifications.unreadCountLabel}
-        accessible={true}
-        accessibilityRole="tab"
-        accessibilityLabel="Notifications"
-        accessibilityHint={
-          notifications.unreadCountLabel === ''
-            ? ''
-            : `${notifications.unreadCountLabel} unread`
-        }
-      />
-      <Btn
-        testID="bottomBarProfileBtn"
-        icon={
-          <View style={styles.ctrlIconSizingWrapper}>
-            {isAtMyProfile ? (
-              <View
-                style={[
-                  styles.ctrlIcon,
-                  pal.text,
-                  styles.profileIcon,
-                  styles.onProfile,
-                  {borderColor: pal.text.color},
-                ]}>
-                <UserAvatar
-                  avatar={store.me.avatar}
-                  size={27}
-                  // See https://github.com/bluesky-social/social-app/pull/1801:
-                  usePlainRNImage={true}
+
+      {hasSession && (
+        <>
+          <Btn
+            testID="bottomBarNotificationsBtn"
+            icon={
+              isAtNotifications ? (
+                <BellIconSolid
+                  size={24}
+                  strokeWidth={1.9}
+                  style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
                 />
-              </View>
-            ) : (
-              <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
-                <UserAvatar
-                  avatar={store.me.avatar}
-                  size={28}
-                  // See https://github.com/bluesky-social/social-app/pull/1801:
-                  usePlainRNImage={true}
+              ) : (
+                <BellIcon
+                  size={24}
+                  strokeWidth={1.9}
+                  style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
                 />
+              )
+            }
+            onPress={onPressNotifications}
+            notificationCount={numUnreadNotifications}
+            accessible={true}
+            accessibilityRole="tab"
+            accessibilityLabel={_(msg`Notifications`)}
+            accessibilityHint={
+              numUnreadNotifications === ''
+                ? ''
+                : `${numUnreadNotifications} unread`
+            }
+          />
+          <Btn
+            testID="bottomBarProfileBtn"
+            icon={
+              <View style={styles.ctrlIconSizingWrapper}>
+                {isAtMyProfile ? (
+                  <View
+                    style={[
+                      styles.ctrlIcon,
+                      pal.text,
+                      styles.profileIcon,
+                      styles.onProfile,
+                      {borderColor: pal.text.color},
+                    ]}>
+                    <UserAvatar
+                      avatar={profile?.avatar}
+                      size={27}
+                      // See https://github.com/bluesky-social/social-app/pull/1801:
+                      usePlainRNImage={true}
+                    />
+                  </View>
+                ) : (
+                  <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
+                    <UserAvatar
+                      avatar={profile?.avatar}
+                      size={28}
+                      // See https://github.com/bluesky-social/social-app/pull/1801:
+                      usePlainRNImage={true}
+                    />
+                  </View>
+                )}
               </View>
-            )}
-          </View>
-        }
-        onPress={onPressProfile}
-        onLongPress={onLongPressProfile}
-        accessibilityRole="tab"
-        accessibilityLabel="Profile"
-        accessibilityHint=""
-      />
+            }
+            onPress={onPressProfile}
+            onLongPress={onLongPressProfile}
+            accessibilityRole="tab"
+            accessibilityLabel={_(msg`Profile`)}
+            accessibilityHint=""
+          />
+        </>
+      )}
     </Animated.View>
   )
-})
+}
 
 interface BtnProps
   extends Pick<
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index c175ed848..ae9381440 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -65,7 +65,4 @@ export const styles = StyleSheet.create({
     borderWidth: 1,
     borderRadius: 100,
   },
-  disabled: {
-    pointerEvents: 'none',
-  },
 })
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index ebcc527a1..3a60bd3b1 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -1,6 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useNavigationState} from '@react-navigation/native'
 import Animated from 'react-native-reanimated'
@@ -23,9 +21,10 @@ import {Link} from 'view/com/util/Link'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {makeProfileLink} from 'lib/routes/links'
 import {CommonNavigatorParams} from 'lib/routes/types'
+import {useSession} from '#/state/session'
 
-export const BottomBarWeb = observer(function BottomBarWebImpl() {
-  const store = useStores()
+export function BottomBarWeb() {
+  const {hasSession, currentAccount} = useSession()
   const pal = usePalette('default')
   const safeAreaInsets = useSafeAreaInsets()
   const {footerMinimalShellTransform} = useMinimalShellMode()
@@ -76,55 +75,69 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() {
           )
         }}
       </NavItem>
-      <NavItem routeName="Notifications" href="/notifications">
-        {({isActive}) => {
-          const Icon = isActive ? BellIconSolid : BellIcon
-          return (
-            <Icon
-              size={24}
-              strokeWidth={1.9}
-              style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
-            />
-          )
-        }}
-      </NavItem>
-      <NavItem routeName="Profile" href={makeProfileLink(store.me)}>
-        {({isActive}) => {
-          const Icon = isActive ? UserIconSolid : UserIcon
-          return (
-            <Icon
-              size={28}
-              strokeWidth={1.5}
-              style={[styles.ctrlIcon, pal.text, styles.profileIcon]}
-            />
-          )
-        }}
-      </NavItem>
+
+      {hasSession && (
+        <>
+          <NavItem routeName="Notifications" href="/notifications">
+            {({isActive}) => {
+              const Icon = isActive ? BellIconSolid : BellIcon
+              return (
+                <Icon
+                  size={24}
+                  strokeWidth={1.9}
+                  style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
+                />
+              )
+            }}
+          </NavItem>
+          <NavItem
+            routeName="Profile"
+            href={
+              currentAccount
+                ? makeProfileLink({
+                    did: currentAccount.did,
+                    handle: currentAccount.handle,
+                  })
+                : '/'
+            }>
+            {({isActive}) => {
+              const Icon = isActive ? UserIconSolid : UserIcon
+              return (
+                <Icon
+                  size={28}
+                  strokeWidth={1.5}
+                  style={[styles.ctrlIcon, pal.text, styles.profileIcon]}
+                />
+              )
+            }}
+          </NavItem>
+        </>
+      )}
     </Animated.View>
   )
-})
+}
 
 const NavItem: React.FC<{
   children: (props: {isActive: boolean}) => React.ReactChild
   href: string
   routeName: string
 }> = ({children, href, routeName}) => {
+  const {currentAccount} = useSession()
   const currentRoute = useNavigationState(state => {
     if (!state) {
       return {name: 'Home'}
     }
     return getCurrentRoute(state)
   })
-  const store = useStores()
   const isActive =
     currentRoute.name === 'Profile'
       ? isTab(currentRoute.name, routeName) &&
         (currentRoute.params as CommonNavigatorParams['Profile']).name ===
-          store.me.handle
+          currentAccount?.handle
       : isTab(currentRoute.name, routeName)
 
   return (
-    <Link href={href} style={styles.ctrl}>
+    <Link href={href} style={styles.ctrl} navigationAction="navigate">
       {children({isActive})}
     </Link>
   )
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
new file mode 100644
index 000000000..c7b5d1d2e
--- /dev/null
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -0,0 +1,150 @@
+import * as React from 'react'
+import {View} from 'react-native'
+
+// Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts
+// MIT License
+// Copyright (c) 2017 React Navigation Contributors
+
+import {
+  createNavigatorFactory,
+  EventArg,
+  ParamListBase,
+  StackActionHelpers,
+  StackActions,
+  StackNavigationState,
+  StackRouter,
+  StackRouterOptions,
+  useNavigationBuilder,
+} from '@react-navigation/native'
+import type {
+  NativeStackNavigationEventMap,
+  NativeStackNavigationOptions,
+} from '@react-navigation/native-stack'
+import type {NativeStackNavigatorProps} from '@react-navigation/native-stack/src/types'
+import {NativeStackView} from '@react-navigation/native-stack'
+
+import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
+import {DesktopLeftNav} from './desktop/LeftNav'
+import {DesktopRightNav} from './desktop/RightNav'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {useOnboardingState} from '#/state/shell'
+import {
+  useLoggedOutView,
+  useLoggedOutViewControls,
+} from '#/state/shell/logged-out'
+import {useSession} from '#/state/session'
+import {isWeb} from 'platform/detection'
+import {LoggedOut} from '../com/auth/LoggedOut'
+import {Onboarding} from '../com/auth/Onboarding'
+
+type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & {
+  requireAuth?: boolean
+}
+
+function NativeStackNavigator({
+  id,
+  initialRouteName,
+  children,
+  screenListeners,
+  screenOptions,
+  ...rest
+}: NativeStackNavigatorProps) {
+  // --- this is copy and pasted from the original native stack navigator ---
+  const {state, descriptors, navigation, NavigationContent} =
+    useNavigationBuilder<
+      StackNavigationState<ParamListBase>,
+      StackRouterOptions,
+      StackActionHelpers<ParamListBase>,
+      NativeStackNavigationOptionsWithAuth,
+      NativeStackNavigationEventMap
+    >(StackRouter, {
+      id,
+      initialRouteName,
+      children,
+      screenListeners,
+      screenOptions,
+    })
+  React.useEffect(
+    () =>
+      // @ts-expect-error: there may not be a tab navigator in parent
+      navigation?.addListener?.('tabPress', (e: any) => {
+        const isFocused = navigation.isFocused()
+
+        // Run the operation in the next frame so we're sure all listeners have been run
+        // This is necessary to know if preventDefault() has been called
+        requestAnimationFrame(() => {
+          if (
+            state.index > 0 &&
+            isFocused &&
+            !(e as EventArg<'tabPress', true>).defaultPrevented
+          ) {
+            // When user taps on already focused tab and we're inside the tab,
+            // reset the stack to replicate native behaviour
+            navigation.dispatch({
+              ...StackActions.popToTop(),
+              target: state.key,
+            })
+          }
+        })
+      }),
+    [navigation, state.index, state.key],
+  )
+
+  // --- our custom logic starts here ---
+  const {hasSession} = useSession()
+  const activeRoute = state.routes[state.index]
+  const activeDescriptor = descriptors[activeRoute.key]
+  const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false
+  const onboardingState = useOnboardingState()
+  const {showLoggedOut} = useLoggedOutView()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const {isMobile} = useWebMediaQueries()
+  if (activeRouteRequiresAuth && !hasSession) {
+    return <LoggedOut />
+  }
+  if (showLoggedOut) {
+    return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
+  }
+  if (onboardingState.isActive) {
+    return <Onboarding />
+  }
+  const newDescriptors: typeof descriptors = {}
+  for (let key in descriptors) {
+    const descriptor = descriptors[key]
+    const requireAuth = descriptor.options.requireAuth ?? false
+    newDescriptors[key] = {
+      ...descriptor,
+      render() {
+        if (requireAuth && !hasSession) {
+          return <View />
+        } else {
+          return descriptor.render()
+        }
+      },
+    }
+  }
+  return (
+    <NavigationContent>
+      <NativeStackView
+        {...rest}
+        state={state}
+        navigation={navigation}
+        descriptors={newDescriptors}
+      />
+      {isWeb && isMobile && <BottomBarWeb />}
+      {isWeb && !isMobile && (
+        <>
+          <DesktopLeftNav />
+          <DesktopRightNav />
+        </>
+      )}
+    </NavigationContent>
+  )
+}
+
+export const createNativeStackNavigatorWithAuth = createNavigatorFactory<
+  StackNavigationState<ParamListBase>,
+  NativeStackNavigationOptionsWithAuth,
+  NativeStackNavigationEventMap,
+  typeof NativeStackNavigator
+>(NativeStackNavigator)
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 3237d2cdd..ff51ffe22 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -1,17 +1,17 @@
 import React from 'react'
 import {View, StyleSheet} from 'react-native'
 import {useNavigationState} from '@react-navigation/native'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems'
 import {TextLink} from 'view/com/util/Link'
 import {getCurrentRoute} from 'lib/routes/helpers'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {usePinnedFeedsInfos} from '#/state/queries/feed'
 
-export const DesktopFeeds = observer(function DesktopFeeds() {
-  const store = useStores()
+export function DesktopFeeds() {
   const pal = usePalette('default')
-  const items = useDesktopRightNavItems(store.preferences.pinnedFeeds)
+  const {_} = useLingui()
+  const {feeds} = usePinnedFeedsInfos()
 
   const route = useNavigationState(state => {
     if (!state) {
@@ -23,40 +23,40 @@ export const DesktopFeeds = observer(function DesktopFeeds() {
   return (
     <View style={[styles.container, pal.view, pal.border]}>
       <FeedItem href="/" title="Following" current={route.name === 'Home'} />
-      {items.map(item => {
-        try {
-          const params = route.params as Record<string, string>
-          const routeName =
-            item.collection === 'app.bsky.feed.generator'
-              ? 'ProfileFeed'
-              : 'ProfileList'
-          return (
-            <FeedItem
-              key={item.uri}
-              href={item.href}
-              title={item.displayName}
-              current={
-                route.name === routeName &&
-                params.name === item.hostname &&
-                params.rkey === item.rkey
-              }
-            />
-          )
-        } catch {
-          return null
-        }
-      })}
+      {feeds
+        .filter(f => f.displayName !== 'Following')
+        .map(feed => {
+          try {
+            const params = route.params as Record<string, string>
+            const routeName =
+              feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList'
+            return (
+              <FeedItem
+                key={feed.uri}
+                href={feed.route.href}
+                title={feed.displayName}
+                current={
+                  route.name === routeName &&
+                  params.name === feed.route.params.name &&
+                  params.rkey === feed.route.params.rkey
+                }
+              />
+            )
+          } catch {
+            return null
+          }
+        })}
       <View style={{paddingTop: 8, paddingBottom: 6}}>
         <TextLink
           type="lg"
           href="/feeds"
-          text="More feeds"
+          text={_(msg`More feeds`)}
           style={[pal.link]}
         />
       </View>
     </View>
   )
-})
+}
 
 function FeedItem({
   title,
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 39271605c..2ed294501 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {PressableWithHover} from 'view/com/util/PressableWithHover'
 import {
@@ -16,7 +15,6 @@ import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Link} from 'view/com/util/Link'
 import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s, colors} from 'lib/styles'
 import {
@@ -39,18 +37,36 @@ import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
 import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
 import {router} from '../../../routes'
 import {makeProfileLink} from 'lib/routes/links'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {useComposerControls} from '#/state/shell/composer'
+import {useFetchHandle} from '#/state/queries/handle'
+import {emitSoftReset} from '#/state/events'
+import {useQueryClient} from '@tanstack/react-query'
+import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
+import {NavSignupCard} from '#/view/shell/NavSignupCard'
+import {truncateAndInvalidate} from '#/state/queries/util'
 
-const ProfileCard = observer(function ProfileCardImpl() {
-  const store = useStores()
+function ProfileCard() {
+  const {currentAccount} = useSession()
+  const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did})
   const {isDesktop} = useWebMediaQueries()
+  const {_} = useLingui()
   const size = 48
-  return store.me.handle ? (
+
+  return !isLoading && profile ? (
     <Link
-      href={makeProfileLink(store.me)}
+      href={makeProfileLink({
+        did: currentAccount!.did,
+        handle: currentAccount!.handle,
+      })}
       style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}
-      title="My Profile"
+      title={_(msg`My Profile`)}
       asAnchor>
-      <UserAvatar avatar={store.me.avatar} size={size} />
+      <UserAvatar avatar={profile.avatar} size={size} />
     </Link>
   ) : (
     <View style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}>
@@ -61,12 +77,13 @@ const ProfileCard = observer(function ProfileCardImpl() {
       />
     </View>
   )
-})
+}
 
 function BackBtn() {
   const {isTablet} = useWebMediaQueries()
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
   const shouldShow = useNavigationState(state => !isStateAtTabRoot(state))
 
   const onPressBack = React.useCallback(() => {
@@ -86,7 +103,7 @@ function BackBtn() {
       onPress={onPressBack}
       style={styles.backBtn}
       accessibilityRole="button"
-      accessibilityLabel="Go back"
+      accessibilityLabel={_(msg`Go back`)}
       accessibilityHint="">
       <FontAwesomeIcon
         size={24}
@@ -104,15 +121,10 @@ interface NavItemProps {
   iconFilled: JSX.Element
   label: string
 }
-const NavItem = observer(function NavItemImpl({
-  count,
-  href,
-  icon,
-  iconFilled,
-  label,
-}: NavItemProps) {
+function NavItem({count, href, icon, iconFilled, label}: NavItemProps) {
   const pal = usePalette('default')
-  const store = useStores()
+  const queryClient = useQueryClient()
+  const {currentAccount} = useSession()
   const {isDesktop, isTablet} = useWebMediaQueries()
   const [pathName] = React.useMemo(() => router.matchPath(href), [href])
   const currentRouteInfo = useNavigationState(state => {
@@ -125,7 +137,7 @@ const NavItem = observer(function NavItemImpl({
     currentRouteInfo.name === 'Profile'
       ? isTab(currentRouteInfo.name, pathName) &&
         (currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
-          store.me.handle
+          currentAccount?.handle
       : isTab(currentRouteInfo.name, pathName)
   const {onPress} = useLinkProps({to: href})
   const onPressWrapped = React.useCallback(
@@ -135,12 +147,16 @@ const NavItem = observer(function NavItemImpl({
       }
       e.preventDefault()
       if (isCurrent) {
-        store.emitScreenSoftReset()
+        emitSoftReset()
       } else {
+        if (href === '/notifications') {
+          // fetch new notifs on view
+          truncateAndInvalidate(queryClient, NOTIFS_RQKEY())
+        }
         onPress()
       }
     },
-    [onPress, isCurrent, store],
+    [onPress, isCurrent, queryClient, href],
   )
 
   return (
@@ -179,12 +195,16 @@ const NavItem = observer(function NavItemImpl({
       )}
     </PressableWithHover>
   )
-})
+}
 
 function ComposeBtn() {
-  const store = useStores()
+  const {currentAccount} = useSession()
   const {getState} = useNavigation()
+  const {openComposer} = useComposerControls()
+  const {_} = useLingui()
   const {isTablet} = useWebMediaQueries()
+  const [isFetchingHandle, setIsFetchingHandle] = React.useState(false)
+  const fetchHandle = useFetchHandle()
 
   const getProfileHandle = async () => {
     const {routes} = getState()
@@ -196,13 +216,21 @@ function ComposeBtn() {
       ).name
 
       if (handle.startsWith('did:')) {
-        const cached = await store.profiles.cache.get(handle)
-        const profile = cached ? cached.data : undefined
-        // if we can't resolve handle, set to undefined
-        handle = profile?.handle || undefined
+        try {
+          setIsFetchingHandle(true)
+          handle = await fetchHandle(handle)
+        } catch (e) {
+          handle = undefined
+        } finally {
+          setIsFetchingHandle(false)
+        }
       }
 
-      if (!handle || handle === store.me.handle || handle === 'handle.invalid')
+      if (
+        !handle ||
+        handle === currentAccount?.handle ||
+        handle === 'handle.invalid'
+      )
         return undefined
 
       return handle
@@ -212,17 +240,18 @@ function ComposeBtn() {
   }
 
   const onPressCompose = async () =>
-    store.shell.openComposer({mention: await getProfileHandle()})
+    openComposer({mention: await getProfileHandle()})
 
   if (isTablet) {
     return null
   }
   return (
     <TouchableOpacity
+      disabled={isFetchingHandle}
       style={[styles.newPostBtn]}
       onPress={onPressCompose}
       accessibilityRole="button"
-      accessibilityLabel="New post"
+      accessibilityLabel={_(msg`New post`)}
       accessibilityHint="">
       <View style={styles.newPostBtnIconWrapper}>
         <ComposeIcon2
@@ -232,16 +261,18 @@ function ComposeBtn() {
         />
       </View>
       <Text type="button" style={styles.newPostBtnLabel}>
-        New Post
+        <Trans>New Post</Trans>
       </Text>
     </TouchableOpacity>
   )
 }
 
-export const DesktopLeftNav = observer(function DesktopLeftNav() {
-  const store = useStores()
+export function DesktopLeftNav() {
+  const {hasSession, currentAccount} = useSession()
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isDesktop, isTablet} = useWebMediaQueries()
+  const numUnread = useUnreadNotifications()
 
   return (
     <View
@@ -251,8 +282,16 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         pal.view,
         pal.border,
       ]}>
-      {store.session.hasSession && <ProfileCard />}
+      {hasSession ? (
+        <ProfileCard />
+      ) : isDesktop ? (
+        <View style={{paddingHorizontal: 12}}>
+          <NavSignupCard />
+        </View>
+      ) : null}
+
       <BackBtn />
+
       <NavItem
         href="/"
         icon={<HomeIcon size={isDesktop ? 24 : 28} style={pal.text} />}
@@ -263,7 +302,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
             style={pal.text}
           />
         }
-        label="Home"
+        label={_(msg`Home`)}
       />
       <NavItem
         href="/search"
@@ -281,7 +320,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
             style={pal.text}
           />
         }
-        label="Search"
+        label={_(msg`Search`)}
       />
       <NavItem
         href="/feeds"
@@ -299,105 +338,109 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
             size={isDesktop ? 24 : 28}
           />
         }
-        label="Feeds"
+        label={_(msg`Feeds`)}
       />
-      <NavItem
-        href="/notifications"
-        count={store.me.notifications.unreadCountLabel}
-        icon={
-          <BellIcon
-            strokeWidth={2}
-            size={isDesktop ? 24 : 26}
-            style={pal.text}
+
+      {hasSession && (
+        <>
+          <NavItem
+            href="/notifications"
+            count={numUnread}
+            icon={
+              <BellIcon
+                strokeWidth={2}
+                size={isDesktop ? 24 : 26}
+                style={pal.text}
+              />
+            }
+            iconFilled={
+              <BellIconSolid
+                strokeWidth={1.5}
+                size={isDesktop ? 24 : 26}
+                style={pal.text}
+              />
+            }
+            label={_(msg`Notifications`)}
           />
-        }
-        iconFilled={
-          <BellIconSolid
-            strokeWidth={1.5}
-            size={isDesktop ? 24 : 26}
-            style={pal.text}
+          <NavItem
+            href="/lists"
+            icon={
+              <ListIcon
+                style={pal.text}
+                size={isDesktop ? 26 : 30}
+                strokeWidth={2}
+              />
+            }
+            iconFilled={
+              <ListIcon
+                style={pal.text}
+                size={isDesktop ? 26 : 30}
+                strokeWidth={3}
+              />
+            }
+            label={_(msg`Lists`)}
           />
-        }
-        label="Notifications"
-      />
-      <NavItem
-        href="/lists"
-        icon={
-          <ListIcon
-            style={pal.text}
-            size={isDesktop ? 26 : 30}
-            strokeWidth={2}
+          <NavItem
+            href="/moderation"
+            icon={
+              <HandIcon
+                style={pal.text}
+                size={isDesktop ? 24 : 27}
+                strokeWidth={5.5}
+              />
+            }
+            iconFilled={
+              <FontAwesomeIcon
+                icon="hand"
+                style={pal.text as FontAwesomeIconStyle}
+                size={isDesktop ? 20 : 26}
+              />
+            }
+            label={_(msg`Moderation`)}
           />
-        }
-        iconFilled={
-          <ListIcon
-            style={pal.text}
-            size={isDesktop ? 26 : 30}
-            strokeWidth={3}
+          <NavItem
+            href={currentAccount ? makeProfileLink(currentAccount) : '/'}
+            icon={
+              <UserIcon
+                strokeWidth={1.75}
+                size={isDesktop ? 28 : 30}
+                style={pal.text}
+              />
+            }
+            iconFilled={
+              <UserIconSolid
+                strokeWidth={1.75}
+                size={isDesktop ? 28 : 30}
+                style={pal.text}
+              />
+            }
+            label="Profile"
           />
-        }
-        label="Lists"
-      />
-      <NavItem
-        href="/moderation"
-        icon={
-          <HandIcon
-            style={pal.text}
-            size={isDesktop ? 24 : 27}
-            strokeWidth={5.5}
+          <NavItem
+            href="/settings"
+            icon={
+              <CogIcon
+                strokeWidth={1.75}
+                size={isDesktop ? 28 : 32}
+                style={pal.text}
+              />
+            }
+            iconFilled={
+              <CogIconSolid
+                strokeWidth={1.5}
+                size={isDesktop ? 28 : 32}
+                style={pal.text}
+              />
+            }
+            label={_(msg`Settings`)}
           />
-        }
-        iconFilled={
-          <FontAwesomeIcon
-            icon="hand"
-            style={pal.text as FontAwesomeIconStyle}
-            size={isDesktop ? 20 : 26}
-          />
-        }
-        label="Moderation"
-      />
-      {store.session.hasSession && (
-        <NavItem
-          href={makeProfileLink(store.me)}
-          icon={
-            <UserIcon
-              strokeWidth={1.75}
-              size={isDesktop ? 28 : 30}
-              style={pal.text}
-            />
-          }
-          iconFilled={
-            <UserIconSolid
-              strokeWidth={1.75}
-              size={isDesktop ? 28 : 30}
-              style={pal.text}
-            />
-          }
-          label="Profile"
-        />
+
+          <ComposeBtn />
+        </>
       )}
-      <NavItem
-        href="/settings"
-        icon={
-          <CogIcon
-            strokeWidth={1.75}
-            size={isDesktop ? 28 : 32}
-            style={pal.text}
-          />
-        }
-        iconFilled={
-          <CogIconSolid
-            strokeWidth={1.5}
-            size={isDesktop ? 28 : 32}
-            style={pal.text}
-          />
-        }
-        label="Settings"
-      />
-      {store.session.hasSession && <ComposeBtn />}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   leftNav: {
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 84d7d7854..9a5186549 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -9,15 +8,19 @@ import {Text} from 'view/com/util/text/Text'
 import {TextLink} from 'view/com/util/Link'
 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {pluralize} from 'lib/strings/helpers'
 import {formatCount} from 'view/com/util/numeric/format'
+import {useModalControls} from '#/state/modals'
+import {useLingui} from '@lingui/react'
+import {Plural, Trans, msg, plural} from '@lingui/macro'
+import {useSession} from '#/state/session'
+import {useInviteCodesQuery} from '#/state/queries/invites'
 
-export const DesktopRightNav = observer(function DesktopRightNavImpl() {
-  const store = useStores()
+export function DesktopRightNav() {
   const pal = usePalette('default')
   const palError = usePalette('error')
+  const {_} = useLingui()
+  const {isSandbox, hasSession, currentAccount} = useSession()
 
   const {isTablet} = useWebMediaQueries()
   if (isTablet) {
@@ -26,10 +29,22 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
 
   return (
     <View style={[styles.rightNav, pal.view]}>
-      {store.session.hasSession && <DesktopSearch />}
-      {store.session.hasSession && <DesktopFeeds />}
-      <View style={styles.message}>
-        {store.session.isSandbox ? (
+      <DesktopSearch />
+
+      {hasSession && (
+        <View style={{paddingTop: 18, marginBottom: 18}}>
+          <DesktopFeeds />
+        </View>
+      )}
+
+      <View
+        style={[
+          styles.message,
+          {
+            paddingTop: hasSession ? 0 : 18,
+          },
+        ]}>
+        {isSandbox ? (
           <View style={[palError.view, styles.messageLine, s.p10]}>
             <Text type="md" style={[palError.text, s.bold]}>
               SANDBOX. Posts and accounts are not permanent.
@@ -37,23 +52,27 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
           </View>
         ) : undefined}
         <View style={[s.flexRow]}>
-          <TextLink
-            type="md"
-            style={pal.link}
-            href={FEEDBACK_FORM_URL({
-              email: store.session.currentSession?.email,
-              handle: store.session.currentSession?.handle,
-            })}
-            text="Send feedback"
-          />
-          <Text type="md" style={pal.textLight}>
-            &nbsp;&middot;&nbsp;
-          </Text>
+          {hasSession && (
+            <>
+              <TextLink
+                type="md"
+                style={pal.link}
+                href={FEEDBACK_FORM_URL({
+                  email: currentAccount!.email,
+                  handle: currentAccount!.handle,
+                })}
+                text={_(msg`Feedback`)}
+              />
+              <Text type="md" style={pal.textLight}>
+                &nbsp;&middot;&nbsp;
+              </Text>
+            </>
+          )}
           <TextLink
             type="md"
             style={pal.link}
             href="https://blueskyweb.xyz/support/privacy-policy"
-            text="Privacy"
+            text={_(msg`Privacy`)}
           />
           <Text type="md" style={pal.textLight}>
             &nbsp;&middot;&nbsp;
@@ -62,7 +81,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
             type="md"
             style={pal.link}
             href="https://blueskyweb.xyz/support/tos"
-            text="Terms"
+            text={_(msg`Terms`)}
           />
           <Text type="md" style={pal.textLight}>
             &nbsp;&middot;&nbsp;
@@ -71,52 +90,80 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
             type="md"
             style={pal.link}
             href={HELP_DESK_URL}
-            text="Help"
+            text={_(msg`Help`)}
           />
         </View>
       </View>
-      <InviteCodes />
+
+      {hasSession && <InviteCodes />}
     </View>
   )
-})
+}
 
-const InviteCodes = observer(function InviteCodesImpl() {
-  const store = useStores()
+function InviteCodes() {
   const pal = usePalette('default')
-
-  const {invitesAvailable} = store.me
+  const {openModal} = useModalControls()
+  const {data: invites} = useInviteCodesQuery()
+  const invitesAvailable = invites?.available?.length ?? 0
+  const {_} = useLingui()
 
   const onPress = React.useCallback(() => {
-    store.shell.openModal({name: 'invite-codes'})
-  }, [store])
+    openModal({name: 'invite-codes'})
+  }, [openModal])
+
+  if (!invites) {
+    return null
+  }
+
+  if (invites?.disabled) {
+    return (
+      <View style={[styles.inviteCodes, pal.border]}>
+        <FontAwesomeIcon
+          icon="ticket"
+          style={[styles.inviteCodesIcon, pal.textLight]}
+          size={16}
+        />
+        <Text type="md-medium" style={pal.textLight}>
+          <Trans>
+            Your invite codes are hidden when logged in using an App Password
+          </Trans>
+        </Text>
+      </View>
+    )
+  }
+
   return (
     <TouchableOpacity
       style={[styles.inviteCodes, pal.border]}
       onPress={onPress}
       accessibilityRole="button"
-      accessibilityLabel={
-        invitesAvailable === 1
-          ? 'Invite codes: 1 available'
-          : `Invite codes: ${invitesAvailable} available`
-      }
-      accessibilityHint="Opens list of invite codes">
+      accessibilityLabel={_(
+        plural(invitesAvailable, {
+          one: 'Invite codes: # available',
+          other: 'Invite codes: # available',
+        }),
+      )}
+      accessibilityHint={_(msg`Opens list of invite codes`)}>
       <FontAwesomeIcon
         icon="ticket"
         style={[
           styles.inviteCodesIcon,
-          store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
+          invitesAvailable > 0 ? pal.link : pal.textLight,
         ]}
         size={16}
       />
       <Text
         type="md-medium"
-        style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
-        {formatCount(store.me.invitesAvailable)} invite{' '}
-        {pluralize(store.me.invitesAvailable, 'code')} available
+        style={invitesAvailable > 0 ? pal.link : pal.textLight}>
+        <Plural
+          value={formatCount(invitesAvailable)}
+          one="# invite code available"
+          other="# invite codes available"
+        />
       </Text>
     </TouchableOpacity>
   )
-})
+}
 
 const styles = StyleSheet.create({
   rightNav: {
@@ -142,9 +189,10 @@ const styles = StyleSheet.create({
     paddingHorizontal: 16,
     paddingVertical: 12,
     flexDirection: 'row',
-    alignItems: 'center',
   },
   inviteCodesIcon: {
+    marginTop: 2,
     marginRight: 6,
+    flexShrink: 0,
   },
 })
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index caecea4a8..f899431b6 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -1,56 +1,150 @@
 import React from 'react'
-import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native'
+import {
+  ViewStyle,
+  TextInput,
+  View,
+  StyleSheet,
+  TouchableOpacity,
+  ActivityIndicator,
+} from 'react-native'
 import {useNavigation, StackActions} from '@react-navigation/native'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ProfileModeration,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {s} from '#/lib/styles'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {makeProfileLink} from '#/lib/routes/links'
+import {Link} from '#/view/com/util/Link'
 import {usePalette} from 'lib/hooks/usePalette'
 import {MagnifyingGlassIcon2} from 'lib/icons'
 import {NavigationProp} from 'lib/routes/types'
-import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {Text} from 'view/com/util/text/Text'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useModerationOpts} from '#/state/queries/preferences'
 
-export const DesktopSearch = observer(function DesktopSearch() {
-  const store = useStores()
+export function SearchResultCard({
+  profile,
+  style,
+  moderation,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  style: ViewStyle
+  moderation: ProfileModeration
+}) {
   const pal = usePalette('default')
-  const textInput = React.useRef<TextInput>(null)
-  const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
-  const [query, setQuery] = React.useState<string>('')
-  const autocompleteView = React.useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
+
+  return (
+    <Link
+      href={makeProfileLink(profile)}
+      title={profile.handle}
+      asAnchor
+      anchorNoUnderline>
+      <View
+        style={[
+          pal.border,
+          style,
+          {
+            borderTopWidth: 1,
+            flexDirection: 'row',
+            alignItems: 'center',
+            gap: 12,
+            paddingVertical: 8,
+            paddingHorizontal: 12,
+          },
+        ]}>
+        <UserAvatar
+          size={40}
+          avatar={profile.avatar}
+          moderation={moderation.avatar}
+        />
+        <View style={{flex: 1}}>
+          <Text
+            type="lg"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
+            )}
+          </Text>
+          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+            {sanitizeHandle(profile.handle, '@')}
+          </Text>
+        </View>
+      </View>
+    </Link>
   )
+}
+
+export function DesktopSearch() {
+  const {_} = useLingui()
+  const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
+  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
+    undefined,
+  )
+  const [isActive, setIsActive] = React.useState<boolean>(false)
+  const [isFetching, setIsFetching] = React.useState<boolean>(false)
+  const [query, setQuery] = React.useState<string>('')
+  const [searchResults, setSearchResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
 
-  // initial setup
-  React.useEffect(() => {
-    if (store.me.did) {
-      autocompleteView.setup()
-    }
-  }, [autocompleteView, store.me.did])
+  const moderationOpts = useModerationOpts()
+  const search = useActorAutocompleteFn()
 
-  const onChangeQuery = React.useCallback(
-    (text: string) => {
+  const onChangeText = React.useCallback(
+    async (text: string) => {
       setQuery(text)
-      if (text.length > 0 && isInputFocused) {
-        autocompleteView.setActive(true)
-        autocompleteView.setPrefix(text)
+
+      if (text.length > 0) {
+        setIsFetching(true)
+        setIsActive(true)
+
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+
+        searchDebounceTimeout.current = setTimeout(async () => {
+          const results = await search({query: text})
+
+          if (results) {
+            setSearchResults(results)
+            setIsFetching(false)
+          }
+        }, 300)
       } else {
-        autocompleteView.setActive(false)
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
+        setIsFetching(false)
+        setIsActive(false)
       }
     },
-    [setQuery, autocompleteView, isInputFocused],
+    [setQuery, search, setSearchResults],
   )
 
   const onPressCancelSearch = React.useCallback(() => {
     setQuery('')
-    autocompleteView.setActive(false)
-  }, [setQuery, autocompleteView])
-
+    setIsActive(false)
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
+  }, [setQuery])
   const onSubmit = React.useCallback(() => {
+    setIsActive(false)
+    if (!query.length) return
+    setSearchResults([])
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
     navigation.dispatch(StackActions.push('Search', {q: query}))
-    autocompleteView.setActive(false)
-  }, [query, navigation, autocompleteView])
+  }, [query, navigation, setSearchResults])
 
   return (
     <View style={[styles.container, pal.view]}>
@@ -63,19 +157,16 @@ export const DesktopSearch = observer(function DesktopSearch() {
           />
           <TextInput
             testID="searchTextInput"
-            ref={textInput}
-            placeholder="Search"
+            placeholder={_(msg`Search`)}
             placeholderTextColor={pal.colors.textLight}
             selectTextOnFocus
             returnKeyType="search"
             value={query}
             style={[pal.textLight, styles.input]}
-            onFocus={() => setIsInputFocused(true)}
-            onBlur={() => setIsInputFocused(false)}
-            onChangeText={onChangeQuery}
+            onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             accessibilityRole="search"
-            accessibilityLabel="Search"
+            accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
           />
           {query ? (
@@ -83,11 +174,11 @@ export const DesktopSearch = observer(function DesktopSearch() {
               <TouchableOpacity
                 onPress={onPressCancelSearch}
                 accessibilityRole="button"
-                accessibilityLabel="Cancel search"
+                accessibilityLabel={_(msg`Cancel search`)}
                 accessibilityHint="Exits inputting search query"
                 onAccessibilityEscape={onPressCancelSearch}>
                 <Text type="lg" style={[pal.link]}>
-                  Cancel
+                  <Trans>Cancel</Trans>
                 </Text>
               </TouchableOpacity>
             </View>
@@ -95,32 +186,42 @@ export const DesktopSearch = observer(function DesktopSearch() {
         </View>
       </View>
 
-      {query !== '' && (
+      {query !== '' && isActive && moderationOpts && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {autocompleteView.suggestions.length ? (
+          {isFetching ? (
+            <View style={{padding: 8}}>
+              <ActivityIndicator />
+            </View>
+          ) : (
             <>
-              {autocompleteView.suggestions.map((item, i) => (
-                <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
-              ))}
+              {searchResults.length ? (
+                searchResults.map((item, i) => (
+                  <SearchResultCard
+                    key={item.did}
+                    profile={item}
+                    moderation={moderateProfile(item, moderationOpts)}
+                    style={i === 0 ? {borderTopWidth: 0} : {}}
+                  />
+                ))
+              ) : (
+                <View>
+                  <Text style={[pal.textLight, styles.noResults]}>
+                    <Trans>No results found for {query}</Trans>
+                  </Text>
+                </View>
+              )}
             </>
-          ) : (
-            <View>
-              <Text style={[pal.textLight, styles.noResults]}>
-                No results found for {autocompleteView.prefix}
-              </Text>
-            </View>
           )}
         </View>
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
     position: 'relative',
     width: 300,
-    paddingBottom: 18,
   },
   search: {
     paddingHorizontal: 16,
@@ -150,15 +251,11 @@ const styles = StyleSheet.create({
     paddingVertical: 7,
   },
   resultsContainer: {
-    // @ts-ignore supported by web
-    // position: 'fixed',
     marginTop: 10,
-
     flexDirection: 'column',
     width: 300,
     borderWidth: 1,
     borderRadius: 6,
-    paddingVertical: 4,
   },
   noResults: {
     textAlign: 'center',
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 703edf27a..5562af9ac 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {StatusBar} from 'expo-status-bar'
 import {
   DimensionValue,
@@ -11,7 +10,6 @@ import {
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {Drawer} from 'react-native-drawer-layout'
 import {useNavigationState} from '@react-navigation/native'
-import {useStores} from 'state/index'
 import {ModalsContainer} from 'view/com/modals/Modal'
 import {Lightbox} from 'view/com/lightbox/Lightbox'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -25,20 +23,19 @@ import {
   SafeAreaProvider,
   initialWindowMetrics,
 } from 'react-native-safe-area-context'
-import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
 import {
   useIsDrawerOpen,
   useSetDrawerOpen,
   useIsDrawerSwipeDisabled,
 } from '#/state/shell'
 import {isAndroid} from 'platform/detection'
+import {useSession} from '#/state/session'
+import {useCloseAnyActiveElement} from '#/state/util'
 
-const ShellInner = observer(function ShellInnerImpl() {
-  const store = useStores()
+function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
   const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
   const setIsDrawerOpen = useSetDrawerOpen()
-  useOTAUpdate() // this hook polls for OTA updates every few seconds
   const winDim = useWindowDimensions()
   const safeAreaInsets = useSafeAreaInsets()
   const containerPadding = React.useMemo(
@@ -55,18 +52,20 @@ const ShellInner = observer(function ShellInnerImpl() {
     [setIsDrawerOpen],
   )
   const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
+  const {hasSession} = useSession()
+  const closeAnyActiveElement = useCloseAnyActiveElement()
+
   React.useEffect(() => {
     let listener = {remove() {}}
     if (isAndroid) {
       listener = BackHandler.addEventListener('hardwareBackPress', () => {
-        setIsDrawerOpen(false)
-        return store.shell.closeAnyActiveElement()
+        return closeAnyActiveElement()
       })
     }
     return () => {
       listener.remove()
     }
-  }, [store, setIsDrawerOpen])
+  }, [closeAnyActiveElement])
 
   return (
     <>
@@ -78,28 +77,19 @@ const ShellInner = observer(function ShellInnerImpl() {
             onOpen={onOpenDrawer}
             onClose={onCloseDrawer}
             swipeEdgeWidth={winDim.width / 2}
-            swipeEnabled={
-              !canGoBack && store.session.hasSession && !isDrawerSwipeDisabled
-            }>
+            swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}>
             <TabsNavigator />
           </Drawer>
         </ErrorBoundary>
       </View>
-      <Composer
-        active={store.shell.isComposerActive}
-        winHeight={winDim.height}
-        replyTo={store.shell.composerOpts?.replyTo}
-        onPost={store.shell.composerOpts?.onPost}
-        quote={store.shell.composerOpts?.quote}
-        mention={store.shell.composerOpts?.mention}
-      />
+      <Composer winHeight={winDim.height} />
       <ModalsContainer />
       <Lightbox />
     </>
   )
-})
+}
 
-export const Shell: React.FC = observer(function ShellImpl() {
+export const Shell: React.FC = function ShellImpl() {
   const pal = usePalette('default')
   const theme = useTheme()
   return (
@@ -112,7 +102,7 @@ export const Shell: React.FC = observer(function ShellImpl() {
       </View>
     </SafeAreaProvider>
   )
-})
+}
 
 const styles = StyleSheet.create({
   outerContainer: {
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 843d0b284..38da860bd 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -1,9 +1,5 @@
 import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
 import {View, StyleSheet, TouchableOpacity} from 'react-native'
-import {useStores} from 'state/index'
-import {DesktopLeftNav} from './desktop/LeftNav'
-import {DesktopRightNav} from './desktop/RightNav'
 import {ErrorBoundary} from '../com/util/ErrorBoundary'
 import {Lightbox} from '../com/lightbox/Lightbox'
 import {ModalsContainer} from '../com/modals/Modal'
@@ -13,30 +9,29 @@ import {s, colors} from 'lib/styles'
 import {RoutesContainer, FlatNavigator} from '../../Navigation'
 import {DrawerContent} from './Drawer'
 import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
-import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {useAuxClick} from 'lib/hooks/useAuxClick'
+import {t} from '@lingui/macro'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
+import {useCloseAllActiveElements} from '#/state/util'
 
-const ShellInner = observer(function ShellInnerImpl() {
-  const store = useStores()
+function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
   const setDrawerOpen = useSetDrawerOpen()
-  const {isDesktop, isMobile} = useWebMediaQueries()
+  const {isDesktop} = useWebMediaQueries()
   const navigator = useNavigation<NavigationProp>()
+  const closeAllActiveElements = useCloseAllActiveElements()
+
   useAuxClick()
 
   useEffect(() => {
-    navigator.addListener('state', () => {
-      setDrawerOpen(false)
-      store.shell.closeAnyActiveElement()
+    const unsubscribe = navigator.addListener('state', () => {
+      closeAllActiveElements()
     })
-  }, [navigator, store.shell, setDrawerOpen])
+    return unsubscribe
+  }, [navigator, closeAllActiveElements])
 
-  const showBottomBar = isMobile && !store.onboarding.isActive
-  const showSideNavs =
-    !isMobile && store.session.hasSession && !store.onboarding.isActive
   return (
     <View style={[s.hContentRegion, {overflow: 'hidden'}]}>
       <View style={s.hContentRegion}>
@@ -44,28 +39,14 @@ const ShellInner = observer(function ShellInnerImpl() {
           <FlatNavigator />
         </ErrorBoundary>
       </View>
-      {showSideNavs && (
-        <>
-          <DesktopLeftNav />
-          <DesktopRightNav />
-        </>
-      )}
-      <Composer
-        active={store.shell.isComposerActive}
-        winHeight={0}
-        replyTo={store.shell.composerOpts?.replyTo}
-        quote={store.shell.composerOpts?.quote}
-        onPost={store.shell.composerOpts?.onPost}
-        mention={store.shell.composerOpts?.mention}
-      />
-      {showBottomBar && <BottomBarWeb />}
+      <Composer winHeight={0} />
       <ModalsContainer />
       <Lightbox />
       {!isDesktop && isDrawerOpen && (
         <TouchableOpacity
           onPress={() => setDrawerOpen(false)}
           style={styles.drawerMask}
-          accessibilityLabel="Close navigation footer"
+          accessibilityLabel={t`Close navigation footer`}
           accessibilityHint="Closes bottom navigation bar">
           <View style={styles.drawerContainer}>
             <DrawerContent />
@@ -74,9 +55,9 @@ const ShellInner = observer(function ShellInnerImpl() {
       )}
     </View>
   )
-})
+}
 
-export const Shell: React.FC = observer(function ShellImpl() {
+export const Shell: React.FC = function ShellImpl() {
   const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
   return (
     <View style={[s.hContentRegion, pageBg]}>
@@ -85,7 +66,7 @@ export const Shell: React.FC = observer(function ShellImpl() {
       </RoutesContainer>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   bgLight: {