diff --git a/assets/images/germ_logo.webp b/assets/images/germ_logo.webp
new file mode 100644
index 0000000000..a85048381a
Binary files /dev/null and b/assets/images/germ_logo.webp differ
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 97b7fcfe09..65f2a5d39f 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -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': {}
}
diff --git a/src/components/dialogs/LinkWarning.tsx b/src/components/dialogs/LinkWarning.tsx
index 74be78f9d3..181d411ce4 100644
--- a/src/components/dialogs/LinkWarning.tsx
+++ b/src/components/dialogs/LinkWarning.tsx
@@ -22,12 +22,27 @@ export function LinkWarningDialog() {
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
-
+
)
}
-function InAppBrowserConsentInner({
+export function CustomLinkWarningDialog({
+ control,
+ link,
+}: {
+ control: Dialog.DialogControlProps
+ link?: {href: string; displayText: string; share?: boolean}
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function LinkWarningDialogInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 6467756d4e..c60fcd032a 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -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 = ({
) : undefined}
+ {profile.associated?.germ && (
+
+ )}
+
{!isMe &&
!isBlockedUser &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
diff --git a/src/screens/Profile/components/GermButton.tsx b/src/screens/Profile/components/GermButton.tsx
new file mode 100644
index 0000000000..c5af7d536e
--- /dev/null
+++ b/src/screens/Profile/components/GermButton.tsx
@@ -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
+ }
+
+ if (germ.showButtonTo === 'usersIFollow' && !profile.viewer?.followedBy) {
+ return null
+ }
+
+ const url = constructGermUrl(germ, profile, currentAccount?.did)
+
+ if (!url) {
+ return null
+ }
+
+ return (
+ <>
+ {
+ 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},
+ ]}>
+
+
+ Germ DM
+
+
+
+
+ >
+ )
+}
+
+function GermLogo({size}: {size: 'small' | 'large'}) {
+ return (
+
+ )
+}
+
+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(
+
+
+
+ Germ DM disconnected
+
+ {previousRecord && (
+ void undo()}>
+ Undo
+
+ )}
+ ,
+ )
+ })
+ },
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+ Germ DM Link
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+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}),
+ )
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 4a70fc4c09..e63e180a4e 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -430,7 +430,7 @@ export function useRequireAuth() {
const {signinDialogControl} = useGlobalDialogsControlContext()
return useCallback(
- (fn: () => void) => {
+ (fn: () => unknown) => {
if (hasSession) {
fn()
} else {