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>
This commit is contained in:
Samuel Newman
2026-02-11 23:51:19 +00:00
committed by GitHub
parent 3a9526d55e
commit 93d2f3a8db
6 changed files with 362 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+5
View File
@@ -875,4 +875,9 @@ export type Events = {
'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
'profile:associated:germ:click-to-chat': {}
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
}
+17 -2
View File
@@ -22,12 +22,27 @@ export function LinkWarningDialog() {
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
<LinkWarningDialogInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
export function CustomLinkWarningDialog({
control,
link,
}: {
control: Dialog.DialogControlProps
link?: {href: string; displayText: string; share?: boolean}
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<LinkWarningDialogInner link={link} />
</Dialog.Outer>
)
}
function LinkWarningDialogInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
@@ -39,6 +39,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {IS_IOS} from '#/env'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
@@ -163,6 +164,10 @@ let ProfileHeaderStandard = ({
</View>
) : undefined}
{profile.associated?.germ && (
<GermButton germ={profile.associated.germ} profile={profile} />
)}
{!isMe &&
!isBlockedUser &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
@@ -0,0 +1,334 @@
import {Platform, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyActorGetProfile,
type AtpAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {isNetworkError} from '#/lib/strings/errors'
import {RQKEY} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CustomLinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function GermButton({
germ,
profile,
}: {
germ: AppBskyActorDefs.ProfileAssociatedGerm
profile: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const linkWarningControl = Dialog.useDialogControl()
// exclude `none` and all unknown values
if (
!(germ.showButtonTo === 'everyone' || germ.showButtonTo === 'usersIFollow')
) {
return null
}
if (currentAccount?.did === profile.did) {
return <GermSelfButton did={currentAccount.did} />
}
if (germ.showButtonTo === 'usersIFollow' && !profile.viewer?.followedBy) {
return null
}
const url = constructGermUrl(germ, profile, currentAccount?.did)
if (!url) {
return null
}
return (
<>
<Link
to={url}
onPress={evt => {
ax.metric('profile:associated:germ:click-to-chat', {})
if (isCustomGermDomain(url)) {
evt.preventDefault()
linkWarningControl.open()
return false
}
}}
label={_(msg`Open Germ DM`)}
overridePresentation={false}
shouldProxy={false}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
<ArrowTopRightIcon style={[t.atoms.text, a.mx_2xs]} width={14} />
</Link>
<CustomLinkWarningDialog
control={linkWarningControl}
link={{
href: url,
displayText: '',
share: false,
}}
/>
</>
)
}
function GermLogo({size}: {size: 'small' | 'large'}) {
return (
<Image
source={require('../../../../assets/images/germ_logo.webp')}
accessibilityIgnoresInvertColors={false}
contentFit="cover"
style={[
a.rounded_full,
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
]}
/>
)
}
function GermSelfButton({did}: {did: string}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const selfExplanationDialogControl = Dialog.useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const {mutate: deleteDeclaration, isPending} = useMutation({
mutationFn: async () => {
const previousRecord = await agent.com.germnetwork.declaration
.get({
repo: did,
rkey: 'self',
})
.then(res => res.value)
.catch(() => null)
await agent.com.germnetwork.declaration.delete({
repo: did,
rkey: 'self',
})
await whenAppViewReady(agent, did, res => !res.data.associated?.germ)
return previousRecord
},
onSuccess: previousRecord => {
ax.metric('profile:associated:germ:self-disconnect', {})
async function undo() {
if (!previousRecord) return
try {
await agent.com.germnetwork.declaration.put(
{
repo: did,
rkey: 'self',
},
previousRecord,
)
await whenAppViewReady(agent, did, res => !!res.data.associated?.germ)
await queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(_(msg`Germ DM reconnected`))
ax.metric('profile:associated:germ:self-reconnect', {})
} catch (e: any) {
Toast.show(
_(msg`Failed to reconnect Germ DM. Error: ${e?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(e)) {
ax.logger.error('Failed to reconnect Germ DM link', {
safeMessage: e,
})
}
}
}
selfExplanationDialogControl.close(() => {
void queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
<Trans>Germ DM disconnected</Trans>
</Toast.Text>
{previousRecord && (
<Toast.Action label={_(msg`Undo`)} onPress={() => void undo()}>
<Trans>Undo</Trans>
</Toast.Action>
)}
</Toast.Outer>,
)
})
},
onError: error => {
Toast.show(
_(msg`Failed to disconnect Germ DM. Error: ${error?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(error)) {
ax.logger.error('Failed to disconnect Germ DM link', {
safeMessage: error,
})
}
},
})
return (
<>
<Button
label={_(msg`Learn more about your Germ DM link`)}
onPress={() => {
ax.metric('profile:associated:germ:click-self-info', {})
selfExplanationDialogControl.open()
}}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6, paddingRight: 10},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
</Button>
<Dialog.Outer
control={selfExplanationDialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Germ DM Link`)}
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
<GermLogo size="large" />
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Germ DM Link</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug, a.mt_sm]}>
<Trans>
This button lets others open the Germ DM app to send you a
message. You can manage its visibility from the Germ DM app, or
you can disconnect your Bluesky account from Germ DM altogether by
clicking the button below.
</Trans>
</Text>
<View style={[a.mt_2xl, a.gap_md]}>
<Button
label={_(msg`Got it`)}
size="large"
color="primary"
onPress={() => selfExplanationDialogControl.close()}>
<ButtonText>
<Trans>Got it</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Disconnect Germ DM`)}
size="large"
color="secondary"
onPress={() => deleteDeclaration()}
disabled={isPending}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
<Trans>Disconnect Germ DM</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
function constructGermUrl(
declaration: AppBskyActorDefs.ProfileAssociatedGerm,
profile: bsky.profile.AnyProfileView,
viewerDid?: string,
) {
try {
const urlp = new URL(declaration.messageMeUrl)
if (urlp.pathname.endsWith('/')) {
urlp.pathname = urlp.pathname.slice(0, -1)
}
urlp.pathname += `/${platform()}`
if (viewerDid) {
urlp.hash = `#${profile.did}+${viewerDid}`
} else {
urlp.hash = `#${profile.did}`
}
return urlp.toString()
} catch {
return null
}
}
function isCustomGermDomain(url: string) {
try {
const urlp = new URL(url)
return urlp.hostname !== 'landing.ger.mx'
} catch {
return false
}
}
function platform() {
switch (Platform.OS) {
case 'ios':
return 'iOS'
case 'android':
return 'android'
default:
return 'web'
}
}
async function whenAppViewReady(
agent: AtpAgent,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() => agent.app.bsky.actor.getProfile({actor}),
)
}
+1 -1
View File
@@ -430,7 +430,7 @@ export function useRequireAuth() {
const {signinDialogControl} = useGlobalDialogsControlContext()
return useCallback(
(fn: () => void) => {
(fn: () => unknown) => {
if (hasSession) {
fn()
} else {