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 {