about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-submit-android.yml1
-rw-r--r--.github/workflows/build-submit-ios.yml1
-rw-r--r--.github/workflows/bundle-deploy-eas-update.yml5
-rw-r--r--.github/workflows/pull-request-comment.yml179
-rw-r--r--app.config.js1
-rw-r--r--src/alf/atoms.ts3
-rw-r--r--src/components/SearchError.tsx45
-rw-r--r--src/components/VideoPostCard.tsx17
-rw-r--r--src/components/interstitials/TrendingVideos.tsx31
-rw-r--r--src/lib/hooks/useIntentHandler.ts23
-rw-r--r--src/lib/hooks/useOTAUpdates.ts166
-rw-r--r--src/screens/Search/SearchResults.tsx65
-rw-r--r--src/screens/Settings/Settings.tsx16
13 files changed, 498 insertions, 55 deletions
diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml
index f75c95052..e862a701f 100644
--- a/.github/workflows/build-submit-android.yml
+++ b/.github/workflows/build-submit-android.yml
@@ -13,6 +13,7 @@ on:
 
 jobs:
   build:
+    if: github.repository == 'bluesky-social/social-app'
     name: Build and Submit Android
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml
index dd7c5f85b..a197695db 100644
--- a/.github/workflows/build-submit-ios.yml
+++ b/.github/workflows/build-submit-ios.yml
@@ -13,6 +13,7 @@ on:
 
 jobs:
   build:
+    if: github.repository == 'bluesky-social/social-app'
     name: Build and Submit iOS
     runs-on: macos-15
     steps:
diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml
index 5a4702f96..475bc087f 100644
--- a/.github/workflows/bundle-deploy-eas-update.yml
+++ b/.github/workflows/bundle-deploy-eas-update.yml
@@ -20,6 +20,7 @@ on:
 
 jobs:
   bundleDeploy:
+    if: github.repository == 'bluesky-social/social-app'
     name: Bundle and Deploy EAS Update
     runs-on: ubuntu-latest
     concurrency:
@@ -150,7 +151,7 @@ jobs:
     needs: [bundleDeploy]
     # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
     # available here
-    if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
+    if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
     steps:
       - name: Check for EXPO_TOKEN
         run: >
@@ -239,7 +240,7 @@ jobs:
     needs: [bundleDeploy]
     # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
     # available here
-    if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
+    if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
 
     steps:
       - name: Check for EXPO_TOKEN
diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml
new file mode 100644
index 000000000..2233fbbba
--- /dev/null
+++ b/.github/workflows/pull-request-comment.yml
@@ -0,0 +1,179 @@
+---
+name: PR Comment Trigger
+
+on:
+  issue_comment:
+    types: [created]
+
+jobs:
+  handle-comment:
+    if: github.event.issue.pull_request
+    runs-on: ubuntu-latest
+    outputs:
+      should-deploy: ${{ steps.check-org.outputs.result }}
+
+    steps:
+      - name: Check if bot is mentioned
+        id: check-mention
+        env:
+          COMMENT: ${{ github.event.comment.body }}
+        run: |
+          if [[ "$TITLE" == *"@github-actions"* ]] || \
+             [[ "$TITLE" == *"github-actions[bot]"* ]]; then
+            bot_mentioned=true
+          else
+            bot_mentioned=false
+          fi
+
+
+          if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
+            has_ota=true
+          else
+            has_ota=false
+          fi
+
+
+          if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then
+            echo "mentioned=true" >> $GITHUB_OUTPUT
+          else
+            echo "mentioned=false" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Check organization membership
+        if: steps.check-mention.outputs.mentioned == 'true'
+        id: check-org
+        uses: actions/github-script@v7
+        with:
+          script: |
+            try {
+              const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                username: context.payload.comment.user.login
+              });
+
+              const hasAccess = ['admin', 'write'].includes(perm.permission);
+              console.log(`User has ${perm.permission} access`);
+
+              return hasAccess;
+            } catch(error) {
+              console.log('User has no repository access');
+              return false;
+            }
+
+  bundle-deploy:
+    name: Bundle and Deploy EAS Update
+    runs-on: ubuntu-latest
+    needs: [handle-comment]
+    if: needs.handle-comment.outputs.should-deploy == 'true'
+    concurrency:
+      group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
+      cancel-in-progress: true
+
+    steps:
+      - name: 💬 Drop a comment
+        uses: marocchino/sticky-pull-request-comment@v2
+        with:
+          header: pull-request-eas-build-${{ github.sha }}
+          message: |
+            An OTA deployment has been requested and is now running...
+
+            [Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
+            ---
+            *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+
+      - 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@v4
+        with:
+          node-version-file: .nvmrc
+          cache: yarn
+
+      - name: Install dependencies
+        run: yarn install --frozen-lockfile
+
+      - name: Lint check
+        run: yarn lint
+
+      - name: Lint lockfile
+        run: yarn lockfile-lint
+
+      - name: 🔤 Compile translations
+        run: yarn intl:build 2>&1 | tee i18n.log
+
+      - name: Check for i18n compilation errors
+        run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
+
+      - name: Type check
+        run: yarn typecheck
+
+      - name: 🔨 Setup EAS
+        uses: expo/expo-github-action@v8
+        with:
+          expo-version: latest
+          eas-version: latest
+          token: ${{ secrets.EXPO_TOKEN }}
+
+      - name: ⛏️ Setup Expo
+        run: yarn global add eas-cli-local-build-plugin
+
+      - name: 🪛 Setup jq
+        uses: dcarbone/install-jq-action@v2
+
+      - name: ✏️ Write environment variables
+        run: |
+          export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
+          echo "${{ secrets.ENV_TOKEN }}" > .env
+          echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
+          echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
+          echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
+          echo "$json" > google-services.json
+
+      - name: Setup Sentry vars for build-time injection
+        id: sentry
+        run: |
+          echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
+          echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
+
+      - name: 🏗️ Create Bundle
+        run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="pull-request" yarn export
+
+      - name: 📦 Package Bundle and 🚀 Deploy
+        run: yarn use-build-number bash scripts/bundleUpdate.sh
+        env:
+          DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
+          CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
+
+
+      - name: 💬 Drop a comment
+        uses: marocchino/sticky-pull-request-comment@v2
+        with:
+          header: pull-request-eas-build-${{ github.sha }}
+          message: |
+            Your requested OTA deployment was successful! You may now apply it by pressing the link below.
+
+            [Apply OTA update](bluesky://ota-apply?channel=pull-request-${{ github.event.issue.number }})
+
+            [Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
+            ---
+            *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+
+
+      - name: 💬 Drop a comment
+        uses: marocchino/sticky-pull-request-comment@v2
+        if: failure()
+        with:
+          header: pull-request-eas-build-${{ github.sha }}
+          message: |
+            Your requested OTA deployment was unsuccessful. See action logs for more details.
+            ---
+            *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
diff --git a/app.config.js b/app.config.js
index cbe028273..e065d0d73 100644
--- a/app.config.js
+++ b/app.config.js
@@ -190,7 +190,6 @@ module.exports = function (_config) {
             }
           : undefined,
         checkAutomatically: 'NEVER',
-        channel: UPDATES_CHANNEL,
       },
       plugins: [
         'expo-video',
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 440ac16ac..572560217 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -67,6 +67,9 @@ export const atoms = {
     zIndex: 50,
   },
 
+  overflow_visible: {
+    overflow: 'visible',
+  },
   overflow_hidden: {
     overflow: 'hidden',
   },
diff --git a/src/components/SearchError.tsx b/src/components/SearchError.tsx
new file mode 100644
index 000000000..443bbab8f
--- /dev/null
+++ b/src/components/SearchError.tsx
@@ -0,0 +1,45 @@
+import {View} from 'react-native'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {atoms as a, useBreakpoints} from '#/alf'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
+
+export function SearchError({
+  title,
+  children,
+}: {
+  title?: string
+  children?: React.ReactNode
+}) {
+  const {gtMobile} = useBreakpoints()
+  const pal = usePalette('default')
+
+  return (
+    <Layout.Content>
+      <View
+        style={[
+          a.align_center,
+          a.gap_4xl,
+          a.px_xl,
+          {
+            paddingVertical: 150,
+          },
+        ]}>
+        <TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
+        <View
+          style={[
+            a.align_center,
+            {maxWidth: gtMobile ? 394 : 294},
+            gtMobile ? a.gap_md : a.gap_sm,
+          ]}>
+          <Text style={[a.font_bold, a.text_lg, a.text_center, a.leading_snug]}>
+            {title}
+          </Text>
+          {children}
+        </View>
+      </View>
+    </Layout.Content>
+  )
+}
diff --git a/src/components/VideoPostCard.tsx b/src/components/VideoPostCard.tsx
index 191c7b82a..a1bdd29b4 100644
--- a/src/components/VideoPostCard.tsx
+++ b/src/components/VideoPostCard.tsx
@@ -411,6 +411,7 @@ export function CompactVideoPostCard({
       onPressOut={onPressOut}
       style={[
         a.flex_col,
+        t.atoms.shadow_sm,
         {
           alignItems: undefined,
           justifyContent: undefined,
@@ -421,8 +422,10 @@ export function CompactVideoPostCard({
           <View
             style={[
               a.justify_center,
-              a.rounded_md,
+              a.rounded_lg,
               a.overflow_hidden,
+              a.border,
+              t.atoms.border_contrast_low,
               {
                 backgroundColor: black,
                 aspectRatio: 9 / 16,
@@ -443,6 +446,8 @@ export function CompactVideoPostCard({
                   a.inset_0,
                   a.justify_center,
                   a.align_center,
+                  a.border,
+                  t.atoms.border_contrast_low,
                   {
                     backgroundColor: 'black',
                     opacity: 0.2,
@@ -462,8 +467,10 @@ export function CompactVideoPostCard({
           <View
             style={[
               a.justify_center,
-              a.rounded_md,
+              a.rounded_lg,
               a.overflow_hidden,
+              a.border,
+              t.atoms.border_contrast_low,
               {
                 backgroundColor: black,
                 aspectRatio: 9 / 16,
@@ -534,11 +541,13 @@ export function CompactVideoPostCardPlaceholder() {
   const black = getBlackColor(t)
 
   return (
-    <View style={[a.flex_1]}>
+    <View style={[a.flex_1, t.atoms.shadow_sm]}>
       <View
         style={[
-          a.rounded_md,
+          a.rounded_lg,
           a.overflow_hidden,
+          a.border,
+          t.atoms.border_contrast_low,
           {
             backgroundColor: black,
             aspectRatio: 9 / 16,
diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx
index fab738b9c..4d59e2fb5 100644
--- a/src/components/interstitials/TrendingVideos.tsx
+++ b/src/components/interstitials/TrendingVideos.tsx
@@ -16,7 +16,6 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
 import {Link} from '#/components/Link'
 import * as Prompt from '#/components/Prompt'
 import {Text} from '#/components/Typography'
@@ -25,7 +24,7 @@ import {
   CompactVideoPostCardPlaceholder,
 } from '#/components/VideoPostCard'
 
-const CARD_WIDTH = 100
+const CARD_WIDTH = 108
 
 const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
 const FEED_PARAMS: {
@@ -68,9 +67,10 @@ export function TrendingVideos() {
   return (
     <View
       style={[
-        a.pt_lg,
+        a.pt_sm,
         a.pb_lg,
         a.border_t,
+        a.overflow_hidden,
         t.atoms.border_contrast_low,
         t.atoms.bg_contrast_25,
       ]}>
@@ -82,20 +82,17 @@ export function TrendingVideos() {
           a.align_center,
           a.justify_between,
         ]}>
-        <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
-          <Graph />
-          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
-            <Trans>Trending Videos</Trans>
-          </Text>
-        </View>
+        <Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
+          <Trans>Trending Videos</Trans>
+        </Text>
         <Button
           label={_(msg`Dismiss this section`)}
           size="tiny"
-          variant="ghost"
+          variant="solid"
           color="secondary"
-          shape="round"
+          shape="square"
           onPress={() => trendingPrompt.open()}>
-          <ButtonIcon icon={X} />
+          <ButtonIcon icon={X} size="sm" />
         </Button>
       </View>
 
@@ -104,11 +101,12 @@ export function TrendingVideos() {
           horizontal
           showsHorizontalScrollIndicator={false}
           decelerationRate="fast"
-          snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
+          snapToInterval={CARD_WIDTH + a.gap_md.gap}
+          style={[a.overflow_visible]}>
           <View
             style={[
               a.flex_row,
-              a.gap_sm,
+              a.gap_md,
               {
                 paddingLeft: gutters.paddingLeft,
                 paddingRight: gutters.paddingRight,
@@ -193,8 +191,11 @@ function VideoCards({
             a.justify_center,
             a.align_center,
             a.flex_1,
-            a.rounded_md,
+            a.rounded_lg,
+            a.border,
+            t.atoms.border_contrast_low,
             t.atoms.bg,
+            t.atoms.shadow_sm,
           ]}>
           {({pressed}) => (
             <View
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 6b1083aa4..f55217e56 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -1,8 +1,9 @@
 import React from 'react'
+import {Alert} from 'react-native'
 import * as Linking from 'expo-linking'
 
 import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {useSession} from '#/state/session'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -12,8 +13,10 @@ import {
 } from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {useIntentDialogs} from '#/components/intents/IntentDialogs'
 import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
+import {IS_TESTFLIGHT} from '../app-info.web'
+import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
 
-type IntentType = 'compose' | 'verify-email' | 'age-assurance'
+type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota'
 
 const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
 
@@ -27,12 +30,13 @@ export function useIntentHandler() {
   const ageAssuranceRedirectDialogControl =
     useAgeAssuranceRedirectDialogControl()
   const {currentAccount} = useSession()
+  const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
 
   React.useEffect(() => {
     const handleIncomingURL = (url: string) => {
       const referrerInfo = Referrer.getReferrerInfo()
       if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
-        logEvent('deepLink:referrerReceived', {
+        logger.metric('deepLink:referrerReceived', {
           to: url,
           referrer: referrerInfo?.referrer,
           hostname: referrerInfo?.hostname,
@@ -92,6 +96,18 @@ export function useIntentHandler() {
           }
           return
         }
+        case 'apply-ota': {
+          if (!isNative || !IS_TESTFLIGHT) {
+            return
+          }
+
+          const channel = params.get('channel')
+          if (!channel) {
+            Alert.alert('Error', 'No channel provided to look for.')
+          } else {
+            tryApplyUpdate(channel)
+          }
+        }
         default: {
           return
         }
@@ -111,6 +127,7 @@ export function useIntentHandler() {
     verifyEmailIntent,
     ageAssuranceRedirectDialogControl,
     currentAccount,
+    tryApplyUpdate,
   ])
 }
 
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
index 731406dce..72f215fa9 100644
--- a/src/lib/hooks/useOTAUpdates.ts
+++ b/src/lib/hooks/useOTAUpdates.ts
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Alert, AppState, AppStateStatus} from 'react-native'
+import {Alert, AppState, type AppStateStatus} from 'react-native'
 import {nativeBuildVersion} from 'expo-application'
 import {
   checkForUpdateAsync,
@@ -29,6 +29,128 @@ async function setExtraParams() {
   )
 }
 
+async function setExtraParamsPullRequest(channel: string) {
+  await setExtraParamAsync(
+    isIOS ? 'ios-build-number' : 'android-build-number',
+    // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
+    // This just ensures it gets passed as a string
+    `${nativeBuildVersion}`,
+  )
+  await setExtraParamAsync('channel', channel)
+}
+
+async function updateTestflight() {
+  await setExtraParams()
+
+  const res = await checkForUpdateAsync()
+  if (res.isAvailable) {
+    await fetchUpdateAsync()
+
+    Alert.alert(
+      'Update Available',
+      'A new version of the app is available. Relaunch now?',
+      [
+        {
+          text: 'No',
+          style: 'cancel',
+        },
+        {
+          text: 'Relaunch',
+          style: 'default',
+          onPress: async () => {
+            await reloadAsync()
+          },
+        },
+      ],
+    )
+  }
+}
+
+export function useApplyPullRequestOTAUpdate() {
+  const {currentlyRunning} = useUpdates()
+  const [pending, setPending] = React.useState(false)
+  const currentChannel = currentlyRunning?.channel
+  const isCurrentlyRunningPullRequestDeployment =
+    currentChannel?.startsWith('pull-request')
+
+  const tryApplyUpdate = async (channel: string) => {
+    setPending(true)
+    if (currentChannel === channel) {
+      const res = await checkForUpdateAsync()
+      if (res.isAvailable) {
+        logger.debug('Attempting to fetch update...')
+        await fetchUpdateAsync()
+        Alert.alert(
+          'Deployment Available',
+          `A new deployment of ${channel} is availalble. Relaunch now?`,
+          [
+            {
+              text: 'No',
+              style: 'cancel',
+            },
+            {
+              text: 'Relaunch',
+              style: 'default',
+              onPress: async () => {
+                await reloadAsync()
+              },
+            },
+          ],
+        )
+      } else {
+        Alert.alert(
+          'No Deployment Available',
+          `No new deployments of ${channel} are currently available for your current native build.`,
+        )
+      }
+    } else {
+      setExtraParamsPullRequest(channel)
+      const res = await checkForUpdateAsync()
+      if (res.isAvailable) {
+        Alert.alert(
+          'Deployment Available',
+          `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`,
+          [
+            {
+              text: 'No',
+              style: 'cancel',
+            },
+            {
+              text: 'Relaunch',
+              style: 'default',
+              onPress: async () => {
+                await reloadAsync()
+              },
+            },
+          ],
+        )
+      } else {
+        Alert.alert(
+          'No Deployment Available',
+          `No new deployments of ${channel} are currently available for your current native build.`,
+        )
+      }
+    }
+    setPending(false)
+  }
+
+  const revertToEmbedded = async () => {
+    try {
+      await updateTestflight()
+    } catch (e: any) {
+      logger.error('Internal OTA Update Error', {error: `${e}`})
+    }
+  }
+
+  return {
+    tryApplyUpdate,
+    revertToEmbedded,
+    currentChannel,
+    isCurrentlyRunningPullRequestDeployment,
+    pending,
+  }
+}
+
 export function useOTAUpdates() {
   const shouldReceiveUpdates = isEnabled && !__DEV__
 
@@ -36,7 +158,8 @@ export function useOTAUpdates() {
   const lastMinimize = React.useRef(0)
   const ranInitialCheck = React.useRef(false)
   const timeout = React.useRef<NodeJS.Timeout>()
-  const {isUpdatePending} = useUpdates()
+  const {currentlyRunning, isUpdatePending} = useUpdates()
+  const currentChannel = currentlyRunning?.channel
 
   const setCheckTimeout = React.useCallback(() => {
     timeout.current = setTimeout(async () => {
@@ -60,36 +183,18 @@ export function useOTAUpdates() {
 
   const onIsTestFlight = React.useCallback(async () => {
     try {
-      await setExtraParams()
-
-      const res = await checkForUpdateAsync()
-      if (res.isAvailable) {
-        await fetchUpdateAsync()
-
-        Alert.alert(
-          'Update Available',
-          'A new version of the app is available. Relaunch now?',
-          [
-            {
-              text: 'No',
-              style: 'cancel',
-            },
-            {
-              text: 'Relaunch',
-              style: 'default',
-              onPress: async () => {
-                await reloadAsync()
-              },
-            },
-          ],
-        )
-      }
+      await updateTestflight()
     } catch (e: any) {
       logger.error('Internal OTA Update Error', {error: `${e}`})
     }
   }, [])
 
   React.useEffect(() => {
+    // We don't need to check anything if the current update is a PR update
+    if (currentChannel?.startsWith('pull-request')) {
+      return
+    }
+
     // We use this setTimeout to allow Statsig to initialize before we check for an update
     // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
     // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
@@ -103,12 +208,15 @@ export function useOTAUpdates() {
 
     setCheckTimeout()
     ranInitialCheck.current = true
-  }, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
+  }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates])
 
   // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
   // or B check for an update again.
   React.useEffect(() => {
-    if (!isEnabled) return
+    // We also don't start this timeout if the user is on a pull request update
+    if (!isEnabled || currentChannel?.startsWith('pull-request')) {
+      return
+    }
 
     const subscription = AppState.addEventListener(
       'change',
@@ -138,5 +246,5 @@ export function useOTAUpdates() {
       clearTimeout(timeout.current)
       subscription.remove()
     }
-  }, [isUpdatePending, setCheckTimeout])
+  }, [isUpdatePending, currentChannel, setCheckTimeout])
 }
diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx
index 6b7a582d5..b626c9329 100644
--- a/src/screens/Search/SearchResults.tsx
+++ b/src/screens/Search/SearchResults.tsx
@@ -4,11 +4,14 @@ import {type AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {usePalette} from '#/lib/hooks/usePalette'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
 import {useActorSearch} from '#/state/queries/actor-search'
 import {usePopularFeedsSearch} from '#/state/queries/feed'
 import {useSearchPostsQuery} from '#/state/queries/search-posts'
 import {useSession} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useCloseAllActiveElements} from '#/state/util'
 import {Pager} from '#/view/com/pager/Pager'
 import {TabBar} from '#/view/com/pager/TabBar'
 import {Post} from '#/view/com/post/Post'
@@ -17,6 +20,8 @@ import {List} from '#/view/com/util/List'
 import {atoms as a, useTheme, web} from '#/alf'
 import * as FeedCard from '#/components/FeedCard'
 import * as Layout from '#/components/Layout'
+import {InlineLinkText} from '#/components/Link'
+import {SearchError} from '#/components/SearchError'
 import {Text} from '#/components/Typography'
 
 let SearchResults = ({
@@ -104,7 +109,15 @@ function Loader() {
   )
 }
 
-function EmptyState({message, error}: {message: string; error?: string}) {
+function EmptyState({
+  message,
+  error,
+  children,
+}: {
+  message: string
+  error?: string
+  children?: React.ReactNode
+}) {
   const t = useTheme()
 
   return (
@@ -132,6 +145,8 @@ function EmptyState({message, error}: {message: string; error?: string}) {
               </Text>
             </>
           )}
+
+          {children}
         </View>
       </View>
     </Layout.Content>
@@ -161,6 +176,7 @@ let SearchScreenPostResults = ({
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const [isPTR, setIsPTR] = useState(false)
+  const isLoggedin = Boolean(currentAccount?.did)
 
   const augmentedQuery = useMemo(() => {
     return augmentSearchQuery(query || '', {did: currentAccount?.did})
@@ -177,6 +193,8 @@ let SearchScreenPostResults = ({
     hasNextPage,
   } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
 
+  const pal = usePalette('default')
+  const t = useTheme()
   const onPullToRefresh = useCallback(async () => {
     setIsPTR(true)
     await refetch()
@@ -216,6 +234,51 @@ let SearchScreenPostResults = ({
     return temp
   }, [posts, isFetchingNextPage])
 
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const {requestSwitchToAccount} = useLoggedOutViewControls()
+
+  const showSignIn = () => {
+    closeAllActiveElements()
+    requestSwitchToAccount({requestedAccount: 'none'})
+  }
+
+  const showCreateAccount = () => {
+    closeAllActiveElements()
+    requestSwitchToAccount({requestedAccount: 'new'})
+  }
+
+  if (!isLoggedin) {
+    return (
+      <SearchError
+        title={_(msg`Search is currently unavailable when logged out`)}>
+        <Text style={[a.text_md, a.text_center, a.leading_snug]}>
+          <Trans>
+            <InlineLinkText
+              style={[pal.link]}
+              label={_(msg`sign in`)}
+              to={'#'}
+              onPress={showSignIn}>
+              Sign in
+            </InlineLinkText>
+            <Text style={t.atoms.text_contrast_medium}> or </Text>
+            <InlineLinkText
+              style={[pal.link]}
+              label={_(msg`create an account`)}
+              to={'#'}
+              onPress={showCreateAccount}>
+              create an account
+            </InlineLinkText>
+            <Text> </Text>
+            <Text style={t.atoms.text_contrast_medium}>
+              to search for news, sports, politics, and everything else
+              happening on Bluesky.
+            </Text>
+          </Trans>
+        </Text>
+      </SearchError>
+    )
+  }
+
   return error ? (
     <EmptyState
       message={_(
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 4d10a9d0d..9596c2479 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -12,12 +12,14 @@ import {useActorStatus} from '#/lib/actor-status'
 import {IS_INTERNAL} from '#/lib/app-info'
 import {HELP_DESK_URL} from '#/lib/constants'
 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
+import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
 import {
   type CommonNavigatorParams,
   type NavigationProp,
 } from '#/lib/routes/types'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
+import {isNative} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import * as persisted from '#/state/persisted'
 import {clearStorage} from '#/state/persisted'
@@ -364,6 +366,11 @@ function DevOptions() {
   const onboardingDispatch = useOnboardingDispatch()
   const navigation = useNavigation<NavigationProp>()
   const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
+  const {
+    revertToEmbedded,
+    isCurrentlyRunningPullRequestDeployment,
+    currentChannel,
+  } = useApplyPullRequestOTAUpdate()
   const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
 
   const resetOnboarding = async () => {
@@ -452,6 +459,15 @@ function DevOptions() {
           <Trans>Clear all storage data (restart after this)</Trans>
         </SettingsList.ItemText>
       </SettingsList.PressableItem>
+      {isNative && isCurrentlyRunningPullRequestDeployment ? (
+        <SettingsList.PressableItem
+          onPress={revertToEmbedded}
+          label={_(msg`Unapply Pull Request`)}>
+          <SettingsList.ItemText>
+            <Trans>Unapply Pull Request {currentChannel}</Trans>
+          </SettingsList.ItemText>
+        </SettingsList.PressableItem>
+      ) : null}
     </>
   )
 }