Files
fluxer/fluxer_app/src/components/modals/UserProfileModal.tsx
2026-01-02 19:27:51 +00:00

1368 lines
43 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 {Plural, Trans, useLingui} from '@lingui/react/macro';
import {
CaretDownIcon,
ChatTeardropIcon,
CheckCircleIcon,
ClockCounterClockwiseIcon,
DotsThreeIcon,
PencilIcon,
ProhibitIcon,
UserMinusIcon,
UserPlusIcon,
UsersThreeIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import React, {useId} from 'react';
import type {PressEvent} from 'react-aria-components';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as UserNoteActionCreators from '~/actions/UserNoteActionCreators';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import {DEFAULT_ACCENT_COLOR, Permissions, RelationshipTypes} from '~/Constants';
import {UserTag} from '~/components/channel/UserTag';
import {CustomStatusDisplay} from '~/components/common/CustomStatusDisplay/CustomStatusDisplay';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {CustomStatusModal} from '~/components/modals/CustomStatusModal';
import type {IARContext} from '~/components/modals/IARModal';
import {IARModal} from '~/components/modals/IARModal';
import * as Modal from '~/components/modals/Modal';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {UserProfileBadges} from '~/components/popouts/UserProfileBadges';
import {UserProfileDataWarning} from '~/components/popouts/UserProfileDataWarning';
import {UserProfileBio, UserProfileMembershipInfo, UserProfileRoles} from '~/components/popouts/UserProfileShared';
import {Button} from '~/components/uikit/Button/Button';
import {
BlockUserIcon,
CopyFluxerTagIcon,
CopyUserIdIcon,
ReportUserIcon,
VideoCallIcon,
ViewGlobalProfileIcon,
VoiceCallIcon,
} from '~/components/uikit/ContextMenu/ContextMenuIcons';
import {GroupDMContextMenu} from '~/components/uikit/ContextMenu/GroupDMContextMenu';
import {GuildContextMenu} from '~/components/uikit/ContextMenu/GuildContextMenu';
import {GuildMemberContextMenu} from '~/components/uikit/ContextMenu/GuildMemberContextMenu';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
import {UserContextMenu} from '~/components/uikit/ContextMenu/UserContextMenu';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tabs} from '~/components/uikit/Tabs/Tabs';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useAutoplayExpandedProfileAnimations} from '~/hooks/useAutoplayExpandedProfileAnimations';
import {TextareaAutosize} from '~/lib/TextareaAutosize';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {ProfileRecord} from '~/records/ProfileRecord';
import {type UserPartial, UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import type {ContextMenuTargetElement} from '~/stores/ContextMenuStore';
import ContextMenuStore, {isContextMenuNodeTarget} from '~/stores/ContextMenuStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import MemberPresenceSubscriptionStore from '~/stores/MemberPresenceSubscriptionStore';
import ModalStore from '~/stores/ModalStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserNoteStore from '~/stores/UserNoteStore';
import UserProfileStore from '~/stores/UserProfileStore';
import UserStore from '~/stores/UserStore';
import * as CallUtils from '~/utils/CallUtils';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as ColorUtils from '~/utils/ColorUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as ProfileDisplayUtils from '~/utils/ProfileDisplayUtils';
import {createMockProfile} from '~/utils/ProfileUtils';
import * as RelationshipActionUtils from '~/utils/RelationshipActionUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import modalRootStyles from './Modal.module.css';
import userProfileModalStyles from './UserProfileModal.module.css';
export interface UserProfileModalProps {
userId: string;
guildId?: string;
autoFocusNote?: boolean;
disableEditProfile?: boolean;
previewOverrides?: ProfileDisplayUtils.ProfilePreviewOverrides;
previewUser?: UserRecord;
}
interface UserInfoProps {
user: UserRecord;
profile: ProfileRecord;
guildId?: string;
warningIndicator?: React.ReactNode;
isCurrentUser?: boolean;
onEditCustomStatus?: () => void;
}
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;
warningIndicator?: React.ReactNode;
}
const UserInfo: React.FC<UserInfoProps> = observer(
({user, profile, guildId, warningIndicator, isCurrentUser, onEditCustomStatus}) => {
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}>
<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}
warningIndicator={warningIndicator}
/>
</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}
isEditable={isCurrentUser}
onEdit={onEditCustomStatus}
showPlaceholder={isCurrentUser}
alwaysAnimate={shouldAutoplayProfileAnimations}
/>
</div>
</div>
</div>
</div>
);
},
);
const UserNoteEditor: React.FC<UserNoteEditorProps> = observer(({userId, initialNote, autoFocus, noteRef}) => {
const {t} = useLingui();
const [isEditing, setIsEditing] = React.useState(false);
const [localNote, setLocalNote] = React.useState<string | null>(null);
const internalNoteRef = React.useRef<HTMLTextAreaElement | null>(null);
const textareaRef = noteRef || internalNoteRef;
React.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});
return (
<div className={userProfileModalStyles.profileContent}>
<div className={userProfileModalStyles.profileContentHeader}>
<UserProfileBio profile={profile} />
<UserProfileMembershipInfo profile={profile} user={user} />
<UserProfileRoles
profile={profile}
user={user}
memberRoles={[...memberRoles]}
canManageRoles={canManageRoles}
/>
<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, warningIndicator}) => {
const {t} = useLingui();
const effectiveProfile = profile?.getEffectiveProfile() ?? null;
const rawAccentColor = effectiveProfile?.accent_color;
const accentColorHex = typeof rawAccentColor === 'number' ? ColorUtils.int2hex(rawAccentColor) : rawAccentColor;
const bannerColor = accentColorHex || DEFAULT_ACCENT_COLOR;
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? '', user.id);
const profileContext = React.useMemo<ProfileDisplayUtils.ProfileDisplayContext>(
() => ({
user,
profile,
guildId: profile?.guildId,
guildMember,
guildMemberProfile: profile?.guildMemberProfile,
}),
[user, profile, guildMember],
);
const shouldAutoplayProfileAnimations = useAutoplayExpandedProfileAnimations();
const {avatarUrl, hoverAvatarUrl} = React.useMemo(
() => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides),
[profileContext, previewOverrides],
);
const bannerUrl = React.useMemo(
() => ProfileDisplayUtils.getProfileBannerUrl(profileContext, previewOverrides, shouldAutoplayProfileAnimations),
[profileContext, previewOverrides, shouldAutoplayProfileAnimations],
);
type MutualView = 'mutual_friends' | 'mutual_communities' | 'mutual_groups';
const [activeTab, setActiveTab] = React.useState<'overview' | 'mutual'>('overview');
const handleTabChange = React.useCallback((tab: 'overview' | 'mutual') => {
setActiveTab(tab);
}, []);
const showMutualFriendsTab = !user.bot;
const mutualFriendsCount = profile?.mutualFriends?.length ?? 0;
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const openCustomStatus = React.useCallback(() => {
ModalActionCreators.push(modal(() => <CustomStatusModal />));
}, []);
const mutualGuilds = React.useMemo(() => GuildMemberStore.getMutualGuilds(user.id), [user.id]);
const mutualGroups = ChannelStore.dmChannels.filter(
(channel) => channel.isGroupDM() && channel.recipientIds.includes(user.id),
);
const [mutualView, setMutualView] = React.useState<MutualView>(
showMutualFriendsTab ? 'mutual_friends' : 'mutual_communities',
);
const mutualMenuButtonRef = React.useRef<HTMLButtonElement>(null);
const [isMutualMenuOpen, setIsMutualMenuOpen] = React.useState(false);
const getMutualViewLabel = React.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 = mutualGuilds.length;
return t`Mutual Communities (${count})`;
}
}
},
[t, mutualFriendsCount, mutualGroups.length, mutualGuilds.length],
);
const openMutualMenu = React.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 = React.useMemo(() => getMutualViewLabel(mutualView), [getMutualViewLabel, mutualView]);
const tabs = React.useMemo(
() =>
[
{key: 'overview', label: t`Overview`},
{key: 'mutual', label: mutualTabLabelText},
] as Array<{key: 'overview' | 'mutual'; label: React.ReactNode}>,
[t, mutualTabLabelText],
);
React.useEffect(() => {
setMutualView(showMutualFriendsTab ? 'mutual_friends' : 'mutual_communities');
}, [showMutualFriendsTab, user.id]);
React.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);
RouterUtils.transitionTo(Routes.guildChannel(guild.id, selectedChannel));
NavigationActionCreators.selectGuild(guild.id);
};
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();
RouterUtils.transitionTo(Routes.dmChannel(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] = React.useState<ContextMenuTargetElement | null>(null);
React.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;
};
React.useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen =
!!contextMenu && !!mutualMenuButtonRef.current && contextMenu.target.target === mutualMenuButtonRef.current;
setIsMutualMenuOpen(isOpen);
});
return () => {
disposer();
};
}, []);
const renderMutualFriendsList = React.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 = React.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 = React.useCallback(() => {
return (
<div className={userProfileModalStyles.mutualFriendsList}>
{mutualGuilds.map((guild) => (
<MutualGuildItem
key={guild.id}
guild={guild}
onClick={() => handleGuildClick(guild)}
onContextMenu={(e) => handleGuildContextMenu(e, guild)}
isContextMenuOpen={isContextMenuOpenFor}
/>
))}
{mutualGuilds.length === 0 && (
<div className={userProfileModalStyles.emptyState}>
<UsersThreeIcon className={userProfileModalStyles.emptyStateIcon} />
<Trans>No mutual communities found.</Trans>
</div>
)}
</div>
);
}, [handleGuildClick, handleGuildContextMenu, isContextMenuOpenFor, mutualGuilds]);
const renderMutualTabContent = React.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 = React.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}>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: this is fine */}
<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})`}>
{bannerUrl ? (
<div
className={userProfileModalStyles.bannerImage}
style={{
backgroundImage: `url(${bannerUrl})`,
}}
/>
) : (
<div className={userProfileModalStyles.bannerColor} style={{backgroundColor: bannerColor}} />
)}
</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}
warningIndicator={warningIndicator}
isCurrentUser={isCurrentUser}
onEditCustomStatus={openCustomStatus}
/>
{!isCurrentUser ? (
<div className={userProfileModalStyles.tabsWrapper}>
<Tabs
activeTab={activeTab}
onTabChange={handleTabChange}
tabs={tabs}
renderTabSibling={(tab) =>
tab === 'mutual' ? (
<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>
) : 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 = React.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,
onClick,
onContextMenu,
isContextMenuOpen,
}: {
guild: GuildRecord;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
isContextMenuOpen: (target: EventTarget | null) => boolean;
}) => {
const itemRef = React.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>
</div>
</div>
);
};
const MutualGroupItem = ({
group,
onClick,
onContextMenu,
isContextMenuOpen,
}: {
group: ChannelRecord;
onClick: () => void;
onContextMenu: (e: React.MouseEvent) => void;
isContextMenuOpen: (target: EventTarget | null) => boolean;
}) => {
const itemRef = React.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}
>
<GroupDMAvatar channel={group} size={40} />
<div className={userProfileModalStyles.mutualFriendInfo}>
<span className={userProfileModalStyles.mutualFriendName}>{ChannelUtils.getDMDisplayName(group)}</span>
<span className={userProfileModalStyles.mutualFriendUsername}>
<Plural value={group.recipientIds.length + 1} one="# Member" other="# Members" />
</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 = React.useMemo(
() =>
new UserRecord({
id: userId,
username: userId,
discriminator: '0000',
avatar: null,
flags: 0,
}),
[userId],
);
const displayUser = user ?? fallbackUser;
const fallbackProfile = React.useMemo(() => createMockProfile(fallbackUser), [fallbackUser]);
const mockProfile = React.useMemo(() => (user ? createMockProfile(user) : null), [user]);
const initialProfile = React.useMemo(() => UserProfileStore.getProfile(userId, guildId), [userId, guildId]);
const [profile, setProfile] = React.useState<ProfileRecord | null>(initialProfile);
const [profileLoadError, setProfileLoadError] = React.useState(false);
const [showGlobalProfile, setShowGlobalProfile] = React.useState(false);
const [isProfileLoading, setIsProfileLoading] = React.useState(() => !previewUser && !initialProfile);
const userNote = UserNoteStore.getUserNote(userId);
const isCurrentUser = user?.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(userId);
const relationshipType = relationship?.type;
const isUserBot = user?.bot ?? false;
const noteRef = React.useRef<HTMLTextAreaElement | null>(null);
const moreOptionsButtonRef = React.useRef<HTMLButtonElement>(null);
const [isMoreMenuOpen, setIsMoreMenuOpen] = React.useState(false);
React.useEffect(() => {
setProfile(initialProfile);
setIsProfileLoading(!previewUser && !initialProfile);
}, [initialProfile, previewUser]);
React.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;
console.error('Failed to fetch user profile:', error);
setProfileLoadError(true);
})
.finally(() => {
if (cancelled) return;
setIsProfileLoading(false);
});
return () => {
cancelled = true;
};
}, [userId, guildId, previewUser, profile]);
React.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();
}, []);
React.useEffect(() => {
if (!guildId || !userId || previewUser) {
return;
}
const hasMember = GuildMemberStore.getMember(guildId, userId);
if (!hasMember) {
MemberPresenceSubscriptionStore.touchMember(guildId, userId);
GuildMemberStore.fetchMembers(guildId, {userIds: [userId]});
} 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 = React.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 = React.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) {
console.error('Failed to open DM channel:', error);
}
};
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) {
console.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) {
console.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={<BlockUserIcon />}
onClick={() => {
handleUnblockUser();
onClose();
}}
>
{t`Unblock`}
</MenuItem>
);
default:
return (
<MenuItem
icon={<BlockUserIcon />}
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={<ViewGlobalProfileIcon />}
onClick={() => {
setShowGlobalProfile(!showGlobalProfile);
props.onClose();
}}
>
{showGlobalProfile ? t`View Community Profile` : t`View Global Profile`}
</MenuItem>
</MenuGroup>
)}
{!isCurrentUser && !isUserBot && relationshipType === RelationshipTypes.FRIEND && (
<MenuGroup>
<MenuItem
icon={<VoiceCallIcon />}
onClick={(pressEvent: PressEvent) => {
handleStartVoiceCall(pressEvent);
props.onClose();
}}
>
{t`Start Voice Call`}
</MenuItem>
<MenuItem
icon={<VideoCallIcon />}
onClick={(pressEvent: PressEvent) => {
handleStartVideoCall(pressEvent);
props.onClose();
}}
>
{t`Start Video Call`}
</MenuItem>
</MenuGroup>
)}
<MenuGroup>
<MenuItem
icon={<CopyFluxerTagIcon />}
onClick={() => {
handleCopyFluxerTag();
props.onClose();
}}
>
{t`Copy FluxerTag`}
</MenuItem>
<MenuItem
icon={<CopyUserIdIcon />}
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={<ReportUserIcon />}
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) {
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) {
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={handleMessage}
>
<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 rawAccentColor = borderProfile?.accent_color;
const accentColorHex = typeof rawAccentColor === 'number' ? ColorUtils.int2hex(rawAccentColor) : rawAccentColor;
const borderColor = accentColorHex || DEFAULT_ACCENT_COLOR;
return (
<Modal.Root
size="medium"
initialFocusRef={autoFocusNote ? noteRef : undefined}
className={clsx(modalRootStyles.root, modalRootStyles.medium, 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}
warningIndicator={shouldShowProfileDataWarning ? <UserProfileDataWarning /> : undefined}
previewOverrides={previewOverrides}
/>
)}
</div>
</Modal.Root>
);
},
);