about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/feed/home.ts6
-rw-r--r--src/lib/custom-animations/GestureActionView.tsx15
-rw-r--r--src/lib/hooks/useIntentHandler.ts18
-rw-r--r--src/lib/hooks/useOTAUpdates.ts136
-rw-r--r--src/lib/hooks/useOTAUpdates.web.ts9
-rw-r--r--src/lib/notifications/notifications.e2e.ts6
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/lib/statsig/statsig.tsx3
-rw-r--r--src/lib/strings/mention-manip.ts2
9 files changed, 154 insertions, 42 deletions
diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts
index e6bc45bea..7a0d72d91 100644
--- a/src/lib/api/feed/home.ts
+++ b/src/lib/api/feed/home.ts
@@ -1,9 +1,9 @@
-import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
+import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
 
 import {PROD_DEFAULT_FEED} from '#/lib/constants'
 import {CustomFeedAPI} from './custom'
 import {FollowingFeedAPI} from './following'
-import {FeedAPI, FeedAPIResponse} from './types'
+import {type FeedAPI, type FeedAPIResponse} from './types'
 
 // HACK
 // the feed API does not include any facilities for passing down
@@ -93,7 +93,7 @@ export class HomeFeedAPI implements FeedAPI {
       }
     }
 
-    if (this.usingDiscover) {
+    if (this.usingDiscover && !__DEV__) {
       const res = await this.discover.fetch({cursor, limit})
       returnCursor = res.cursor
       posts = posts.concat(res.feed)
diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx
index ba6952a81..e7fba570b 100644
--- a/src/lib/custom-animations/GestureActionView.tsx
+++ b/src/lib/custom-animations/GestureActionView.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
+import {type ColorValue, Dimensions, StyleSheet, View} from 'react-native'
 import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 import Animated, {
   clamp,
@@ -114,11 +114,16 @@ export function GestureActionView({
     },
   )
 
+  // NOTE(haileyok):
+  // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
+  // reanimated doesn't offer great support for disabling y/x axes :/
+  const effectivelyDisabledOffset = 200
   const panGesture = Gesture.Pan()
-    .activeOffsetX([-10, 10])
-    // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
-    // reanimated doesn't offer great support for disabling y/x axes :/
-    .activeOffsetY([-200, 200])
+    .activeOffsetX([
+      actions.leftFirst ? -10 : -effectivelyDisabledOffset,
+      actions.rightFirst ? 10 : effectivelyDisabledOffset,
+    ])
+    .activeOffsetY([-effectivelyDisabledOffset, effectivelyDisabledOffset])
     .onStart(() => {
       'worklet'
       isActive.set(true)
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 6b1083aa4..c359b2bd6 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,9 @@ import {
 } from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {useIntentDialogs} from '#/components/intents/IntentDialogs'
 import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
+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 +29,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 +95,14 @@ export function useIntentHandler() {
           }
           return
         }
+        case 'apply-ota': {
+          const channel = params.get('channel')
+          if (!channel) {
+            Alert.alert('Error', 'No channel provided to look for.')
+          } else {
+            tryApplyUpdate(channel)
+          }
+        }
         default: {
           return
         }
@@ -111,6 +122,7 @@ export function useIntentHandler() {
     verifyEmailIntent,
     ageAssuranceRedirectDialogControl,
     currentAccount,
+    tryApplyUpdate,
   ])
 }
 
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
index 731406dce..864d5d697 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,98 @@ 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)
+    await 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 fetchUpdateAsync()
+              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,
+    isCurrentlyRunningPullRequestDeployment,
+    currentChannel,
+    pending,
+  }
+}
+
 export function useOTAUpdates() {
   const shouldReceiveUpdates = isEnabled && !__DEV__
 
@@ -36,7 +128,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 +153,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 +178,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 +216,5 @@ export function useOTAUpdates() {
       clearTimeout(timeout.current)
       subscription.remove()
     }
-  }, [isUpdatePending, setCheckTimeout])
+  }, [isUpdatePending, currentChannel, setCheckTimeout])
 }
diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts
index 1baf4894e..2783a04dd 100644
--- a/src/lib/hooks/useOTAUpdates.web.ts
+++ b/src/lib/hooks/useOTAUpdates.web.ts
@@ -1 +1,10 @@
 export function useOTAUpdates() {}
+export function useApplyPullRequestOTAUpdate() {
+  return {
+    tryApplyUpdate: () => {},
+    revertToEmbedded: () => {},
+    isCurrentlyRunningPullRequestDeployment: false,
+    currentChannel: 'web-build',
+    pending: false,
+  }
+}
diff --git a/src/lib/notifications/notifications.e2e.ts b/src/lib/notifications/notifications.e2e.ts
index 0586ac1bf..1a9d861b7 100644
--- a/src/lib/notifications/notifications.e2e.ts
+++ b/src/lib/notifications/notifications.e2e.ts
@@ -1,3 +1,5 @@
+import {useCallback} from 'react'
+
 export function useNotificationsRegistration() {}
 
 export function useRequestNotificationsPermission() {
@@ -6,6 +8,10 @@ export function useRequestNotificationsPermission() {
   ) => {}
 }
 
+export function useGetAndRegisterPushToken() {
+  return useCallback(async ({}: {} = {}) => {}, [])
+}
+
 export async function decrementBadgeCount(_by: number) {}
 
 export async function resetBadgeCount() {}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index efd7d605a..3b1106480 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,6 +1,5 @@
 export type Gate =
   // Keep this alphabetic please.
-  | 'age_assurance'
   | 'alt_share_icon'
   | 'debug_show_feedcontext'
   | 'debug_subscriptions'
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index f509f2980..f2d3ffca9 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -8,6 +8,7 @@ import {logger} from '#/logger'
 import {type MetricEvents} from '#/logger/metrics'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
+import packageDotJson from '../../../package.json'
 import {useSession} from '../../state/session'
 import {timeout} from '../async/timeout'
 import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
@@ -25,6 +26,7 @@ type StatsigUser = {
     // This is the place where we can add our own stuff.
     // Fields here have to be non-optional to be visible in the UI.
     platform: 'ios' | 'android' | 'web'
+    appVersion: string
     bundleIdentifier: string
     bundleDate: number
     refSrc: string
@@ -210,6 +212,7 @@ function toStatsigUser(did: string | undefined): StatsigUser {
       refSrc,
       refUrl,
       platform: Platform.OS as 'ios' | 'android' | 'web',
+      appVersion: packageDotJson.version,
       bundleIdentifier: BUNDLE_IDENTIFIER,
       bundleDate: BUNDLE_DATE,
       appLanguage: languagePrefs.appLanguage,
diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts
index 1f7cbe434..7b52f745b 100644
--- a/src/lib/strings/mention-manip.ts
+++ b/src/lib/strings/mention-manip.ts
@@ -7,7 +7,7 @@ export function getMentionAt(
   text: string,
   cursorPos: number,
 ): FoundMention | undefined {
-  let re = /(^|\s)@([a-z0-9.]*)/gi
+  let re = /(^|\s)@([a-z0-9.-]*)/gi
   let match
   while ((match = re.exec(text))) {
     const spaceOffset = match[1].length