about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-submit-android.yml61
-rw-r--r--.github/workflows/build-submit-ios.yml72
-rw-r--r--.github/workflows/deploy-nightly-testflight.yml52
-rw-r--r--app.config.js23
-rw-r--r--bskyweb/templates/base.html3
-rw-r--r--eas.json19
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewManager.m1
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift43
-rw-r--r--package.json13
-rwxr-xr-xscripts/bumpAndroidBuildNumber.sh10
-rwxr-xr-xscripts/bumpIosBuildNumber.sh10
-rwxr-xr-xscripts/useBuildNumberEnv.sh11
-rw-r--r--src/alf/atoms.ts18
-rw-r--r--src/components/Dialog/context.ts10
-rw-r--r--src/components/Dialog/index.tsx131
-rw-r--r--src/components/Dialog/index.web.tsx57
-rw-r--r--src/components/Dialog/types.ts27
-rw-r--r--src/components/Link.tsx86
-rw-r--r--src/components/Prompt.tsx2
-rw-r--r--src/components/RichText.tsx27
-rw-r--r--src/components/Typography.tsx38
-rw-r--r--src/components/icons/Times.tsx5
-rw-r--r--src/lib/constants.ts97
-rw-r--r--src/lib/strings/embed-player.ts28
-rw-r--r--src/lib/strings/url-helpers.ts4
-rw-r--r--src/screens/Onboarding/StepTopicalFeeds.tsx12
-rw-r--r--src/state/queries/notifications/feed.ts17
-rw-r--r--src/state/queries/post-feed.ts17
-rw-r--r--src/state/queries/post-thread.ts66
-rw-r--r--src/state/queries/util.ts1
-rw-r--r--src/view/com/auth/create/state.ts4
-rw-r--r--src/view/com/auth/server-input/index.tsx6
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx8
-rw-r--r--src/view/com/lists/ListCard.tsx7
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx15
-rw-r--r--src/view/com/post/Post.tsx9
-rw-r--r--src/view/com/posts/FeedItem.tsx9
-rw-r--r--src/view/com/profile/ProfileHeader.tsx9
-rw-r--r--src/view/com/util/EventStopper.tsx10
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx31
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx94
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx10
-rw-r--r--src/view/com/util/text/RichText.tsx3
-rw-r--r--src/view/screens/ProfileFeed.tsx8
-rw-r--r--src/view/screens/ProfileList.tsx8
-rw-r--r--src/view/screens/Settings/ExportCarDialog.tsx2
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx49
-rw-r--r--src/view/screens/Storybook/Typography.tsx5
48 files changed, 667 insertions, 581 deletions
diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml
new file mode 100644
index 000000000..051e95151
--- /dev/null
+++ b/.github/workflows/build-submit-android.yml
@@ -0,0 +1,61 @@
+---
+name: Build and Submit Android
+
+on:
+  workflow_dispatch:
+    inputs:
+      profile:
+        type: choice
+        description: Build profile to use
+        options:
+          - production
+
+jobs:
+  build:
+    name: Build and Submit Android
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check for EXPO_TOKEN
+        run: >
+          if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
+            echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
+            exit 1
+          fi
+
+      - name: ā¬‡ļø Checkout
+        uses: actions/checkout@v4
+
+      - name: šŸ”§ Setup Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 18.x
+          cache: yarn
+
+      - name: šŸ”Ø Setup EAS
+        uses: expo/expo-github-action@v8
+        with:
+          expo-version: latest
+          eas-version: latest
+          token: ${{ secrets.EXPO_TOKEN }}
+
+      - name: ā›ļø Setup EAS local builds
+        run: yarn global add eas-cli-local-build-plugin
+
+      - uses: actions/setup-java@v4
+        with:
+          distribution: 'temurin'
+          java-version: '17'
+
+      - name: āš™ļø Install dependencies
+        run: yarn install
+
+      - name: āœļø Write environment variables
+        run: |
+          echo "${{ secrets.ENV_TOKEN }}" > .env
+          echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
+
+      - name: šŸ—ļø EAS Build
+        run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive
+
+      - name: šŸš€ Deploy
+        run: eas submit -p android --non-interactive --path build.aab
diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml
new file mode 100644
index 000000000..0fd691bb9
--- /dev/null
+++ b/.github/workflows/build-submit-ios.yml
@@ -0,0 +1,72 @@
+---
+name: Build and Submit iOS
+
+on:
+  schedule:
+    - cron: '0 5 * * *'
+  workflow_dispatch:
+    inputs:
+      profile:
+        type: choice
+        description: Build profile to use
+        options:
+          - production
+
+jobs:
+  build:
+    name: Build and Submit iOS
+    runs-on: macos-14
+    steps:
+      - name: Check for EXPO_TOKEN
+        run: >
+          if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
+            echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
+            exit 1
+          fi
+
+      - name: ā¬‡ļø Checkout
+        uses: actions/checkout@v4
+
+      - name: šŸ”§ Setup Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 18.x
+          cache: yarn
+
+      - name: šŸ”Ø Setup EAS
+        uses: expo/expo-github-action@v8
+        with:
+          expo-version: latest
+          eas-version: latest
+          token: ${{ secrets.EXPO_TOKEN }}
+
+      - name: ā›ļø Setup EAS local builds
+        run: yarn global add eas-cli-local-build-plugin
+
+      - name: āš™ļø Install dependencies
+        run: yarn install
+
+      - name: ā˜•ļø Setup Cocoapods
+        uses: maxim-lobanov/setup-cocoapods@v1
+        with:
+          version: 1.14.3
+
+      - name: šŸ’¾ Cache Pods
+        uses: actions/cache@v3
+        id: pods-cache
+        with:
+          path: ./ios/Pods
+          # We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
+          # change unless the yarn version changes as well.
+          key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
+
+      - name: āœļø Write environment variables
+        run: |
+          echo "${{ secrets.ENV_TOKEN }}" > .env
+          echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
+
+      - name: šŸ—ļø EAS Build
+        run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive
+
+      - name: šŸš€ Deploy
+        run: eas submit -p ios --non-interactive --path build.ipa
diff --git a/.github/workflows/deploy-nightly-testflight.yml b/.github/workflows/deploy-nightly-testflight.yml
deleted file mode 100644
index e3875899e..000000000
--- a/.github/workflows/deploy-nightly-testflight.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: Deploy Nightly Testflight Release
-
-on:
-  schedule:
-    - cron: '0 5 * * *'
-
-jobs:
-  build:
-    name: Deploy Nightly Testflight Release
-    runs-on: ubuntu-latest
-    permissions:
-      contents: write
-
-    steps:
-      - name: Check for EXPO_TOKEN
-        run: |
-          if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
-            echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
-            exit 1
-          fi
-
-      - name: Checkout
-        uses: actions/checkout@v4
-
-      - name: Setup Node
-        uses: actions/setup-node@v3
-        with:
-          node-version: 18.x
-          cache: yarn
-
-      - name: Setup EAS
-        uses: expo/expo-github-action@v8
-        with:
-          eas-version: latest
-          token: ${{ secrets.EXPO_TOKEN }}
-
-      - name: Install dependencies
-        run: yarn install
-
-      - name: Bump build number
-        run: yarn bump:ios
-
-      - name: EAS build and submit
-        run: eas build -p ios --profile production --auto-submit --non-interactive
-
-      - name: Commit
-        uses: stefanzweifel/git-auto-commit-action@v5
-        with:
-          commit_message: Nightly iOS Build Bump
-          branch: main
-          commit_user_name: github-actions[bot]
-          commit_user_email: github-actions[bot]@users.noreply.github.com
diff --git a/app.config.js b/app.config.js
index 9814065f4..e710420b0 100644
--- a/app.config.js
+++ b/app.config.js
@@ -11,36 +11,23 @@ const DARK_SPLASH_CONFIG = {
   resizeMode: 'cover',
 }
 
