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