Files
fluxer/fluxer_app/src/features/channel/components/MemberListItem.tsx
T

276 lines
9.8 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import {UserTag} from '@app/features/channel/components/ChannelUserTag';
import {CompactMemberCustomStatus} from '@app/features/channel/components/CompactMemberCustomStatus';
import styles from '@app/features/channel/components/MemberListItem.module.css';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import Guilds from '@app/features/guild/state/Guilds';
import {useMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus';
import {useMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
import {GroupDMMemberContextMenu} from '@app/features/ui/action_menu/GroupDMContextMenu';
import {GuildMemberContextMenu} from '@app/features/ui/action_menu/GuildMemberContextMenu';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {FocusRingWrapper} from '@app/features/ui/components/FocusRingWrapper';
import {ListStatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import {useTextOverflow} from '@app/features/ui/hooks/useTextOverflow';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import type {User} from '@app/features/user/models/User';
import type {CustomStatus} from '@app/features/user/state/CustomStatus';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {isOfflineStatus} from '@fluxer/constants/src/StatusConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CrownIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const COMMUNITY_OWNER_DESCRIPTOR = msg({
message: 'Community owner',
comment: 'Short label in the channel and chat member list item. Keep it concise.',
});
const GROUP_OWNER_DESCRIPTOR = msg({
message: 'Group owner',
comment: 'Short label in the direct message member list item. Keep it concise.',
});
type MemberRoleColorStyle = React.CSSProperties & {'--member-role-color': string};
interface MemberListItemProps {
user: User;
channelId: string;
guildId?: string;
guildMember?: GuildMember;
status?: StatusType;
customStatus?: CustomStatus | null;
isOwner?: boolean;
roleColor?: string;
displayName?: string;
disableBackdrop?: boolean;
deferAvatarLoad?: boolean;
deferCustomStatusMedia?: boolean;
avatarMediaSize?: MediaProxyImageSize;
}
export const MemberListItem: React.FC<MemberListItemProps> = observer((props) => {
const {i18n} = useLingui();
const {
user,
channelId,
guildId,
guildMember,
status: providedStatus,
customStatus: providedCustomStatus,
isOwner = false,
roleColor,
displayName,
disableBackdrop = false,
deferAvatarLoad = false,
deferCustomStatusMedia = false,
avatarMediaSize,
} = props;
const itemRef = useRef<HTMLButtonElement>(null);
const hookStatus = useMemberListPresence({
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined && providedStatus === undefined,
});
const status = providedStatus ?? hookStatus;
const hookCustomStatus = useMemberListCustomStatus({
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined && providedCustomStatus === undefined,
});
const memberListCustomStatus = providedCustomStatus !== undefined ? providedCustomStatus : hookCustomStatus;
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isCurrentUser = user.id === Authentication.currentUserId;
const isTyping = TypingIndicator.isMemberListTyping(channelId, user.id, Authentication.currentUserId);
useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenu.contextMenu;
const targetElement = contextMenu?.target.target;
const isNodeTarget = typeof Node !== 'undefined' && targetElement instanceof Node;
const isOpen = Boolean(contextMenu && isNodeTarget && itemRef.current?.contains(targetElement));
setContextMenuOpen(isOpen);
});
return () => {
disposer();
};
}, []);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<>
{guildId ? (
<GuildMemberContextMenu
user={user}
onClose={onClose}
guildId={guildId}
channelId={channelId}
member={guildMember}
data-flx="channel.member-list-item.handle-context-menu.guild-member-context-menu"
/>
) : (
<GroupDMMemberContextMenu
userId={user.id}
channelId={channelId}
onClose={onClose}
data-flx="channel.member-list-item.handle-context-menu.group-dm-member-context-menu"
/>
)}
</>
));
},
[user, guildId, channelId, guildMember],
);
const ownerTitle = guildId ? i18n._(COMMUNITY_OWNER_DESCRIPTOR) : i18n._(GROUP_OWNER_DESCRIPTOR);
const hideOwnerCrown = guildId
? (Guilds.getGuild(guildId)?.features.has(GuildFeatures.HIDE_OWNER_CROWN) ?? false)
: false;
const showOwnerCrown = isOwner && !hideOwnerCrown;
const nickname = displayName || NicknameUtils.getNickname(user, guildId, channelId);
const nameRef = useRef<HTMLSpanElement>(null);
const isNameOverflowing = useTextOverflow(nameRef, {content: nickname, measureTextRange: true});
const nameStyle = useMemo<MemberRoleColorStyle | undefined>(
() => (roleColor ? {'--member-role-color': roleColor} : undefined),
[roleColor],
);
const memberAvatarUrl = useMemo(() => {
if (!guildId || !guildMember) {
return undefined;
}
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
user,
memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(),
animated: false,
size: avatarMediaSize,
});
}, [avatarMediaSize, guildId, guildMember, user]);
const memberHoverAvatarUrl = useMemo(() => {
if (!guildId || !guildMember) {
return undefined;
}
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
user,
memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(),
animated: true,
size: avatarMediaSize,
});
}, [avatarMediaSize, guildId, guildMember, user]);
const nameContent = (
<span
ref={nameRef}
className={clsx(styles.name, roleColor && styles.nameRoleColored)}
style={nameStyle}
data-flx="channel.member-list-item.name"
>
{nickname}
</span>
);
const content = (
<FocusRingWrapper
focusRingClassName={styles.memberFocusRing}
data-flx="channel.member-list-item.focus-ring-wrapper"
>
<button
type="button"
className={clsx(
styles.button,
!isCurrentUser && isOfflineStatus(status) && !contextMenuOpen && styles.buttonOffline,
contextMenuOpen && styles.buttonContextMenuOpen,
)}
onContextMenu={handleContextMenu}
data-member-list-focus-item="true"
data-flx="channel.member-list-item.button.context-menu"
>
<div className={styles.grid} data-flx="channel.member-list-item.grid">
<span className={styles.content} data-flx="channel.member-list-item.content">
<div className={styles.avatarContainer} data-flx="channel.member-list-item.avatar-container">
<ListStatusAwareAvatar
user={user}
size={32}
isTyping={isTyping}
showOffline={isCurrentUser || isTyping}
guildId={guildId}
status={status}
avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize}
deferImageLoad={deferAvatarLoad}
data-flx="channel.member-list-item.status-aware-avatar"
/>
</div>
<div className={styles.userInfoContainer} data-flx="channel.member-list-item.user-info-container">
<div className={styles.nameContainer} data-flx="channel.member-list-item.name-container">
{isNameOverflowing ? (
<Tooltip text={nickname} data-flx="channel.member-list-item.name-tooltip">
{nameContent}
</Tooltip>
) : (
nameContent
)}
{showOwnerCrown && (
<div className={styles.ownerIcon} data-flx="channel.member-list-item.owner-icon">
<Tooltip text={ownerTitle} data-flx="channel.member-list-item.tooltip">
<CrownIcon className={styles.crownIcon} data-flx="channel.member-list-item.crown-icon" />
</Tooltip>
</div>
)}
{user.bot && (
<UserTag
className={styles.userTag}
system={user.system}
data-flx="channel.member-list-item.user-tag"
/>
)}
</div>
<CompactMemberCustomStatus
customStatus={memberListCustomStatus}
userId={user.id}
className={styles.memberCustomStatus}
deferMediaLoad={deferCustomStatusMedia}
data-flx="channel.member-list-item.member-custom-status"
/>
</div>
</span>
</div>
</button>
</FocusRingWrapper>
);
return (
<PreloadableUserPopout
ref={itemRef}
user={user}
isWebhook={false}
guildId={guildId}
guildMember={guildMember}
channelId={channelId}
key={user.id}
disableContextMenu
disableBackdrop={disableBackdrop}
profilePopoutAnimationType="profile-slide-inverted"
data-flx="channel.member-list-item.preloadable-user-popout"
>
{content}
</PreloadableUserPopout>
);
});