about summary refs log tree commit diff
path: root/src/lib/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/hooks')
-rw-r--r--src/lib/hooks/useDedupe.ts17
-rw-r--r--src/lib/hooks/useInitialNumToRender.ts11
-rw-r--r--src/lib/hooks/useIntentHandler.ts93
-rw-r--r--src/lib/hooks/useNavigationDeduped.ts80
-rw-r--r--src/lib/hooks/useOTAUpdate.ts52
5 files changed, 216 insertions, 37 deletions
diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts
new file mode 100644
index 000000000..d9432cb2c
--- /dev/null
+++ b/src/lib/hooks/useDedupe.ts
@@ -0,0 +1,17 @@
+import React from 'react'
+
+export const useDedupe = () => {
+  const canDo = React.useRef(true)
+
+  return React.useRef((cb: () => unknown) => {
+    if (canDo.current) {
+      canDo.current = false
+      setTimeout(() => {
+        canDo.current = true
+      }, 250)
+      cb()
+      return true
+    }
+    return false
+  }).current
+}
diff --git a/src/lib/hooks/useInitialNumToRender.ts b/src/lib/hooks/useInitialNumToRender.ts
new file mode 100644
index 000000000..942f0404a
--- /dev/null
+++ b/src/lib/hooks/useInitialNumToRender.ts
@@ -0,0 +1,11 @@
+import React from 'react'
+import {Dimensions} from 'react-native'
+
+const MIN_POST_HEIGHT = 100
+
+export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
+  return React.useMemo(() => {
+    const screenHeight = Dimensions.get('window').height
+    return Math.ceil(screenHeight / minItemHeight) + 1
+  }, [minItemHeight])
+}
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
new file mode 100644
index 000000000..8741530b5
--- /dev/null
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -0,0 +1,93 @@
+import React from 'react'
+import * as Linking from 'expo-linking'
+import {isNative} from 'platform/detection'
+import {useComposerControls} from 'state/shell'
+import {useSession} from 'state/session'
+import {useCloseAllActiveElements} from 'state/util'
+
+type IntentType = 'compose'
+
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
+export function useIntentHandler() {
+  const incomingUrl = Linking.useURL()
+  const composeIntent = useComposeIntent()
+
+  React.useEffect(() => {
+    const handleIncomingURL = (url: string) => {
+      // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
+      // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
+      // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+      // path parameter is in pathname rather than in hostname.
+      if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
+        url = url.replace('bluesky://', 'bluesky:///')
+      }
+
+      const urlp = new URL(url)
+      const [_, intent, intentType] = urlp.pathname.split('/')
+
+      // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
+      // intent check. On web, we have to check the first part of the path since we have an actual hostname
+      const isIntent = intent === 'intent'
+      const params = urlp.searchParams
+
+      if (!isIntent) return
+
+      switch (intentType as IntentType) {
+        case 'compose': {
+          composeIntent({
+            text: params.get('text'),
+            imageUrisStr: params.get('imageUris'),
+          })
+        }
+      }
+    }
+
+    if (incomingUrl) handleIncomingURL(incomingUrl)
+  }, [incomingUrl, composeIntent])
+}
+
+function useComposeIntent() {
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const {openComposer} = useComposerControls()
+  const {hasSession} = useSession()
+
+  return React.useCallback(
+    ({
+      text,
+      imageUrisStr,
+    }: {
+      text: string | null
+      imageUrisStr: string | null // unused for right now, will be used later with intents
+    }) => {
+      if (!hasSession) return
+
+      closeAllActiveElements()
+
+      const imageUris = imageUrisStr
+        ?.split(',')
+        .filter(part => {
+          // For some security, we're going to filter out any image uri that is external. We don't want someone to
+          // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+          // and we load that image
+          if (part.includes('https://') || part.includes('http://')) {
+            return false
+          }
+          // We also should just filter out cases that don't have all the info we need
+          return VALID_IMAGE_REGEX.test(part)
+        })
+        .map(part => {
+          const [uri, width, height] = part.split('|')
+          return {uri, width: Number(width), height: Number(height)}
+        })
+
+      setTimeout(() => {
+        openComposer({
+          text: text ?? undefined,
+          imageUris: isNative ? imageUris : undefined,
+        })
+      }, 500)
+    },
+    [hasSession, closeAllActiveElements, openComposer],
+  )
+}
diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts
new file mode 100644
index 000000000..d913f7f3d
--- /dev/null
+++ b/src/lib/hooks/useNavigationDeduped.ts
@@ -0,0 +1,80 @@
+import React from 'react'
+import {useNavigation} from '@react-navigation/core'
+import {AllNavigatorParams, NavigationProp} from 'lib/routes/types'
+import type {NavigationAction} from '@react-navigation/routers'
+import {NavigationState} from '@react-navigation/native'
+import {useDedupe} from 'lib/hooks/useDedupe'
+
+export type DebouncedNavigationProp = Pick<
+  NavigationProp,
+  | 'popToTop'
+  | 'push'
+  | 'navigate'
+  | 'canGoBack'
+  | 'replace'
+  | 'dispatch'
+  | 'goBack'
+  | 'getState'
+>
+
+export function useNavigationDeduped() {
+  const navigation = useNavigation<NavigationProp>()
+  const dedupe = useDedupe()
+
+  return React.useMemo(
+    (): DebouncedNavigationProp => ({
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      push: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.push(...args))
+      },
+      // Types from @react-navigation/core/src/types.tsx
+      navigate: <RouteName extends keyof AllNavigatorParams>(
+        ...args: RouteName extends unknown
+          ? undefined extends AllNavigatorParams[RouteName]
+            ?
+                | [screen: RouteName]
+                | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+            : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : never
+      ) => {
+        dedupe(() => navigation.navigate(...args))
+      },
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      replace: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.replace(...args))
+      },
+      dispatch: (
+        action:
+          | NavigationAction
+          | ((state: NavigationState) => NavigationAction),
+      ) => {
+        dedupe(() => navigation.dispatch(action))
+      },
+      popToTop: () => {
+        dedupe(() => navigation.popToTop())
+      },
+      goBack: () => {
+        dedupe(() => navigation.goBack())
+      },
+      canGoBack: () => {
+        return navigation.canGoBack()
+      },
+      getState: () => {
+        return navigation.getState()
+      },
+    }),
+    [dedupe, navigation],
+  )
+}
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e..d35179256 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const {openModal} = useModalControls()
-
   // HELPER FUNCTIONS
