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, toShareUrlBsky} 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 {
19 useDeerVerificationEnabled,
20 useDeerVerificationTrusted,
21 useSetDeerVerificationTrust,
22} from '#/state/preferences/deer-verification'
23import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
24import {
25 RQKEY as profileQueryKey,
26 useProfileBlockMutationQueue,
27 useProfileFollowMutationQueue,
28 useProfileMuteMutationQueue,
29} from '#/state/queries/profile'
30import {useCanGoLive} from '#/state/service-config'
31import {useSession} from '#/state/session'
32import {EventStopper} from '#/view/com/util/EventStopper'
33import * as Toast from '#/view/com/util/Toast'
34import {Button, ButtonIcon} from '#/components/Button'
35import {useDialogControl} from '#/components/Dialog'
36import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
37import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
38import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
39import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
40import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
41import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
42import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
43import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
44import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
45import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
46import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
47import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
48import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
49import {
50 PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
51 PersonX_Stroke2_Corner0_Rounded as PersonX,
52} from '#/components/icons/Person'
53import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
54import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
55import {StarterPack} from '#/components/icons/StarterPack'
56import {EditLiveDialog} from '#/components/live/EditLiveDialog'
57import {GoLiveDialog} from '#/components/live/GoLiveDialog'
58import * as Menu from '#/components/Menu'
59import {
60 ReportDialog,
61 useReportDialogControl,
62} from '#/components/moderation/ReportDialog'
63import * as Prompt from '#/components/Prompt'
64import {useFullVerificationState} from '#/components/verification'
65import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
66import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
67import {useDevMode} from '#/storage/hooks/dev-mode'
68
69let ProfileMenu = ({
70 profile,
71}: {
72 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
73}): React.ReactNode => {
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 isFollowedBy = profile.viewer?.followedBy
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(currentAccount?.did)
89
90 const deerVerificationEnabled = useDeerVerificationEnabled()
91 const deerVerificationTrusted = useDeerVerificationTrusted().has(profile.did)
92 const setDeerVerificationTrust = useSetDeerVerificationTrust()
93
94 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
95 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
96 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
97 profile,
98 'ProfileMenu',
99 )
100
101 const blockPromptControl = Prompt.usePromptControl()
102 const loggedOutWarningPromptControl = Prompt.usePromptControl()
103 const goLiveDialogControl = useDialogControl()
104 const addToStarterPacksDialogControl = useDialogControl()
105
106 const showLoggedOutWarning = React.useMemo(() => {
107 return (
108 profile.did !== currentAccount?.did &&
109 !!profile.labels?.find(label => label.val === '!no-unauthenticated')
110 )
111 }, [currentAccount, profile])
112
113 const invalidateProfileQuery = React.useCallback(() => {
114 queryClient.invalidateQueries({
115 queryKey: profileQueryKey(profile.did),
116 })
117 }, [queryClient, profile.did])
118
119 const onPressAddToStarterPacks = React.useCallback(() => {
120 logger.metric('profile:addToStarterPack', {})
121 addToStarterPacksDialogControl.open()
122 }, [addToStarterPacksDialogControl])
123
124 const onPressShare = React.useCallback(() => {
125 shareUrl(toShareUrl(makeProfileLink(profile)))
126 }, [profile])
127
128 const onPressShareBsky = React.useCallback(() => {
129 shareUrl(toShareUrlBsky(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 const status = useActorStatus(profile)
239
240 const enableSquareButtons = useEnableSquareButtons()
241
242 return (
243 <EventStopper onKeyDown={false}>
244 <Menu.Root>
245 <Menu.Trigger label={_(msg`More options`)}>
246 {({props}) => {
247 return (
248 <Button
249 {...props}
250 testID="profileHeaderDropdownBtn"
251 label={_(msg`More options`)}
252 hitSlop={HITSLOP_20}
253 variant="solid"
254 color="secondary"
255 size="small"
256 shape={enableSquareButtons ? 'square' : 'round'}>
257 <ButtonIcon icon={Ellipsis} size="sm" />
258 </Button>
259 )
260 }}
261 </Menu.Trigger>
262
263 <Menu.Outer style={{minWidth: 170}}>
264 <Menu.Group>
265 <Menu.Item
266 testID="profileHeaderDropdownShareBtn"
267 label={
268 isWeb ? _(msg`Copy link to profile`) : _(msg`Share via...`)
269 }
270 onPress={() => {
271 if (showLoggedOutWarning) {
272 loggedOutWarningPromptControl.open()
273 } else {
274 onPressShare()
275 }
276 }}>
277 <Menu.ItemText>
278 {isWeb ? (
279 <Trans>Copy link to profile</Trans>
280 ) : (
281 <Trans>Share via...</Trans>
282 )}
283 </Menu.ItemText>
284 <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} />
285 </Menu.Item>
286 <Menu.Item
287 testID="profileHeaderDropdownShareBtn"
288 label={
289 isWeb
290 ? _(msg`Copy via bsky.app`)
291 : _(msg`Share via bsky.app...`)
292 }
293 onPress={() => {
294 if (showLoggedOutWarning) {
295 loggedOutWarningPromptControl.open()
296 } else {
297 onPressShareBsky()
298 }
299 }}>
300 <Menu.ItemText>
301 {isWeb ? (
302 <Trans>Copy via bsky.app</Trans>
303 ) : (
304 <Trans>Share via bsky.app...</Trans>
305 )}
306 </Menu.ItemText>
307 <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} />
308 </Menu.Item>
309 <Menu.Item
310 testID="profileHeaderDropdownSearchBtn"
311 label={_(msg`Search skeets`)}
312 onPress={onPressSearch}>
313 <Menu.ItemText>
314 <Trans>Search skeets</Trans>
315 </Menu.ItemText>
316 <Menu.ItemIcon icon={SearchIcon} />
317 </Menu.Item>
318 </Menu.Group>
319
320 {hasSession && (
321 <>
322 <Menu.Divider />
323 <Menu.Group>
324 {!isSelf && (
325 <>
326 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && (
327 <Menu.Item
328 testID="profileHeaderDropdownFollowBtn"
329 label={
330 isFollowing
331 ? isFollowedBy
332 ? _(msg`Divorce mutual`)
333 : _(msg`Unfollow account`)
334 : _(msg`Follow account`)
335 }
336 onPress={
337 isFollowing
338 ? onPressUnfollowAccount
339 : onPressFollowAccount
340 }>
341 <Menu.ItemText>
342 {isFollowing ? (
343 isFollowedBy ? (
344 <Trans>Divorce mutual</Trans>
345 ) : (
346 <Trans>Unfollow account</Trans>
347 )
348 ) : (
349 <Trans>Follow account</Trans>
350 )}
351 </Menu.ItemText>
352 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} />
353 </Menu.Item>
354 )}
355 </>
356 )}
357 <Menu.Item
358 testID="profileHeaderDropdownStarterPackAddRemoveBtn"
359 label={_(msg`Add to starter packs`)}
360 onPress={onPressAddToStarterPacks}>
361 <Menu.ItemText>
362 <Trans>Add to starter packs</Trans>
363 </Menu.ItemText>
364 <Menu.ItemIcon icon={StarterPack} />
365 </Menu.Item>
366 <Menu.Item
367 testID="profileHeaderDropdownListAddRemoveBtn"
368 label={_(msg`Add to lists`)}
369 onPress={onPressAddRemoveLists}>
370 <Menu.ItemText>
371 <Trans>Add to lists</Trans>
372 </Menu.ItemText>
373 <Menu.ItemIcon icon={List} />
374 </Menu.Item>
375 {!isSelf &&
376 deerVerificationEnabled &&
377 (deerVerificationTrusted ? (
378 <Menu.Item
379 testID="profileHeaderDropdownVerificationTrustRemoveButton"
380 label={_(msg`Remove trust`)}
381 onPress={() =>
382 setDeerVerificationTrust.remove(profile.did)
383 }>
384 <Menu.ItemText>
385 <Trans>Remove trust</Trans>
386 </Menu.ItemText>
387 <Menu.ItemIcon icon={CircleXIcon} />
388 </Menu.Item>
389 ) : (
390 <Menu.Item
391 testID="profileHeaderDropdownVerificationTrustAddButton"
392 label={_(msg`Trust verifier`)}
393 onPress={() => setDeerVerificationTrust.add(profile.did)}>
394 <Menu.ItemText>
395 <Trans>Trust verifier</Trans>
396 </Menu.ItemText>
397 <Menu.ItemIcon icon={CircleCheckIcon} />
398 </Menu.Item>
399 ))}
400 {isSelf && canGoLive && (
401 <Menu.Item
402 testID="profileHeaderDropdownListAddRemoveBtn"
403 label={
404 status.isActive
405 ? _(msg`Edit live status`)
406 : _(msg`Go live`)
407 }
408 onPress={goLiveDialogControl.open}>
409 <Menu.ItemText>
410 {status.isActive ? (
411 <Trans>Edit live status</Trans>
412 ) : (
413 <Trans>Go live</Trans>
414 )}
415 </Menu.ItemText>
416 <Menu.ItemIcon icon={LiveIcon} />
417 </Menu.Item>
418 )}
419 {verification.viewer.role === 'verifier' &&
420 !verification.profile.isViewer &&
421 (verification.viewer.hasIssuedVerification ? (
422 <Menu.Item
423 testID="profileHeaderDropdownVerificationRemoveButton"
424 label={_(msg`Remove verification`)}
425 onPress={() => verificationRemovePromptControl.open()}>
426 <Menu.ItemText>
427 <Trans>Remove verification</Trans>
428 </Menu.ItemText>
429 <Menu.ItemIcon icon={CircleXIcon} />
430 </Menu.Item>
431 ) : (
432 <Menu.Item
433 testID="profileHeaderDropdownVerificationCreateButton"
434 label={_(msg`Verify account`)}
435 onPress={() => verificationCreatePromptControl.open()}>
436 <Menu.ItemText>
437 <Trans>Verify account</Trans>
438 </Menu.ItemText>
439 <Menu.ItemIcon icon={CircleCheckIcon} />
440 </Menu.Item>
441 ))}
442 {!isSelf && (
443 <>
444 {!profile.viewer?.blocking &&
445 !profile.viewer?.mutedByList && (
446 <Menu.Item
447 testID="profileHeaderDropdownMuteBtn"
448 label={
449 profile.viewer?.muted
450 ? _(msg`Unmute account`)
451 : _(msg`Mute account`)
452 }
453 onPress={onPressMuteAccount}>
454 <Menu.ItemText>
455 {profile.viewer?.muted ? (
456 <Trans>Unmute account</Trans>
457 ) : (
458 <Trans>Mute account</Trans>
459 )}
460 </Menu.ItemText>
461 <Menu.ItemIcon
462 icon={profile.viewer?.muted ? Unmute : Mute}
463 />
464 </Menu.Item>
465 )}
466 {!profile.viewer?.blockingByList && (
467 <Menu.Item
468 testID="profileHeaderDropdownBlockBtn"
469 label={
470 profile.viewer
471 ? _(msg`Unblock account`)
472 : _(msg`Block account`)
473 }
474 onPress={() => blockPromptControl.open()}>
475 <Menu.ItemText>
476 {profile.viewer?.blocking ? (
477 <Trans>Unblock account</Trans>
478 ) : (
479 <Trans>Block account</Trans>
480 )}
481 </Menu.ItemText>
482 <Menu.ItemIcon
483 icon={
484 profile.viewer?.blocking ? PersonCheck : PersonX
485 }
486 />
487 </Menu.Item>
488 )}
489 <Menu.Item
490 testID="profileHeaderDropdownReportBtn"
491 label={_(msg`Report account`)}
492 onPress={onPressReportAccount}>
493 <Menu.ItemText>
494 <Trans>Report account</Trans>
495 </Menu.ItemText>
496 <Menu.ItemIcon icon={Flag} />
497 </Menu.Item>
498 </>
499 )}
500 </Menu.Group>
501 </>
502 )}
503 {devModeEnabled ? (
504 <>
505 <Menu.Divider />
506 <Menu.Group>
507 <Menu.Item
508 testID="profileHeaderDropdownShareATURIBtn"
509 label={_(msg`Copy at:// URI`)}
510 onPress={onPressShareATUri}>
511 <Menu.ItemText>
512 <Trans>Copy at:// URI</Trans>
513 </Menu.ItemText>
514 <Menu.ItemIcon icon={ClipboardIcon} />
515 </Menu.Item>
516 <Menu.Item
517 testID="profileHeaderDropdownShareDIDBtn"
518 label={_(msg`Copy DID`)}
519 onPress={onPressShareDID}>
520 <Menu.ItemText>
521 <Trans>Copy DID</Trans>
522 </Menu.ItemText>
523 <Menu.ItemIcon icon={ClipboardIcon} />
524 </Menu.Item>
525 </Menu.Group>
526 </>
527 ) : null}
528 </Menu.Outer>
529 </Menu.Root>
530
531 <StarterPackDialog
532 control={addToStarterPacksDialogControl}
533 targetDid={profile.did}
534 />
535
536 <ReportDialog
537 control={reportDialogControl}
538 subject={{
539 ...profile,
540 $type: 'app.bsky.actor.defs#profileViewDetailed',
541 }}
542 />
543
544 <Prompt.Basic
545 control={blockPromptControl}
546 title={
547 profile.viewer?.blocking
548 ? _(msg`Unblock Account?`)
549 : _(msg`Block Account?`)
550 }
551 description={
552 profile.viewer?.blocking
553 ? _(
554 msg`The account will be able to interact with you after unblocking.`,
555 )
556 : profile.associated?.labeler
557 ? _(
558 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.`,
559 )
560 : _(
561 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
562 )
563 }
564 onConfirm={blockAccount}
565 confirmButtonCta={
566 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
567 }
568 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
569 />
570
571 <Prompt.Basic
572 control={loggedOutWarningPromptControl}
573 title={_(msg`Note about sharing`)}
574 description={_(
575 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`,
576 )}
577 onConfirm={onPressShare}
578 confirmButtonCta={_(msg`Share anyway`)}
579 />
580
581 <VerificationCreatePrompt
582 control={verificationCreatePromptControl}
583 profile={profile}
584 />
585 <VerificationRemovePrompt
586 control={verificationRemovePromptControl}
587 profile={profile}
588 verifications={currentAccountVerifications}
589 />
590
591 {status.isActive ? (
592 <EditLiveDialog
593 control={goLiveDialogControl}
594 status={status}
595 embed={status.embed}
596 />
597 ) : (
598 <GoLiveDialog control={goLiveDialogControl} profile={profile} />
599 )}
600 </EventStopper>
601 )
602}
603
604ProfileMenu = memo(ProfileMenu)
605export {ProfileMenu}