Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/images/bluvy_logo.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/analytics/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,8 @@ export type Events = {
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}

'profile:associated:bluvy:click-to-chat': {}

// Post photo embed events
'post:photoEmbed:impression': {
layout: 'single' | 'grid' | 'carousel'
Expand Down
14 changes: 13 additions & 1 deletion src/screens/Profile/Header/ProfileHeaderStandard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeWebsiteUrl, toShortUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useBluvyDeclarationQuery} from '#/state/queries/bluvy'
import {
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
Expand Down Expand Up @@ -45,6 +46,7 @@ import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
import {BluvyButton} from '../components/BluvyButton'
import {GermButton} from '../components/GermButton'
import {ProfileHeaderDisplayName} from './DisplayName'
import {EditProfileDialog} from './EditProfileDialog'
Expand Down Expand Up @@ -73,6 +75,9 @@ let ProfileHeaderStandard = ({
const {currentAccount} = useSession()
const {_} = useLingui()
const t = useTheme()
const {data: bluvyDeclaration} = useBluvyDeclarationQuery({
did: profile.did,
})
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
Expand Down Expand Up @@ -165,7 +170,7 @@ let ProfileHeaderStandard = ({
</View>
) : undefined}

{showWebsite || profile.associated?.germ ? (
{showWebsite || profile.associated?.germ || bluvyDeclaration ? (
<View
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}
pointerEvents="auto">
Expand Down Expand Up @@ -206,6 +211,13 @@ let ProfileHeaderStandard = ({
profile={profile}
/>
)}

{bluvyDeclaration && (
<BluvyButton
declaration={bluvyDeclaration}
profile={profile}
/>
)}
</View>
) : undefined}

Expand Down
102 changes: 102 additions & 0 deletions src/screens/Profile/components/BluvyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'

import {type BluvyDeclaration} from '#/state/queries/bluvy'
import {atoms as a, useTheme} from '#/alf'
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 {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'

export function BluvyButton({
declaration,
profile,
}: {
declaration: BluvyDeclaration
profile: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const linkWarningControl = Dialog.useDialogControl()

const {showButtonTo, messageMeUrl} = declaration.messageMe

// exclude `nothing` and all unknown values
if (showButtonTo !== 'everyone' && showButtonTo !== 'mutual') {
return null
}

if (
showButtonTo === 'mutual' &&
!(profile.viewer?.following && profile.viewer?.followedBy)
) {
return null
}

let url: string
try {
const urlp = new URL(messageMeUrl)
// some declarations omit the did hash (e.g. a bare "https://bluvy.app/message")
// -> point it at this profile
if (!urlp.hash) {
urlp.hash = profile.did
}
url = urlp.toString()
} catch {
return null
}

return (
<>
<Link
to={url}
onPress={evt => {
ax.metric('profile:associated:bluvy:click-to-chat', {})
evt.preventDefault()
linkWarningControl.open()
return false
}}
label={_(msg`Open Bluvy DM`)}
overridePresentation={false}
shouldProxy={false}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6},
]}>
<BluvyLogo />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Bluvy DM</Trans>
</Text>
<ArrowTopRightIcon style={[t.atoms.text, a.mx_2xs]} width={14} />
</Link>
<CustomLinkWarningDialog
control={linkWarningControl}
link={{
href: url,
displayText: '',
share: false,
}}
/>
</>
)
}

function BluvyLogo() {
return (
<Image
source={require('../../../../assets/images/bluvy_logo.webp')}
accessibilityIgnoresInvertColors={false}
contentFit="cover"
useAppleWebpCodec
style={[a.rounded_full, {width: 16, height: 16}]}
/>
)
}
47 changes: 47 additions & 0 deletions src/state/queries/bluvy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {useQuery} from '@tanstack/react-query'

import {GCTIME, STALE} from '#/state/queries'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'

/**
* `com.bluvy.declaration` isn't a lexicon the Bluesky AppView resolves (unlike
* `com.germnetwork.declaration`, which Bluesky's production AppView populates
* onto every profile as `associated.germ`). We have to fetch it ourselves.
*/
export type BluvyDeclaration = {
version: string
messageMe: {
showButtonTo: 'everyone' | 'mutual' | 'nothing' | (string & {})
messageMeUrl: string
}
}

const bluvyDeclarationQueryKeyRoot = 'bluvy-declaration'

export const createBluvyDeclarationQueryKey = (did: string) =>
createQueryKey(bluvyDeclarationQueryKeyRoot, {did}, {persistedVersion: 1})

export function useBluvyDeclarationQuery({did}: {did: string | undefined}) {
const agent = useAgent()
return useQuery<BluvyDeclaration | null>({
queryKey: createBluvyDeclarationQueryKey(did ?? ''),
enabled: !!did,
staleTime: STALE.HOURS.ONE,
gcTime: GCTIME.INFINITY,
retry: false,
queryFn: async () => {
try {
const {data} = await agent.com.atproto.repo.getRecord({
repo: did!,
collection: 'com.bluvy.declaration',
rkey: 'self',
})
return data.value as BluvyDeclaration
} catch {
// no record, or repo/rkey doesn't resolve -> treat as "no declaration"
return null
}
},
})
}