-module.exports = function () {
+module.exports = function (config) {
   /**
    * App version number. Should be incremented as part of a release cycle.
    */
   const VERSION = pkg.version
 
   /**
-   * iOS build number. Must be incremented for each TestFlight version.
-   * WARNING: Always leave this variable on line 24! If it is moved, you need to update ./scripts/bumpIosBuildNumber.sh
-   */
-  const IOS_BUILD_NUMBER = '7'
-
-  /**
-   * Android build number. Must be incremented for each release.
-   * WARNING: Always leave this variable on line 30! If it is moved, you need to update ./scripts/bumpAndroidBuildNumber.sh
-   */
-  const ANDROID_VERSION_CODE = 62
-
-  /**
    * Uses built-in Expo env vars
    *
    * @see https://docs.expo.dev/build-reference/variables/#built-in-environment-variables
    */
   const PLATFORM = process.env.EAS_BUILD_PLATFORM
 
-  /**
-   * Additional granularity for the `dist` field
-   */
   const DIST_BUILD_NUMBER =
-    PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
+    PLATFORM === 'android'
+      ? process.env.BSKY_ANDROID_VERSION_CODE
+      : process.env.BSKY_IOS_BUILD_NUMBER
 
   return {
     expo: {
@@ -57,7 +44,6 @@ module.exports = function () {
       userInterfaceStyle: 'automatic',
       splash: SPLASH_CONFIG,
       ios: {
-        buildNumber: IOS_BUILD_NUMBER,
         supportsTablet: false,
         bundleIdentifier: 'xyz.blueskyweb.app',
         config: {
@@ -85,7 +71,6 @@ module.exports = function () {
         backgroundColor: '#ffffff',
       },
       android: {
-        versionCode: ANDROID_VERSION_CODE,
         icon: './assets/icon.png',
         adaptiveIcon: {
           foregroundImage: './assets/icon-android-foreground.png',
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 99b6f1dc6..e29e4032b 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -2,7 +2,6 @@
 <html>
 <head>
   <meta charset="UTF-8">
-  <meta name="theme-color">
   <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
   <meta name="referrer" content="origin-when-cross-origin">
   <!--
@@ -212,7 +211,7 @@
   <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
   <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
   <link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
-  <meta name="theme-color" content="#ffffff">
+  <meta name="theme-color">
   <meta name="application-name" content="Bluesky">
   <meta name="generator" content="bskyweb">
   <meta property="og:site_name" content="Bluesky Social" />
diff --git a/eas.json b/eas.json
index 75254d293..2b4c7cb61 100644
--- a/eas.json
+++ b/eas.json
@@ -1,7 +1,8 @@
 {
   "cli": {
     "version": ">= 3.8.1",
-    "promptToConfigurePushNotifications": false
+    "promptToConfigurePushNotifications": false,
+    "appVersionSource": "remote"
   },
   "build": {
     "base": {
@@ -28,7 +29,21 @@
     "production": {
       "extends": "base",
       "ios": {
-        "resourceClass": "large"
+        "resourceClass": "large",
+        "autoIncrement": true
+      },
+      "android": {
+        "autoIncrement": true
+      },
+      "channel": "production"
+    },
+    "github": {
+      "extends": "base",
+      "ios": {
+        "autoIncrement": true
+      },
+      "android": {
+        "autoIncrement": true
       },
       "channel": "production"
     }
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewManager.m b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m
index 9a6f0285c..32dfb3b28 100644
--- a/modules/react-native-ui-text-view/ios/RNUITextViewManager.m
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m
@@ -4,6 +4,7 @@
 RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
 RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
 
+RCT_EXPORT_VIEW_PROPERTY(numberOfLines, NSInteger)
 RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
 RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
 RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift
index 4f3eda43c..5a462f6b6 100644
--- a/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift
@@ -40,19 +40,19 @@ class RNUITextViewShadow: RCTShadowView {
     self.setAttributedText()
   }
 
-  // Tell yoga not to use flexbox
+  // Returning true here will tell Yoga to not use flexbox and instead use our custom measure func.
   override func isYogaLeafNode() -> Bool {
     return true
   }
 
-  // We only need to insert text children
+  // We should only insert children that are UITextView shadows
   override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
     if subview.isKind(of: RNUITextViewChildShadow.self) {
       super.insertReactSubview(subview, at: atIndex)
     }
   }
 
-  // Whenever the subvies update, set the text
+  // Every time the subviews change, we need to reformat and render the text.
   override func didUpdateReactSubviews() {
     self.setAttributedText()
   }
@@ -64,7 +64,7 @@ class RNUITextViewShadow: RCTShadowView {
       return
     }
 
-    // Update the text
+    // Since we are inside the shadow view here, we have to find the real view and update the text.
     self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
       guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
         return
@@ -100,18 +100,25 @@ class RNUITextViewShadow: RCTShadowView {
       // Create the attributed string with the generic attributes
       let string = NSMutableAttributedString(string: child.text, attributes: attributes)
 
-      // Set the paragraph style attributes if necessary
+      // Set the paragraph style attributes if necessary. We can check this by seeing if the provided
+      // line height is not 0.0.
       let paragraphStyle = NSMutableParagraphStyle()
       if child.lineHeight != 0.0 {
-        paragraphStyle.minimumLineHeight = child.lineHeight
-        paragraphStyle.maximumLineHeight = child.lineHeight
+        // Whenever we change the line height for the text, we are also removing the DynamicType
+        // adjustment for line height. We need to get the multiplier and apply that to the
+        // line height.
+        let scaleMultiplier = scaledFontSize / child.fontSize
+        paragraphStyle.minimumLineHeight = child.lineHeight * scaleMultiplier
+        paragraphStyle.maximumLineHeight = child.lineHeight * scaleMultiplier
+
         string.addAttribute(
           NSAttributedString.Key.paragraphStyle,
           value: paragraphStyle,
           range: NSMakeRange(0, string.length)
         )
 
-        // Store that height
+        // To calcualte the size of the text without creating a new UILabel or UITextView, we have
+        // to store this line height for later.
         self.lineHeight = child.lineHeight
       } else {
         self.lineHeight = font.lineHeight
@@ -124,24 +131,22 @@ class RNUITextViewShadow: RCTShadowView {
     self.dirtyLayout()
   }
 
-  // Create a YGSize based on the max width
+  // To create the needed size we need to:
+  // 1. Get the max size that we can use for the view
+  // 2. Calculate the height of the text based on that max size
+  // 3. Determine how many lines the text is, and limit that number if it exceeds the max
+  // 4. Set the frame size and return the YGSize. YGSize requires Float values while CGSize needs CGFloat
   func getNeededSize(maxWidth: Float) -> YGSize {
-    // Create the max size and figure out the size of the entire text
     let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
     let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
 
-    // Figure out how many total lines there are
-    let totalLines = Int(ceil(textSize.height / self.lineHeight))
-
-    // Default to the text size
-    var neededSize: CGSize = textSize.size
+    var totalLines = Int(ceil(textSize.height / self.lineHeight))
 
-    // If the total lines > max number, return size with the max
     if self.numberOfLines != 0, totalLines > self.numberOfLines {
-      neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight))
+      totalLines = self.numberOfLines
     }
 
-    self.frameSize = neededSize
-    return YGSize(width: Float(neededSize.width), height: Float(neededSize.height))
+    self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
+    return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
   }
 }
diff --git a/package.json b/package.json
index 4a3a2a7dc..5c31f10f0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.68.0",
+  "version": "1.69.0",
   "private": true,
   "engines": {
     "node": ">=18"
@@ -12,8 +12,12 @@
     "android": "expo run:android",
     "ios": "expo run:ios",
     "web": "expo start --web",
+    "use-build-number": "./scripts/useBuildNumberEnv.sh",
     "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
-    "build-all": "yarn intl:build && eas build --platform all",
+    "build-all": "yarn intl:build && yarn use-build-number eas build --platform all",
+    "build-ios": "yarn use-build-number eas build -p ios",
+    "build-android": "yarn use-build-number eas build -p android",
+    "build": "yarn use-build-number eas build",
     "start": "expo start --dev-client",
     "start:prod": "expo start --dev-client --no-dev --minify",
     "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@@ -36,10 +40,7 @@
     "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\nāš ļø i18n detected un-extracted translations\n' && exit 1)",
     "intl:extract": "lingui extract",
     "intl:compile": "lingui compile",
-    "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
-    "bump": "./scripts/bumpIosBuildNumber.sh && ./scripts/bumpAndroidBuildNumber.sh",
-    "bump:ios": "./scripts/bumpIosBuildNumber.sh",
-    "bump:android": "./scripts/bumpAndroidBuildNumber.sh"
+    "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
   },
   "dependencies": {
     "@atproto/api": "^0.9.5",
diff --git a/scripts/bumpAndroidBuildNumber.sh b/scripts/bumpAndroidBuildNumber.sh
deleted file mode 100755
index 105f1296d..000000000
--- a/scripts/bumpAndroidBuildNumber.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-# The number here should always be the line number the iOS build variable is on
-line=$(sed "30q;d" ./app.config.js)
-currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
-newBuildNumber=$((currentBuildNumber+1))
-newBuildVariable="const ANDROID_VERSION_CODE = '$newBuildNumber'"
-sed -i.bak "30s/.*/  $newBuildVariable/" ./app.config.js
-rm -rf ./app.config.js.bak
-
-echo "Android build number bumped to $newBuildNumber"
diff --git a/scripts/bumpIosBuildNumber.sh b/scripts/bumpIosBuildNumber.sh
deleted file mode 100755
index b78d2e69d..000000000
--- a/scripts/bumpIosBuildNumber.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-# The number here should always be the line number the iOS build variable is on
-line=$(sed "24q;d" ./app.config.js)
-currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
-newBuildNumber=$((currentBuildNumber+1))
-newBuildVariable="const IOS_BUILD_NUMBER = '$newBuildNumber'"
-sed -i.bak "24s/.*/  $newBuildVariable/" ./app.config.js
-rm -rf ./app.config.js.bak
-
-echo "iOS build number bumped to $newBuildNumber"
diff --git a/scripts/useBuildNumberEnv.sh b/scripts/useBuildNumberEnv.sh
new file mode 100755
index 000000000..fe273d394
--- /dev/null
+++ b/scripts/useBuildNumberEnv.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+outputIos=$(eas build:version:get -p ios)
+outputAndroid=$(eas build:version:get -p android)
+currentIosVersion=${outputIos#*buildNumber - }
+currentAndroidVersion=${outputAndroid#*versionCode - }
+
+BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
+BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
+
+bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
+
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index f75e8ffe0..18f492d6e 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -176,43 +176,59 @@ export const atoms = {
   },
   text_2xs: {
     fontSize: tokens.fontSize._2xs,
+    letterSpacing: 0.25,
   },
   text_xs: {
     fontSize: tokens.fontSize.xs,
+    letterSpacing: 0.25,
   },
   text_sm: {
     fontSize: tokens.fontSize.sm,
+    letterSpacing: 0.25,
   },
   text_md: {
     fontSize: tokens.fontSize.md,
+    letterSpacing: 0.25,
   },
   text_lg: {
     fontSize: tokens.fontSize.lg,
+    letterSpacing: 0.25,
   },
   text_xl: {
     fontSize: tokens.fontSize.xl,
+    letterSpacing: 0.25,
   },
   text_2xl: {
     fontSize: tokens.fontSize._2xl,
+    letterSpacing: 0.25,
   },
   text_3xl: {
     fontSize: tokens.fontSize._3xl,
+    letterSpacing: 0.25,
   },
   text_4xl: {
     fontSize: tokens.fontSize._4xl,
+    letterSpacing: 0.25,
   },
   text_5xl: {
     fontSize: tokens.fontSize._5xl,
+    letterSpacing: 0.25,
   },
   leading_tight: {
     lineHeight: 1.15,
   },
   leading_snug: {
-    lineHeight: 1.25,
+    lineHeight: 1.3,
   },
   leading_normal: {
     lineHeight: 1.5,
   },
+  tracking_normal: {
+    letterSpacing: 0,
+  },
+  tracking_wide: {
+    letterSpacing: 0.25,
+  },
   font_normal: {
     fontWeight: tokens.fontWeight.normal,
   },
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index b28b9f5a2..f0c7c983a 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -1,7 +1,11 @@
 import React from 'react'
 
 import {useDialogStateContext} from '#/state/dialogs'
-import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
+import {
+  DialogContextProps,
+  DialogControlProps,
+  DialogOuterProps,
+} from '#/components/Dialog/types'
 
 export const Context = React.createContext<DialogContextProps>({
   close: () => {},
@@ -11,7 +15,7 @@ export function useDialogContext() {
   return React.useContext(Context)
 }
 
-export function useDialogControl() {
+export function useDialogControl(): DialogOuterProps['control'] {
   const id = React.useId()
   const control = React.useRef<DialogControlProps>({
     open: () => {},
@@ -30,6 +34,6 @@ export function useDialogControl() {
   return {
     ref: control,
     open: () => control.current.open(),
-    close: () => control.current.close(),
+    close: cb => control.current.close(cb),
   }
 }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 9132e68de..27f43afd3 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -8,7 +8,7 @@ import BottomSheet, {
 } from '@gorhom/bottom-sheet'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 
-import {useTheme, atoms as a} from '#/alf'
+import {useTheme, atoms as a, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 import {createInput} from '#/components/forms/TextField'
 
@@ -35,12 +35,30 @@ export function Outer({
   const sheetOptions = nativeOptions?.sheet || {}
   const hasSnapPoints = !!sheetOptions.snapPoints
   const insets = useSafeAreaInsets()
+  const closeCallback = React.useRef<() => void>()
 
-  const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
-    sheet.current?.snapToIndex(i)
-  }, [])
+  /*
+   * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
+   */
+  const [openIndex, setOpenIndex] = React.useState(-1)
+
+  /*
+   * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
+   */
+  const isOpen = openIndex > -1
+
+  const open = React.useCallback<DialogControlProps['open']>(
+    ({index} = {}) => {
+      // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
+      setOpenIndex(index || 0)
+    },
+    [setOpenIndex],
+  )
 
-  const close = React.useCallback(() => {
+  const close = React.useCallback<DialogControlProps['close']>(cb => {
+    if (cb) {
+      closeCallback.current = cb
+    }
     sheet.current?.close()
   }, [])
 
@@ -56,78 +74,85 @@ export function Outer({
   const onChange = React.useCallback(
     (index: number) => {
       if (index === -1) {
+        closeCallback.current?.()
+        closeCallback.current = undefined
         onClose?.()
+        setOpenIndex(-1)
       }
     },
-    [onClose],
+    [onClose, setOpenIndex],
   )
 
   const context = React.useMemo(() => ({close}), [close])
 
   return (
-    <Portal>
-      <BottomSheet
-        enableDynamicSizing={!hasSnapPoints}
-        enablePanDownToClose
-        keyboardBehavior="interactive"
-        android_keyboardInputMode="adjustResize"
-        keyboardBlurBehavior="restore"
-        topInset={insets.top}
-        {...sheetOptions}
-        ref={sheet}
-        index={-1}
-        backgroundStyle={{backgroundColor: 'transparent'}}
-        backdropComponent={props => (
-          <BottomSheetBackdrop
-            opacity={0.4}
-            appearsOnIndex={0}
-            disappearsOnIndex={-1}
-            {...props}
-          />
-        )}
-        handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
-        handleStyle={{display: 'none'}}
-        onChange={onChange}>
-        <Context.Provider value={context}>
-          <View
-            style={[
-              a.absolute,
-              a.inset_0,
-              t.atoms.bg,
-              {
-                borderTopLeftRadius: 40,
-                borderTopRightRadius: 40,
-                height: Dimensions.get('window').height * 2,
-              },
-            ]}
-          />
-          {children}
-        </Context.Provider>
-      </BottomSheet>
-    </Portal>
+    isOpen && (
+      <Portal>
+        <BottomSheet
+          enableDynamicSizing={!hasSnapPoints}
+          enablePanDownToClose
+          keyboardBehavior="interactive"
+          android_keyboardInputMode="adjustResize"
+          keyboardBlurBehavior="restore"
+          topInset={insets.top}
+          {...sheetOptions}
+          snapPoints={sheetOptions.snapPoints || ['100%']}
+          ref={sheet}
+          index={openIndex}
+          backgroundStyle={{backgroundColor: 'transparent'}}
+          backdropComponent={props => (
+            <BottomSheetBackdrop
+              opacity={0.4}
+              appearsOnIndex={0}
+              disappearsOnIndex={-1}
+              {...props}
+              style={[flatten(props.style), t.atoms.bg_contrast_300]}
+            />
+          )}
+          handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
+          handleStyle={{display: 'none'}}
+          onChange={onChange}>
+          <Context.Provider value={context}>
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                t.atoms.bg,
+                {
+                  borderTopLeftRadius: 40,
+                  borderTopRightRadius: 40,
+                  height: Dimensions.get('window').height * 2,
+                },
+              ]}
+            />
+            {children}
+          </Context.Provider>
+        </BottomSheet>
+      </Portal>
+    )
   )
 }
 
-// TODO a11y props here, or is that handled by the sheet?
-export function Inner(props: DialogInnerProps) {
+export function Inner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetView
       style={[
-        a.p_lg,
+        a.p_xl,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
           paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
         },
+        flatten(style),
       ]}>
-      {props.children}
+      {children}
     </BottomSheetView>
   )
 }
 
-export function ScrollableInner(props: DialogInnerProps) {
+export function ScrollableInner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetScrollView
@@ -136,13 +161,15 @@ export function ScrollableInner(props: DialogInnerProps) {
       style={[
         a.flex_1, // main diff is this
         a.p_xl,
+        a.h_full,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
         },
+        flatten(style),
       ]}>
-      {props.children}
+      {children}
       <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
     </BottomSheetScrollView>
   )
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 305c00e97..79441fb5e 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -5,11 +5,13 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 
 import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
 import {Context} from '#/components/Dialog/context'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 
 export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
@@ -18,9 +20,9 @@ export {Input} from '#/components/forms/TextField'
 const stopPropagation = (e: any) => e.stopPropagation()
 
 export function Outer({
+  children,
   control,
   onClose,
-  children,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const {_} = useLingui()
   const t = useTheme()
@@ -147,7 +149,7 @@ export function Inner({
           a.rounded_md,
           a.w_full,
           a.border,
-          gtMobile ? a.p_xl : a.p_lg,
+          gtMobile ? a.p_2xl : a.p_xl,
           t.atoms.bg,
           {
             maxWidth: 600,
@@ -156,7 +158,7 @@ export function Inner({
             shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
             shadowRadius: 30,
           },
-          ...(Array.isArray(style) ? style : [style || {}]),
+          flatten(style),
         ]}>
         {children}
       </Animated.View>
@@ -170,25 +172,28 @@ export function Handle() {
   return null
 }
 
-/**
- * TODO(eric) unused rn
- */
-// export function Close() {
-//   const {_} = useLingui()
-//   const t = useTheme()
-//   const {close} = useDialogContext()
-//   return (
-//     <View
-//       style={[
-//         a.absolute,
-//         a.z_10,
-//         {
-//           top: a.pt_lg.paddingTop,
-//           right: a.pr_lg.paddingRight,
-//         },
-//       ]}>
-//       <Button onPress={close} label={_(msg`Close active dialog`)}>
-//       </Button>
-//     </View>
-//   )
-// }
+export function Close() {
+  const {_} = useLingui()
+  const {close} = React.useContext(Context)
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.z_10,
+        {
+          top: a.pt_md.paddingTop,
+          right: a.pr_md.paddingRight,
+        },
+      ]}>
+      <Button
+        size="small"
+        variant="ghost"
+        color="primary"
+        shape="round"
+        onPress={close}
+        label={_(msg`Close active dialog`)}>
+        <ButtonIcon icon={X} size="md" />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index d36784183..75ba825ac 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -1,24 +1,34 @@
 import React from 'react'
-import type {ViewStyle, AccessibilityProps} from 'react-native'
+import type {AccessibilityProps} from 'react-native'
 import {BottomSheetProps} from '@gorhom/bottom-sheet'
 
+import {ViewStyleProp} from '#/alf'
+
 type A11yProps = Required<AccessibilityProps>
 
 export type DialogContextProps = {
   close: () => void
 }
 
+export type DialogControlOpenOptions = {
+  /**
+   * NATIVE ONLY
+   *
+   * Optional index of the snap point to open the bottom sheet to. Defaults to
+   * 0, which is the first snap point (i.e. "open").
+   */
+  index?: number
+}
+
 export type DialogControlProps = {
-  open: (index?: number) => void
-  close: () => void
+  open: (options?: DialogControlOpenOptions) => void
+  close: (callback?: () => void) => void
 }
 
 export type DialogOuterProps = {
   control: {
     ref: React.RefObject<DialogControlProps>
-    open: (index?: number) => void
-    close: () => void
-  }
+  } & DialogControlProps
   onClose?: () => void
   nativeOptions?: {
     sheet?: Omit<BottomSheetProps, 'children'>
@@ -26,10 +36,7 @@ export type DialogOuterProps = {
   webOptions?: {}
 }
 
-type DialogInnerPropsBase<T> = React.PropsWithChildren<{
-  style?: ViewStyle
-}> &
-  T
+type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
 export type DialogInnerProps =
   | DialogInnerPropsBase<{
       label?: undefined
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index afd30b5ee..593b0863a 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,9 +1,5 @@
 import React from 'react'
-import {
-  GestureResponderEvent,
-  Linking,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import {GestureResponderEvent, Linking} from 'react-native'
 import {
   useLinkProps,
   useNavigation,
@@ -23,7 +19,7 @@ import {
 } from '#/lib/strings/url-helpers'
 import {useModalControls} from '#/state/modals'
 import {router} from '#/routes'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
 
 /**
  * Only available within a `Link`, since that inherits from `Button`.
@@ -55,11 +51,12 @@ type BaseLinkProps = Pick<
   warnOnMismatchingTextChild?: boolean
 
   /**
-   * Callback for when the link is pressed.
+   * Callback for when the link is pressed. Prevent default and return `false`
+   * to exit early and prevent navigation.
    *
    * DO NOT use this for navigation, that's what the `to` prop is for.
    */
-  onPress?: (e: GestureResponderEvent) => void
+  onPress?: (e: GestureResponderEvent) => void | false
 
   /**
    * Web-only attribute. Sets `download` attr on web.
@@ -86,7 +83,9 @@ export function useLink({
 
   const onPress = React.useCallback(
     (e: GestureResponderEvent) => {
-      outerOnPress?.(e)
+      const exitEarlyIfFalse = outerOnPress?.(e)
+
+      if (exitEarlyIfFalse === false) return
 
       const requiresWarning = Boolean(
         warnOnMismatchingTextChild &&
@@ -217,7 +216,7 @@ export function Link({
 }
 
 export type InlineLinkProps = React.PropsWithChildren<
-  BaseLinkProps & TextStyleProp
+  BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
 >
 
 export function InlineLink({
@@ -228,6 +227,7 @@ export function InlineLink({
   style,
   onPress: outerOnPress,
   download,
+  selectable,
   ...rest
 }: InlineLinkProps) {
   const t = useTheme()
@@ -253,43 +253,41 @@ export function InlineLink({
   const flattenedStyle = flatten(style)
 
   return (
-    <TouchableWithoutFeedback
-      accessibilityRole="button"
+    <Text
+      selectable={selectable}
+      label={href}
+      {...rest}
+      style={[
+        {color: t.palette.primary_500},
+        (hovered || focused || pressed) && {
+          outline: 0,
+          textDecorationLine: 'underline',
+          textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
+        },
+        flattenedStyle,
+      ]}
+      role="link"
       onPress={download ? undefined : onPress}
       onPressIn={onPressIn}
       onPressOut={onPressOut}
       onFocus={onFocus}
-      onBlur={onBlur}>
-      <Text
-        label={href}
-        {...rest}
-        style={[
-          {color: t.palette.primary_500},
-          (hovered || focused || pressed) && {
-            outline: 0,
-            textDecorationLine: 'underline',
-            textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
-          },
-          flattenedStyle,
-        ]}
-        role="link"
-        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>
-    </TouchableWithoutFeedback>
+      onBlur={onBlur}
+      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>
   )
 }
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 2c79d27cf..411679102 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -41,7 +41,7 @@ export function Outer({
         <Dialog.Inner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={{width: 'auto', maxWidth: 400}}>
+          style={[{width: 'auto', maxWidth: 400}]}>
           {children}
         </Dialog.Inner>
       </Context.Provider>
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 068ee99e0..c72fcabdd 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
 import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
 
-import {atoms as a, TextStyleProp} from '#/alf'
+import {atoms as a, TextStyleProp, flatten} from '#/alf'
 import {InlineLink} from '#/components/Link'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
 import {toShortUrl} from 'lib/strings/url-helpers'
 import {getAgent} from '#/state/session'
 
@@ -16,18 +16,20 @@ export function RichText({
   numberOfLines,
   disableLinks,
   resolveFacets = false,
-}: TextStyleProp & {
-  value: RichTextAPI | string
-  testID?: string
-  numberOfLines?: number
-  disableLinks?: boolean
-  resolveFacets?: boolean
-}) {
+  selectable,
+}: TextStyleProp &
+  Pick<TextProps, 'selectable'> & {
+    value: RichTextAPI | string
+    testID?: string
+    numberOfLines?: number
+    disableLinks?: boolean
+    resolveFacets?: boolean
+  }) {
   const detected = React.useRef(false)
   const [richText, setRichText] = React.useState<RichTextAPI>(() =>
     value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
   )
-  const styles = [a.leading_normal, style]
+  const styles = [a.leading_snug, flatten(style)]
 
   React.useEffect(() => {
     if (!resolveFacets) return
@@ -50,6 +52,7 @@ export function RichText({
     if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
       return (
         <Text
+          selectable={selectable}
           testID={testID}
           style={[
             {
@@ -65,6 +68,7 @@ export function RichText({
     }
     return (
       <Text
+        selectable={selectable}
         testID={testID}
         style={styles}
         numberOfLines={numberOfLines}
@@ -88,6 +92,7 @@ export function RichText({
     ) {
       els.push(
         <InlineLink
+          selectable={selectable}
           key={key}
           to={`/profile/${mention.did}`}
           style={[...styles, {pointerEvents: 'auto'}]}
@@ -102,6 +107,7 @@ export function RichText({
       } else {
         els.push(
           <InlineLink
+            selectable={selectable}
             key={key}
             to={link.uri}
             style={[...styles, {pointerEvents: 'auto'}]}
@@ -120,6 +126,7 @@ export function RichText({
 
   return (
     <Text
+      selectable={selectable}
       testID={testID}
       style={styles}
       numberOfLines={numberOfLines}
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index b34f51018..c9ab7a8a1 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,7 +1,16 @@
 import React from 'react'
-import {Text as RNText, TextStyle, TextProps} from 'react-native'
+import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
+import {UITextView} from 'react-native-ui-text-view'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
+import {isIOS} from '#/platform/detection'
+
+export type TextProps = RNTextProps & {
+  /**
+   * Lets the user select text, to use the native copy and paste functionality.
+   */
+  selectable?: boolean
+}
 
 /**
  * Util to calculate lineHeight from a text size atom and a leading atom
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
 /**
  * Our main text component. Use this most of the time.
  */
-export function Text({style, ...rest}: TextProps) {
+export function Text({style, selectable, ...rest}: TextProps) {
   const t = useTheme()
   const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
-  return <RNText style={s} {...rest} />
+  return selectable && isIOS ? (
+    <UITextView style={s} {...rest} />
+  ) : (
+    <RNText selectable={selectable} style={s} {...rest} />
+  )
 }
 
 export function createHeadingElement({level}: {level: number}) {
   return function HeadingElement({style, ...rest}: TextProps) {
-    const t = useTheme()
     const attr =
       web({
         role: 'heading',
         'aria-level': level,
       }) || {}
-    return (
-      <RNText
-        {...attr}
-        {...rest}
-        style={normalizeTextStyles([t.atoms.text, flatten(style)])}
-      />
-    )
+    return <Text {...attr} {...rest} style={style} />
   }
 }
 
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
 export const H5 = createHeadingElement({level: 5})
 export const H6 = createHeadingElement({level: 6})
 export function P({style, ...rest}: TextProps) {
-  const t = useTheme()
   const attr =
     web({
       role: 'paragraph',
     }) || {}
   return (
-    <RNText
+    <Text
       {...attr}
       {...rest}
-      style={normalizeTextStyles([
-        atoms.text_md,
-        atoms.leading_normal,
-        t.atoms.text,
-        flatten(style),
-      ])}
+      style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
     />
   )
 }
diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx
new file mode 100644
index 000000000..678ac3fcb
--- /dev/null
+++ b/src/components/icons/Times.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index aec8338d0..c8e5273d4 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
 export const STAGING_SERVICE = 'https://staging.bsky.dev'
-export const PROD_SERVICE = 'https://bsky.social'
-export const DEFAULT_SERVICE = PROD_SERVICE
-
+export const BSKY_SERVICE = 'https://bsky.social'
+export const DEFAULT_SERVICE = BSKY_SERVICE
 const HELP_DESK_LANG = 'en-us'
 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
 
@@ -36,92 +35,12 @@ export const MAX_GRAPHEME_LENGTH = 300
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
 
-export function IS_LOCAL_DEV(url: string) {
-  return url.includes('localhost')
-}
-
-export function IS_STAGING(url: string) {
-  return url.startsWith('https://staging.bsky.dev')
-}
-
-export function IS_PROD(url: string) {
-  // NOTE
-  // until open federation, "production" is defined as the main server
-  // this definition will not work once federation is enabled!
-  // -prf
-  return (
-    url.startsWith('https://bsky.social') ||
-    url.startsWith('https://api.bsky.app') ||
-    /bsky\.network\/?$/.test(url)
-  )
+export function IS_PROD_SERVICE(url?: string) {
+  return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
 }
 
-export const PROD_TEAM_HANDLES = [
-  'jay.bsky.social',
-  'pfrazee.com',
-  'divy.zone',
-  'dholms.xyz',
-  'why.bsky.world',
-  'iamrosewang.bsky.social',
-]
-export const STAGING_TEAM_HANDLES = [
-  'arcalinea.staging.bsky.dev',
-  'paul.staging.bsky.dev',
-  'paul2.staging.bsky.dev',
-]
-export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
-
-export function TEAM_HANDLES(serviceUrl: string) {
-  if (serviceUrl.includes('localhost')) {
-    return DEV_TEAM_HANDLES
-  } else if (serviceUrl.includes('staging')) {
-    return STAGING_TEAM_HANDLES
-  } else {
-    return PROD_TEAM_HANDLES
-  }
-}
-
-export const STAGING_DEFAULT_FEED = (rkey: string) =>
-  `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
 export const PROD_DEFAULT_FEED = (rkey: string) =>
   `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
-export async function DEFAULT_FEEDS(
-  serviceUrl: string,
-  resolveHandle: (name: string) => Promise<string>,
-) {
-  // TODO: remove this when the test suite no longer relies on it
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    // local dev
-    const aliceDid = await resolveHandle('alice.test')
-    return {
-      pinned: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-      saved: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-    }
-  } else if (IS_STAGING(serviceUrl)) {
-    // staging
-    return {
-      pinned: [STAGING_DEFAULT_FEED('whats-hot')],
-      saved: [
-        STAGING_DEFAULT_FEED('bsky-team'),
-        STAGING_DEFAULT_FEED('with-friends'),
-        STAGING_DEFAULT_FEED('whats-hot'),
-        STAGING_DEFAULT_FEED('hot-classic'),
-      ],
-    }
-  } else {
-    // production
-    return {
-      pinned: [PROD_DEFAULT_FEED('whats-hot')],
-      saved: [PROD_DEFAULT_FEED('whats-hot')],
-    }
-  }
-}
 
 export const POST_IMG_MAX = {
   width: 2000,
@@ -135,13 +54,11 @@ export const STAGING_LINK_META_PROXY =
 export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
 
 export function LINK_META_PROXY(serviceUrl: string) {
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else if (IS_STAGING(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else {
+  if (IS_PROD_SERVICE(serviceUrl)) {
     return PROD_LINK_META_PROXY
   }
+
+  return STAGING_LINK_META_PROXY
 }
 
 export const STATUS_PAGE_URL = 'https://status.bsky.app/'
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 21a575b91..1cf3b1293 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -343,45 +343,45 @@ export function parseEmbedPlayerFromUrl(
   }
 }
 
-export function getPlayerHeight({
+export function getPlayerAspect({
   type,
-  width,
   hasThumb,
+  width,
 }: {
   type: EmbedPlayerParams['type']
-  width: number
   hasThumb: boolean
-}) {
-  if (!hasThumb) return (width / 16) * 9
+  width: number
+}): {aspectRatio?: number; height?: number} {
+  if (!hasThumb) return {aspectRatio: 16 / 9}
 
   switch (type) {
     case 'youtube_video':
     case 'twitch_video':
     case 'vimeo_video':
-      return (width / 16) * 9
+      return {aspectRatio: 16 / 9}
     case 'youtube_short':
       if (SCREEN_HEIGHT < 600) {
-        return ((width / 9) * 16) / 1.75
+        return {aspectRatio: (9 / 16) * 1.75}
       } else {
-        return ((width / 9) * 16) / 1.5
+        return {aspectRatio: (9 / 16) * 1.5}
       }
     case 'spotify_album':
     case 'apple_music_album':
     case 'apple_music_playlist':
     case 'spotify_playlist':
     case 'soundcloud_set':
-      return 380
+      return {height: 380}
     case 'spotify_song':
       if (width <= 300) {
-        return 155
+        return {height: 155}
       }
-      return 232
+      return {height: 232}
     case 'soundcloud_track':
-      return 165
+      return {height: 165}
     case 'apple_music_song':
-      return 150
+      return {height: 150}
     default:
-      return width
+      return {aspectRatio: 16 / 9}
   }
 }
 
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 8a71718c8..ef341154d 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,5 +1,5 @@
 import {AtUri} from '@atproto/api'
-import {PROD_SERVICE} from 'lib/constants'
+import {BSKY_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
@@ -28,7 +28,7 @@ export function makeRecordUri(
 export function toNiceDomain(url: string): string {
   try {
     const urlp = new URL(url)
-    if (`https://${urlp.host}` === PROD_SERVICE) {
+    if (`https://${urlp.host}` === BSKY_SERVICE) {
       return 'Bluesky Social'
     }
     return urlp.host ? urlp.host : url
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
index 4a2210853..636565e34 100644
--- a/src/screens/Onboarding/StepTopicalFeeds.tsx
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -3,7 +3,6 @@ import {View} from 'react-native'
 import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
 
-import {IS_PROD} from '#/env'
 import {atoms as a} from '#/alf'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
@@ -22,21 +21,28 @@ import {
 import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
 import {aggregateInterestItems} from '#/screens/Onboarding/util'
 import {IconCircle} from '#/components/IconCircle'
+import {IS_PROD_SERVICE} from 'lib/constants'
+import {useSession} from 'state/session'
 
 export function StepTopicalFeeds() {
   const {_} = useLingui()
   const {track} = useAnalytics()
+  const {currentAccount} = useSession()
   const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
   const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
   const [saving, setSaving] = React.useState(false)
   const suggestedFeedUris = React.useMemo(() => {
-    if (!IS_PROD) return []
+    if (!IS_PROD_SERVICE(currentAccount?.service)) return []
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
       state.interestsStepResults.apiResponse.suggestedFeedUris,
       state.interestsStepResults.apiResponse.suggestedFeedUris.default,
     ).slice(0, 10)
-  }, [state.interestsStepResults])
+  }, [
+    currentAccount?.service,
+    state.interestsStepResults.apiResponse.suggestedFeedUris,
+    state.interestsStepResults.selectedInterests,
+  ])
 
   const interestsText = React.useMemo(() => {
     const i = state.interestsStepResults.selectedInterests.map(
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index b91db9237..405d054d4 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   return query
 }
 
-/**
- * This helper is used by the post-thread placeholder function to
- * find a post in the query-data cache
- */
-export function findPostInQueryData(
-  queryClient: QueryClient,
-  uri: string,
-): AppBskyFeedDefs.PostView | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
-  }
-}
-
 export function* findAllPostsInQueryData(
   queryClient: QueryClient,
   uri: string,
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 320009089..40399395a 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -365,23 +365,6 @@ function createApi(
   }
 }
 
-/**
- * This helper is used by the post-thread placeholder function to
- * find a post in the query-data cache
- */
-export function findPostInQueryData(
-  queryClient: QueryClient,
-  uri: string,
-): AppBskyFeedDefs.PostView | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
-  }
-}
-
 export function* findAllPostsInQueryData(
   queryClient: QueryClient,
   uri: string,
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index ba4243163..26d40599c 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
 
 import {getAgent} from '#/state/session'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
-import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
-import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
+import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
+import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
 import {precacheThreadPostProfiles} from './profile'
 import {getEmbeddedPost} from './util'
 
@@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) {
         return undefined
       }
       {
-        const item = findPostInQueryData(queryClient, uri)
-        if (item) {
-          return threadNodeToPlaceholderThread(item)
-        }
-      }
-      {
-        const item = findPostInFeedQueryData(queryClient, uri)
-        if (item) {
-          return postViewToPlaceholderThread(item)
-        }
-      }
-      {
-        const item = findPostInNotifsQueryData(queryClient, uri)
-        if (item) {
-          return postViewToPlaceholderThread(item)
+        const post = findPostInQueryData(queryClient, uri)
+        if (post) {
+          return post
         }
       }
       return undefined
@@ -171,11 +159,18 @@ function responseToThreadNodes(
     AppBskyFeedPost.isRecord(node.post.record) &&
     AppBskyFeedPost.validateRecord(node.post.record).success
   ) {
+    const post = node.post
+    // These should normally be present. They're missing only for
+    // posts that were *just* created. Ideally, the backend would
+    // know to return zeros. Fill them in manually to compensate.
+    post.replyCount ??= 0
+    post.likeCount ??= 0
+    post.repostCount ??= 0
     return {
       type: 'post',
       _reactKey: node.post.uri,
       uri: node.post.uri,
-      post: node.post,
+      post: post,
       record: node.post.record,
       parent:
         node.parent && direction !== 'down'
@@ -213,14 +208,24 @@ function responseToThreadNodes(
 function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
-): ThreadNode | undefined {
-  const generator = findAllPostsInQueryData(queryClient, uri)
-  const result = generator.next()
-  if (result.done) {
-    return undefined
-  } else {
-    return result.value
+): ThreadNode | void {
+  let partial
+  for (let item of findAllPostsInQueryData(queryClient, uri)) {
+    if (item.type === 'post') {
+      // Currently, the backend doesn't send full post info in some cases
+      // (for example, for quoted posts). We use missing `likeCount`
+      // as a way to detect that. In the future, we should fix this on
+      // the backend, which will let us always stop on the first result.
+      const hasAllInfo = item.post.likeCount != null
+      if (hasAllInfo) {
+        return item
+      } else {
+        partial = item
+        // Keep searching, we might still find a full post in the cache.
+      }
+    }
   }
+  return partial
 }
 
 export function* findAllPostsInQueryData(
@@ -236,7 +241,10 @@ export function* findAllPostsInQueryData(
     }
     for (const item of traverseThread(queryData)) {
       if (item.uri === uri) {
-        yield item
+        const placeholder = threadNodeToPlaceholderThread(item)
+        if (placeholder) {
+          yield placeholder
+        }
       }
       const quotedPost =
         item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
@@ -245,6 +253,12 @@ export function* findAllPostsInQueryData(
       }
     }
   }
+  for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
+    yield postViewToPlaceholderThread(post)
+  }
+  for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
+    yield postViewToPlaceholderThread(post)
+  }
 }
 
 function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts
index f3a87ae5d..54752b332 100644
--- a/src/state/queries/util.ts
+++ b/src/state/queries/util.ts
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
     record: v.value,
     indexedAt: v.indexedAt,
     labels: v.labels,
+    embed: v.embeds?.[0],
   }
 }
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index 276eaf924..68cfaceec 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -12,7 +12,7 @@ import {createFullHandle} from '#/lib/strings/handles'
 import {cleanError} from '#/lib/strings/errors'
 import {useOnboardingDispatch} from '#/state/shell/onboarding'
 import {useSessionApi} from '#/state/session'
-import {DEFAULT_SERVICE, IS_PROD} from '#/lib/constants'
+import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
 import {
   DEFAULT_PROD_FEEDS,
   usePreferencesSetBirthDateMutation,
@@ -147,7 +147,7 @@ export function useSubmitCreateAccount(
             : undefined,
         })
         setBirthDate({birthDate: uiState.birthDate})
-        if (IS_PROD(uiState.serviceUrl)) {
+        if (IS_PROD_SERVICE(uiState.serviceUrl)) {
           setSavedFeeds(DEFAULT_PROD_FEEDS)
         }
       } catch (e: any) {
diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx
index 2277eb867..b26ac1dcb 100644
--- a/src/view/com/auth/server-input/index.tsx
+++ b/src/view/com/auth/server-input/index.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import {useLingui} from '@lingui/react'
 import {Trans, msg} from '@lingui/macro'
-import {PROD_SERVICE} from 'lib/constants'
+import {BSKY_SERVICE} from 'lib/constants'
 import * as persisted from '#/state/persisted'
 
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
@@ -26,7 +26,7 @@ export function ServerInputDialog({
   const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
     persisted.get('pdsAddressHistory') || [],
   )
-  const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
+  const [fixedOption, setFixedOption] = React.useState([BSKY_SERVICE])
   const [customAddress, setCustomAddress] = React.useState('')
 
   const onClose = React.useCallback(() => {
@@ -86,7 +86,7 @@ export function ServerInputDialog({
             label="Preferences"
             values={fixedOption}
             onChange={setFixedOption}>
-            <ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
+            <ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
               {_(msg`Bluesky`)}
             </ToggleButton.Button>
             <ToggleButton.Button
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 0de88b248..9bd7238df 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {UserAvatar} from '../util/UserAvatar'
@@ -25,6 +25,7 @@ import {
 } from '#/state/queries/preferences'
 import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {useTheme} from '#/alf'
 
 export function FeedSourceCard({
   feedUri,
@@ -82,6 +83,7 @@ export function FeedSourceCardLoaded({
   pinOnSave?: boolean
   showMinimalPlaceholder?: boolean
 }) {
+  const t = useTheme()
   const pal = usePalette('default')
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
@@ -266,8 +268,8 @@ export function FeedSourceCardLoaded({
 
       {showDescription && feed.description ? (
         <RichText
-          style={[pal.textLight, styles.description]}
-          richText={feed.description}
+          style={[t.atoms.text_contrast_high, styles.description]}
+          value={feed.description}
           numberOfLines={3}
         />
       ) : null}
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
index 5750faec1..19842eb54 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -3,7 +3,7 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
-import {RichText as RichTextCom} from '../util/text/RichText'
+import {RichText as RichTextCom} from '#/components/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -12,6 +12,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {Trans} from '@lingui/macro'
+import {atoms as a} from '#/alf'
 
 export const ListCard = ({
   testID,
@@ -119,9 +120,9 @@ export const ListCard = ({
       {descriptionRichText ? (
         <View style={styles.details}>
           <RichTextCom
-            style={[pal.text, s.flex1]}
+            style={[a.flex_1]}
             numberOfLines={20}
-            richText={descriptionRichText}
+            value={descriptionRichText}
           />
         </View>
       ) : undefined}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index c66947d44..015859dd6 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -11,7 +11,7 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {Link, TextLink} from '../util/Link'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
@@ -44,6 +44,7 @@ import {ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from 'state/session'
 import {WhoCanReply} from '../threadgate/WhoCanReply'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {atoms as a} from '#/alf'
 
 export function PostThreadItem({
   post,
@@ -326,10 +327,8 @@ let PostThreadItemLoaded = ({
                     styles.postTextLargeContainer,
                   ]}>
                   <RichText
-                    type="post-text-lg"
-                    richText={richText}
-                    lineHeight={1.3}
-                    style={s.flex1}
+                    value={richText}
+                    style={[a.flex_1, a.text_xl]}
                     selectable
                   />
                 </View>
@@ -522,10 +521,8 @@ let PostThreadItemLoaded = ({
                 {richText?.text ? (
                   <View style={styles.postTextContainer}>
                     <RichText
-                      type="post-text"
-                      richText={richText}
-                      style={[pal.text, s.flex1]}
-                      lineHeight={1.3}
+                      value={richText}
+                      style={[a.flex_1, a.text_md]}
                       numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                     />
                   </View>
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 2f1c0d37b..aec916adb 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -17,7 +17,7 @@ import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -29,6 +29,7 @@ import {useComposerControls} from '#/state/shell/composer'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {atoms as a} from '#/alf'
 
 export function Post({
   post,
@@ -184,11 +185,9 @@ function PostInner({
               <View style={styles.postTextContainer}>
                 <RichText
                   testID="postText"
-                  type="post-text"
-                  richText={richText}
-                  lineHeight={1.3}
+                  value={richText}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
-                  style={s.flex1}
+                  style={[a.flex_1, a.text_md]}
                 />
               </View>
             ) : undefined}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 8d0f2bef2..6f64de181 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -20,7 +20,7 @@ import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -36,6 +36,7 @@ import {FeedNameText} from '../util/FeedInfoText'
 import {useSession} from '#/state/session'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {atoms as a} from '#/alf'
 
 export function FeedItem({
   post,
@@ -347,11 +348,9 @@ let PostContent = ({
         <View style={styles.postTextContainer}>
           <RichText
             testID="postText"
-            type="post-text"
-            richText={richText}
-            lineHeight={1.3}
+            value={richText}
             numberOfLines={limitLines ? MAX_POST_LINES : undefined}
-            style={s.flex1}
+            style={[a.flex_1, a.text_md]}
           />
         </View>
       ) : undefined}
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 8fd50fad6..3e479d7b5 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -23,7 +23,7 @@ import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
 import {ThemedText} from '../util/text/ThemedText'
-import {RichText} from '../util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
 import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
@@ -56,6 +56,7 @@ import {Shadow} from '#/state/cache/types'
 import {useRequireAuth} from '#/state/session'
 import {LabelInfo} from '../util/moderation/LabelInfo'
 import {useProfileShadow} from 'state/cache/profile-shadow'
+import {atoms as a} from '#/alf'
 
 let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
@@ -608,12 +609,12 @@ let ProfileHeader = ({
               </Text>
             </View>
             {descriptionRT && !moderation.profile.blur ? (
-              <View pointerEvents="auto">
+              <View pointerEvents="auto" style={[styles.description]}>
                 <RichText
                   testID="profileHeaderDescription"
-                  style={[styles.description, pal.text]}
+                  style={[a.text_md]}
                   numberOfLines={15}
-                  richText={descriptionRT}
+                  value={descriptionRT}
                 />
               </View>
             ) : undefined}
diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx
index 1e672e945..e743e89bb 100644
--- a/src/view/com/util/EventStopper.tsx
+++ b/src/view/com/util/EventStopper.tsx
@@ -1,11 +1,14 @@
 import React from 'react'
-import {View} from 'react-native'
+import {View, ViewStyle} from 'react-native'
 
 /**
  * This utility function captures events and stops
  * them from propagating upwards.
  */
-export function EventStopper({children}: React.PropsWithChildren<{}>) {
+export function EventStopper({
+  children,
+  style,
+}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) {
   const stop = (e: any) => {
     e.stopPropagation()
   }
@@ -15,7 +18,8 @@ export function EventStopper({children}: React.PropsWithChildren<{}>) {
       onTouchEnd={stop}
       // @ts-ignore web only -prf
       onClick={stop}
-      onKeyDown={stop}>
+      onKeyDown={stop}
+      style={style}>
       {children}
     </View>
   )
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index e56c88d2c..1dfb687df 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -2,6 +2,7 @@ import React, {memo} from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
 import {
   AppBskyActorDefs,
   AppBskyFeedPost,
@@ -19,6 +20,8 @@ import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
 import {useModalControls} from '#/state/modals'
 import {makeProfileLink} from '#/lib/routes/links'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {getCurrentRoute} from 'lib/routes/helpers'
 import {getTranslatorLink} from '#/locale/helpers'
 import {usePostDeleteMutation} from '#/state/queries/post'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
@@ -63,6 +66,7 @@ let PostDropdownBtn = ({
   const hiddenPosts = useHiddenPosts()
   const {hidePost} = useHiddenPostsApi()
   const openLink = useOpenLink()
+  const navigation = useNavigation()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -82,13 +86,38 @@ let PostDropdownBtn = ({
     postDeleteMutation.mutateAsync({uri: postUri}).then(
       () => {
         Toast.show(_(msg`Post deleted`))
+
+        const route = getCurrentRoute(navigation.getState())
+        if (route.name === 'PostThread') {
+          const params = route.params as CommonNavigatorParams['PostThread']
+          if (
+            currentAccount &&
+            isAuthor &&
+            (params.name === currentAccount.handle ||
+              params.name === currentAccount.did)
+          ) {
+            const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
+            if (currentHref === href && navigation.canGoBack()) {
+              navigation.goBack()
+            }
+          }
+        }
       },
       e => {
         logger.error('Failed to delete post', {message: e})
         Toast.show(_(msg`Failed to delete post, please try again`))
       },
     )
-  }, [postUri, postDeleteMutation, _])
+  }, [
+    navigation,
+    postUri,
+    postDeleteMutation,
+    postAuthor,
+    currentAccount,
+    isAuthor,
+    href,
+    _,
+  ])
 
   const onToggleThreadMute = React.useCallback(() => {
     try {
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index d556e7669..cf2db5b33 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -21,7 +21,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {AppBskyEmbedExternal} from '@atproto/api'
-import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
+import {EmbedPlayerParams, getPlayerAspect} from 'lib/strings/embed-player'
 import {EventStopper} from '../EventStopper'
 import {isNative} from 'platform/detection'
 import {NavigationProp} from 'lib/routes/types'
@@ -67,14 +67,12 @@ function PlaceholderOverlay({
 
 // This renders the webview/youtube player as a separate layer
 function Player({
-  height,
   params,
   onLoad,
   isPlayerActive,
 }: {
   isPlayerActive: boolean
   params: EmbedPlayerParams
-  height: number
   onLoad: () => void
 }) {
   // ensures we only load what's requested
@@ -91,25 +89,21 @@ function Player({
   if (!isPlayerActive) return null
 
   return (
-    <View style={[styles.layer, styles.playerLayer]}>
-      <EventStopper>
-        <View style={{height, width: '100%'}}>
-          <WebView
-            javaScriptEnabled={true}
-            onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
-            mediaPlaybackRequiresUserAction={false}
-            allowsInlineMediaPlayback
-            bounces={false}
-            allowsFullscreenVideo
-            nestedScrollEnabled
-            source={{uri: params.playerUri}}
-            onLoad={onLoad}
-            setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
-            style={[styles.webview, styles.topRadius]}
-          />
-        </View>
-      </EventStopper>
-    </View>
+    <EventStopper style={[styles.layer, styles.playerLayer]}>
+      <WebView
+        javaScriptEnabled={true}
+        onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+        mediaPlaybackRequiresUserAction={false}
+        allowsInlineMediaPlayback
+        bounces={false}
+        allowsFullscreenVideo
+        nestedScrollEnabled
+        source={{uri: params.playerUri}}
+        onLoad={onLoad}
+        style={styles.webview}
+        setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+      />
+    </EventStopper>
   )
 }
 
@@ -129,13 +123,16 @@ export function ExternalPlayer({
 
   const [isPlayerActive, setPlayerActive] = React.useState(false)
   const [isLoading, setIsLoading] = React.useState(true)
-  const [dim, setDim] = React.useState({
-    width: 0,
-    height: 0,
-  })
 
-  const viewRef = useAnimatedRef()
+  const aspect = React.useMemo(() => {
+    return getPlayerAspect({
+      type: params.type,
+      width: windowDims.width,
+      hasThumb: !!link.thumb,
+    })
+  }, [params.type, windowDims.width, link.thumb])
 
+  const viewRef = useAnimatedRef()
   const frameCallback = useFrameCallback(() => {
     const measurement = measure(viewRef)
     if (!measurement) return
@@ -180,17 +177,6 @@ export function ExternalPlayer({
     }
   }, [navigation, isPlayerActive, frameCallback])
 
-  // calculate height for the player and the screen size
-  const height = React.useMemo(
-    () =>
-      getPlayerHeight({
-        type: params.type,
-        width: dim.width,
-        hasThumb: !!link.thumb,
-      }),
-    [params.type, dim.width, link.thumb],
-  )
-
   const onLoad = React.useCallback(() => {
     setIsLoading(false)
   }, [])
@@ -216,32 +202,11 @@ export function ExternalPlayer({
     [externalEmbedsPrefs, openModal, params.source],
   )
 
-  // measure the layout to set sizing
-  const onLayout = React.useCallback(
-    (event: {nativeEvent: {layout: {width: any; height: any}}}) => {
-      setDim({
-        width: event.nativeEvent.layout.width,
-        height: event.nativeEvent.layout.height,
-      })
-    },
-    [],
-  )
-
   return (
-    <Animated.View
-      ref={viewRef}
-      style={{height}}
-      collapsable={false}
-      onLayout={onLayout}>
+    <Animated.View ref={viewRef} collapsable={false} style={[aspect]}>
       {link.thumb && (!isPlayerActive || isLoading) && (
         <Image
-          style={[
-            {
-              width: dim.width,
-              height,
-            },
-            styles.topRadius,
-          ]}
+          style={[{flex: 1}, styles.topRadius]}
           source={{uri: link.thumb}}
           accessibilityIgnoresInvertColors
         />
@@ -251,12 +216,7 @@ export function ExternalPlayer({
         isPlayerActive={isPlayerActive}
         onPress={onPlayPress}
       />
-      <Player
-        isPlayerActive={isPlayerActive}
-        params={params}
-        height={height}
-        onLoad={onLoad}
-      />
+      <Player isPlayerActive={isPlayerActive} params={params} onLoad={onLoad} />
     </Animated.View>
   )
 }
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index d9d84feb4..c128a6f00 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -20,7 +20,8 @@ import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
 import {InfoCircleIcon} from 'lib/icons'
 import {Trans} from '@lingui/macro'
-import {RichText} from 'view/com/util/text/RichText'
+import {RichText} from '#/components/RichText'
+import {atoms as a} from '#/alf'
 
 export function MaybeQuoteEmbed({
   embed,
@@ -127,11 +128,10 @@ export function QuoteEmbed({
       ) : null}
       {richText ? (
         <RichText
-          richText={richText}
-          type="post-text"
-          style={pal.text}
+          value={richText}
+          style={[a.text_md]}
           numberOfLines={20}
-          noLinks
+          disableLinks
         />
       ) : null}
       {embed && <PostEmbeds embed={embed} moderation={{}} />}
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index e910127fe..b6d461224 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -10,6 +10,9 @@ import {usePalette} from 'lib/hooks/usePalette'
 
 const WORD_WRAP = {wordWrap: 1}
 
+/**
+ * @deprecated use `#/components/RichText`
+ */
 export function RichText({
   testID,
   type = 'md',
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 4901308ba..2c346e892 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -17,7 +17,7 @@ import {TextLink} from 'view/com/util/Link'
 import {ListRef} from 'view/com/util/List'
 import {Button} from 'view/com/util/forms/Button'
 import {Text} from 'view/com/util/text/Text'
-import {RichText} from 'view/com/util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
 import {EmptyState} from 'view/com/util/EmptyState'
@@ -59,6 +59,7 @@ import {useComposerControls} from '#/state/shell/composer'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
+import {atoms as a} from '#/alf'
 
 const SECTION_TITLES = ['Posts', 'About']
 
@@ -575,9 +576,8 @@ function AboutSection({
         {feedInfo.description ? (
           <RichText
             testID="listDescription"
-            type="lg"
-            style={pal.text}
-            richText={feedInfo.description}
+            style={[a.text_md]}
+            value={feedInfo.description}
           />
         ) : (
           <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 796464883..d86b569e2 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -14,7 +14,7 @@ import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
 import {CenteredView} from 'view/com/util/Views'
 import {EmptyState} from 'view/com/util/EmptyState'
 import {LoadingScreen} from 'view/com/util/LoadingScreen'
-import {RichText} from 'view/com/util/text/RichText'
+import {RichText} from '#/components/RichText'
 import {Button} from 'view/com/util/forms/Button'
 import {TextLink} from 'view/com/util/Link'
 import {ListRef} from 'view/com/util/List'
@@ -60,6 +60,7 @@ import {
 import {logger} from '#/logger'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {listenSoftReset} from '#/state/events'
+import {atoms as a} from '#/alf'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -742,9 +743,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
             {descriptionRT ? (
               <RichText
                 testID="listDescription"
-                type="lg"
-                style={pal.text}
-                richText={descriptionRT}
+                style={[a.text_md]}
+                value={descriptionRT}
               />
             ) : (
               <Text
diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx
index 8c028d4d3..ba8fad2df 100644
--- a/src/view/screens/Settings/ExportCarDialog.tsx
+++ b/src/view/screens/Settings/ExportCarDialog.tsx
@@ -76,7 +76,7 @@ export function ExportCarDialog({
               This feature is in beta. You can read more about repository
               exports in{' '}
               <InlineLink
-                to="https://atproto.com/blog/repo-export"
+                to="https://docs.bsky.app/blog/repo-export"
                 style={[a.text_sm]}>
                 this blogpost
               </InlineLink>
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index db568c6bd..09be124db 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -9,7 +9,8 @@ import * as Prompt from '#/components/Prompt'
 import {useDialogStateControlContext} from '#/state/dialogs'
 
 export function Dialogs() {
-  const control = Dialog.useDialogControl()
+  const scrollable = Dialog.useDialogControl()
+  const basic = Dialog.useDialogControl()
   const prompt = Prompt.usePromptControl()
   const {closeAllDialogs} = useDialogStateControlContext()
 
@@ -20,8 +21,31 @@ export function Dialogs() {
         color="secondary"
         size="small"
         onPress={() => {
-          control.open()
+          scrollable.open()
           prompt.open()
+          basic.open()
+        }}
+        label="Open basic dialog">
+        Open all dialogs
+      </Button>
+
+      <Button
+        variant="outline"
+        color="secondary"
+        size="small"
+        onPress={() => {
+          scrollable.open()
+        }}
+        label="Open basic dialog">
+        Open scrollable dialog
+      </Button>
+
+      <Button
+        variant="outline"
+        color="secondary"
+        size="small"
+        onPress={() => {
+          basic.open()
         }}
         label="Open basic dialog">
         Open basic dialog
@@ -48,9 +72,18 @@ export function Dialogs() {
         </Prompt.Actions>
       </Prompt.Outer>
 
+      <Dialog.Outer control={basic}>
+        <Dialog.Handle />
+
+        <Dialog.Inner label="test">
+          <H3 nativeID="dialog-title">Dialog</H3>
+          <P nativeID="dialog-description">A basic dialog</P>
+        </Dialog.Inner>
+      </Dialog.Outer>
+
       <Dialog.Outer
-        control={control}
-        nativeOptions={{sheet: {snapPoints: ['90%']}}}>
+        control={scrollable}
+        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
         <Dialog.Handle />
 
         <Dialog.ScrollableInner
@@ -77,9 +110,13 @@ export function Dialogs() {
                 variant="outline"
                 color="primary"
                 size="small"
-                onPress={() => control.close()}
+                onPress={() =>
+                  scrollable.close(() => {
+                    console.log('CLOSED')
+                  })
+                }
                 label="Open basic dialog">
-                Close basic dialog
+                Close dialog
               </Button>
             </View>
           </View>
diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
index 5d3a96f4d..8ee4270b2 100644
--- a/src/view/screens/Storybook/Typography.tsx
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -8,7 +8,9 @@ import {RichText} from '#/components/RichText'
 export function Typography() {
   return (
     <View style={[a.gap_md]}>
-      <Text style={[a.text_5xl]}>atoms.text_5xl</Text>
+      <Text selectable style={[a.text_5xl]}>
+        atoms.text_5xl
+      </Text>
       <Text style={[a.text_4xl]}>atoms.text_4xl</Text>
       <Text style={[a.text_3xl]}>atoms.text_3xl</Text>
       <Text style={[a.text_2xl]}>atoms.text_2xl</Text>
@@ -24,6 +26,7 @@ export function Typography() {
         value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
       />
       <RichText
+        selectable
         resolveFacets
         value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
         style={[a.text_xl]}