-  const showUpdatePopup = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: t`Update Available`,
-      message: t`A new version of the app is available. Please update to continue using the app.`,
-      onPressConfirm: async () => {
-        Updates.reloadAsync().catch(err => {
-          throw err
-        })
-      },
-    })
-  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
       }
       // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
       await Updates.fetchUpdateAsync()
-      // show a popup modal
-      showUpdatePopup()
     } catch (e) {
       logger.error('useOTAUpdate: Error while checking for update', {
         message: e,
       })
     }
-  }, [showUpdatePopup])
-  const updateEventListener = useCallback(
-    (event: Updates.UpdateEvent) => {
-      logger.debug('useOTAUpdate: Listening for update...')
-      if (event.type === Updates.UpdateEventType.ERROR) {
-        logger.error('useOTAUpdate: Error while listening for update', {
-          message: event.message,
-        })
-      } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
-        // Handle no update available
-        // do nothing
-      } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
-        // Handle update available
-        // open modal, ask for user confirmation, and reload the app
-        showUpdatePopup()
-      }
-    },
-    [showUpdatePopup],
-  )
+  }, [])
+  const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+    logger.debug('useOTAUpdate: Listening for update...')
+    if (event.type === Updates.UpdateEventType.ERROR) {
+      logger.error('useOTAUpdate: Error while listening for update', {
+        message: event.message,
+      })
+    } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+      // Handle no update available
+      // do nothing
+    } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+      // Handle update available
+      // open modal, ask for user confirmation, and reload the app
+    }
+  }, [])
 
   useEffect(() => {
     // ADD EVENT LISTENERS