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