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