forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {Alert} from 'react-native'
3import * as Linking from 'expo-linking'
4import * as WebBrowser from 'expo-web-browser'
5
6import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
7import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
8import {logger} from '#/logger'
9import {isIOS, isNative} from '#/platform/detection'
10import {useSession} from '#/state/session'
11import {useCloseAllActiveElements} from '#/state/util'
12import {useIntentDialogs} from '#/components/intents/IntentDialogs'
13import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
14import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
15
16type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota'
17
18const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
19
20// This needs to stay outside of react to persist between account switches
21let previousIntentUrl = ''
22
23export function useIntentHandler() {
24 const incomingUrl = Linking.useLinkingURL()
25 const composeIntent = useComposeIntent()
26 const verifyEmailIntent = useVerifyEmailIntent()
27 const {currentAccount} = useSession()
28 const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
29
30 React.useEffect(() => {
31 const handleIncomingURL = async (url: string) => {
32 if (isIOS) {
33 // Close in-app browser if it's open (iOS only)
34 await WebBrowser.dismissBrowser().catch(() => {})
35 }
36
37 const referrerInfo = Referrer.getReferrerInfo()
38 if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
39 logger.metric('deepLink:referrerReceived', {
40 to: url,
41 referrer: referrerInfo?.referrer,
42 hostname: referrerInfo?.hostname,
43 })
44 }
45 const urlp = parseLinkingUrl(url)
46 const [, intent, intentType] = urlp.pathname.split('/')
47
48 // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
49 // intent check. On web, we have to check the first part of the path since we have an actual hostname
50 const isIntent = intent === 'intent'
51 const params = urlp.searchParams
52
53 if (!isIntent) return
54
55 switch (intentType as IntentType) {
56 case 'compose': {
57 composeIntent({
58 text: params.get('text'),
59 imageUrisStr: params.get('imageUris'),
60 videoUri: params.get('videoUri'),
61 })
62 return
63 }
64 case 'verify-email': {
65 const code = params.get('code')
66 if (!code) return
67 verifyEmailIntent(code)
68 return
69 }
70 case 'age-assurance': {
71 // Handled in `#/ageAssurance/components/RedirectOverlay.tsx`
72 return
73 }
74 case 'apply-ota': {
75 const channel = params.get('channel')
76 if (!channel) {
77 Alert.alert('Error', 'No channel provided to look for.')
78 } else {
79 tryApplyUpdate(channel)
80 }
81 return
82 }
83 default: {
84 return
85 }
86 }
87 }
88
89 if (incomingUrl) {
90 if (previousIntentUrl === incomingUrl) {
91 return
92 }
93 handleIncomingURL(incomingUrl)
94 previousIntentUrl = incomingUrl
95 }
96 }, [
97 incomingUrl,
98 composeIntent,
99 verifyEmailIntent,
100 currentAccount,
101 tryApplyUpdate,
102 ])
103}
104
105export function useComposeIntent() {
106 const closeAllActiveElements = useCloseAllActiveElements()
107 const {openComposer} = useOpenComposer()
108 const {hasSession} = useSession()
109
110 return React.useCallback(
111 ({
112 text,
113 imageUrisStr,
114 videoUri,
115 }: {
116 text: string | null
117 imageUrisStr: string | null
118 videoUri: string | null
119 }) => {
120 if (!hasSession) return
121 closeAllActiveElements()
122
123 // Whenever a video URI is present, we don't support adding images right now.
124 if (videoUri) {
125 const [uri, width, height] = videoUri.split('|')
126 openComposer({
127 text: text ?? undefined,
128 videoUri: {uri, width: Number(width), height: Number(height)},
129 })
130 return
131 }
132
133 const imageUris = imageUrisStr
134 ?.split(',')
135 .filter(part => {
136 // For some security, we're going to filter out any image uri that is external. We don't want someone to
137 // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
138 // and we load that image
139 if (part.includes('https://') || part.includes('http://')) {
140 return false
141 }
142 // We also should just filter out cases that don't have all the info we need
143 return VALID_IMAGE_REGEX.test(part)
144 })
145 .map(part => {
146 const [uri, width, height] = part.split('|')
147 return {uri, width: Number(width), height: Number(height)}
148 })
149
150 setTimeout(() => {
151 openComposer({
152 text: text ?? undefined,
153 imageUris: isNative ? imageUris : undefined,
154 })
155 }, 500)
156 },
157 [hasSession, closeAllActiveElements, openComposer],
158 )
159}
160
161function useVerifyEmailIntent() {
162 const closeAllActiveElements = useCloseAllActiveElements()
163 const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
164 useIntentDialogs()
165 return React.useCallback(
166 (code: string) => {
167 closeAllActiveElements()
168 setState({
169 code,
170 })
171 setTimeout(() => {
172 control.open()
173 }, 1000)
174 },
175 [closeAllActiveElements, control, setState],
176 )
177}