007c21fa8e
Co-authored-by: Claude <noreply@anthropic.com>
233 lines
7.1 KiB
TypeScript
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],
|
|
)
|
|
}
|