initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
50
fluxer_app/src/components/profile/ProfilePreview.module.css
Normal file
50
fluxer_app/src/components/profile/ProfilePreview.module.css
Normal 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;
|
||||
}
|
||||
303
fluxer_app/src/components/profile/ProfilePreview.tsx
Normal file
303
fluxer_app/src/components/profile/ProfilePreview.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user