384987262b
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>
241 lines
7.5 KiB
TypeScript
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],
|
|
)
|
|
}
|