Bluesky app fork with some witchin' additions 💫

Germ DM button (#9848)

* germ link (wip)

* add todos

* just yeet declaration for now

* ensure not proxied

* allow undo delete

* tweak styles

* ignore unknown values

* tweak styles

* fix boolean logic

* skip IAB

* fix mutationFn

* add link warning interstitial

* Logging and metrics

* Little more error handling

* Update copy

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
93d2f3a8 3a9526d5

+362 -3
assets/images/germ_logo.webp

This is a binary file and will not be displayed.

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