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