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/SplashScreen.web.tsx13
-rw-r--r--src/view/com/composer/Composer.tsx46
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx9
-rw-r--r--src/view/com/composer/GifAltText.tsx177
-rw-r--r--src/view/com/composer/photos/Gallery.tsx67
-rw-r--r--src/view/com/home/HomeHeaderLayout.web.tsx14
-rw-r--r--src/view/com/notifications/Feed.tsx27
-rw-r--r--src/view/com/post-thread/PostThread.tsx84
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/profile/ProfileCard.tsx2
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx2
-rw-r--r--src/view/com/util/List.tsx16
-rw-r--r--src/view/com/util/List.web.tsx201
-rw-r--r--src/view/com/util/MainScrollProvider.tsx59
-rw-r--r--src/view/com/util/PostMeta.tsx65
-rw-r--r--src/view/com/util/TimeElapsed.tsx16
-rw-r--r--src/view/com/util/Views.jsx10
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx150
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx4
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx4
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx75
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/icons/Logo.tsx25
-rw-r--r--src/view/screens/DebugMod.tsx2
-rw-r--r--src/view/screens/Home.tsx30
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx6
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx6
-rw-r--r--src/view/screens/Profile.tsx80
-rw-r--r--src/view/screens/ProfileList.tsx24
-rw-r--r--src/view/screens/Search/Search.tsx511
-rw-r--r--src/view/screens/Settings/index.tsx153
-rw-r--r--src/view/screens/Storybook/ListContained.tsx104
-rw-r--r--src/view/screens/Storybook/index.tsx164
-rw-r--r--src/view/shell/Drawer.tsx13
-rw-r--r--src/view/shell/desktop/RightNav.tsx33
-rw-r--r--src/view/shell/desktop/Search.tsx32
38 files changed, 1474 insertions, 761 deletions
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
index f905e1e8d..6df4e439a 100644
--- a/src/view/com/auth/SplashScreen.web.tsx
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -4,6 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useKawaiiMode} from '#/state/preferences/kawaii'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {Logo} from '#/view/icons/Logo'
 import {Logotype} from '#/view/icons/Logotype'
