forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {Platform, View} from 'react-native'
2import {Image} from 'expo-image'
3import {
4 type AppBskyActorDefs,
5 type AppBskyActorGetProfile,
6 type AtpAgent,
7} from '@atproto/api'
8import {msg} from '@lingui/core/macro'
9import {useLingui} from '@lingui/react'
10import {Trans} from '@lingui/react/macro'
11import {useMutation, useQueryClient} from '@tanstack/react-query'
12
13import {until} from '#/lib/async/until'
14import {isNetworkError} from '#/lib/strings/errors'
15import {RQKEY} from '#/state/queries/profile'
16import {useAgent, useSession} from '#/state/session'
17import {atoms as a, useTheme, web} from '#/alf'
18import {Button, ButtonIcon, ButtonText} from '#/components/Button'
19import * as Dialog from '#/components/Dialog'
20import {CustomLinkWarningDialog} from '#/components/dialogs/LinkWarning'
21import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
22import {Link} from '#/components/Link'
23import {Loader} from '#/components/Loader'
24import * as Toast from '#/components/Toast'
25import {Text} from '#/components/Typography'
26import {useAnalytics} from '#/analytics'
27import type * as bsky from '#/types/bsky'
28
29export function GermButton({
30 germ,
31 profile,
32}: {
33 germ: AppBskyActorDefs.ProfileAssociatedGerm
34 profile: bsky.profile.AnyProfileView
35}) {
36 const t = useTheme()
37 const ax = useAnalytics()
38 const {_} = useLingui()
39 const {currentAccount} = useSession()
40 const linkWarningControl = Dialog.useDialogControl()
41
42 // exclude `none` and all unknown values
43 if (
44 !(germ.showButtonTo === 'everyone' || germ.showButtonTo === 'usersIFollow')
45 ) {
46 return null
47 }
48
49 if (currentAccount?.did === profile.did) {
50 return <GermSelfButton did={currentAccount.did} />
51 }
52
53 if (germ.showButtonTo === 'usersIFollow' && !profile.viewer?.followedBy) {
54 return null
55 }
56
57 const url = constructGermUrl(germ, profile, currentAccount?.did)
58
59 if (!url) {
60 return null
61 }
62
63 return (
64 <>
65 <Link
66 to={url}
67 onPress={evt => {
68 ax.metric('profile:associated:germ:click-to-chat', {})
69 if (isCustomGermDomain(url)) {
70 evt.preventDefault()
71 linkWarningControl.open()
72 return false
73 }
74 }}
75 label={_(msg`Open Germ DM`)}
76 overridePresentation={false}
77 shouldProxy={false}
78 style={[
79 t.atoms.bg_contrast_50,
80 a.rounded_full,
81 a.self_start,
82 {padding: 6},
83 ]}>
84 <GermLogo size="small" />
85 <Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
86 <Trans>Germ DM</Trans>
87 </Text>
88 <ArrowTopRightIcon style={[t.atoms.text, a.mx_2xs]} width={14} />
89 </Link>
90 <CustomLinkWarningDialog
91 control={linkWarningControl}
92 link={{
93 href: url,
94 displayText: '',
95 share: false,
96 }}
97 />
98 </>
99 )
100}
101
102function GermLogo({size}: {size: 'small' | 'large'}) {
103 return (
104 <Image
105 source={require('../../../../assets/images/germ_logo.webp')}
106 accessibilityIgnoresInvertColors={false}
107 contentFit="cover"
108 style={[
109 a.rounded_full,
110 size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
111 ]}
112 />
113 )
114}
115
116function GermSelfButton({did}: {did: string}) {
117 const t = useTheme()
118 const ax = useAnalytics()
119 const {_} = useLingui()
120 const selfExplanationDialogControl = Dialog.useDialogControl()
121 const agent = useAgent()
122 const queryClient = useQueryClient()
123
124 const {mutate: deleteDeclaration, isPending} = useMutation({
125 mutationFn: async () => {
126 const previousRecord = await agent.com.germnetwork.declaration
127 .get({
128 repo: did,
129 rkey: 'self',
130 })
131 .then(res => res.value)
132 .catch(() => null)
133
134 await agent.com.germnetwork.declaration.delete({
135 repo: did,
136 rkey: 'self',
137 })
138
139 await whenAppViewReady(agent, did, res => !res.data.associated?.germ)
140
141 return previousRecord
142 },
143 onSuccess: previousRecord => {
144 ax.metric('profile:associated:germ:self-disconnect', {})
145
146 async function undo() {
147 if (!previousRecord) return
148 try {
149 await agent.com.germnetwork.declaration.put(
150 {
151 repo: did,
152 rkey: 'self',
153 },
154 previousRecord,
155 )
156 await whenAppViewReady(agent, did, res => !!res.data.associated?.germ)
157 await queryClient.refetchQueries({queryKey: RQKEY(did)})
158
159 Toast.show(_(msg`Germ DM reconnected`))
160 ax.metric('profile:associated:germ:self-reconnect', {})
161 } catch (e: any) {
162 Toast.show(
163 _(msg`Failed to reconnect Germ DM. Error: ${e?.message}`),
164 {
165 type: 'error',
166 },
167 )
168 if (!isNetworkError(e)) {
169 ax.logger.error('Failed to reconnect Germ DM link', {
170 safeMessage: e,
171 })
172 }
173 }
174 }
175
176 selfExplanationDialogControl.close(() => {
177 void queryClient.refetchQueries({queryKey: RQKEY(did)})
178 Toast.show(
179 <Toast.Outer>
180 <Toast.Icon />
181 <Toast.Text>
182 <Trans>Germ DM disconnected</Trans>
183 </Toast.Text>
184 {previousRecord && (
185 <Toast.Action label={_(msg`Undo`)} onPress={() => void undo()}>
186 <Trans>Undo</Trans>
187 </Toast.Action>
188 )}
189 </Toast.Outer>,
190 )
191 })
192 },
193 onError: error => {
194 Toast.show(
195 _(msg`Failed to disconnect Germ DM. Error: ${error?.message}`),
196 {
197 type: 'error',
198 },
199 )
200 if (!isNetworkError(error)) {
201 ax.logger.error('Failed to disconnect Germ DM link', {
202 safeMessage: error,
203 })
204 }
205 },
206 })
207
208 return (
209 <>
210 <Button
211 label={_(msg`Learn more about your Germ DM link`)}
212 onPress={() => {
213 ax.metric('profile:associated:germ:click-self-info', {})
214 selfExplanationDialogControl.open()
215 }}
216 style={[
217 t.atoms.bg_contrast_50,
218 a.rounded_full,
219 a.self_start,
220 {padding: 6, paddingRight: 10},
221 ]}>
222 <GermLogo size="small" />
223 <Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
224 <Trans>Germ DM</Trans>
225 </Text>
226 </Button>
227
228 <Dialog.Outer
229 control={selfExplanationDialogControl}
230 nativeOptions={{preventExpansion: true}}>
231 <Dialog.Handle />
232 <Dialog.ScrollableInner
233 label={_(msg`Germ DM Link`)}
234 style={web([{maxWidth: 400, borderRadius: 36}])}>
235 <View style={[a.flex_row, a.align_center, {gap: 6}]}>
236 <GermLogo size="large" />
237 <Text style={[a.text_2xl, a.font_bold]}>
238 <Trans>Germ DM Link</Trans>
239 </Text>
240 </View>
241
242 <Text style={[a.text_md, a.leading_snug, a.mt_sm]}>
243 <Trans>
244 This button lets others open the Germ DM app to send you a
245 message. You can manage its visibility from the Germ DM app, or
246 you can disconnect your Bluesky account from Germ DM altogether by
247 clicking the button below.
248 </Trans>
249 </Text>
250 <View style={[a.mt_2xl, a.gap_md]}>
251 <Button
252 label={_(msg`Got it`)}
253 size="large"
254 color="primary"
255 onPress={() => selfExplanationDialogControl.close()}>
256 <ButtonText>
257 <Trans>Got it</Trans>
258 </ButtonText>
259 </Button>
260 <Button
261 label={_(msg`Disconnect Germ DM`)}
262 size="large"
263 color="secondary"
264 onPress={() => deleteDeclaration()}
265 disabled={isPending}>
266 {isPending && <ButtonIcon icon={Loader} />}
267 <ButtonText>
268 <Trans>Disconnect Germ DM</Trans>
269 </ButtonText>
270 </Button>
271 </View>
272 </Dialog.ScrollableInner>
273 </Dialog.Outer>
274 </>
275 )
276}
277
278function constructGermUrl(
279 declaration: AppBskyActorDefs.ProfileAssociatedGerm,
280 profile: bsky.profile.AnyProfileView,
281 viewerDid?: string,
282) {
283 try {
284 const urlp = new URL(declaration.messageMeUrl)
285
286 if (urlp.pathname.endsWith('/')) {
287 urlp.pathname = urlp.pathname.slice(0, -1)
288 }
289
290 urlp.pathname += `/${platform()}`
291
292 if (viewerDid) {
293 urlp.hash = `#${profile.did}+${viewerDid}`
294 } else {
295 urlp.hash = `#${profile.did}`
296 }
297
298 return urlp.toString()
299 } catch {
300 return null
301 }
302}
303
304function isCustomGermDomain(url: string) {
305 try {
306 const urlp = new URL(url)
307 return urlp.hostname !== 'landing.ger.mx'
308 } catch {
309 return false
310 }
311}
312
313function platform() {
314 switch (Platform.OS) {
315 case 'ios':
316 return 'iOS'
317 case 'android':
318 return 'android'
319 default:
320 return 'web'
321 }
322}
323
324async function whenAppViewReady(
325 agent: AtpAgent,
326 actor: string,
327 fn: (res: AppBskyActorGetProfile.Response) => boolean,
328) {
329 await until(
330 5, // 5 tries
331 1e3, // 1s delay between tries
332 fn,
333 () => agent.app.bsky.actor.getProfile({actor}),
334 )
335}