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 {HITSLOP_20} from '#/lib/constants'
9import {makeProfileLink} from '#/lib/routes/links'
10import {type NavigationProp} from '#/lib/routes/types'
11import {shareText, shareUrl} from '#/lib/sharing'
12import {toShareUrl} from '#/lib/strings/url-helpers'
13import {type Shadow} from '#/state/cache/types'
14import {useModalControls} from '#/state/modals'
15import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
16import {
17 RQKEY as profileQueryKey,
18 useProfileBlockMutationQueue,
19 useProfileFollowMutationQueue,
20 useProfileMuteMutationQueue,
21} from '#/state/queries/profile'
22import {useSession} from '#/state/session'
23import {EventStopper} from '#/view/com/util/EventStopper'
24import * as Toast from '#/view/com/util/Toast'
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 {DotGrid_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 {useFullVerificationState} from '#/components/verification'
55import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
56import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
57import {useAnalytics} from '#/analytics'
58import {IS_WEB} from '#/env'
59import {useActorStatus, useLiveNowConfig} from '#/features/liveNow'
60import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
61import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog'
62import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog'
63import {Dot} from '#/features/nuxs/components/Dot'
64import {Gradient} from '#/features/nuxs/components/Gradient'
65import {useDevMode} from '#/storage/hooks/dev-mode'
66
67let ProfileMenu = ({
68 profile,
69}: {
70 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
71}): React.ReactNode => {
72 const t = useTheme()
73 const ax = useAnalytics()
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} = useLiveNowConfig()
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 ax.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 ax.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 ax.logger.error('Failed to mute account', {message: e})
161 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
162 }
163 }
164 }
165 }, [ax, 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 ax.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 ax.logger.error('Failed to block account', {message: e})
185 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
186 }
187 }
188 }
189 }, [ax, 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 ax.logger.error('Failed to follow account', {message: e})
198 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
199 }
200 }
201 }, [_, ax, 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 ax.logger.error('Failed to unfollow account', {message: e})
210 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
211 }
212 }
213 }, [_, ax, 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 IS_WEB ? _(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 {IS_WEB ? (
280 <Trans>Copy link to profile</Trans>
281 ) : (
282 <Trans>Share via...</Trans>
283 )}
284 </Menu.ItemText>
285 <Menu.ItemIcon
286 icon={IS_WEB ? ChainLinkIcon : ArrowOutOfBoxIcon}
287 />
288 </Menu.Item>
289 <Menu.Item
290 testID="profileHeaderDropdownSearchBtn"
291 label={_(msg`Search posts`)}
292 onPress={onPressSearch}>
293 <Menu.ItemText>
294 <Trans>Search posts</Trans>
295 </Menu.ItemText>
296 <Menu.ItemIcon icon={SearchIcon} />
297 </Menu.Item>
298 </Menu.Group>
299
300 {hasSession && (
301 <>
302 <Menu.Divider />
303 <Menu.Group>
304 {!isSelf && (
305 <>
306 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && (
307 <Menu.Item
308 testID="profileHeaderDropdownFollowBtn"
309 label={
310 isFollowing
311 ? _(msg`Unfollow account`)
312 : _(msg`Follow account`)
313 }
314 onPress={
315 isFollowing
316 ? onPressUnfollowAccount
317 : onPressFollowAccount
318 }>
319 <Menu.ItemText>
320 {isFollowing ? (
321 <Trans>Unfollow account</Trans>
322 ) : (
323 <Trans>Follow account</Trans>
324 )}
325 </Menu.ItemText>
326 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} />
327 </Menu.Item>
328 )}
329 </>
330 )}
331 {!isSelf && (
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 )}
342 <Menu.Item
343 testID="profileHeaderDropdownListAddRemoveBtn"
344 label={_(msg`Add to lists`)}
345 onPress={onPressAddRemoveLists}>
346 <Menu.ItemText>
347 <Trans>Add to lists</Trans>
348 </Menu.ItemText>
349 <Menu.ItemIcon icon={List} />
350 </Menu.Item>
351 {isSelf && canGoLive && (
352 <Menu.Item
353 testID="profileHeaderDropdownListAddRemoveBtn"
354 label={
355 status.isDisabled
356 ? _(msg`Go live (disabled)`)
357 : status.isActive
358 ? _(msg`Edit live status`)
359 : _(msg`Go live`)
360 }
361 onPress={() => {
362 if (status.isDisabled) {
363 goLiveDisabledDialogControl.open()
364 } else {
365 goLiveDialogControl.open()
366 }
367 saveNux({
368 id: Nux.LiveNowBetaNudge,
369 data: undefined,
370 completed: true,
371 })
372 }}>
373 {statusNudgeActive && <Gradient />}
374 <Menu.ItemText>
375 {status.isDisabled ? (
376 <Trans>Go live (disabled)</Trans>
377 ) : status.isActive ? (
378 <Trans>Edit live status</Trans>
379 ) : (
380 <Trans>Go live</Trans>
381 )}
382 </Menu.ItemText>
383 {statusNudgeActive && (
384 <Menu.ItemText
385 style={[
386 a.flex_0,
387 {
388 color: t.palette.primary_500,
389 right: IS_WEB ? -8 : -4,
390 },
391 ]}>
392 <Trans>New</Trans>
393 </Menu.ItemText>
394 )}
395 <Menu.ItemIcon
396 icon={LiveIcon}
397 fill={
398 statusNudgeActive
399 ? () => t.palette.primary_500
400 : undefined
401 }
402 />
403 </Menu.Item>
404 )}
405 {verification.viewer.role === 'verifier' &&
406 !verification.profile.isViewer &&
407 (verification.viewer.hasIssuedVerification ? (
408 <Menu.Item
409 testID="profileHeaderDropdownVerificationRemoveButton"
410 label={_(msg`Remove verification`)}
411 onPress={() => verificationRemovePromptControl.open()}>
412 <Menu.ItemText>
413 <Trans>Remove verification</Trans>
414 </Menu.ItemText>
415 <Menu.ItemIcon icon={CircleXIcon} />
416 </Menu.Item>
417 ) : (
418 <Menu.Item
419 testID="profileHeaderDropdownVerificationCreateButton"
420 label={_(msg`Verify account`)}
421 onPress={() => verificationCreatePromptControl.open()}>
422 <Menu.ItemText>
423 <Trans>Verify account</Trans>
424 </Menu.ItemText>
425 <Menu.ItemIcon icon={CircleCheckIcon} />
426 </Menu.Item>
427 ))}
428 {!isSelf && (
429 <>
430 {!profile.viewer?.blocking &&
431 !profile.viewer?.mutedByList && (
432 <Menu.Item
433 testID="profileHeaderDropdownMuteBtn"
434 label={
435 profile.viewer?.muted
436 ? _(msg`Unmute account`)
437 : _(msg`Mute account`)
438 }
439 onPress={onPressMuteAccount}>
440 <Menu.ItemText>
441 {profile.viewer?.muted ? (
442 <Trans>Unmute account</Trans>
443 ) : (
444 <Trans>Mute account</Trans>
445 )}
446 </Menu.ItemText>
447 <Menu.ItemIcon
448 icon={profile.viewer?.muted ? Unmute : Mute}
449 />
450 </Menu.Item>
451 )}
452 {!profile.viewer?.blockingByList && (
453 <Menu.Item
454 testID="profileHeaderDropdownBlockBtn"
455 label={
456 profile.viewer
457 ? _(msg`Unblock account`)
458 : _(msg`Block account`)
459 }
460 onPress={() => blockPromptControl.open()}>
461 <Menu.ItemText>
462 {profile.viewer?.blocking ? (
463 <Trans>Unblock account</Trans>
464 ) : (
465 <Trans>Block account</Trans>
466 )}
467 </Menu.ItemText>
468 <Menu.ItemIcon
469 icon={
470 profile.viewer?.blocking ? PersonCheck : PersonX
471 }
472 />
473 </Menu.Item>
474 )}
475 <Menu.Item
476 testID="profileHeaderDropdownReportBtn"
477 label={_(msg`Report account`)}
478 onPress={onPressReportAccount}>
479 <Menu.ItemText>
480 <Trans>Report account</Trans>
481 </Menu.ItemText>
482 <Menu.ItemIcon icon={Flag} />
483 </Menu.Item>
484 </>
485 )}
486 </Menu.Group>
487 </>
488 )}
489 {devModeEnabled ? (
490 <>
491 <Menu.Divider />
492 <Menu.Group>
493 <Menu.Item
494 testID="profileHeaderDropdownShareATURIBtn"
495 label={_(msg`Copy at:// URI`)}
496 onPress={onPressShareATUri}>
497 <Menu.ItemText>
498 <Trans>Copy at:// URI</Trans>
499 </Menu.ItemText>
500 <Menu.ItemIcon icon={ClipboardIcon} />
501 </Menu.Item>
502 <Menu.Item
503 testID="profileHeaderDropdownShareDIDBtn"
504 label={_(msg`Copy DID`)}
505 onPress={onPressShareDID}>
506 <Menu.ItemText>
507 <Trans>Copy DID</Trans>
508 </Menu.ItemText>
509 <Menu.ItemIcon icon={ClipboardIcon} />
510 </Menu.Item>
511 </Menu.Group>
512 </>
513 ) : null}
514 </Menu.Outer>
515 </Menu.Root>
516
517 <StarterPackDialog
518 control={addToStarterPacksDialogControl}
519 targetDid={profile.did}
520 />
521
522 <ReportDialog
523 control={reportDialogControl}
524 subject={{
525 ...profile,
526 $type: 'app.bsky.actor.defs#profileViewDetailed',
527 }}
528 />
529
530 <Prompt.Basic
531 control={blockPromptControl}
532 title={
533 profile.viewer?.blocking
534 ? _(msg`Unblock Account?`)
535 : _(msg`Block Account?`)
536 }
537 description={
538 profile.viewer?.blocking
539 ? _(
540 msg`The account will be able to interact with you after unblocking.`,
541 )
542 : profile.associated?.labeler
543 ? _(
544 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.`,
545 )
546 : _(
547 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
548 )
549 }
550 onConfirm={blockAccount}
551 confirmButtonCta={
552 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
553 }
554 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
555 />
556
557 <Prompt.Basic
558 control={loggedOutWarningPromptControl}
559 title={_(msg`Note about sharing`)}
560 description={_(
561 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`,
562 )}
563 onConfirm={onPressShare}
564 confirmButtonCta={_(msg`Share anyway`)}
565 />
566
567 <VerificationCreatePrompt
568 control={verificationCreatePromptControl}
569 profile={profile}
570 />
571 <VerificationRemovePrompt
572 control={verificationRemovePromptControl}
573 profile={profile}
574 verifications={currentAccountVerifications}
575 />
576
577 {status.isDisabled ? (
578 <GoLiveDisabledDialog
579 control={goLiveDisabledDialogControl}
580 status={status}
581 />
582 ) : status.isActive ? (
583 <EditLiveDialog
584 control={goLiveDialogControl}
585 status={status}
586 embed={status.embed}
587 />
588 ) : (
589 <GoLiveDialog control={goLiveDialogControl} profile={profile} />
590 )}
591 </EventStopper>
592 )
593}
594
595ProfileMenu = memo(ProfileMenu)
596export {ProfileMenu}