// SPDX-License-Identifier: AGPL-3.0-or-later import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as Modal from '@app/features/app/components/dialogs/Modal'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import Authentication from '@app/features/auth/state/Authentication'; import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands'; import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import { BLOCKED_USER_DM_WARNING_DESCRIPTOR, COPY_USER_ID_DESCRIPTOR, COPY_USERNAME_DESCRIPTOR, OPEN_DM_DESCRIPTOR, START_VIDEO_CALL_DESCRIPTOR, START_VOICE_CALL_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import GuildMembers from '@app/features/member/state/GuildMembers'; import { BLOCK_DESCRIPTOR, REPORT_USER_PROFILE_DESCRIPTOR, } from '@app/features/moderation/utils/ModerationMessageDescriptors'; import {openReportUserProfileModal} from '@app/features/moderation/utils/ReportActionUtils'; import {Logger} from '@app/features/platform/utils/AppLogger'; import MemberPresenceSubscription from '@app/features/presence/state/MemberPresenceSubscription'; import Relationships from '@app/features/relationship/state/Relationships'; import * as RelationshipActionUtils from '@app/features/relationship/utils/RelationshipActionUtils'; import { ACCEPT_FRIEND_REQUEST_DESCRIPTOR, ADD_FRIEND_DESCRIPTOR, CANCEL_FRIEND_REQUEST_DESCRIPTOR, REMOVE_FRIEND_DESCRIPTOR, UNBLOCK_USER_ACTION_DESCRIPTOR, } from '@app/features/relationship/utils/RelationshipMessageDescriptors'; import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils'; import type {ContextMenuActionEvent} from '@app/features/ui/action_menu/ContextMenu'; import { getUserMenuAvatarUrl, getUserMenuBannerUrl, UserImageMenuItems, } from '@app/features/ui/action_menu/items/UserImageMenuItems'; import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; import {Button} from '@app/features/ui/button/Button'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; import ContextMenu from '@app/features/ui/state/ContextMenu'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import userProfileModalStyles from '@app/features/user/components/modals/UserProfileModal.module.css'; import {ProfileModalContent} from '@app/features/user/components/modals/user_profile_modal/ProfileModalContent'; import {UserProfileLoadingSkeleton} from '@app/features/user/components/profile/UserProfileLoadingSkeleton'; import {useUserProfileSurfaceState} from '@app/features/user/hooks/useUserProfileSurfaceState'; import type {Profile} from '@app/features/user/models/Profile'; import {User} from '@app/features/user/models/User'; import UserNote from '@app/features/user/state/UserNote'; import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import type * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils'; import { getProfileMembershipDisplayName, resolveProfileGuildMembership, toProfileDisplayContext, } from '@app/features/user/utils/ProfileGuildMembership'; import {createMockProfile} from '@app/features/user/utils/ProfileUtils'; import * as CallUtils from '@app/features/voice/utils/CallUtils'; import {hasActiveDirectCallWithUser} from '@app/features/voice/utils/PrivateCallMenuUtils'; import {PublicUserFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import { ChatTeardropIcon, CheckCircleIcon, ClockCounterClockwiseIcon, CopyIcon, DotsThreeIcon, FlagIcon, GlobeIcon, IdentificationCardIcon, PencilIcon, PhoneIcon, ProhibitIcon, UserMinusIcon, UserPlusIcon, VideoCameraIcon, } from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const USER_PROFILE_DESCRIPTOR = msg({ message: 'User profile', comment: 'Short label in the user profile modal. Keep it concise.', }); const USER_PROFILE_2_DESCRIPTOR = msg({ message: 'User profile: {tag}', comment: 'Short label in the user profile modal. Keep it concise. Preserve {tag}; it is inserted by code.', }); const VIEW_COMMUNITY_PROFILE_DESCRIPTOR = msg({ message: 'View community profile', comment: 'Button or menu action label in the user profile modal. Keep it concise.', }); const VIEW_GLOBAL_PROFILE_DESCRIPTOR = msg({ message: 'View global profile', comment: 'Button or menu action label in the user profile modal. Keep it concise.', }); const YOU_CAN_T_BEFRIEND_YOURSELF_DESCRIPTOR = msg({ message: "You can't befriend yourself", comment: 'Error message in the user profile modal.', }); const YOU_CAN_T_MESSAGE_YOURSELF_DESCRIPTOR = msg({ message: "You can't message yourself", comment: 'Error message in the user profile modal.', }); const MORE_ACTIONS_DESCRIPTOR = msg({ message: 'More actions', comment: 'Short label in the user profile modal. Keep it concise.', }); const UNBLOCK_USER_DESCRIPTOR = msg({ message: 'Unblock user', comment: 'Button or menu action label in the user profile modal. Keep it concise. Keep the tone plain and specific.', }); const CLAIM_YOUR_ACCOUNT_TO_SEND_FRIEND_REQUESTS_DESCRIPTOR = msg({ message: 'Claim your account to send friend requests.', comment: 'Description text in the user profile modal.', }); const SEND_FRIEND_REQUEST_DESCRIPTOR = msg({ message: 'Send friend request', comment: 'Button or menu action label in the user profile modal. Keep it concise.', }); const logger = new Logger('UserProfileModal'); interface UserProfileModalProps { userId: string; guildId?: string; autoFocusNote?: boolean; disableEditProfile?: boolean; previewOverrides?: ProfileDisplayUtils.ProfilePreviewOverrides; previewUser?: User; previewProfile?: Profile; } type UserProfileModalComponent = React.FC; export const UserProfileModal: UserProfileModalComponent = observer( ({userId, guildId, autoFocusNote, disableEditProfile, previewOverrides, previewUser, previewProfile}) => { const {i18n} = useLingui(); const storeUser = Users.getUser(userId); const user = previewUser ?? storeUser; const isPreview = previewUser !== undefined || previewProfile !== undefined; const fallbackUser = useMemo( () => new User({ id: userId, username: userId, discriminator: '0000', global_name: null, avatar: null, avatar_color: null, flags: 0, }), [userId], ); const displayUser = user ?? fallbackUser; const fallbackProfile = useMemo(() => createMockProfile(fallbackUser), [fallbackUser]); const mockProfile = useMemo(() => (user ? createMockProfile(user) : null), [user]); const profileFallback = previewProfile ?? mockProfile ?? fallbackProfile; const handleProfileLoadError = useCallback((error: unknown) => { logger.error('Failed to fetch user profile:', error); }, []); const {profile, profileLoadError, showProfileSkeleton} = useUserProfileSurfaceState({ userId, guildId, enabled: !isPreview, fallbackProfile: profileFallback, onError: handleProfileLoadError, }); const [showGlobalProfile, setShowGlobalProfile] = useState(false); const userNote = UserNote.getUserNote(userId); const isCurrentUser = user?.id === Authentication.currentUserId; const relationship = Relationships.getRelationship(userId); const relationshipType = relationship?.type; const isBlocked = relationshipType === RelationshipTypes.BLOCKED; const hasActiveDirectCall = hasActiveDirectCallWithUser(userId); const isUserBot = user?.bot ?? false; const isFriendlyBot = isUserBot && (displayUser.flags & PublicUserFlags.FRIENDLY_BOT) === PublicUserFlags.FRIENDLY_BOT; const noteRef = useRef(null); const moreOptionsButtonRef = useRef(null); useEffect(() => { if (!guildId || !userId || isPreview) { return; } const hasMember = GuildMembers.getMember(guildId, userId); if (!hasMember) { MemberPresenceSubscription.touchMember(guildId, userId); GuildMembers.fetchMembers(guildId, {userIds: [userId]}).catch((error) => { logger.error('Failed to fetch guild member:', error); }); } else { MemberPresenceSubscription.touchMember(guildId, userId); } return () => { MemberPresenceSubscription.unsubscribe(guildId, userId); }; }, [guildId, userId, isPreview]); const hasGuildProfile = !!(profile?.guildId && profile?.guildMemberProfile); const shouldShowProfileDataWarning = !showProfileSkeleton && (profileLoadError || DeveloperOptions.forceProfileDataWarning); const displayProfile = useMemo((): Profile | null => { if (!profile) return null; if (showGlobalProfile && hasGuildProfile) { return profile.withUpdates({guild_member_profile: null}).withGuildId(null); } return profile; }, [profile, showGlobalProfile, hasGuildProfile]); const screenReaderLabel = useMemo(() => { if (!displayUser) return i18n._(USER_PROFILE_DESCRIPTOR); const tag = NicknameUtils.formatTagForStreamerMode(displayUser.tag); return i18n._(USER_PROFILE_2_DESCRIPTOR, {tag}); }, [displayUser, i18n.locale]); const effectiveProfile: Profile | null = displayProfile ?? profile ?? profileFallback; const resolvedProfile: Profile = effectiveProfile ?? fallbackProfile; const displayMembership = resolveProfileGuildMembership(displayProfile, { fallbackGuildId: guildId, userId, allowStoreFallback: Boolean(displayProfile), }); const displayUserName = getProfileMembershipDisplayName( displayUser, displayMembership, displayProfile?.guildId ?? guildId, ); const displayProfileContext = useMemo( () => toProfileDisplayContext({ user: displayUser, profile: displayProfile, membership: displayMembership, guildMemberProfile: displayProfile?.guildMemberProfile, }), [displayUser, displayProfile, displayMembership], ); const hasImageMenuItems = Boolean( getUserMenuAvatarUrl({ user: displayUser, profile: displayProfile, profileContext: displayProfileContext, previewOverrides, }) || getUserMenuBannerUrl({ user: displayUser, profile: displayProfile, profileContext: displayProfileContext, previewOverrides, }), ); const handleEditProfile = () => { ModalCommands.pop(); ModalCommands.push( modal( () => ( ), 'user-settings', ), ); }; const handleMessage = async () => { try { ModalCommands.pop(); await PrivateChannelCommands.openDMChannel(userId); } catch (error) { logger.error('Failed to open DM channel:', error); showDmActionErrorModal(error); } }; const handleOpenBlockedDm = () => { ModalCommands.push( modal(() => ( )), ); }; const handleSendFriendRequest = () => { RelationshipActionUtils.sendFriendRequest(i18n, userId); }; const handleAcceptFriendRequest = (event?: {shiftKey?: boolean}) => { RelationshipActionUtils.showAcceptFriendRequestConfirmation(i18n, displayUser, { bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event), showShiftBypassConfirmationTip: true, }); }; const handleRemoveFriend = (event?: {shiftKey?: boolean}) => { RelationshipActionUtils.showRemoveFriendConfirmation(i18n, displayUser, { bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event), showShiftBypassConfirmationTip: true, }); }; const handleBlockUser = (event?: {shiftKey?: boolean}) => { RelationshipActionUtils.showBlockUserConfirmation(i18n, displayUser, { bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event), showShiftBypassConfirmationTip: true, }); }; const handleUnblockUser = (event?: {shiftKey?: boolean}) => { RelationshipActionUtils.showUnblockUserConfirmation(i18n, displayUser, { bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event), showShiftBypassConfirmationTip: true, }); }; const handleCancelFriendRequest = () => { RelationshipActionUtils.cancelFriendRequest(i18n, userId); }; const handleStartVoiceCall = async (event?: ContextMenuActionEvent) => { try { const channelId = await PrivateChannelCommands.ensureDMChannel(userId); await CallUtils.requestStartCall(i18n, channelId, CallUtils.getCallStartRequestOptions(event, {kind: 'voice'})); } catch (error) { logger.error('Failed to start voice call:', error); showDmActionErrorModal(error); } }; const handleStartVideoCall = async (event?: ContextMenuActionEvent) => { try { const channelId = await PrivateChannelCommands.ensureDMChannel(userId); await CallUtils.requestStartCall(i18n, channelId, CallUtils.getCallStartRequestOptions(event, {kind: 'video'})); } catch (error) { logger.error('Failed to start video call:', error); showDmActionErrorModal(error); } }; const handleReportUserProfile = () => { openReportUserProfileModal({user: displayUser, guildId}); }; const handleCopyFluxerTag = () => { TextCopyCommands.copy(i18n, displayUser.tag, true); }; const handleCopyUserId = () => { TextCopyCommands.copy(i18n, displayUser.id, true); }; const handleMoreOptionsPointerDown = (event: React.PointerEvent) => { const contextMenu = ContextMenu.contextMenu; const isOpen = !!contextMenu && contextMenu.target.target === moreOptionsButtonRef.current; if (isOpen) { event.stopPropagation(); event.preventDefault(); ContextMenuCommands.close(); } }; const renderBlockMenuItem = (onClose: () => void) => { if (displayUser?.system) { return null; } switch (relationshipType) { case RelationshipTypes.BLOCKED: return ( } onClick={(event) => { handleUnblockUser(event); onClose(); }} data-flx="user.user-profile-modal.render-block-menu-item.menu-item.unblock-user" > {i18n._(UNBLOCK_USER_ACTION_DESCRIPTOR)} ); default: return ( } onClick={(event) => { handleBlockUser(event); onClose(); }} danger data-flx="user.user-profile-modal.render-block-menu-item.menu-item.block-user" > {i18n._(BLOCK_DESCRIPTOR)} ); } }; const openMoreOptionsMenu = (event: React.MouseEvent) => { const contextMenu = ContextMenu.contextMenu; const isOpen = !!contextMenu && contextMenu.target.target === event.currentTarget; if (isOpen) { return; } ContextMenuCommands.openFromElementBottomRight(event, (props) => ( <> {hasGuildProfile && ( } onClick={() => { setShowGlobalProfile(!showGlobalProfile); props.onClose(); }} data-flx="user.user-profile-modal.open-more-options-menu.menu-item.set-show-global-profile" > {showGlobalProfile ? i18n._(VIEW_COMMUNITY_PROFILE_DESCRIPTOR) : i18n._(VIEW_GLOBAL_PROFILE_DESCRIPTOR)} )} {!isCurrentUser && !isUserBot && relationshipType === RelationshipTypes.FRIEND && !hasActiveDirectCall && !RuntimeConfig.directMessagesDisabled && ( } onClick={(pressEvent) => { handleStartVoiceCall(pressEvent); props.onClose(); }} data-flx="user.user-profile-modal.open-more-options-menu.menu-item.start-voice-call" > {i18n._(START_VOICE_CALL_DESCRIPTOR)} } onClick={(pressEvent) => { handleStartVideoCall(pressEvent); props.onClose(); }} data-flx="user.user-profile-modal.open-more-options-menu.menu-item.start-video-call" > {i18n._(START_VIDEO_CALL_DESCRIPTOR)} )} } onClick={() => { handleCopyFluxerTag(); props.onClose(); }} data-flx="user.user-profile-modal.open-more-options-menu.menu-item.copy-fluxer-tag" > {i18n._(COPY_USERNAME_DESCRIPTOR)} } onClick={() => { handleCopyUserId(); props.onClose(); }} data-flx="user.user-profile-modal.open-more-options-menu.menu-item.copy-user-id" > {i18n._(COPY_USER_ID_DESCRIPTOR)} {!isCurrentUser && relationshipType === RelationshipTypes.FRIEND && !RuntimeConfig.directMessagesDisabled && ( } onClick={(event) => { handleRemoveFriend(event); props.onClose(); }} danger data-flx="user.user-profile-modal.open-more-options-menu.menu-item.remove-friend" > {i18n._(REMOVE_FRIEND_DESCRIPTOR)} )} {!isCurrentUser && ( } onClick={() => { handleReportUserProfile(); props.onClose(); }} danger data-flx="user.user-profile-modal.open-more-options-menu.menu-item.report-user-profile" > {i18n._(REPORT_USER_PROFILE_DESCRIPTOR)} {renderBlockMenuItem(props.onClose)} )} {hasImageMenuItems && ( )} )); }; const renderActionButtons = () => { const currentUserUnclaimed = !(Users.currentUser?.isClaimed() ?? true); if (isCurrentUser && disableEditProfile) { return (
); } if (isCurrentUser && !disableEditProfile) { return (
{!currentUserUnclaimed && ( )}
); } const renderPrimaryActionButton = () => { if (isUserBot && !isFriendlyBot) { return null; } if (RuntimeConfig.directMessagesDisabled && relationshipType !== RelationshipTypes.BLOCKED) { return null; } if (relationshipType === RelationshipTypes.FRIEND) { return (
); } if (relationshipType === RelationshipTypes.BLOCKED) { return (
); } if (relationshipType === RelationshipTypes.INCOMING_REQUEST) { return (
); } if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) { return (
); } if (relationshipType === undefined && (!isUserBot || isFriendlyBot)) { const tooltipText = currentUserUnclaimed ? i18n._(CLAIM_YOUR_ACCOUNT_TO_SEND_FRIEND_REQUESTS_DESCRIPTOR) : i18n._(SEND_FRIEND_REQUEST_DESCRIPTOR); return (
); } return null; }; return (
{!RuntimeConfig.directMessagesDisabled && ( )} {renderPrimaryActionButton()}
); }; const borderProfile = resolvedProfile.getEffectiveProfile() ?? null; const borderAccentColor = previewOverrides?.previewAccentColor !== undefined ? previewOverrides.previewAccentColor : borderProfile?.accent_color; const borderColor = getUserAccentColor(displayUser, borderAccentColor); return (
{showProfileSkeleton ? ( ) : ( )}
); }, );