Bluesky app fork with some witchin' additions 馃挮
at main 335 lines 9.3 kB view raw
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}