this repo has no description
1import {memo, useCallback, useMemo} from 'react'
2import {type AppBskyActorDefs} from '@atproto/api'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5import {Trans} from '@lingui/react/macro'
6import {useNavigation} from '@react-navigation/native'
7import {useQueryClient} from '@tanstack/react-query'
8
9import {HITSLOP_20} from '#/lib/constants'
10import {makeProfileLink} from '#/lib/routes/links'
11import {type NavigationProp} from '#/lib/routes/types'
12import {shareText, shareUrl} from '#/lib/sharing'
13import {toShareUrl} from '#/lib/strings/url-helpers'
14import {type Shadow} from '#/state/cache/types'
15import {useModalControls} from '#/state/modals'
16import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
17import {
18 RQKEY as profileQueryKey,
19 useProfileBlockMutationQueue,
20 useProfileFollowMutationQueue,
21 useProfileMuteMutationQueue,
22} from '#/state/queries/profile'
23import {useSession} from '#/state/session'
24import {EventStopper} from '#/view/com/util/EventStopper'
25import {atoms as a, useTheme} from '#/alf'
26import {Button, ButtonIcon} from '#/components/Button'
27import {useDialogControl} from '#/components/Dialog'
28import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
29import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
30import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
31import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
32import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
33import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
34import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
35import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
36import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
37import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
38import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
39import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
40import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
41import {
42 PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
43 PersonX_Stroke2_Corner0_Rounded as PersonX,
44} from '#/components/icons/Person'
45import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
46import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
47import {StarterPack} from '#/components/icons/StarterPack'
48import * as Menu from '#/components/Menu'
49import {
50 ReportDialog,
51 useReportDialogControl,
52} from '#/components/moderation/ReportDialog'
53import * as Prompt from '#/components/Prompt'
54import * as Toast from '#/components/Toast'
55import {useFullVerificationState} from '#/components/verification'
56import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
57import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
58import {useAnalytics} from '#/analytics'
59import {IS_WEB} from '#/env'
60import {useActorStatus, useLiveNowConfig} from '#/features/liveNow'
61import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
62import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog'
63import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog'
64import {Dot} from '#/features/nuxs/components/Dot'
65import {Gradient} from '#/features/nuxs/components/Gradient'
66import {useDevMode} from '#/storage/hooks/dev-mode'
67
68let ProfileMenu = ({
69 profile,
70}: {
71 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
72}): React.ReactNode => {
73 const t = useTheme()
74 const ax = useAnalytics()
75 const {_} = useLingui()
76 const {currentAccount, hasSession} = useSession()
77 const {openModal} = useModalControls()
78 const reportDialogControl = useReportDialogControl()
79 const queryClient = useQueryClient()
80 const navigation = useNavigation<NavigationProp>()
81 const isSelf = currentAccount?.did === profile.did
82 const isFollowing = profile.viewer?.following
83 const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy
84 const isFollowingBlockedAccount = isFollowing && isBlocked
85 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
86 const [devModeEnabled] = useDevMode()
87 const verification = useFullVerificationState({profile})
88 const {canGoLive} = useLiveNowConfig()
89 const status = useActorStatus(profile)
90 const statusNudge = useNux(Nux.LiveNowBetaNudge)
91 const statusNudgeActive =
92 isSelf &&
93 canGoLive &&
94 statusNudge.status === 'ready' &&
95 !statusNudge.nux?.completed
96 const {mutate: saveNux} = useSaveNux()
97
98 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
99 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
100 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
101 profile,
102 'ProfileMenu',
103 )
104
105 const blockPromptControl = Prompt.usePromptControl()
106 const loggedOutWarningPromptControl = Prompt.usePromptControl()
107 const goLiveDialogControl = useDialogControl()
108 const goLiveDisabledDialogControl = useDialogControl()
109 const addToStarterPacksDialogControl = useDialogControl()
110
111 const showLoggedOutWarning = useMemo(() => {
112 return (
113 profile.did !== currentAccount?.did &&
114 !!profile.labels?.find(label => label.val === '!no-unauthenticated')
115 )
116 }, [currentAccount, profile])
117
118 const invalidateProfileQuery = useCallback(() => {
119 queryClient.invalidateQueries({
120 queryKey: profileQueryKey(profile.did),
121 })
122 }, [queryClient, profile.did])
123
124 const onPressAddToStarterPacks = useCallback(() => {
125 ax.metric('profile:addToStarterPack', {})
126 addToStarterPacksDialogControl.open()
127 }, [addToStarterPacksDialogControl])
128
129 const onPressShare = useCallback(() => {
130 shareUrl(toShareUrl(makeProfileLink(profile)))
131 }, [profile])
132
133 const onPressAddRemoveLists = useCallback(() => {
134 openModal({
135 name: 'user-add-remove-lists',
136 subject: profile.did,
137 handle: profile.handle,
138 displayName: profile.displayName || profile.handle,
139 onAdd: invalidateProfileQuery,
140 onRemove: invalidateProfileQuery,
141 })
142 }, [profile, openModal, invalidateProfileQuery])
143
144 const onPressMuteAccount = useCallback(async () => {
145 if (profile.viewer?.muted) {
146 try {
147 await queueUnmute()
148 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
149 } catch (e: any) {
150 if (e?.name !== 'AbortError') {
151 ax.logger.error('Failed to unmute account', {message: e})
152 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
153 type: 'error',
154 })
155 }
156 }
157 } else {
158 try {
159 await queueMute()
160 Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
161 } catch (e: any) {
162 if (e?.name !== 'AbortError') {
163 ax.logger.error('Failed to mute account', {message: e})
164 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
165 type: 'error',
166 })
167 }
168 }
169 }
170 }, [ax, profile.viewer?.muted, queueUnmute, _, queueMute])
171
172 const blockAccount = useCallback(async () => {
173 if (profile.viewer?.blocking) {
174 try {
175 await queueUnblock()
176 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
177 } catch (e: any) {
178 if (e?.name !== 'AbortError') {
179 ax.logger.error('Failed to unblock account', {message: e})
180 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
181 type: 'error',
182 })
183 }
184 }
185 } else {
186 try {
187 await queueBlock()
188 Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
189 } catch (e: any) {
190 if (e?.name !== 'AbortError') {
191 ax.logger.error('Failed to block account', {message: e})
192 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
193 type: 'error',
194 })
195 }
196 }
197 }
198 }, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock])
199
200 const onPressFollowAccount = useCallback(async () => {
201 try {
202 await queueFollow()
203 Toast.show(_(msg({message: 'Account followed', context: 'toast'})))
204 } catch (e: any) {
205 if (e?.name !== 'AbortError') {
206 ax.logger.error('Failed to follow account', {message: e})
207 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
208 type: 'error',
209 })
210 }
211 }
212 }, [_, ax, queueFollow])
213
214 const onPressUnfollowAccount = useCallback(async () => {
215 try {
216 await queueUnfollow()
217 Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'})))
218 } catch (e: any) {
219 if (e?.name !== 'AbortError') {
220 ax.logger.error('Failed to unfollow account', {message: e})
221 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
222 type: 'error',
223 })
224 }
225 }
226 }, [_, ax, queueUnfollow])
227
228 const onPressReportAccount = useCallback(() => {
229 reportDialogControl.open()
230 }, [reportDialogControl])
231
232 const onPressShareATUri = useCallback(() => {
233 shareText(`at://${profile.did}`)
234 }, [profile.did])
235
236 const onPressShareDID = useCallback(() => {
237 shareText(profile.did)
238 }, [profile.did])
239
240 const onPressSearch = useCallback(() => {
241 navigation.navigate('ProfileSearch', {name: profile.handle})
242 }, [navigation, profile.handle])
243
244 const verificationCreatePromptControl = Prompt.usePromptControl()
245 const verificationRemovePromptControl = Prompt.usePromptControl()
246 const currentAccountVerifications =
247 profile.verification?.verifications?.filter(v => {
248 return v.issuer === currentAccount?.did
249 }) ?? []
250
251 return (
252 <EventStopper onKeyDown={false}>
253 <Menu.Root>
254 <Menu.Trigger label={_(msg`More options`)}>
255 {({props}) => {
256 return (
257 <>
258 <Button
259 {...props}
260 testID="profileHeaderDropdownBtn"
261 label={_(msg`More options`)}
262 hitSlop={HITSLOP_20}
263 variant="solid"
264 color="secondary"
265 size="small"
266 shape="round">
267 {statusNudgeActive && <Gradient style={[a.rounded_full]} />}
268 <ButtonIcon icon={Ellipsis} size="sm" />
269 </Button>
270
271 {statusNudgeActive && <Dot top={1} right={1} />}
272 </>
273 )
274 }}
275 </Menu.Trigger>
276
277 <Menu.Outer style={{minWidth: 170}}>
278 <Menu.Group>
279 <Menu.Item
280 testID="profileHeaderDropdownShareBtn"
281 label={
282 IS_WEB ? _(msg`Copy link to profile`) : _(msg`Share via...`)
283 }
284 onPress={() => {
285 if (showLoggedOutWarning) {
286 loggedOutWarningPromptControl.open()
287 } else {
288 onPressShare()
289 }
290 }}>
291 <Menu.ItemText>
292 {IS_WEB ? (
293 <Trans>Copy link to profile</Trans>
294 ) : (
295 <Trans>Share via...</Trans>
296 )}
297 </Menu.ItemText>
298 <Menu.ItemIcon
299 icon={IS_WEB ? ChainLinkIcon : ArrowOutOfBoxIcon}
300 />
301 </Menu.Item>
302 <Menu.Item
303 testID="profileHeaderDropdownSearchBtn"
304 label={_(msg`Search posts`)}
305 onPress={onPressSearch}>
306 <Menu.ItemText>
307 <Trans>Search posts</Trans>
308 </Menu.ItemText>
309 <Menu.ItemIcon icon={SearchIcon} />
310 </Menu.Item>
311 </Menu.Group>
312
313 {hasSession && (
314 <>
315 <Menu.Divider />
316 <Menu.Group>
317 {!isSelf && (
318 <>
319 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && (
320 <Menu.Item
321 testID="profileHeaderDropdownFollowBtn"
322 label={
323 isFollowing
324 ? _(msg`Unfollow account`)
325 : _(msg`Follow account`)
326 }
327 onPress={
328 isFollowing
329 ? onPressUnfollowAccount
330 : onPressFollowAccount
331 }>
332 <Menu.ItemText>
333 {isFollowing ? (
334 <Trans>Unfollow account</Trans>
335 ) : (
336 <Trans>Follow account</Trans>
337 )}
338 </Menu.ItemText>
339 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} />
340 </Menu.Item>
341 )}
342 </>
343 )}
344 <Menu.Item
345 testID="profileHeaderDropdownStarterPackAddRemoveBtn"
346 label={_(msg`Add to starter packs`)}
347 onPress={onPressAddToStarterPacks}>
348 <Menu.ItemText>
349 <Trans>Add to starter packs</Trans>
350 </Menu.ItemText>
351 <Menu.ItemIcon icon={StarterPack} />
352 </Menu.Item>
353 <Menu.Item
354 testID="profileHeaderDropdownListAddRemoveBtn"
355 label={_(msg`Add to lists`)}
356 onPress={onPressAddRemoveLists}>
357 <Menu.ItemText>
358 <Trans>Add to lists</Trans>
359 </Menu.ItemText>
360 <Menu.ItemIcon icon={List} />
361 </Menu.Item>
362 {isSelf && canGoLive && (
363 <Menu.Item
364 testID="profileHeaderDropdownListAddRemoveBtn"
365 label={
366 status.isDisabled
367 ? _(msg`Go live (disabled)`)
368 : status.isActive
369 ? _(msg`Edit live status`)
370 : _(msg`Go live`)
371 }
372 onPress={() => {
373 if (status.isDisabled) {
374 goLiveDisabledDialogControl.open()
375 } else {
376 goLiveDialogControl.open()
377 }
378 saveNux({
379 id: Nux.LiveNowBetaNudge,
380 data: undefined,
381 completed: true,
382 })
383 }}>
384 {statusNudgeActive && <Gradient />}
385 <Menu.ItemText>
386 {status.isDisabled ? (
387 <Trans>Go live (disabled)</Trans>
388 ) : status.isActive ? (
389 <Trans>Edit live status</Trans>
390 ) : (
391 <Trans>Go live</Trans>
392 )}
393 </Menu.ItemText>
394 {statusNudgeActive && (
395 <Menu.ItemText
396 style={[
397 a.flex_0,
398 {
399 color: t.palette.primary_500,
400 right: IS_WEB ? -8 : -4,
401 },
402 ]}>
403 <Trans>New</Trans>
404 </Menu.ItemText>
405 )}
406 <Menu.ItemIcon
407 icon={LiveIcon}
408 fill={
409 statusNudgeActive
410 ? () => t.palette.primary_500
411 : undefined
412 }
413 />
414 </Menu.Item>
415 )}
416 {verification.viewer.role === 'verifier' &&
417 !verification.profile.isViewer &&
418 (verification.viewer.hasIssuedVerification ? (
419 <Menu.Item
420 testID="profileHeaderDropdownVerificationRemoveButton"
421 label={_(msg`Remove verification`)}
422 onPress={() => verificationRemovePromptControl.open()}>
423 <Menu.ItemText>
424 <Trans>Remove verification</Trans>
425 </Menu.ItemText>
426 <Menu.ItemIcon icon={CircleXIcon} />
427 </Menu.Item>
428 ) : (
429 <Menu.Item
430 testID="profileHeaderDropdownVerificationCreateButton"
431 label={_(msg`Verify account`)}
432 onPress={() => verificationCreatePromptControl.open()}>
433 <Menu.ItemText>
434 <Trans>Verify account</Trans>
435 </Menu.ItemText>
436 <Menu.ItemIcon icon={CircleCheckIcon} />
437 </Menu.Item>
438 ))}
439 {!isSelf && (
440 <>
441 {!profile.viewer?.blocking &&
442 !profile.viewer?.mutedByList && (
443 <Menu.Item
444 testID="profileHeaderDropdownMuteBtn"
445 label={
446 profile.viewer?.muted
447 ? _(msg`Unmute account`)
448 : _(msg`Mute account`)
449 }
450 onPress={onPressMuteAccount}>
451 <Menu.ItemText>
452 {profile.viewer?.muted ? (
453 <Trans>Unmute account</Trans>
454 ) : (
455 <Trans>Mute account</Trans>
456 )}
457 </Menu.ItemText>
458 <Menu.ItemIcon
459 icon={profile.viewer?.muted ? Unmute : Mute}
460 />
461 </Menu.Item>
462 )}
463 {!profile.viewer?.blockingByList && (
464 <Menu.Item
465 testID="profileHeaderDropdownBlockBtn"
466 label={
467 profile.viewer
468 ? _(msg`Unblock account`)
469 : _(msg`Block account`)
470 }
471 onPress={() => blockPromptControl.open()}>
472 <Menu.ItemText>
473 {profile.viewer?.blocking ? (
474 <Trans>Unblock account</Trans>
475 ) : (
476 <Trans>Block account</Trans>
477 )}
478 </Menu.ItemText>
479 <Menu.ItemIcon
480 icon={
481 profile.viewer?.blocking ? PersonCheck : PersonX
482 }
483 />
484 </Menu.Item>
485 )}
486 <Menu.Item
487 testID="profileHeaderDropdownReportBtn"
488 label={_(msg`Report account`)}
489 onPress={onPressReportAccount}>
490 <Menu.ItemText>
491 <Trans>Report account</Trans>
492 </Menu.ItemText>
493 <Menu.ItemIcon icon={Flag} />
494 </Menu.Item>
495 </>
496 )}
497 </Menu.Group>
498 </>
499 )}
500 {devModeEnabled ? (
501 <>
502 <Menu.Divider />
503 <Menu.Group>
504 <Menu.Item
505 testID="profileHeaderDropdownShareATURIBtn"
506 label={_(msg`Copy at:// URI`)}
507 onPress={onPressShareATUri}>
508 <Menu.ItemText>
509 <Trans>Copy at:// URI</Trans>
510 </Menu.ItemText>
511 <Menu.ItemIcon icon={ClipboardIcon} />
512 </Menu.Item>
513 <Menu.Item
514 testID="profileHeaderDropdownShareDIDBtn"
515 label={_(msg`Copy DID`)}
516 onPress={onPressShareDID}>
517 <Menu.ItemText>
518 <Trans>Copy DID</Trans>
519 </Menu.ItemText>
520 <Menu.ItemIcon icon={ClipboardIcon} />
521 </Menu.Item>
522 </Menu.Group>
523 </>
524 ) : null}
525 </Menu.Outer>
526 </Menu.Root>
527
528 <StarterPackDialog
529 control={addToStarterPacksDialogControl}
530 targetDid={profile.did}
531 />
532
533 <ReportDialog
534 control={reportDialogControl}
535 subject={{
536 ...profile,
537 $type: 'app.bsky.actor.defs#profileViewDetailed',
538 }}
539 />
540
541 <Prompt.Basic
542 control={blockPromptControl}
543 title={
544 profile.viewer?.blocking
545 ? _(msg`Unblock Account?`)
546 : _(msg`Block Account?`)
547 }
548 description={
549 profile.viewer?.blocking
550 ? _(
551 msg`The account will be able to interact with you after unblocking.`,
552 )
553 : profile.associated?.labeler
554 ? _(
555 msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`,
556 )
557 : _(
558 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
559 )
560 }
561 onConfirm={blockAccount}
562 confirmButtonCta={
563 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
564 }
565 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
566 />
567
568 <Prompt.Basic
569 control={loggedOutWarningPromptControl}
570 title={_(msg`Note about sharing`)}
571 description={_(
572 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`,
573 )}
574 onConfirm={onPressShare}
575 confirmButtonCta={_(msg`Share anyway`)}
576 />
577
578 <VerificationCreatePrompt
579 control={verificationCreatePromptControl}
580 profile={profile}
581 />
582 <VerificationRemovePrompt
583 control={verificationRemovePromptControl}
584 profile={profile}
585 verifications={currentAccountVerifications}
586 />
587
588 {status.isDisabled ? (
589 <GoLiveDisabledDialog
590 control={goLiveDisabledDialogControl}
591 status={status}
592 />
593 ) : status.isActive ? (
594 <EditLiveDialog
595 control={goLiveDialogControl}
596 status={status}
597 embed={status.embed}
598 />
599 ) : (
600 <GoLiveDialog control={goLiveDialogControl} profile={profile} />
601 )}
602 </EventStopper>
603 )
604}
605
606ProfileMenu = memo(ProfileMenu)
607export {ProfileMenu}