diff options
Diffstat (limited to 'src/lib/hooks')
-rw-r--r-- | src/lib/hooks/useDedupe.ts | 17 | ||||
-rw-r--r-- | src/lib/hooks/useInitialNumToRender.ts | 11 | ||||
-rw-r--r-- | src/lib/hooks/useIntentHandler.ts | 93 | ||||
-rw-r--r-- | src/lib/hooks/useNavigationDeduped.ts | 80 | ||||
-rw-r--r-- | src/lib/hooks/useOTAUpdate.ts | 52 |
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 |