Files
fluxer/fluxer_app/src/components/modals/UserProfileModal.tsx
2026-02-17 12:22:36 +00:00

1422 lines
45 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
import * as PrivateChannelActionCreators from '@app/actions/PrivateChannelActionCreators';
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
import * as UserNoteActionCreators from '@app/actions/UserNoteActionCreators';
import * as UserProfileActionCreators from '@app/actions/UserProfileActionCreators';
import {UserTag} from '@app/components/channel/UserTag';
import {CustomStatusDisplay} from '@app/components/common/custom_status_display/CustomStatusDisplay';
import {GroupDMAvatar} from '@app/components/common/GroupDMAvatar';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import type {IARContext} from '@app/components/modals/IARModal';
import {IARModal} from '@app/components/modals/IARModal';
import * as Modal from '@app/components/modals/Modal';
import userProfileModalStyles from '@app/components/modals/UserProfileModal.module.css';
import {UserSettingsModal} from '@app/components/modals/UserSettingsModal';
import {GuildIcon} from '@app/components/popouts/GuildIcon';
import {UserProfileBadges} from '@app/components/popouts/UserProfileBadges';
import {UserProfileDataWarning} from '@app/components/popouts/UserProfileDataWarning';
import {
UserProfileBio,
UserProfileConnections,
UserProfileMembershipInfo,
UserProfileRoles,
} from '@app/components/popouts/UserProfileShared';
import {VoiceActivitySection} from '@app/components/profile/VoiceActivitySection';
import {Button} from '@app/components/uikit/button/Button';
import {GroupDMContextMenu} from '@app/components/uikit/context_menu/GroupDMContextMenu';
import {GuildContextMenu} from '@app/components/uikit/context_menu/GuildContextMenu';
import {GuildMemberContextMenu} from '@app/components/uikit/context_menu/GuildMemberContextMenu';
import {MenuGroup} from '@app/components/uikit/context_menu/MenuGroup';
import {MenuItem} from '@app/components/uikit/context_menu/MenuItem';
import {MenuItemRadio} from '@app/components/uikit/context_menu/MenuItemRadio';
import {UserContextMenu} from '@app/components/uikit/context_menu/UserContextMenu';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Scroller} from '@app/components/uikit/Scroller';
import {Spinner} from '@app/components/uikit/Spinner';
import {StatusAwareAvatar} from '@app/components/uikit/StatusAwareAvatar';
import {Tabs} from '@app/components/uikit/tabs/Tabs';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {useAutoplayExpandedProfileAnimations} from '@app/hooks/useAutoplayExpandedProfileAnimations';
import {Logger} from '@app/lib/Logger';
import {TextareaAutosize} from '@app/lib/TextareaAutosize';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {GuildRecord} from '@app/records/GuildRecord';
import type {ProfileRecord} from '@app/records/ProfileRecord';
import {UserRecord} from '@app/records/UserRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import type {ContextMenuTargetElement} from '@app/stores/ContextMenuStore';
import ContextMenuStore, {isContextMenuNodeTarget} from '@app/stores/ContextMenuStore';
import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import MemberPresenceSubscriptionStore from '@app/stores/MemberPresenceSubscriptionStore';
import ModalStore from '@app/stores/ModalStore';
import PermissionStore from '@app/stores/PermissionStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import UserNoteStore from '@app/stores/UserNoteStore';
import UserProfileStore from '@app/stores/UserProfileStore';
import UserStore from '@app/stores/UserStore';
import {getUserAccentColor} from '@app/utils/AccentColorUtils';
import * as CallUtils from '@app/utils/CallUtils';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import * as NicknameUtils from '@app/utils/NicknameUtils';
import * as ProfileDisplayUtils from '@app/utils/ProfileDisplayUtils';
import {createMockProfile} from '@app/utils/ProfileUtils';
import * as RelationshipActionUtils from '@app/utils/RelationshipActionUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
MEDIA_PROXY_AVATAR_SIZE_PROFILE,
MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
} from '@fluxer/constants/src/MediaProxyAssetSizes';
import {PublicUserFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {
CaretDownIcon,
ChatTeardropIcon,
CheckCircleIcon,
ClockCounterClockwiseIcon,
CopyIcon,
DotsThreeIcon,
FlagIcon,
GlobeIcon,
IdentificationCardIcon,
PencilIcon,
ProhibitIcon,
UserMinusIcon,
UserPlusIcon,
UsersThreeIcon,
VideoCameraIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
import type {PressEvent} from 'react-aria-components';
const logger = new Logger('UserProfileModal');
export interface UserProfileModalProps {
userId: string;
guildId?: string;
autoFocusNote?: boolean;
disableEditProfile?: boolean;
previewOverrides?: ProfileDisplayUtils.ProfilePreviewOverrides;
previewUser?: UserRecord;
}
interface UserInfoProps {
user: UserRecord;
profile: ProfileRecord;
guildId?: string;
showProfileDataWarning?: boolean;
}
interface UserNoteEditorProps {
userId: string;
initialNote: string | null;
autoFocus?: boolean;
noteRef?: React.RefObject<HTMLTextAreaElement | null>;
}
interface ProfileContentProps {
profile: ProfileRecord;
user: UserRecord;
userNote: string | null;
autoFocusNote?: boolean;
noteRef?: React.RefObject<HTMLTextAreaElement | null>;
}
type UserProfileModalComponent = React.FC<UserProfileModalProps>;
interface ProfileModalContentProps {
profile: ProfileRecord;
user: UserRecord;
userNote: string | null;
autoFocusNote?: boolean;
noteRef?: React.RefObject<HTMLTextAreaElement | null>;
renderActionButtons: () => React.ReactNode;
previewOverrides?: ProfileDisplayUtils.ProfilePreviewOverrides;
showProfileDataWarning?: boolean;
}
const UserInfo: React.FC<UserInfoProps> = observer(({user, profile, guildId, showProfileDataWarning}) => {
const displayName = NicknameUtils.getNickname(user, guildId);
const effectiveProfile = profile?.getEffectiveProfile() ?? null;
const shouldAutoplayProfileAnimations = useAutoplayExpandedProfileAnimations();
return (
<div className={userProfileModalStyles.userInfo}>
<div className={clsx(userProfileModalStyles.userInfoHeader, userProfileModalStyles.userInfoHeaderDesktop)}>
<div className={userProfileModalStyles.userInfoContent}>
{showProfileDataWarning && (
<div className={userProfileModalStyles.profileDataWarning}>
<UserProfileDataWarning />
</div>
)}
<div className={userProfileModalStyles.nameRow}>
<span className={userProfileModalStyles.userName}>{displayName}</span>
{user.bot && <UserTag className={userProfileModalStyles.userTag} system={user.system} size="lg" />}
</div>
<div className={userProfileModalStyles.tagBadgeRow}>
<div className={userProfileModalStyles.usernameRow}>{user.tag}</div>
<div className={userProfileModalStyles.badgesWrapper}>
<UserProfileBadges user={user} profile={profile} isModal={true} isMobile={false} />
</div>
</div>
{effectiveProfile?.pronouns && (
<div className={userProfileModalStyles.pronouns}>{effectiveProfile.pronouns}</div>
)}
<div className={userProfileModalStyles.customStatusRow}>
<CustomStatusDisplay
userId={user.id}
className={userProfileModalStyles.customStatusText}
showTooltip
allowJumboEmoji
maxLines={0}
alwaysAnimate={shouldAutoplayProfileAnimations}
/>
</div>
</div>
</div>
</div>
);
});
const UserNoteEditor: React.FC<UserNoteEditorProps> = observer(({userId, initialNote, autoFocus, noteRef}) => {
const {t} = useLingui();
const [isEditing, setIsEditing] = useState(false);
const [localNote, setLocalNote] = useState<string | null>(null);
const internalNoteRef = useRef<HTMLTextAreaElement | null>(null);
const textareaRef = noteRef || internalNoteRef;
useEffect(() => {
if (autoFocus && textareaRef.current) {
setIsEditing(true);
}
}, [autoFocus, textareaRef]);
const handleBlur = () => {
if (localNote != null && localNote !== initialNote) {
UserNoteActionCreators.update(userId, localNote);
}
setIsEditing(false);
};
const handleFocus = () => {
setIsEditing(true);
if (textareaRef.current) {
const length = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(length, length);
}
};
return (
<div className={userProfileModalStyles.userNoteEditor}>
<span className={userProfileModalStyles.noteLabel}>
<Trans>Note</Trans>
</span>
<TextareaAutosize
ref={textareaRef}
aria-label={t`Note`}
className={clsx(
userProfileModalStyles.noteTextarea,
userProfileModalStyles.noteTextareaBase,
isEditing ? userProfileModalStyles.noteTextareaEditing : userProfileModalStyles.noteTextareaNotEditing,
)}
defaultValue={initialNote ?? undefined}
maxLength={256}
onBlur={handleBlur}
onChange={(event) => setLocalNote(event.target.value)}
onFocus={handleFocus}
placeholder={isEditing ? undefined : t`Click to add a note`}
/>
</div>
);
});
const ProfileContent: React.FC<ProfileContentProps> = observer(({profile, user, userNote, autoFocusNote, noteRef}) => {
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? '', user.id);
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
const canManageRoles = PermissionStore.can(Permissions.MANAGE_ROLES, {guildId: profile?.guild?.id});
const handleNavigate = useCallback(() => {
ModalActionCreators.pop();
}, []);
return (
<div className={userProfileModalStyles.profileContent}>
<div className={userProfileModalStyles.profileContentHeader}>
<VoiceActivitySection userId={user.id} onNavigate={handleNavigate} showAllActivities={true} />
<UserProfileBio profile={profile} />
<UserProfileMembershipInfo profile={profile} user={user} />
<UserProfileRoles
profile={profile}
user={user}
memberRoles={[...memberRoles]}
canManageRoles={canManageRoles}
/>
<UserProfileConnections profile={profile} variant="cards" />
<UserNoteEditor userId={user.id} initialNote={userNote} autoFocus={autoFocusNote} noteRef={noteRef} />
</div>
</div>
);
});
const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
({
profile,
user,
userNote,
autoFocusNote,
noteRef,
renderActionButtons,
previewOverrides,
showProfileDataWarning,
}) => {
const {t} = useLingui();
const effectiveProfile = profile?.getEffectiveProfile() ?? null;
const bannerColor = getUserAccentColor(user, effectiveProfile?.accent_color);
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? '', user.id);
const profileContext = useMemo<ProfileDisplayUtils.ProfileDisplayContext>(
() => ({
user,
profile,
guildId: profile?.guildId,
guildMember,
guildMemberProfile: profile?.guildMemberProfile,
}),
[user, profile, guildMember],
);
const shouldAutoplayProfileAnimations = useAutoplayExpandedProfileAnimations();
const {avatarUrl, hoverAvatarUrl} = useMemo(
() => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides, MEDIA_PROXY_AVATAR_SIZE_PROFILE),
[profileContext, previewOverrides],
);
const bannerUrl = useMemo(
() =>
ProfileDisplayUtils.getProfileBannerUrl(
profileContext,
previewOverrides,
shouldAutoplayProfileAnimations,
MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
),
[profileContext, previewOverrides, shouldAutoplayProfileAnimations],
);
type MutualView = 'mutual_friends' | 'mutual_communities' | 'mutual_groups';
const [activeTab, setActiveTab] = useState<'overview' | 'mutual'>('overview');
const handleTabChange = useCallback((tab: 'overview' | 'mutual') => {
setActiveTab(tab);
}, []);
const showMutualFriendsTab = !user.bot;
const mutualFriendsCount = profile?.mutualFriends?.length ?? 0;
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const profileMutualGuilds = profile?.mutualGuilds ?? [];
type MutualGuildDisplay = {
guild: GuildRecord;
nick: string | null;
};
const mutualGuildDisplayItems = useMemo(() => {
return profileMutualGuilds
.map((mutualGuild) => {
const guild = GuildStore.getGuild(mutualGuild.id);
if (!guild) {
return null;
}
return {guild, nick: mutualGuild.nick};
})
.filter((item): item is MutualGuildDisplay => item !== null);
}, [profileMutualGuilds]);
const mutualGroups = ChannelStore.dmChannels.filter(
(channel) => channel.isGroupDM() && channel.recipientIds.includes(user.id),
);
const [mutualView, setMutualView] = useState<MutualView>(
showMutualFriendsTab ? 'mutual_friends' : 'mutual_communities',
);
const mutualMenuButtonRef = useRef<HTMLButtonElement>(null);
const [isMutualMenuOpen, setIsMutualMenuOpen] = useState(false);
const getMutualViewLabel = useCallback(
(view: MutualView) => {
switch (view) {
case 'mutual_friends': {
const count = mutualFriendsCount;
return t`Mutual Friends (${count})`;
}
case 'mutual_groups': {
const count = mutualGroups.length;
return t`Mutual Groups (${count})`;
}
default: {
const count = profileMutualGuilds.length;
return t`Mutual Communities (${count})`;
}
}
},
[t, mutualFriendsCount, mutualGroups.length, profileMutualGuilds.length],
);
const openMutualMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen = !!contextMenu && contextMenu.target.target === event.currentTarget;
if (isOpen) {
return;
}
setActiveTab('mutual');
ContextMenuActionCreators.openFromEvent(event, () => (
<MenuGroup>
{showMutualFriendsTab && (
<MenuItemRadio
selected={mutualView === 'mutual_friends'}
closeOnSelect
onSelect={() => setMutualView('mutual_friends')}
>
{getMutualViewLabel('mutual_friends')}
</MenuItemRadio>
)}
<MenuItemRadio
selected={mutualView === 'mutual_communities'}
closeOnSelect
onSelect={() => setMutualView('mutual_communities')}
>
{getMutualViewLabel('mutual_communities')}
</MenuItemRadio>
<MenuItemRadio
selected={mutualView === 'mutual_groups'}
closeOnSelect
onSelect={() => setMutualView('mutual_groups')}
>
{getMutualViewLabel('mutual_groups')}
</MenuItemRadio>
</MenuGroup>
));
},
[getMutualViewLabel, mutualView, showMutualFriendsTab],
);
const mutualTabLabelText = useMemo(() => getMutualViewLabel(mutualView), [getMutualViewLabel, mutualView]);
const tabs = useMemo(
() =>
[
{key: 'overview', label: t`Overview`},
{key: 'mutual', label: mutualTabLabelText},
] as Array<{key: 'overview' | 'mutual'; label: React.ReactNode}>,
[t, mutualTabLabelText],
);
useEffect(() => {
setMutualView(showMutualFriendsTab ? 'mutual_friends' : 'mutual_communities');
}, [showMutualFriendsTab, user.id]);
useEffect(() => {
if (!showMutualFriendsTab && mutualView === 'mutual_friends') {
setMutualView('mutual_communities');
}
}, [mutualView, showMutualFriendsTab]);
const handleMutualFriendClick = (friendId: string) => {
const currentModal = ModalStore.getModal();
if (currentModal) {
ModalActionCreators.update(currentModal.key, () =>
modal(() => <UserProfileModal userId={friendId} guildId={profile?.guildId ?? undefined} />),
);
}
};
const handleMutualFriendContextMenu = (event: React.MouseEvent, friend: UserRecord) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<>
{profile?.guildId ? (
<GuildMemberContextMenu user={friend} guildId={profile.guildId} onClose={onClose} />
) : (
<UserContextMenu user={friend} onClose={onClose} />
)}
</>
));
};
const handleGuildClick = (guild: GuildRecord) => {
ModalActionCreators.pop();
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(guild.id);
NavigationActionCreators.selectGuild(guild.id, selectedChannel);
};
const handleGuildContextMenu = (event: React.MouseEvent, guild: GuildRecord) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, (props) => (
<GuildContextMenu guild={guild} onClose={props.onClose} />
));
};
const handleGroupClick = (group: ChannelRecord) => {
ModalActionCreators.pop();
NavigationActionCreators.selectChannel(ME, group.id);
};
const handleGroupContextMenu = (event: React.MouseEvent, group: ChannelRecord) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<GroupDMContextMenu channel={group} onClose={onClose} />
));
};
const [contextMenuTarget, setContextMenuTarget] = useState<ContextMenuTargetElement | null>(null);
useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
setContextMenuTarget(contextMenu?.target.target ?? null);
});
return () => {
disposer();
};
}, []);
const isContextMenuOpenFor = (target: EventTarget | null) => {
if (!contextMenuTarget || !target) {
return false;
}
if (target === contextMenuTarget) {
return true;
}
if (target instanceof Node && isContextMenuNodeTarget(contextMenuTarget)) {
return target.contains(contextMenuTarget);
}
return false;
};
useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen =
!!contextMenu && !!mutualMenuButtonRef.current && contextMenu.target.target === mutualMenuButtonRef.current;
setIsMutualMenuOpen(isOpen);
});
return () => {
disposer();
};
}, []);
const renderMutualFriendsList = useCallback(() => {
const friends = profile?.mutualFriends ?? [];
return (
<div className={userProfileModalStyles.mutualFriendsList}>
{friends.map((friend: UserPartial) => {
const friendRecord = new UserRecord(friend);
return (
<MutualFriendItem
key={friendRecord.id}
user={friendRecord}
profile={profile}
onClick={() => handleMutualFriendClick(friendRecord.id)}
onContextMenu={(e) => handleMutualFriendContextMenu(e, friendRecord)}
isContextMenuOpen={isContextMenuOpenFor}
/>
);
})}
{friends.length === 0 && (
<div className={userProfileModalStyles.emptyState}>
<UsersThreeIcon className={userProfileModalStyles.emptyStateIcon} />
<Trans>No mutual friends found.</Trans>
</div>
)}
</div>
);
}, [handleMutualFriendClick, profile, isContextMenuOpenFor]);
const renderMutualGroupsList = useCallback(() => {
return (
<div className={userProfileModalStyles.mutualFriendsList}>
{mutualGroups.map((group) => (
<MutualGroupItem
key={group.id}
group={group}
onClick={() => handleGroupClick(group)}
onContextMenu={(e) => handleGroupContextMenu(e, group)}
isContextMenuOpen={isContextMenuOpenFor}
/>
))}
{mutualGroups.length === 0 && (
<div className={userProfileModalStyles.emptyState}>
<UsersThreeIcon className={userProfileModalStyles.emptyStateIcon} />
<Trans>No mutual groups found.</Trans>
</div>
)}
</div>
);
}, [handleGroupClick, handleGroupContextMenu, isContextMenuOpenFor, mutualGroups]);
const renderMutualGuildsList = useCallback(() => {
return (
<div className={userProfileModalStyles.mutualFriendsList}>
{mutualGuildDisplayItems.map(({guild, nick}) => (
<MutualGuildItem
key={guild.id}
guild={guild}
nick={nick}
onClick={() => handleGuildClick(guild)}
onContextMenu={(e) => handleGuildContextMenu(e, guild)}
isContextMenuOpen={isContextMenuOpenFor}
/>
))}
{profileMutualGuilds.length === 0 && (
<div className={userProfileModalStyles.emptyState}>
<UsersThreeIcon className={userProfileModalStyles.emptyStateIcon} />
<Trans>No mutual communities found.</Trans>
</div>
)}
</div>
);
}, [
handleGuildClick,
handleGuildContextMenu,
isContextMenuOpenFor,
mutualGuildDisplayItems,
profileMutualGuilds.length,
]);
const renderMutualTabContent = useCallback(() => {
switch (mutualView) {
case 'mutual_friends':
return showMutualFriendsTab ? renderMutualFriendsList() : renderMutualGuildsList();
case 'mutual_groups':
return renderMutualGroupsList();
default:
return renderMutualGuildsList();
}
}, [mutualView, renderMutualFriendsList, renderMutualGroupsList, renderMutualGuildsList, showMutualFriendsTab]);
const renderActiveTabContent = useCallback(() => {
switch (activeTab) {
case 'overview':
return (
<ProfileContent
profile={profile}
user={user}
userNote={userNote}
autoFocusNote={autoFocusNote}
noteRef={noteRef}
/>
);
default:
return renderMutualTabContent();
}
}, [activeTab, autoFocusNote, noteRef, profile, renderMutualTabContent, user, userNote]);
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const maskId = `uid_${safeId}`;
return (
<>
<header>
<div className={userProfileModalStyles.bannerContainer}>
<svg className={userProfileModalStyles.bannerMask} viewBox="0 0 600 210" preserveAspectRatio="none">
<mask id={maskId}>
<rect fill="white" x="0" y="0" width="600" height="210" />
<circle fill="black" cx="82" cy="210" r="66" />
</mask>
<foreignObject x="0" y="0" width="600" height="210" overflow="visible" mask={`url(#${maskId})`}>
<div
className={userProfileModalStyles.bannerImage}
style={{
backgroundColor: bannerColor,
...(bannerUrl ? {backgroundImage: `url(${bannerUrl})`} : {}),
}}
/>
</foreignObject>
</svg>
</div>
<div className={userProfileModalStyles.headerContainer}>
<div className={userProfileModalStyles.avatarContainer}>
<StatusAwareAvatar size={120} user={user} avatarUrl={avatarUrl} hoverAvatarUrl={hoverAvatarUrl} />
</div>
<div className={userProfileModalStyles.actionButtonsContainer}>{renderActionButtons()}</div>
</div>
</header>
<div className={userProfileModalStyles.contentContainer}>
<UserInfo
user={user}
profile={profile}
guildId={profile.guildId ?? undefined}
showProfileDataWarning={showProfileDataWarning}
/>
{!isCurrentUser ? (
<div className={userProfileModalStyles.tabsWrapper}>
<Tabs
activeTab={activeTab}
onTabChange={handleTabChange}
tabs={tabs}
renderTabSibling={(tab) =>
tab === 'mutual' ? (
<FocusRing offset={-2}>
<button
ref={mutualMenuButtonRef}
type="button"
className={clsx(
userProfileModalStyles.mutualMenuButton,
isMutualMenuOpen && userProfileModalStyles.mutualMenuButtonActive,
)}
onClick={(event) => openMutualMenu(event)}
aria-label={t`Select mutual view`}
>
<CaretDownIcon
weight="bold"
className={clsx(
userProfileModalStyles.mutualMenuIcon,
isMutualMenuOpen && userProfileModalStyles.mutualMenuIconOpen,
)}
/>
</button>
</FocusRing>
) : null
}
/>
</div>
) : (
<div className={userProfileModalStyles.separator} />
)}
<div className={userProfileModalStyles.profileContentWrapper}>
<Scroller className={userProfileModalStyles.scrollerFullHeight} key="user-profile-modal-content-scroller">
{renderActiveTabContent()}
</Scroller>
</div>
</div>
</>
);
},
);
const MutualFriendItem = ({
user,
profile,
onClick,
onContextMenu,
isContextMenuOpen,
}: {
user: UserRecord;
profile: ProfileRecord | null;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
isContextMenuOpen: (target: EventTarget | null) => boolean;
}) => {
const itemRef = useRef<HTMLDivElement>(null);
const isActive = isContextMenuOpen(itemRef.current);
return (
<div
ref={itemRef}
className={clsx(userProfileModalStyles.mutualFriendItem, isActive && userProfileModalStyles.active)}
onClick={onClick}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick()}
onContextMenu={onContextMenu}
role="button"
tabIndex={0}
>
<StatusAwareAvatar size={40} user={user} />
<div className={userProfileModalStyles.mutualFriendInfo}>
<span className={userProfileModalStyles.mutualFriendName}>
{NicknameUtils.getNickname(user, profile?.guildId ?? undefined)}
</span>
<span className={userProfileModalStyles.mutualFriendUsername}>{user.tag}</span>
</div>
</div>
);
};
const MutualGuildItem = ({
guild,
nick,
onClick,
onContextMenu,
isContextMenuOpen,
}: {
guild: GuildRecord;
nick: string | null;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
isContextMenuOpen: (target: EventTarget | null) => boolean;
}) => {
const itemRef = useRef<HTMLDivElement>(null);
const isActive = isContextMenuOpen(itemRef.current);
return (
<div
ref={itemRef}
className={clsx(userProfileModalStyles.mutualFriendItem, isActive && userProfileModalStyles.active)}
onClick={onClick}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick()}
onContextMenu={onContextMenu}
role="button"
tabIndex={0}
>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
className={userProfileModalStyles.mutualGuildIcon}
sizePx={40}
/>
<div className={userProfileModalStyles.mutualFriendInfo}>
<span className={userProfileModalStyles.mutualFriendName}>{guild.name}</span>
{nick && <span className={userProfileModalStyles.mutualFriendUsername}>{nick}</span>}
</div>
</div>
);
};
const MutualGroupItem = ({
group,
onClick,
onContextMenu,
isContextMenuOpen,
}: {
group: ChannelRecord;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
isContextMenuOpen: (target: EventTarget | null) => boolean;
}) => {
const {t} = useLingui();
const itemRef = useRef<HTMLDivElement>(null);
const isActive = isContextMenuOpen(itemRef.current);
const memberCount = group.recipientIds.length + 1;
const memberLabel = memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`;
return (
<div
ref={itemRef}
className={clsx(userProfileModalStyles.mutualFriendItem, isActive && userProfileModalStyles.active)}
onClick={onClick}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick()}
onContextMenu={onContextMenu}
role="button"
tabIndex={0}
>
<GroupDMAvatar channel={group} size={40} />
<div className={userProfileModalStyles.mutualFriendInfo}>
<span className={userProfileModalStyles.mutualFriendName}>{ChannelUtils.getDMDisplayName(group)}</span>
<span className={userProfileModalStyles.mutualFriendUsername}>{memberLabel}</span>
</div>
</div>
);
};
export const UserProfileModal: UserProfileModalComponent = observer(
({userId, guildId, autoFocusNote, disableEditProfile, previewOverrides, previewUser}) => {
const {t, i18n} = useLingui();
const storeUser = UserStore.getUser(userId);
const user = previewUser ?? storeUser;
const fallbackUser = useMemo(
() =>
new UserRecord({
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 initialProfile = useMemo(() => UserProfileStore.getProfile(userId, guildId), [userId, guildId]);
const [profile, setProfile] = useState<ProfileRecord | null>(initialProfile);
const [profileLoadError, setProfileLoadError] = useState(false);
const [showGlobalProfile, setShowGlobalProfile] = useState(false);
const [isProfileLoading, setIsProfileLoading] = useState(() => !previewUser && !initialProfile);
const userNote = UserNoteStore.getUserNote(userId);
const isCurrentUser = user?.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(userId);
const relationshipType = relationship?.type;
const isBlocked = relationshipType === RelationshipTypes.BLOCKED;
const isUserBot = user?.bot ?? false;
const isFriendlyBot =
isUserBot && (displayUser.flags & PublicUserFlags.FRIENDLY_BOT) === PublicUserFlags.FRIENDLY_BOT;
const noteRef = useRef<HTMLTextAreaElement | null>(null);
const moreOptionsButtonRef = useRef<HTMLButtonElement>(null);
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
useEffect(() => {
setProfile(initialProfile);
setIsProfileLoading(!previewUser && !initialProfile);
}, [initialProfile, previewUser]);
useEffect(() => {
if (previewUser || profile) {
setIsProfileLoading(false);
setProfileLoadError(false);
return;
}
let cancelled = false;
setIsProfileLoading(true);
setProfileLoadError(false);
UserProfileActionCreators.fetch(userId, guildId)
.then(() => {
if (cancelled) return;
const fetchedProfile = UserProfileStore.getProfile(userId, guildId);
if (fetchedProfile) {
setProfile(fetchedProfile);
}
setProfileLoadError(false);
})
.catch((error) => {
if (cancelled) return;
logger.error('Failed to fetch user profile:', error);
setProfileLoadError(true);
})
.finally(() => {
if (cancelled) return;
setIsProfileLoading(false);
});
return () => {
cancelled = true;
};
}, [userId, guildId, previewUser, profile]);
useEffect(() => {
const handleContextMenuChange = () => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen =
!!contextMenu && !!moreOptionsButtonRef.current && contextMenu.target.target === moreOptionsButtonRef.current;
setIsMoreMenuOpen(isOpen);
};
const disposer = autorun(handleContextMenuChange);
return () => disposer();
}, []);
useEffect(() => {
if (!guildId || !userId || previewUser) {
return;
}
const hasMember = GuildMemberStore.getMember(guildId, userId);
if (!hasMember) {
MemberPresenceSubscriptionStore.touchMember(guildId, userId);
GuildMemberStore.fetchMembers(guildId, {userIds: [userId]}).catch((error) => {
logger.error('Failed to fetch guild member:', error);
});
} else {
MemberPresenceSubscriptionStore.touchMember(guildId, userId);
}
return () => {
MemberPresenceSubscriptionStore.unsubscribe(guildId, userId);
};
}, [guildId, userId, previewUser]);
const hasGuildProfile = !!(profile?.guildId && profile?.guildMemberProfile);
const shouldShowProfileDataWarning = profileLoadError || DeveloperOptionsStore.forceProfileDataWarning;
const displayProfile = useMemo((): ProfileRecord | 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 t`User Profile`;
const tag = displayUser.tag;
return t`User Profile: ${tag}`;
}, [displayUser, t]);
const shouldShowSpinner = isProfileLoading || !user;
const effectiveProfile: ProfileRecord | null = displayProfile ?? profile ?? mockProfile;
const resolvedProfile: ProfileRecord = effectiveProfile ?? fallbackProfile;
const handleEditProfile = () => {
ModalActionCreators.pop();
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="my_profile" />));
};
const handleMessage = async () => {
try {
ModalActionCreators.pop();
await PrivateChannelActionCreators.openDMChannel(userId);
} catch (error) {
logger.error('Failed to open DM channel:', error);
}
};
const handleOpenBlockedDm = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Open DM`}
description={t`You blocked ${displayUser.username}. You won't be able to send messages unless you unblock them.`}
primaryText={t`Open DM`}
primaryVariant="primary"
onPrimary={handleMessage}
/>
)),
);
};
const handleSendFriendRequest = () => {
RelationshipActionUtils.sendFriendRequest(i18n, userId);
};
const handleAcceptFriendRequest = () => {
RelationshipActionUtils.acceptFriendRequest(i18n, userId);
};
const handleRemoveFriend = () => {
RelationshipActionUtils.showRemoveFriendConfirmation(i18n, displayUser);
};
const handleBlockUser = () => {
RelationshipActionUtils.showBlockUserConfirmation(i18n, displayUser);
};
const handleUnblockUser = () => {
RelationshipActionUtils.showUnblockUserConfirmation(i18n, displayUser);
};
const handleCancelFriendRequest = () => {
RelationshipActionUtils.cancelFriendRequest(i18n, userId);
};
const handleStartVoiceCall = async (event?: PressEvent) => {
try {
const channelId = await PrivateChannelActionCreators.ensureDMChannel(userId);
await CallUtils.checkAndStartCall(channelId, event?.shiftKey ?? false);
} catch (error) {
logger.error('Failed to start voice call:', error);
}
};
const handleStartVideoCall = async (event?: PressEvent) => {
try {
const channelId = await PrivateChannelActionCreators.ensureDMChannel(userId);
await CallUtils.checkAndStartCall(channelId, event?.shiftKey ?? false);
} catch (error) {
logger.error('Failed to start video call:', error);
}
};
const handleReportUser = () => {
const context: IARContext = {
type: 'user',
user: displayUser,
guildId,
};
ModalActionCreators.push(modal(() => <IARModal context={context} />));
};
const handleCopyFluxerTag = () => {
TextCopyActionCreators.copy(i18n, `${displayUser.username}#${displayUser.discriminator}`, true);
};
const handleCopyUserId = () => {
TextCopyActionCreators.copy(i18n, displayUser.id, true);
};
const handleMoreOptionsPointerDown = (event: React.PointerEvent) => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen = !!contextMenu && contextMenu.target.target === moreOptionsButtonRef.current;
if (isOpen) {
event.stopPropagation();
event.preventDefault();
ContextMenuActionCreators.close();
}
};
const renderBlockMenuItem = (onClose: () => void) => {
switch (relationshipType) {
case RelationshipTypes.BLOCKED:
return (
<MenuItem
icon={<ProhibitIcon />}
onClick={() => {
handleUnblockUser();
onClose();
}}
>
{t`Unblock`}
</MenuItem>
);
default:
return (
<MenuItem
icon={<ProhibitIcon />}
onClick={() => {
handleBlockUser();
onClose();
}}
danger
>
{t`Block`}
</MenuItem>
);
}
};
const openMoreOptionsMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen = !!contextMenu && contextMenu.target.target === event.currentTarget;
if (isOpen) {
return;
}
ContextMenuActionCreators.openFromEvent(event, (props) => (
<>
{hasGuildProfile && (
<MenuGroup>
<MenuItem
icon={<GlobeIcon />}
onClick={() => {
setShowGlobalProfile(!showGlobalProfile);
props.onClose();
}}
>
{showGlobalProfile ? t`View Community Profile` : t`View Global Profile`}
</MenuItem>
</MenuGroup>
)}
{!isCurrentUser && !isUserBot && relationshipType === RelationshipTypes.FRIEND && (
<MenuGroup>
<MenuItem
icon={<VideoCameraIcon />}
onClick={(pressEvent: PressEvent) => {
handleStartVoiceCall(pressEvent);
props.onClose();
}}
>
{t`Start Voice Call`}
</MenuItem>
<MenuItem
icon={<VideoCameraIcon />}
onClick={(pressEvent: PressEvent) => {
handleStartVideoCall(pressEvent);
props.onClose();
}}
>
{t`Start Video Call`}
</MenuItem>
</MenuGroup>
)}
<MenuGroup>
<MenuItem
icon={<CopyIcon />}
onClick={() => {
handleCopyFluxerTag();
props.onClose();
}}
>
{t`Copy FluxerTag`}
</MenuItem>
<MenuItem
icon={<IdentificationCardIcon />}
onClick={() => {
handleCopyUserId();
props.onClose();
}}
>
{t`Copy User ID`}
</MenuItem>
</MenuGroup>
{!isCurrentUser && relationshipType === RelationshipTypes.FRIEND && (
<MenuGroup>
<MenuItem
icon={<UserMinusIcon className={userProfileModalStyles.menuIcon} weight="fill" />}
onClick={() => {
handleRemoveFriend();
props.onClose();
}}
danger
>
{t`Remove Friend`}
</MenuItem>
</MenuGroup>
)}
{!isCurrentUser && (
<MenuGroup>
<MenuItem
icon={<FlagIcon />}
onClick={() => {
handleReportUser();
props.onClose();
}}
danger
>
{t`Report User`}
</MenuItem>
{renderBlockMenuItem(props.onClose)}
</MenuGroup>
)}
</>
));
};
const renderActionButtons = () => {
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
if (isCurrentUser && disableEditProfile) {
return (
<div className={userProfileModalStyles.actionButtons}>
<Tooltip text={t`You can't befriend yourself`} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
leftIcon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
disabled={true}
>
<Trans>Add Friend</Trans>
</Button>
</div>
</Tooltip>
<Tooltip text={t`You can't message yourself`} maxWidth="xl">
<div>
<Button
small={true}
leftIcon={<ChatTeardropIcon className={userProfileModalStyles.buttonIcon} />}
disabled={true}
>
<Trans>Message</Trans>
</Button>
</div>
</Tooltip>
</div>
);
}
if (isCurrentUser && !disableEditProfile) {
return (
<div className={userProfileModalStyles.actionButtons}>
<Button
small={true}
leftIcon={<PencilIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleEditProfile}
>
<Trans>Edit Profile</Trans>
</Button>
<Button
ref={moreOptionsButtonRef}
small={true}
square={true}
variant="secondary"
icon={<DotsThreeIcon className={userProfileModalStyles.buttonIcon} weight="bold" />}
onPointerDownCapture={handleMoreOptionsPointerDown}
onClick={openMoreOptionsMenu}
className={isMoreMenuOpen ? userProfileModalStyles.moreMenuButtonActive : undefined}
/>
</div>
);
}
const renderPrimaryActionButton = () => {
if (isUserBot && !isFriendlyBot) {
return null;
}
if (relationshipType === RelationshipTypes.FRIEND) {
return (
<Tooltip text={t`Remove Friend`} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
square={true}
icon={<UserMinusIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleRemoveFriend}
/>
</div>
</Tooltip>
);
}
if (relationshipType === RelationshipTypes.BLOCKED) {
return (
<Tooltip text={t`Unblock User`} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
square={true}
icon={<ProhibitIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleUnblockUser}
/>
</div>
</Tooltip>
);
}
if (relationshipType === RelationshipTypes.INCOMING_REQUEST) {
return (
<Tooltip text={t`Accept Friend Request`} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
square={true}
icon={<CheckCircleIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleAcceptFriendRequest}
/>
</div>
</Tooltip>
);
}
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
return (
<Tooltip text={t`Cancel Friend Request`} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
square={true}
icon={<ClockCounterClockwiseIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleCancelFriendRequest}
/>
</div>
</Tooltip>
);
}
if (relationshipType === undefined && (!isUserBot || isFriendlyBot)) {
const tooltipText = currentUserUnclaimed
? t`Claim your account to send friend requests.`
: t`Send Friend Request`;
return (
<Tooltip text={tooltipText} maxWidth="xl">
<div>
<Button
variant="secondary"
small={true}
square={true}
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleSendFriendRequest}
disabled={currentUserUnclaimed}
/>
</div>
</Tooltip>
);
}
return null;
};
return (
<div className={userProfileModalStyles.actionButtons}>
<Button
small={true}
leftIcon={<ChatTeardropIcon className={userProfileModalStyles.buttonIcon} />}
onClick={isBlocked ? handleOpenBlockedDm : handleMessage}
>
{isBlocked ? <Trans>Open DM</Trans> : <Trans>Message</Trans>}
</Button>
{renderPrimaryActionButton()}
<Button
ref={moreOptionsButtonRef}
small={true}
square={true}
variant="secondary"
icon={<DotsThreeIcon className={userProfileModalStyles.buttonIcon} weight="bold" />}
onPointerDownCapture={handleMoreOptionsPointerDown}
onClick={openMoreOptionsMenu}
className={isMoreMenuOpen ? userProfileModalStyles.moreMenuButtonActive : undefined}
/>
</div>
);
};
const borderProfile = displayProfile?.getEffectiveProfile() ?? null;
const borderColor = getUserAccentColor(displayUser, borderProfile?.accent_color);
return (
<Modal.Root
size="medium"
initialFocusRef={autoFocusNote ? noteRef : undefined}
className={userProfileModalStyles.modalRoot}
>
<Modal.ScreenReaderLabel text={screenReaderLabel} />
<div className={userProfileModalStyles.modalContainer} style={{borderColor}}>
{shouldShowSpinner ? (
<div className={userProfileModalStyles.loadingScreen}>
<Spinner size="large" />
</div>
) : (
<ProfileModalContent
key={displayUser.id}
profile={resolvedProfile}
user={displayUser}
userNote={userNote}
autoFocusNote={autoFocusNote}
noteRef={noteRef}
renderActionButtons={renderActionButtons}
showProfileDataWarning={shouldShowProfileDataWarning}
previewOverrides={previewOverrides}
/>
)}
</div>
</Modal.Root>
);
},
);