@@ -28,6 +29,8 @@ export const SplashScreen = ({
   const t = useTheme()
   const {isTabletOrMobile: isMobileWeb} = useWebMediaQueries()
 
+  const kawaii = useKawaiiMode()
+
   return (
     <>
       {onDismiss && (
@@ -66,11 +69,13 @@ export const SplashScreen = ({
           ]}>
           <ErrorBoundary>
             <View style={[a.justify_center, a.align_center]}>
-              <Logo width={92} fill="sky" />
+              <Logo width={kawaii ? 300 : 92} fill="sky" />
 
-              <View style={[a.pb_sm, a.pt_5xl]}>
-                <Logotype width={161} fill={t.atoms.text.color} />
-              </View>
+              {!kawaii && (
+                <View style={[a.pb_sm, a.pt_5xl]}>
+                  <Logotype width={161} fill={t.atoms.text.color} />
+                </View>
+              )}
 
               <Text
                 style={[
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0ac4ac56e..f472bb2e2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -59,6 +59,7 @@ import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {CharProgress} from './char-progress/CharProgress'
 import {ExternalEmbed} from './ExternalEmbed'
+import {GifAltText} from './GifAltText'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {Gallery} from './photos/Gallery'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
@@ -327,7 +328,7 @@ export const ComposePost = observer(function ComposePost({
           image: gif.media_formats.preview.url,
           likelyType: LikelyType.HTML,
           title: gif.content_description,
-          description: `ALT: ${gif.content_description}`,
+          description: '',
         },
       })
       setExtGif(gif)
@@ -335,6 +336,26 @@ export const ComposePost = observer(function ComposePost({
     [setExtLink],
   )
 
+  const handleChangeGifAltText = useCallback(
+    (altText: string) => {
+      setExtLink(ext =>
+        ext && ext.meta
+          ? {
+              ...ext,
+              meta: {
+                ...ext?.meta,
+                description:
+                  altText.trim().length === 0
+                    ? ''
+                    : `Alt text: ${altText.trim()}`,
+              },
+            }
+          : ext,
+      )
+    },
+    [setExtLink],
+  )
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -474,14 +495,21 @@ export const ComposePost = observer(function ComposePost({
 
           <Gallery gallery={gallery} />
           {gallery.isEmpty && extLink && (
-            <ExternalEmbed
-              link={extLink}
-              gif={extGif}
-              onRemove={() => {
-                setExtLink(undefined)
-                setExtGif(undefined)
-              }}
-            />
+            <View style={a.relative}>
+              <ExternalEmbed
+                link={extLink}
+                gif={extGif}
+                onRemove={() => {
+                  setExtLink(undefined)
+                  setExtGif(undefined)
+                }}
+              />
+              <GifAltText
+                link={extLink}
+                gif={extGif}
+                onSubmit={handleChangeGifAltText}
+              />
+            </View>
           )}
           {quote ? (
             <View style={[s.mt5, isWeb && s.mb10]}>
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 321e29b30..b81065e99 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
     : undefined
 
   return (
-    <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
+    <View
+      style={[
+        !gif && a.mb_xl,
+        a.overflow_hidden,
+        t.atoms.border_contrast_medium,
+      ]}>
       {link.isLoading ? (
         <Container style={loadingStyle}>
           <Loader size="xl" />
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
         </Container>
       ) : linkInfo ? (
         <View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
-          <ExternalLinkEmbed link={linkInfo} />
+          <ExternalLinkEmbed link={linkInfo} hideAlt />
         </View>
       ) : null}
       <TouchableOpacity
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
new file mode 100644
index 000000000..9e41a328f
--- /dev/null
+++ b/src/view/com/composer/GifAltText.tsx
@@ -0,0 +1,177 @@
+import React, {useCallback, useState} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ExternalEmbedDraft} from '#/lib/api'
+import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
+import {
+  EmbedPlayerParams,
+  parseEmbedPlayerFromUrl,
+} from '#/lib/strings/embed-player'
+import {enforceLen} from '#/lib/strings/helpers'
+import {isAndroid} from '#/platform/detection'
+import {Gif} from '#/state/queries/tenor'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Text} from '#/components/Typography'
+import {GifEmbed} from '../util/post-embeds/GifEmbed'
+import {AltTextReminder} from './photos/Gallery'
+
+export function GifAltText({
+  link: linkProp,
+  gif,
+  onSubmit,
+}: {
+  link: ExternalEmbedDraft
+  gif?: Gif
+  onSubmit: (alt: string) => void
+}) {
+  const control = Dialog.useDialogControl()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {link, params} = React.useMemo(() => {
+    return {
+      link: {
+        title: linkProp.meta?.title ?? linkProp.uri,
+        uri: linkProp.uri,
+        description: linkProp.meta?.description ?? '',
+        thumb: linkProp.localThumb?.path,
+      },
+      params: parseEmbedPlayerFromUrl(linkProp.uri),
+    }
+  }, [linkProp])
+
+  const onPressSubmit = useCallback(
+    (alt: string) => {
+      control.close(() => {
+        onSubmit(alt)
+      })
+    },
+    [onSubmit, control],
+  )
+
+  if (!gif || !params) return null
+
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={[
+          a.absolute,
+          {top: 20, left: 12},
+          {borderRadius: 6},
+          a.pl_xs,
+          a.pr_sm,
+          a.py_2xs,
+          a.flex_row,
+          a.gap_xs,
+          a.align_center,
+          {backgroundColor: 'rgba(0, 0, 0, 0.75)'},
+        ]}>
+        {link.description ? (
+          <Check size="xs" fill={t.palette.white} style={a.ml_xs} />
+        ) : (
+          <Plus size="sm" fill={t.palette.white} />
+        )}
+        <Text
+          style={[a.font_bold, {color: t.palette.white}]}
+          accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <AltTextReminder />
+
+      <Dialog.Outer
+        control={control}
+        nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+        <Dialog.Handle />
+        <AltTextInner
+          onSubmit={onPressSubmit}
+          link={link}
+          params={params}
+          initalValue={link.description.replace('Alt text: ', '')}
+          key={link.uri}
+        />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function AltTextInner({
+  onSubmit,
+  link,
+  params,
+  initalValue,
+}: {
+  onSubmit: (text: string) => void
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+  initalValue: string
+}) {
+  const {_} = useLingui()
+  const [altText, setAltText] = useState(initalValue)
+
+  const onPressSubmit = useCallback(() => {
+    onSubmit(altText)
+  }, [onSubmit, altText])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Add alt text`)}>
+      <View style={a.flex_col_reverse}>
+        <View style={[a.mt_md, a.gap_md]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>Descriptive alt text</Trans>
+            </TextField.LabelText>
+            <TextField.Root>
+              <Dialog.Input
+                label={_(msg`Alt text`)}
+                placeholder={link.title}
+                onChangeText={text =>
+                  setAltText(enforceLen(text, MAX_ALT_TEXT))
+                }
+                value={altText}
+                multiline
+                numberOfLines={3}
+                autoFocus
+              />
+            </TextField.Root>
+          </View>
+          <Button
+            label={_(msg`Save`)}
+            size="medium"
+            color="primary"
+            variant="solid"
+            onPress={onPressSubmit}>
+            <ButtonText>
+              <Trans>Save</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+        {/* below the text input to force tab order */}
+        <View>
+          <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
+            <Trans>Add ALT text</Trans>
+          </Text>
+          <View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
+            <GifEmbed link={link} params={params} hideAlt />
+          </View>
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 69c8debb0..7ff1b7b9a 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,19 +1,20 @@
 import React, {useState} from 'react'
 import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {GalleryModel} from 'state/models/media/gallery'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-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 {Dimensions} from 'lib/media/types'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {Trans, msg} from '@lingui/macro'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {observer} from 'mobx-react-lite'
+
 import {useModalControls} from '#/state/modals'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Dimensions} from 'lib/media/types'
+import {colors, s} from 'lib/styles'
 import {isNative} from 'platform/detection'
+import {GalleryModel} from 'state/models/media/gallery'
+import {Text} from 'view/com/util/text/Text'
+import {useTheme} from '#/alf'
 
 const IMAGE_GAP = 8
 
@@ -49,10 +50,10 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const pal = usePalette('default')
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const t = useTheme()
 
   let side: number
 
@@ -126,16 +127,22 @@ const GalleryInner = observer(function GalleryImpl({
                 })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
-              <Text style={styles.altTextControlLabel} accessible={false}>
-                <Trans>ALT</Trans>
-              </Text>
               {image.altText.length > 0 ? (
                 <FontAwesomeIcon
                   icon="check"
                   size={10}
-                  style={{color: colors.green3}}
+                  style={{color: t.palette.white}}
+                />
+              ) : (
+                <FontAwesomeIcon
+                  icon="plus"
+                  size={10}
+                  style={{color: t.palette.white}}
                 />
-              ) : undefined}
+              )}
+              <Text style={styles.altTextControlLabel} accessible={false}>
+                <Trans>ALT</Trans>
+              </Text>
             </TouchableOpacity>
             <View style={imageControlsStyle}>
               <TouchableOpacity
@@ -201,21 +208,28 @@ const GalleryInner = observer(function GalleryImpl({
           </View>
         ))}
       </View>
-      <View style={[styles.reminder]}>
-        <View style={[styles.infoIcon, pal.viewLight]}>
-          <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
-        </View>
-        <Text type="sm" style={[pal.textLight, s.flex1]}>
-          <Trans>
-            Alt text describes images for blind and low-vision users, and helps
-            give context to everyone.
-          </Trans>
-        </Text>
-      </View>
+      <AltTextReminder />
     </>
   ) : null
 })
 
+export function AltTextReminder() {
+  const t = useTheme()
+  return (
+    <View style={[styles.reminder]}>
+      <View style={[styles.infoIcon, t.atoms.bg_contrast_25]}>
+        <FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} />
+      </View>
+      <Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}>
+        <Trans>
+          Alt text describes images for blind and low-vision users, and helps
+          give context to everyone.
+        </Trans>
+      </Text>
+    </View>
+  )
+}
+
 const styles = StyleSheet.create({
   gallery: {
     flex: 1,
@@ -244,6 +258,7 @@ const styles = StyleSheet.create({
     paddingVertical: 3,
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
   },
   altTextControlLabel: {
     color: 'white',
diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index 644d4cab6..f00a15b3f 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -15,6 +15,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {Logo} from '#/view/icons/Logo'
+import {useKawaiiMode} from '../../../state/preferences/kawaii'
 import {Link} from '../util/Link'
 import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
 
@@ -43,10 +44,19 @@ function HomeHeaderLayoutDesktopAndTablet({
   const {hasSession} = useSession()
   const {_} = useLingui()
 
+  const kawaii = useKawaiiMode()
+
   return (
     <>
       {hasSession && (
-        <View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
+        <View
+          style={[
+            pal.view,
+            pal.border,
+            styles.bar,
+            styles.topBar,
+            kawaii && {paddingTop: 4, paddingBottom: 0},
+          ]}>
           <Link
             href="/settings/following-feed"
             hitSlop={10}
@@ -58,7 +68,7 @@ function HomeHeaderLayoutDesktopAndTablet({
               style={pal.textLight as FontAwesomeIconStyle}
             />
           </Link>
-          <Logo width={28} />
+          <Logo width={kawaii ? 60 : 28} />
           <Link
             href="/settings/saved-feeds"
             hitSlop={10}
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index dd439d475..7d34596d9 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,21 +1,22 @@
 import React from 'react'
-import {CenteredView} from '../util/Views'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
-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 {s} from 'lib/styles'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 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'
+import {s} from 'lib/styles'
+import {EmptyState} from '../util/EmptyState'
+import {ErrorMessage} from '../util/error/ErrorMessage'
 import {List, ListRef} from '../util/List'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import {usePalette} from '#/lib/hooks/usePalette'
+import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {CenteredView} from '../util/Views'
+import {FeedItem} from './FeedItem'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index f4bf3b1ac..a52818fd1 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,11 +1,14 @@
 import React, {useEffect, useRef} from 'react'
 import {StyleSheet, useWindowDimensions, View} from 'react-native'
+import {runOnJS} from 'react-native-reanimated'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {ScrollProvider} from '#/lib/ScrollContext'
 import {isAndroid, isNative, isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {
   sortThread,
   ThreadBlocked,
@@ -14,10 +17,7 @@ import {
   ThreadPost,
   usePostThreadQuery,
 } from '#/state/queries/post-thread'
-import {
-  useModerationOpts,
-  usePreferencesQuery,
-} from '#/state/queries/preferences'
+import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -276,8 +276,11 @@ export function PostThread({
       setMaxParents(n => n + PARENTS_CHUNK_SIZE)
     }
   }, [])
-  const onMomentumScrollEnd = bumpMaxParentsIfNeeded
   const onScrollToTop = bumpMaxParentsIfNeeded
+  const onMomentumEnd = React.useCallback(() => {
+    'worklet'
+    runOnJS(bumpMaxParentsIfNeeded)()
+  }, [bumpMaxParentsIfNeeded])
 
   const onEndReached = React.useCallback(() => {
     if (isFetching || posts.length < maxReplies) return
@@ -368,11 +371,11 @@ export function PostThread({
     ],
   )
 
-  if (error || !thread) {
+  if (!thread || !preferences || error) {
     return (
       <ListMaybePlaceholder
-        isLoading={(!preferences || !thread) && !error}
-        isError={!!error}
+        isLoading={!error}
+        isError={Boolean(error)}
         noEmpty
         onRetry={refetch}
         errorTitle={error?.title}
@@ -382,38 +385,39 @@ export function PostThread({
   }
 
   return (
-    <List
-      ref={ref}
-      data={posts}
-      renderItem={renderItem}
-      keyExtractor={keyExtractor}
-      onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
-      onStartReached={onStartReached}
-      onEndReached={onEndReached}
-      onEndReachedThreshold={2}
-      onMomentumScrollEnd={onMomentumScrollEnd}
-      onScrollToTop={onScrollToTop}
-      maintainVisibleContentPosition={
-        isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
-      }
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-      removeClippedSubviews={isAndroid ? false : undefined}
-      ListFooterComponent={
-        <ListFooter
-          // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
-          // initial render
-          isFetchingNextPage={isFetching}
-          error={cleanError(threadError)}
-          onRetry={refetch}
-          // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
-          // work without causing weird jumps on web or glitches on native
-          height={windowHeight - 200}
-        />
-      }
-      initialNumToRender={initialNumToRender}
-      windowSize={11}
-    />
+    <ScrollProvider onMomentumEnd={onMomentumEnd}>
+      <List
+        ref={ref}
+        data={posts}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
+        onStartReached={onStartReached}
+        onEndReached={onEndReached}
+        onEndReachedThreshold={2}
+        onScrollToTop={onScrollToTop}
+        maintainVisibleContentPosition={
+          isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
+        }
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+        removeClippedSubviews={isAndroid ? false : undefined}
+        ListFooterComponent={
+          <ListFooter
+            // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
+            // initial render
+            isFetchingNextPage={isFetching}
+            error={cleanError(threadError)}
+            onRetry={refetch}
+            // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
+            // work without causing weird jumps on web or glitches on native
+            height={windowHeight - 200}
+          />
+        }
+        initialNumToRender={initialNumToRender}
+        windowSize={11}
+      />
+    </ScrollProvider>
   )
 }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 564e37e7a..cfb8bd93f 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -15,8 +15,8 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {ThreadPost} from '#/state/queries/post-thread'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {useComposerControls} from '#/state/shell/composer'
 import {MAX_POST_LINES} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 546eb2821..1a7185cd9 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -14,7 +14,7 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useComposerControls} from '#/state/shell/composer'
 import {MAX_POST_LINES} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 90ab9b738..6c8978946 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -12,7 +12,7 @@ import {useQueryClient} from '@tanstack/react-query'
 import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {Shadow} from '#/state/cache/types'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useSession} from '#/state/session'
 import {usePalette} from 'lib/hooks/usePalette'
 import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index 4c9d164f7..bb5ad2a63 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -9,7 +9,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
 import {useAnalytics} from 'lib/analytics/analytics'
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 5729a43a5..84b401e63 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -5,15 +5,17 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useGate} from 'lib/statsig/statsig'
 import {addStyle} from 'lib/styles'
-import {isWeb} from 'platform/detection'
 import {FlatList_INTERNAL} from './Views'
 
 export type ListMethods = FlatList_INTERNAL
 export type ListProps<ItemT> = Omit<
   FlatListProps<ItemT>,
+  | 'onMomentumScrollBegin' // Use ScrollContext instead.
+  | 'onMomentumScrollEnd' // Use ScrollContext instead.
   | 'onScroll' // Use ScrollContext instead.
+  | 'onScrollBeginDrag' // Use ScrollContext instead.
+  | 'onScrollEndDrag' // Use ScrollContext instead.
   | 'refreshControl' // Pass refreshing and/or onRefresh instead.
   | 'contentOffset' // Pass headerOffset instead.
 > & {
@@ -21,6 +23,7 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
@@ -40,7 +43,6 @@ function ListImpl<ItemT>(
   const isScrolledDown = useSharedValue(false)
   const contextScrollHandlers = useScrollHandlers()
   const pal = usePalette('default')
-  const gate = useGate()
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     onScrolledDownChange?.(didScrollDown)
@@ -64,6 +66,11 @@ function ListImpl<ItemT>(
         }
       }
     },
+    // Note: adding onMomentumBegin here makes simulator scroll
+    // lag on Android. So either don't add it, or figure out why.
+    onMomentumEnd(e, ctx) {
+      contextScrollHandlers.onMomentumEnd?.(e, ctx)
+    },
   })
 
   let refreshControl
@@ -97,9 +104,6 @@ function ListImpl<ItemT>(
       scrollEventThrottle={1}
       style={style}
       ref={ref}
-      showsVerticalScrollIndicator={
-        isWeb || !gate('hide_vertical_scroll_indicators')
-      }
     />
   )
 }
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 936bac198..9bea2d795 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -1,11 +1,13 @@
-import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import React, {isValidElement, memo, startTransition, useRef} from 'react'
 import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
-import {addStyle} from 'lib/styles'
+import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
+
+import {batchedUpdates} from '#/lib/batchedUpdates'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {useScrollHandlers} from '#/lib/ScrollContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {batchedUpdates} from '#/lib/batchedUpdates'
+import {addStyle} from 'lib/styles'
 
 export type ListMethods = any // TODO: Better types.
 export type ListProps<ItemT> = Omit<
@@ -19,6 +21,7 @@ export type ListProps<ItemT> = Omit<
   refreshing?: boolean
   onRefresh?: () => void
   desktopFixedHeight: any // TODO: Better types.
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
@@ -26,12 +29,15 @@ function ListImpl<ItemT>(
   {
     ListHeaderComponent,
     ListFooterComponent,
+    containWeb,
     contentContainerStyle,
     data,
     desktopFixedHeight,
     headerOffset,
     keyExtractor,
     refreshing: _unsupportedRefreshing,
+    onStartReached,
+    onStartReachedThreshold = 0,
     onEndReached,
     onEndReachedThreshold = 0,
     onRefresh: _unsupportedOnRefresh,
@@ -80,14 +86,88 @@ function ListImpl<ItemT>(
     })
   }
 
-  const nativeRef = React.useRef(null)
+  const getScrollableNode = React.useCallback(() => {
+    if (containWeb) {
+      const element = nativeRef.current as HTMLDivElement | null
+      if (!element) return
+
+      return {
+        get scrollWidth() {
+          return element.scrollWidth
+        },
+        get scrollHeight() {
+          return element.scrollHeight
+        },
+        get clientWidth() {
+          return element.clientWidth
+        },
+        get clientHeight() {
+          return element.clientHeight
+        },
+        get scrollY() {
+          return element.scrollTop
+        },
+        get scrollX() {
+          return element.scrollLeft
+        },
+        scrollTo(options?: ScrollToOptions) {
+          element.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          element.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          element.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          element.removeEventListener(event, handler)
+        },
+      }
+    } else {
+      return {
+        get scrollWidth() {
+          return document.documentElement.scrollWidth
+        },
+        get scrollHeight() {
+          return document.documentElement.scrollHeight
+        },
+        get clientWidth() {
+          return window.innerWidth
+        },
+        get clientHeight() {
+          return window.innerHeight
+        },
+        get scrollY() {
+          return window.scrollY
+        },
+        get scrollX() {
+          return window.scrollX
+        },
+        scrollTo(options: ScrollToOptions) {
+          window.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          window.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          window.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          window.removeEventListener(event, handler)
+        },
+      }
+    }
+  }, [containWeb])
+
+  const nativeRef = React.useRef<HTMLDivElement>(null)
   React.useImperativeHandle(
     ref,
     () =>
       ({
         scrollToTop() {
-          window.scrollTo({top: 0})
+          getScrollableNode()?.scrollTo({top: 0})
         },
+
         scrollToOffset({
           animated,
           offset,
@@ -95,46 +175,74 @@ function ListImpl<ItemT>(
           animated: boolean
           offset: number
         }) {
-          window.scrollTo({
+          getScrollableNode()?.scrollTo({
             left: 0,
             top: offset,
             behavior: animated ? 'smooth' : 'instant',
           })
         },
+        scrollToEnd({animated = true}: {animated?: boolean}) {
+          const element = getScrollableNode()
+          element?.scrollTo({
+            left: 0,
+            top: element.scrollHeight,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
       } as any), // TODO: Better types.
-    [],
+    [getScrollableNode],
   )
 
-  // --- onContentSizeChange ---
+  // --- onContentSizeChange, maintainVisibleContentPosition ---
   const containerRef = useRef(null)
   useResizeObserver(containerRef, onContentSizeChange)
 
   // --- onScroll ---
   const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
-  const handleWindowScroll = useNonReactiveCallback(() => {
-    if (isInsideVisibleTree) {
-      contextScrollHandlers.onScroll?.(
-        {
-          contentOffset: {
-            x: Math.max(0, window.scrollX),
-            y: Math.max(0, window.scrollY),
-          },
-        } as any, // TODO: Better types.
-        null as any,
-      )
-    }
+  const handleScroll = useNonReactiveCallback(() => {
+    if (!isInsideVisibleTree) return
+
+    const element = getScrollableNode()
+    contextScrollHandlers.onScroll?.(
+      {
+        contentOffset: {
+          x: Math.max(0, element?.scrollX ?? 0),
+          y: Math.max(0, element?.scrollY ?? 0),
+        },
+        layoutMeasurement: {
+          width: element?.clientWidth,
+          height: element?.clientHeight,
+        },
+        contentSize: {
+          width: element?.scrollWidth,
+          height: element?.scrollHeight,
+        },
+      } as Exclude<
+        ReanimatedScrollEvent,
+        | 'velocity'
+        | 'eventName'
+        | 'zoomScale'
+        | 'targetContentOffset'
+        | 'contentInset'
+      >,
+      null as any,
+    )
   })
+
   React.useEffect(() => {
     if (!isInsideVisibleTree) {
       // Prevents hidden tabs from firing scroll events.
       // Only one list is expected to be firing these at a time.
       return
     }
-    window.addEventListener('scroll', handleWindowScroll)
+
+    const element = getScrollableNode()
+
+    element?.addEventListener('scroll', handleScroll)
     return () => {
-      window.removeEventListener('scroll', handleWindowScroll)
+      element?.removeEventListener('scroll', handleScroll)
     }
-  }, [isInsideVisibleTree, handleWindowScroll])
+  }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
 
   // --- onScrolledDownChange ---
   const isScrolledDown = useRef(false)
@@ -148,6 +256,17 @@ function ListImpl<ItemT>(
     }
   }
 
+  // --- onStartReached ---
+  const onHeadVisibilityChange = useNonReactiveCallback(
+    (isHeadVisible: boolean) => {
+      if (isHeadVisible) {
+        onStartReached?.({
+          distanceFromStart: onStartReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
   // --- onEndReached ---
   const onTailVisibilityChange = useNonReactiveCallback(
     (isTailVisible: boolean) => {
@@ -160,7 +279,17 @@ function ListImpl<ItemT>(
   )
 
   return (
-    <View {...props} style={style} ref={nativeRef}>
+    <View
+      {...props}
+      style={[
+        style,
+        containWeb && {
+          flex: 1,
+          // @ts-expect-error web only
+          'overflow-y': 'scroll',
+        },
+      ]}
+      ref={nativeRef as any}>
       <Visibility
         onVisibleChange={setIsInsideVisibleTree}
         style={
@@ -178,9 +307,17 @@ function ListImpl<ItemT>(
           pal.border,
         ]}>
         <Visibility
+          root={containWeb ? nativeRef : null}
           onVisibleChange={handleAboveTheFoldVisibleChange}
           style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
         />
+        {onStartReached && (
+          <Visibility
+            root={containWeb ? nativeRef : null}
+            onVisibleChange={onHeadVisibilityChange}
+            topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
+          />
+        )}
         {header}
         {(data as Array<ItemT>).map((item, index) => (
           <Row<ItemT>
@@ -193,8 +330,9 @@ function ListImpl<ItemT>(
         ))}
         {onEndReached && (
           <Visibility
-            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            root={containWeb ? nativeRef : null}
             onVisibleChange={onTailVisibilityChange}
+            bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
           />
         )}
         {footer}
@@ -255,11 +393,15 @@ let Row = function RowImpl<ItemT>({
 Row = React.memo(Row)
 
 let Visibility = ({
+  root,
   topMargin = '0px',
+  bottomMargin = '0px',
   onVisibleChange,
   style,
 }: {
+  root?: React.RefObject<HTMLDivElement> | null
   topMargin?: string
+  bottomMargin?: string
   onVisibleChange: (isVisible: boolean) => void
   style?: ViewProps['style']
 }): React.ReactNode => {
@@ -281,14 +423,15 @@ let Visibility = ({
 
   React.useEffect(() => {
     const observer = new IntersectionObserver(handleIntersection, {
-      rootMargin: `${topMargin} 0px 0px 0px`,
+      root: root?.current ?? null,
+      rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
     })
     const tail: Element | null = tailRef.current!
     observer.observe(tail)
     return () => {
       observer.unobserve(tail)
     }
-  }, [handleIntersection, topMargin])
+  }, [bottomMargin, handleIntersection, topMargin, root])
 
   return (
     <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 01b8a954d..f45229dc4 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,11 +1,12 @@
 import React, {useCallback, useEffect} from 'react'
+import {NativeScrollEvent} from 'react-native'
+import {interpolate, useSharedValue} from 'react-native-reanimated'
 import EventEmitter from 'eventemitter3'
+
 import {ScrollProvider} from '#/lib/ScrollContext'
-import {NativeScrollEvent} from 'react-native'
-import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
+import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
 import {isNative, isWeb} from 'platform/detection'
-import {useSharedValue, interpolate} from 'react-native-reanimated'
 
 const WEB_HIDE_SHELL_THRESHOLD = 200
 
@@ -32,6 +33,31 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     }
   })
 
+  const snapToClosestState = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      if (isNative) {
+        if (startDragOffset.value === null) {
+          return
+        }
+        const didScrollDown = e.contentOffset.y > startDragOffset.value
+        startDragOffset.value = null
+        startMode.value = null
+        if (e.contentOffset.y < headerHeight.value) {
+          // If we're close to the top, show the shell.
+          setMode(false)
+        } else if (didScrollDown) {
+          // Showing the bar again on scroll down feels annoying, so don't.
+          setMode(true)
+        } else {
+          // Snap to whichever state is the closest.
+          setMode(Math.round(mode.value) === 1)
+        }
+      }
+    },
+    [startDragOffset, startMode, setMode, mode, headerHeight],
+  )
+
   const onBeginDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
@@ -47,18 +73,24 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (e: NativeScrollEvent) => {
       'worklet'
       if (isNative) {
-        startDragOffset.value = null
-        startMode.value = null
-        if (e.contentOffset.y < headerHeight.value / 2) {
-          // If we're close to the top, show the shell.
-          setMode(false)
-        } else {
-          // Snap to whichever state is the closest.
-          setMode(Math.round(mode.value) === 1)
+        if (e.velocity && e.velocity.y !== 0) {
+          // If we detect a velocity, wait for onMomentumEnd to snap.
+          return
         }
+        snapToClosestState(e)
       }
     },
-    [startDragOffset, startMode, setMode, mode, headerHeight],
+    [snapToClosestState],
+  )
+
+  const onMomentumEnd = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      if (isNative) {
+        snapToClosestState(e)
+      }
+    },
+    [snapToClosestState],
   )
 
   const onScroll = useCallback(
@@ -119,7 +151,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     <ScrollProvider
       onBeginDrag={onBeginDrag}
       onEndDrag={onEndDrag}
-      onScroll={onScroll}>
+      onScroll={onScroll}
+      onMomentumEnd={onMomentumEnd}>
       {children}
     </ScrollProvider>
   )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index db16ff066..e7ce18535 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {niceDate} from 'lib/strings/time'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {isAndroid, isWeb} from 'platform/detection'
+import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {TextLinkOnWebOnly} from './Link'
 import {Text} from './text/Text'
 import {TimeElapsed} from './TimeElapsed'
@@ -58,37 +59,39 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
           />
         </View>
       )}
-      <Text
-        numberOfLines={1}
-        style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
-        <TextLinkOnWebOnly
-          type={opts.displayNameType || 'lg-bold'}
-          style={[pal.text]}
-          lineHeight={1.2}
-          disableMismatchWarning
-          text={
-            <>
-              {sanitizeDisplayName(
-                displayName,
-                opts.moderation?.ui('displayName'),
-              )}
-            </>
-          }
-          href={profileLink}
-          onBeforePress={onBeforePress}
-          onPointerEnter={onPointerEnter}
-        />
-        <TextLinkOnWebOnly
-          type="md"
-          disableMismatchWarning
-          style={[pal.textLight, {flexShrink: 4}]}
-          text={'\xa0' + sanitizeHandle(handle, '@')}
-          href={profileLink}
-          onBeforePress={onBeforePress}
-          onPointerEnter={onPointerEnter}
-          anchorNoUnderline
-        />
-      </Text>
+      <ProfileHoverCard inline did={opts.author.did}>
+        <Text
+          numberOfLines={1}
+          style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
+          <TextLinkOnWebOnly
+            type={opts.displayNameType || 'lg-bold'}
+            style={[pal.text]}
+            lineHeight={1.2}
+            disableMismatchWarning
+            text={
+              <>
+                {sanitizeDisplayName(
+                  displayName,
+                  opts.moderation?.ui('displayName'),
+                )}
+              </>
+            }
+            href={profileLink}
+            onBeforePress={onBeforePress}
+            onPointerEnter={onPointerEnter}
+          />
+          <TextLinkOnWebOnly
+            type="md"
+            disableMismatchWarning
+            style={[pal.textLight, {flexShrink: 4}]}
+            text={'\xa0' + sanitizeHandle(handle, '@')}
+            href={profileLink}
+            onBeforePress={onBeforePress}
+            onPointerEnter={onPointerEnter}
+            anchorNoUnderline
+          />
+        </Text>
+      </ProfileHoverCard>
       {!isAndroid && (
         <Text
           type="md"
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index 6ea41b82b..a5d3a5372 100644
--- a/src/view/com/util/TimeElapsed.tsx
+++ b/src/view/com/util/TimeElapsed.tsx
@@ -3,21 +3,25 @@ import React from 'react'
 import {useTickEveryMinute} from '#/state/shell'
 import {ago} from 'lib/strings/time'
 
-// FIXME(dan): Figure out why the false positives
-
 export function TimeElapsed({
   timestamp,
   children,
+  timeToString = ago,
 }: {
   timestamp: string
   children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
+  timeToString?: (timeElapsed: string) => string
 }) {
   const tick = useTickEveryMinute()
-  const [timeElapsed, setTimeAgo] = React.useState(() => ago(timestamp))
+  const [timeElapsed, setTimeAgo] = React.useState(() =>
+    timeToString(timestamp),
+  )
 
-  React.useEffect(() => {
-    setTimeAgo(ago(timestamp))
-  }, [timestamp, setTimeAgo, tick])
+  const [prevTick, setPrevTick] = React.useState(tick)
+  if (prevTick !== tick) {
+    setPrevTick(tick)
+    setTimeAgo(timeToString(timestamp))
+  }
 
   return children({timeElapsed})
 }
diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx
index 75f2b5081..2984a2d2d 100644
--- a/src/view/com/util/Views.jsx
+++ b/src/view/com/util/Views.jsx
@@ -2,19 +2,11 @@ import React from 'react'
 import {View} from 'react-native'
 import Animated from 'react-native-reanimated'
 
-import {useGate} from 'lib/statsig/statsig'
-
 export const FlatList_INTERNAL = Animated.FlatList
 export function CenteredView(props) {
   return <View {...props} />
 }
 
 export function ScrollView(props) {
-  const gate = useGate()
-  return (
-    <Animated.ScrollView
-      {...props}
-      showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')}
-    />
-  )
+  return <Animated.ScrollView {...props} />
 }
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
index 94591d393..6668ac211 100644
--- a/src/view/com/util/forms/NativeDropdown.web.tsx
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -1,12 +1,13 @@
 import React from 'react'
+import {Pressable, StyleSheet, Text, View, ViewStyle} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
-import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+
+import {HITSLOP_10} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
-import {HITSLOP_10} from 'lib/constants'
 
 // Custom Dropdown Menu Components
 // ==
@@ -64,15 +65,9 @@ export function NativeDropdown({
   accessibilityHint,
   triggerStyle,
 }: React.PropsWithChildren<Props>) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const dropDownBackgroundColor =
-    theme.colorScheme === 'dark' ? pal.btn : pal.view
   const [open, setOpen] = React.useState(false)
   const buttonRef = React.useRef<HTMLButtonElement>(null)
   const menuRef = React.useRef<HTMLDivElement>(null)
-  const {borderColor: separatorColor} =
-    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
 
   React.useEffect(() => {
     function clickHandler(e: MouseEvent) {
@@ -114,14 +109,27 @@ export function NativeDropdown({
 
   return (
     <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
-      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+      <DropdownMenu.Trigger asChild>
         <Pressable
           ref={buttonRef as unknown as React.Ref<View>}
           testID={testID}
           accessibilityRole="button"
           accessibilityLabel={accessibilityLabel}
           accessibilityHint={accessibilityHint}
-          onPress={() => setOpen(o => !o)}
+          onPointerDown={e => {
+            // Prevent false positive that interpret mobile scroll as a tap.
+            // This requires the custom onPress handler below to compensate.
+            // https://github.com/radix-ui/primitives/issues/1912
+            e.preventDefault()
+          }}
+          onPress={() => {
+            if (window.event instanceof KeyboardEvent) {
+              // The onPointerDown hack above is not relevant to this press, so don't do anything.
+              return
+            }
+            // Compensate for the disabled onPointerDown above by triggering it manually.
+            setOpen(o => !o)
+          }}
           hitSlop={HITSLOP_10}
           style={triggerStyle}>
           {children}
@@ -129,53 +137,53 @@ export function NativeDropdown({
       </DropdownMenu.Trigger>
 
       <DropdownMenu.Portal>
-        <DropdownMenu.Content
-          ref={menuRef}
-          style={
-            StyleSheet.flatten([
-              styles.content,
-              dropDownBackgroundColor,
-            ]) as React.CSSProperties
-          }
-          loop>
-          {items.map((item, index) => {
-            if (item.label === 'separator') {
-              return (
-                <DropdownMenu.Separator
-                  key={getKey(item.label, index, item.testID)}
-                  style={
-                    StyleSheet.flatten([
-                      styles.separator,
-                      {backgroundColor: separatorColor},
-                    ]) as React.CSSProperties
-                  }
-                />
-              )
-            }
-            if (index > 1 && items[index - 1].label === 'separator') {
-              return (
-                <DropdownMenu.Group
-                  key={getKey(item.label, index, item.testID)}>
-                  <DropdownMenuItem
-                    key={getKey(item.label, index, item.testID)}
-                    onSelect={item.onPress}>
-                    <Text
-                      selectable={false}
-                      style={[pal.text, styles.itemTitle]}>
-                      {item.label}
-                    </Text>
-                    {item.icon && (
-                      <FontAwesomeIcon
-                        icon={item.icon.web}
-                        size={20}
-                        color={pal.colors.textLight}
-                      />
-                    )}
-                  </DropdownMenuItem>
-                </DropdownMenu.Group>
-              )
-            }
-            return (
+        <DropdownContent items={items} menuRef={menuRef} />
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+function DropdownContent({
+  items,
+  menuRef,
+}: {
+  items: DropdownItem[]
+  menuRef: React.RefObject<HTMLDivElement>
+}) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+  const {borderColor: separatorColor} =
+    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+  return (
+    <DropdownMenu.Content
+      ref={menuRef}
+      style={
+        StyleSheet.flatten([
+          styles.content,
+          dropDownBackgroundColor,
+        ]) as React.CSSProperties
+      }
+      loop>
+      {items.map((item, index) => {
+        if (item.label === 'separator') {
+          return (
+            <DropdownMenu.Separator
+              key={getKey(item.label, index, item.testID)}
+              style={
+                StyleSheet.flatten([
+                  styles.separator,
+                  {backgroundColor: separatorColor},
+                ]) as React.CSSProperties
+              }
+            />
+          )
+        }
+        if (index > 1 && items[index - 1].label === 'separator') {
+          return (
+            <DropdownMenu.Group key={getKey(item.label, index, item.testID)}>
               <DropdownMenuItem
                 key={getKey(item.label, index, item.testID)}
                 onSelect={item.onPress}>
@@ -190,11 +198,27 @@ export function NativeDropdown({
                   />
                 )}
               </DropdownMenuItem>
-            )
-          })}
-        </DropdownMenu.Content>
-      </DropdownMenu.Portal>
-    </DropdownMenuRoot>
+            </DropdownMenu.Group>
+          )
+        }
+        return (
+          <DropdownMenuItem
+            key={getKey(item.label, index, item.testID)}
+            onSelect={item.onPress}>
+            <Text selectable={false} style={[pal.text, styles.itemTitle]}>
+              {item.label}
+            </Text>
+            {item.icon && (
+              <FontAwesomeIcon
+                icon={item.icon.web}
+                size={20}
+                color={pal.colors.textLight}
+              />
+            )}
+          </DropdownMenuItem>
+        )
+      })}
+    </DropdownMenu.Content>
   )
 }
 
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 32520182e..ac97f3da2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,6 @@
 import React, {memo} from 'react'
 import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
-import {setStringAsync} from 'expo-clipboard'
+import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyActorDefs,
   AppBskyFeedPost,
@@ -160,7 +160,7 @@ let PostDropdownBtn = ({
   const onCopyPostText = React.useCallback(() => {
     const str = richTextToString(richText, true)
 
-    setStringAsync(str)
+    Clipboard.setStringAsync(str)
     Toast.show(_(msg`Copied to clipboard`))
   }, [_, richText])
 
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 7de3b093a..f6d2c7a1b 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,9 +1,10 @@
-import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
-import {StyleSheet, Text, Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {Image} from 'expo-image'
+import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+
 import {isWeb} from 'platform/detection'
 
 type EventFunction = (index: number) => void
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1fe75c44e..b84c04b83 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -20,9 +20,11 @@ import {Text} from '../text/Text'
 export const ExternalLinkEmbed = ({
   link,
   style,
+  hideAlt,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
   style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
@@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({
   }, [link.uri, externalEmbedPrefs])
 
   if (embedPlayerParams?.source === 'tenor') {
-    return <GifEmbed params={embedPlayerParams} link={link} />
+    return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
   }
 
   return (
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 5d21ce064..286b57992 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -1,14 +1,18 @@
 import React from 'react'
-import {Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
 import {EmbedPlayerParams} from 'lib/strings/embed-player'
 import {useAutoplayDisabled} from 'state/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
 import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
 import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
 
@@ -82,9 +86,11 @@ function PlaybackControls({
 export function GifEmbed({
   params,
   link,
+  hideAlt,
 }: {
   params: EmbedPlayerParams
   link: AppBskyEmbedExternal.ViewExternal
+  hideAlt?: boolean
 }) {
   const {_} = useLingui()
   const autoplayDisabled = useAutoplayDisabled()
@@ -111,7 +117,8 @@ export function GifEmbed({
   }, [])
 
   return (
-    <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
+    <View
+      style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
       <View
         style={[
           a.rounded_sm,
@@ -133,9 +140,69 @@ export function GifEmbed({
           onPlayerStateChange={onPlayerStateChange}
           ref={playerRef}
           accessibilityHint={_(msg`Animated GIF`)}
-          accessibilityLabel={link.description.replace('ALT: ', '')}
+          accessibilityLabel={link.description.replace('Alt text: ', '')}
         />
+
+        {!hideAlt && link.description.startsWith('Alt text: ') && (
+          <AltText text={link.description.replace('Alt text: ', '')} />
+        )}
       </View>
     </View>
   )
 }
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text style={styles.alt} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={control.close}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    left: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
+})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index e0178f34b..0e19a6ccd 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {HITSLOP_20} from '#/lib/constants'
 import {s} from '#/lib/styles'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {InfoCircleIcon} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'
diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx
index 9212381a9..4de7c1613 100644
--- a/src/view/icons/Logo.tsx
+++ b/src/view/icons/Logo.tsx
@@ -1,15 +1,17 @@
 import React from 'react'
 import {StyleSheet, TextProps} from 'react-native'
 import Svg, {
-  Path,
   Defs,
   LinearGradient,
+  Path,
+  PathProps,
   Stop,
   SvgProps,
-  PathProps,
 } from 'react-native-svg'
+import {Image} from 'expo-image'
 
 import {colors} from '#/lib/styles'
+import {useKawaiiMode} from '#/state/preferences/kawaii'
 
 const ratio = 57 / 64
 
@@ -25,6 +27,25 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
   const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3
   // @ts-ignore it's fiiiiine
   const size = parseInt(rest.width || 32)
+
+  const isKawaii = useKawaiiMode()
+
+  if (isKawaii) {
+    return (
+      <Image
+        source={
+          size > 100
+            ? require('../../../assets/kawaii.png')
+            : require('../../../assets/kawaii_smol.png')
+        }
+        accessibilityLabel="Bluesky"
+        accessibilityHint=""
+        accessibilityIgnoresInvertColors
+        style={[{height: size, aspectRatio: 1.4}]}
+      />
+    )
+  }
+
   return (
     <Svg
       fill="none"
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index f88d500f9..442e33fd3 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -20,12 +20,12 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {moderationOptsOverrideContext} from '#/state/preferences/moderation-opts'
 import {FeedNotification} from '#/state/queries/notifications/types'
 import {
   groupNotifications,
   shouldFilterNotif,
 } from '#/state/queries/notifications/util'
-import {moderationOptsOverrideContext} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 3eaa1b875..665400f14 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -119,22 +119,24 @@ function HomeScreenReady({
   const gate = useGate()
   const mode = useMinimalShellMode()
   const {isMobile} = useWebMediaQueries()
-  React.useEffect(() => {
-    const listener = AppState.addEventListener('change', nextAppState => {
-      if (nextAppState === 'active') {
-        if (
-          isMobile &&
-          mode.value === 1 &&
-          gate('disable_min_shell_on_foregrounding_v2')
-        ) {
-          setMinimalShellMode(false)
+  useFocusEffect(
+    React.useCallback(() => {
+      const listener = AppState.addEventListener('change', nextAppState => {
+        if (nextAppState === 'active') {
+          if (
+            isMobile &&
+            mode.value === 1 &&
+            gate('disable_min_shell_on_foregrounding_v3')
+          ) {
+            setMinimalShellMode(false)
+          }
         }
+      })
+      return () => {
+        listener.remove()
       }
-    })
-    return () => {
-      listener.remove()
-    }
-  }, [setMinimalShellMode, mode, isMobile, gate])
+    }, [setMinimalShellMode, mode, isMobile, gate]),
+  )
 
   const onPageSelected = React.useCallback(
     (index: number) => {
diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index b7ce8cdd0..ebd9bb23e 100644
--- a/src/view/screens/ModerationBlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {useGate} from 'lib/statsig/statsig'
-import {isWeb} from 'platform/detection'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {CenteredView} from 'view/com/util/Views'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
@@ -38,7 +36,6 @@ export function ModerationBlockedAccounts({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {screen} = useAnalytics()
-  const gate = useGate()
 
   const [isPTRing, setIsPTRing] = React.useState(false)
   const {
@@ -168,9 +165,6 @@ export function ModerationBlockedAccounts({}: Props) {
           )}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
-          showsVerticalScrollIndicator={
-            isWeb || !gate('hide_vertical_scroll_indicators')
-          }
         />
       )}
     </CenteredView>
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 4d7ca6294..e395a3a5b 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {useGate} from 'lib/statsig/statsig'
-import {isWeb} from 'platform/detection'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 import {CenteredView} from 'view/com/util/Views'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
@@ -38,7 +36,6 @@ export function ModerationMutedAccounts({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {screen} = useAnalytics()
-  const gate = useGate()
 
   const [isPTRing, setIsPTRing] = React.useState(false)
   const {
@@ -167,9 +164,6 @@ export function ModerationMutedAccounts({}: Props) {
           )}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
-          showsVerticalScrollIndicator={
-            isWeb || !gate('hide_vertical_scroll_indicators')
-          }
         />
       )}
     </CenteredView>
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index eb9979823..4fa46a4cf 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useMemo} from 'react'
+import React, {useMemo} from 'react'
 import {StyleSheet} from 'react-native'
 import {
   AppBskyActorDefs,
@@ -11,12 +11,11 @@ import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {logEvent, useGate} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useLabelerInfoQuery} from '#/state/queries/labeler'
 import {resetProfilePostsQueries} from '#/state/queries/post-feed'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useAgent, useSession} from '#/state/session'
@@ -466,7 +465,6 @@ function ProfileScreenLoaded({
           accessibilityHint=""
         />
       )}
-      <TestGates />
     </ScreenHider>
   )
 }
@@ -525,77 +523,3 @@ const styles = StyleSheet.create({
     textAlign: 'center',
   },
 })
-
-const shouldExposeToGate2 = Math.random() < 0.2
-
-// --- Temporary: we're testing our Statsig setup ---
-let TestGates = React.memo(function TestGates() {
-  const gate = useGate()
-
-  useEffect(() => {
-    logEvent('test:all:always', {})
-    if (Math.random() < 0.2) {
-      logEvent('test:all:sometimes', {})
-    }
-    if (Math.random() < 0.1) {
-      logEvent('test:all:boosted_by_gate1', {
-        reason: 'base',
-      })
-    }
-    if (Math.random() < 0.1) {
-      logEvent('test:all:boosted_by_gate2', {
-        reason: 'base',
-      })
-    }
-    if (Math.random() < 0.1) {
-      logEvent('test:all:boosted_by_both', {
-        reason: 'base',
-      })
-    }
-  }, [])
-
-  return [
-    gate('test_gate_1') ? <TestGate1 /> : null,
-    shouldExposeToGate2 && gate('test_gate_2') ? <TestGate2 /> : null,
-  ]
-})
-
-function TestGate1() {
-  useEffect(() => {
-    logEvent('test:gate1:always', {})
-    if (Math.random() < 0.2) {
-      logEvent('test:gate1:sometimes', {})
-    }
-    if (Math.random() < 0.5) {
-      logEvent('test:all:boosted_by_gate1', {
-        reason: 'gate1',
-      })
-    }
-    if (Math.random() < 0.5) {
-      logEvent('test:all:boosted_by_both', {
-        reason: 'gate1',
-      })
-    }
-  }, [])
-  return null
-}
-
-function TestGate2() {
-  useEffect(() => {
-    logEvent('test:gate2:always', {})
-    if (Math.random() < 0.2) {
-      logEvent('test:gate2:sometimes', {})
-    }
-    if (Math.random() < 0.5) {
-      logEvent('test:all:boosted_by_gate2', {
-        reason: 'gate2',
-      })
-    }
-    if (Math.random() < 0.5) {
-      logEvent('test:all:boosted_by_both', {
-        reason: 'gate2',
-      })
-    }
-  }, [])
-  return null
-}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 1d93a9fd7..2902ccf5e 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -454,33 +454,29 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         },
       })
     }
-    if (isCurateList) {
+    if (isCurateList && (isBlocking || isMuting)) {
       items.push({label: 'separator'})
 
-      if (!isBlocking) {
+      if (isMuting) {
         items.push({
           testID: 'listHeaderDropdownMuteBtn',
-          label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
-          onPress: isMuting
-            ? onUnsubscribeMute
-            : subscribeMutePromptControl.open,
+          label: _(msg`Un-mute list`),
+          onPress: onUnsubscribeMute,
           icon: {
             ios: {
-              name: isMuting ? 'eye' : 'eye.slash',
+              name: 'eye',
             },
             android: '',
-            web: isMuting ? 'eye' : ['far', 'eye-slash'],
+            web: 'eye',
           },
         })
       }
 
-      if (!isMuting) {
+      if (isBlocking) {
         items.push({
           testID: 'listHeaderDropdownBlockBtn',
-          label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
-          onPress: isBlocking
-            ? onUnsubscribeBlock
-            : subscribeBlockPromptControl.open,
+          label: _(msg`Un-block list`),
+          onPress: onUnsubscribeBlock,
           icon: {
             ios: {
               name: 'person.fill.xmark',
@@ -508,9 +504,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     isBlocking,
     isMuting,
     onUnsubscribeMute,
-    subscribeMutePromptControl.open,
     onUnsubscribeBlock,
-    subscribeBlockPromptControl.open,
   ])
 
   const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index ee9e69433..9dd1c397f 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -25,11 +25,11 @@ import {NavigationProp} from '#/lib/routes/types'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
-import {isNative, isWeb} from '#/platform/detection'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useActorSearch} from '#/state/queries/actor-search'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {useSearchPostsQuery} from '#/state/queries/search-posts'
 import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
 import {useSession} from '#/state/session'
@@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
 import {List} from '#/view/com/util/List'
 import {Text} from '#/view/com/util/text/Text'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
-import {
-  MATCH_HANDLE,
-  SearchLinkCard,
-  SearchProfileCard,
-} from '#/view/shell/desktop/Search'
+import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
 import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {atoms as a} from '#/alf'
 
@@ -156,7 +152,7 @@ function useSuggestedFollows(): [
   return [items, onEndReached]
 }
 
-function SearchScreenSuggestedFollows() {
+let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
   const [suggestions, onEndReached] = useSuggestedFollows()
 
@@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() {
     </CenteredView>
   )
 }
+SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
 
 type SearchResultSlice =
   | {
@@ -192,7 +189,7 @@ type SearchResultSlice =
       key: string
     }
 
-function SearchScreenPostResults({
+let SearchScreenPostResults = ({
   query,
   sort,
   active,
@@ -200,7 +197,7 @@ function SearchScreenPostResults({
   query: string
   sort?: 'top' | 'latest'
   active: boolean
-}) {
+}): React.ReactNode => {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const [isPTR, setIsPTR] = React.useState(false)
@@ -298,14 +295,15 @@ function SearchScreenPostResults({
     </>
   )
 }
+SearchScreenPostResults = React.memo(SearchScreenPostResults)
 
-function SearchScreenUserResults({
+let SearchScreenUserResults = ({
   query,
   active,
 }: {
   query: string
   active: boolean
-}) {
+}): React.ReactNode => {
   const {_} = useLingui()
 
   const {data: results, isFetched} = useActorSearch({
@@ -334,8 +332,9 @@ function SearchScreenUserResults({
     <Loader />
   )
 }
+SearchScreenUserResults = React.memo(SearchScreenUserResults)
 
-export function SearchScreenInner({query}: {query?: string}) {
+let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) {
     </CenteredView>
   )
 }
+SearchScreenInner = React.memo(SearchScreenInner)
 
 export function SearchScreen(
   props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 ) {
   const navigation = useNavigation<NavigationProp>()
-  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 setMinimalShellMode = useSetMinimalShellMode()
   const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
 
@@ -527,23 +525,10 @@ export function SearchScreen(
 
   const onPressCancelSearch = React.useCallback(() => {
     scrollToTopWeb()
-
-    if (showAutocomplete) {
-      textInput.current?.blur()
-      setShowAutocomplete(false)
-      setSearchText(queryParam)
-    } else {
-      // If we just `setParams` and set `q` to an empty string, the URL still displays `q=`, which isn't pretty.
-      // However, `.replace()` on native has a "push" animation that we don't want. So we need to handle these
-      // differently.
-      if (isWeb) {
-        navigation.replace('Search', {})
-      } else {
-        setSearchText('')
-        navigation.setParams({q: ''})
-      }
-    }
-  }, [showAutocomplete, navigation, queryParam])
+    textInput.current?.blur()
+    setShowAutocomplete(false)
+    setSearchText(queryParam)
+  }, [queryParam])
 
   const onChangeText = React.useCallback(async (text: string) => {
     scrollToTopWeb()
@@ -597,20 +582,31 @@ export function SearchScreen(
     navigateToItem(searchText)
   }, [navigateToItem, searchText])
 
-  const handleHistoryItemClick = (item: string) => {
-    setSearchText(item)
-    navigateToItem(item)
-  }
+  const onAutocompleteResultPress = React.useCallback(() => {
+    if (isWeb) {
+      setShowAutocomplete(false)
+    } else {
+      textInput.current?.blur()
+    }
+  }, [])
 
-  const onSoftReset = React.useCallback(() => {
-    scrollToTopWeb()
-    onPressCancelSearch()
-  }, [onPressCancelSearch])
+  const handleHistoryItemClick = React.useCallback(
+    (item: string) => {
+      setSearchText(item)
+      navigateToItem(item)
+    },
+    [navigateToItem],
+  )
 
-  const queryMaybeHandle = React.useMemo(() => {
-    const match = MATCH_HANDLE.exec(queryParam)
-    return match && match[1]
-  }, [queryParam])
+  const onSoftReset = React.useCallback(() => {
+    if (isWeb) {
+      // Empty params resets the URL to be /search rather than /search?q=
+      navigation.replace('Search', {})
+    } else {
+      setSearchText('')
+      navigation.setParams({q: ''})
+    }
+  }, [navigation])
 
   useFocusEffect(
     React.useCallback(() => {
@@ -619,15 +615,19 @@ export function SearchScreen(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
-  const handleRemoveHistoryItem = (itemToRemove: string) => {
-    const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
-    setSearchHistory(updatedHistory)
-    AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch(
-      e => {
+  const handleRemoveHistoryItem = React.useCallback(
+    (itemToRemove: string) => {
+      const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
+      setSearchHistory(updatedHistory)
+      AsyncStorage.setItem(
+        'searchHistory',
+        JSON.stringify(updatedHistory),
+      ).catch(e => {
         logger.error('Failed to update search history', {message: e})
-      },
-    )
-  }
+      })
+    },
+    [searchHistory],
+  )
 
   return (
     <View style={isWeb ? null : {flex: 1}}>
@@ -655,175 +655,269 @@ export function SearchScreen(
             />
           </Pressable>
         )}
-
-        <View
-          style={[
-            {backgroundColor: pal.colors.backgroundLight},
-            styles.headerSearchContainer,
-          ]}>
-          <MagnifyingGlassIcon
-            style={[pal.icon, styles.headerSearchIcon]}
-            size={21}
-          />
-          <TextInput
-            testID="searchTextInput"
-            ref={textInput}
-            placeholder={_(msg`Search`)}
-            placeholderTextColor={pal.colors.textLight}
-            selectTextOnFocus={isNative}
-            returnKeyType="search"
-            value={searchText}
-            style={[pal.text, styles.headerSearchInput]}
-            keyboardAppearance={theme.colorScheme}
-            onFocus={() => {
-              if (isWeb) {
-                // Prevent a jump on iPad by ensuring that
-                // the initial focused render has no result list.
-                requestAnimationFrame(() => {
-                  setShowAutocomplete(true)
-                })
-              } else {
-                setShowAutocomplete(true)
-              }
-            }}
-            onChangeText={onChangeText}
-            onSubmitEditing={onSubmit}
-            autoFocus={false}
-            accessibilityRole="search"
-            accessibilityLabel={_(msg`Search`)}
-            accessibilityHint=""
-            autoCorrect={false}
-            autoComplete="off"
-            autoCapitalize="none"
-          />
-          {showAutocomplete ? (
-            <Pressable
-              testID="searchTextInputClearBtn"
-              onPress={onPressClearQuery}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Clear search query`)}
-              accessibilityHint=""
-              hitSlop={HITSLOP_10}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            </Pressable>
-          ) : undefined}
-        </View>
-
-        {(queryParam || showAutocomplete) && (
-          <View style={styles.headerCancelBtn}>
+        <SearchInputBox
+          textInput={textInput}
+          searchText={searchText}
+          showAutocomplete={showAutocomplete}
+          setShowAutocomplete={setShowAutocomplete}
+          onChangeText={onChangeText}
+          onSubmit={onSubmit}
+          onPressClearQuery={onPressClearQuery}
+        />
+        {showAutocomplete && (
+          <View style={[styles.headerCancelBtn]}>
             <Pressable
               onPress={onPressCancelSearch}
               accessibilityRole="button"
               hitSlop={HITSLOP_10}>
-              <Text style={[pal.text]}>
+              <Text style={pal.text}>
                 <Trans>Cancel</Trans>
               </Text>
             </Pressable>
           </View>
         )}
       </CenteredView>
+      <View
+        style={{
+          display: showAutocomplete ? 'flex' : 'none',
+          flex: 1,
+        }}>
+        {searchText.length > 0 ? (
+          <AutocompleteResults
+            isAutocompleteFetching={isAutocompleteFetching}
+            autocompleteData={autocompleteData}
+            searchText={searchText}
+            onSubmit={onSubmit}
+            onResultPress={onAutocompleteResultPress}
+          />
+        ) : (
+          <SearchHistory
+            searchHistory={searchHistory}
+            onItemClick={handleHistoryItemClick}
+            onRemoveItemClick={handleRemoveHistoryItem}
+          />
+        )}
+      </View>
+      <View
+        style={{
+          display: showAutocomplete ? 'none' : 'flex',
+          flex: 1,
+        }}>
+        <SearchScreenInner query={queryParam} />
+      </View>
+    </View>
+  )
+}
 
-      {showAutocomplete && searchText.length > 0 ? (
-        <>
-          {(isAutocompleteFetching && !autocompleteData?.length) ||
-          !moderationOpts ? (
-            <Loader />
-          ) : (
-            <ScrollView
-              style={{height: '100%'}}
-              // @ts-ignore web only -prf
-              dataSet={{stableGutters: '1'}}
-              keyboardShouldPersistTaps="handled"
-              keyboardDismissMode="on-drag">
-              <SearchLinkCard
-                label={_(msg`Search for "${searchText}"`)}
-                onPress={isNative ? onSubmit : undefined}
-                to={
-                  isNative
-                    ? undefined
-                    : `/search?q=${encodeURIComponent(searchText)}`
-                }
-                style={{borderBottomWidth: 1}}
-              />
-
-              {queryMaybeHandle ? (
-                <SearchLinkCard
-                  label={_(msg`Go to @${queryMaybeHandle}`)}
-                  to={`/profile/${queryMaybeHandle}`}
-                />
-              ) : null}
-
-              {autocompleteData?.map(item => (
-                <SearchProfileCard
-                  key={item.did}
-                  profile={item}
-                  moderation={moderateProfile(item, moderationOpts)}
-                  onPress={() => {
-                    if (isWeb) {
-                      setShowAutocomplete(false)
-                    } else {
-                      textInput.current?.blur()
-                    }
-                  }}
-                />
-              ))}
-
-              <View style={{height: 200}} />
-            </ScrollView>
-          )}
-        </>
-      ) : !queryParam && showAutocomplete ? (
-        <CenteredView
-          sideBorders={isTabletOrDesktop}
+let SearchInputBox = ({
+  textInput,
+  searchText,
+  showAutocomplete,
+  setShowAutocomplete,
+  onChangeText,
+  onSubmit,
+  onPressClearQuery,
+}: {
+  textInput: React.RefObject<TextInput>
+  searchText: string
+  showAutocomplete: boolean
+  setShowAutocomplete: (show: boolean) => void
+  onChangeText: (text: string) => void
+  onSubmit: () => void
+  onPressClearQuery: () => void
+}): React.ReactNode => {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const theme = useTheme()
+  return (
+    <Pressable
+      // This only exists only for extra hitslop so don't expose it to the a11y tree.
+      accessible={false}
+      focusable={false}
+      // @ts-ignore web-only
+      tabIndex={-1}
+      style={[
+        {backgroundColor: pal.colors.backgroundLight},
+        styles.headerSearchContainer,
+        isWeb && {
+          // @ts-ignore web only
+          cursor: 'default',
+        },
+      ]}
+      onPress={() => {
+        textInput.current?.focus()
+      }}>
+      <MagnifyingGlassIcon
+        style={[pal.icon, styles.headerSearchIcon]}
+        size={21}
+      />
+      <TextInput
+        testID="searchTextInput"
+        ref={textInput}
+        placeholder={_(msg`Search`)}
+        placeholderTextColor={pal.colors.textLight}
+        returnKeyType="search"
+        value={searchText}
+        style={[pal.text, styles.headerSearchInput]}
+        keyboardAppearance={theme.colorScheme}
+        selectTextOnFocus={isNative}
+        onFocus={() => {
+          if (isWeb) {
+            // Prevent a jump on iPad by ensuring that
+            // the initial focused render has no result list.
+            requestAnimationFrame(() => {
+              setShowAutocomplete(true)
+            })
+          } else {
+            setShowAutocomplete(true)
+            if (isIOS) {
+              // We rely on selectTextOnFocus, but it's broken on iOS:
+              // https://github.com/facebook/react-native/issues/41988
+              textInput.current?.setSelection(0, searchText.length)
+              // We still rely on selectTextOnFocus for it to be instant on Android.
+            }
+          }
+        }}
+        onChangeText={onChangeText}
+        onSubmitEditing={onSubmit}
+        autoFocus={false}
+        accessibilityRole="search"
+        accessibilityLabel={_(msg`Search`)}
+        accessibilityHint=""
+        autoCorrect={false}
+        autoComplete="off"
+        autoCapitalize="none"
+      />
+      {showAutocomplete && searchText.length > 0 && (
+        <Pressable
+          testID="searchTextInputClearBtn"
+          onPress={onPressClearQuery}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Clear search query`)}
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}>
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={pal.textLight as FontAwesomeIconStyle}
+          />
+        </Pressable>
+      )}
+    </Pressable>
+  )
+}
+SearchInputBox = React.memo(SearchInputBox)
+
+let AutocompleteResults = ({
+  isAutocompleteFetching,
+  autocompleteData,
+  searchText,
+  onSubmit,
+  onResultPress,
+}: {
+  isAutocompleteFetching: boolean
+  autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
+  searchText: string
+  onSubmit: () => void
+  onResultPress: () => void
+}): React.ReactNode => {
+  const moderationOpts = useModerationOpts()
+  const {_} = useLingui()
+  return (
+    <>
+      {(isAutocompleteFetching && !autocompleteData?.length) ||
+      !moderationOpts ? (
+        <Loader />
+      ) : (
+        <ScrollView
+          style={{height: '100%'}}
           // @ts-ignore web only -prf
-          style={{
-            height: isWeb ? '100vh' : undefined,
-          }}>
-          <View style={styles.searchHistoryContainer}>
-            {searchHistory.length > 0 && (
-              <View style={styles.searchHistoryContent}>
-                <Text style={[pal.text, styles.searchHistoryTitle]}>
-                  <Trans>Recent Searches</Trans>
-                </Text>
-                {searchHistory.map((historyItem, index) => (
-                  <View
-                    key={index}
-                    style={[
-                      a.flex_row,
-                      a.mt_md,
-                      a.justify_center,
-                      a.justify_between,
-                    ]}>
-                    <Pressable
-                      accessibilityRole="button"
-                      onPress={() => handleHistoryItemClick(historyItem)}
-                      style={[a.flex_1, a.py_sm]}>
-                      <Text style={pal.text}>{historyItem}</Text>
-                    </Pressable>
-                    <Pressable
-                      accessibilityRole="button"
-                      onPress={() => handleRemoveHistoryItem(historyItem)}
-                      style={[a.px_md, a.py_xs, a.justify_center]}>
-                      <FontAwesomeIcon
-                        icon="xmark"
-                        size={16}
-                        style={pal.textLight as FontAwesomeIconStyle}
-                      />
-                    </Pressable>
-                  </View>
-                ))}
+          dataSet={{stableGutters: '1'}}
+          keyboardShouldPersistTaps="handled"
+          keyboardDismissMode="on-drag">
+          <SearchLinkCard
+            label={_(msg`Search for "${searchText}"`)}
+            onPress={isNative ? onSubmit : undefined}
+            to={
+              isNative
+                ? undefined
+                : `/search?q=${encodeURIComponent(searchText)}`
+            }
+            style={{borderBottomWidth: 1}}
+          />
+          {autocompleteData?.map(item => (
+            <SearchProfileCard
+              key={item.did}
+              profile={item}
+              moderation={moderateProfile(item, moderationOpts)}
+              onPress={onResultPress}
+            />
+          ))}
+          <View style={{height: 200}} />
+        </ScrollView>
+      )}
+    </>
+  )
+}
+AutocompleteResults = React.memo(AutocompleteResults)
+
+function SearchHistory({
+  searchHistory,
+  onItemClick,
+  onRemoveItemClick,
+}: {
+  searchHistory: string[]
+  onItemClick: (item: string) => void
+  onRemoveItemClick: (item: string) => void
+}) {
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const pal = usePalette('default')
+  return (
+    <CenteredView
+      sideBorders={isTabletOrDesktop}
+      // @ts-ignore web only -prf
+      style={{
+        height: isWeb ? '100vh' : undefined,
+      }}>
+      <View style={styles.searchHistoryContainer}>
+        {searchHistory.length > 0 && (
+          <View style={styles.searchHistoryContent}>
+            <Text style={[pal.text, styles.searchHistoryTitle]}>
+              <Trans>Recent Searches</Trans>
+            </Text>
+            {searchHistory.map((historyItem, index) => (
+              <View
+                key={index}
+                style={[
+                  a.flex_row,
+                  a.mt_md,
+                  a.justify_center,
+                  a.justify_between,
+                ]}>
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={() => onItemClick(historyItem)}
+                  hitSlop={HITSLOP_10}
+                  style={[a.flex_1, a.py_sm]}>
+                  <Text style={pal.text}>{historyItem}</Text>
+                </Pressable>
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={() => onRemoveItemClick(historyItem)}
+                  hitSlop={HITSLOP_10}
+                  style={[a.px_md, a.py_xs, a.justify_center]}>
+                  <FontAwesomeIcon
+                    icon="xmark"
+                    size={16}
+                    style={pal.textLight as FontAwesomeIconStyle}
+                  />
+                </Pressable>
               </View>
-            )}
+            ))}
           </View>
-        </CenteredView>
-      ) : (
-        <SearchScreenInner query={queryParam} />
-      )}
-    </View>
+        )}
+      </View>
+    </CenteredView>
   )
 }
 
@@ -874,6 +968,9 @@ const styles = StyleSheet.create({
   },
   headerCancelBtn: {
     paddingLeft: 10,
+    alignSelf: 'center',
+    zIndex: -1,
+    elevation: -1, // For Android
   },
   tabBarContainer: {
     // @ts-ignore web only
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 6b5390c29..c3864e5a9 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -1,7 +1,5 @@
 import React from 'react'
 import {
-  ActivityIndicator,
-  Linking,
   Platform,
   Pressable,
   StyleSheet,
@@ -41,7 +39,7 @@ import {
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useAnalytics} from 'lib/analytics/analytics'
-import * as AppInfo from 'lib/app-info'
+import {appVersion, BUNDLE_DATE, bundleInfo} from 'lib/app-info'
 import {STATUS_PAGE_URL} from 'lib/constants'
 import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
 import {useCustomPalette} from 'lib/hooks/useCustomPalette'
@@ -61,23 +59,40 @@ import {Text} from 'view/com/util/text/Text'
 import * as Toast from 'view/com/util/Toast'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {ScrollView} from 'view/com/util/Views'
+import {useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
 import {navigate, resetToTab} from '#/Navigation'
 import {Email2FAToggle} from './Email2FAToggle'
 import {ExportCarDialog} from './ExportCarDialog'
 
-function SettingsAccountCard({account}: {account: SessionAccount}) {
+function SettingsAccountCard({
+  account,
+  pendingDid,
+  onPressSwitchAccount,
+}: {
+  account: SessionAccount
+  pendingDid: string | null
+  onPressSwitchAccount: (
+    account: SessionAccount,
+    logContext: 'Settings',
+  ) => void
+}) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {isSwitchingAccounts, currentAccount} = useSession()
+  const t = useTheme()
+  const {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={[
+        pal.view,
+        styles.linkCard,
+        account.did === pendingDid && t.atoms.bg_contrast_25,
+      ]}>
       <View style={styles.avi}>
         <UserAvatar
           size={40}
@@ -109,7 +124,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
           }}
           accessibilityRole="button"
           accessibilityLabel={_(msg`Sign out`)}
-          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
+          accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
+          activeOpacity={0.8}>
           <Text type="lg" style={pal.link}>
             <Trans>Sign out</Trans>
           </Text>
@@ -135,13 +151,12 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
       testID={`switchToAccountBtn-${account.handle}`}
       key={account.did}
       onPress={
-        isSwitchingAccounts
-          ? undefined
-          : () => onPressSwitchAccount(account, 'Settings')
+        pendingDid ? undefined : () => onPressSwitchAccount(account, 'Settings')
       }
       accessibilityRole="button"
       accessibilityLabel={_(msg`Switch to ${account.handle}`)}
-      accessibilityHint={_(msg`Switches the account you are logged in to`)}>
+      accessibilityHint={_(msg`Switches the account you are logged in to`)}
+      activeOpacity={0.8}>
       {contents}
     </TouchableOpacity>
   )
@@ -162,12 +177,14 @@ export function SettingsScreen({}: Props) {
   const {isMobile} = useWebMediaQueries()
   const {screen, track} = useAnalytics()
   const {openModal} = useModalControls()
-  const {isSwitchingAccounts, accounts, currentAccount} = useSession()
+  const {accounts, currentAccount} = useSession()
   const {mutate: clearPreferences} = useClearPreferencesMutation()
   const {setShowLoggedOut} = useLoggedOutViewControls()
   const closeAllActiveElements = useCloseAllActiveElements()
   const exportCarControl = useDialogControl()
   const birthdayControl = useDialogControl()
+  const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
+  const isSwitchingAccounts = !!pendingDid
 
   // const primaryBg = useCustomPalette<ViewStyle>({
   //   light: {backgroundColor: colors.blue0},
@@ -238,7 +255,7 @@ export function SettingsScreen({}: Props) {
 
   const onPressBuildInfo = React.useCallback(() => {
     setStringAsync(
-      `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
+      `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`,
     )
     Toast.show(_(msg`Copied build version to clipboard`))
   }, [_])
@@ -275,10 +292,6 @@ export function SettingsScreen({}: Props) {
     navigation.navigate('AccessibilitySettings')
   }, [navigation])
 
-  const onPressStatusPage = React.useCallback(() => {
-    Linking.openURL(STATUS_PAGE_URL)
-  }, [])
-
   const onPressBirthday = React.useCallback(() => {
     birthdayControl.open()
   }, [birthdayControl])
@@ -363,50 +376,53 @@ export function SettingsScreen({}: Props) {
             <View style={styles.spacer20} />
 
             {!currentAccount.emailConfirmed && <EmailConfirmationNotice />}
+
+            <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 pointerEvents={pendingDid ? 'none' : 'auto'}>
+              <SettingsAccountCard
+                account={currentAccount}
+                onPressSwitchAccount={onPressSwitchAccount}
+                pendingDid={pendingDid}
+              />
+            </View>
           </>
         ) : 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>
 
-        {isSwitchingAccounts ? (
-          <View style={[pal.view, styles.linkCard]}>
-            <ActivityIndicator />
-          </View>
-        ) : (
-          <SettingsAccountCard account={currentAccount!} />
-        )}
+        <View pointerEvents={pendingDid ? 'none' : 'auto'}>
+          {accounts
+            .filter(a => a.did !== currentAccount?.did)
+            .map(account => (
+              <SettingsAccountCard
+                key={account.did}
+                account={account}
+                onPressSwitchAccount={onPressSwitchAccount}
+                pendingDid={pendingDid}
+              />
+            ))}
 
-        {accounts
-          .filter(a => a.did !== currentAccount?.did)
-          .map(account => (
-            <SettingsAccountCard key={account.did} account={account} />
-          ))}
-
-        <TouchableOpacity
-          testID="switchToNewAccountBtn"
-          style={[
-            styles.linkCard,
-            pal.view,
-            isSwitchingAccounts && styles.dimmed,
-          ]}
-          onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Add account`)}
-          accessibilityHint={_(msg`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>
+          <TouchableOpacity
+            testID="switchToNewAccountBtn"
+            style={[styles.linkCard, pal.view]}
+            onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Add account`)}
+            accessibilityHint={_(msg`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>
+        </View>
 
         <View style={styles.spacer20} />
 
@@ -849,17 +865,9 @@ export function SettingsScreen({}: Props) {
             accessibilityRole="button"
             onPress={onPressBuildInfo}>
             <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
-              <Trans>Version {AppInfo.appVersion}</Trans>
-            </Text>
-          </TouchableOpacity>
-          <Text type="sm" style={[pal.textLight]}>
-            &nbsp; &middot; &nbsp;
-          </Text>
-          <TouchableOpacity
-            accessibilityRole="button"
-            onPress={onPressStatusPage}>
-            <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
-              <Trans>Status page</Trans>
+              <Trans>
+                Version {appVersion} {bundleInfo}
+              </Trans>
             </Text>
           </TouchableOpacity>
         </View>
@@ -881,6 +889,12 @@ export function SettingsScreen({}: Props) {
             href="https://bsky.social/about/support/privacy-policy"
             text={_(msg`Privacy Policy`)}
           />
+          <TextLink
+            type="md"
+            style={pal.link}
+            href={STATUS_PAGE_URL}
+            text={_(msg`Status Page`)}
+          />
         </View>
         <View style={s.footerSpacer} />
       </ScrollView>
@@ -1026,7 +1040,6 @@ const styles = StyleSheet.create({
   footer: {
     flex: 1,
     flexDirection: 'row',
-    alignItems: 'center',
     paddingLeft: 18,
   },
 })
diff --git a/src/view/screens/Storybook/ListContained.tsx b/src/view/screens/Storybook/ListContained.tsx
new file mode 100644
index 000000000..b3ea091f4
--- /dev/null
+++ b/src/view/screens/Storybook/ListContained.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {FlatList, View} from 'react-native'
+
+import {ScrollProvider} from 'lib/ScrollContext'
+import {List} from 'view/com/util/List'
+import {Button, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+export function ListContained() {
+  const [animated, setAnimated] = React.useState(false)
+  const ref = React.useRef<FlatList>(null)
+
+  const data = React.useMemo(() => {
+    return Array.from({length: 100}, (_, i) => ({
+      id: i,
+      text: `Message ${i}`,
+    }))
+  }, [])
+
+  return (
+    <>
+      <View style={{width: '100%', height: 300}}>
+        <ScrollProvider
+          onScroll={e => {
+            'worklet'
+            console.log(
+              JSON.stringify({
+                contentOffset: e.contentOffset,
+                layoutMeasurement: e.layoutMeasurement,
+                contentSize: e.contentSize,
+              }),
+            )
+          }}>
+          <List
+            data={data}
+            renderItem={item => {
+              return (
+                <View
+                  style={{
+                    padding: 10,
+                    borderBottomWidth: 1,
+                    borderBottomColor: 'rgba(0,0,0,0.1)',
+                  }}>
+                  <Text>{item.item.text}</Text>
+                </View>
+              )
+            }}
+            keyExtractor={item => item.id.toString()}
+            containWeb={true}
+            style={{flex: 1}}
+            onStartReached={() => {
+              console.log('Start Reached')
+            }}
+            onEndReached={() => {
+              console.log('End Reached (threshold of 2)')
+            }}
+            onEndReachedThreshold={2}
+            ref={ref}
+            disableVirtualization={true}
+          />
+        </ScrollProvider>
+      </View>
+
+      <View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}>
+        <Toggle.Item
+          name="a"
+          label="Click me"
+          value={animated}
+          onChange={() => setAnimated(prev => !prev)}>
+          <Toggle.Checkbox />
+          <Toggle.LabelText>Animated Scrolling</Toggle.LabelText>
+        </Toggle.Item>
+      </View>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to End"
+        onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}>
+        <ButtonText>Scroll to Top</ButtonText>
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to End"
+        onPress={() => ref.current?.scrollToEnd({animated})}>
+        <ButtonText>Scroll to End</ButtonText>
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to Offset 100"
+        onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}>
+        <ButtonText>Scroll to Offset 500</ButtonText>
+      </Button>
+    </>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 35a666601..282b3ff5c 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -1,8 +1,10 @@
 import React from 'react'
-import {View} from 'react-native'
+import {ScrollView, View} from 'react-native'
 
 import {useSetThemePrefs} from '#/state/shell'
-import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {isWeb} from 'platform/detection'
+import {CenteredView} from '#/view/com/util/Views'
+import {ListContained} from 'view/screens/Storybook/ListContained'
 import {atoms as a, ThemeProvider, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {Breakpoints} from './Breakpoints'
@@ -18,77 +20,111 @@ import {Theming} from './Theming'
 import {Typography} from './Typography'
 
 export function Storybook() {
+  if (isWeb) return <StorybookInner />
+
+  return (
+    <ScrollView>
+      <StorybookInner />
+    </ScrollView>
+  )
+}
+
+function StorybookInner() {
   const t = useTheme()
   const {setColorMode, setDarkTheme} = useSetThemePrefs()
+  const [showContainedList, setShowContainedList] = React.useState(false)
 
   return (
-    <ScrollView>
-      <CenteredView style={[t.atoms.bg]}>
-        <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
-          <View style={[a.flex_row, a.align_start, a.gap_md]}>
-            <Button
-              variant="outline"
-              color="primary"
-              size="small"
-              label='Set theme to "system"'
-              onPress={() => setColorMode('system')}>
-              <ButtonText>System</ButtonText>
-            </Button>
-            <Button
-              variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "light"'
-              onPress={() => setColorMode('light')}>
-              <ButtonText>Light</ButtonText>
-            </Button>
+    <CenteredView style={[t.atoms.bg]}>
+      <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
+        {!showContainedList ? (
+          <>
+            <View style={[a.flex_row, a.align_start, a.gap_md]}>
+              <Button
+                variant="outline"
+                color="primary"
+                size="small"
+                label='Set theme to "system"'
+                onPress={() => setColorMode('system')}>
+                <ButtonText>System</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "light"'
+                onPress={() => setColorMode('light')}>
+                <ButtonText>Light</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "dim"'
+                onPress={() => {
+                  setColorMode('dark')
+                  setDarkTheme('dim')
+                }}>
+                <ButtonText>Dim</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "dark"'
+                onPress={() => {
+                  setColorMode('dark')
+                  setDarkTheme('dark')
+                }}>
+                <ButtonText>Dark</ButtonText>
+              </Button>
+            </View>
+
+            <Dialogs />
+            <ThemeProvider theme="light">
+              <Theming />
+            </ThemeProvider>
+            <ThemeProvider theme="dim">
+              <Theming />
+            </ThemeProvider>
+            <ThemeProvider theme="dark">
+              <Theming />
+            </ThemeProvider>
+
+            <Typography />
+            <Spacing />
+            <Shadows />
+            <Buttons />
+            <Icons />
+            <Links />
+            <Forms />
+            <Dialogs />
+            <Menus />
+            <Breakpoints />
+
             <Button
               variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "dim"'
-              onPress={() => {
-                setColorMode('dark')
-                setDarkTheme('dim')
-              }}>
-              <ButtonText>Dim</ButtonText>
+              color="primary"
+              size="large"
+              label="Switch to Contained List"
+              onPress={() => setShowContainedList(true)}>
+              <ButtonText>Switch to Contained List</ButtonText>
             </Button>
+          </>
+        ) : (
+          <>
             <Button
               variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "dark"'
-              onPress={() => {
-                setColorMode('dark')
-                setDarkTheme('dark')
-              }}>
-              <ButtonText>Dark</ButtonText>
+              color="primary"
+              size="large"
+              label="Switch to Storybook"
+              onPress={() => setShowContainedList(false)}>
+              <ButtonText>Switch to Storybook</ButtonText>
             </Button>
-          </View>
-
-          <Dialogs />
-          <ThemeProvider theme="light">
-            <Theming />
-          </ThemeProvider>
-          <ThemeProvider theme="dim">
-            <Theming />
-          </ThemeProvider>
-          <ThemeProvider theme="dark">
-            <Theming />
-          </ThemeProvider>
-
-          <Typography />
-          <Spacing />
-          <Shadows />
-          <Buttons />
-          <Icons />
-          <Links />
-          <Forms />
-          <Dialogs />
-          <Menus />
-          <Breakpoints />
-        </View>
-      </CenteredView>
-    </ScrollView>
+            <ListContained />
+          </>
+        )}
+      </View>
+    </CenteredView>
   )
 }
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 8145fa408..d8e604ec3 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -18,6 +18,7 @@ import {useLingui} from '@lingui/react'
 import {StackActions, useNavigation} from '@react-navigation/native'
 
 import {emitSoftReset} from '#/state/events'
+import {useKawaiiMode} from '#/state/preferences/kawaii'
 import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 import {useProfileQuery} from '#/state/queries/profile'
 import {SessionAccount, useSession} from '#/state/session'
@@ -117,6 +118,7 @@ let DrawerContent = ({}: {}): React.ReactNode => {
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
   const {hasSession, currentAccount} = useSession()
+  const kawaii = useKawaiiMode()
 
   // events
   // =
@@ -262,6 +264,17 @@ let DrawerContent = ({}: {}): React.ReactNode => {
               href="https://bsky.social/about/support/privacy-policy"
               text={_(msg`Privacy Policy`)}
             />
+            {kawaii && (
+              <Text type="md" style={pal.textLight}>
+                Logo by{' '}
+                <TextLink
+                  type="md"
+                  href="/profile/sawaratsuki.bsky.social"
+                  text="@sawaratsuki.bsky.social"
+                  style={pal.link}
+                />
+              </Text>
+            )}
           </View>
 
           <View style={styles.smallSpacer} />
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index c1f498724..f0cd4f59a 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -1,22 +1,26 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {DesktopSearch} from './Search'
-import {DesktopFeeds} from './Feeds'
-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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useKawaiiMode} from '#/state/preferences/kawaii'
 import {useSession} from '#/state/session'
+import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {s} from 'lib/styles'
+import {TextLink} from 'view/com/util/Link'
+import {Text} from 'view/com/util/text/Text'
+import {DesktopFeeds} from './Feeds'
+import {DesktopSearch} from './Search'
 
 export function DesktopRightNav({routeName}: {routeName: string}) {
   const pal = usePalette('default')
   const {_} = useLingui()
   const {hasSession, currentAccount} = useSession()
 
+  const kawaii = useKawaiiMode()
+
   const {isTablet} = useWebMediaQueries()
   if (isTablet) {
     return null
@@ -90,6 +94,17 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
               text={_(msg`Help`)}
             />
           </View>
+          {kawaii && (
+            <Text type="md" style={[pal.textLight, {marginTop: 12}]}>
+              Logo by{' '}
+              <TextLink
+                type="md"
+                href="/profile/sawaratsuki.bsky.social"
+                text="@sawaratsuki.bsky.social"
+                style={pal.link}
+              />
+            </Text>
+          )}
         </View>
       </View>
     </View>
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 52f28cc63..3829a6c0b 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -21,8 +21,8 @@ import {makeProfileLink} from '#/lib/routes/links'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {s} from '#/lib/styles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {usePalette} from 'lib/hooks/usePalette'
 import {MagnifyingGlassIcon2} from 'lib/icons'
 import {NavigationProp} from 'lib/routes/types'
@@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {Text} from 'view/com/util/text/Text'
 
-export const MATCH_HANDLE =
-  /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
-
-export function SearchLinkCard({
+let SearchLinkCard = ({
   label,
   to,
   onPress,
@@ -44,7 +41,7 @@ export function SearchLinkCard({
   to?: string
   onPress?: () => void
   style?: ViewStyle
-}) {
+}): React.ReactNode => {
   const pal = usePalette('default')
 
   const inner = (
@@ -82,8 +79,10 @@ export function SearchLinkCard({
     </Link>
   )
 }
+SearchLinkCard = React.memo(SearchLinkCard)
+export {SearchLinkCard}
 
-export function SearchProfileCard({
+let SearchProfileCard = ({
   profile,
   moderation,
   onPress: onPressInner,
@@ -91,7 +90,7 @@ export function SearchProfileCard({
   profile: AppBskyActorDefs.ProfileViewBasic
   moderation: ModerationDecision
   onPress: () => void
-}) {
+}): React.ReactNode => {
   const pal = usePalette('default')
   const queryClient = useQueryClient()
 
@@ -144,6 +143,8 @@ export function SearchProfileCard({
     </Link>
   )
 }
+SearchProfileCard = React.memo(SearchProfileCard)
+export {SearchProfileCard}
 
 export function DesktopSearch() {
   const {_} = useLingui()
@@ -179,11 +180,6 @@ export function DesktopSearch() {
     setIsActive(false)
   }, [])
 
-  const queryMaybeHandle = React.useMemo(() => {
-    const match = MATCH_HANDLE.exec(query)
-    return match && match[1]
-  }, [query])
-
   return (
     <View style={[styles.container, pal.view]}>
       <View
@@ -239,19 +235,11 @@ export function DesktopSearch() {
                 label={_(msg`Search for "${query}"`)}
                 to={`/search?q=${encodeURIComponent(query)}`}
                 style={
-                  queryMaybeHandle || (autocompleteData?.length ?? 0) > 0
+                  (autocompleteData?.length ?? 0) > 0
                     ? {borderBottomWidth: 1}
                     : undefined
                 }
               />
-
-              {queryMaybeHandle ? (
-                <SearchLinkCard
-                  label={_(msg`Go to @${queryMaybeHandle}`)}
-                  to={`/profile/${queryMaybeHandle}`}
-                />
-              ) : null}
-
               {autocompleteData?.map(item => (
                 <SearchProfileCard
                   key={item.did}