Files
bsky-social-app/src/lib/hooks/useIntentHandler.ts
T
Samuel Newman 384987262b check for a deployment before prompting to apply it
Reloading into an update re-delivers the deep link that triggered it, and
the same link can also just be tapped again. Checking first means both
cases are silent when there is nothing new to apply - the version
mismatch warning no longer fires ahead of the check either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:50:25 +03:00

241 lines
7.5 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')
const releaseVersion = params.get('releaseVersion')
const buildNumber = params.get(
IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber',
)
const appVersion =
releaseVersion && buildNumber
? `${releaseVersion}.${buildNumber}`
: null
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
return
}
tryApplyUpdate(channel, appVersion)
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],
)
}