mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +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;
|
onUserContextMenu?: (event: React.MouseEvent<HTMLElement>, user: User, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AvatarStackEntry {
|
||||||
|
key: string;
|
||||||
|
node: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
||||||
({
|
({
|
||||||
children,
|
children,
|
||||||
@@ -56,69 +61,14 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
|||||||
onUserContextMenu,
|
onUserContextMenu,
|
||||||
}) => {
|
}) => {
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const childArray = React.Children.toArray(children).filter(Boolean);
|
const childEntries: Array<AvatarStackEntry> = React.Children.toArray(children)
|
||||||
const userChildren =
|
.filter(Boolean)
|
||||||
users?.map((user, index) => {
|
.map((child, index) => ({
|
||||||
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
key: React.isValidElement(child) && child.key != null ? child.key : `avatar-stack-child-${index}`,
|
||||||
const avatarNode = renderAvatar?.(user, size, index) ?? (
|
node: child,
|
||||||
<Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" />
|
}));
|
||||||
);
|
const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number, displayName: string) => {
|
||||||
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) => {
|
|
||||||
if (!onUserContextMenu) return node;
|
if (!onUserContextMenu) return node;
|
||||||
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.avatarContextMenuWrap}
|
className={styles.avatarContextMenuWrap}
|
||||||
@@ -131,26 +81,80 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const userChildrenWithContextMenu =
|
const userEntries: Array<AvatarStackEntry> = [];
|
||||||
users && onUserContextMenu
|
const userKeyCounts = new Map<string, number>();
|
||||||
? userChildren.map((child, index) => {
|
users?.forEach((user, index) => {
|
||||||
const user = users[index];
|
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
|
||||||
return user ? wrapWithContextMenu(child, user, index) : child;
|
const avatarNode = renderAvatar?.(user, size, index) ?? (
|
||||||
})
|
<Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" />
|
||||||
: resolvedChildren;
|
);
|
||||||
const finalVisibleChildren = users ? userChildrenWithContextMenu.slice(0, maxVisible) : visibleChildren;
|
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 (
|
return (
|
||||||
<div className={clsx(styles.container, className)} style={cssVars} data-flx="ui.avatars.avatar-stack.container">
|
<div className={clsx(styles.container, className)} style={cssVars} data-flx="ui.avatars.avatar-stack.container">
|
||||||
{finalVisibleChildren.map((child, index) => (
|
{visibleEntries.map((entry, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={entry.key}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.avatar,
|
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"
|
data-flx="ui.avatars.avatar-stack.avatar--2"
|
||||||
>
|
>
|
||||||
{child}
|
{entry.node}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{remainingCount > 0 &&
|
{remainingCount > 0 &&
|
||||||
|
|||||||
Reference in new issue
Block a user