initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/*
* 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/>.
*/
.iconMedium {
height: 20px;
width: 20px;
}
.noteButtonContainer {
transition: opacity 0.3s ease-in-out;
opacity: 0;
pointer-events: none;
}
.noteButtonContainerVisible {
opacity: 1;
pointer-events: auto;
}
.noteButton {
cursor: pointer;
border: none;
background: transparent;
padding: 0;
}
.noteTooltipContent {
max-width: 13rem;
text-align: center;
}
.noteIconWrapper {
padding-top: 0.25rem;
color: var(--text-primary-muted);
}
.copyIdButtonContainer {
transition: opacity 0.3s ease-in-out;
opacity: 0;
pointer-events: none;
}
.copyIdButtonContainerVisible {
opacity: 1;
pointer-events: auto;
}
.copyIdButton {
cursor: pointer;
border: none;
background: transparent;
padding: 0;
}
.copyIdIconWrapper {
padding-top: 0.25rem;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,74 @@
/*
* 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 {useLingui} from '@lingui/react/macro';
import {ListPlusIcon, SnowflakeIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import UserNoteStore from '~/stores/UserNoteStore';
import styles from './ProfileCardActions.module.css';
interface ProfileCardActionsProps {
userId: string;
isHovering: boolean;
onNoteClick: () => void;
}
export const ProfileCardActions: React.FC<ProfileCardActionsProps> = observer(({userId, isHovering, onNoteClick}) => {
const {t, i18n} = useLingui();
const userNote = UserNoteStore.getUserNote(userId);
const noteButtonRef = React.useRef<HTMLButtonElement>(null);
const copyIdButtonRef = React.useRef<HTMLButtonElement>(null);
return (
<>
<div className={clsx(styles.noteButtonContainer, isHovering && styles.noteButtonContainerVisible)}>
<FocusRing offset={-2} focusTarget={noteButtonRef} ringTarget={noteButtonRef}>
<Tooltip
text={userNote ? () => <div className={styles.noteTooltipContent}>{userNote}</div> : t`Add Note`}
maxWidth="none"
>
<button ref={noteButtonRef} type="button" onClick={onNoteClick} className={styles.noteButton}>
<ListPlusIcon className={clsx(styles.iconMedium, styles.noteIconWrapper)} />
</button>
</Tooltip>
</FocusRing>
</div>
<div className={clsx(styles.copyIdButtonContainer, isHovering && styles.copyIdButtonContainerVisible)}>
<FocusRing offset={-2} focusTarget={copyIdButtonRef} ringTarget={copyIdButtonRef}>
<Tooltip text={t`Copy User ID`} maxWidth="none">
<button
ref={copyIdButtonRef}
type="button"
onClick={() => TextCopyActionCreators.copy(i18n, userId)}
className={styles.copyIdButton}
>
<SnowflakeIcon weight="bold" className={clsx(styles.iconMedium, styles.copyIdIconWrapper)} />
</button>
</Tooltip>
</FocusRing>
</div>
</>
);
});

View File

@@ -0,0 +1,68 @@
/*
* 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/>.
*/
.headerSection {
height: 140px;
position: relative;
}
.bannerWrapper {
flex-shrink: 0;
min-height: 105px;
position: relative;
}
.banner {
width: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
:where(.banner):before {
border-bottom: 1px solid var(--background-modifier-accent);
bottom: 0;
content: '';
left: 0;
position: absolute;
width: 100%;
}
.bannerMask {
contain: layout paint;
z-index: 0;
display: block;
width: 100%;
height: 100%;
}
.avatarButton {
position: absolute;
top: 55px;
left: 10px;
border: 6px solid var(--background-primary);
border-radius: 9999px;
background-color: var(--background-primary);
padding: 0;
outline: none;
}
.avatarButton:focus {
outline: none;
}

View File

@@ -0,0 +1,96 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {useId} from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import type {UserRecord} from '~/records/UserRecord';
import styles from './ProfileCardBanner.module.css';
interface ProfileCardBannerProps {
bannerUrl: string | null;
bannerColor: string;
user: UserRecord;
avatarUrl: string | null;
hoverAvatarUrl: string | null;
disablePresence?: boolean;
isClickable?: boolean;
onAvatarClick?: () => void;
headerHeight?: number;
}
export const ProfileCardBanner: React.FC<ProfileCardBannerProps> = observer(
({
bannerUrl,
bannerColor,
user,
avatarUrl,
hoverAvatarUrl,
disablePresence = false,
isClickable = true,
onAvatarClick,
headerHeight = 140,
}) => {
const bannerHeight = headerHeight === 140 ? 105 : 105;
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const maskId = `uid_${safeId}`;
const bannerStyle = {
height: bannerHeight,
minHeight: bannerHeight,
backgroundColor: bannerColor,
...(bannerUrl ? {backgroundImage: `url(${bannerUrl})`} : {}),
};
return (
<header className={styles.headerSection} style={{height: headerHeight}}>
<div className={styles.bannerWrapper} style={{minHeight: bannerHeight}}>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: this is fine */}
<svg className={styles.bannerMask} viewBox="0 0 300 105" preserveAspectRatio="none">
<mask id={maskId}>
<rect fill="white" x="0" y="0" width="300" height="105" />
<circle fill="black" cx="56" cy="101" r="46" />
</mask>
<foreignObject x="0" y="0" width="300" height="105" overflow="visible" mask={`url(#${maskId})`}>
<div className={styles.banner} style={bannerStyle} />
</foreignObject>
</svg>
</div>
<FocusRing offset={-2}>
<button type="button" onClick={onAvatarClick} className={styles.avatarButton}>
<StatusAwareAvatar
size={80}
user={user}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
disablePresence={disablePresence}
isClickable={isClickable}
/>
</button>
</FocusRing>
</header>
);
},
);

View File

@@ -0,0 +1,31 @@
/*
* 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/>.
*/
.contentSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
}
.contentSectionWebhook {
padding-bottom: 1rem;
}

