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