feat: move the profile overflow menu into the sticky header
1234123486 unmodified lines91929394991001011029596979816 unmodified lines1151161171181271191201301311211221231341351241251261271391401281291301431311321331341354 unmodified lines140141142143156144145159160161162163164165146import type { ReactNode } from 'react';
import { ProfileMenu } from '#/screens/Profile/components/ProfileMenu';
import { SubscribeProfileButton } from '#/components/activity-notifications/SubscribeProfileButton';import * as Dialog from '#/components/Dialog';import { MessageProfileButton } from '#/components/dms/MessageProfileButton';import * as Prompt from '#/components/Prompt';86 unmodified lines </> );}
/** * The profile header action row, selected by the viewer's relationship to the profile. The profile menu is * always present; the relationship discriminant decides what precedes it. */export function ProfileHeaderActions() { const { meta: { hasSession, moderationOpts, relationship }, state: { profile },16 unmodified lines break; } }
let relationshipActions: ReactNode = null; switch (relationship) { case 'self': { relationshipActions = <EditProfileButton />; break; return <EditProfileButton />; } case 'blocking': { relationshipActions = <UnblockButton />; break; return <UnblockButton />; } case 'blocked-by': case 'blocking-by-list': { relationshipActions = null; break; return null; } case 'default': { relationshipActions = ( return ( <> {hasSession && ( <> {subscriptionsAllowed && (4 unmodified lines )} <FollowButton /> </> ); break; } }
return ( <> {relationshipActions} <ProfileMenu profile={profile} /> </> );}1 unmodified line2345678910111213141515 unmodified lines3132333429353637383915 unmodified lines55565758535459606162636417 unmodified lines1 unmodified line
import { colors } from '#/styles/colors';import { borderRadius, space } from '#/styles/tokens.css';
const AVATAR_SIZE = 94;
const AVATAR_LIFT = 46;
export const AVATAR_OVERHANG = AVATAR_SIZE - AVATAR_LIFT;
export const frame = style({ position: 'relative', backgroundColor: colors.bg,});15 unmodified linesexport const avatarBox = style({ display: 'block', position: 'absolute', // straddle the banner's bottom edge top: -46, top: -AVATAR_LIFT, left: 10,});
export const avatarButton = style([15 unmodified lines borderStyle: 'solid', borderRadius: borderRadius.full, borderColor: colors.bg, backgroundColor: colors.bg, width: 94, height: 94, width: AVATAR_SIZE, height: AVATAR_SIZE,});
export const avatarRingLive = style({ borderWidth: 3,17 unmodified lines39 unmodified lines404142434444454647483 unmodified lines39 unmodified lines});
export const content = style([reveal, { minWidth: 0 }]);
export const backButton = style([export const action = style([ { selectors: { '&:hover:not(:disabled)': { boxShadow: 'inset 0 0 0 999px rgb(from currentColor r g b / 0.15)' }, },3 unmodified lines12345678910111213141516171819202122231624252627283 unmodified lines3233343536373839404142323334353637383940414243444543444546474849505152535455565758596061626364656667686970717273747576777879808182838485import type { Ref } from 'react';
import type { AppBskyActorDefs } from '@atcute/bluesky';
import type { Shadow } from '#/state/cache/types';
import { formatCount } from '#/locale/intl/number';
import { ProfileMenu } from '#/screens/Profile/components/ProfileMenu';
import { Text } from '#/components/Text';import { Button, ButtonIcon } from '#/components/web/Button';import * as Layout from '#/components/web/Layout';
import Ellipsis from '#/icons/central/DotGrid1x3Horizontal_round_outlined_radius1_stroke2.svg';import SearchIcon from '#/icons/central/MagnifyingGlass_round_outlined_radius1_stroke2.svg';import { m } from '#/paraglide/messages';import { useRouter } from '#/router';
import * as css from './Sticky.css';
interface Props { isPlaceholderProfile: boolean; profile: AppBskyActorDefs.ProfileViewDetailed; profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>; ref?: Ref<HTMLDivElement>;}
/**3 unmodified lines * @param profile displayed profile * @param ref header ref */export function ProfileStickyHeader({ isPlaceholderProfile, profile, ref }: Props) { const router = useRouter(); const postsCount = profile.postsCount || 0;
return ( <div className={css.outer} ref={ref}> <Layout.Header.BackButton className={css.action} variant="scrim" /> {/* keeps trailing controls aligned while loading */} <Layout.Header.BackButton className={css.backButton} variant="scrim" /> {!isPlaceholderProfile && ( <Layout.Header.Content className={css.content}> <Text numberOfLines={1} size="lg" weight="bold"> {profile.handle} </Text> <Text color="textContrastMedium" numberOfLines={1} size="sm"> {m['screens.profile.posts.count']({ count: postsCount, formatted: formatCount(postsCount), })} </Text> </Layout.Header.Content> )} <Layout.Header.Content className={css.content}> {!isPlaceholderProfile && ( <> <Text numberOfLines={1} size="lg" weight="bold"> {profile.handle} </Text> <Text color="textContrastMedium" numberOfLines={1} size="sm"> {m['screens.profile.posts.count']({ count: postsCount, formatted: formatCount(postsCount), })} </Text> </> )} </Layout.Header.Content> <Layout.Header.EndSlot> <Button className={css.action} label={m['view.profile.action.searchPosts']()} variant="scrim" shape="round" onClick={() => router.navigate({ to: { name: 'ProfileSearch', actor: profile.did } })} > <ButtonIcon icon={SearchIcon} size="lg" /> </Button> <ProfileMenu profile={profile} render={ <Button className={css.action} label={m['common.a11y.moreOptions']()} variant="scrim" shape="round" > <ButtonIcon icon={Ellipsis} size="lg" /> </Button> } /> </Layout.Header.EndSlot> <Layout.ScrollAway.Backdrop /> </div> );}12345678910611121314154 unmodified lines2021222319242526272829303133 unmodified linesimport { style } from '@vanilla-extract/css';
import { space } from '#/styles/tokens.css';
import { AVATAR_OVERHANG } from './Shell.css';
const bodyPaddingTop = space.md;const buttonRowPaddingBottom = space.sm;
export const body = style({ paddingTop: space.md, paddingTop: bodyPaddingTop, paddingBottom: space.sm, paddingInline: space.lg, overflow: 'hidden',});4 unmodified lines flexWrap: 'wrap', gap: space.xs, alignItems: 'center', justifyContent: 'flex-end', paddingBottom: space.sm, paddingBottom: buttonRowPaddingBottom, paddingLeft: 90, // keep the name below the avatar minHeight: AVATAR_OVERHANG - bodyPaddingTop - buttonRowPaddingBottom,});
export const nameBlock = style({ display: 'flex',33 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422import type { ReactNode } from 'react';
import type { AppBskyActorDefs } from '@atcute/bluesky';
import { useQueryClient } from '@tanstack/react-query';
import { isAbortError } from '#/lib/errors';import { targetToShareUrl } from '#/lib/routes/app-links';import { profileTarget } from '#/lib/routes/targets';
import type { Shadow } from '#/state/cache/types';import { useDevMode } from '#/state/preferences/dev-mode';import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, useProfileFollowMutationQueue, useProfileMuteMutationQueue, useProfileMuteRepostsMutationQueue,} from '#/state/queries/profile';import { useSession } from '#/state/session';
import { EditLiveDialog } from '#/features/liveNow/components/EditLiveDialog';import { GoLiveDialog } from '#/features/liveNow/components/GoLiveDialog';import { GoLiveDisabledDialog } from '#/features/liveNow/components/GoLiveDisabledDialog';import { useActorStatus, useLiveNowConfig } from '#/features/liveNow/use-actor-status';
import * as Dialog from '#/components/Dialog';import { UserAddRemoveListsDialog } from '#/components/dialogs/lists/UserAddRemoveListsDialog';import { StarterPackDialog } from '#/components/dialogs/StarterPackDialog';import * as Menu from '#/components/Menu';import { BlockAccountPrompt } from '#/components/moderation/block-account-prompt';import { MuteAccountPrompt } from '#/components/moderation/mute-account-prompt';import { ReportDialog } from '#/components/moderation/ReportDialog';import * as Prompt from '#/components/Prompt';import { shareText, shareUrl } from '#/components/sharing';import * as Toast from '#/components/Toast';import { Button, ButtonIcon } from '#/components/web/Button';
import Repost from '#/icons/central/ArrowsRepeatRightLeft_round_outlined_radius1_stroke2.svg';import RepostOff from '#/icons/central/ArrowsRepeatRightLeftOff_round_outlined_radius1_stroke2.svg';import ChainLinkIcon from '#/icons/central/ChainLink3_round_outlined_radius1_stroke2.svg';import ClipboardIcon from '#/icons/central/Clipboard_round_outlined_radius1_stroke2.svg';import Ellipsis from '#/icons/central/DotGrid1x3Horizontal_round_outlined_radius1_stroke2.svg';import Flag from '#/icons/central/Flag1_round_outlined_radius1_stroke2.svg';import ListAdd from '#/icons/central/ListAdd_round_outlined_radius1_stroke2.svg';import LiveIcon from '#/icons/central/LiveFull_round_outlined_radius1_stroke2.svg';import SearchIcon from '#/icons/central/MagnifyingGlass_round_outlined_radius1_stroke2.svg';import Mute from '#/icons/central/Mute_round_outlined_radius1_stroke2.svg';import PersonCheck from '#/icons/central/PeopleAdded_round_outlined_radius1_stroke2.svg';import UserMinus from '#/icons/central/PeopleRemove2_round_outlined_radius1_stroke2.svg';import PersonX from '#/icons/central/PeopleRemove_round_outlined_radius1_stroke2.svg';import Unmute from '#/icons/central/VolumeFull_round_outlined_radius1_stroke2.svg';import StarterPack from '#/icons/original/StarterPack.svg';import { m } from '#/paraglide/messages';import { useRouter } from '#/router';
function ProfileMenu({ profile }: { profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> }): ReactNode { const { currentAccount, hasSession } = useSession(); const reportDialogHandle = Dialog.useDialogHandle(); const addToListsDialogHandle = Dialog.useDialogHandle(); const queryClient = useQueryClient(); const router = useRouter(); const isSelf = currentAccount?.did === profile.did; const isFollowing = profile.viewer?.following; const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy; const isFollowingBlockedAccount = isFollowing && isBlocked; const devModeEnabled = useDevMode(); const { canGoLive } = useLiveNowConfig(); const status = useActorStatus(profile);
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile); const [queueMuteReposts, queueUnmuteReposts] = useProfileMuteRepostsMutationQueue(profile); const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile); const [, queueUnfollow] = useProfileFollowMutationQueue(profile);
const blockPromptHandle = Prompt.usePromptHandle(); const mutePromptHandle = Prompt.usePromptHandle(); const loggedOutWarningPromptHandle = Prompt.usePromptHandle(); const goLiveDialogHandle = Dialog.useDialogHandle(); const goLiveDisabledDialogHandle = Dialog.useDialogHandle(); const addToStarterPacksDialogHandle = Dialog.useDialogHandle();
const showLoggedOutWarning = profile.did !== currentAccount?.did && !!profile.labels?.find((label) => label.val === '!no-unauthenticated');
const invalidateProfileQuery = () => { void queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), }); };
const onPressAddToStarterPacks = () => { addToStarterPacksDialogHandle.open(null); };
const onPressShare = () => { void shareUrl(targetToShareUrl(profileTarget(profile.did))); };
const onPressMuteAccount = async () => { if (profile.viewer?.muted) { try { await queueUnmute(); Toast.show(m['common.mute.unmutedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueMute(); Toast.show(m['common.mute.mutedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const onPressMuteReposts = async () => { if (profile.viewer?.mutedOnlyReposts) { try { await queueUnmuteReposts(); Toast.show(m['common.mute.repostsShownToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueMuteReposts(); Toast.show(m['common.mute.repostsHiddenToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const blockAccount = async () => { if (profile.viewer?.blocking) { try { await queueUnblock(); Toast.show(m['common.block.unblockedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueBlock(); Toast.show(m['common.block.blockedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const onPressUnfollowAccount = async () => { try { await queueUnfollow(); Toast.show(m['view.profile.follow.unfollowedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } };
const onPressReportAccount = () => { reportDialogHandle.open(null); };
const onPressShareATUri = () => { void shareText(`at://${profile.did}`); };
const onPressShareDID = () => { void shareText(profile.did); };
const onPressSearch = () => { router.navigate({ to: { name: 'ProfileSearch', actor: profile.did } }); };
return ( <> <Menu.Root> <Menu.Trigger render={ <Button label={m['common.a11y.moreOptions']()} variant="solid" color="secondary" size="small" shape="round" > <ButtonIcon icon={Ellipsis} size="sm" /> </Button> } />
<Menu.Popup label={m['common.a11y.moreOptions']()} align="end" minWidth={170}> <Menu.Group> <Menu.Item label={m['view.profile.sharing.action.copyLink']()} onClick={() => { if (showLoggedOutWarning) { loggedOutWarningPromptHandle.open(null); } else { onPressShare(); } }} > <Menu.ItemText>{m['view.profile.sharing.action.copyLink']()}</Menu.ItemText> <Menu.ItemIcon icon={ChainLinkIcon} /> </Menu.Item> <Menu.Item label={m['view.profile.action.searchPosts']()} onClick={onPressSearch}> <Menu.ItemText>{m['view.profile.action.searchPosts']()}</Menu.ItemText> <Menu.ItemIcon icon={SearchIcon} /> </Menu.Item> </Menu.Group>
{hasSession && ( <> <Menu.Separator /> <Menu.Group> {!isSelf && isFollowingBlockedAccount && ( <Menu.Item label={m['view.profile.follow.action.unfollow']()} onClick={() => void onPressUnfollowAccount()} > <Menu.ItemText>{m['view.profile.follow.action.unfollow']()}</Menu.ItemText> <Menu.ItemIcon icon={UserMinus} /> </Menu.Item> )} <Menu.Item label={m['common.starterPack.action.add']()} onClick={onPressAddToStarterPacks}> <Menu.ItemText>{m['common.starterPack.action.add']()}</Menu.ItemText> <Menu.ItemIcon icon={StarterPack} /> </Menu.Item> <Menu.Item label={m['view.profile.list.add']()} onClick={() => addToListsDialogHandle.open(null)} > <Menu.ItemText>{m['view.profile.list.add']()}</Menu.ItemText> <Menu.ItemIcon icon={ListAdd} /> </Menu.Item> {isSelf && canGoLive && ( <Menu.Item label={ status.isDisabled ? m['view.profile.liveStatus.action.goLiveDisabled']() : status.isActive ? m['view.profile.liveStatus.action.edit']() : m['features.liveNow.goLive.confirm']() } onClick={() => { if (status.isDisabled) { goLiveDisabledDialogHandle.open(null); } else { goLiveDialogHandle.open(null); } }} > <Menu.ItemText> {status.isDisabled ? m['view.profile.liveStatus.action.goLiveDisabled']() : status.isActive ? m['view.profile.liveStatus.action.edit']() : m['features.liveNow.goLive.confirm']()} </Menu.ItemText> <Menu.ItemIcon icon={LiveIcon} /> </Menu.Item> )} {!isSelf && ( <> {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( <> {/* a full mute already hides the reposts, so the narrower scope has nothing left to offer */} {!profile.viewer?.muted && (profile.viewer?.following || profile.viewer?.mutedOnlyReposts) && ( <Menu.Item label={ profile.viewer?.mutedOnlyReposts ? m['common.mute.action.showReposts']() : m['common.mute.action.hideReposts']() } onClick={() => void onPressMuteReposts()} > <Menu.ItemText> {profile.viewer?.mutedOnlyReposts ? m['common.mute.action.showReposts']() : m['common.mute.action.hideReposts']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.mutedOnlyReposts ? Repost : RepostOff} /> </Menu.Item> )} <Menu.Item label={ profile.viewer?.muted ? m['common.mute.action.unmuteAccount']() : m['common.mute.action.muteAccount']() } onClick={() => mutePromptHandle.open(null)} > <Menu.ItemText> {profile.viewer?.muted ? m['common.mute.action.unmuteAccount']() : m['common.mute.action.muteAccount']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.muted ? Unmute : Mute} /> </Menu.Item> </> )} {!profile.viewer?.blockingByList && ( <Menu.Item label={ profile.viewer ? m['common.block.action.unblockAccount']() : m['common.block.action.blockAccount']() } onClick={() => blockPromptHandle.open(null)} > <Menu.ItemText> {profile.viewer?.blocking ? m['common.block.action.unblockAccount']() : m['common.block.action.blockAccount']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.blocking ? PersonCheck : PersonX} /> </Menu.Item> )} <Menu.Item label={m['view.profile.action.report']()} onClick={onPressReportAccount}> <Menu.ItemText>{m['view.profile.action.report']()}</Menu.ItemText> <Menu.ItemIcon icon={Flag} /> </Menu.Item> </> )} </Menu.Group> </> )} {devModeEnabled ? ( <> <Menu.Separator /> <Menu.Group> <Menu.Item label={m['view.profile.sharing.action.copyUri']()} onClick={onPressShareATUri}> <Menu.ItemText>{m['view.profile.sharing.action.copyUri']()}</Menu.ItemText> <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> <Menu.Item label={m['view.profile.sharing.action.copyDid']()} onClick={onPressShareDID}> <Menu.ItemText>{m['view.profile.sharing.action.copyDid']()}</Menu.ItemText> <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> </Menu.Group> </> ) : null} </Menu.Popup> </Menu.Root> <StarterPackDialog handle={addToStarterPacksDialogHandle} targetDid={profile.did} /> <UserAddRemoveListsDialog handle={addToListsDialogHandle} profile={profile} onChange={invalidateProfileQuery} /> <ReportDialog handle={reportDialogHandle} subject={{ $type: 'app.bsky.actor.defs#profileViewDetailed', ...profile, }} /> <BlockAccountPrompt handle={blockPromptHandle} isBlocking={!!profile.viewer?.blocking} isLabeler={!!profile.associated?.labeler} onConfirm={() => void blockAccount()} /> <MuteAccountPrompt handle={mutePromptHandle} isMuted={!!profile.viewer?.muted} onConfirm={() => void onPressMuteAccount()} /> <Prompt.Basic handle={loggedOutWarningPromptHandle} title={m['view.profile.sharing.note']()} description={m['view.profile.sharing.loggedInOnly.message']()} onConfirm={onPressShare} confirmButtonCta={m['view.profile.sharing.loggedInOnly.confirm']()} /> {status.isDisabled ? ( <GoLiveDisabledDialog handle={goLiveDisabledDialogHandle} status={status} /> ) : status.isActive && status.embed ? ( <EditLiveDialog embed={status.embed} handle={goLiveDialogHandle} status={status} /> ) : ( <GoLiveDialog handle={goLiveDialogHandle} profile={profile} /> )} </> );}
export { ProfileMenu };123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393import type { ReactNode } from 'react';
import type { AppBskyActorDefs } from '@atcute/bluesky';
import { useQueryClient } from '@tanstack/react-query';
import { isAbortError } from '#/lib/errors';import { targetToShareUrl } from '#/lib/routes/app-links';import { profileTarget } from '#/lib/routes/targets';
import type { Shadow } from '#/state/cache/types';import { useDevMode } from '#/state/preferences/dev-mode';import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, useProfileFollowMutationQueue, useProfileMuteMutationQueue, useProfileMuteRepostsMutationQueue,} from '#/state/queries/profile';import { useSession } from '#/state/session';
import { EditLiveDialog } from '#/features/liveNow/components/EditLiveDialog';import { GoLiveDialog } from '#/features/liveNow/components/GoLiveDialog';import { GoLiveDisabledDialog } from '#/features/liveNow/components/GoLiveDisabledDialog';import { useActorStatus, useLiveNowConfig } from '#/features/liveNow/use-actor-status';
import * as Dialog from '#/components/Dialog';import { UserAddRemoveListsDialog } from '#/components/dialogs/lists/UserAddRemoveListsDialog';import { StarterPackDialog } from '#/components/dialogs/StarterPackDialog';import * as Menu from '#/components/Menu';import { BlockAccountPrompt } from '#/components/moderation/block-account-prompt';import { MuteAccountPrompt } from '#/components/moderation/mute-account-prompt';import { ReportDialog } from '#/components/moderation/ReportDialog';import * as Prompt from '#/components/Prompt';import { shareText, shareUrl } from '#/components/sharing';import * as Toast from '#/components/Toast';
import Repost from '#/icons/central/ArrowsRepeatRightLeft_round_outlined_radius1_stroke2.svg';import RepostOff from '#/icons/central/ArrowsRepeatRightLeftOff_round_outlined_radius1_stroke2.svg';import ChainLinkIcon from '#/icons/central/ChainLink3_round_outlined_radius1_stroke2.svg';import ClipboardIcon from '#/icons/central/Clipboard_round_outlined_radius1_stroke2.svg';import Flag from '#/icons/central/Flag1_round_outlined_radius1_stroke2.svg';import ListAdd from '#/icons/central/ListAdd_round_outlined_radius1_stroke2.svg';import LiveIcon from '#/icons/central/LiveFull_round_outlined_radius1_stroke2.svg';import Mute from '#/icons/central/Mute_round_outlined_radius1_stroke2.svg';import PersonCheck from '#/icons/central/PeopleAdded_round_outlined_radius1_stroke2.svg';import UserMinus from '#/icons/central/PeopleRemove2_round_outlined_radius1_stroke2.svg';import PersonX from '#/icons/central/PeopleRemove_round_outlined_radius1_stroke2.svg';import Unmute from '#/icons/central/VolumeFull_round_outlined_radius1_stroke2.svg';import StarterPack from '#/icons/original/StarterPack.svg';import { m } from '#/paraglide/messages';
function ProfileMenuItems({ profile }: { profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> }): ReactNode { const { currentAccount, hasSession } = useSession(); const reportDialogHandle = Dialog.useDialogHandle(); const addToListsDialogHandle = Dialog.useDialogHandle(); const queryClient = useQueryClient(); const isSelf = currentAccount?.did === profile.did; const isFollowing = profile.viewer?.following; const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy; const isFollowingBlockedAccount = isFollowing && isBlocked; const devModeEnabled = useDevMode(); const { canGoLive } = useLiveNowConfig(); const status = useActorStatus(profile);
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile); const [queueMuteReposts, queueUnmuteReposts] = useProfileMuteRepostsMutationQueue(profile); const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile); const [, queueUnfollow] = useProfileFollowMutationQueue(profile);
const blockPromptHandle = Prompt.usePromptHandle(); const mutePromptHandle = Prompt.usePromptHandle(); const loggedOutWarningPromptHandle = Prompt.usePromptHandle(); const goLiveDialogHandle = Dialog.useDialogHandle(); const goLiveDisabledDialogHandle = Dialog.useDialogHandle(); const addToStarterPacksDialogHandle = Dialog.useDialogHandle();
const showLoggedOutWarning = profile.did !== currentAccount?.did && !!profile.labels?.find((label) => label.val === '!no-unauthenticated');
const invalidateProfileQuery = () => { void queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), }); };
const onPressAddToStarterPacks = () => { addToStarterPacksDialogHandle.open(null); };
const onPressShare = () => { void shareUrl(targetToShareUrl(profileTarget(profile.did))); };
const onPressMuteAccount = async () => { if (profile.viewer?.muted) { try { await queueUnmute(); Toast.show(m['common.mute.unmutedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueMute(); Toast.show(m['common.mute.mutedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const onPressMuteReposts = async () => { if (profile.viewer?.mutedOnlyReposts) { try { await queueUnmuteReposts(); Toast.show(m['common.mute.repostsShownToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueMuteReposts(); Toast.show(m['common.mute.repostsHiddenToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const blockAccount = async () => { if (profile.viewer?.blocking) { try { await queueUnblock(); Toast.show(m['common.block.unblockedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } else { try { await queueBlock(); Toast.show(m['common.block.blockedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } } };
const onPressUnfollowAccount = async () => { try { await queueUnfollow(); Toast.show(m['view.profile.follow.unfollowedToast']()); } catch (e) { if (!isAbortError(e)) { Toast.show(m['common.error.issueWithDetail']({ error: String(e) }), { type: 'error', }); } } };
const onPressReportAccount = () => { reportDialogHandle.open(null); };
const onPressShareATUri = () => { void shareText(`at://${profile.did}`); };
const onPressShareDID = () => { void shareText(profile.did); };
return ( <> <Menu.Popup label={m['common.a11y.moreOptions']()} align="end" minWidth={170}> <Menu.Group> <Menu.Item label={m['view.profile.sharing.action.copyLink']()} onClick={() => { if (showLoggedOutWarning) { loggedOutWarningPromptHandle.open(null); } else { onPressShare(); } }} > <Menu.ItemText>{m['view.profile.sharing.action.copyLink']()}</Menu.ItemText> <Menu.ItemIcon icon={ChainLinkIcon} /> </Menu.Item> </Menu.Group>
{hasSession && ( <> <Menu.Separator /> <Menu.Group> {!isSelf && isFollowingBlockedAccount && ( <Menu.Item label={m['view.profile.follow.action.unfollow']()} onClick={() => void onPressUnfollowAccount()} > <Menu.ItemText>{m['view.profile.follow.action.unfollow']()}</Menu.ItemText> <Menu.ItemIcon icon={UserMinus} /> </Menu.Item> )} <Menu.Item label={m['common.starterPack.action.add']()} onClick={onPressAddToStarterPacks}> <Menu.ItemText>{m['common.starterPack.action.add']()}</Menu.ItemText> <Menu.ItemIcon icon={StarterPack} /> </Menu.Item> <Menu.Item label={m['view.profile.list.add']()} onClick={() => addToListsDialogHandle.open(null)} > <Menu.ItemText>{m['view.profile.list.add']()}</Menu.ItemText> <Menu.ItemIcon icon={ListAdd} /> </Menu.Item> {isSelf && canGoLive && ( <Menu.Item label={ status.isDisabled ? m['view.profile.liveStatus.action.goLiveDisabled']() : status.isActive ? m['view.profile.liveStatus.action.edit']() : m['features.liveNow.goLive.confirm']() } onClick={() => { if (status.isDisabled) { goLiveDisabledDialogHandle.open(null); } else { goLiveDialogHandle.open(null); } }} > <Menu.ItemText> {status.isDisabled ? m['view.profile.liveStatus.action.goLiveDisabled']() : status.isActive ? m['view.profile.liveStatus.action.edit']() : m['features.liveNow.goLive.confirm']()} </Menu.ItemText> <Menu.ItemIcon icon={LiveIcon} /> </Menu.Item> )} {!isSelf && ( <> {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( <> {/* a full mute already hides the reposts, so the narrower scope has nothing left to offer */} {!profile.viewer?.muted && (profile.viewer?.following || profile.viewer?.mutedOnlyReposts) && ( <Menu.Item label={ profile.viewer?.mutedOnlyReposts ? m['common.mute.action.showReposts']() : m['common.mute.action.hideReposts']() } onClick={() => void onPressMuteReposts()} > <Menu.ItemText> {profile.viewer?.mutedOnlyReposts ? m['common.mute.action.showReposts']() : m['common.mute.action.hideReposts']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.mutedOnlyReposts ? Repost : RepostOff} /> </Menu.Item> )} <Menu.Item label={ profile.viewer?.muted ? m['common.mute.action.unmuteAccount']() : m['common.mute.action.muteAccount']() } onClick={() => mutePromptHandle.open(null)} > <Menu.ItemText> {profile.viewer?.muted ? m['common.mute.action.unmuteAccount']() : m['common.mute.action.muteAccount']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.muted ? Unmute : Mute} /> </Menu.Item> </> )} {!profile.viewer?.blockingByList && ( <Menu.Item label={ profile.viewer ? m['common.block.action.unblockAccount']() : m['common.block.action.blockAccount']() } onClick={() => blockPromptHandle.open(null)} > <Menu.ItemText> {profile.viewer?.blocking ? m['common.block.action.unblockAccount']() : m['common.block.action.blockAccount']()} </Menu.ItemText> <Menu.ItemIcon icon={profile.viewer?.blocking ? PersonCheck : PersonX} /> </Menu.Item> )} <Menu.Item label={m['view.profile.action.report']()} onClick={onPressReportAccount}> <Menu.ItemText>{m['view.profile.action.report']()}</Menu.ItemText> <Menu.ItemIcon icon={Flag} /> </Menu.Item> </> )} </Menu.Group> </> )} {devModeEnabled ? ( <> <Menu.Separator /> <Menu.Group> <Menu.Item label={m['view.profile.sharing.action.copyUri']()} onClick={onPressShareATUri}> <Menu.ItemText>{m['view.profile.sharing.action.copyUri']()}</Menu.ItemText> <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> <Menu.Item label={m['view.profile.sharing.action.copyDid']()} onClick={onPressShareDID}> <Menu.ItemText>{m['view.profile.sharing.action.copyDid']()}</Menu.ItemText> <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> </Menu.Group> </> ) : null} </Menu.Popup> <StarterPackDialog handle={addToStarterPacksDialogHandle} targetDid={profile.did} /> <UserAddRemoveListsDialog handle={addToListsDialogHandle} profile={profile} onChange={invalidateProfileQuery} /> <ReportDialog handle={reportDialogHandle} subject={{ $type: 'app.bsky.actor.defs#profileViewDetailed', ...profile, }} /> <BlockAccountPrompt handle={blockPromptHandle} isBlocking={!!profile.viewer?.blocking} isLabeler={!!profile.associated?.labeler} onConfirm={() => void blockAccount()} /> <MuteAccountPrompt handle={mutePromptHandle} isMuted={!!profile.viewer?.muted} onConfirm={() => void onPressMuteAccount()} /> <Prompt.Basic handle={loggedOutWarningPromptHandle} title={m['view.profile.sharing.note']()} description={m['view.profile.sharing.loggedInOnly.message']()} onConfirm={onPressShare} confirmButtonCta={m['view.profile.sharing.loggedInOnly.confirm']()} /> {status.isDisabled ? ( <GoLiveDisabledDialog handle={goLiveDisabledDialogHandle} status={status} /> ) : status.isActive && status.embed ? ( <EditLiveDialog embed={status.embed} handle={goLiveDialogHandle} status={status} /> ) : ( <GoLiveDialog handle={goLiveDialogHandle} profile={profile} /> )} </> );}
export { ProfileMenuItems };1234567891011121314151617181920212223242526272829303132333435363738import { type ReactElement, type ReactNode, useState } from 'react';
import type { AppBskyActorDefs } from '@atcute/bluesky';
import type { Shadow } from '#/state/cache/types';
import * as Menu from '#/components/Menu';
import { ProfileMenuItems } from './ProfileMenuItems';
/** * profile overflow menu * * @param profile menu subject * @param render trigger element */export function ProfileMenu({ profile, render,}: { profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>; render: ReactElement;}): ReactNode { const [hasBeenOpen, setHasBeenOpen] = useState(false);
return ( <Menu.Root onOpenChange={(open) => { if (open) { setHasBeenOpen(true); } }} > <Menu.Trigger render={render} /> {hasBeenOpen && <ProfileMenuItems profile={profile} />} </Menu.Root> );}