Files
bsky-social-app/src/lib/hooks/useIntentHandler.ts
T
Samuel Newman 007c21fa8e Add moduleSuffixes to tsconfig (#11146)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 12:47:53 -07:00

233 lines
7.1 KiB
TypeScript

import {useCallback, useEffect} from 'react'
import {Alert} from 'react-native'
import * as Linking from 'expo-linking'
import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
// This needs to stay outside of react to persist between account switches
let previousIntentUrl = ''
export function useIntentHandler() {
const incomingUrl = Linking.useLinkingURL()
const ax = useAnalytics()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
const groupChatJoinIntent = useGroupChatJoinIntent()
const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
useEffect(() => {
const handleIncomingURL = async (url: string) => {
if (IS_IOS) {
// Close in-app browser if it's open (iOS only)
await WebBrowser.dismissBrowser().catch(() => {})
}
const referrerInfo = await Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: url,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
const urlp = parseLinkingUrl(url)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
groupChatJoinIntent(chatInviteMatch[1], url)
return
}
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'),
videoUri: params.get('videoUri'),
})
return
}
case 'verify-email': {
const code = params.get('code')
if (!code) return
verifyEmailIntent(code)
return
}
case 'age-assurance': {
// Handled in `#/ageAssurance/components/RedirectOverlay.tsx`
return
}
case 'apply-ota': {
const channel = params.get('channel')
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
} else {
tryApplyUpdate(channel)
}
return
}
default: {
return
}
}
}
if (incomingUrl) {
if (previousIntentUrl === incomingUrl) {
return
}
handleIncomingURL(incomingUrl)
previousIntentUrl = incomingUrl
}
}, [
incomingUrl,
ax,
composeIntent,
verifyEmailIntent,
groupChatJoinIntent,
currentAccount,
tryApplyUpdate,
])
}
export function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useOpenComposer()
const {hasSession} = useSession()
return useCallback(
({
text,
imageUrisStr,
videoUri,
}: {
text: string | null
imageUrisStr: string | null
videoUri: string | null
}) => {
if (!hasSession) return
closeAllActiveElements()
// Whenever a video URI is present, we don't support adding images right now.
if (videoUri) {
const [uri, width, height] = videoUri.split('|')
openComposer({
text: text ?? undefined,
videoUri: {uri, width: Number(width), height: Number(height)},
logContext: 'Deeplink',
})
return
}
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: IS_NATIVE ? imageUris : undefined,
logContext: 'Deeplink',
})
}, 500)
},
[hasSession, closeAllActiveElements, openComposer],
)
}
export function useGroupChatJoinIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {hasSession} = useSession()
const {groupChatJoinDialogControl: control, setGroupChatJoinState: setState} =
useIntentDialogs()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
return useCallback(
(code: string, uri?: string) => {
closeAllActiveElements()
if (hasSession) {
setState({code})
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
control.open()
})
} else {
setActiveLanding({type: 'groupchat', uri: uri ?? '', code})
requestSwitchToAccount({requestedAccount: 'groupchat'})
}
},
[
closeAllActiveElements,
hasSession,
control,
setState,
prefetchJoinLinkPreviews,
requestSwitchToAccount,
setActiveLanding,
],
)
}
function useVerifyEmailIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
useIntentDialogs()
return useCallback(
(code: string) => {
closeAllActiveElements()
setState({
code,
})
setTimeout(() => {
control.open()
}, 1000)
},
[closeAllActiveElements, control, setState],
)
}