fix(app): paint avatars from a real image with a fading placeholder (#1856)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:32:14 +02:00
1 parent b49cf349a8
commit 4c14ba0a5e
13 files changed
+384 -358

No files matched your search

@@ -5,7 +5,6 @@ import styles from '@app/features/app/components/shared/GroupDMAvatar.module.css
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import type {Channel} from '@app/features/channel/models/Channel';
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils';
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
import Presence from '@app/features/presence/state/Presence';
import TransientPresence from '@app/features/presence/state/TransientPresence';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -147,7 +146,7 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
width={1}
height={1}
fill={statusColor}
mask={`url(#svg-mask-status-${renderableStatus})`}
mask={`url(#flx-mask-presence-${renderableStatus})`}
data-flx="app.group-dm-avatar.render-group-status-dot.rect"
/>
</svg>
@@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
const user = Users.getUser(userId);
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
let avatarUrl: string;
if (user) {
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar});
} else {
const avatarIndex = index % 6;
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
}
const avatarUrl = user
? AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar})
: AvatarUtils.getDefaultAvatarURL(userId);
return (
<image
key={userId}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useEffect, useState} from 'react';
type ConnectionLike = {
saveData?: boolean;
addEventListener?: (event: string, cb: () => void) => void;
removeEventListener?: (event: string, cb: () => void) => void;
};
const getConnection = (): ConnectionLike | null => {
if (typeof navigator === 'undefined') return null;
const nav = navigator as Navigator & {connection?: ConnectionLike};
return nav.connection ?? null;
};
const readSaveData = (): boolean => getConnection()?.saveData === true;
const saveDataListeners = new Set<(value: boolean) => void>();
let cachedSaveData = readSaveData();
let detachConnectionListener: (() => void) | null = null;
function publishSaveData(): void {
const next = readSaveData();
if (next === cachedSaveData) return;
cachedSaveData = next;
for (const listener of saveDataListeners) listener(next);
}
export function subscribeSaveData(listener: (value: boolean) => void): () => void {
saveDataListeners.add(listener);
if (detachConnectionListener === null) {
const connection = getConnection();
if (connection?.addEventListener) {
connection.addEventListener('change', publishSaveData);
const {removeEventListener} = connection;
detachConnectionListener = removeEventListener
? () => removeEventListener.call(connection, 'change', publishSaveData)
: () => {};
}
}
return () => {
saveDataListeners.delete(listener);
if (saveDataListeners.size > 0) return;
detachConnectionListener?.();
detachConnectionListener = null;
};
}
export function useSaveData(): boolean {
const [saveData, setSaveData] = useState(readSaveData);
useEffect(() => {
const unsubscribe = subscribeSaveData(setSaveData);
cachedSaveData = readSaveData();
setSaveData(cachedSaveData);
return unsubscribe;
}, []);
return saveData;
}
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useSaveData} from '@app/features/app/hooks/useSaveData';
import UserSettings from '@app/features/user/state/UserSettings';
import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants';
import {useEffect, useState} from 'react';
export type ShouldAnimateKind =
| 'avatar'
@@ -17,65 +16,13 @@ export type ShouldAnimateKind =
export interface UseShouldAnimateOptions {
kind: ShouldAnimateKind;
isAnimated?: boolean;
isHovering?: boolean;
isFocused?: boolean;
entitlementOk?: boolean;
respectPlaybackAllowed?: boolean;
}
type ConnectionLike = {
saveData?: boolean;
addEventListener?: (event: string, cb: () => void) => void;
};
const getConnection = (): ConnectionLike | undefined => {
const nav =
typeof navigator === 'undefined'
? undefined
: (navigator as Navigator & {
connection?: ConnectionLike;
});
return nav?.connection;
};
let cachedSaveData: boolean = getConnection()?.saveData === true;
const saveDataListeners = new Set<(value: boolean) => void>();
let saveDataListenerInstalled = false;
function installSaveDataListener(): void {
if (saveDataListenerInstalled) return;
const connection = getConnection();
if (!connection?.addEventListener) {
saveDataListenerInstalled = true;
return;
}
connection.addEventListener('change', () => {
const next = getConnection()?.saveData === true;
if (next !== cachedSaveData) {
cachedSaveData = next;
saveDataListeners.forEach((listener) => listener(next));
}
});
saveDataListenerInstalled = true;
}
function useSaveData(): boolean {
const [saveData, setSaveData] = useState(() => getConnection()?.saveData === true);
useEffect(() => {
installSaveDataListener();
const live = getConnection()?.saveData === true;
if (live !== cachedSaveData) cachedSaveData = live;
setSaveData(live);
saveDataListeners.add(setSaveData);
return () => {
saveDataListeners.delete(setSaveData);
};
}, []);
return saveData;
}
function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean {
if (kind === 'emoji' || kind === 'gif' || kind === 'sticker') {
return Accessibility.isAnimationKeptUnderReducedMotion(kind);
@@ -106,27 +53,27 @@ function getKindAllowance(kind: ShouldAnimateKind): AnimationAllowanceMode {
}
export interface ShouldAnimateDecisionInput {
isAnimated?: boolean;
allowance: AnimationAllowanceMode;
reducedMotion: boolean;
keptUnderReducedMotion: boolean;
isInteracting: boolean;
entitlementOk?: boolean;
saveData: boolean;
animatedMediaPlaybackAllowed: boolean;
}
export function resolveShouldAnimateDecision({
isAnimated = true,
allowance,
reducedMotion,
keptUnderReducedMotion,
isInteracting,
entitlementOk,
saveData,
animatedMediaPlaybackAllowed,
}: ShouldAnimateDecisionInput): boolean {
if (!isAnimated) return false;
if (entitlementOk === false) return false;
if (saveData) return false;
if (!animatedMediaPlaybackAllowed) return false;
if (allowance === 'NEVER') return false;
if (reducedMotion && !keptUnderReducedMotion) return isInteracting;
if (allowance === 'ALWAYS') return true;
@@ -135,21 +82,20 @@ export function resolveShouldAnimateDecision({
export function useShouldAnimate({
kind,
isAnimated = true,
isHovering = false,
isFocused = false,
entitlementOk,
respectPlaybackAllowed = true,
}: UseShouldAnimateOptions): boolean {
const saveData = useSaveData();
const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed({enabled: respectPlaybackAllowed});
const allowance = getKindAllowance(kind);
return resolveShouldAnimateDecision({
isAnimated,
allowance,
reducedMotion: Accessibility.useReducedMotion,
keptUnderReducedMotion: isKeptUnderReducedMotion(kind),
isInteracting: isHovering || isFocused,
entitlementOk,
saveData,
animatedMediaPlaybackAllowed,
});
}
@@ -75,8 +75,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
roleColor,
displayName,
disableBackdrop = false,
deferAvatarLoad = false,
deferCustomStatusMedia = false,
deferCustomStatusMedia = false,
avatarMediaSize,
} = props;
const itemRef = useRef<HTMLButtonElement>(null);
@@ -214,8 +213,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize}
deferImageLoad={deferAvatarLoad}
data-flx="channel.member-list-item.status-aware-avatar"
data-flx="channel.member-list-item.status-aware-avatar"
/>
</div>
<div className={styles.userInfoContainer} data-flx="channel.member-list-item.user-info-container">
@@ -181,8 +181,7 @@ export const MobileMemberListItem = observer(
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
mediaSize={MEMBER_LIST_AVATAR_MEDIA_SIZE}
deferImageLoad={deferAvatarLoad}
data-flx="channel.channel-details-bottom-sheet-member-list.mobile-member-list-item.status-aware-avatar"
data-flx="channel.channel-details-bottom-sheet-member-list.mobile-member-list-item.status-aware-avatar"
/>
<div
className={styles.memberContent}
@@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import GuildMembers from '@app/features/member/state/GuildMembers';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import styles from '@app/features/ui/avatars/AvatarWithPresence.module.css';
import {BaseAvatar} from '@app/features/ui/components/BaseAvatar';
import type {User} from '@app/features/user/models/User';
import {parseAvatarHash} from '@app/features/user/utils/AvatarMediaUtils';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -11,6 +15,7 @@ import {MicrophoneSlashIcon, SpeakerSlashIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useState} from 'react';
const MUTED_VOICE_BADGE_LABEL = msg({
message: 'Muted',
@@ -47,17 +52,42 @@ export const AvatarWithPresence: React.FC<Props> = observer(function AvatarWithP
}) {
const {i18n} = useLingui();
const guildMember = GuildMembers.getMember(guildId || '', user.id);
const animated = speaking ?? false;
const src =
const resolveSrc = (animated: boolean) =>
guildId && guildMember
? AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
user,
user: {id: user.id, avatar: user.avatar},
memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(),
animated,
})
: AvatarUtils.getUserAvatarURL(user, animated);
: AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar}, animated);
const displayAvatarHash =
guildId && guildMember ? (guildMember.isAvatarUnset() ? null : (guildMember.avatar ?? user.avatar)) : user.avatar;
const isAnimatableAvatar = displayAvatarHash != null && parseAvatarHash(displayAvatarHash).animated;
const src = resolveSrc(false);
const animatedSrc = isAnimatableAvatar ? resolveSrc(true) : null;
const hoverSrc = animatedSrc && animatedSrc !== src ? animatedSrc : undefined;
const speakingNow = speaking === true;
const settingsAllowAnimation = useShouldAnimate({
kind: 'avatar',
isAnimated: isAnimatableAvatar,
isHovering: speakingNow && !Accessibility.useReducedMotion,
});
const wantsAnimatedAvatar = hoverSrc != null && speakingNow && settingsAllowAnimation;
const [loadedAnimatedSrc, setLoadedAnimatedSrc] = useState<string | null>(null);
useEffect(() => {
if (!hoverSrc || !wantsAnimatedAvatar) return;
let active = true;
const cancelAnimatedAvatarLoad = ImageCacheUtils.loadImage(hoverSrc, () => {
if (active) setLoadedAnimatedSrc(hoverSrc);
});
return () => {
active = false;
cancelAnimatedAvatarLoad();
};
}, [hoverSrc, wantsAnimatedAvatar]);
const isAnimatedAvatarLoaded = hoverSrc != null && loadedAnimatedSrc === hoverSrc;
const voiceBadge = deafened ? (
<SpeakerSlashIcon
weight="fill"
@@ -82,13 +112,15 @@ export const AvatarWithPresence: React.FC<Props> = observer(function AvatarWithP
<BaseAvatar
size={size}
avatarUrl={src}
hoverAvatarUrl={isAnimatedAvatarLoaded ? hoverSrc : undefined}
shouldPlayAnimated={wantsAnimatedAvatar && isAnimatedAvatarLoaded}
className={clsx(styles.container, speaking && styles.containerSpeaking, borderClassName, className)}
userTag={ariaLabel ?? user.displayName}
disableStatusTooltip
customStatusBadge={voiceBadge}
customStatusBadgeColor="var(--status-danger)"
customStatusBadgeLabel={voiceBadgeLabel}
customStatusBadgeMaskId="svg-mask-status-online"
customStatusBadgeMaskId="flx-mask-presence-online"
customStatusBadgeScale={1.5}
customStatusBadgeMaxSizeRatio={0.36}
customStatusBadgeCutoutPaddingScale={1.35}
+64 -107
View File
@@ -15,7 +15,7 @@ import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSiz
import {normalizeStatus} from '@fluxer/constants/src/StatusConstants';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React, {type CSSProperties, useEffect, useMemo, useState} from 'react';
import React, {type CSSProperties, useCallback, useEffect, useMemo, useState} from 'react';
interface AvatarProps {
user: User;
@@ -34,55 +34,10 @@ interface AvatarProps {
hoverAvatarUrl?: string | null;
guildId?: string | null;
mediaSize?: MediaProxyImageSize;
deferImageLoad?: boolean;
animateStatusCutout?: boolean;
title?: never;
}
type AvatarImagePresentation =
| {
kind: 'image';
avatarUrl: string;
showSkeleton: false;
}
| {
kind: 'deferredKnownAvatar';
avatarUrl: '';
showSkeleton: true;
};
function resolveInitiallyLoadedImageUrl(url: string | null): string | null {
if (url == null) return null;
if (ImageCacheUtils.hasImage(url)) return url;
return null;
}
function resolveAvatarImagePresentation({
avatarUrl,
fallbackAvatarUrl,
deferImageLoad,
isStaticLoaded,
}: {
avatarUrl: string | null;
fallbackAvatarUrl: string;
deferImageLoad: boolean;
isStaticLoaded: boolean;
}): AvatarImagePresentation {
const hasKnownAvatar = Boolean(avatarUrl) && avatarUrl !== fallbackAvatarUrl;
if (deferImageLoad && !isStaticLoaded && hasKnownAvatar) {
return {
kind: 'deferredKnownAvatar',
avatarUrl: '',
showSkeleton: true,
};
}
return {
kind: 'image',
avatarUrl: deferImageLoad && !isStaticLoaded ? fallbackAvatarUrl : (avatarUrl ?? fallbackAvatarUrl),
showSkeleton: false,
};
}
const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
(
{
@@ -101,51 +56,73 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
hoverAvatarUrl: customHoverAvatarUrl,
guildId,
mediaSize,
deferImageLoad = false,
animateStatusCutout = false,
...props
},
ref,
) => {
const {i18n} = useLingui();
const guildMember = GuildMembers.getMember(guildId || '', user.id);
const userId = user.id;
const userAvatar = user.avatar;
const guildMember = GuildMembers.getMember(guildId || '', userId);
const hasGuildMemberAvatarSource = Boolean(guildId) && guildMember != null;
const memberAvatar = guildMember?.avatar ?? null;
const memberAvatarUnset = guildMember?.isAvatarUnset() ?? false;
const avatarUrl = useMemo(() => {
if (customAvatarUrl !== undefined) return customAvatarUrl;
if (guildId && guildMember) {
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
user,
memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(),
user: {id: userId, avatar: userAvatar},
memberAvatar,
avatarUnset: memberAvatarUnset,
animated: false,
size: mediaSize,
});
}
return AvatarUtils.getUserAvatarURL(user, false, mediaSize);
}, [user, customAvatarUrl, guildId, guildMember, mediaSize]);
return AvatarUtils.getUserAvatarURL({id: userId, avatar: userAvatar}, false, mediaSize);
}, [
customAvatarUrl,
guildId,
hasGuildMemberAvatarSource,
memberAvatar,
memberAvatarUnset,
mediaSize,
userAvatar,
userId,
]);
const hoverAvatarUrl = useMemo(() => {
if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl;
if (guildId && guildMember) {
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
user,
memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(),
user: {id: userId, avatar: userAvatar},
memberAvatar,
avatarUnset: memberAvatarUnset,
animated: true,
size: mediaSize,
});
}
return AvatarUtils.getUserAvatarURL(user, true, mediaSize);
}, [user, customHoverAvatarUrl, guildId, guildMember, mediaSize]);
return AvatarUtils.getUserAvatarURL({id: userId, avatar: userAvatar}, true, mediaSize);
}, [
customHoverAvatarUrl,
guildId,
hasGuildMemberAvatarSource,
memberAvatar,
memberAvatarUnset,
mediaSize,
userAvatar,
userId,
]);
const statusLabel = status != null ? getStatusTypeLabel(i18n, status) : null;
const hasDistinctHoverAvatar = Boolean(hoverAvatarUrl && hoverAvatarUrl !== avatarUrl);
const [hoverRef, isHovering] = useHover();
const settingsAnimationAllowed = useShouldAnimate({
const animationAllowed = useShouldAnimate({
kind: 'avatar',
isHovering: hasDistinctHoverAvatar && (isHovering || forceAnimate),
respectPlaybackAllowed: hasDistinctHoverAvatar,
isAnimated: hasDistinctHoverAvatar,
isHovering: hasDistinctHoverAvatar && (isHovering || forceAnimate || forceAnimateIgnoringSettings),
});
const animationAllowed = hasDistinctHoverAvatar && (forceAnimateIgnoringSettings || settingsAnimationAllowed);
const inlineAutoplayAllowed = animationAllowed && forceAnimateIgnoringSettings;
const [requestedAnimatedUrl, setRequestedAnimatedUrl] = useState<string | null>(() =>
hasDistinctHoverAvatar && animationAllowed ? hoverAvatarUrl : null,
);
@@ -155,55 +132,34 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
}
}, [hasDistinctHoverAvatar, animationAllowed, hoverAvatarUrl]);
const isAnimatedNeeded = hasDistinctHoverAvatar && (animationAllowed || requestedAnimatedUrl === hoverAvatarUrl);
const [loadedStaticUrl, setLoadedStaticUrl] = useState<string | null>(() =>
resolveInitiallyLoadedImageUrl(avatarUrl),
);
const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState<string | null>(() =>
resolveInitiallyLoadedImageUrl(hoverAvatarUrl),
);
const rendersAnimatedInline = isAnimatedNeeded && inlineAutoplayAllowed;
const [loadedStaticUrl, setLoadedStaticUrl] = useState<string | null>(null);
const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState<string | null>(null);
const isStaticLoaded = avatarUrl != null && loadedStaticUrl === avatarUrl;
const isAnimatedLoaded = hasDistinctHoverAvatar && hoverAvatarUrl != null && loadedAnimatedUrl === hoverAvatarUrl;
const handleImageLoaded = useCallback(
(loadedUrl: string) => {
if (loadedUrl === avatarUrl) setLoadedStaticUrl(loadedUrl);
else if (loadedUrl === hoverAvatarUrl) setLoadedAnimatedUrl(loadedUrl);
},
[avatarUrl, hoverAvatarUrl],
);
useEffect(() => {
const staticLoaded = ImageCacheUtils.hasImage(avatarUrl);
const animatedLoaded = hasDistinctHoverAvatar ? ImageCacheUtils.hasImage(hoverAvatarUrl) : false;
if (staticLoaded && loadedStaticUrl !== avatarUrl && avatarUrl != null) {
setLoadedStaticUrl(avatarUrl);
}
if (animatedLoaded && loadedAnimatedUrl !== hoverAvatarUrl && hoverAvatarUrl != null) {
setLoadedAnimatedUrl(hoverAvatarUrl);
}
if (deferImageLoad && !staticLoaded) {
if (!isAnimatedNeeded || rendersAnimatedInline || hoverAvatarUrl == null) {
return;
}
let active = true;
const cleanupStaticLoad = ImageCacheUtils.loadImage(avatarUrl, () => {
if (active && avatarUrl != null) {
setLoadedStaticUrl(avatarUrl);
}
const cleanupAnimatedLoad = ImageCacheUtils.loadImage(hoverAvatarUrl, () => {
if (active) setLoadedAnimatedUrl(hoverAvatarUrl);
});
let cleanupAnimatedLoad: (() => void) | undefined;
if (isAnimatedNeeded && !deferImageLoad && hoverAvatarUrl != null) {
cleanupAnimatedLoad = ImageCacheUtils.loadImage(hoverAvatarUrl, () => {
if (active) setLoadedAnimatedUrl(hoverAvatarUrl);
});
}
return () => {
active = false;
cleanupStaticLoad();
if (cleanupAnimatedLoad !== undefined) cleanupAnimatedLoad();
cleanupAnimatedLoad();
};
}, [avatarUrl, hoverAvatarUrl, hasDistinctHoverAvatar, isAnimatedNeeded, deferImageLoad]);
}, [hoverAvatarUrl, isAnimatedNeeded, rendersAnimatedInline]);
const shouldPlayAnimated = hasDistinctHoverAvatar && animationAllowed && isAnimatedLoaded;
const fallbackAvatarUrl = useMemo(
() => AvatarUtils.getUserAvatarURL({id: user.id, avatar: null}, false),
[user.id],
);
const avatarPresentation = resolveAvatarImagePresentation({
avatarUrl,
fallbackAvatarUrl,
deferImageLoad,
isStaticLoaded,
});
const fallbackAvatarUrl = useMemo(() => AvatarUtils.getUserAvatarURL({id: userId, avatar: null}, false), [userId]);
const resolvedAvatarUrl = avatarUrl ?? fallbackAvatarUrl;
const safeHoverAvatarUrl = isAnimatedNeeded ? hoverAvatarUrl || undefined : undefined;
const normalizedStatusAttr = status != null ? normalizeStatus(status) : undefined;
const displayName = NicknameUtils.getNickname(user, guildId ?? null);
@@ -213,13 +169,14 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
<BaseAvatar
ref={mergedRef}
size={size}
avatarUrl={avatarPresentation.avatarUrl}
avatarUrl={resolvedAvatarUrl}
hoverAvatarUrl={safeHoverAvatarUrl}
onImageLoaded={handleImageLoaded}
status={status}
isMobileStatus={isMobileStatus}
showSkeleton={avatarPresentation.showSkeleton}
showSkeleton
shouldPlayAnimated={shouldPlayAnimated && isStaticLoaded}
forceAnimatedPlayback={forceAnimateIgnoringSettings}
forceAnimatedPlayback={inlineAutoplayAllowed}
isTyping={isTyping}
showOffline={showOffline}
className={className}
@@ -229,11 +186,11 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
disableStatusTooltip={disableStatusTooltip}
animateStatusCutout={animateStatusCutout}
data-flx="ui.avatar.avatar-component.base-avatar"
data-flx-user-id={user.id}
data-flx-user-id={userId}
data-flx-user-username={NicknameUtils.formatNameForStreamerMode(user.username)}
data-flx-user-name={displayName}
data-flx-user-bot={user.bot ? 'true' : undefined}
data-flx-user-self={user.id === Users.currentUserId ? 'true' : undefined}
data-flx-user-self={userId === Users.currentUserId ? 'true' : undefined}
data-flx-guild-id={guildId ?? undefined}
data-flx-size={String(size)}
data-flx-status={normalizedStatusAttr ?? undefined}
@@ -21,6 +21,37 @@
border-radius: 50%;
}
.image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
.placeholder {
fill: var(--background-secondary-alt);
pointer-events: none;
transition: none;
}
:global(html:not(.reduced-motion)) .placeholder {
transition: opacity 0.2s ease;
}
:global(html:not(.reduced-motion)) .placeholderFastFade {
transition: opacity 0.05s ease;
}
.placeholderVisible {
opacity: 1;
}
.placeholderHidden {
opacity: 0;
}
.hoverOverlay {
opacity: 0;
transition: opacity 0.2s ease;
@@ -61,18 +92,20 @@
transition-duration: 0s;
}
.statusAnimationDriven {
transition: none;
}
.animatedMaskShape {
cx: var(--avatar-mask-cx);
x: var(--avatar-mask-x);
y: var(--avatar-mask-y);
width: var(--avatar-mask-width);
height: var(--avatar-mask-height);
rx: var(--avatar-mask-radius);
ry: var(--avatar-mask-radius);
transition:
cx var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
x var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
width var(--avatar-status-transition-duration) var(--avatar-status-transition-easing);
y var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
width var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
height var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
rx var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
ry var(--avatar-status-transition-duration) var(--avatar-status-transition-easing);
}
.animatedMaskReducedMotion {
@@ -2,7 +2,7 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {type AvatarStatusLayout, getAvatarStatusLayout} from '@app/features/ui/components/AvatarStatusLayout';
import styles from '@app/features/ui/components/BaseAvatar.module.css';
@@ -13,7 +13,7 @@ import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {normalizeStatus, StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import React, {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useId, useMemo, useState} from 'react';
const AVATAR_DESCRIPTOR = msg({
message: 'Avatar',
@@ -29,7 +29,8 @@ const STATUS_DESCRIPTOR = msg({
});
const DEFAULT_CUSTOM_STATUS_BADGE_SCALE = 1.25;
const DEFAULT_CUSTOM_STATUS_BADGE_CUTOUT_PADDING_SCALE = 1.2;
const STATUS_TRANSITION_DURATION_MS = 160;
const AVATAR_IMAGE_RETRY_LIMIT = 3;
const PLACEHOLDER_FAST_FADE_WINDOW_MS = 200;
type AvatarCSSProperties = React.CSSProperties & {
'--avatar-status-width'?: string;
@@ -42,6 +43,14 @@ type AvatarCSSProperties = React.CSSProperties & {
'--avatar-typing-dot-size'?: string;
};
type AvatarMaskCSSProperties = React.CSSProperties & {
'--avatar-mask-x'?: string;
'--avatar-mask-y'?: string;
'--avatar-mask-width'?: string;
'--avatar-mask-height'?: string;
'--avatar-mask-radius'?: string;
};
interface AvatarStatusBox {
width: number;
height: number;
@@ -51,10 +60,18 @@ interface AvatarStatusBox {
cutoutPadding: number;
}
type PlaceholderFade = 'instant' | 'fast' | 'gradual';
interface PaintedAvatarImage {
url: string;
fade: PlaceholderFade;
}
const SVG_MASK_URL_CACHE = new Map<string, string>();
const AVATAR_VIEW_BOX_CACHE = new Map<number, string>();
const STATIC_AVATAR_MASK_SIZES = new Set([16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120]);
const STATUS_INDICATOR_STYLE = {display: 'block', width: '100%', height: '100%'} satisfies React.CSSProperties;
const PLACEHOLDER_INSTANT_STYLE = {transition: 'none'} satisfies React.CSSProperties;
type DynamicAvatarMaskKind = 'animated' | 'mobile' | 'round' | 'typing';
@@ -64,9 +81,6 @@ interface AvatarMaskConfig {
}
function getSvgMaskUrl(maskId: string): string {
if (maskId.startsWith('svg-mask-avatar-dynamic-')) {
return `url(#${maskId})`;
}
const cached = SVG_MASK_URL_CACHE.get(maskId);
if (cached) return cached;
const value = `url(#${maskId})`;
@@ -82,14 +96,13 @@ function getAvatarViewBox(size: number): string {
return value;
}
function getDynamicAvatarMaskId(kind: DynamicAvatarMaskKind, size: number): string {
return `svg-mask-avatar-dynamic-${kind}-${String(size).replace(/[^A-Za-z0-9_-]/g, '_')}`;
}
const isImagePainted = (image: HTMLImageElement): boolean => image.complete && image.naturalWidth > 0;
interface BaseAvatarProps {
size: number;
avatarUrl: string;
hoverAvatarUrl?: string;
onImageLoaded?: (src: string) => void;
status?: StatusType | string | null;
shouldPlayAnimated?: boolean;
forceAnimatedPlayback?: boolean;
@@ -120,6 +133,7 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
size,
avatarUrl,
hoverAvatarUrl,
onImageLoaded,
status,
shouldPlayAnimated = false,
forceAnimatedPlayback = false,
@@ -145,17 +159,12 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
ref,
) => {
const {i18n} = useLingui();
const dynamicMaskId = useId();
const animatedMediaPlaybackEnabled = Boolean((shouldPlayAnimated || forceAnimatedPlayback) && hoverAvatarUrl);
const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed({enabled: animatedMediaPlaybackEnabled});
const normalizedStatus = status == null ? null : normalizeStatus(status);
const renderableStatus = resolveRenderableStatus(normalizedStatus);
const isMobileOnline = isMobileStatus && renderableStatus === StatusTypes.ONLINE && !isTyping;
const layout = getAvatarStatusLayout(size, isMobileOnline);
const rawDynamicMaskId = useId();
const dynamicAvatarMaskId = useMemo(
() => `svg-mask-avatar-dynamic-${rawDynamicMaskId.replace(/[^A-Za-z0-9_-]/g, '_')}`,
[rawDynamicMaskId],
);
const hasCustomStatusBadge = customStatusBadge != null;
const shouldShowPresenceStatus =
isTyping || (normalizedStatus != null && (showOffline || renderableStatus !== StatusTypes.OFFLINE));
@@ -165,35 +174,75 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
const shouldUseDynamicAvatarMask = shouldShowCustomStatusBadge || shouldAnimateAvatarMask;
const maskIsMobileOnline = shouldShowCustomStatusBadge ? false : isMobileOnline;
const reducedMotion = Accessibility.useReducedMotion;
const statusAnimationProgress = useAvatarStatusAnimationProgress({
target: isTyping ? 1 : 0,
enabled: shouldAnimateAvatarMask && !reducedMotion,
});
const candidateUrl =
animatedMediaPlaybackEnabled && animatedMediaPlaybackAllowed ? hoverAvatarUrl || '' : avatarUrl;
const candidateUrl = animatedMediaPlaybackEnabled ? hoverAvatarUrl || '' : avatarUrl;
const [imgError, setImgError] = useState(false);
const [imgRetryCount, setImgRetryCount] = useState(0);
const [wasCachedAtMount] = useState(() => ImageCacheUtils.hasImage(candidateUrl));
const [mountedAt] = useState(() => Date.now());
const [paintedImage, setPaintedImage] = useState<PaintedAvatarImage | null>(() =>
wasCachedAtMount ? {url: candidateUrl, fade: 'instant'} : null,
);
useEffect(() => {
setImgError(false);
setImgRetryCount(0);
}, [candidateUrl]);
useEffect(() => {
if (!imgError || !candidateUrl || imgRetryCount >= AVATAR_IMAGE_RETRY_LIMIT) {
return;
}
let active = true;
const cancelImageRetry = ImageCacheUtils.loadImage(candidateUrl, () => {
if (!active) return;
setImgRetryCount((count) => count + 1);
setImgError(false);
});
return () => {
active = false;
cancelImageRetry();
};
}, [candidateUrl, imgError, imgRetryCount]);
const handleImageRef = useCallback(
(node: HTMLImageElement | null) => {
if (node == null) return;
const painted = isImagePainted(node);
setPaintedImage((current) => {
if (!painted) return current?.url === candidateUrl ? null : current;
if (current?.url === candidateUrl) return current;
return {url: candidateUrl, fade: 'instant'};
});
if (painted) onImageLoaded?.(candidateUrl);
},
[candidateUrl, onImageLoaded],
);
const handleImageLoad = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
const image = event.currentTarget;
const src = image.getAttribute('src');
ImageCacheUtils.rememberImage(src, image);
if (src != null && src.length > 0) {
const fade: PlaceholderFade = Date.now() - mountedAt < PLACEHOLDER_FAST_FADE_WINDOW_MS ? 'fast' : 'gradual';
setPaintedImage((current) => (current?.url === src ? current : {url: src, fade}));
onImageLoaded?.(src);
}
},
[mountedAt, onImageLoaded],
);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
const showFallback = !candidateUrl || imgError;
const avatarMask = resolveAvatarMask({
shouldShowStatus,
isTyping,
isMobileOnline: maskIsMobileOnline,
size,
animatedMaskId: shouldUseDynamicAvatarMask ? dynamicAvatarMaskId : null,
});
const avatarMaskUrl = getSvgMaskUrl(avatarMask.id);
const paintedCandidate = paintedImage?.url === candidateUrl ? paintedImage : null;
const shouldMountPlaceholder = showSkeleton && !wasCachedAtMount;
const isPlaceholderVisible = showSkeleton && paintedCandidate == null;
const placeholderStyle = paintedCandidate?.fade === 'instant' ? PLACEHOLDER_INSTANT_STYLE : undefined;
const placeholderFastFade = paintedCandidate?.fade === 'fast';
const statusMaskId = shouldShowCustomStatusBadge
? (customStatusBadgeMaskId ?? 'svg-mask-status-online')
? (customStatusBadgeMaskId ?? 'flx-mask-presence-online')
: isMobileOnline
? STATIC_AVATAR_MASK_SIZES.has(size)
? `svg-mask-status-online-mobile-${size}`
: 'svg-mask-status-online-mobile'
: `svg-mask-status-${renderableStatus}`;
? `flx-mask-presence-online-mobile-${size}`
: 'flx-mask-presence-online-mobile'
: `flx-mask-presence-${renderableStatus}`;
const avatarViewBox = getAvatarViewBox(size);
const statusColor = shouldShowCustomStatusBadge
? (customStatusBadgeColor ?? `var(--status-${renderableStatus})`)
@@ -203,11 +252,8 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
[size],
);
const regularStatusBox = useMemo(
() =>
shouldAnimateAvatarMask && !reducedMotion
? getInterpolatedAvatarStatusBox(layout, isMobileOnline, statusAnimationProgress)
: getAvatarStatusBox(layout, isTyping, isMobileOnline),
[isMobileOnline, isTyping, layout, reducedMotion, shouldAnimateAvatarMask, statusAnimationProgress],
() => getAvatarStatusBox(layout, isTyping, isMobileOnline),
[isMobileOnline, isTyping, layout],
);
const customStatusBox = useMemo(
() =>
@@ -221,6 +267,16 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
[customStatusBadgeCutoutPaddingScale, customStatusBadgeMaxSizeRatio, customStatusBadgeScale, layout, size],
);
const statusBox = shouldShowCustomStatusBadge ? customStatusBox : regularStatusBox;
const avatarMask = resolveAvatarMask({
shouldShowStatus,
isTyping,
isMobileOnline: maskIsMobileOnline,
size,
dynamicMaskId,
useDynamicMask: shouldUseDynamicAvatarMask,
});
const avatarMaskUrl = avatarMask.dynamicKind == null ? getSvgMaskUrl(avatarMask.id) : `url(#${avatarMask.id})`;
const animateAvatarMaskShape = shouldAnimateAvatarMask && !shouldShowCustomStatusBadge;
const statusContainerStyle = useMemo<AvatarCSSProperties>(() => {
const dotSize = Math.round(layout.innerTypingHeight * TYPING_DOT_SIZE_RATIO);
const dotGap = Math.round(layout.innerTypingHeight * TYPING_DOT_GAP_RATIO);
@@ -237,9 +293,7 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
}, [layout, statusBox, statusColor]);
const statusContainerClassName = `${styles.statusContainer} ${
isTyping || disableStatusTooltip ? styles.statusTooltipDisabled : ''
} ${reducedMotion ? styles.reducedMotion : ''} ${
shouldAnimateAvatarMask && !reducedMotion ? styles.statusAnimationDriven : ''
}`.trim();
} ${reducedMotion ? styles.reducedMotion : ''}`.trim();
const ariaLabel = statusLabel && userTag ? `${userTag}, ${statusLabel}` : userTag || i18n._(AVATAR_DESCRIPTOR);
const interactiveProps = useMemo(
() =>
@@ -331,30 +385,44 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
size={size}
layout={layout}
statusBox={statusBox}
animateShape={animateAvatarMaskShape}
reducedMotion={reducedMotion}
data-flx="ui.base-avatar.dynamic-avatar-mask"
/>
)}
{showSkeleton ? (
{shouldMountPlaceholder && (
<rect
className={`${styles.placeholder} ${isPlaceholderVisible ? styles.placeholderVisible : styles.placeholderHidden} ${placeholderFastFade ? styles.placeholderFastFade : ''}`.trim()}
style={placeholderStyle}
x={0}
y={0}
width={size}
height={size}
mask={avatarMaskUrl}
fill="var(--background-modifier-accent)"
opacity={0.45}
data-flx="ui.base-avatar.skeleton"
/>
) : showFallback ? null : (
<image
href={candidateUrl}
)}
{showFallback ? null : (
<foreignObject
x={0}
y={0}
width={size}
height={size}
mask={avatarMaskUrl}
preserveAspectRatio="xMidYMid slice"
onError={handleImageError}
data-flx="ui.base-avatar.image"
/>
data-flx="ui.base-avatar.image-frame"
>
<img
ref={handleImageRef}
className={styles.image}
src={candidateUrl}
alt=""
aria-hidden
draggable={false}
onLoad={handleImageLoad}
onError={handleImageError}
data-flx="ui.base-avatar.image"
/>
</foreignObject>
)}
<rect
className={styles.hoverOverlay}
@@ -442,108 +510,31 @@ function getCustomAvatarStatusBox(
};
}
function getInterpolatedAvatarStatusBox(
layout: AvatarStatusLayout,
isMobileOnline: boolean,
progress: number,
): AvatarStatusBox {
const statusBox = getAvatarStatusBox(layout, false, isMobileOnline);
const typingBox = getAvatarStatusBox(layout, true, isMobileOnline);
return {
width: interpolate(statusBox.width, typingBox.width, progress),
height: interpolate(statusBox.height, typingBox.height, progress),
right: interpolate(statusBox.right, typingBox.right, progress),
bottom: interpolate(statusBox.bottom, typingBox.bottom, progress),
radius: interpolate(statusBox.radius, typingBox.radius, progress),
cutoutPadding: interpolate(statusBox.cutoutPadding, typingBox.cutoutPadding, progress),
};
}
function interpolate(from: number, to: number, progress: number): number {
return from + (to - from) * progress;
}
function easeOutCubic(progress: number): number {
return 1 - (1 - progress) ** 3;
}
function useAvatarStatusAnimationProgress({target, enabled}: {target: number; enabled: boolean}): number {
const [progress, setProgress] = useState(target);
const progressRef = useRef(progress);
const frameRef = useRef<number | null>(null);
useEffect(() => {
progressRef.current = progress;
}, [progress]);
useEffect(() => {
if (frameRef.current != null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
if (!enabled) {
progressRef.current = target;
setProgress(target);
return;
}
const from = progressRef.current;
if (Math.abs(from - target) < 0.001) {
progressRef.current = target;
setProgress(target);
return;
}
const startedAt = performance.now();
const step = (timestamp: number) => {
const elapsed = Math.min(1, (timestamp - startedAt) / STATUS_TRANSITION_DURATION_MS);
const nextProgress = interpolate(from, target, easeOutCubic(elapsed));
progressRef.current = nextProgress;
setProgress(nextProgress);
if (elapsed < 1) {
frameRef.current = requestAnimationFrame(step);
} else {
frameRef.current = null;
}
};
frameRef.current = requestAnimationFrame(step);
return () => {
if (frameRef.current != null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [enabled, target]);
return enabled ? progress : target;
}
const resolveAvatarMask = ({
shouldShowStatus,
isTyping,
isMobileOnline,
size,
animatedMaskId,
dynamicMaskId,
useDynamicMask,
}: {
shouldShowStatus: boolean;
isTyping: boolean;
isMobileOnline: boolean;
size: number;
animatedMaskId?: string | null;
dynamicMaskId: string;
useDynamicMask: boolean;
}): AvatarMaskConfig => {
if (!shouldShowStatus) return {id: 'svg-mask-avatar-default', dynamicKind: null};
if (animatedMaskId && !isMobileOnline) {
return {id: animatedMaskId, dynamicKind: 'animated'};
if (!shouldShowStatus) return {id: 'flx-mask-avatar-plain', dynamicKind: null};
if (useDynamicMask && !isMobileOnline) {
return {id: dynamicMaskId, dynamicKind: 'animated'};
}
if (STATIC_AVATAR_MASK_SIZES.has(size)) {
if (isTyping) return {id: `svg-mask-avatar-status-typing-${size}`, dynamicKind: null};
if (isMobileOnline) return {id: `svg-mask-avatar-status-mobile-${size}`, dynamicKind: null};
return {id: `svg-mask-avatar-status-round-${size}`, dynamicKind: null};
if (isTyping) return {id: `flx-mask-avatar-notch-typing-${size}`, dynamicKind: null};
if (isMobileOnline) return {id: `flx-mask-avatar-notch-phone-${size}`, dynamicKind: null};
return {id: `flx-mask-avatar-notch-circle-${size}`, dynamicKind: null};
}
const dynamicKind = isTyping ? 'typing' : isMobileOnline ? 'mobile' : 'round';
return {id: getDynamicAvatarMaskId(dynamicKind, size), dynamicKind};
return {id: dynamicMaskId, dynamicKind: isTyping ? 'typing' : isMobileOnline ? 'mobile' : 'round'};
};
interface DynamicAvatarMaskProps {
@@ -552,6 +543,8 @@ interface DynamicAvatarMaskProps {
size: number;
layout: AvatarStatusLayout;
statusBox: AvatarStatusBox;
animateShape: boolean;
reducedMotion: boolean;
}
const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
@@ -560,6 +553,8 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
size,
layout,
statusBox,
animateShape,
reducedMotion,
}: DynamicAvatarMaskProps) {
const cutoutRadius = layout.cutoutRadius;
const cutoutY = layout.cutoutCy - cutoutRadius;
@@ -573,6 +568,18 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
const typingBridgeShift = layout.innerStatusRight - layout.innerTypingRight;
const typingLeftCx = layout.cutoutCx - typingExtension + typingBridgeShift;
const typingRightCx = layout.cutoutCx + typingBridgeShift;
const animatedCutoutClassName = animateShape
? `${styles.animatedMaskShape} ${reducedMotion ? styles.animatedMaskReducedMotion : ''}`.trim()
: undefined;
const animatedCutoutStyle: AvatarMaskCSSProperties | undefined = animateShape
? {
'--avatar-mask-x': `${animatedCutoutX}px`,
'--avatar-mask-y': `${animatedCutoutY}px`,
'--avatar-mask-width': `${animatedCutoutWidth}px`,
'--avatar-mask-height': `${animatedCutoutHeight}px`,
'--avatar-mask-radius': `${animatedCutoutRadius}px`,
}
: undefined;
return (
<defs data-flx="ui.base-avatar.dynamic-avatar-mask.defs">
<mask
@@ -600,6 +607,8 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
height={animatedCutoutHeight}
rx={animatedCutoutRadius}
ry={animatedCutoutRadius}
className={animatedCutoutClassName}
style={animatedCutoutStyle}
data-flx="ui.base-avatar.dynamic-avatar-mask.animated-cutout"
/>
) : kind === 'typing' ? (
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
import {BaseAvatar} from '@app/features/ui/components/BaseAvatar';
import {getDefaultAvatarURLForIndex} from '@app/features/user/utils/AvatarUtils';
import {useLingui} from '@lingui/react/macro';
import React from 'react';
@@ -25,7 +25,7 @@ export const MockAvatar = React.forwardRef<HTMLDivElement, MockAvatarProps>(
(
{
size,
avatarUrl = cdnUrl('avatars/0.png'),
avatarUrl = getDefaultAvatarURLForIndex(0),
hoverAvatarUrl,
status,
isTyping = false,
@@ -25,7 +25,6 @@ export interface StatusAwareAvatarProps {
hoverAvatarUrl?: string | null;
guildId?: string | null;
mediaSize?: MediaProxyImageSize;
deferImageLoad?: boolean;
status?: string | null;
animateStatusCutout?: boolean;
}
@@ -54,7 +53,6 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
hoverAvatarUrl,
guildId,
mediaSize,
deferImageLoad,
status: externalStatus,
animateStatusCutout,
}) => {
@@ -86,7 +84,6 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
hoverAvatarUrl={hoverAvatarUrl}
guildId={guildId}
mediaSize={mediaSize}
deferImageLoad={deferImageLoad}
animateStatusCutout={animateStatusCutout}
data-flx="ui.status-aware-avatar.avatar"
/>
@@ -18,7 +18,7 @@ const normalizeStatus = (status: string) => (status === StatusTypes.INVISIBLE ?
export const StatusIndicator = memo(
({status, size = 12, className, appearance = 'default', monochromeColor}: StatusIndicatorProps) => {
const normalizedStatus = normalizeStatus(status);
const maskId = `svg-mask-status-${normalizedStatus}`;
const maskId = `flx-mask-presence-${normalizedStatus}`;
const fill =
appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`;
return (
@@ -55,7 +55,7 @@ interface RenderStatusIconOptions {
export const renderStatusIconContent = (status: string, size: number, options: RenderStatusIconOptions = {}) => {
const {appearance = 'default', monochromeColor} = options;
const normalizedStatus = normalizeStatus(status);
const maskId = `svg-mask-status-${normalizedStatus}`;
const maskId = `flx-mask-presence-${normalizedStatus}`;
const fill = appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`;
return (
<svg