about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--app.config.js5
-rw-r--r--assets/app-icons/icon_default_next.pngbin0 -> 497706 bytes
-rw-r--r--bskyweb/cmd/bskyweb/filters.go28
-rw-r--r--bskyweb/cmd/bskyweb/filters_test.go61
-rw-r--r--bskyweb/cmd/bskyweb/server.go4
-rw-r--r--bskyweb/templates/home.html1
-rw-r--r--bskyweb/templates/post.html1
-rw-r--r--bskyweb/templates/profile.html1
-rw-r--r--package.json6
-rw-r--r--patches/expo-image-picker+16.1.4.patch38
-rw-r--r--patches/expo-image-picker+16.1.4.patch.md5
-rw-r--r--src/Navigation.tsx2
-rw-r--r--src/components/BlockedGeoOverlay.tsx109
-rw-r--r--src/components/Dialog/index.web.tsx6
-rw-r--r--src/components/Dialog/shared.tsx5
-rw-r--r--src/components/FeedInterstitials.tsx323
-rw-r--r--src/components/Layout/const.ts2
-rw-r--r--src/components/Link.tsx88
-rw-r--r--src/components/MediaPreview.tsx8
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx2
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx16
-rw-r--r--src/components/Post/Embed/index.tsx12
-rw-r--r--src/components/ProfileCard.tsx37
-rw-r--r--src/components/dialogs/GifSelect.tsx14
-rw-r--r--src/components/dms/ConvoMenu.tsx40
-rw-r--r--src/components/dms/MessagesListHeader.tsx171
-rw-r--r--src/components/forms/TextField.tsx15
-rw-r--r--src/components/icons/Logo.tsx37
-rw-r--r--src/components/interstitials/TrendingVideos.tsx108
-rw-r--r--src/lib/constants.ts4
-rw-r--r--src/lib/custom-animations/AccordionAnimation.tsx77
-rw-r--r--src/lib/haptics.ts4
-rw-r--r--src/lib/media/picker.shared.ts6
-rw-r--r--src/lib/media/video/compress.ts13
-rw-r--r--src/lib/statsig/gates.ts2
-rw-r--r--src/lib/strings/helpers.ts4
-rw-r--r--src/locale/locales/en/messages.po461
-rw-r--r--src/logger/metrics.ts7
-rw-r--r--src/screens/Log.tsx128
-rw-r--r--src/screens/Moderation/index.tsx19
-rw-r--r--src/screens/PostThread/components/ThreadItemReadMore.tsx6
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx329
-rw-r--r--src/screens/Profile/Header/Shell.tsx2
-rw-r--r--src/screens/Profile/Header/SuggestedFollows.tsx45
-rw-r--r--src/screens/Settings/AppIconSettings/index.tsx16
-rw-r--r--src/screens/Settings/AppIconSettings/types.ts5
-rw-r--r--src/screens/Settings/AppIconSettings/useAppIconSets.ts10
-rw-r--r--src/screens/Settings/AppearanceSettings.tsx4
-rw-r--r--src/screens/Signup/StepHandle/index.tsx6
-rw-r--r--src/state/cache/post-shadow.ts15
-rw-r--r--src/state/feed-feedback.tsx5
-rw-r--r--src/state/geolocation.tsx56
-rw-r--r--src/state/queries/suggested-follows.ts15
-rw-r--r--src/state/queries/usePostThread/queryCache.ts29
-rw-r--r--src/state/queries/usePostThread/traversal.ts11
-rw-r--r--src/state/queries/usePostThread/types.ts4
-rw-r--r--src/storage/schema.ts1
-rw-r--r--src/view/com/composer/Composer.tsx111
-rw-r--r--src/view/com/composer/SelectMediaButton.tsx524
-rw-r--r--src/view/com/composer/photos/ImageAltTextDialog.tsx5
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx60
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx88
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx31
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx4
-rw-r--r--src/view/com/profile/FollowButton.tsx10
-rw-r--r--src/view/screens/Log.tsx116
-rw-r--r--src/view/shell/index.tsx18
-rw-r--r--src/view/shell/index.web.tsx27
-rw-r--r--yarn.lock31
71 files changed, 2273 insertions, 1183 deletions
diff --git a/.gitignore b/.gitignore
index 7233b3547..0bef5d88a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,6 +58,7 @@ buck-out/
 # Ruby / CocoaPods
 /ios/Pods/
 /vendor/bundle/
+Gemfile.lock
 
 # Testing
 coverage/
diff --git a/app.config.js b/app.config.js
index b30cdf0fe..8a0e6e48c 100644
--- a/app.config.js
+++ b/app.config.js
@@ -303,6 +303,11 @@ module.exports = function (_config) {
               android: './assets/app-icons/android_icon_default_dark.png',
               prerendered: true,
             },
+            next: {
+              ios: './assets/app-icons/icon_default_next.png',
+              android: './assets/app-icons/icon_default_next.png',
+              prerendered: true,
+            },
 
             /**
              * Bluesky+ core set
diff --git a/assets/app-icons/icon_default_next.png b/assets/app-icons/icon_default_next.png
new file mode 100644
index 000000000..8c17c0ea6
--- /dev/null
+++ b/assets/app-icons/icon_default_next.png
Binary files differdiff --git a/bskyweb/cmd/bskyweb/filters.go b/bskyweb/cmd/bskyweb/filters.go
new file mode 100644
index 000000000..a92cc606b
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/filters.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+	"net/url"
+
+	"github.com/flosch/pongo2/v6"
+)
+
+func init() {
+	pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
+}
+
+func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
+	urlStr := in.String()
+
+	parsedURL, err := url.Parse(urlStr)
+	if err != nil {
+		// If parsing fails, return the original URL
+		return in, nil
+	}
+
+	// Remove query parameters and fragment
+	parsedURL.RawQuery = ""
+	parsedURL.Fragment = ""
+
+	// Return the cleaned URL
+	return pongo2.AsValue(parsedURL.String()), nil
+}
diff --git a/bskyweb/cmd/bskyweb/filters_test.go b/bskyweb/cmd/bskyweb/filters_test.go
new file mode 100644
index 000000000..a63ad0317
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/filters_test.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/flosch/pongo2/v6"
+)
+
+func TestCanonicalizeURLFilter(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "clean URL",
+			input:    "https://bsky.app/profile/user",
+			expected: "https://bsky.app/profile/user",
+		},
+		{
+			name:     "URL with query params",
+			input:    "https://bsky.app/profile/user?utm_source=test",
+			expected: "https://bsky.app/profile/user",
+		},
+		{
+			name:     "URL with multiple params",
+			input:    "https://bsky.app/profile/user?utm_source=twitter&utm_campaign=test",
+			expected: "https://bsky.app/profile/user",
+		},
+		{
+			name:     "URL with fragment",
+			input:    "https://bsky.app/profile/user#section",
+			expected: "https://bsky.app/profile/user",
+		},
+		{
+			name:     "URL with both params and fragment",
+			input:    "https://bsky.app/profile/user?param=1#section",
+			expected: "https://bsky.app/profile/user",
+		},
+		{
+			name:     "malformed URL",
+			input:    "not-a-url",
+			expected: "not-a-url", // Should return original on error
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			inputValue := pongo2.AsValue(tt.input)
+			result, err := filterCanonicalizeURL(inputValue, nil)
+			if err != nil {
+				t.Errorf("filterCanonicalizeURL() error = %v", err)
+				return
+			}
+
+			if result.String() != tt.expected {
+				t.Errorf("filterCanonicalizeURL() = %v, want %v", result.String(), tt.expected)
+			}
+		})
+	}
+}
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 4208eea2d..89cd112cd 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -606,10 +606,10 @@ type IPCCRequest struct {
 type IPCCResponse struct {
 	CC               string `json:"countryCode"`
 	AgeRestrictedGeo bool   `json:"isAgeRestrictedGeo,omitempty"`
+	AgeBlockedGeo    bool   `json:"isAgeBlockedGeo,omitempty"`
 }
 
-// IP address data is powered by IPinfo
-// https://ipinfo.io
+// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
 func (srv *Server) WebIpCC(c echo.Context) error {
 	realIP := c.RealIP()
 	addr, err := netip.ParseAddr(realIP)
diff --git a/bskyweb/templates/home.html b/bskyweb/templates/home.html
index 5d3fbad85..9e5c670a2 100644
--- a/bskyweb/templates/home.html
+++ b/bskyweb/templates/home.html
@@ -12,6 +12,7 @@
 
   <meta property="og:url" content="https://bsky.app" />
   <meta name="twitter:url" content="https://bsky.app" />
+  <link rel="canonical" href="https://bsky.app" />
 
   <meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
   <meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png"  />
diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html
index f6f03c7ee..1f3f6da4e 100644
--- a/bskyweb/templates/post.html
+++ b/bskyweb/templates/post.html
@@ -14,6 +14,7 @@
   <meta property="profile:username" content="{{ profileView.Handle }}">
   {%- if requestURI %}
   <meta property="og:url" content="{{ requestURI }}">
+  <link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
   {% endif -%}
   {%- if postView.Author.DisplayName %}
   <meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
diff --git a/bskyweb/templates/profile.html b/bskyweb/templates/profile.html
index ab84dd157..8506a9cff 100644
--- a/bskyweb/templates/profile.html
+++ b/bskyweb/templates/profile.html
@@ -15,6 +15,7 @@
   <meta property="profile:username" content="{{ profileView.Handle }}">
   {%- if requestURI %}
   <meta property="og:url" content="{{ requestURI }}">
+  <link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
   {% endif -%}
   {%- if profileView.DisplayName %}
   <meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
diff --git a/package.json b/package.json
index ee5d276f4..73e9d7a02 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
   "scripts": {
     "prepare": "is-ci || husky install",
     "postinstall": "patch-package && yarn intl:compile-if-needed",
-    "prebuild": "expo prebuild --clean",
+    "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
     "android": "expo run:android",
     "android:prod": "expo run:android --variant release",
     "android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
@@ -92,7 +92,7 @@
     "@lingui/react": "^4.14.1",
     "@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
     "@miblanchard/react-native-slider": "^2.6.0",
-    "@mozzius/expo-dynamic-app-icon": "1.5.0",
+    "@mozzius/expo-dynamic-app-icon": "^1.7.1",
     "@react-native-async-storage/async-storage": "2.1.2",
     "@react-native-menu/menu": "^1.2.3",
     "@react-native-picker/picker": "2.11.0",
@@ -146,7 +146,7 @@
     "expo-image": "^2.4.0",
     "expo-image-crop-tool": "^0.1.8",
     "expo-image-manipulator": "~13.1.7",
-    "expo-image-picker": "~16.1.4",
+    "expo-image-picker": "^17.0.2",
     "expo-intent-launcher": "^12.1.5",
     "expo-linear-gradient": "~14.1.5",
     "expo-linking": "~7.1.5",
diff --git a/patches/expo-image-picker+16.1.4.patch b/patches/expo-image-picker+16.1.4.patch
deleted file mode 100644
index 0396fecbc..000000000
--- a/patches/expo-image-picker+16.1.4.patch
+++ /dev/null
@@ -1,38 +0,0 @@
-diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-index c863fb8..cde8859 100644
---- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
-@@ -101,16 +101,30 @@ internal class MediaHandler(
-       val fileData = getAdditionalFileData(sourceUri)
-       val mimeType = getType(context.contentResolver, sourceUri)
- 
-+      // Extract basic metadata
-+      var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
-+      var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
-+      val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
-+
-+      // Android returns the encoded width/height which do not take the display rotation into
-+      // account. For videos recorded in portrait mode the encoded dimensions are often landscape
-+      // (e.g. 1920x1080) paired with a 90°/270° rotation flag.  iOS adjusts these values before
-+      // reporting them, so to keep the behaviour consistent across platforms we swap the width
-+      // and height when the rotation indicates the video should be displayed in portrait.
-+      if (rotation % 180 != 0) {
-+        width = height.also { height = width }
-+      }
-+
-       return ImagePickerAsset(
-         type = MediaType.VIDEO,
-         uri = outputUri.toString(),
--        width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
--        height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
-+        width = width,
-+        height = height,
-         fileName = fileData?.fileName,
-         fileSize = fileData?.fileSize,
-         mimeType = mimeType,
-         duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
--        rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
-+        rotation = rotation,
-         assetId = sourceUri.getMediaStoreAssetId()
-       )
-     } catch (cause: FailedToExtractVideoMetadataException) {
diff --git a/patches/expo-image-picker+16.1.4.patch.md b/patches/expo-image-picker+16.1.4.patch.md
deleted file mode 100644
index 7855e8621..000000000
--- a/patches/expo-image-picker+16.1.4.patch.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Expo Image Picker patch
-
-Cherry-picked https://github.com/expo/expo/pull/37849
-
-Remove when we update to a version that includes this commit.
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 97c351bc8..7af38105b 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -55,7 +55,6 @@ import {DebugModScreen} from '#/view/screens/DebugMod'
 import {FeedsScreen} from '#/view/screens/Feeds'
 import {HomeScreen} from '#/view/screens/Home'
 import {ListsScreen} from '#/view/screens/Lists'
-import {LogScreen} from '#/view/screens/Log'
 import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
 import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
 import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
@@ -74,6 +73,7 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
 import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
 import HashtagScreen from '#/screens/Hashtag'
+import {LogScreen} from '#/screens/Log'
 import {MessagesScreen} from '#/screens/Messages/ChatList'
 import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
 import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx
new file mode 100644
index 000000000..ae5790da9
--- /dev/null
+++ b/src/components/BlockedGeoOverlay.tsx
@@ -0,0 +1,109 @@
+import {useEffect} from 'react'
+import {ScrollView, View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Full as Logo, Mark} from '#/components/icons/Logo'
+import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function BlockedGeoOverlay() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtPhone} = useBreakpoints()
+  const insets = useSafeAreaInsets()
+
+  useEffect(() => {
+    // just counting overall hits here
+    logger.metric(`blockedGeoOverlay:shown`, {})
+  }, [])
+
+  const textStyles = [a.text_md, a.leading_normal]
+  const links = {
+    blog: {
+      to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
+      label: _(msg`Read our blog post`),
+      overridePresentation: false,
+      disableMismatchWarning: true,
+      style: textStyles,
+    },
+  }
+
+  const blocks = [
+    _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
+    _(
+      msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
+    ),
+    _(
+      msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
+    ),
+    _(
+      msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
+    ),
+    <>
+      To learn more, read our{' '}
+      <InlineLinkText {...links.blog}>blog post</InlineLinkText>.
+    </>,
+  ]
+
+  return (
+    <ScrollView
+      contentContainerStyle={[
+        a.px_2xl,
+        {
+          paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
+          paddingBottom: 100,
+        },
+      ]}>
+      <View
+        style={[
+          a.mx_auto,
+          web({
+            maxWidth: 440,
+            paddingTop: gtPhone ? '8vh' : undefined,
+          }),
+        ]}>
+        <View style={[a.align_start]}>
+          <View
+            style={[
+              a.pl_md,
+              a.pr_lg,
+              a.py_sm,
+              a.rounded_full,
+              a.flex_row,
+              a.align_center,
+              a.gap_xs,
+              {
+                backgroundColor: t.palette.primary_25,
+              },
+            ]}>
+            <Mark fill={t.palette.primary_600} width={14} />
+            <Text
+              style={[
+                a.font_bold,
+                {
+                  color: t.palette.primary_600,
+                },
+              ]}>
+              <Trans>Announcement</Trans>
+            </Text>
+          </View>
+        </View>
+
+        <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
+          {blocks.map((block, index) => (
+            <Text key={index} style={[textStyles]}>
+              {block}
+            </Text>
+          ))}
+        </View>
+
+        <Logo width={120} textFill={t.atoms.text.color} />
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 12bd8819b..1417e9e91 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -193,7 +193,7 @@ export function Inner({
           onInteractOutside={preventDefault}
           onFocusOutside={preventDefault}
           onDismiss={close}
-          style={{display: 'flex', flexDirection: 'column'}}>
+          style={{height: '100%', display: 'flex', flexDirection: 'column'}}>
           {header}
           <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
             {children}
@@ -227,10 +227,10 @@ export const InnerFlatList = React.forwardRef<
         web({maxHeight: '80vh'}),
         webInnerStyle,
       ]}
-      contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
+      contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
       <FlatList
         ref={ref}
-        style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
+        style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
         {...props}
       />
     </Inner>
diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx
index 40d040878..b5513b19c 100644
--- a/src/components/Dialog/shared.tsx
+++ b/src/components/Dialog/shared.tsx
@@ -5,7 +5,6 @@ import {
   View,
   type ViewStyle,
 } from 'react-native'
-import type React from 'react'
 
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
@@ -28,6 +27,8 @@ export function Header({
     <View
       onLayout={onLayout}
       style={[
+        a.sticky,
+        a.top_0,
         a.relative,
         a.w_full,
         a.py_sm,
@@ -61,7 +62,7 @@ export function HeaderText({
   style?: StyleProp<TextStyle>
 }) {
   return (
-    <Text style={[a.text_lg, a.text_center, a.font_bold, style]}>
+    <Text style={[a.text_lg, a.text_center, a.font_heavy, style]}>
       {children}
     </Text>
   )
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 18da12b22..7debbf5e1 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
-import {View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
+import {ScrollView, View} from 'react-native'
 import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
 import {type NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
+import {isIOS} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useGetPopularFeedsQuery} from '#/state/queries/feed'
 import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -25,9 +25,9 @@ import {
   type ViewStyleProp,
   web,
 } from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
-import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
 import {InlineLinkText} from '#/components/Link'
 import * as ProfileCard from '#/components/ProfileCard'
@@ -36,6 +36,7 @@ import type * as bsky from '#/types/bsky'
 import {ProgressGuideList} from './ProgressGuide/List'
 
 const MOBILE_CARD_WIDTH = 165
+const FINAL_CARD_WIDTH = 120
 
 function CardOuter({
   children,
@@ -46,11 +47,13 @@ function CardOuter({
   return (
     <View
       style={[
+        a.flex_1,
         a.w_full,
         a.p_md,
         a.rounded_lg,
         a.border,
         t.atoms.bg,
+        t.atoms.shadow_sm,
         t.atoms.border_contrast_low,
         !gtMobile && {
           width: MOBILE_CARD_WIDTH,
@@ -63,11 +66,8 @@ function CardOuter({
 }
 
 export function SuggestedFollowPlaceholder() {
-  const t = useTheme()
-
   return (
-    <CardOuter
-      style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
+    <CardOuter>
       <ProfileCard.Outer>
         <View
           style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
@@ -78,24 +78,15 @@ export function SuggestedFollowPlaceholder() {
           </View>
         </View>
 
-        <Button
-          label=""
-          size="small"
-          variant="solid"
-          color="secondary"
-          disabled
-          style={[a.w_full, a.rounded_sm]}>
-          <ButtonText>Follow</ButtonText>
-        </Button>
+        <ProfileCard.FollowButtonPlaceholder />
       </ProfileCard.Outer>
     </CardOuter>
   )
 }
 
 export function SuggestedFeedsCardPlaceholder() {
-  const t = useTheme()
   return (
-    <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
+    <CardOuter style={[a.gap_sm]}>
       <FeedCard.Header>
         <FeedCard.AvatarPlaceholder />
         <FeedCard.TitleAndBylinePlaceholder creator />
@@ -253,129 +244,133 @@ export function ProfileGrid({
   profiles: bsky.profile.AnyProfileView[]
   recId?: number
   error: Error | null
-  viewContext: 'profile' | 'feed'
+  viewContext: 'profile' | 'profileHeader' | 'feed'
 }) {
   const t = useTheme()
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
   const {gtMobile} = useBreakpoints()
+
   const isLoading = isSuggestionsLoading || !moderationOpts
-  const maxLength = gtMobile ? 3 : 6
+  const isProfileHeaderContext = viewContext === 'profileHeader'
+  const isFeedContext = viewContext === 'feed'
 
-  const content = isLoading ? (
-    Array(maxLength)
-      .fill(0)
-      .map((_, i) => (
-        <View
-          key={i}
-          style={[
-            gtMobile &&
-              web([
-                a.flex_0,
-                a.flex_grow,
-                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
-              ]),
-          ]}>
-          <SuggestedFollowPlaceholder />
-        </View>
-      ))
-  ) : error || !profiles.length ? null : (
-    <>
-      {profiles.slice(0, maxLength).map((profile, index) => (
-        <ProfileCard.Link
-          key={profile.did}
-          profile={profile}
-          onPress={() => {
-            logEvent('suggestedUser:press', {
-              logContext:
-                viewContext === 'feed'
+  const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
+  const minLength = gtMobile ? 3 : 4
+
+  const content = isLoading
+    ? Array(maxLength)
+        .fill(0)
+        .map((_, i) => (
+          <View
+            key={i}
+            style={[
+              a.flex_1,
+              gtMobile &&
+                web([
+                  a.flex_0,
+                  a.flex_grow,
+                  {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+                ]),
+            ]}>
+            <SuggestedFollowPlaceholder />
+          </View>
+        ))
+    : error || !profiles.length
+      ? null
+      : profiles.slice(0, maxLength).map((profile, index) => (
+          <ProfileCard.Link
+            key={profile.did}
+            profile={profile}
+            onPress={() => {
+              logEvent('suggestedUser:press', {
+                logContext: isFeedContext
                   ? 'InterstitialDiscover'
                   : 'InterstitialProfile',
-              recId,
-              position: index,
-            })
-          }}
-          style={[
-            a.flex_1,
-            gtMobile &&
-              web([
-                a.flex_0,
-                a.flex_grow,
-                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
-              ]),
-          ]}>
-          {({hovered, pressed}) => (
-            <CardOuter
-              style={[
-                a.flex_1,
-                t.atoms.shadow_sm,
-                (hovered || pressed) && t.atoms.border_contrast_high,
-              ]}>
-              <ProfileCard.Outer>
-                <View
-                  style={[
-                    a.flex_col,
-                    a.align_center,
-                    a.gap_sm,
-                    a.pb_sm,
-                    a.mb_auto,
-                  ]}>
-                  <ProfileCard.Avatar
-                    profile={profile}
-                    moderationOpts={moderationOpts}
-                    size={88}
-                  />
-                  <View style={[a.flex_col, a.align_center, a.max_w_full]}>
-                    <ProfileCard.Name
+                recId,
+                position: index,
+              })
+            }}
+            style={[
+              a.flex_1,
+              gtMobile &&
+                web([
+                  a.flex_0,
+                  a.flex_grow,
+                  {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+                ]),
+            ]}>
+            {({hovered, pressed}) => (
+              <CardOuter
+                style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
+                <ProfileCard.Outer>
+                  <View
+                    style={[
+                      a.flex_col,
+                      a.align_center,
+                      a.gap_sm,
+                      a.pb_sm,
+                      a.mb_auto,
+                    ]}>
+                    <ProfileCard.Avatar
                       profile={profile}
                       moderationOpts={moderationOpts}
+                      disabledPreview
+                      size={88}
                     />
-                    <ProfileCard.Description
-                      profile={profile}
-                      numberOfLines={2}
-                      style={[
-                        t.atoms.text_contrast_medium,
-                        a.text_center,
-                        a.text_xs,
-                      ]}
-                    />
+                    <View style={[a.flex_col, a.align_center, a.max_w_full]}>
+                      <ProfileCard.Name
+                        profile={profile}
+                        moderationOpts={moderationOpts}
+                      />
+                      <ProfileCard.Description
+                        profile={profile}
+                        numberOfLines={2}
+                        style={[
+                          t.atoms.text_contrast_medium,
+                          a.text_center,
+                          a.text_xs,
+                        ]}
+                      />
+                    </View>
                   </View>
-                </View>
-
-                <ProfileCard.FollowButton
-                  profile={profile}
-                  moderationOpts={moderationOpts}
-                  logContext="FeedInterstitial"
-                  withIcon={false}
-                  style={[a.rounded_sm]}
-                  onFollow={() => {
-                    logEvent('suggestedUser:follow', {
-                      logContext:
-                        viewContext === 'feed'
+
+                  <ProfileCard.FollowButton
+                    profile={profile}
+                    moderationOpts={moderationOpts}
+                    logContext="FeedInterstitial"
+                    withIcon={false}
+                    style={[a.rounded_sm]}
+                    onFollow={() => {
+                      logEvent('suggestedUser:follow', {
+                        logContext: isFeedContext
                           ? 'InterstitialDiscover'
                           : 'InterstitialProfile',
-                      location: 'Card',
-                      recId,
-                      position: index,
-                    })
-                  }}
-                />
-              </ProfileCard.Outer>
-            </CardOuter>
-          )}
-        </ProfileCard.Link>
-      ))}
-    </>
-  )
+                        location: 'Card',
+                        recId,
+                        position: index,
+                      })
+                    }}
+                  />
+                </ProfileCard.Outer>
+              </CardOuter>
+            )}
+          </ProfileCard.Link>
+        ))
 
-  if (error || (!isLoading && profiles.length < 4)) {
+  if (error || (!isLoading && profiles.length < minLength)) {
     logger.debug(`Not enough profiles to show suggested follows`)
     return null
   }
 
   return (
     <View
-      style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
+      style={[
+        !isProfileHeaderContext && a.border_t,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+      ]}
+      pointerEvents={isIOS ? 'auto' : 'box-none'}>
       <View
         style={[
           a.px_lg,
@@ -383,19 +378,22 @@ export function ProfileGrid({
           a.flex_row,
           a.align_center,
           a.justify_between,
-        ]}>
+        ]}
+        pointerEvents={isIOS ? 'auto' : 'box-none'}>
         <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
-          {viewContext === 'profile' ? (
-            <Trans>Similar accounts</Trans>
-          ) : (
+          {isFeedContext ? (
             <Trans>Suggested for you</Trans>
+          ) : (
+            <Trans>Similar accounts</Trans>
           )}
         </Text>
-        <InlineLinkText
-          label={_(msg`See more suggested profiles on the Explore page`)}
-          to="/search">
-          <Trans>See more</Trans>
-        </InlineLinkText>
+        {!isProfileHeaderContext && (
+          <InlineLinkText
+            label={_(msg`See more suggested profiles on the Explore page`)}
+            to="/search">
+            <Trans>See more</Trans>
+          </InlineLinkText>
+        )}
       </View>
 
       {gtMobile ? (
@@ -406,19 +404,16 @@ export function ProfileGrid({
         </View>
       ) : (
         <BlockDrawerGesture>
-          <View>
-            <ScrollView
-              horizontal
-              showsHorizontalScrollIndicator={false}
-              snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
-              decelerationRate="fast">
-              <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
-                {content}
-
-                <SeeMoreSuggestedProfilesCard />
-              </View>
-            </ScrollView>
-          </View>
+          <ScrollView
+            horizontal
+            showsHorizontalScrollIndicator={false}
+            contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
+            snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
+            decelerationRate="fast">
+            {content}
+
+            {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
+          </ScrollView>
         </BlockDrawerGesture>
       )}
     </View>
@@ -426,28 +421,29 @@ export function ProfileGrid({
 }
 
 function SeeMoreSuggestedProfilesCard() {
-  const navigation = useNavigation<NavigationProp>()
   const t = useTheme()
   const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
 
   return (
     <Button
+      color="primary"
       label={_(msg`Browse more accounts on the Explore page`)}
-      style={[a.flex_col]}
-      onPress={() => {
-        navigation.navigate('SearchTab')
-      }}>
-      <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
-        <View style={[a.flex_1, a.justify_center]}>
-          <View style={[a.flex_col, a.align_center, a.gap_md]}>
-            <Text style={[a.leading_snug, a.text_center]}>
-              <Trans>See more accounts you might like</Trans>
-            </Text>
-
-            <Arrow size="xl" />
-          </View>
-        </View>
-      </CardOuter>
+      style={[
+        a.flex_col,
+        a.align_center,
+        a.gap_xs,
+        a.p_md,
+        a.rounded_lg,
+        t.atoms.shadow_sm,
+        {width: FINAL_CARD_WIDTH},
+      ]}
+      onPress={() => navigation.navigate('SearchTab')}>
+      <ButtonIcon icon={ArrowRight} size="lg" />
+      <ButtonText
+        style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
+        <Trans>See more</Trans>
+      </ButtonText>
     </Button>
   )
 }
@@ -491,10 +487,7 @@ export function SuggestedFeeds() {
           }}>
           {({hovered, pressed}) => (
             <CardOuter
-              style={[
-                a.flex_1,
-                (hovered || pressed) && t.atoms.border_contrast_high,
-              ]}>
+              style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
               <FeedCard.Outer>
                 <FeedCard.Header>
                   <FeedCard.Avatar src={feed.avatar} />
@@ -549,7 +542,7 @@ export function SuggestedFeeds() {
               style={[t.atoms.text_contrast_medium]}>
               <Trans>Browse more suggestions</Trans>
             </InlineLinkText>
-            <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
+            <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
           </View>
         </View>
       ) : (
@@ -568,7 +561,7 @@ export function SuggestedFeeds() {
                   navigation.navigate('SearchTab')
                 }}
                 style={[a.flex_col]}>
-                <CardOuter style={[a.flex_1]}>
+                <CardOuter>
                   <View style={[a.flex_1, a.justify_center]}>
                     <View style={[a.flex_row, a.px_lg]}>
                       <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
@@ -577,7 +570,7 @@ export function SuggestedFeeds() {
                         </Trans>
                       </Text>
 
-                      <Arrow size="xl" />
+                      <ArrowRight size="xl" />
                     </View>
                   </View>
                 </CardOuter>
diff --git a/src/components/Layout/const.ts b/src/components/Layout/const.ts
index 2b5d3a1fc..2721bed21 100644
--- a/src/components/Layout/const.ts
+++ b/src/components/Layout/const.ts
@@ -13,7 +13,7 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
 /**
  * Corresponds to the width of a small square or round button
  */