View File

@@ -0,0 +1,32 @@
/*
* 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import styles from './ProfileCardContent.module.css';
interface ProfileCardContentProps {
children: React.ReactNode;
isWebhook?: boolean;
}
export const ProfileCardContent: React.FC<ProfileCardContentProps> = observer(({children, isWebhook = false}) => {
return <div className={clsx(styles.contentSection, isWebhook && styles.contentSectionWebhook)}>{children}</div>;
});

View File

@@ -0,0 +1,27 @@
/*
* 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/>.
*/
.footerSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
}

View File

@@ -0,0 +1,30 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import styles from './ProfileCardFooter.module.css';
interface ProfileCardFooterProps {
children: React.ReactNode;
}
export const ProfileCardFooter: React.FC<ProfileCardFooterProps> = observer(({children}) => {
return <footer className={styles.footerSection}>{children}</footer>;
});

View File

@@ -0,0 +1,39 @@
/*
* 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/>.
*/
.previewLabel {
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.profileCard {
position: relative;
display: flex;
width: 300px;
flex-direction: column;
gap: 4px;
overflow: hidden;
border-radius: 0.375rem;
border-width: 2px;
background-color: var(--background-primary);
padding-bottom: 0.75rem;
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import styles from './ProfileCardLayout.module.css';
interface ProfileCardLayoutProps {
borderColor: string;
showPreviewLabel?: boolean;
hoverRef?: (instance: HTMLDivElement | null) => void;
children: React.ReactNode;
}
export const ProfileCardLayout: React.FC<ProfileCardLayoutProps> = observer(
({borderColor, showPreviewLabel = false, hoverRef, children}) => {
return (
<div>
{showPreviewLabel && (
<div className={styles.previewLabel}>
<Trans>Profile Preview</Trans>
</div>
)}
<div ref={hoverRef} className={styles.profileCard} style={{borderColor}}>
{children}
</div>
</div>
);
},
);

View File

@@ -0,0 +1,103 @@
/*
* 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/>.
*/
.userInfoContainer {
user-select: text;
-webkit-user-select: text;
}
.nameRow {
display: flex;
align-items: center;
gap: 0.125rem;
}
.nameButton {
display: inline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: none;
background: transparent;
padding: 0;
text-align: left;
vertical-align: middle;
font-weight: 500;
color: var(--text-primary);
font-size: 1.25rem;
line-height: 1.5rem;
max-height: 1.5rem;
}
.nameButtonClickable {
cursor: pointer;
}
.nameButtonClickable:hover {
text-decoration: underline;
}
.badgeContainer {
display: inline;
}
.userTagWrapper {
margin-left: 0.25rem;
}
.actionsContainer {
margin-top: 0.25rem;
display: flex;
}
.usernameRow {
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
font-size: 14px;
color: var(--text-tertiary);
line-height: 18px;
}
.usernameButton {
display: inline;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: none;
background: transparent;
padding: 0;
text-align: left;
font: inherit;
color: inherit;
line-height: 18px;
max-height: 18px;
}
.usernameButton:hover {
text-decoration: underline;
}
.pronouns {
margin-top: 0.25rem;
font-size: 13px;
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,89 @@
/*
* 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {UserTag} from '~/components/channel/UserTag';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import type {UserRecord} from '~/records/UserRecord';
import styles from './ProfileCardUserInfo.module.css';
interface ProfileCardUserInfoProps {
displayName: string;
user: UserRecord;
pronouns?: string | null;
showUsername?: boolean;
isClickable?: boolean;
isWebhook?: boolean;
onDisplayNameClick?: () => void;
onUsernameClick?: () => void;
actions?: React.ReactNode;
usernameActions?: React.ReactNode;
}
export const ProfileCardUserInfo: React.FC<ProfileCardUserInfoProps> = observer(
({
displayName,
user,
pronouns,
showUsername = true,
isClickable = true,
isWebhook = false,
onDisplayNameClick,
onUsernameClick,
actions,
usernameActions,
}) => {
return (
<div className={styles.userInfoContainer}>
<div className={styles.nameRow}>
<FocusRing offset={-2}>
<button
type="button"
onClick={onDisplayNameClick}
className={clsx(styles.nameButton, isClickable && styles.nameButtonClickable)}
>
{displayName}
</button>
</FocusRing>
<div className={styles.badgeContainer}>
{(user.bot || isWebhook) && <UserTag className={styles.userTagWrapper} system={user.system} />}
</div>
{actions && <div className={styles.actionsContainer}>{actions}</div>}
</div>
{showUsername && (
<div className={styles.usernameRow}>
<FocusRing offset={-2}>
<button type="button" onClick={onUsernameClick} className={styles.usernameButton}>
{user.tag}
</button>
</FocusRing>
{usernameActions}
</div>
)}
{pronouns && <div className={styles.pronouns}>{pronouns}</div>}
</div>
);
},
);

View File

@@ -0,0 +1,50 @@
/*
* 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/>.
*/
.messageIcon {
height: 1rem;
width: 1rem;
}
.previewInteractive {
display: flex;
flex-direction: column;
gap: 0.5rem;
outline: none;
}
.messageButtonWrapper {
width: 100%;
}
.profileCustomStatus {
display: flex;
align-items: center;
gap: 0.35rem;
}
.profileCustomStatusText {
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-primary-muted);
}
.profileCustomStatus:hover .profileCustomStatusText {
--emoji-show-animated: 1;
}

View File

@@ -0,0 +1,303 @@
/*
* 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 {Trans, useLingui} from '@lingui/react/macro';
import {ChatTeardropIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {DEFAULT_ACCENT_COLOR} from '~/Constants';
import {CustomStatusDisplay} from '~/components/common/CustomStatusDisplay/CustomStatusDisplay';
import {CustomStatusModal} from '~/components/modals/CustomStatusModal';
import {UserProfileModal} from '~/components/modals/UserProfileModal';
import {UserProfileBadges} from '~/components/popouts/UserProfileBadges';
import {UserProfileBio, UserProfileMembershipInfo} from '~/components/popouts/UserProfileShared';
import {ProfileCardBanner} from '~/components/profile/ProfileCard/ProfileCardBanner';
import {ProfileCardContent} from '~/components/profile/ProfileCard/ProfileCardContent';
import {ProfileCardFooter} from '~/components/profile/ProfileCard/ProfileCardFooter';
import {ProfileCardLayout} from '~/components/profile/ProfileCard/ProfileCardLayout';
import {ProfileCardUserInfo} from '~/components/profile/ProfileCard/ProfileCardUserInfo';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useAutoplayExpandedProfileAnimations} from '~/hooks/useAutoplayExpandedProfileAnimations';
import type {CustomStatus} from '~/lib/customStatus';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {ProfileRecord} from '~/records/ProfileRecord';
import type {UserProfile, UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildStore from '~/stores/GuildStore';
import * as ColorUtils from '~/utils/ColorUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as ProfileDisplayUtils from '~/utils/ProfileDisplayUtils';
import {type BadgeSettings, createMockProfile} from '~/utils/ProfileUtils';
import styles from './ProfilePreview.module.css';
interface ProfilePreviewProps {
user: UserRecord;
previewAvatarUrl?: string | null;
previewBannerUrl?: string | null;
hasClearedAvatar?: boolean;
hasClearedBanner?: boolean;
previewBio?: string | null;
previewPronouns?: string | null;
previewAccentColor?: string | null;
previewGlobalName?: string | null;
previewNick?: string | null;
guildId?: string | null;
guildMember?: GuildMemberRecord | null;
guildMemberProfile?: UserProfile | null;
previewBadgeSettings?: BadgeSettings;
ignoreGuildAvatarInPreview?: boolean;
ignoreGuildBannerInPreview?: boolean;
showMembershipInfo?: boolean;
showMessageButton?: boolean;
showPreviewLabel?: boolean;
previewCustomStatus?: CustomStatus | null;
}
export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
({
user,
previewAvatarUrl,
previewBannerUrl,
hasClearedAvatar,
hasClearedBanner,
previewBio,
previewPronouns,
previewAccentColor,
previewGlobalName,
previewNick,
guildId,
guildMember,
guildMemberProfile,
previewBadgeSettings,
ignoreGuildAvatarInPreview,
ignoreGuildBannerInPreview,
showMembershipInfo = true,
showMessageButton = true,
showPreviewLabel = true,
previewCustomStatus,
}) => {
const {t} = useLingui();
const profileContext = React.useMemo<ProfileDisplayUtils.ProfileDisplayContext>(
() => ({
user,
profile: null,
guildId,
guildMember,
guildMemberProfile,
}),
[user, guildId, guildMember, guildMemberProfile],
);
const previewOverrides = React.useMemo<ProfileDisplayUtils.ProfilePreviewOverrides>(
() => ({
previewAvatarUrl,
previewBannerUrl,
hasClearedAvatar,
hasClearedBanner,
ignoreGuildAvatar: ignoreGuildAvatarInPreview,
ignoreGuildBanner: ignoreGuildBannerInPreview,
}),
[
previewAvatarUrl,
previewBannerUrl,
hasClearedAvatar,
hasClearedBanner,
ignoreGuildAvatarInPreview,
ignoreGuildBannerInPreview,
],
);
const {avatarUrl: finalAvatarUrl, hoverAvatarUrl: finalHoverAvatarUrl} = React.useMemo(
() => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides),
[profileContext, previewOverrides],
);
const shouldAutoplayProfileAnimations = useAutoplayExpandedProfileAnimations();
const finalBannerUrl = React.useMemo(
() => ProfileDisplayUtils.getProfileBannerUrl(profileContext, previewOverrides, shouldAutoplayProfileAnimations),
[profileContext, previewOverrides, shouldAutoplayProfileAnimations],
) as string | null;
const previewUser = React.useMemo(() => {
const bio = previewBio !== undefined ? previewBio : user.bio;
const pronouns = previewPronouns !== undefined ? previewPronouns : user.pronouns;
const globalName = previewGlobalName !== undefined ? previewGlobalName : user.globalName;
return user.withUpdates({bio, pronouns, global_name: globalName});
}, [user, previewBio, previewPronouns, previewGlobalName]);
const mockProfile = React.useMemo(() => {
const profile = createMockProfile(previewUser, {
previewBannerUrl,
hasClearedBanner,
previewBio,
previewPronouns,
previewAccentColor,
previewBadgeSettings,
});
if (guildId && guildMemberProfile) {
return profile
.withUpdates({
guild_member_profile: {
bio: previewBio !== undefined ? previewBio : guildMemberProfile.bio,
banner: previewBannerUrl || guildMemberProfile.banner,
pronouns: previewPronouns !== undefined ? previewPronouns : guildMemberProfile.pronouns,
accent_color: previewAccentColor !== undefined ? previewAccentColor : guildMemberProfile.accent_color,
},
})
.withGuildId(guildId);
}
return profile;
}, [
previewUser,
previewBannerUrl,
hasClearedBanner,
previewBio,
previewPronouns,
previewAccentColor,
previewBadgeSettings,
guildId,
guildMemberProfile,
]);
const openMockProfile = React.useCallback(() => {
ModalActionCreators.push(
modal(() => (
<UserProfileModal
userId={user.id}
guildId={guildId || undefined}
disableEditProfile={true}
previewOverrides={{
previewAvatarUrl,
previewBannerUrl,
hasClearedAvatar,
hasClearedBanner,
}}
previewUser={previewUser}
/>
)),
);
}, [user.id, guildId, previewAvatarUrl, previewBannerUrl, hasClearedAvatar, hasClearedBanner, previewUser]);
const pronouns = previewPronouns !== undefined ? previewPronouns : user.pronouns;
const displayName = previewNick || NicknameUtils.getNickname(previewUser, guildId);
const rawAccentColor = previewAccentColor !== undefined ? previewAccentColor : user.accentColor;
const accentColorHex = typeof rawAccentColor === 'number' ? ColorUtils.int2hex(rawAccentColor) : rawAccentColor;
const borderColor = accentColorHex || DEFAULT_ACCENT_COLOR;
const bannerColor = accentColorHex || DEFAULT_ACCENT_COLOR;
const selectedGuild = guildId ? GuildStore.getGuild(guildId) : null;
const hasPreviewStatus = previewCustomStatus !== undefined;
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const canEditCustomStatus = isCurrentUser && !hasPreviewStatus;
const openCustomStatus = React.useCallback(() => {
ModalActionCreators.push(modal(() => <CustomStatusModal />));
}, []);
const handlePreviewKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openMockProfile();
}
};
return (
<FocusRing offset={-2}>
<div
className={styles.previewInteractive}
role="group"
aria-label={t`Profile preview (press Enter to open full preview)`}
onKeyDown={handlePreviewKeyDown}
>
<ProfileCardLayout borderColor={borderColor} showPreviewLabel={showPreviewLabel}>
<ProfileCardBanner
bannerUrl={finalBannerUrl}
bannerColor={bannerColor}
user={user}
avatarUrl={finalAvatarUrl}
hoverAvatarUrl={finalHoverAvatarUrl}
isClickable={true}
onAvatarClick={openMockProfile}
/>
<UserProfileBadges user={previewUser} profile={mockProfile} />
<ProfileCardContent>
<ProfileCardUserInfo
displayName={displayName}
user={previewUser}
pronouns={pronouns}
showUsername={true}
isClickable={true}
onDisplayNameClick={openMockProfile}
onUsernameClick={openMockProfile}
/>
<div className={styles.profileCustomStatus}>
<CustomStatusDisplay
userId={hasPreviewStatus ? undefined : user.id}
customStatus={hasPreviewStatus ? previewCustomStatus : undefined}
className={styles.profileCustomStatusText}
allowJumboEmoji
maxLines={0}
isEditable={canEditCustomStatus}
onEdit={openCustomStatus}
showPlaceholder={canEditCustomStatus}
alwaysAnimate={shouldAutoplayProfileAnimations}
/>
</div>
<UserProfileBio profile={mockProfile} onShowMore={openMockProfile} />
{showMembershipInfo && (
<UserProfileMembershipInfo
profile={{...mockProfile, guild: selectedGuild, guildMember} as ProfileRecord}
user={previewUser}
/>
)}
</ProfileCardContent>
{showMessageButton && (
<ProfileCardFooter>
<Tooltip text={t`You can't message yourself`} maxWidth="xl">
<div className={styles.messageButtonWrapper}>
<Button
small={true}
fitContainer={true}
leftIcon={<ChatTeardropIcon className={styles.messageIcon} />}
disabled={true}
>
<Trans>Message</Trans>
</Button>
</div>
</Tooltip>
</ProfileCardFooter>
)}
</ProfileCardLayout>
</div>
</FocusRing>
);
},
);