mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(app): keep avatar stack entries keyed by user across re-renders (#1908)
This commit is contained in:
1 parent
5eb7822691
commit
97eb84fd46
1 file changed
+78
-74
@@ -39,6 +39,11 @@ export interface AvatarStackProps {
|
||||
onUserContextMenu?: (event: React.MouseEvent<HTMLElement>, user: User, index: number) => void;
|
||||
}
|
||||
|
||||
interface AvatarStackEntry {
|
||||
key: string;
|
||||
node: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
||||
({
|
||||
children,
|
||||
@@ -56,69 +61,14 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
||||
onUserContextMenu,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const childArray = React.Children.toArray(children).filter(Boolean);
|
||||
const userChildren =
|
||||
users?.map((user, index) => {
|
||||
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
||||
const avatarNode = renderAvatar?.(user, size, index) ?? (
|
||||
<Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" />
|
||||
);
|
||||
if (!avatarNode) return null;
|
||||
if (enableProfileModal) {
|
||||
const profileTrigger = (
|
||||
<FocusRing offset={-2} data-flx="ui.avatars.avatar-stack.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.avatarButton}
|
||||
aria-label={i18n._(OPEN_PROFILE_FOR_DESCRIPTOR, {displayName})}
|
||||
data-flx="ui.avatars.avatar-stack.avatar-button"
|
||||
>
|
||||
{avatarNode}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
return (
|
||||
<PreloadableUserPopout
|
||||
key={user.id}
|
||||
user={user}
|
||||
isWebhook={false}
|
||||
guildId={guildId ?? undefined}
|
||||
channelId={channelId ?? undefined}
|
||||
disableContextMenu={true}
|
||||
tooltip={showTooltips ? displayName : undefined}
|
||||
data-flx="ui.avatars.avatar-stack.preloadable-user-popout"
|
||||
>
|
||||
{profileTrigger}
|
||||
</PreloadableUserPopout>
|
||||
);
|
||||
}
|
||||
const content = (
|
||||
<div className={styles.avatarContent} data-flx="ui.avatars.avatar-stack.avatar-content">
|
||||
{avatarNode}
|
||||
</div>
|
||||
);
|
||||
if (!showTooltips) {
|
||||
return React.cloneElement(content, {key: user.id});
|
||||
}
|
||||
return (
|
||||
<Tooltip key={user.id} text={displayName} data-flx="ui.avatars.avatar-stack.tooltip">
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}) ?? [];
|
||||
const resolvedChildren = users ? userChildren.filter(Boolean) : childArray;
|
||||
const totalCount = resolvedChildren.length;
|
||||
const visibleChildren = resolvedChildren.slice(0, maxVisible);
|
||||
const remainingCount = Math.max(0, totalCount - maxVisible);
|
||||
const geometry = resolveAvatarStackGeometry(size, overlap);
|
||||
const cssVars = {
|
||||
'--avatar-size': geometry.sizeRem,
|
||||
'--avatar-overlap': geometry.overlapRem,
|
||||
'--avatar-outline': geometry.outlineRem,
|
||||
} as React.CSSProperties;
|
||||
const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number) => {
|
||||
const childEntries: Array<AvatarStackEntry> = React.Children.toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, index) => ({
|
||||
key: React.isValidElement(child) && child.key != null ? child.key : `avatar-stack-child-${index}`,
|
||||
node: child,
|
||||
}));
|
||||
const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number, displayName: string) => {
|
||||
if (!onUserContextMenu) return node;
|
||||
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
||||
return (
|
||||
<div
|
||||
className={styles.avatarContextMenuWrap}
|
||||
@@ -131,26 +81,80 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const userChildrenWithContextMenu =
|
||||
users && onUserContextMenu
|
||||
? userChildren.map((child, index) => {
|
||||
const user = users[index];
|
||||
return user ? wrapWithContextMenu(child, user, index) : child;
|
||||
})
|
||||
: resolvedChildren;
|
||||
const finalVisibleChildren = users ? userChildrenWithContextMenu.slice(0, maxVisible) : visibleChildren;
|
||||
const userEntries: Array<AvatarStackEntry> = [];
|
||||
const userKeyCounts = new Map<string, number>();
|
||||
users?.forEach((user, index) => {
|
||||
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
||||
const avatarNode = renderAvatar?.(user, size, index) ?? (
|
||||
<Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" />
|
||||
);
|
||||
if (!avatarNode) return;
|
||||
let node: React.ReactNode;
|
||||
if (enableProfileModal) {
|
||||
node = (
|
||||
<PreloadableUserPopout
|
||||
user={user}
|
||||
isWebhook={false}
|
||||
guildId={guildId ?? undefined}
|
||||
channelId={channelId ?? undefined}
|
||||
disableContextMenu={true}
|
||||
tooltip={showTooltips ? displayName : undefined}
|
||||
data-flx="ui.avatars.avatar-stack.preloadable-user-popout"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="ui.avatars.avatar-stack.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.avatarButton}
|
||||
aria-label={i18n._(OPEN_PROFILE_FOR_DESCRIPTOR, {displayName})}
|
||||
data-flx="ui.avatars.avatar-stack.avatar-button"
|
||||
>
|
||||
{avatarNode}
|
||||
</button>
|
||||
</FocusRing>
|
||||
</PreloadableUserPopout>
|
||||
);
|
||||
} else {
|
||||
const content = (
|
||||
<div className={styles.avatarContent} data-flx="ui.avatars.avatar-stack.avatar-content">
|
||||
{avatarNode}
|
||||
</div>
|
||||
);
|
||||
node = showTooltips ? (
|
||||
<Tooltip text={displayName} data-flx="ui.avatars.avatar-stack.tooltip">
|
||||
{content}
|
||||
</Tooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
const occurrence = userKeyCounts.get(user.id) ?? 0;
|
||||
userKeyCounts.set(user.id, occurrence + 1);
|
||||
userEntries.push({
|
||||
key: occurrence === 0 ? user.id : `${user.id}:${occurrence}`,
|
||||
node: wrapWithContextMenu(node, user, index, displayName),
|
||||
});
|
||||
});
|
||||
const resolvedEntries = users ? userEntries : childEntries;
|
||||
const remainingCount = Math.max(0, resolvedEntries.length - maxVisible);
|
||||
const visibleEntries = resolvedEntries.slice(0, maxVisible);
|
||||
const geometry = resolveAvatarStackGeometry(size, overlap);
|
||||
const cssVars = {
|
||||
'--avatar-size': geometry.sizeRem,
|
||||
'--avatar-overlap': geometry.overlapRem,
|
||||
'--avatar-outline': geometry.outlineRem,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<div className={clsx(styles.container, className)} style={cssVars} data-flx="ui.avatars.avatar-stack.container">
|
||||
{finalVisibleChildren.map((child, index) => (
|
||||
{visibleEntries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={entry.key}
|
||||
className={clsx(
|
||||
styles.avatar,
|
||||
(index < finalVisibleChildren.length - 1 || remainingCount > 0) && styles.withMask,
|
||||
(index < visibleEntries.length - 1 || remainingCount > 0) && styles.withMask,
|
||||
)}
|
||||
data-flx="ui.avatars.avatar-stack.avatar--2"
|
||||
>
|
||||
{child}
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 &&
|
||||
|
||||
Reference in new issue
Block a user