-export const HEADER_SLOT_SIZE = 34
+export const HEADER_SLOT_SIZE = 33
 
 /**
  * How far to shift the center column when in the tablet breakpoint
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 6954be6a8..421a7fe9d 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,5 +1,5 @@
 import React, {useMemo} from 'react'
-import {type GestureResponderEvent} from 'react-native'
+import {type GestureResponderEvent, Linking} from 'react-native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 import {
   type LinkProps as RNLinkProps,
@@ -13,6 +13,7 @@ import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
 import {shareUrl} from '#/lib/sharing'
 import {
   convertBskyAppUrlIfNeeded,
+  createProxiedUrl,
   isBskyDownloadUrl,
   isExternalUrl,
   linkRequiresWarning,
@@ -407,6 +408,91 @@ export function InlineLinkText({
   )
 }
 
+/**
+ * A barebones version of `InlineLinkText`, for use outside a
+ * `react-navigation` context.
+ */
+export function SimpleInlineLinkText({
+  children,
+  to,
+  style,
+  download,
+  selectable,
+  label,
+  disableUnderline,
+  shouldProxy,
+  ...rest
+}: Omit<
+  InlineLinkProps,
+  | 'to'
+  | 'action'
+  | 'disableMismatchWarning'
+  | 'overridePresentation'
+  | 'onPress'
+  | 'onLongPress'
+  | 'shareOnLongPress'
+> & {
+  to: string
+}) {
+  const t = useTheme()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const flattenedStyle = flatten(style) || {}
+  const isExternal = isExternalUrl(to)
+
+  let href = to
+  if (shouldProxy) {
+    href = createProxiedUrl(href)
+  }
+
+  const onPress = () => {
+    Linking.openURL(href)
+  }
+
+  return (
+    <Text
+      selectable={selectable}
+      accessibilityHint=""
+      accessibilityLabel={label}
+      {...rest}
+      style={[
+        {color: t.palette.primary_500},
+        hovered &&
+          !disableUnderline && {
+            ...web({
+              outline: 0,
+              textDecorationLine: 'underline',
+              textDecorationColor:
+                flattenedStyle.color ?? t.palette.primary_500,
+            }),
+          },
+        flattenedStyle,
+      ]}
+      role="link"
+      onPress={onPress}
+      onMouseEnter={onHoverIn}
+      onMouseLeave={onHoverOut}
+      accessibilityRole="link"
+      href={href}
+      {...web({
+        hrefAttrs: {
+          target: download ? undefined : isExternal ? 'blank' : undefined,
+          rel: isExternal ? 'noopener noreferrer' : undefined,
+          download,
+        },
+        dataSet: {
+          // default to no underline, apply this ourselves
+          noUnderline: '1',
+        },
+      })}>
+      {children}
+    </Text>
+  )
+}
+
 export function WebOnlyInlineLinkText({
   children,
   to,
diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx
index 208973cc9..c2603a4d7 100644
--- a/src/components/MediaPreview.tsx
+++ b/src/components/MediaPreview.tsx
@@ -1,7 +1,6 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
 import {Image} from 'expo-image'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {type AppBskyFeedDefs} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 
 import {isTenorGifUri} from '#/lib/strings/embed-player'
@@ -92,12 +91,11 @@ export function ImageItem({
       <Image
         key={thumbnail}
         source={{uri: thumbnail}}
+        alt={alt}
         style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
         contentFit="cover"
         accessible={true}
         accessibilityIgnoresInvertColors
-        accessibilityHint={alt}
-        accessibilityLabel=""
       />
       <MediaInsetBorder style={[a.rounded_xs]} />
       {children}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
index d84a90fa6..e4814462f 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
@@ -146,6 +146,8 @@ export function Scrubber({
   const progress = scrubberActive ? seekPosition : currentTime
   const progressPercent = (progress / duration) * 100
 
+  if (duration < 3) return null
+
   return (
     <View
       testID="scrubber"
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
index 676b52661..7a54ef486 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -373,13 +373,15 @@ export function Controls({
             onPress={onPressPlayPause}
           />
           <View style={a.flex_1} />
-          <Text
-            style={[
-              a.px_xs,
-              {color: t.palette.white, fontVariant: ['tabular-nums']},
-            ]}>
-            {formatTime(currentTime)} / {formatTime(duration)}
-          </Text>
+          {Math.round(duration) > 0 && (
+            <Text
+              style={[
+                a.px_xs,
+                {color: t.palette.white, fontVariant: ['tabular-nums']},
+              ]}>
+              {formatTime(currentTime)} / {formatTime(duration)}
+            </Text>
+          )}
           {hasSubtitleTrack && (
             <ControlButton
               active={subtitlesEnabled}
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
index 9c5444b27..8566c2fe6 100644
--- a/src/components/Post/Embed/index.tsx
+++ b/src/components/Post/Embed/index.tsx
@@ -87,14 +87,18 @@ function MediaEmbed({
   switch (embed.type) {
     case 'images': {
       return (
-        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+        <ContentHider
+          modui={rest.moderation?.ui('contentMedia')}
+          activeStyle={[a.mt_sm]}>
           <ImageEmbed embed={embed} {...rest} />
         </ContentHider>
       )
     }
     case 'link': {
       return (
-        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+        <ContentHider
+          modui={rest.moderation?.ui('contentMedia')}
+          activeStyle={[a.mt_sm]}>
           <ExternalEmbed
             link={embed.view.external}
             onOpen={rest.onOpen}
@@ -105,7 +109,9 @@ function MediaEmbed({
     }
     case 'video': {
       return (
-        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+        <ContentHider
+          modui={rest.moderation?.ui('contentMedia')}
+          activeStyle={[a.mt_sm]}>
           <VideoEmbed embed={embed.view} />
         </ContentHider>
       )
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index f12d922fd..095b62167 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -513,12 +513,19 @@ export function FollowButtonInner({
       comment: 'User is following this account, click to unfollow',
     }),
   )
-  const followLabel = _(
-    msg({
-      message: 'Follow',
-      comment: 'User is not following this account, click to follow',
-    }),
-  )
+  const followLabel = profile.viewer?.followedBy
+    ? _(
+        msg({
+          message: 'Follow back',
+          comment: 'User is not following this account, click to follow back',
+        }),
+      )
+    : _(
+        msg({
+          message: 'Follow',
+          comment: 'User is not following this account, click to follow',
+        }),
+      )
 
   if (!profile.viewer) return null
   if (
@@ -561,6 +568,24 @@ export function FollowButtonInner({
   )
 }
 
+export function FollowButtonPlaceholder({style}: ViewStyleProp) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.rounded_sm,
+        t.atoms.bg_contrast_25,
+        a.w_full,
+        {
+          height: 33,
+        },
+        style,
+      ]}
+    />
+  )
+}
+
 export function Labels({
   profile,
   moderationOpts,
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index e18fdf2db..15c1ba26e 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -1,4 +1,4 @@
-import React, {
+import {
   useCallback,
   useImperativeHandle,
   useMemo,
@@ -119,7 +119,7 @@ function GifList({
     [onSelectGif],
   )
 
-  const onEndReached = React.useCallback(() => {
+  const onEndReached = useCallback(() => {
     if (isFetchingNextPage || !hasNextPage || error) return
     fetchNextPage()
   }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
@@ -172,7 +172,7 @@ function GifList({
           </Button>
         )}
 
-        <TextField.Root>
+        <TextField.Root style={[!gtMobile && isWeb && a.flex_1]}>
           <TextField.Icon icon={Search} />
           <TextField.Input
             label={_(msg`Search GIFs`)}
@@ -206,11 +206,9 @@ function GifList({
         renderItem={renderItem}
         numColumns={gtMobile ? 3 : 2}
         columnWrapperStyle={[a.gap_sm]}
-        contentContainerStyle={[
-          native([a.px_xl, {minHeight: height}]),
-          web(a.h_full_vh),
-        ]}
-        style={[web(a.h_full_vh)]}
+        contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
+        webInnerStyle={[web({minHeight: '80vh'})]}
+        webInnerContentContainerStyle={[web(a.pb_0)]}
         ListHeaderComponent={
           <>
             {listHeader}
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 8aa2335d0..1b1ebbcd5 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -1,12 +1,12 @@
 import React, {useCallback} from 'react'
-import {Keyboard, Pressable, View} from 'react-native'
-import {ChatBskyConvoDefs, ModerationCause} from '@atproto/api'
+import {Keyboard, View} from 'react-native'
+import {type ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {NavigationProp} from '#/lib/routes/types'
-import {Shadow} from '#/state/cache/types'
+import {type NavigationProp} from '#/lib/routes/types'
+import {type Shadow} from '#/state/cache/types'
 import {
   useConvoQuery,
   useMarkAsReadMutation,
@@ -14,11 +14,15 @@ import {
 import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
 import {useProfileBlockMutationQueue} from '#/state/queries/profile'
 import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {type ViewStyleProp} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
 import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
 import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
+import {ReportDialog} from '#/components/dms/ReportDialog'
 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
+import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
@@ -30,9 +34,7 @@ import {
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
 import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
-import * as bsky from '#/types/bsky'
-import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
-import {ReportDialog} from './ReportDialog'
+import type * as bsky from '#/types/bsky'
 
 let ConvoMenu = ({
   convo,
@@ -59,7 +61,6 @@ let ConvoMenu = ({
   style?: ViewStyleProp['style']
 }): React.ReactNode => {
   const {_} = useLingui()
-  const t = useTheme()
 
   const leaveConvoControl = Prompt.usePromptControl()
   const reportControl = Prompt.usePromptControl()
@@ -73,22 +74,21 @@ let ConvoMenu = ({
         {!hideTrigger && (
           <View style={[style]}>
             <Menu.Trigger label={_(msg`Chat settings`)}>
-              {({props, state}) => (
-                <Pressable
+              {({props}) => (
+                <Button
+                  label={props.accessibilityLabel}
                   {...props}
                   onPress={() => {
                     Keyboard.dismiss()
                     props.onPress()
                   }}
-                  style={[
-                    a.p_sm,
-                    a.rounded_full,
-                    (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
-                    // make sure pfp is in the middle
-                    {marginLeft: -10},
-                  ]}>
-                  <DotsHorizontal size="md" style={t.atoms.text} />
-                </Pressable>
+                  size="small"
+                  color="secondary"
+                  shape="round"
+                  variant="ghost"
+                  style={[a.bg_transparent]}>
+                  <ButtonIcon icon={DotsHorizontal} size="md" />
+                </Button>
               )}
             </Menu.Trigger>
           </View>
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index c8ed98f88..d37e4a34a 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -1,48 +1,42 @@
-import React, {useCallback} from 'react'
-import {TouchableOpacity, View} from 'react-native'
+import {useMemo} from 'react'
+import {View} from 'react-native'
 import {
   type AppBskyActorDefs,
   type ModerationCause,
   type ModerationDecision,
 } from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
 
-import {BACK_HITSLOP} from '#/lib/constants'
 import {makeProfileLink} from '#/lib/routes/links'
-import {type NavigationProp} from '#/lib/routes/types'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {isWeb} from '#/platform/detection'
 import {type Shadow} from '#/state/cache/profile-shadow'
 import {isConvoActive, useConvo} from '#/state/messages/convo'
 import {type ConvoItem} from '#/state/messages/convo/types'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {atoms as a, useTheme, web} from '#/alf'
 import {ConvoMenu} from '#/components/dms/ConvoMenu'
 import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
+import * as Layout from '#/components/Layout'
 import {Link} from '#/components/Link'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {Text} from '#/components/Typography'
 import {useSimpleVerificationState} from '#/components/verification'
 import {VerificationCheck} from '#/components/verification/VerificationCheck'
 
-const PFP_SIZE = isWeb ? 40 : 34
+const PFP_SIZE = isWeb ? 40 : Layout.HEADER_SLOT_SIZE
 
-export let MessagesListHeader = ({
+export function MessagesListHeader({
   profile,
   moderation,
 }: {
   profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
   moderation?: ModerationDecision
-}): React.ReactNode => {
+}) {
   const t = useTheme()
-  const {_} = useLingui()
-  const {gtTablet} = useBreakpoints()
-  const navigation = useNavigation<NavigationProp>()
 
-  const blockInfo = React.useMemo(() => {
+  const blockInfo = useMemo(() => {
     if (!moderation) return
     const modui = moderation.ui('profileView')
     const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
@@ -54,87 +48,54 @@ export let MessagesListHeader = ({
     }
   }, [moderation])
 
-  const onPressBack = useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Messages', {})
-    }
-  }, [navigation])
-
   return (
-    <View
-      style={[
-        t.atoms.bg,
-        t.atoms.border_contrast_low,
-        a.border_b,
-        a.flex_row,
-        a.align_start,
-        a.gap_sm,
-        gtTablet ? a.pl_lg : a.pl_xl,
-        a.pr_lg,
-        a.py_sm,
-      ]}>
-      <TouchableOpacity
-        testID="conversationHeaderBackBtn"
-        onPress={onPressBack}
-        hitSlop={BACK_HITSLOP}
-        style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`Back`)}
-        accessibilityHint="">
-        <FontAwesomeIcon
-          size={18}
-          icon="angle-left"
-          style={{
-            marginTop: 6,
-          }}
-          color={t.atoms.text.color}
-        />
-      </TouchableOpacity>
-
-      {profile && moderation && blockInfo ? (
-        <HeaderReady
-          profile={profile}
-          moderation={moderation}
-          blockInfo={blockInfo}
-        />
-      ) : (
-        <>
-          <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
-            <View
-              style={[
-                {width: PFP_SIZE, height: PFP_SIZE},
-                a.rounded_full,
-                t.atoms.bg_contrast_25,
-              ]}
-            />
-            <View style={a.gap_xs}>
-              <View
-                style={[
-                  {width: 120, height: 16},
-                  a.rounded_xs,
-                  t.atoms.bg_contrast_25,
-                  a.mt_xs,
-                ]}
-              />
+    <Layout.Header.Outer>
+      <View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
+        <View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
+          <Layout.Header.BackButton />
+        </View>
+        {profile && moderation && blockInfo ? (
+          <HeaderReady
+            profile={profile}
+            moderation={moderation}
+            blockInfo={blockInfo}
+          />
+        ) : (
+          <>
+            <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
               <View
                 style={[
-                  {width: 175, height: 12},
-                  a.rounded_xs,
+                  {width: PFP_SIZE, height: PFP_SIZE},
+                  a.rounded_full,
                   t.atoms.bg_contrast_25,
                 ]}
               />
+              <View style={a.gap_xs}>
+                <View
+                  style={[
+                    {width: 120, height: 16},
+                    a.rounded_xs,
+                    t.atoms.bg_contrast_25,
+                    a.mt_xs,
+                  ]}
+                />
+                <View
+                  style={[
+                    {width: 175, height: 12},
+                    a.rounded_xs,
+                    t.atoms.bg_contrast_25,
+                  ]}
+                />
+              </View>
             </View>
-          </View>
 
-          <View style={{width: 30}} />
-        </>
-      )}
-    </View>
+            <Layout.Header.Slot />
+          </>
+        )}
+      </View>
+    </Layout.Header.Outer>
   )
 }
-MessagesListHeader = React.memo(MessagesListHeader)
 
 function HeaderReady({
   profile,
@@ -181,15 +142,13 @@ function HeaderReady({
           label={_(msg`View ${displayName}'s profile`)}
           style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
           to={makeProfileLink(profile)}>
-          <View style={[a.pt_2xs]}>
-            <PreviewableUserAvatar
-              size={PFP_SIZE}
-              profile={profile}
-              moderation={moderation.ui('avatar')}
-              disableHoverCard={moderation.blocked}
-            />
-          </View>
-          <View style={a.flex_1}>
+          <PreviewableUserAvatar
+            size={PFP_SIZE}
+            profile={profile}
+            moderation={moderation.ui('avatar')}
+            disableHoverCard={moderation.blocked}
+          />
+          <View style={[a.flex_1]}>
             <View style={[a.flex_row, a.align_center]}>
               <Text
                 emoji
@@ -215,7 +174,7 @@ function HeaderReady({
               <Text
                 style={[
                   t.atoms.text_contrast_medium,
-                  a.text_sm,
+                  a.text_xs,
                   web([a.leading_normal, {marginTop: -2}]),
                 ]}
                 numberOfLines={1}>
@@ -235,15 +194,19 @@ function HeaderReady({
           </View>
         </Link>
 
-        {isConvoActive(convoState) && (
-          <ConvoMenu
-            convo={convoState.convo}
-            profile={profile}
-            currentScreen="conversation"
-            blockInfo={blockInfo}
-            latestReportableMessage={latestReportableMessage}
-          />
-        )}
+        <View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
+          <Layout.Header.Slot>
+            {isConvoActive(convoState) && (
+              <ConvoMenu
+                convo={convoState.convo}
+                profile={profile}
+                currentScreen="conversation"
+                blockInfo={blockInfo}
+                latestReportableMessage={latestReportableMessage}
+              />
+            )}
+          </Layout.Header.Slot>
+        </View>
       </View>
 
       <View
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 9993317d6..3d4caa93b 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -48,9 +48,11 @@ const Context = createContext<{
 })
 Context.displayName = 'TextFieldContext'
 
-export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
+export type RootProps = React.PropsWithChildren<
+  {isInvalid?: boolean} & TextStyleProp
+>
 
-export function Root({children, isInvalid = false}: RootProps) {
+export function Root({children, isInvalid = false, style}: RootProps) {
   const inputRef = useRef<TextInput>(null)
   const {
     state: hovered,
@@ -85,7 +87,14 @@ export function Root({children, isInvalid = false}: RootProps) {
   return (
     <Context.Provider value={context}>
       <View
-        style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.relative,
+          a.w_full,
+          a.px_md,
+          style,
+        ]}
         {...web({
           onClick: () => inputRef.current?.focus(),
           onMouseOver: onHoverIn,
diff --git a/src/components/icons/Logo.tsx b/src/components/icons/Logo.tsx
index 6f16d8a44..75c5cb420 100644
--- a/src/components/icons/Logo.tsx
+++ b/src/components/icons/Logo.tsx
@@ -1,5 +1,42 @@
+import Svg, {Path} from 'react-native-svg'
+
+import {type Props, useCommonSVGProps} from './common'
 import {createSinglePathSVG} from './TEMPLATE'
 
 export const Mark = createSinglePathSVG({
   path: 'M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z',
 })
+
+export function Full(
+  props: Omit<Props, 'fill' | 'size' | 'height'> & {
+    markFill?: Props['fill']
+    textFill?: Props['fill']
+  },
+) {
+  const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
+  const ratio = 123 / 555
+
+  return (
+    <Svg
+      fill="none"
+      {...rest}
+      viewBox="0 0 555 123"
+      width={size}
+      height={size * ratio}
+      style={[style]}>
+      {gradient}
+      <Path
+        fill={props.markFill ?? fill}
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M101.821 7.673C112.575-.367 130-6.589 130 13.21c0 3.953-2.276 33.214-3.611 37.965-4.641 16.516-21.549 20.729-36.591 18.179 26.292 4.457 32.979 19.218 18.535 33.98-27.433 28.035-39.428-7.034-42.502-16.02-.563-1.647-.827-2.418-.831-1.763-.004-.655-.268.116-.831 1.763-3.074 8.986-15.07 44.055-42.502 16.02C7.223 88.571 13.91 73.81 40.202 69.353c-15.041 2.55-31.95-1.663-36.59-18.179C2.275 46.424 0 17.162 0 13.21 0-6.59 17.426-.368 28.18 7.673 43.084 18.817 59.114 41.413 65 53.54c5.886-12.125 21.917-34.722 36.821-45.866Z"
+      />
+      <Path
+        fill={props.textFill ?? fill}
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="m454.459 63.823 24.128-25.056h32.638l4.825 15.104c3.561 11.357 6.664 22.598 9.422 33.72 2.527-9.6 5.744-20.84 9.536-33.603l4.826-15.221H555l-22.864 65.335c-2.413 6.673-5.4 11.475-9.192 14.168-3.791 2.693-9.192 3.98-16.315 3.98-2.413 0-4.481-.117-6.319-.352v-11.59h5.514c6.549 0 9.767-4.099 9.767-9.719 0-2.81-.92-6.908-2.758-12.177l-17.177-49.478-22.239 22.665L497.2 99.184h-16.545l-17.234-28.101-8.962 9.133v18.968h-14.246V15.817h14.246v48.006Zm-48.373-26.46c16.889 0 25.622 6.79 26.196 20.49h-13.673c-.344-7.377-4.595-9.954-12.523-9.954-6.894 0-10.341 2.342-10.341 7.026 0 4.215 2.987 6.089 9.881 7.377l7.469 1.17c14.361 2.694 20.566 8.08 20.566 18.384 0 12.176-9.652 18.967-26.311 18.967-17.235 0-26.311-6.908-27.116-20.842h14.132c.804 7.494 4.481 10.304 13.213 10.304 7.813 0 11.72-2.459 11.72-7.26 0-4.332-2.758-6.44-11.605-7.962l-6.778-1.17c-12.983-2.224-19.418-8.313-19.418-18.265 0-11.358 8.847-18.266 24.588-18.266ZM270.534 76.351c0 7.61 3.677 11.474 11.145 11.474 7.008 0 13.212-5.268 13.213-15.22v-33.84h14.476v60.418h-14.016v-8.782c-4.481 6.791-10.686 10.187-18.614 10.187-12.523 0-20.68-7.728-20.68-21.778V38.767h14.476v37.585Zm75.432-38.99c8.961 0 16.085 3.045 21.37 9.016s7.928 13.933 7.928 23.651v3.513h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.663 0 11.144-2.927 13.557-8.664h13.903c-3.103 12.294-13.443 20.139-27.575 20.139-8.847 0-15.971-2.927-21.371-8.664-5.4-5.737-8.157-13.348-8.157-22.95 0-9.483 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664ZM195.628 15.817c17.809 0 26.426 9.251 26.426 21.545 0 8.196-3.677 14.168-10.915 17.914 9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.767 23.886-28.494 23.886h-38.26V15.817h36.996Zm51.264 83.367h-14.477V15.817h14.477v83.367ZM174.143 86.07h21.944c8.732 0 13.443-4.098 13.443-11.474 0-7.728-4.481-11.592-13.443-11.592h-21.944V86.07Zm171.708-37.233c-7.928 0-13.443 4.683-14.822 14.401h29.758c-1.264-8.781-6.549-14.401-14.936-14.401Zm-171.708 1.756h20.336c7.927 0 12.178-4.215 12.178-11.24 0-6.44-4.366-10.539-12.178-10.539h-20.336v21.779Z"
+      />
+    </Svg>
+  )
+}
diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx
index 4d59e2fb5..6be64335a 100644
--- a/src/components/interstitials/TrendingVideos.tsx
+++ b/src/components/interstitials/TrendingVideos.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react'
+import {useCallback, useEffect, useMemo} from 'react'
 import {ScrollView, View} from 'react-native'
 import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
@@ -55,7 +55,7 @@ export function TrendingVideos() {
   const {setTrendingVideoDisabled} = useTrendingSettingsApi()
   const trendingPrompt = Prompt.usePromptControl()
 
-  const onConfirmHide = React.useCallback(() => {
+  const onConfirmHide = useCallback(() => {
     setTrendingVideoDisabled(true)
     logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
   }, [setTrendingVideoDisabled])
@@ -147,9 +147,7 @@ function VideoCards({
 }: {
   data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
 }) {
-  const t = useTheme()
-  const {_} = useLingui()
-  const items = React.useMemo(() => {
+  const items = useMemo(() => {
     return data.pages
       .flatMap(page => page.slices)
       .map(slice => slice.items[0])
@@ -157,10 +155,6 @@ function VideoCards({
       .filter(item => AppBskyEmbedVideo.isView(item.post.embed))
       .slice(0, 8)
   }, [data])
-  const href = React.useMemo(() => {
-    const urip = new AtUri(VIDEO_FEED_URI)
-    return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
-  }, [])
 
   return (
     <>
@@ -183,50 +177,58 @@ function VideoCards({
         </View>
       ))}
 
-      <View style={[{width: CARD_WIDTH * 2}]}>
-        <Link
-          to={href}
-          label={_(msg`View more`)}
-          style={[
-            a.justify_center,
-            a.align_center,
-            a.flex_1,
-            a.rounded_lg,
-            a.border,
-            t.atoms.border_contrast_low,
-            t.atoms.bg,
-            t.atoms.shadow_sm,
-          ]}>
-          {({pressed}) => (
-            <View
-              style={[
-                a.flex_row,
-                a.align_center,
-                a.gap_md,
-                {
-                  opacity: pressed ? 0.6 : 1,
-                },
-              ]}>
-              <Text style={[a.text_md]}>
-                <Trans>View more</Trans>
-              </Text>
-              <View
-                style={[
-                  a.align_center,
-                  a.justify_center,
-                  a.rounded_full,
-                  {
-                    width: 34,
-                    height: 34,
-                    backgroundColor: t.palette.primary_500,
-                  },
-                ]}>
-                <ButtonIcon icon={ChevronRight} />
-              </View>
-            </View>
-          )}
-        </Link>
-      </View>
+      <ViewMoreCard />
     </>
   )
 }
+
+function ViewMoreCard() {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const href = useMemo(() => {
+    const urip = new AtUri(VIDEO_FEED_URI)
+    return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
+  }, [])
+
+  return (
+    <View style={[{width: CARD_WIDTH * 2}]}>
+      <Link
+        to={href}
+        label={_(msg`View more`)}
+        style={[
+          a.justify_center,
+          a.align_center,
+          a.flex_1,
+          a.rounded_lg,
+          a.border,
+          t.atoms.border_contrast_low,
+          t.atoms.bg,
+          t.atoms.shadow_sm,
+        ]}>
+        {({pressed}) => (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.gap_md,
+              {
+                opacity: pressed ? 0.6 : 1,
+              },
+            ]}>
+            <Text style={[a.text_md]}>
+              <Trans>View more</Trans>
+            </Text>
+            <Button
+              color="primary"
+              size="small"
+              shape="round"
+              label={_(msg`View more trending videos`)}>
+              <ButtonIcon icon={ChevronRight} />
+            </Button>
+          </View>
+        )}
+      </Link>
+    </View>
+  )
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 21f0ab870..130722b9c 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -181,6 +181,10 @@ export const VIDEO_SERVICE = 'https://video.bsky.app'
 export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
 
 export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
+/**
+ * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
+ * ISO megabytes.
+ */
 export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb
 
 export const SUPPORTED_MIME_TYPES = [
diff --git a/src/lib/custom-animations/AccordionAnimation.tsx b/src/lib/custom-animations/AccordionAnimation.tsx
new file mode 100644
index 000000000..146735aa6
--- /dev/null
+++ b/src/lib/custom-animations/AccordionAnimation.tsx
@@ -0,0 +1,77 @@
+import {
+  type LayoutChangeEvent,
+  type StyleProp,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import Animated, {
+  Easing,
+  FadeInUp,
+  FadeOutUp,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+
+import {isIOS, isWeb} from '#/platform/detection'
+
+type AccordionAnimationProps = React.PropsWithChildren<{
+  isExpanded: boolean
+  duration?: number
+  style?: StyleProp<ViewStyle>
+}>
+
+function WebAccordion({
+  isExpanded,
+  duration = 300,
+  style,
+  children,
+}: AccordionAnimationProps) {
+  const heightValue = useSharedValue(0)
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const targetHeight = isExpanded ? heightValue.get() : 0
+    return {
+      height: withTiming(targetHeight, {
+        duration,
+        easing: Easing.out(Easing.cubic),
+      }),
+      overflow: 'hidden',
+    }
+  })
+
+  const onLayout = (e: LayoutChangeEvent) => {
+    if (heightValue.get() === 0) {
+      heightValue.set(e.nativeEvent.layout.height)
+    }
+  }
+
+  return (
+    <Animated.View style={[animatedStyle, style]}>
+      <View onLayout={onLayout}>{children}</View>
+    </Animated.View>
+  )
+}
+
+function MobileAccordion({
+  isExpanded,
+  duration = 200,
+  style,
+  children,
+}: AccordionAnimationProps) {
+  if (!isExpanded) return null
+
+  return (
+    <Animated.View
+      style={style}
+      entering={FadeInUp.duration(duration)}
+      exiting={FadeOutUp.duration(duration / 2)}
+      pointerEvents={isIOS ? 'auto' : 'box-none'}>
+      {children}
+    </Animated.View>
+  )
+}
+
+export function AccordionAnimation(props: AccordionAnimationProps) {
+  return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} />
+}
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
index 234be777d..32e371644 100644
--- a/src/lib/haptics.ts
+++ b/src/lib/haptics.ts
@@ -4,7 +4,6 @@ import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
 
 import {isIOS, isWeb} from '#/platform/detection'
 import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
-import * as Toast from '#/view/com/util/Toast'
 
 export function useHaptics() {
   const isHapticsDisabled = useHapticsDisabled()
@@ -23,7 +22,8 @@ export function useHaptics() {
 
       // DEV ONLY - show a toast when a haptic is meant to fire on simulator
       if (__DEV__ && !Device.isDevice) {
-        Toast.show(`Buzzz!`)
+        // disabled because it's annoying
+        // Toast.show(`Buzzz!`)
       }
     },
     [isHapticsDisabled],
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 8fd76f414..8ec1154c8 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -17,16 +17,12 @@ export async function openPicker(opts?: ImagePickerOptions) {
     exif: false,
     mediaTypes: ['images'],
     quality: 1,
+    selectionLimit: 1,
     ...opts,
     legacy: true,
   })
 
-  if (response.assets && response.assets.length > 4) {
-    Toast.show(t`You may only select up to 4 images`, 'exclamation-circle')
-  }
-
   return (response.assets ?? [])
-    .slice(0, 4)
     .filter(asset => {
       if (asset.mimeType?.startsWith('image/')) return true
       Toast.show(t`Only image files are supported`, 'exclamation-circle')
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index c2d1470c6..1d00bfcea 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -1,8 +1,8 @@
 import {getVideoMetaData, Video} from 'react-native-compressor'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {type ImagePickerAsset} from 'expo-image-picker'
 
-import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
-import {CompressedVideo} from './types'
+import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
+import {type CompressedVideo} from './types'
 import {extToMime} from './util'
 
 const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
@@ -20,6 +20,13 @@ export async function compressVideo(
     file.mimeType as SupportedMimeTypes,
   )
 
+  if (file.mimeType === 'image/gif') {
+    // let's hope they're small enough that they don't need compression!
+    // this compression library doesn't support gifs
+    // worst case - server rejects them. I think that's fine -sfn
+    return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
+  }
+
   const minimumFileSizeForCompress = isAcceptableFormat
     ? MIN_SIZE_FOR_COMPRESSION
     : 0
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 66134a462..ef6dc1d4d 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -5,9 +5,9 @@ export type Gate =
   | 'debug_subscriptions'
   | 'disable_onboarding_policy_update_notice'
   | 'explore_show_suggested_feeds'
-  | 'handle_suggestions'
   | 'old_postonboarding'
   | 'onboarding_add_video_feed'
+  | 'post_follow_profile_suggested_accounts'
   | 'post_threads_v2_unspecced'
   | 'remove_show_latest_button'
   | 'test_gate_1'
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index ca77c4666..61ad4e85b 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -84,6 +84,10 @@ export function augmentSearchQuery(query: string, {did}: {did?: string}) {
     return query
   }
 
+  // replace “smart quotes” with normal ones
+  // iOS keyboard will add fancy unicode quotes, but only normal ones work
+  query = query.replaceAll(/[“”]/g, '"')
+
   // We don't want to replace substrings that are being "quoted" because those
   // are exact string matches, so what we'll do here is to split them apart
 
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index 9d96abb7a..a6c98211c 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -145,7 +145,7 @@ msgstr ""
 msgid "{0} is not a valid URL"
 msgstr ""
 
-#: src/screens/Signup/StepHandle/index.tsx:189
+#: src/screens/Signup/StepHandle/index.tsx:186
 msgid "{0} is not available"
 msgstr ""
 
@@ -153,7 +153,7 @@ msgstr ""
 msgid "{0} joined this week"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:202
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:204
 msgid "{0} of {1}"
 msgstr ""
 
@@ -524,6 +524,10 @@ msgstr ""
 msgid "A new form of verification"
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:39
+msgid "A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies."
+msgstr ""
+
 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:113
 msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature."
 msgstr ""
@@ -602,7 +606,7 @@ msgstr ""
 msgid "Account removed from quick access"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:128
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132
 #: src/view/com/profile/ProfileMenu.tsx:148
 msgctxt "toast"
 msgid "Account unblocked"
@@ -687,7 +691,7 @@ msgstr ""
 msgid "Add another account"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:780
+#: src/view/com/composer/Composer.tsx:788
 msgid "Add another post"
 msgstr ""
 
@@ -705,6 +709,11 @@ msgstr ""
 msgid "Add emoji reaction"
 msgstr ""
 
+#. Accessibility label for button in composer to add photos or a video to a post
+#: src/view/com/composer/SelectMediaButton.tsx:482
+msgid "Add media to post"
+msgstr ""
+
 #: src/components/moderation/ReportDialog/index.tsx:403
 #: src/components/moderation/ReportDialog/index.tsx:407
 msgid "Add more details (optional)"
@@ -718,7 +727,7 @@ msgstr ""
 msgid "Add muted words and tags"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1344
+#: src/view/com/composer/Composer.tsx:1421
 msgid "Add new post"
 msgstr ""
 
@@ -788,7 +797,7 @@ msgstr ""
 msgid "Adult Content"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:404
+#: src/screens/Moderation/index.tsx:423
 msgid "Adult content can only be enabled via the Web at <0>bsky.app</0>."
 msgstr ""
 
@@ -801,7 +810,7 @@ msgstr ""
 msgid "Adult Content labels"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:454
+#: src/screens/Moderation/index.tsx:473
 msgid "Advanced"
 msgstr ""
 
@@ -888,7 +897,7 @@ msgstr ""
 
 #: src/screens/Settings/AccessibilitySettings.tsx:54
 #: src/view/com/composer/GifAltText.tsx:154
-#: src/view/com/composer/photos/ImageAltTextDialog.tsx:118
+#: src/view/com/composer/photos/ImageAltTextDialog.tsx:117
 #: src/view/com/composer/videos/SubtitleDialog.tsx:40
 #: src/view/com/composer/videos/SubtitleDialog.tsx:55
 #: src/view/com/composer/videos/SubtitleDialog.tsx:101
@@ -905,7 +914,7 @@ msgid "Alt text describes images for blind and low-vision users, and helps give
 msgstr ""
 
 #: src/view/com/composer/GifAltText.tsx:179
-#: src/view/com/composer/photos/ImageAltTextDialog.tsx:139
+#: src/view/com/composer/photos/ImageAltTextDialog.tsx:138
 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}"
 msgstr ""
 
@@ -913,11 +922,11 @@ msgstr ""
 msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below."
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:266
+#: src/components/dialogs/GifSelect.tsx:264
 msgid "An error has occurred"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:420
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:422
 msgid "An error occurred"
 msgstr ""
 
@@ -945,10 +954,6 @@ msgstr ""
 msgid "An error occurred while saving the QR code!"
 msgstr ""
 
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:63
-msgid "An error occurred while selecting the video"
-msgstr ""
-
 #: src/screens/StarterPack/StarterPackScreen.tsx:352
 #: src/screens/StarterPack/StarterPackScreen.tsx:374
 msgid "An error occurred while trying to follow all"
@@ -1011,6 +1016,7 @@ msgstr ""
 msgid "Animated GIF"
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:92
 #: src/components/PolicyUpdateOverlay/Badge.tsx:33
 msgid "Announcement"
 msgstr ""
@@ -1108,7 +1114,7 @@ msgid "Appeal this decision"
 msgstr ""
 
 #: src/Navigation.tsx:390
-#: src/screens/Settings/AppearanceSettings.tsx:88
+#: src/screens/Settings/AppearanceSettings.tsx:86
 #: src/screens/Settings/Settings.tsx:212
 #: src/screens/Settings/Settings.tsx:215
 msgid "Appearance"
@@ -1164,11 +1170,11 @@ msgstr ""
 msgid "Are you sure you want to remove this from your feeds?"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:729
+#: src/view/com/composer/Composer.tsx:737
 msgid "Are you sure you'd like to discard this draft?"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:914
+#: src/view/com/composer/Composer.tsx:927
 msgid "Are you sure you'd like to discard this post?"
 msgstr ""
 
@@ -1189,12 +1195,16 @@ msgstr ""
 msgid "Artistic or non-erotic nudity."
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:42
+msgid "As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending."
+msgstr ""
+
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:491
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:493
 msgid "Assign topic for algo"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:48
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:58
 msgctxt "Name of app icon variant"
 msgid "Aurora"
 msgstr ""
@@ -1209,7 +1219,6 @@ msgstr ""
 msgid "Available"
 msgstr ""
 
-#: src/components/dms/MessagesListHeader.tsx:84
 #: src/components/moderation/LabelsOnMeDialog.tsx:315
 #: src/components/moderation/LabelsOnMeDialog.tsx:316
 #: src/screens/Login/ChooseAccountForm.tsx:90
@@ -1279,7 +1288,7 @@ msgid "Birthday"
 msgstr ""
 
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:753
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328
 #: src/view/com/profile/ProfileMenu.tsx:473
 msgid "Block"
 msgstr ""
@@ -1333,11 +1342,11 @@ msgstr ""
 msgid "Block User"
 msgstr ""
 
-#: src/components/Post/Embed/index.tsx:180
+#: src/components/Post/Embed/index.tsx:186
 msgid "Blocked"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:282
+#: src/screens/Moderation/index.tsx:301
 msgid "Blocked accounts"
 msgstr ""
 
@@ -1385,7 +1394,7 @@ msgstr ""
 msgid "Bluesky cannot confirm the authenticity of the claimed date."
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:165
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:175
 msgctxt "Name of app icon variant"
 msgid "Bluesky Classic™"
 msgstr ""
@@ -1448,20 +1457,20 @@ msgstr ""
 msgid "Books"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:435
+#: src/components/FeedInterstitials.tsx:428
 msgid "Browse more accounts on the Explore page"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:566
+#: src/components/FeedInterstitials.tsx:556
 msgid "Browse more feeds on the Explore page"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:547
-#: src/components/FeedInterstitials.tsx:550
+#: src/components/FeedInterstitials.tsx:537
+#: src/components/FeedInterstitials.tsx:540
 msgid "Browse more suggestions"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:575
+#: src/components/FeedInterstitials.tsx:565
 msgid "Browse more suggestions on the Explore page"
 msgstr ""
 
@@ -1557,8 +1566,8 @@ msgstr ""
 #: src/screens/Settings/Settings.tsx:289
 #: src/screens/Takendown.tsx:99
 #: src/screens/Takendown.tsx:102
-#: src/view/com/composer/Composer.tsx:969
-#: src/view/com/composer/Composer.tsx:980
+#: src/view/com/composer/Composer.tsx:982
+#: src/view/com/composer/Composer.tsx:993
 #: src/view/com/composer/photos/EditImageDialog.web.tsx:43
 #: src/view/com/composer/photos/EditImageDialog.web.tsx:52
 #: src/view/com/modals/ChangePassword.tsx:279
@@ -1702,7 +1711,7 @@ msgstr ""
 msgid "Chat requests"
 msgstr ""
 
-#: src/components/dms/ConvoMenu.tsx:75
+#: src/components/dms/ConvoMenu.tsx:76
 #: src/Navigation.tsx:553
 #: src/screens/Messages/ChatList.tsx:367
 msgid "Chat settings"
@@ -1838,7 +1847,7 @@ msgstr ""
 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:184
 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:237
 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:243
-#: src/components/dialogs/GifSelect.tsx:282
+#: src/components/dialogs/GifSelect.tsx:280
 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:158
 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167
 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178
@@ -1881,7 +1890,7 @@ msgstr ""
 
 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:224
 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:230
-#: src/components/dialogs/GifSelect.tsx:276
+#: src/components/dialogs/GifSelect.tsx:274
 #: src/components/verification/VerificationsDialog.tsx:136
 #: src/components/verification/VerifierDialog.tsx:136
 msgid "Close dialog"
@@ -1904,7 +1913,7 @@ msgstr ""
 msgid "Close image"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:109
+#: src/view/com/lightbox/Lightbox.web.tsx:110
 msgid "Close image viewer"
 msgstr ""
 
@@ -1922,7 +1931,7 @@ msgstr ""
 msgid "Closes password update alert"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:977
+#: src/view/com/composer/Composer.tsx:990
 msgid "Closes post composer and discards post draft"
 msgstr ""
 
@@ -1944,7 +1953,7 @@ msgid "Collapses list of users for a given notification"
 msgstr ""
 
 #: src/components/dialogs/Embed.tsx:154
-#: src/screens/Settings/AppearanceSettings.tsx:96
+#: src/screens/Settings/AppearanceSettings.tsx:94
 msgid "Color mode"
 msgstr ""
 
@@ -1980,7 +1989,7 @@ msgstr ""
 msgid "Compose new post"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:878
+#: src/view/com/composer/Composer.tsx:891
 msgid "Compose posts up to {0, plural, other {# characters}} in length"
 msgstr ""
 
@@ -1988,7 +1997,7 @@ msgstr ""
 msgid "Compose reply"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1738
+#: src/view/com/composer/Composer.tsx:1815
 msgid "Compressing video..."
 msgstr ""
 
@@ -2015,11 +2024,11 @@ msgstr ""
 msgid "Confirm delete account"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:330
+#: src/screens/Moderation/index.tsx:349
 msgid "Confirm your age:"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:321
+#: src/screens/Moderation/index.tsx:340
 msgid "Confirm your birthdate"
 msgstr ""
 
@@ -2072,8 +2081,8 @@ msgstr ""
 msgid "Content Blocked"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:317
-#: src/screens/Moderation/index.tsx:351
+#: src/screens/Moderation/index.tsx:336
+#: src/screens/Moderation/index.tsx:370
 msgid "Content filters"
 msgstr ""
 
@@ -2404,8 +2413,8 @@ msgstr ""
 
 #: src/components/dialogs/Embed.tsx:167
 #: src/components/dialogs/Embed.tsx:169
-#: src/screens/Settings/AppearanceSettings.tsx:108
-#: src/screens/Settings/AppearanceSettings.tsx:129
+#: src/screens/Settings/AppearanceSettings.tsx:106
+#: src/screens/Settings/AppearanceSettings.tsx:127
 msgid "Dark"
 msgstr ""
 
@@ -2418,7 +2427,7 @@ msgstr ""
 msgid "Dark mode"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:121
+#: src/screens/Settings/AppearanceSettings.tsx:119
 msgid "Dark theme"
 msgstr ""
 
@@ -2440,7 +2449,7 @@ msgstr ""
 msgid "Debug panel"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:171
+#: src/screens/Settings/AppearanceSettings.tsx:169
 msgid "Default"
 msgstr ""
 
@@ -2518,7 +2527,7 @@ msgstr ""
 
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:678
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:680
-#: src/view/com/composer/Composer.tsx:888
+#: src/view/com/composer/Composer.tsx:901
 msgid "Delete post"
 msgstr ""
 
@@ -2539,11 +2548,11 @@ msgstr ""
 msgid "Delete this post?"
 msgstr ""
 
-#: src/components/Post/Embed/index.tsx:173
+#: src/components/Post/Embed/index.tsx:179
 msgid "Deleted"
 msgstr ""
 
-#: src/components/dms/MessagesListHeader.tsx:160
+#: src/components/dms/MessagesListHeader.tsx:121
 #: src/screens/Messages/components/ChatListItem.tsx:128
 msgid "Deleted Account"
 msgstr ""
@@ -2566,7 +2575,7 @@ msgid "Description"
 msgstr ""
 
 #: src/view/com/composer/GifAltText.tsx:150
-#: src/view/com/composer/photos/ImageAltTextDialog.tsx:114
+#: src/view/com/composer/photos/ImageAltTextDialog.tsx:113
 msgid "Descriptive alt text"
 msgstr ""
 
@@ -2598,7 +2607,7 @@ msgstr ""
 msgid "Dialog: adjust who can interact with this post"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:125
+#: src/screens/Settings/AppearanceSettings.tsx:123
 msgid "Dim"
 msgstr ""
 
@@ -2620,7 +2629,7 @@ msgstr ""
 msgid "Disable haptic feedback"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:386
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:388
 msgid "Disable subtitles"
 msgstr ""
 
@@ -2629,13 +2638,13 @@ msgstr ""
 #: src/lib/moderation/useLabelBehaviorDescription.ts:68
 #: src/screens/Messages/Settings.tsx:155
 #: src/screens/Messages/Settings.tsx:158
-#: src/screens/Moderation/index.tsx:394
+#: src/screens/Moderation/index.tsx:413
 msgid "Disabled"
 msgstr ""
 
 #: src/screens/Profile/Header/EditProfileDialog.tsx:89
-#: src/view/com/composer/Composer.tsx:731
-#: src/view/com/composer/Composer.tsx:921
+#: src/view/com/composer/Composer.tsx:739
+#: src/view/com/composer/Composer.tsx:934
 msgid "Discard"
 msgstr ""
 
@@ -2643,11 +2652,11 @@ msgstr ""
 msgid "Discard changes?"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:728
+#: src/view/com/composer/Composer.tsx:736
 msgid "Discard draft?"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:913
+#: src/view/com/composer/Composer.tsx:926
 msgid "Discard post?"
 msgstr ""
 
@@ -2673,7 +2682,7 @@ msgstr ""
 msgid "Dismiss"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1662
+#: src/view/com/composer/Composer.tsx:1739
 msgid "Dismiss error"
 msgstr ""
 
@@ -2905,12 +2914,12 @@ msgstr ""
 #: src/screens/Profile/Header/EditProfileDialog.tsx:276
 #: src/screens/Profile/Header/EditProfileDialog.tsx:282
 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:183
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:185
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:190
 msgid "Edit profile"
 msgstr ""
 
 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:186
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:188
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:193
 msgid "Edit Profile"
 msgstr ""
 
@@ -3002,7 +3011,7 @@ msgstr ""
 msgid "Enable {0} only"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:381
+#: src/screens/Moderation/index.tsx:400
 msgid "Enable adult content"
 msgstr ""
 
@@ -3028,7 +3037,7 @@ msgstr ""
 msgid "Enable push notifications"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:387
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:389
 msgid "Enable subtitles"
 msgstr ""
 
@@ -3048,7 +3057,7 @@ msgstr ""
 
 #: src/screens/Messages/Settings.tsx:146
 #: src/screens/Messages/Settings.tsx:149
-#: src/screens/Moderation/index.tsx:392
+#: src/screens/Moderation/index.tsx:411
 msgid "Enabled"
 msgstr ""
 
@@ -3074,7 +3083,7 @@ msgstr ""
 msgid "Enter code"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:407
 msgid "Enter fullscreen"
 msgstr ""
 
@@ -3119,7 +3128,7 @@ msgstr ""
 msgid "Entertainment"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1747
+#: src/view/com/composer/Composer.tsx:1824
 #: src/view/com/util/error/ErrorScreen.tsx:42
 msgid "Error"
 msgstr ""
@@ -3194,7 +3203,7 @@ msgstr ""
 msgid "Excludes users you follow"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:404
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406
 msgid "Exit fullscreen"
 msgstr ""
 
@@ -3206,11 +3215,11 @@ msgstr ""
 msgid "Exits image cropping process"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:110
+#: src/view/com/lightbox/Lightbox.web.tsx:111
 msgid "Exits image view"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:184
+#: src/view/com/lightbox/Lightbox.web.tsx:185
 msgid "Expand alt text"
 msgstr ""
 
@@ -3362,7 +3371,7 @@ msgstr ""
 msgid "Failed to load feeds preferences"
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:226
+#: src/components/dialogs/GifSelect.tsx:224
 msgid "Failed to load GIFs"
 msgstr ""
 
@@ -3606,17 +3615,17 @@ msgstr ""
 msgid "Fitness"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:149
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:159
 msgctxt "Name of app icon variant"
 msgid "Flat Black"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:117
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:127
 msgctxt "Name of app icon variant"
 msgid "Flat Blue"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:133
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:143
 msgctxt "Name of app icon variant"
 msgid "Flat White"
 msgstr ""
@@ -3626,10 +3635,10 @@ msgid "Flexible"
 msgstr ""
 
 #. User is not following this account, click to follow
-#: src/components/ProfileCard.tsx:517
+#: src/components/ProfileCard.tsx:524
 #: src/components/ProfileHoverCard/index.web.tsx:496
 #: src/components/ProfileHoverCard/index.web.tsx:507
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:245
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:252
 #: src/screens/VideoFeed/index.tsx:851
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:131
 msgid "Follow"
@@ -3640,7 +3649,7 @@ msgctxt "action"
 msgid "Follow"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:230
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:237
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:113
 msgid "Follow {0}"
 msgstr ""
@@ -3668,14 +3677,16 @@ msgstr ""
 msgid "Follow all"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:243
+#. User is not following this account, click to follow back
+#: src/components/ProfileCard.tsx:518
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:250
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:129
-msgid "Follow Back"
+msgid "Follow back"
 msgstr ""
 
 #: src/view/com/profile/FollowButton.tsx:81
 msgctxt "action"
-msgid "Follow Back"
+msgid "Follow back"
 msgstr ""
 
 #: src/components/KnownFollowers.tsx:238
@@ -3707,7 +3718,7 @@ msgstr ""
 #: src/components/ProfileCard.tsx:511
 #: src/components/ProfileHoverCard/index.web.tsx:495
 #: src/components/ProfileHoverCard/index.web.tsx:506
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:241
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:248
 #: src/screens/VideoFeed/index.tsx:849
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:134
 msgid "Following"
@@ -3720,7 +3731,7 @@ msgid "Following"
 msgstr ""
 
 #: src/components/ProfileCard.tsx:474
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:89
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:92
 msgid "Following {0}"
 msgstr ""
 
@@ -3746,11 +3757,11 @@ msgstr ""
 msgid "Follows You"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:143
+#: src/screens/Settings/AppearanceSettings.tsx:141
 msgid "Font"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:163
+#: src/screens/Settings/AppearanceSettings.tsx:161
 msgid "Font size"
 msgstr ""
 
@@ -3759,6 +3770,10 @@ msgstr ""
 msgid "Food"
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:45
+msgid "For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi."
+msgstr ""
+
 #: src/view/com/modals/DeleteAccount.tsx:125
 msgid "For security reasons, we'll need to send a confirmation code to your email address."
 msgstr ""
@@ -3767,7 +3782,7 @@ msgstr ""
 msgid "For security reasons, you won't be able to view this again. If you lose this app password, you'll need to generate a new one."
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:145
+#: src/screens/Settings/AppearanceSettings.tsx:143
 msgid "For the best experience, we recommend using the theme font."
 msgstr ""
 
@@ -3810,10 +3825,6 @@ msgctxt "from-feed"
 msgid "From <0/>"
 msgstr ""
 
-#: src/view/com/composer/photos/SelectPhotoBtn.tsx:50
-msgid "Gallery"
-msgstr ""
-
 #: src/components/StarterPack/ProfileStarterPacks.tsx:307
 msgid "Generate a starter pack"
 msgstr ""
@@ -3887,7 +3898,7 @@ msgstr ""
 msgid "Getting started"
 msgstr ""
 
-#: src/components/MediaPreview.tsx:116
+#: src/components/MediaPreview.tsx:114
 msgid "GIF"
 msgstr ""
 
@@ -3967,6 +3978,7 @@ msgstr ""
 #: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:89
 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:75
 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:84
+#: src/screens/Moderation/index.tsx:214
 msgid "Go to account settings"
 msgstr ""
 
@@ -4176,7 +4188,7 @@ msgstr ""
 msgid "Hmm, we're having trouble finding this feed. It may have been deleted."
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:59
+#: src/screens/Moderation/index.tsx:60
 msgid "Hmmmm, it seems we're having trouble loading this data. See below for more details. If this issue persists, please contact us."
 msgstr ""
 
@@ -4236,7 +4248,7 @@ msgstr ""
 msgid "I understand"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:186
+#: src/view/com/lightbox/Lightbox.web.tsx:187
 msgid "If alt text is long, toggles alt text expanded state"
 msgstr ""
 
@@ -4382,7 +4394,7 @@ msgstr ""
 msgid "Interaction limited"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:222
+#: src/screens/Moderation/index.tsx:241
 msgid "Interaction settings"
 msgstr ""
 
@@ -4451,7 +4463,7 @@ msgstr ""
 msgid "It's just you right now! Add more people to your starter pack by searching above."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1681
+#: src/view/com/composer/Composer.tsx:1758
 msgid "Job ID: {0}"
 msgstr ""
 
@@ -4536,7 +4548,7 @@ msgstr ""
 msgid "Languages"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:175
+#: src/screens/Settings/AppearanceSettings.tsx:173
 msgid "Larger"
 msgstr ""
 
@@ -4667,7 +4679,7 @@ msgstr ""
 
 #: src/components/dialogs/Embed.tsx:162
 #: src/components/dialogs/Embed.tsx:164
-#: src/screens/Settings/AppearanceSettings.tsx:104
+#: src/screens/Settings/AppearanceSettings.tsx:102
 msgid "Light"
 msgstr ""
 
@@ -4927,7 +4939,7 @@ msgstr ""
 msgid "Manage saved feeds"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:292
+#: src/screens/Moderation/index.tsx:311
 msgid "Manage verification settings"
 msgstr ""
 
@@ -5025,7 +5037,7 @@ msgstr ""
 msgid "Messages"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:101
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:111
 msgctxt "Name of app icon variant"
 msgid "Midnight"
 msgstr ""
@@ -5045,7 +5057,7 @@ msgid "Misleading Post"
 msgstr ""
 
 #: src/Navigation.tsx:176
-#: src/screens/Moderation/index.tsx:99
+#: src/screens/Moderation/index.tsx:100
 #: src/screens/Settings/Settings.tsx:188
 #: src/screens/Settings/Settings.tsx:191
 msgid "Moderation"
@@ -5079,7 +5091,7 @@ msgctxt "toast"
 msgid "Moderation list updated"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:252
+#: src/screens/Moderation/index.tsx:271
 msgid "Moderation lists"
 msgstr ""
 
@@ -5096,7 +5108,7 @@ msgstr ""
 msgid "Moderation states"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:206
+#: src/screens/Moderation/index.tsx:225
 msgid "Moderation tools"
 msgstr ""
 
@@ -5211,7 +5223,7 @@ msgstr ""
 msgid "Mute words & tags"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:267
+#: src/screens/Moderation/index.tsx:286
 msgid "Muted accounts"
 msgstr ""
 
@@ -5228,7 +5240,7 @@ msgstr ""
 msgid "Muted by \"{0}\""
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:237
+#: src/screens/Moderation/index.tsx:256
 msgid "Muted words & tags"
 msgstr ""
 
@@ -5446,7 +5458,12 @@ msgstr ""
 msgid "Next"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:169
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:42
+msgctxt "Name of app icon variant"
+msgid "Next"
+msgstr ""
+
+#: src/view/com/lightbox/Lightbox.web.tsx:170
 msgid "Next image"
 msgstr ""
 
@@ -5463,7 +5480,7 @@ msgstr ""
 msgid "No expiry set"
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:232
+#: src/components/dialogs/GifSelect.tsx:230
 msgid "No featured GIFs found. There may be an issue with Tenor."
 msgstr ""
 
@@ -5481,7 +5498,7 @@ msgid "No likes yet"
 msgstr ""
 
 #: src/components/ProfileCard.tsx:496
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:110
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:114
 msgid "No longer following {0}"
 msgstr ""
 
@@ -5553,7 +5570,7 @@ msgstr ""
 msgid "No results."
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:230
+#: src/components/dialogs/GifSelect.tsx:228
 msgid "No search results found for \"{search}\"."
 msgstr ""
 
@@ -5680,7 +5697,7 @@ msgstr ""
 msgid "Off"
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:269
+#: src/components/dialogs/GifSelect.tsx:267
 #: src/view/com/util/ErrorBoundary.tsx:57
 msgid "Oh no!"
 msgstr ""
@@ -5722,15 +5739,23 @@ msgstr ""
 msgid "Onboarding reset"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:347
+#: src/view/com/composer/Composer.tsx:354
 msgid "One or more GIFs is missing alt text."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:344
+#: src/view/com/composer/Composer.tsx:351
 msgid "One or more images is missing alt text."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:354
+#: src/view/com/composer/SelectMediaButton.tsx:390
+msgid "One or more of your selected files are not supported."
+msgstr ""
+
+#: src/view/com/composer/SelectMediaButton.tsx:413
+msgid "One or more of your selected files is too large. Maximum size is 100 MB."
+msgstr ""
+
+#: src/view/com/composer/Composer.tsx:361
 msgid "One or more videos is missing alt text."
 msgstr ""
 
@@ -5748,7 +5773,7 @@ msgstr ""
 msgid "Only followers who I follow"
 msgstr ""
 
-#: src/lib/media/picker.shared.ts:32
+#: src/lib/media/picker.shared.ts:28
 msgid "Only image files are supported"
 msgstr ""
 
@@ -5787,7 +5812,7 @@ msgid "Open drawer menu"
 msgstr ""
 
 #: src/screens/Messages/components/MessageInput.web.tsx:181
-#: src/view/com/composer/Composer.tsx:1329
+#: src/view/com/composer/Composer.tsx:1406
 msgid "Open emoji picker"
 msgstr ""
 
@@ -5816,7 +5841,7 @@ msgstr ""
 msgid "Open moderation debug page"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:233
+#: src/screens/Moderation/index.tsx:252
 msgid "Open muted words and tags settings"
 msgstr ""
 
@@ -5862,7 +5887,7 @@ msgstr ""
 msgid "Opens a dialog to choose who can reply to this thread"
 msgstr ""
 
-#: src/view/screens/Log.tsx:59
+#: src/screens/Log.tsx:83
 msgid "Opens additional details for a debug entry"
 msgstr ""
 
@@ -5886,11 +5911,17 @@ msgstr ""
 msgid "Opens composer"
 msgstr ""
 
-#: src/view/com/composer/photos/SelectPhotoBtn.tsx:51
-msgid "Opens device photo gallery"
+#. Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.
+#: src/view/com/composer/SelectMediaButton.tsx:501
+msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF."
+msgstr ""
+
+#. Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.
+#: src/view/com/composer/SelectMediaButton.tsx:490
+msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1330
+#: src/view/com/composer/Composer.tsx:1407
 msgid "Opens emoji picker"
 msgstr ""
 
@@ -5933,10 +5964,6 @@ msgstr ""
 msgid "Opens this profile"
 msgstr ""
 
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:75
-msgid "Opens video picker"
-msgstr ""
-
 #: src/components/dms/ReportDialog.tsx:221
 #: src/components/ReportDialog/SubmitView.tsx:168
 msgid "Optionally provide additional information below:"
@@ -6310,12 +6337,12 @@ msgctxt "description"
 msgid "Post"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1040
+#: src/view/com/composer/Composer.tsx:1053
 msgctxt "action"
 msgid "Post"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1038
+#: src/view/com/composer/Composer.tsx:1051
 msgctxt "action"
 msgid "Post All"
 msgstr ""
@@ -6443,7 +6470,7 @@ msgstr ""
 msgid "Press to view followers of this account that you also follow"
 msgstr ""
 
-#: src/view/com/lightbox/Lightbox.web.tsx:150
+#: src/view/com/lightbox/Lightbox.web.tsx:151
 msgid "Previous image"
 msgstr ""
 
@@ -6490,7 +6517,7 @@ msgstr ""
 msgid "Privacy Policy"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1744
+#: src/view/com/composer/Composer.tsx:1821
 msgid "Processing video..."
 msgstr ""
 
@@ -6529,22 +6556,22 @@ msgid "Public, sharable lists which can be used to drive feeds."
 msgstr ""
 
 #. Accessibility label for button to publish a single post
-#: src/view/com/composer/Composer.tsx:1020
+#: src/view/com/composer/Composer.tsx:1033
 msgid "Publish post"
 msgstr ""
 
 #. Accessibility label for button to publish multiple posts in a thread
-#: src/view/com/composer/Composer.tsx:1013
+#: src/view/com/composer/Composer.tsx:1026
 msgid "Publish posts"
 msgstr ""
 
 #. Accessibility label for button to publish multiple replies in a thread
-#: src/view/com/composer/Composer.tsx:998
+#: src/view/com/composer/Composer.tsx:1011
 msgid "Publish replies"
 msgstr ""
 
 #. Accessibility label for button to publish a single reply
-#: src/view/com/composer/Composer.tsx:1005
+#: src/view/com/composer/Composer.tsx:1018
 msgid "Publish reply"
 msgstr ""
 
@@ -6643,7 +6670,7 @@ msgid "Reactivate your account"
 msgstr ""
 
 #: src/screens/PostThread/components/ThreadItemReadMore.tsx:92
-msgid "Read {0} more {1, plural, one {reply} other {replies}}"
+msgid "Read {0, plural, one {# more reply} other {# more replies}}"
 msgstr ""
 
 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:158
@@ -6663,6 +6690,7 @@ msgstr ""
 msgid "Read more replies"
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:29
 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112
 msgid "Read our blog post"
 msgstr ""
@@ -6841,11 +6869,11 @@ msgstr ""
 msgid "Remove your verification for this account?"
 msgstr ""
 
-#: src/components/Post/Embed/index.tsx:208
+#: src/components/Post/Embed/index.tsx:214
 msgid "Removed by author"
 msgstr ""
 
-#: src/components/Post/Embed/index.tsx:206
+#: src/components/Post/Embed/index.tsx:212
 msgid "Removed by you"
 msgstr ""
 
@@ -6911,7 +6939,7 @@ msgstr ""
 msgid "Replies to this post are disabled."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1036
+#: src/view/com/composer/Composer.tsx:1049
 msgctxt "action"
 msgid "Reply"
 msgstr ""
@@ -7239,8 +7267,8 @@ msgstr ""
 #: src/view/com/composer/GifAltText.tsx:202
 #: src/view/com/composer/photos/EditImageDialog.web.tsx:62
 #: src/view/com/composer/photos/EditImageDialog.web.tsx:75
-#: src/view/com/composer/photos/ImageAltTextDialog.tsx:153
-#: src/view/com/composer/photos/ImageAltTextDialog.tsx:163
+#: src/view/com/composer/photos/ImageAltTextDialog.tsx:152
+#: src/view/com/composer/photos/ImageAltTextDialog.tsx:162
 #: src/view/com/modals/CreateOrEditList.tsx:315
 #: src/view/screens/SavedFeeds.tsx:117
 msgid "Save"
@@ -7424,15 +7452,15 @@ msgstr ""
 msgid "See jobs at Bluesky"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:397
+#: src/components/FeedInterstitials.tsx:393
 msgid "See more"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:444
+#: src/components/FeedInterstitials.tsx:437
 msgid "See more accounts you might like"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:395
+#: src/components/FeedInterstitials.tsx:391
 msgid "See more suggested profiles on the Explore page"
 msgstr ""
 
@@ -7440,7 +7468,7 @@ msgstr ""
 msgid "See this guide"
 msgstr ""
 
-#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:195
+#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:197
 msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause"
 msgstr ""
 
@@ -7493,7 +7521,7 @@ msgstr ""
 msgid "Select GIF"
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:307
+#: src/components/dialogs/GifSelect.tsx:305
 msgid "Select GIF \"{0}\""
 msgstr ""
 
@@ -7539,10 +7567,6 @@ msgstr ""
 msgid "Select the moderation service(s) to report to"
 msgstr ""
 
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:74
-msgid "Select video"
-msgstr ""
-
 #: src/components/dialogs/MutedWords.tsx:242
 msgid "Select what content this mute word should apply to."
 msgstr ""
@@ -7572,6 +7596,10 @@ msgstr ""
 msgid "Select your preferred notification channels"
 msgstr ""
 
+#: src/view/com/composer/SelectMediaButton.tsx:393
+msgid "Selecting multiple media types is not supported."
+msgstr ""
+
 #: src/view/com/util/forms/DropdownButton.tsx:302
 msgid "Selects option {0} of {numItems}"
 msgstr ""
@@ -7649,7 +7677,7 @@ msgstr ""
 msgid "Set app icon to {0}"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:333
+#: src/screens/Moderation/index.tsx:352
 msgid "Set birthdate"
 msgstr ""
 
@@ -8015,7 +8043,7 @@ msgstr ""
 msgid "Signed in as @{0}"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:389
+#: src/components/FeedInterstitials.tsx:386
 msgid "Similar accounts"
 msgstr ""
 
@@ -8028,7 +8056,7 @@ msgstr ""
 msgid "Skip this flow"
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:167
+#: src/screens/Settings/AppearanceSettings.tsx:165
 msgid "Smaller"
 msgstr ""
 
@@ -8045,7 +8073,7 @@ msgstr ""
 msgid "Some of your verifications are invalid."
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:529
+#: src/components/FeedInterstitials.tsx:519
 msgid "Some other feeds you might like"
 msgstr ""
 
@@ -8077,7 +8105,7 @@ msgid "Something went wrong, please try again"
 msgstr ""
 
 #: src/components/ReportDialog/index.tsx:54
-#: src/screens/Moderation/index.tsx:111
+#: src/screens/Moderation/index.tsx:112
 #: src/screens/Profile/Sections/Labels.tsx:184
 msgid "Something went wrong, please try again."
 msgstr ""
@@ -8277,7 +8305,7 @@ msgstr ""
 msgid "Suggested Accounts"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:391
+#: src/components/FeedInterstitials.tsx:384
 msgid "Suggested for you"
 msgstr ""
 
@@ -8286,12 +8314,12 @@ msgstr ""
 msgid "Suggestive"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:82
 msgctxt "Name of app icon variant"
 msgid "Sunrise"
 msgstr ""
 
-#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:86
+#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:96
 msgctxt "Name of app icon variant"
 msgid "Sunset"
 msgstr ""
@@ -8324,11 +8352,12 @@ msgstr ""
 
 #: src/components/dialogs/Embed.tsx:157
 #: src/components/dialogs/Embed.tsx:159
-#: src/screens/Settings/AppearanceSettings.tsx:100
-#: src/screens/Settings/AppearanceSettings.tsx:150
+#: src/screens/Settings/AppearanceSettings.tsx:98
+#: src/screens/Settings/AppearanceSettings.tsx:148
 msgid "System"
 msgstr ""
 
+#: src/screens/Log.tsx:58
 #: src/screens/Settings/AboutSettings.tsx:107
 #: src/screens/Settings/AboutSettings.tsx:110
 #: src/screens/Settings/Settings.tsx:441
@@ -8455,7 +8484,7 @@ msgstr ""
 msgid "That starter pack could not be found."
 msgstr ""
 
-#: src/screens/Signup/StepHandle/index.tsx:81
+#: src/screens/Signup/StepHandle/index.tsx:78
 msgid "That username is already taken"
 msgstr ""
 
@@ -8467,7 +8496,7 @@ msgstr ""
 msgid "That's everything!"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:316
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:324
 #: src/view/com/profile/ProfileMenu.tsx:461
 msgid "The account will be able to interact with you after unblocking."
 msgstr ""
@@ -8482,7 +8511,7 @@ msgstr ""
 msgid "The author of this thread has hidden this reply."
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:407
+#: src/screens/Moderation/index.tsx:426
 msgid "The Bluesky web application"
 msgstr ""
 
@@ -8569,7 +8598,7 @@ msgstr ""
 msgid "The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one."
 msgstr ""
 
-#: src/screens/Settings/AppearanceSettings.tsx:154
+#: src/screens/Settings/AppearanceSettings.tsx:152
 msgid "Theme"
 msgstr ""
 
@@ -8577,7 +8606,7 @@ msgstr ""
 msgid "There is no time limit for account deactivation, come back any time."
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:227
+#: src/components/dialogs/GifSelect.tsx:225
 msgid "There was an issue connecting to Tenor."
 msgstr ""
 
@@ -8637,9 +8666,9 @@ msgstr ""
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:361
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:374
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:384
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:98
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:119
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:101
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:123
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:136
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:90
 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:101
 #: src/view/com/profile/ProfileMenu.tsx:128
@@ -8662,7 +8691,7 @@ msgstr ""
 msgid "There was an issue. Please check your internet connection and try again."
 msgstr ""
 
-#: src/components/dialogs/GifSelect.tsx:271
+#: src/components/dialogs/GifSelect.tsx:269
 #: src/view/com/util/ErrorBoundary.tsx:59
 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!"
 msgstr ""
@@ -8840,7 +8869,7 @@ msgstr ""
 msgid "This post will be hidden from feeds and threads. This cannot be undone."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:463
+#: src/view/com/composer/Composer.tsx:470
 msgid "This post's author has disabled quote posts."
 msgstr ""
 
@@ -8974,7 +9003,7 @@ msgstr ""
 msgid "Toggle dropdown"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:384
+#: src/screens/Moderation/index.tsx:403
 msgid "Toggle to enable or disable adult content"
 msgstr ""
 
@@ -9087,14 +9116,14 @@ msgstr ""
 #: src/components/dms/MessagesListBlockedFooter.tsx:104
 #: src/components/dms/MessagesListBlockedFooter.tsx:112
 #: src/components/dms/MessagesListBlockedFooter.tsx:119
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:203
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328
 #: src/view/com/profile/ProfileMenu.tsx:473
 #: src/view/screens/ProfileList.tsx:723
 msgid "Unblock"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:213
 msgctxt "action"
 msgid "Unblock"
 msgstr ""
@@ -9106,7 +9135,7 @@ msgstr ""
 msgid "Unblock account"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:322
 #: src/view/com/profile/ProfileMenu.tsx:455
 msgid "Unblock Account?"
 msgstr ""
@@ -9130,7 +9159,7 @@ msgctxt "action"
 msgid "Unfollow"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:229
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:236
 msgid "Unfollow {0}"
 msgstr ""
 
@@ -9143,6 +9172,10 @@ msgstr ""
 msgid "Unfollows the user"
 msgstr ""
 
+#: src/components/BlockedGeoOverlay.tsx:37
+msgid "Unfortunately, Bluesky is unavailable in Mississippi right now."
+msgstr ""
+
 #: src/components/moderation/ReportDialog/index.tsx:372
 msgid "Unfortunately, none of your subscribed labelers supports this report type."
 msgstr ""
@@ -9256,12 +9289,8 @@ msgstr ""
 msgid "Unsubscribed from list"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:818
-msgid "Unsupported video type"
-msgstr ""
-
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:48
-msgid "Unsupported video type: {0}"
+#: src/view/com/composer/Composer.tsx:829
+msgid "Unsupported video type: {mimeType}"
 msgstr ""
 
 #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:77
@@ -9346,7 +9375,7 @@ msgstr ""
 msgid "Uploading link thumbnail..."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1741
+#: src/view/com/composer/Composer.tsx:1818
 msgid "Uploading video..."
 msgstr ""
 
@@ -9437,15 +9466,15 @@ msgctxt "toast"
 msgid "User list updated"
 msgstr ""
 
-#: src/screens/Signup/StepHandle/index.tsx:235
+#: src/screens/Signup/StepHandle/index.tsx:231
 msgid "Username cannot be longer than {MAX_SERVICE_HANDLE_LENGTH, plural, other {# characters}}"
 msgstr ""
 
-#: src/screens/Signup/StepHandle/index.tsx:219
+#: src/screens/Signup/StepHandle/index.tsx:215
 msgid "Username cannot begin or end with a hyphen"
 msgstr ""
 
-#: src/screens/Signup/StepHandle/index.tsx:223
+#: src/screens/Signup/StepHandle/index.tsx:219
 msgid "Username must only contain letters (a-z), numbers, and hyphens"
 msgstr ""
 
@@ -9482,7 +9511,7 @@ msgstr ""
 msgid "Verification failed, please try again."
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:297
+#: src/screens/Moderation/index.tsx:316
 msgid "Verification settings"
 msgstr ""
 
@@ -9608,7 +9637,7 @@ msgstr ""
 msgid "Video settings"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:1751
+#: src/view/com/composer/Composer.tsx:1828
 msgid "Video uploaded"
 msgstr ""
 
@@ -9620,9 +9649,8 @@ msgstr ""
 msgid "Videos"
 msgstr ""
 
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:42
-#: src/view/com/composer/videos/SelectVideoBtn.tsx:55
-msgid "Videos must be less than 3 minutes long"
+#: src/view/com/composer/SelectMediaButton.tsx:407
+msgid "Videos must be less than 3 minutes long."
 msgstr ""
 
 #: src/screens/Profile/Header/Shell.tsx:229
@@ -9637,7 +9665,7 @@ msgstr ""
 msgid "View {0}'s profile"
 msgstr ""
 
-#: src/components/dms/MessagesListHeader.tsx:181
+#: src/components/dms/MessagesListHeader.tsx:142
 msgid "View {displayName}'s profile"
 msgstr ""
 
@@ -9649,7 +9677,7 @@ msgstr ""
 msgid "View blogpost for more details"
 msgstr ""
 
-#: src/view/screens/Log.tsx:57
+#: src/screens/Log.tsx:81
 msgid "View debug entry"
 msgstr ""
 
@@ -9671,13 +9699,17 @@ msgstr ""
 msgid "View information about these labels"
 msgstr ""
 
-#: src/components/interstitials/TrendingVideos.tsx:189
-#: src/components/interstitials/TrendingVideos.tsx:211
+#: src/components/interstitials/TrendingVideos.tsx:198
+#: src/components/interstitials/TrendingVideos.tsx:220
 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:194
 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:213
 msgid "View more"
 msgstr ""
 
+#: src/components/interstitials/TrendingVideos.tsx:226
+msgid "View more trending videos"
+msgstr ""
+
 #: src/components/ProfileHoverCard/index.web.tsx:466
 #: src/components/ProfileHoverCard/index.web.tsx:486
 #: src/components/ProfileHoverCard/index.web.tsx:513
@@ -9707,11 +9739,11 @@ msgstr ""
 msgid "View video"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:277
+#: src/screens/Moderation/index.tsx:296
 msgid "View your blocked accounts"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:217
+#: src/screens/Moderation/index.tsx:236
 msgid "View your default post interaction settings"
 msgstr ""
 
@@ -9720,11 +9752,11 @@ msgstr ""
 msgid "View your feeds and explore more"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:247
+#: src/screens/Moderation/index.tsx:266
 msgid "View your moderation lists"
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:262
+#: src/screens/Moderation/index.tsx:281
 msgid "View your muted accounts"
 msgstr ""
 
@@ -9829,7 +9861,7 @@ msgstr ""
 msgid "We were unable to load your birth date preferences. Please try again."
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:464
+#: src/screens/Moderation/index.tsx:483
 msgid "We were unable to load your configured labelers at this time."
 msgstr ""
 
@@ -9894,7 +9926,7 @@ msgstr ""
 msgid "We're sorry, but your search could not be completed. Please try again in a few minutes."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:460
+#: src/view/com/composer/Composer.tsx:467
 msgid "We're sorry! The post you are replying to has been deleted."
 msgstr ""
 
@@ -9945,7 +9977,7 @@ msgstr ""
 
 #: src/view/com/auth/SplashScreen.tsx:38
 #: src/view/com/auth/SplashScreen.web.tsx:99
-#: src/view/com/composer/Composer.tsx:781
+#: src/view/com/composer/Composer.tsx:789
 msgid "What's up?"
 msgstr ""
 
@@ -10027,11 +10059,11 @@ msgstr ""
 msgid "Write a message"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:876
+#: src/view/com/composer/Composer.tsx:889
 msgid "Write post"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:779
+#: src/view/com/composer/Composer.tsx:787
 #: src/view/com/post-thread/PostThreadComposePrompt.tsx:90
 msgid "Write your reply"
 msgstr ""
@@ -10170,10 +10202,23 @@ msgstr ""
 msgid "You can now sign in with your new password."
 msgstr ""
 
+#: src/view/com/composer/SelectMediaButton.tsx:410
+msgid "You can only select one GIF at a time."
+msgstr ""
+
+#: src/view/com/composer/SelectMediaButton.tsx:404
+msgid "You can only select one video at a time."
+msgstr ""
+
 #: src/screens/Deactivated.tsx:133
 msgid "You can reactivate your account to continue logging in. Your profile and posts will be visible to other users."
 msgstr ""
 
+#. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.
+#: src/view/com/composer/SelectMediaButton.tsx:396
+msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total."
+msgstr ""
+
 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:85
 msgid "You can set default interaction settings in <0>Settings → Moderation → Interaction settings</0>."
 msgstr ""
@@ -10316,10 +10361,6 @@ msgstr ""
 msgid "You may only add up to 3 feeds"
 msgstr ""
 
-#: src/lib/media/picker.shared.ts:25
-msgid "You may only select up to 4 images"
-msgstr ""
-
 #: src/screens/Signup/StepInfo/Policies.tsx:136
 msgid "You must be 13 years of age or older to create an account."
 msgstr ""
@@ -10328,7 +10369,7 @@ msgstr ""
 msgid "You must be following at least seven other people to generate a starter pack."
 msgstr ""
 
-#: src/screens/Moderation/index.tsx:355
+#: src/screens/Moderation/index.tsx:374
 msgid "You must complete age assurance in order to access the settings below."
 msgstr ""
 
@@ -10349,6 +10390,10 @@ msgstr ""
 msgid "You must sign in to view this post."
 msgstr ""
 
+#: src/view/com/composer/SelectMediaButton.tsx:439
+msgid "You need to allow access to your media library."
+msgstr ""
+
 #: src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx:23
 msgid "You need to verify your email address before you can enable email 2FA."
 msgstr ""
@@ -10524,6 +10569,10 @@ msgstr ""
 msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account."
 msgstr ""
 
+#: src/screens/Moderation/index.tsx:208
+msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your birthdate in your <0>account settings</0>."
+msgstr ""
+
 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:253
 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:257
 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:258
@@ -10586,11 +10635,11 @@ msgstr ""
 msgid "Your password must be at least 8 characters long."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:522
+#: src/view/com/composer/Composer.tsx:529
 msgid "Your post has been published"
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:519
+#: src/view/com/composer/Composer.tsx:526
 msgid "Your posts have been published"
 msgstr ""
 
@@ -10606,7 +10655,7 @@ msgstr ""
 msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in."
 msgstr ""
 
-#: src/view/com/composer/Composer.tsx:521
+#: src/view/com/composer/Composer.tsx:528
 msgid "Your reply has been published"
 msgstr ""
 
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 0c9ea1ef6..e51905f84 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -475,4 +475,11 @@ export type MetricEvents = {
   'ageAssurance:redirectDialogFail': {}
   'ageAssurance:appealDialogOpen': {}
   'ageAssurance:appealDialogSubmit': {}
+
+  /*
+   * Specifically for the `BlockedGeoOverlay`
+   */
+  'blockedGeoOverlay:shown': {}
+
+  'geo:debug': {}
 }
diff --git a/src/screens/Log.tsx b/src/screens/Log.tsx
new file mode 100644
index 000000000..2dd7fe84c
--- /dev/null
+++ b/src/screens/Log.tsx
@@ -0,0 +1,128 @@
+import {useCallback, useState} from 'react'
+import {LayoutAnimation, View} from 'react-native'
+import {Pressable} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {getEntries} from '#/logger/logDump'
+import {useTickEveryMinute} from '#/state/shell'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {atoms as a, useTheme} from '#/alf'
+import {
+  ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
+  ChevronTop_Stroke2_Corner0_Rounded as ChevronTopIcon,
+} from '#/components/icons/Chevron'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+
+export function LogScreen({}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'Log'
+>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const [expanded, setExpanded] = useState<string[]>([])
+  const timeAgo = useGetTimeAgo()
+  const tick = useTickEveryMinute()
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  const toggler = (id: string) => () => {
+    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+    if (expanded.includes(id)) {
+      setExpanded(expanded.filter(v => v !== id))
+    } else {
+      setExpanded([...expanded, id])
+    }
+  }
+
+  return (
+    <Layout.Screen>
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>System log</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot />
+      </Layout.Header.Outer>
+      <Layout.Content>
+        {getEntries()
+          .slice(0)
+          .map(entry => {
+            return (
+              <View key={`entry-${entry.id}`}>
+                <Pressable
+                  style={[
+                    a.flex_row,
+                    a.align_center,
+                    a.py_md,
+                    a.px_sm,
+                    a.border_b,
+                    t.atoms.border_contrast_low,
+                    t.atoms.bg,
+                    a.gap_sm,
+                  ]}
+                  onPress={toggler(entry.id)}
+                  accessibilityLabel={_(msg`View debug entry`)}
+                  accessibilityHint={_(
+                    msg`Opens additional details for a debug entry`,
+                  )}>
+                  {entry.level === 'warn' || entry.level === 'error' ? (
+                    <WarningIcon size="sm" fill={t.palette.negative_500} />
+                  ) : (
+                    <CircleInfoIcon size="sm" />
+                  )}
+                  <Text style={[a.flex_1]}>{String(entry.message)}</Text>
+                  {entry.metadata &&
+                    Object.keys(entry.metadata).length > 0 &&
+                    (expanded.includes(entry.id) ? (
+                      <ChevronTopIcon
+                        size="sm"
+                        style={[t.atoms.text_contrast_low]}
+                      />
+                    ) : (
+                      <ChevronBottomIcon
+                        size="sm"
+                        style={[t.atoms.text_contrast_low]}
+                      />
+                    ))}
+                  <Text style={[{minWidth: 40}, t.atoms.text_contrast_medium]}>
+                    {timeAgo(entry.timestamp, tick)}
+                  </Text>
+                </Pressable>
+                {expanded.includes(entry.id) && (
+                  <View
+                    style={[
+                      t.atoms.bg_contrast_25,
+                      a.rounded_xs,
+                      a.p_sm,
+                      a.border_b,
+                      t.atoms.border_contrast_low,
+                    ]}>
+                    <View style={[a.px_sm, a.py_xs]}>
+                      <Text>{JSON.stringify(entry.metadata, null, 2)}</Text>
+                    </View>
+                  </View>
+                )}
+              </View>
+            )
+          })}
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
index 1517792a1..983919c64 100644
--- a/src/screens/Moderation/index.tsx
+++ b/src/screens/Moderation/index.tsx
@@ -22,6 +22,7 @@ import {
 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
+import {Admonition} from '#/components/Admonition'
 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
@@ -201,6 +202,24 @@ export function ModerationScreenInner({
 
   return (
     <View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
+      {isDeclaredUnderage && (
+        <View style={[a.pb_2xl]}>
+          <Admonition type="tip" style={[a.pb_md]}>
+            <Trans>
+              Your declared age is under 18. Some settings below may be
+              disabled. If this was a mistake, you may edit your birthdate in
+              your{' '}
+              <InlineLinkText
+                to="/settings/account"
+                label={_(msg`Go to account settings`)}>
+                account settings
+              </InlineLinkText>
+              .
+            </Trans>
+          </Admonition>
+        </View>
+      )}
+
       <Text
         style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
         <Trans>Moderation tools</Trans>
diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx
index 22ae63395..66ec11cb7 100644
--- a/src/screens/PostThread/components/ThreadItemReadMore.tsx
+++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx
@@ -90,10 +90,10 @@ export const ThreadItemReadMore = memo(function ThreadItemReadMore({
                   interacted && a.underline,
                 ]}>
                 <Trans>
-                  Read {item.moreReplies} more{' '}
+                  Read{' '}
                   <Plural
-                    one="reply"
-                    other="replies"
+                    one="# more reply"
+                    other="# more replies"
                     value={item.moreReplies}
                   />
                 </Trans>
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2f61ba4df..32111dd3b 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -1,4 +1,4 @@
-import React, {memo, useMemo} from 'react'
+import {memo, useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
 import {
   type AppBskyActorDefs,
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
 import {ProfileHeaderHandle} from './Handle'
 import {ProfileHeaderMetrics} from './Metrics'
 import {ProfileHeaderShell} from './Shell'
+import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
 
 interface Props {
   profile: AppBskyActorDefs.ProfileViewDetailed
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
   const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
   const unblockPromptControl = Prompt.usePromptControl()
   const requireAuth = useRequireAuth()
+  const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
   const isBlockedUser =
     profile.viewer?.blocking ||
     profile.viewer?.blockedBy ||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
   const editProfileControl = useDialogControl()
 
   const onPressFollow = () => {
+    setShowSuggestedFollows(true)
     requireAuth(async () => {
       try {
         await queueFollow()
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
   }
 
   const onPressUnfollow = () => {
+    setShowSuggestedFollows(false)
     requireAuth(async () => {
       try {
         await queueUnfollow()
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
     })
   }
 
-  const unblockAccount = React.useCallback(async () => {
+  const unblockAccount = useCallback(async () => {
     try {
       await queueUnblock()
       Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
@@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({
   }, [profile])
 
   return (
-    <ProfileHeaderShell
-      profile={profile}
-      moderation={moderation}
-      hideBackButton={hideBackButton}
-      isPlaceholderProfile={isPlaceholderProfile}>
-      <View
-        style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
-        pointerEvents={isIOS ? 'auto' : 'box-none'}>
+    <>
+      <ProfileHeaderShell
+        profile={profile}
+        moderation={moderation}
+        hideBackButton={hideBackButton}
+        isPlaceholderProfile={isPlaceholderProfile}>
         <View
-          style={[
-            {paddingLeft: 90},
-            a.flex_row,
-            a.align_center,
-            a.justify_end,
-            a.gap_xs,
-            a.pb_sm,
-            a.flex_wrap,
-          ]}
+          style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
-          {isMe ? (
-            <>
-              <Button
-                testID="profileHeaderEditProfileButton"
-                size="small"
-                color="secondary"
-                variant="solid"
-                onPress={editProfileControl.open}
-                label={_(msg`Edit profile`)}
-                style={[a.rounded_full]}>
-                <ButtonText>
-                  <Trans>Edit Profile</Trans>
-                </ButtonText>
-              </Button>
-              <EditProfileDialog
-                profile={profile}
-                control={editProfileControl}
-              />
-            </>
-          ) : profile.viewer?.blocking ? (
-            profile.viewer?.blockingByList ? null : (
-              <Button
-                testID="unblockBtn"
-                size="small"
-                color="secondary"
-                variant="solid"
-                label={_(msg`Unblock`)}
-                disabled={!hasSession}
-                onPress={() => unblockPromptControl.open()}
-                style={[a.rounded_full]}>
-                <ButtonText>
-                  <Trans context="action">Unblock</Trans>
-                </ButtonText>
-              </Button>
-            )
-          ) : !profile.viewer?.blockedBy ? (
-            <>
-              {hasSession && subscriptionsAllowed && (
-                <SubscribeProfileButton
+          <View
+            style={[
+              {paddingLeft: 90},
+              a.flex_row,
+              a.align_center,
+              a.justify_end,
+              a.gap_xs,
+              a.pb_sm,
+              a.flex_wrap,
+            ]}
+            pointerEvents={isIOS ? 'auto' : 'box-none'}>
+            {isMe ? (
+              <>
+                <Button
+                  testID="profileHeaderEditProfileButton"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  onPress={editProfileControl.open}
+                  label={_(msg`Edit profile`)}
+                  style={[a.rounded_full]}>
+                  <ButtonText>
+                    <Trans>Edit Profile</Trans>
+                  </ButtonText>
+                </Button>
+                <EditProfileDialog
                   profile={profile}
-                  moderationOpts={moderationOpts}
+                  control={editProfileControl}
                 />
-              )}
-              {hasSession && <MessageProfileButton profile={profile} />}
-
-              <Button
-                testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
-                size="small"
-                color={profile.viewer?.following ? 'secondary' : 'primary'}
-                variant="solid"
-                label={
-                  profile.viewer?.following
-                    ? _(msg`Unfollow ${profile.handle}`)
-                    : _(msg`Follow ${profile.handle}`)
-                }
-                onPress={
-                  profile.viewer?.following ? onPressUnfollow : onPressFollow
-                }
-                style={[a.rounded_full]}>
-                {!profile.viewer?.following && (
-                  <ButtonIcon position="left" icon={Plus} />
+              </>
+            ) : profile.viewer?.blocking ? (
+              profile.viewer?.blockingByList ? null : (
+                <Button
+                  testID="unblockBtn"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  label={_(msg`Unblock`)}
+                  disabled={!hasSession}
+                  onPress={() => unblockPromptControl.open()}
+                  style={[a.rounded_full]}>
+                  <ButtonText>
+                    <Trans context="action">Unblock</Trans>
+                  </ButtonText>
+                </Button>
+              )
+            ) : !profile.viewer?.blockedBy ? (
+              <>
+                {hasSession && subscriptionsAllowed && (
+                  <SubscribeProfileButton
+                    profile={profile}
+                    moderationOpts={moderationOpts}
+                  />
                 )}
-                <ButtonText>
-                  {profile.viewer?.following ? (
-                    <Trans>Following</Trans>
-                  ) : profile.viewer?.followedBy ? (
-                    <Trans>Follow Back</Trans>
-                  ) : (
-                    <Trans>Follow</Trans>
+                {hasSession && <MessageProfileButton profile={profile} />}
+
+                <Button
+                  testID={
+                    profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
+                  }
+                  size="small"
+                  color={profile.viewer?.following ? 'secondary' : 'primary'}
+                  variant="solid"
+                  label={
+                    profile.viewer?.following
+                      ? _(msg`Unfollow ${profile.handle}`)
+                      : _(msg`Follow ${profile.handle}`)
+                  }
+                  onPress={
+                    profile.viewer?.following ? onPressUnfollow : onPressFollow
+                  }
+                  style={[a.rounded_full]}>
+                  {!profile.viewer?.following && (
+                    <ButtonIcon position="left" icon={Plus} />
                   )}
-                </ButtonText>
-              </Button>
-            </>
-          ) : null}
-          <ProfileMenu profile={profile} />
-        </View>
-        <View
-          style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
-          <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
-            <Text
-              emoji
-              testID="profileHeaderDisplayName"
-              style={[
-                t.atoms.text,
-                gtMobile ? a.text_4xl : a.text_3xl,
-                a.self_start,
-                a.font_heavy,
-                a.leading_tight,
-              ]}>
-              {sanitizeDisplayName(
-                profile.displayName || sanitizeHandle(profile.handle),
-                moderation.ui('displayName'),
-              )}
-              <View
+                  <ButtonText>
+                    {profile.viewer?.following ? (
+                      <Trans>Following</Trans>
+                    ) : profile.viewer?.followedBy ? (
+                      <Trans>Follow back</Trans>
+                    ) : (
+                      <Trans>Follow</Trans>
+                    )}
+                  </ButtonText>
+                </Button>
+              </>
+            ) : null}
+            <ProfileMenu profile={profile} />
+          </View>
+          <View
+            style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
+            <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
+              <Text
+                emoji
+                testID="profileHeaderDisplayName"
                 style={[
-                  a.pl_xs,
-                  {
-                    marginTop: platform({ios: 2}),
-                  },
+                  t.atoms.text,
+                  gtMobile ? a.text_4xl : a.text_3xl,
+                  a.self_start,
+                  a.font_heavy,
+                  a.leading_tight,
                 ]}>
-                <VerificationCheckButton profile={profile} size="lg" />
-              </View>
-            </Text>
+                {sanitizeDisplayName(
+                  profile.displayName || sanitizeHandle(profile.handle),
+                  moderation.ui('displayName'),
+                )}
+                <View
+                  style={[
+                    a.pl_xs,
+                    {
+                      marginTop: platform({ios: 2}),
+                    },
+                  ]}>
+                  <VerificationCheckButton profile={profile} size="lg" />
+                </View>
+              </Text>
+            </View>
+            <ProfileHeaderHandle profile={profile} />
           </View>
-          <ProfileHeaderHandle profile={profile} />
-        </View>
-        {!isPlaceholderProfile && !isBlockedUser && (
-          <View style={a.gap_md}>
-            <ProfileHeaderMetrics profile={profile} />
-            {descriptionRT && !moderation.ui('profileView').blur ? (
-              <View pointerEvents="auto">
-                <RichText
-                  testID="profileHeaderDescription"
-                  style={[a.text_md]}
-                  numberOfLines={15}
-                  value={descriptionRT}
-                  enableTags
-                  authorHandle={profile.handle}
-                />
-              </View>
-            ) : undefined}
-
-            {!isMe &&
-              !isBlockedUser &&
-              shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
-                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-                  <KnownFollowers
-                    profile={profile}
-                    moderationOpts={moderationOpts}
+          {!isPlaceholderProfile && !isBlockedUser && (
+            <View style={a.gap_md}>
+              <ProfileHeaderMetrics profile={profile} />
+              {descriptionRT && !moderation.ui('profileView').blur ? (
+                <View pointerEvents="auto">
+                  <RichText
+                    testID="profileHeaderDescription"
+                    style={[a.text_md]}
+                    numberOfLines={15}
+                    value={descriptionRT}
+                    enableTags
+                    authorHandle={profile.handle}
                   />
                 </View>
-              )}
-          </View>
-        )}
-      </View>
-      <Prompt.Basic
-        control={unblockPromptControl}
-        title={_(msg`Unblock Account?`)}
-        description={_(
-          msg`The account will be able to interact with you after unblocking.`,
-        )}
-        onConfirm={unblockAccount}
-        confirmButtonCta={
-          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
-        }
-        confirmButtonColor="negative"
+              ) : undefined}
+
+              {!isMe &&
+                !isBlockedUser &&
+                shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <KnownFollowers
+                      profile={profile}
+                      moderationOpts={moderationOpts}
+                    />
+                  </View>
+                )}
+            </View>
+          )}
+        </View>
+
+        <Prompt.Basic
+          control={unblockPromptControl}
+          title={_(msg`Unblock Account?`)}
+          description={_(
+            msg`The account will be able to interact with you after unblocking.`,
+          )}
+          onConfirm={unblockAccount}
+          confirmButtonCta={
+            profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+          }
+          confirmButtonColor="negative"
+        />
+      </ProfileHeaderShell>
+
+      <AnimatedProfileHeaderSuggestedFollows
+        isExpanded={showSuggestedFollows}
+        actorDid={profile.did}
       />
-    </ProfileHeaderShell>
+    </>
   )
 }
+
 ProfileHeaderStandard = memo(ProfileHeaderStandard)
 export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 167be0aa8..cff0a707c 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
 
       {!isPlaceholderProfile && (
         <View
-          style={[a.px_lg, a.py_xs]}
+          style={[a.px_lg, a.pt_xs, a.pb_sm]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
           {isMe ? (
             <LabelsOnMe type="account" labels={profile.labels} />
diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx
new file mode 100644
index 000000000..d005d888e
--- /dev/null
+++ b/src/screens/Profile/Header/SuggestedFollows.tsx
@@ -0,0 +1,45 @@
+import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
+import {useGate} from '#/lib/statsig/statsig'
+import {isAndroid} from '#/platform/detection'
+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
+import {ProfileGrid} from '#/components/FeedInterstitials'
+
+export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
+  const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
+    did: actorDid,
+  })
+
+  return (
+    <ProfileGrid
+      isSuggestionsLoading={isLoading}
+      profiles={data?.suggestions ?? []}
+      recId={data?.recId}
+      error={error}
+      viewContext="profileHeader"
+    />
+  )
+}
+
+export function AnimatedProfileHeaderSuggestedFollows({
+  isExpanded,
+  actorDid,
+}: {
+  isExpanded: boolean
+  actorDid: string
+}) {
+  const gate = useGate()
+  if (!gate('post_follow_profile_suggested_accounts')) return null
+
+  /* NOTE (caidanw):
+   * Android does not work well with this feature yet.
+   * This issue stems from Android not allowing dragging on clickable elements in the profile header.
+   * Blocking the ability to scroll on Android is too much of a trade-off for now.
+   **/
+  if (isAndroid) return null
+
+  return (
+    <AccordionAnimation isExpanded={isExpanded}>
+      <ProfileHeaderSuggestedFollows actorDid={actorDid} />
+    </AccordionAnimation>
+  )
+}
diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx
index 799873c2d..953ae2e60 100644
--- a/src/screens/Settings/AppIconSettings/index.tsx
+++ b/src/screens/Settings/AppIconSettings/index.tsx
@@ -28,7 +28,7 @@ export function AppIconSettingsScreen({}: Props) {
     getAppIconName(DynamicAppIcon.getAppIcon()),
   )
 
-  const onSetAppIcon = (icon: string) => {
+  const onSetAppIcon = (icon: DynamicAppIcon.IconName) => {
     if (isAndroid) {
       const next =
         sets.defaults.find(i => i.id === icon) ??
@@ -37,7 +37,7 @@ export function AppIconSettingsScreen({}: Props) {
         next
           ? _(msg`Change app icon to "${next.name}"`)
           : _(msg`Change app icon`),
-        // to determine - can we stop this happening? -sfn
+        // unfortunately necessary -sfn
         _(msg`The app will be restarted`),
         [
           {
@@ -119,7 +119,7 @@ export function AppIconSettingsScreen({}: Props) {
   )
 }
 
-function setAppIcon(icon: string) {
+function setAppIcon(icon: DynamicAppIcon.IconName) {
   if (icon === 'default_light') {
     return getAppIconName(DynamicAppIcon.setAppIcon(null))
   } else {
@@ -127,11 +127,11 @@ function setAppIcon(icon: string) {
   }
 }
 
-function getAppIconName(icon: string | false) {
+function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
   if (!icon || icon === 'DEFAULT') {
     return 'default_light'
   } else {
-    return icon
+    return icon as DynamicAppIcon.IconName
   }
 }
 
@@ -143,8 +143,8 @@ function Group({
 }: {
   children: React.ReactNode
   label: string
-  value: string
-  onChange: (value: string) => void
+  value: DynamicAppIcon.IconName
+  onChange: (value: DynamicAppIcon.IconName) => void
 }) {
   return (
     <Toggle.Group
@@ -153,7 +153,7 @@ function Group({
       values={[value]}
       maxSelections={1}
       onChange={vals => {
-        if (vals[0]) onChange(vals[0])
+        if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName)
       }}>
       <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
         {children}
diff --git a/src/screens/Settings/AppIconSettings/types.ts b/src/screens/Settings/AppIconSettings/types.ts
index 5010f6f02..02c2791dc 100644
--- a/src/screens/Settings/AppIconSettings/types.ts
+++ b/src/screens/Settings/AppIconSettings/types.ts
@@ -1,7 +1,8 @@
-import {ImageSourcePropType} from 'react-native'
+import {type ImageSourcePropType} from 'react-native'
+import type * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
 
 export type AppIconSet = {
-  id: string
+  id: DynamicAppIcon.IconName
   name: string
   iosImage: () => ImageSourcePropType
   androidImage: () => ImageSourcePropType
diff --git a/src/screens/Settings/AppIconSettings/useAppIconSets.ts b/src/screens/Settings/AppIconSettings/useAppIconSets.ts
index fd3caeb30..f7d191f77 100644
--- a/src/screens/Settings/AppIconSettings/useAppIconSets.ts
+++ b/src/screens/Settings/AppIconSettings/useAppIconSets.ts
@@ -37,6 +37,16 @@ export function useAppIconSets() {
           )
         },
       },
+      {
+        id: 'next',
+        name: _(msg({context: 'Name of app icon variant', message: 'Next'})),
+        iosImage: () => {
+          return require(`../../../../assets/app-icons/icon_default_next.png`)
+        },
+        androidImage: () => {
+          return require(`../../../../assets/app-icons/icon_default_next.png`)
+        },
+      },
     ] satisfies AppIconSet[]
 
     /**
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
index 492d6d172..5d597ff8e 100644
--- a/src/screens/Settings/AppearanceSettings.tsx
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -12,7 +12,6 @@ import {
   type CommonNavigatorParams,
   type NativeStackScreenProps,
 } from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
 import {isNative} from '#/platform/detection'
 import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
@@ -32,7 +31,6 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
 export function AppearanceSettingsScreen({}: Props) {
   const {_} = useLingui()
   const {fonts} = useAlf()
-  const gate = useGate()
 
   const {colorMode, darkTheme} = useThemePrefs()
   const {setColorMode, setDarkTheme} = useSetThemePrefs()
@@ -180,7 +178,7 @@ export function AppearanceSettingsScreen({}: Props) {
                 onChange={onChangeFontScale}
               />
 
-              {isNative && IS_INTERNAL && gate('debug_subscriptions') && (
+              {isNative && IS_INTERNAL && (
                 <>
                   <SettingsList.Divider />
                   <AppIconSettingsListItem />
diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx
index aaab435ae..5bf6b2269 100644
--- a/src/screens/Signup/StepHandle/index.tsx
+++ b/src/screens/Signup/StepHandle/index.tsx
@@ -9,7 +9,6 @@ import Animated, {
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useGate} from '#/lib/statsig/statsig'
 import {
   createFullHandle,
   MAX_SERVICE_HANDLE_LENGTH,
@@ -28,14 +27,12 @@ import {useThrottledValue} from '#/components/hooks/useThrottledValue'
 import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
 import {Text} from '#/components/Typography'
-import {IS_INTERNAL} from '#/env'
 import {BackNextButtons} from '../BackNextButtons'
 import {HandleSuggestions} from './HandleSuggestions'
 
 export function StepHandle() {
   const {_} = useLingui()
   const t = useTheme()
-  const gate = useGate()
   const {state, dispatch} = useSignupContext()
   const [draftValue, setDraftValue] = useState(state.handle)
   const isNextLoading = useThrottledValue(state.isLoading, 500)
@@ -193,8 +190,7 @@ export function StepHandle() {
                   </RequirementText>
                 </Requirement>
                 {isHandleAvailable.suggestions &&
-                  isHandleAvailable.suggestions.length > 0 &&
-                  (gate('handle_suggestions') || IS_INTERNAL) && (
+                  isHandleAvailable.suggestions.length > 0 && (
                     <HandleSuggestions
                       suggestions={isHandleAvailable.suggestions}
                       onSelect={suggestion => {
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index d7f1eb8b9..8cc3dca1a 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -24,6 +24,7 @@ export interface PostShadow {
   isDeleted: boolean
   embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
   pinned: boolean
+  optimisticReplyCount: number | undefined
 }
 
 export const POST_TOMBSTONE = Symbol('PostTombstone')
@@ -34,6 +35,14 @@ const shadows: WeakMap<
   Partial<PostShadow>
 > = new WeakMap()
 
+/**
+ * Use with caution! This function returns the raw shadow data for a post.
+ * Prefer using `usePostShadow`.
+ */
+export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) {
+  return shadows.get(post)
+}
+
 export function usePostShadow(
   post: AppBskyFeedDefs.PostView,
 ): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
@@ -95,6 +104,11 @@ function mergeShadow(
     repostCount = Math.max(0, repostCount)
   }
 
+  let replyCount = post.replyCount ?? 0
+  if ('optimisticReplyCount' in shadow) {
+    replyCount = shadow.optimisticReplyCount ?? replyCount
+  }
+
   let embed: typeof post.embed
   if ('embed' in shadow) {
     if (
@@ -112,6 +126,7 @@ function mergeShadow(
     embed: embed || post.embed,
     likeCount: likeCount,
     repostCount: repostCount,
+    replyCount: replyCount,
     viewer: {
       ...(post.viewer || {}),
       like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index ee381259d..8b235f492 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -11,6 +11,7 @@ import {type AppBskyFeedDefs} from '@atproto/api'
 import throttle from 'lodash.throttle'
 
 import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
+import {isNetworkError} from '#/lib/hooks/useCleanError'
 import {logEvent} from '#/lib/statsig/statsig'
 import {Logger} from '#/logger'
 import {
@@ -83,7 +84,9 @@ export function useFeedFeedback(
         },
       )
       .catch((e: any) => {
-        logger.warn('Failed to send feed interactions', {error: e})
+        if (!isNetworkError(e)) {
+          logger.warn('Failed to send feed interactions', {error: e})
+        }
       })
 
     // Send to Statsig
diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx
index 4581996a0..c4d8cb946 100644
--- a/src/state/geolocation.tsx
+++ b/src/state/geolocation.tsx
@@ -5,6 +5,9 @@ import {networkRetry} from '#/lib/async/retry'
 import {logger} from '#/logger'
 import {type Device, device} from '#/storage'
 
+const IPCC_URL = `https://bsky.app/ipcc`
+const BAPP_CONFIG_URL = `https://ip.bsky.app/config`
+
 const events = new EventEmitter()
 const EVENT = 'geolocation-updated'
 const emitGeolocationUpdate = (geolocation: Device['geolocation']) => {
@@ -25,11 +28,22 @@ const onGeolocationUpdate = (
  */
 export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
   countryCode: undefined,
+  isAgeBlockedGeo: undefined,
   isAgeRestrictedGeo: false,
 }
 
-async function getGeolocation(): Promise<Device['geolocation']> {
-  const res = await fetch(`https://bsky.app/ipcc`)
+function sanitizeGeolocation(
+  geolocation: Device['geolocation'],
+): Device['geolocation'] {
+  return {
+    countryCode: geolocation?.countryCode ?? undefined,
+    isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false,
+    isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false,
+  }
+}
+
+async function getGeolocation(url: string): Promise<Device['geolocation']> {
+  const res = await fetch(url)
 
   if (!res.ok) {
     throw new Error(`geolocation: lookup failed ${res.status}`)
@@ -40,13 +54,41 @@ async function getGeolocation(): Promise<Device['geolocation']> {
   if (json.countryCode) {
     return {
       countryCode: json.countryCode,
+      isAgeBlockedGeo: json.isAgeBlockedGeo ?? false,
       isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
+      // @ts-ignore
+      regionCode: json.regionCode ?? undefined,
     }
   } else {
     return undefined
   }
 }
 
+async function compareWithIPCC(bapp: Device['geolocation']) {
+  try {
+    const ipcc = await getGeolocation(IPCC_URL)
+
+    if (!ipcc || !bapp) return
+
+    logger.metric(
+      'geo:debug',
+      {
+        bappCountryCode: bapp.countryCode,
+        // @ts-ignore
+        bappRegionCode: bapp.regionCode,
+        bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo,
+        bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo,
+        ipccCountryCode: ipcc.countryCode,
+        ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo,
+        ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo,
+      },
+      {
+        statsig: false,
+      },
+    )
+  } catch {}
+}
+
 /**
  * Local promise used within this file only.
  */
@@ -79,11 +121,12 @@ export function beginResolveGeolocation() {
 
     try {
       // Try once, fail fast
-      const geolocation = await getGeolocation()
+      const geolocation = await getGeolocation(BAPP_CONFIG_URL)
       if (geolocation) {
-        device.set(['geolocation'], geolocation)
+        device.set(['geolocation'], sanitizeGeolocation(geolocation))
         emitGeolocationUpdate(geolocation)
         logger.debug(`geolocation: success`, {geolocation})
+        compareWithIPCC(geolocation)
       } else {
         // endpoint should throw on all failures, this is insurance
         throw new Error(`geolocation: nothing returned from initial request`)
@@ -99,13 +142,14 @@ export function beginResolveGeolocation() {
       device.set(['geolocation'], DEFAULT_GEOLOCATION)
 
       // retry 3 times, but don't await, proceed with default
-      networkRetry(3, getGeolocation)
+      networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL))
         .then(geolocation => {
           if (geolocation) {
-            device.set(['geolocation'], geolocation)
+            device.set(['geolocation'], sanitizeGeolocation(geolocation))
             emitGeolocationUpdate(geolocation)
             logger.debug(`geolocation: success`, {geolocation})
             success = true
+            compareWithIPCC(geolocation)
           } else {
             // endpoint should throw on all failures, this is insurance
             throw new Error(`geolocation: nothing returned from retries`)
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 0a2343150..c7a6e5f75 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -1,13 +1,13 @@
 import {
-  AppBskyActorDefs,
-  AppBskyActorGetSuggestions,
-  AppBskyGraphGetSuggestedFollowsByActor,
+  type AppBskyActorDefs,
+  type AppBskyActorGetSuggestions,
+  type AppBskyGraphGetSuggestedFollowsByActor,
   moderateProfile,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
-  QueryKey,
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
   useInfiniteQuery,
   useQuery,
 } from '@tanstack/react-query'
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
 export function useSuggestedFollowsByActorQuery({
   did,
   enabled,
+  staleTime = STALE.MINUTES.FIVE,
 }: {
   did: string
   enabled?: boolean
+  staleTime?: number
 }) {
   const agent = useAgent()
   return useQuery({
+    staleTime,
     queryKey: suggestedFollowsByActorQueryKey(did),
     queryFn: async () => {
       const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts
index 826932349..5e27ebb87 100644
--- a/src/state/queries/usePostThread/queryCache.ts
+++ b/src/state/queries/usePostThread/queryCache.ts
@@ -9,6 +9,10 @@ import {
 } from '@atproto/api'
 import {type QueryClient} from '@tanstack/react-query'
 
+import {
+  dangerousGetPostShadow,
+  updatePostShadow,
+} from '#/state/cache/post-shadow'
 import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
 import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
@@ -85,10 +89,27 @@ export function createCacheMutator({
           /*
            * Update parent data
            */
-          parent.value.post = {
-            ...parent.value.post,
-            replyCount: (parent.value.post.replyCount || 0) + 1,
-          }
+          const shadow = dangerousGetPostShadow(parent.value.post)
+          const prevOptimisticCount = shadow?.optimisticReplyCount
+          const prevReplyCount = parent.value.post.replyCount
+          // prefer optimistic count, if we already have some
+          const currentReplyCount =
+            (prevOptimisticCount ?? prevReplyCount ?? 0) + 1
+
+          /*
+           * We must update the value in the query cache in order for thread
+           * traversal to properly compute required metadata.
+           */
+          parent.value.post.replyCount = currentReplyCount
+
+          /**
+           * Additionally, we need to update the post shadow to keep track of
+           * these new values, since mutating the post object above does not
+           * cause a re-render.
+           */
+          updatePostShadow(queryClient, parent.value.post.uri, {
+            optimisticReplyCount: currentReplyCount,
+          })
 
           const opDid = getRootPostAtUri(parent.value.post)?.host
           const nextPreexistingItem = thread.at(i + 1)
diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts
index 2809d32e9..2e7693fab 100644
--- a/src/state/queries/usePostThread/traversal.ts
+++ b/src/state/queries/usePostThread/traversal.ts
@@ -307,9 +307,16 @@ export function sortAndAnnotateThreadItems(
               metadata.isPartOfLastBranchFromDepth = metadata.depth
 
               /**
-               * If the parent is part of the last branch of the sub-tree, so is the child.
+               * If the parent is part of the last branch of the sub-tree, so
+               * is the child. However, if the child is also a last sibling,
+               * then we need to start tracking `isPartOfLastBranchFromDepth`
+               * from this point onwards, always updating it to the depth of
+               * the last sibling as we go down.
                */
-              if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
+              if (
+                !metadata.isLastSibling &&
+                metadata.parentMetadata.isPartOfLastBranchFromDepth
+              ) {
                 metadata.isPartOfLastBranchFromDepth =
                   metadata.parentMetadata.isPartOfLastBranchFromDepth
               }
diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts
index 2f370b0ab..5df7c2e42 100644
--- a/src/state/queries/usePostThread/types.ts
+++ b/src/state/queries/usePostThread/types.ts
@@ -151,8 +151,8 @@ export type TraversalMetadata = {
    */
   isLastChild: boolean
   /**
-   * Indicates if the post is the left/lower-most branch of the reply tree.
-   * Value corresponds to the depth at which this branch started.
+   * Indicates if the post is the left-most AND lower-most branch of the reply
+   * tree. Value corresponds to the depth at which this branch started.
    */
   isPartOfLastBranchFromDepth?: number
   /**
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 421264ac1..a3f2336cf 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -10,6 +10,7 @@ export type Device = {
   geolocation?: {
     countryCode: string | undefined
     isAgeRestrictedGeo: boolean | undefined
+    isAgeBlockedGeo: boolean | undefined
   }
   trendingBetaEnabled: boolean
   devMode: boolean
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 296545353..d0dbdfaba 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -77,7 +77,11 @@ import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
-import {type ComposerImage, pasteImage} from '#/state/gallery'
+import {
+  type ComposerImage,
+  createComposerImage,
+  pasteImage,
+} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
@@ -103,7 +107,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
 import {Gallery} from '#/view/com/composer/photos/Gallery'
 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
-import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
 import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
 // TODO: Prevent naming components that coincide with RN primitives
@@ -113,12 +116,10 @@ import {
   type TextInputRef,
 } from '#/view/com/composer/text-input/TextInput'
 import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
-import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
 import {Text} from '#/view/com/util/text/Text'
-import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -127,9 +128,15 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
 import * as Prompt from '#/components/Prompt'
+import * as toast from '#/components/Toast'
 import {Text as NewText} from '#/components/Typography'
 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
 import {
+  type AssetType,
+  SelectMediaButton,
+  type SelectMediaButtonProps,
+} from './SelectMediaButton'
+import {
   type ComposerAction,
   composerReducer,
   createComposerState,
@@ -514,12 +521,13 @@ export const ComposePost = ({
       onPostSuccess?.(postSuccessData)
     }
     onClose()
-    Toast.show(
+    toast.show(
       thread.posts.length > 1
         ? _(msg`Your posts have been published`)
         : replyTo
           ? _(msg`Your reply has been published`)
           : _(msg`Your post has been published`),
+      {type: 'success'},
     )
   }, [
     _,
@@ -811,11 +819,16 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
+      if (
+        uri.startsWith('data:video/') ||
+        (isWeb && uri.startsWith('data:image/gif'))
+      ) {
         if (isNative) return // web only
         const [mimeType] = uri.slice('data:'.length).split(';')
         if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
-          Toast.show(_(msg`Unsupported video type`), 'xmark')
+          toast.show(_(msg`Unsupported video type: ${mimeType}`), {
+            type: 'error',
+          })
           return
         }
         const name = `pasted.${mimeToExt(mimeType)}`
@@ -1251,7 +1264,6 @@ function ComposerFooter({
   dispatch,
   showAddButton,
   onEmojiButtonPress,
-  onError,
   onSelectVideo,
   onAddPost,
 }: {
@@ -1266,11 +1278,32 @@ function ComposerFooter({
   const t = useTheme()
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  /*
+   * Once we've allowed a certain type of asset to be selected, we don't allow
+   * other types of media to be selected.
+   */
+  const [selectedAssetsType, setSelectedAssetsType] = useState<
+    AssetType | undefined
+  >(undefined)
 
   const media = post.embed.media
   const images = media?.type === 'images' ? media.images : []
   const video = media?.type === 'video' ? media.video : null
   const isMaxImages = images.length >= MAX_IMAGES
+  const isMaxVideos = !!video
+
+  let selectedAssetsCount = 0
+  let isMediaSelectionDisabled = false
+
+  if (media?.type === 'images') {
+    isMediaSelectionDisabled = isMaxImages
+    selectedAssetsCount = images.length
+  } else if (media?.type === 'video') {
+    isMediaSelectionDisabled = isMaxVideos
+    selectedAssetsCount = 1
+  } else {
+    isMediaSelectionDisabled = !!media
+  }
 
   const onImageAdd = useCallback(
     (next: ComposerImage[]) => {
@@ -1289,6 +1322,54 @@ function ComposerFooter({
     [dispatch],
   )
 
+  /*
+   * Reset if the user clears any selected media
+   */
+  if (selectedAssetsType !== undefined && !media) {
+    setSelectedAssetsType(undefined)
+  }
+
+  const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>(
+    async ({type, assets, errors}) => {
+      setSelectedAssetsType(type)
+
+      if (assets.length) {
+        if (type === 'image') {
+          const images: ComposerImage[] = []
+
+          await Promise.all(
+            assets.map(async image => {
+              const composerImage = await createComposerImage({
+                path: image.uri,
+                width: image.width,
+                height: image.height,
+                mime: image.mimeType!,
+              })
+              images.push(composerImage)
+            }),
+          ).catch(e => {
+            logger.error(`createComposerImage failed`, {
+              safeMessage: e.message,
+            })
+          })
+
+          onImageAdd(images)
+        } else if (type === 'video') {
+          onSelectVideo(post.id, assets[0])
+        } else if (type === 'gif') {
+          onSelectVideo(post.id, assets[0])
+        }
+      }
+
+      errors.map(error => {
+        toast.show(error, {
+          type: 'warning',
+        })
+      })
+    },
+    [post.id, onSelectVideo, onImageAdd],
+  )
+
   return (
     <View
       style={[
@@ -1307,15 +1388,11 @@ function ComposerFooter({
             <VideoUploadToolbar state={video} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn
-                size={images.length}
-                disabled={media?.type === 'images' ? isMaxImages : !!media}
-                onAdd={onImageAdd}
-              />
-              <SelectVideoBtn
-                onSelectVideo={asset => onSelectVideo(post.id, asset)}
-                disabled={!!media}
-                setError={onError}
+              <SelectMediaButton
+                disabled={isMediaSelectionDisabled}
+                allowedAssetTypes={selectedAssetsType}
+                selectedAssetsCount={selectedAssetsCount}
+                onSelectAssets={onSelectAssets}
               />
               <OpenCameraBtn
                 disabled={media?.type === 'images' ? isMaxImages : !!media}
diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx
new file mode 100644
index 000000000..026d0ac19
--- /dev/null
+++ b/src/view/com/composer/SelectMediaButton.tsx
@@ -0,0 +1,524 @@
+import {useCallback} from 'react'
+import {Keyboard} from 'react-native'
+import {
+  type ImagePickerAsset,
+  launchImageLibraryAsync,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
+import {
+  usePhotoLibraryPermission,
+  useVideoLibraryPermission,
+} from '#/lib/hooks/usePermissions'
+import {extractDataUriMime} from '#/lib/media/util'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {MAX_IMAGES} from '#/view/com/composer/state/composer'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
+import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
+import * as toast from '#/components/Toast'
+
+export type SelectMediaButtonProps = {
+  disabled?: boolean
+  /**
+   * If set, this limits the types of assets that can be selected.
+   */
+  allowedAssetTypes: AssetType | undefined
+  selectedAssetsCount: number
+  onSelectAssets: (props: {
+    type: AssetType
+    assets: ImagePickerAsset[]
+    errors: string[]
+  }) => void
+}
+
+/**
+ * Generic asset classes, or buckets, that we support.
+ */
+export type AssetType = 'video' | 'image' | 'gif'
+
+/**
+ * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType`
+ */
+type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & {
+  mimeType: string
+}
+
+/**
+ * Codes for known validation states
+ */
+enum SelectedAssetError {
+  Unsupported = 'Unsupported',
+  MixedTypes = 'MixedTypes',
+  MaxImages = 'MaxImages',
+  MaxVideos = 'MaxVideos',
+  VideoTooLong = 'VideoTooLong',
+  FileTooBig = 'FileTooBig',
+  MaxGIFs = 'MaxGIFs',
+}
+
+/**
+ * Supported video mime types. This differs slightly from
+ * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about
+ * videos here.
+ */
+const SUPPORTED_VIDEO_MIME_TYPES = [
+  'video/mp4',
+  'video/mpeg',
+  'video/webm',
+  'video/quicktime',
+] as const
+type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number]
+function isSupportedVideoMimeType(
+  mimeType: string,
+): mimeType is SupportedVideoMimeType {
+  return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType)
+}
+
+/**
+ * Supported image mime types.
+ */
+const SUPPORTED_IMAGE_MIME_TYPES = (
+  [
+    'image/gif',
+    'image/jpeg',
+    'image/png',
+    'image/svg+xml',
+    'image/webp',
+    'image/avif',
+    isNative && 'image/heic',
+  ] as const
+).filter(Boolean)
+type SupportedImageMimeType = Exclude<
+  (typeof SUPPORTED_IMAGE_MIME_TYPES)[number],
+  boolean
+>
+function isSupportedImageMimeType(
+  mimeType: string,
+): mimeType is SupportedImageMimeType {
+  return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType)
+}
+
+/**
+ * This is a last-ditch effort type thing here, try not to rely on this.
+ */
+const extensionToMimeType: Record<
+  string,
+  SupportedVideoMimeType | SupportedImageMimeType
+> = {
+  mp4: 'video/mp4',
+  mov: 'video/quicktime',
+  webm: 'video/webm',
+  webp: 'image/webp',
+  gif: 'image/gif',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  png: 'image/png',
+  svg: 'image/svg+xml',
+  heic: 'image/heic',
+}
+
+/**
+ * Attempts to bucket the given asset into one of our known types based on its
+ * `mimeType`. If `mimeType` is not available, we try to infer it through
+ * various means.
+ */
+function classifyImagePickerAsset(asset: ImagePickerAsset):
+  | {
+      success: true
+      type: AssetType
+      mimeType: string
+    }
+  | {
+      success: false
+      type: undefined
+      mimeType: undefined
+    } {
+  /*
+   * Try to use the `mimeType` reported by `expo-image-picker` first.
+   */
+  let mimeType = asset.mimeType
+
+  if (!mimeType) {
+    /*
+     * We can try to infer this from the data-uri.
+     */
+    const maybeMimeType = extractDataUriMime(asset.uri)
+
+    if (
+      maybeMimeType.startsWith('image/') ||
+      maybeMimeType.startsWith('video/')
+    ) {
+      mimeType = maybeMimeType
+    } else if (maybeMimeType.startsWith('file/')) {
+      /*
+       * On the off-chance we get a `file/*` mime, try to infer from the
+       * extension.
+       */
+      const extension = asset.uri.split('.').pop()?.toLowerCase()
+      mimeType = extensionToMimeType[extension || '']
+    }
+  }
+
+  if (!mimeType) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  /*
+   * Distill this down into a type "class".
+   */
+  let type: AssetType | undefined
+  if (mimeType === 'image/gif') {
+    type = 'gif'
+  } else if (mimeType?.startsWith('video/')) {
+    type = 'video'
+  } else if (mimeType?.startsWith('image/')) {
+    type = 'image'
+  }
+
+  /*
+   * If we weren't able to find a valid type, we don't support this asset.
+   */
+  if (!type) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  return {
+    success: true,
+    type,
+    mimeType,
+  }
+}
+
+/**
+ * Takes in raw assets from `expo-image-picker` and applies validation. Returns
+ * the dominant `AssetType`, any valid assets, and any errors encountered along
+ * the way.
+ */
+async function processImagePickerAssets(
+  assets: ImagePickerAsset[],
+  {
+    selectionCountRemaining,
+    allowedAssetTypes,
+  }: {
+    selectionCountRemaining: number
+    allowedAssetTypes: AssetType | undefined
+  },
+) {
+  /*
+   * A deduped set of error codes, which we'll use later
+   */
+  const errors = new Set<SelectedAssetError>()
+
+  /*
+   * We only support selecting a single type of media at a time, so this gets
+   * set to whatever the first valid asset type is, OR to whatever
+   * `allowedAssetTypes` is set to.
+   */
+  let selectableAssetType: AssetType | undefined
+
+  /*
+   * This will hold the assets that we can actually use, after filtering
+   */
+  let supportedAssets: ValidatedImagePickerAsset[] = []
+
+  for (const asset of assets) {
+    const {success, type, mimeType} = classifyImagePickerAsset(asset)
+
+    if (!success) {
+      errors.add(SelectedAssetError.Unsupported)
+      continue
+    }
+
+    /*
+     * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
+     * set this to the first valid asset type we see, and then use that to
+     * constrain all remaining selected assets.
+     */
+    selectableAssetType = allowedAssetTypes || selectableAssetType || type
+
+    // ignore mixed types
+    if (type !== selectableAssetType) {
+      errors.add(SelectedAssetError.MixedTypes)
+      continue
+    }
+
+    if (type === 'video') {
+      /**
+       * We don't care too much about mimeType at this point on native,
+       * since the `processVideo` step later on will convert to `.mp4`.
+       */
+      if (isWeb && !isSupportedVideoMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress these anyway,
+       * so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    if (type === 'image') {
+      if (!isSupportedImageMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+    }
+
+    if (type === 'gif') {
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress GIFs as
+       * videos anyway, so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    /*
+     * All validations passed, we have an asset!
+     */
+    supportedAssets.push({
+      mimeType,
+      ...asset,
+      /*
+       * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a
+       * data-uri. Our handling elsewhere in the app (for web) relies on the
+       * base64 data-uri, so we construct it here for web only.
+       */
+      uri:
+        isWeb && asset.base64
+          ? `data:${mimeType};base64,${asset.base64}`
+          : asset.uri,
+    })
+  }
+
+  if (supportedAssets.length > 0) {
+    if (selectableAssetType === 'image') {
+      if (supportedAssets.length > selectionCountRemaining) {
+        errors.add(SelectedAssetError.MaxImages)
+        supportedAssets = supportedAssets.slice(0, selectionCountRemaining)
+      }
+    } else if (selectableAssetType === 'video') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxVideos)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+
+      if (supportedAssets[0].duration) {
+        if (isWeb) {
+          /*
+           * Web reports duration as seconds
+           */
+          supportedAssets[0].duration = supportedAssets[0].duration * 1000
+        }
+
+        if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) {
+          errors.add(SelectedAssetError.VideoTooLong)
+          supportedAssets = []
+        }
+      } else {
+        errors.add(SelectedAssetError.Unsupported)
+        supportedAssets = []
+      }
+    } else if (selectableAssetType === 'gif') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxGIFs)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+    }
+  }
+
+  return {
+    type: selectableAssetType!, // set above
+    assets: supportedAssets,
+    errors,
+  }
+}
+
+export function SelectMediaButton({
+  disabled,
+  allowedAssetTypes,
+  selectedAssetsCount,
+  onSelectAssets,
+}: SelectMediaButtonProps) {
+  const {_} = useLingui()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+  const sheetWrapper = useSheetWrapper()
+  const t = useTheme()
+
+  const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
+
+  const processSelectedAssets = useCallback(
+    async (rawAssets: ImagePickerAsset[]) => {
+      const {
+        type,
+        assets,
+        errors: errorCodes,
+      } = await processImagePickerAssets(rawAssets, {
+        selectionCountRemaining,
+        allowedAssetTypes,
+      })
+
+      /*
+       * Convert error codes to user-friendly messages.
+       */
+      const errors = Array.from(errorCodes).map(error => {
+        return {
+          [SelectedAssetError.Unsupported]: _(
+            msg`One or more of your selected files are not supported.`,
+          ),
+          [SelectedAssetError.MixedTypes]: _(
+            msg`Selecting multiple media types is not supported.`,
+          ),
+          [SelectedAssetError.MaxImages]: _(
+            msg({
+              message: `You can select up to ${plural(MAX_IMAGES, {
+                other: '# images',
+              })} in total.`,
+              comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
+            }),
+          ),
+          [SelectedAssetError.MaxVideos]: _(
+            msg`You can only select one video at a time.`,
+          ),
+          [SelectedAssetError.VideoTooLong]: _(
+            msg`Videos must be less than 3 minutes long.`,
+          ),
+          [SelectedAssetError.MaxGIFs]: _(
+            msg`You can only select one GIF at a time.`,
+          ),
+          [SelectedAssetError.FileTooBig]: _(
+            msg`One or more of your selected files is too large. Maximum size is 100 MB.`,
+          ),
+        }[error]
+      })
+
+      /*
+       * Report the selected assets and any errors back to the
+       * composer.
+       */
+      onSelectAssets({
+        type,
+        assets,
+        errors,
+      })
+    },
+    [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
+  )
+
+  const onPressSelectMedia = useCallback(async () => {
+    if (isNative) {
+      const [photoAccess, videoAccess] = await Promise.all([
+        requestPhotoAccessIfNeeded(),
+        requestVideoAccessIfNeeded(),
+      ])
+
+      if (!photoAccess && !videoAccess) {
+        toast.show(_(msg`You need to allow access to your media library.`), {
+          type: 'error',
+        })
+        return
+      }
+    }
+
+    if (isNative && Keyboard.isVisible()) {
+      Keyboard.dismiss()
+    }
+
+    const {assets, canceled} = await sheetWrapper(
+      launchImageLibraryAsync({
+        exif: false,
+        mediaTypes: ['images', 'videos'],
+        quality: 1,
+        allowsMultipleSelection: true,
+        legacy: true,
+        base64: isWeb,
+        selectionLimit: isIOS ? selectionCountRemaining : undefined,
+        preferredAssetRepresentationMode:
+          UIImagePickerPreferredAssetRepresentationMode.Current,
+        videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
+      }),
+    )
+
+    if (canceled) return
+
+    await processSelectedAssets(assets)
+  }, [
+    _,
+    requestPhotoAccessIfNeeded,
+    requestVideoAccessIfNeeded,
+    sheetWrapper,
+    processSelectedAssets,
+    selectionCountRemaining,
+  ])
+
+  return (
+    <Button
+      testID="openMediaBtn"
+      onPress={onPressSelectMedia}
+      label={_(
+        msg({
+          message: `Add media to post`,
+          comment: `Accessibility label for button in composer to add photos or a video to a post`,
+        }),
+      )}
+      accessibilityHint={
+        isNative
+          ? _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video.`,
+                comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+          : _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video or GIF.`,
+                comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+      }
+      style={a.p_sm}
+      variant="ghost"
+      shape="round"
+      color="primary"
+      disabled={disabled}>
+      <ImageIcon
+        size="lg"
+        style={disabled && t.atoms.text_contrast_low}
+        accessibilityIgnoresInvertColors={true}
+      />
+    </Button>
+  )
+}
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 724149937..b356cde9b 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -96,13 +96,12 @@ const ImageAltTextInner = ({
         <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
           <Image
             style={imageStyle}
-            source={{
-              uri: (image.transformed ?? image.source).path,
-            }}
+            source={{uri: (image.transformed ?? image.source).path}}
             contentFit="contain"
             accessible={true}
             accessibilityIgnoresInvertColors
             enableLiveTextInteraction
+            autoplay={false}
           />
         </View>
       </View>
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
deleted file mode 100644
index f4c6aa328..000000000
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
-import {useCallback} from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {openPicker} from '#/lib/media/picker'
-import {isNative} from '#/platform/detection'
-import {ComposerImage, createComposerImage} from '#/state/gallery'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
-import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
-
-type Props = {
-  size: number
-  disabled?: boolean
-  onAdd: (next: ComposerImage[]) => void
-}
-
-export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
-  const {_} = useLingui()
-  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
-  const t = useTheme()
-  const sheetWrapper = useSheetWrapper()
-
-  const onPressSelectPhotos = useCallback(async () => {
-    if (isNative && !(await requestPhotoAccessIfNeeded())) {
-      return
-    }
-
-    const images = await sheetWrapper(
-      openPicker({
-        selectionLimit: 4 - size,
-        allowsMultipleSelection: true,
-      }),
-    )
-
-    const results = await Promise.all(
-      images.map(img => createComposerImage(img)),
-    )
-
-    onAdd(results)
-  }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
-
-  return (
-    <Button
-      testID="openGalleryBtn"
-      onPress={onPressSelectPhotos}
-      label={_(msg`Gallery`)}
-      accessibilityHint={_(msg`Opens device photo gallery`)}
-      style={a.p_sm}
-      variant="ghost"
-      shape="round"
-      color="primary"
-      disabled={disabled}>
-      <Image size="lg" style={disabled && t.atoms.text_contrast_low} />
-    </Button>
-  )
-}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
deleted file mode 100644
index 96715955f..000000000
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import {useCallback} from 'react'
-import {type ImagePickerAsset} from 'expo-image-picker'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {
-  SUPPORTED_MIME_TYPES,
-  type SupportedMimeTypes,
-  VIDEO_MAX_DURATION_MS,
-} from '#/lib/constants'
-import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {isWeb} from '#/platform/detection'
-import {isNative} from '#/platform/detection'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
-import {pickVideo} from './pickVideo'
-
-type Props = {
-  onSelectVideo: (video: ImagePickerAsset) => void
-  disabled?: boolean
-  setError: (error: string) => void
-}
-
-export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
-
-  const onPressSelectVideo = useCallback(async () => {
-    if (isNative && !(await requestVideoAccessIfNeeded())) {
-      return
-    }
-
-    const response = await pickVideo()
-    if (response.assets && response.assets.length > 0) {
-      const asset = response.assets[0]
-      try {
-        if (isWeb) {
-          // asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
-          if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-          // compression step on native converts to mp4, so no need to check there
-          if (
-            !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
-          ) {
-            throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
-          }
-        } else {
-          if (typeof asset.duration !== 'number') {
-            throw Error('Asset is not a video')
-          }
-          if (asset.duration > VIDEO_MAX_DURATION_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-        }
-        onSelectVideo(asset)
-      } catch (err) {
-        if (err instanceof Error) {
-          setError(err.message)
-        } else {
-          setError(_(msg`An error occurred while selecting the video`))
-        }
-      }
-    }
-  }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo])
-
-  return (
-    <>
-      <Button
-        testID="openGifBtn"
-        onPress={onPressSelectVideo}
-        label={_(msg`Select video`)}
-        accessibilityHint={_(msg`Opens video picker`)}
-        style={a.p_sm}
-        variant="ghost"
-        shape="round"
-        color="primary"
-        disabled={disabled}>
-        <VideoClipIcon
-          size="lg"
-          style={disabled && t.atoms.text_contrast_low}
-        />
-      </Button>
-    </>
-  )
-}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 255174bea..84cb1dba7 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,9 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {Image} from 'expo-image'
+import {type ImagePickerAsset} from 'expo-image-picker'
 import {BlueskyVideoView} from '@haileyok/bluesky-video'
 
-import {CompressedVideo} from '#/lib/media/video/types'
+import {type CompressedVideo} from '#/lib/media/video/types'
 import {clamp} from '#/lib/numbers'
 import {useAutoplayDisabled} from '#/state/preferences'
 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
@@ -48,13 +49,25 @@ export function VideoPreview({
         <VideoTranscodeBackdrop uri={asset.uri} />
       </View>
       {isActivePost && (
-        <BlueskyVideoView
-          url={video.uri}
-          autoplay={!autoplayDisabled}
-          beginMuted={true}
-          forceTakeover={true}
-          ref={playerRef}
-        />
+        <>
+          {video.mimeType === 'image/gif' ? (
+            <Image
+              style={[a.flex_1]}
+              autoplay={!autoplayDisabled}
+              source={{uri: video.uri}}
+              accessibilityIgnoresInvertColors
+              cachePolicy="none"
+            />
+          ) : (
+            <BlueskyVideoView
+              url={video.uri}
+              autoplay={!autoplayDisabled}
+              beginMuted={true}
+              forceTakeover={true}
+              ref={playerRef}
+            />
+          )}
+        </>
       )}
       <ExternalEmbedRemoveBtn onRemove={clear} />
       {autoplayDisabled && (
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index 97811da7f..ab50fbcf0 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -76,6 +76,7 @@ function LightboxInner({
   const onKeyDown = useCallback(
     (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
+        e.preventDefault()
         onClose()
       } else if (e.key === 'ArrowLeft') {
         onPressLeft()
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index 145e919f9..fc9296cad 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {AppBskyActorDefs} from '@atproto/api'
+import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
@@ -126,7 +126,7 @@ function PostThreadFollowBtnLoaded({
       <ButtonText>
         {!isFollowing ? (
           isFollowedBy ? (
-            <Trans>Follow Back</Trans>
+            <Trans>Follow back</Trans>
           ) : (
             <Trans>Follow</Trans>
           )
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 656ed914a..ff9c1cd7b 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,11 +1,11 @@
-import {StyleProp, TextStyle, View} from 'react-native'
+import {type StyleProp, type TextStyle, View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {Shadow} from '#/state/cache/types'
+import {type Shadow} from '#/state/cache/types'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
-import * as bsky from '#/types/bsky'
-import {Button, ButtonType} from '../util/forms/Button'
+import type * as bsky from '#/types/bsky'
+import {Button, type ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 
 export function FollowButton({
@@ -78,7 +78,7 @@ export function FollowButton({
         type={unfollowedType}
         labelStyle={labelStyle}
         onPress={onPressFollow}
-        label={_(msg({message: 'Follow Back', context: 'action'}))}
+        label={_(msg({message: 'Follow back', context: 'action'}))}
       />
     )
   }
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
deleted file mode 100644
index 026319baf..000000000
--- a/src/view/screens/Log.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useFocusEffect} from '@react-navigation/native'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
-import {s} from '#/lib/styles'
-import {getEntries} from '#/logger/logDump'
-import {useTickEveryMinute} from '#/state/shell'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {Text} from '#/view/com/util/text/Text'
-import {ViewHeader} from '#/view/com/util/ViewHeader'
-import {ScrollView} from '#/view/com/util/Views'
-import * as Layout from '#/components/Layout'
-
-export function LogScreen({}: NativeStackScreenProps<
-  CommonNavigatorParams,
-  'Log'
->) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const setMinimalShellMode = useSetMinimalShellMode()
-  const [expanded, setExpanded] = React.useState<string[]>([])
-  const timeAgo = useGetTimeAgo()
-  const tick = useTickEveryMinute()
-
-  useFocusEffect(
-    React.useCallback(() => {
-      setMinimalShellMode(false)
-    }, [setMinimalShellMode]),
-  )
-
-  const toggler = (id: string) => () => {
-    if (expanded.includes(id)) {
-      setExpanded(expanded.filter(v => v !== id))
-    } else {
-      setExpanded([...expanded, id])
-    }
-  }
-
-  return (
-    <Layout.Screen>
-      <ViewHeader title="Log" />
-      <ScrollView style={s.flex1}>
-        {getEntries()
-          .slice(0)
-          .map(entry => {
-            return (
-              <View key={`entry-${entry.id}`}>
-                <TouchableOpacity
-                  style={[styles.entry, pal.border, pal.view]}
-                  onPress={toggler(entry.id)}
-                  accessibilityLabel={_(msg`View debug entry`)}
-                  accessibilityHint={_(
-                    msg`Opens additional details for a debug entry`,
-                  )}>
-                  {entry.level === 'debug' ? (
-                    <FontAwesomeIcon icon="info" />
-                  ) : (
-                    <FontAwesomeIcon icon="exclamation" style={s.red3} />
-                  )}
-                  <Text type="sm" style={[styles.summary, pal.text]}>
-                    {String(entry.message)}
-                  </Text>
-                  {entry.metadata && Object.keys(entry.metadata).length ? (
-                    <FontAwesomeIcon
-                      icon={
-                        expanded.includes(entry.id) ? 'angle-up' : 'angle-down'
-                      }
-                      style={s.mr5}
-                    />
-                  ) : undefined}
-                  <Text type="sm" style={[styles.ts, pal.textLight]}>
-                    {timeAgo(entry.timestamp, tick)}
-                  </Text>
-                </TouchableOpacity>
-                {expanded.includes(entry.id) ? (
-                  <View style={[pal.view, s.pl10, s.pr10, s.pb10]}>
-                    <View style={[pal.btn, styles.details]}>
-                      <Text type="mono" style={pal.text}>
-                        {JSON.stringify(entry.metadata, null, 2)}
-                      </Text>
-                    </View>
-                  </View>
-                ) : undefined}
-              </View>
-            )
-          })}
-        <View style={s.footerSpacer} />
-      </ScrollView>
-    </Layout.Screen>
-  )
-}
-
-const styles = StyleSheet.create({
-  entry: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    paddingVertical: 10,
-    paddingHorizontal: 6,
-  },
-  summary: {
-    flex: 1,
-  },
-  ts: {
-    width: 40,
-  },
-  details: {
-    paddingVertical: 10,
-    paddingHorizontal: 6,
-  },
-})
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 04fccc44c..8b4c65b8f 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,6 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
 import {isStateAtTabRoot} from '#/lib/routes/helpers'
 import {isAndroid, isIOS} from '#/platform/detection'
 import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
+import {useGeolocation} from '#/state/geolocation'
 import {useSession} from '#/state/session'
 import {
   useIsDrawerOpen,
@@ -26,6 +27,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {setSystemUITheme} from '#/alf/util/systemUI'
 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
@@ -180,9 +182,11 @@ function ShellInner() {
   )
 }
 
-export const Shell: React.FC = function ShellImpl() {
-  const fullyExpandedCount = useDialogFullyExpandedCountContext()
+export function Shell() {
   const t = useTheme()
+  const {geolocation} = useGeolocation()
+  const fullyExpandedCount = useDialogFullyExpandedCountContext()
+
   useIntentHandler()
 
   useEffect(() => {
@@ -200,9 +204,13 @@ export const Shell: React.FC = function ShellImpl() {
           navigationBar: t.name !== 'light' ? 'light' : 'dark',
         }}
       />
-      <RoutesContainer>
-        <ShellInner />
-      </RoutesContainer>
+      {geolocation?.isAgeBlockedGeo ? (
+        <BlockedGeoOverlay />
+      ) : (
+        <RoutesContainer>
+          <ShellInner />
+        </RoutesContainer>
+      )}
     </View>
   )
 }
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 3c2bc58ab..f942ab49e 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -5,11 +5,10 @@ import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {RemoveScrollBar} from 'react-remove-scroll-bar'
 
-import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
 import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {type NavigationProp} from '#/lib/routes/types'
-import {colors} from '#/lib/styles'
+import {useGeolocation} from '#/state/geolocation'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -18,6 +17,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -130,24 +130,23 @@ function ShellInner() {
   )
 }
 
-export const Shell: React.FC = function ShellImpl() {
-  const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+export function Shell() {
+  const t = useTheme()
+  const {geolocation} = useGeolocation()
   return (
-    <View style={[a.util_screen_outer, pageBg]}>
-      <RoutesContainer>
-        <ShellInner />
-      </RoutesContainer>
+    <View style={[a.util_screen_outer, t.atoms.bg]}>
+      {geolocation?.isAgeBlockedGeo ? (
+        <BlockedGeoOverlay />
+      ) : (
+        <RoutesContainer>
+          <ShellInner />
+        </RoutesContainer>
+      )}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  bgLight: {
-    backgroundColor: colors.white,
-  },
-  bgDark: {
-    backgroundColor: colors.black, // TODO
-  },
   drawerMask: {
     ...a.fixed,
     width: '100%',
diff --git a/yarn.lock b/yarn.lock
index dc6489bf8..843561fa8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5134,13 +5134,12 @@
   resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.6.0.tgz#9f78c805d637ffaff0e3e7429932d2995a67edc9"
   integrity sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==
 
-"@mozzius/expo-dynamic-app-icon@1.5.0":
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/@mozzius/expo-dynamic-app-icon/-/expo-dynamic-app-icon-1.5.0.tgz#c5f88c309965b6d6b89cfd5e2c00faa7bda736af"
-  integrity sha512-yE2yEPO+HQmOqsX7cECh7/vu/LXnqhHGsVm3UiVi/3gaK8u5hAkPTNzZ0Qu6vnMwjPnY+uFbN6X+6Aj9c9yjMQ==
+"@mozzius/expo-dynamic-app-icon@^1.7.1":
+  version "1.7.1"
+  resolved "https://registry.yarnpkg.com/@mozzius/expo-dynamic-app-icon/-/expo-dynamic-app-icon-1.7.1.tgz#20a6475af256ab93112bbaac1c2f3dd052562d16"
+  integrity sha512-zDcY11B3tsQ9WsPTcszUCa6SpygfYz1e4p8PGn/4XhwYbOHJ53sEnGVQ1YlwzQ87SeWPlqrfzY6SUr9GpNq2Kg==
   dependencies:
     "@expo/image-utils" "^0.6.3"
-    expo-modules-core "^2.1.1"
     xcode "^3.0.1"
 
 "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
@@ -11288,6 +11287,11 @@ expo-image-loader@~5.1.0:
   resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153"
   integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==
 
+expo-image-loader@~6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
+  integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
+
 expo-image-manipulator@~13.1.7:
   version "13.1.7"
   resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76"
@@ -11295,12 +11299,12 @@ expo-image-manipulator@~13.1.7:
   dependencies:
     expo-image-loader "~5.1.0"
 
-expo-image-picker@~16.1.4:
-  version "16.1.4"
-  resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40"
-  integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA==
+expo-image-picker@^17.0.2:
+  version "17.0.2"
+  resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809"
+  integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg==
   dependencies:
-    expo-image-loader "~5.1.0"
+    expo-image-loader "~6.0.0"
 
 expo-image@^2.4.0:
   version "2.4.0"
@@ -11375,13 +11379,6 @@ expo-modules-core@2.4.0:
   dependencies:
     invariant "^2.2.4"
 
-expo-modules-core@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-2.1.1.tgz#970af4cfd70c8aa6fc0096dd0a6578aa003a479f"
-  integrity sha512-yQzYCLR2mre4BNMXuqkeJ0oSNgmGEMI6BcmIzeNZbC2NFEjiaDpKvlV9bclYCtyVhUEVNbJcEPYMr6c1Y4eR4w==
-  dependencies:
-    invariant "^2.2.4"
-
 expo-notifications@~0.31.3:
   version "0.31.3"
   resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.31.3.tgz#eb82c9975e26dcc4fa694b79970792c897ad8d16"