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

+17 -17
View File
@@ -444,74 +444,74 @@ for (const size of SIZES) {
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4); const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4); const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> output += ` <mask id="flx-mask-avatar-plain-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask> </mask>
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-avatar-notch-circle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" /> <circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
</mask> </mask>
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-avatar-notch-phone-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(cutoutPhoneRx)}" /> <rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(cutoutPhoneRx)}" />
</mask> </mask>
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-avatar-notch-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" /> <circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
<rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" /> <rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" />
<circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" /> <circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
</mask> </mask>
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" /> <circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
</mask> </mask>
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" /> <rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" /> <rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" /> <ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
</mask> </mask>
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" /> <circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(idleCutoutR)}" /> <circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(idleCutoutR)}" />
</mask> </mask>
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" /> <circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(dndBarRx)}" /> <rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(dndBarRx)}" />
</mask> </mask>
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" /> <circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(offlineInnerR)}" /> <circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(offlineInnerR)}" />
</mask> </mask>
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" /> <rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
</mask> </mask>
`; `;
} }
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> output += ` <mask id="flx-mask-presence-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask> </mask>
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.25" cy="0.25" r="0.375" /> <circle fill="black" cx="0.25" cy="0.25" r="0.375" />
</mask> </mask>
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" /> <rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
</mask> </mask>
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.5" cy="0.5" r="0.25" /> <circle fill="black" cx="0.5" cy="0.5" r="0.25" />
</mask> </mask>
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" /> <rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
</mask> </mask>
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-presence-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" /> <rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" /> <rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" /> <ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
</mask> </mask>
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1"> <mask id="flx-mask-avatar-plain" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" /> <circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask> </mask>
</defs> </defs>
@@ -5,7 +5,6 @@ import styles from '@app/features/app/components/shared/GroupDMAvatar.module.css
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants'; import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import type {Channel} from '@app/features/channel/models/Channel'; import type {Channel} from '@app/features/channel/models/Channel';
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils'; 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 Presence from '@app/features/presence/state/Presence';
import TransientPresence from '@app/features/presence/state/TransientPresence'; import TransientPresence from '@app/features/presence/state/TransientPresence';
import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -147,7 +146,7 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
width={1} width={1}
height={1} height={1}
fill={statusColor} 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" data-flx="app.group-dm-avatar.render-group-status-dot.rect"
/> />
</svg> </svg>
@@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
const user = Users.getUser(userId); const user = Users.getUser(userId);
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize); const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
const avatarMaskId = `${groupMaskId}-avatar-${index}`; const avatarMaskId = `${groupMaskId}-avatar-${index}`;
let avatarUrl: string; const avatarUrl = user
if (user) { ? AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar})
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar}); : AvatarUtils.getDefaultAvatarURL(userId);
} else {
const avatarIndex = index % 6;
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
}
return ( return (
<image <image
key={userId} 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 // SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility'; 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 UserSettings from '@app/features/user/state/UserSettings';
import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants'; import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants';
import {useEffect, useState} from 'react';
export type ShouldAnimateKind = export type ShouldAnimateKind =
| 'avatar' | 'avatar'
@@ -17,65 +16,13 @@ export type ShouldAnimateKind =
export interface UseShouldAnimateOptions { export interface UseShouldAnimateOptions {
kind: ShouldAnimateKind; kind: ShouldAnimateKind;
isAnimated?: boolean;
isHovering?: boolean; isHovering?: boolean;
isFocused?: boolean; isFocused?: boolean;
entitlementOk?: boolean; entitlementOk?: boolean;
respectPlaybackAllowed?: 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 { function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean {
if (kind === 'emoji' || kind === 'gif' || kind === 'sticker') { if (kind === 'emoji' || kind === 'gif' || kind === 'sticker') {
return Accessibility.isAnimationKeptUnderReducedMotion(kind); return Accessibility.isAnimationKeptUnderReducedMotion(kind);
@@ -106,27 +53,27 @@ function getKindAllowance(kind: ShouldAnimateKind): AnimationAllowanceMode {
} }
export interface ShouldAnimateDecisionInput { export interface ShouldAnimateDecisionInput {
isAnimated?: boolean;
allowance: AnimationAllowanceMode; allowance: AnimationAllowanceMode;
reducedMotion: boolean; reducedMotion: boolean;
keptUnderReducedMotion: boolean; keptUnderReducedMotion: boolean;
isInteracting: boolean; isInteracting: boolean;
entitlementOk?: boolean; entitlementOk?: boolean;
saveData: boolean; saveData: boolean;
animatedMediaPlaybackAllowed: boolean;
} }
export function resolveShouldAnimateDecision({ export function resolveShouldAnimateDecision({
isAnimated = true,
allowance, allowance,
reducedMotion, reducedMotion,
keptUnderReducedMotion, keptUnderReducedMotion,
isInteracting, isInteracting,
entitlementOk, entitlementOk,
saveData, saveData,
animatedMediaPlaybackAllowed,
}: ShouldAnimateDecisionInput): boolean { }: ShouldAnimateDecisionInput): boolean {
if (!isAnimated) return false;
if (entitlementOk === false) return false; if (entitlementOk === false) return false;
if (saveData) return false; if (saveData) return false;
if (!animatedMediaPlaybackAllowed) return false;
if (allowance === 'NEVER') return false; if (allowance === 'NEVER') return false;
if (reducedMotion && !keptUnderReducedMotion) return isInteracting; if (reducedMotion && !keptUnderReducedMotion) return isInteracting;
if (allowance === 'ALWAYS') return true; if (allowance === 'ALWAYS') return true;
@@ -135,21 +82,20 @@ export function resolveShouldAnimateDecision({
export function useShouldAnimate({ export function useShouldAnimate({
kind, kind,
isAnimated = true,
isHovering = false, isHovering = false,
isFocused = false, isFocused = false,
entitlementOk, entitlementOk,
respectPlaybackAllowed = true,
}: UseShouldAnimateOptions): boolean { }: UseShouldAnimateOptions): boolean {
const saveData = useSaveData(); const saveData = useSaveData();
const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed({enabled: respectPlaybackAllowed});
const allowance = getKindAllowance(kind); const allowance = getKindAllowance(kind);
return resolveShouldAnimateDecision({ return resolveShouldAnimateDecision({
isAnimated,
allowance, allowance,
reducedMotion: Accessibility.useReducedMotion, reducedMotion: Accessibility.useReducedMotion,
keptUnderReducedMotion: isKeptUnderReducedMotion(kind), keptUnderReducedMotion: isKeptUnderReducedMotion(kind),
isInteracting: isHovering || isFocused, isInteracting: isHovering || isFocused,
entitlementOk, entitlementOk,
saveData, saveData,
animatedMediaPlaybackAllowed,
}); });
} }
@@ -75,8 +75,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
roleColor, roleColor,
displayName, displayName,
disableBackdrop = false, disableBackdrop = false,
deferAvatarLoad = false, deferCustomStatusMedia = false,
deferCustomStatusMedia = false,
avatarMediaSize, avatarMediaSize,
} = props; } = props;
const itemRef = useRef<HTMLButtonElement>(null); const itemRef = useRef<HTMLButtonElement>(null);
@@ -214,8 +213,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
avatarUrl={memberAvatarUrl} avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl} hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize} mediaSize={avatarMediaSize}
deferImageLoad={deferAvatarLoad} data-flx="channel.member-list-item.status-aware-avatar"
data-flx="channel.member-list-item.status-aware-avatar"
/> />
</div> </div>
<div className={styles.userInfoContainer} data-flx="channel.member-list-item.user-info-container"> <div className={styles.userInfoContainer} data-flx="channel.member-list-item.user-info-container">
@@ -181,8 +181,7 @@ export const MobileMemberListItem = observer(
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl} hoverAvatarUrl={hoverAvatarUrl}
mediaSize={MEMBER_LIST_AVATAR_MEDIA_SIZE} 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 <div
className={styles.memberContent} className={styles.memberContent}
@@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 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 styles from '@app/features/ui/avatars/AvatarWithPresence.module.css';
import {BaseAvatar} from '@app/features/ui/components/BaseAvatar'; import {BaseAvatar} from '@app/features/ui/components/BaseAvatar';
import type {User} from '@app/features/user/models/User'; 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 * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
@@ -11,6 +15,7 @@ import {MicrophoneSlashIcon, SpeakerSlashIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {useEffect, useState} from 'react';
const MUTED_VOICE_BADGE_LABEL = msg({ const MUTED_VOICE_BADGE_LABEL = msg({
message: 'Muted', message: 'Muted',
@@ -47,17 +52,42 @@ export const AvatarWithPresence: React.FC<Props> = observer(function AvatarWithP
}) { }) {
const {i18n} = useLingui(); const {i18n} = useLingui();
const guildMember = GuildMembers.getMember(guildId || '', user.id); const guildMember = GuildMembers.getMember(guildId || '', user.id);
const animated = speaking ?? false; const resolveSrc = (animated: boolean) =>
const src =
guildId && guildMember guildId && guildMember
? AvatarUtils.getGuildMemberDisplayAvatarURL({ ? AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId, guildId,
user, user: {id: user.id, avatar: user.avatar},
memberAvatar: guildMember.avatar, memberAvatar: guildMember.avatar,
avatarUnset: guildMember.isAvatarUnset(), avatarUnset: guildMember.isAvatarUnset(),
animated, 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 ? ( const voiceBadge = deafened ? (
<SpeakerSlashIcon <SpeakerSlashIcon
weight="fill" weight="fill"
@@ -82,13 +112,15 @@ export const AvatarWithPresence: React.FC<Props> = observer(function AvatarWithP
<BaseAvatar <BaseAvatar
size={size} size={size}
avatarUrl={src} avatarUrl={src}
hoverAvatarUrl={isAnimatedAvatarLoaded ? hoverSrc : undefined}
shouldPlayAnimated={wantsAnimatedAvatar && isAnimatedAvatarLoaded}
className={clsx(styles.container, speaking && styles.containerSpeaking, borderClassName, className)} className={clsx(styles.container, speaking && styles.containerSpeaking, borderClassName, className)}
userTag={ariaLabel ?? user.displayName} userTag={ariaLabel ?? user.displayName}
disableStatusTooltip disableStatusTooltip
customStatusBadge={voiceBadge} customStatusBadge={voiceBadge}
customStatusBadgeColor="var(--status-danger)" customStatusBadgeColor="var(--status-danger)"
customStatusBadgeLabel={voiceBadgeLabel} customStatusBadgeLabel={voiceBadgeLabel}
customStatusBadgeMaskId="svg-mask-status-online" customStatusBadgeMaskId="flx-mask-presence-online"
customStatusBadgeScale={1.5} customStatusBadgeScale={1.5}
customStatusBadgeMaxSizeRatio={0.36} customStatusBadgeMaxSizeRatio={0.36}
customStatusBadgeCutoutPaddingScale={1.35} 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 {normalizeStatus} from '@fluxer/constants/src/StatusConstants';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; 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 { interface AvatarProps {
user: User; user: User;
@@ -34,55 +34,10 @@ interface AvatarProps {
hoverAvatarUrl?: string | null; hoverAvatarUrl?: string | null;
guildId?: string | null; guildId?: string | null;
mediaSize?: MediaProxyImageSize; mediaSize?: MediaProxyImageSize;
deferImageLoad?: boolean;
animateStatusCutout?: boolean; animateStatusCutout?: boolean;
title?: never; 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>( const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
( (
{ {
@@ -101,51 +56,73 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
hoverAvatarUrl: customHoverAvatarUrl, hoverAvatarUrl: customHoverAvatarUrl,
guildId, guildId,
mediaSize, mediaSize,
deferImageLoad = false,
animateStatusCutout = false, animateStatusCutout = false,
...props ...props
}, },
ref, ref,
) => { ) => {
const {i18n} = useLingui(); 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(() => { const avatarUrl = useMemo(() => {
if (customAvatarUrl !== undefined) return customAvatarUrl; if (customAvatarUrl !== undefined) return customAvatarUrl;
if (guildId && guildMember) { if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({ return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId, guildId,
user, user: {id: userId, avatar: userAvatar},
memberAvatar: guildMember.avatar, memberAvatar,
avatarUnset: guildMember.isAvatarUnset(), avatarUnset: memberAvatarUnset,
animated: false, animated: false,
size: mediaSize, size: mediaSize,
}); });
} }
return AvatarUtils.getUserAvatarURL(user, false, mediaSize); return AvatarUtils.getUserAvatarURL({id: userId, avatar: userAvatar}, false, mediaSize);
}, [user, customAvatarUrl, guildId, guildMember, mediaSize]); }, [
customAvatarUrl,
guildId,
hasGuildMemberAvatarSource,
memberAvatar,
memberAvatarUnset,
mediaSize,
userAvatar,
userId,
]);
const hoverAvatarUrl = useMemo(() => { const hoverAvatarUrl = useMemo(() => {
if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl; if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl;
if (guildId && guildMember) { if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({ return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId, guildId,
user, user: {id: userId, avatar: userAvatar},
memberAvatar: guildMember.avatar, memberAvatar,
avatarUnset: guildMember.isAvatarUnset(), avatarUnset: memberAvatarUnset,
animated: true, animated: true,
size: mediaSize, size: mediaSize,
}); });
} }
return AvatarUtils.getUserAvatarURL(user, true, mediaSize); return AvatarUtils.getUserAvatarURL({id: userId, avatar: userAvatar}, true, mediaSize);
}, [user, customHoverAvatarUrl, guildId, guildMember, mediaSize]); }, [
customHoverAvatarUrl,
guildId,
hasGuildMemberAvatarSource,
memberAvatar,
memberAvatarUnset,
mediaSize,
userAvatar,
userId,
]);
const statusLabel = status != null ? getStatusTypeLabel(i18n, status) : null; const statusLabel = status != null ? getStatusTypeLabel(i18n, status) : null;
const hasDistinctHoverAvatar = Boolean(hoverAvatarUrl && hoverAvatarUrl !== avatarUrl); const hasDistinctHoverAvatar = Boolean(hoverAvatarUrl && hoverAvatarUrl !== avatarUrl);
const [hoverRef, isHovering] = useHover(); const [hoverRef, isHovering] = useHover();
const settingsAnimationAllowed = useShouldAnimate({ const animationAllowed = useShouldAnimate({
kind: 'avatar', kind: 'avatar',
isHovering: hasDistinctHoverAvatar && (isHovering || forceAnimate), isAnimated: hasDistinctHoverAvatar,
respectPlaybackAllowed: hasDistinctHoverAvatar, isHovering: hasDistinctHoverAvatar && (isHovering || forceAnimate || forceAnimateIgnoringSettings),
}); });
const animationAllowed = hasDistinctHoverAvatar && (forceAnimateIgnoringSettings || settingsAnimationAllowed); const inlineAutoplayAllowed = animationAllowed && forceAnimateIgnoringSettings;
const [requestedAnimatedUrl, setRequestedAnimatedUrl] = useState<string | null>(() => const [requestedAnimatedUrl, setRequestedAnimatedUrl] = useState<string | null>(() =>
hasDistinctHoverAvatar && animationAllowed ? hoverAvatarUrl : null, hasDistinctHoverAvatar && animationAllowed ? hoverAvatarUrl : null,
); );
@@ -155,55 +132,34 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
} }
}, [hasDistinctHoverAvatar, animationAllowed, hoverAvatarUrl]); }, [hasDistinctHoverAvatar, animationAllowed, hoverAvatarUrl]);
const isAnimatedNeeded = hasDistinctHoverAvatar && (animationAllowed || requestedAnimatedUrl === hoverAvatarUrl); const isAnimatedNeeded = hasDistinctHoverAvatar && (animationAllowed || requestedAnimatedUrl === hoverAvatarUrl);
const [loadedStaticUrl, setLoadedStaticUrl] = useState<string | null>(() => const rendersAnimatedInline = isAnimatedNeeded && inlineAutoplayAllowed;
resolveInitiallyLoadedImageUrl(avatarUrl), const [loadedStaticUrl, setLoadedStaticUrl] = useState<string | null>(null);
); const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState<string | null>(null);
const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState<string | null>(() =>
resolveInitiallyLoadedImageUrl(hoverAvatarUrl),
);
const isStaticLoaded = avatarUrl != null && loadedStaticUrl === avatarUrl; const isStaticLoaded = avatarUrl != null && loadedStaticUrl === avatarUrl;
const isAnimatedLoaded = hasDistinctHoverAvatar && hoverAvatarUrl != null && loadedAnimatedUrl === hoverAvatarUrl; 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(() => { useEffect(() => {
const staticLoaded = ImageCacheUtils.hasImage(avatarUrl); if (!isAnimatedNeeded || rendersAnimatedInline || hoverAvatarUrl == null) {
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) {
return; return;
} }
let active = true; let active = true;
const cleanupStaticLoad = ImageCacheUtils.loadImage(avatarUrl, () => { const cleanupAnimatedLoad = ImageCacheUtils.loadImage(hoverAvatarUrl, () => {
if (active && avatarUrl != null) { if (active) setLoadedAnimatedUrl(hoverAvatarUrl);
setLoadedStaticUrl(avatarUrl);
}
}); });
let cleanupAnimatedLoad: (() => void) | undefined;
if (isAnimatedNeeded && !deferImageLoad && hoverAvatarUrl != null) {
cleanupAnimatedLoad = ImageCacheUtils.loadImage(hoverAvatarUrl, () => {
if (active) setLoadedAnimatedUrl(hoverAvatarUrl);
});
}
return () => { return () => {
active = false; active = false;
cleanupStaticLoad(); cleanupAnimatedLoad();
if (cleanupAnimatedLoad !== undefined) cleanupAnimatedLoad();
}; };
}, [avatarUrl, hoverAvatarUrl, hasDistinctHoverAvatar, isAnimatedNeeded, deferImageLoad]); }, [hoverAvatarUrl, isAnimatedNeeded, rendersAnimatedInline]);
const shouldPlayAnimated = hasDistinctHoverAvatar && animationAllowed && isAnimatedLoaded; const shouldPlayAnimated = hasDistinctHoverAvatar && animationAllowed && isAnimatedLoaded;
const fallbackAvatarUrl = useMemo( const fallbackAvatarUrl = useMemo(() => AvatarUtils.getUserAvatarURL({id: userId, avatar: null}, false), [userId]);
() => AvatarUtils.getUserAvatarURL({id: user.id, avatar: null}, false), const resolvedAvatarUrl = avatarUrl ?? fallbackAvatarUrl;
[user.id],
);
const avatarPresentation = resolveAvatarImagePresentation({
avatarUrl,
fallbackAvatarUrl,
deferImageLoad,
isStaticLoaded,
});
const safeHoverAvatarUrl = isAnimatedNeeded ? hoverAvatarUrl || undefined : undefined; const safeHoverAvatarUrl = isAnimatedNeeded ? hoverAvatarUrl || undefined : undefined;
const normalizedStatusAttr = status != null ? normalizeStatus(status) : undefined; const normalizedStatusAttr = status != null ? normalizeStatus(status) : undefined;
const displayName = NicknameUtils.getNickname(user, guildId ?? null); const displayName = NicknameUtils.getNickname(user, guildId ?? null);
@@ -213,13 +169,14 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
<BaseAvatar <BaseAvatar
ref={mergedRef} ref={mergedRef}
size={size} size={size}
avatarUrl={avatarPresentation.avatarUrl} avatarUrl={resolvedAvatarUrl}
hoverAvatarUrl={safeHoverAvatarUrl} hoverAvatarUrl={safeHoverAvatarUrl}
onImageLoaded={handleImageLoaded}
status={status} status={status}
isMobileStatus={isMobileStatus} isMobileStatus={isMobileStatus}
showSkeleton={avatarPresentation.showSkeleton} showSkeleton
shouldPlayAnimated={shouldPlayAnimated && isStaticLoaded} shouldPlayAnimated={shouldPlayAnimated && isStaticLoaded}
forceAnimatedPlayback={forceAnimateIgnoringSettings} forceAnimatedPlayback={inlineAutoplayAllowed}
isTyping={isTyping} isTyping={isTyping}
showOffline={showOffline} showOffline={showOffline}
className={className} className={className}
@@ -229,11 +186,11 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
disableStatusTooltip={disableStatusTooltip} disableStatusTooltip={disableStatusTooltip}
animateStatusCutout={animateStatusCutout} animateStatusCutout={animateStatusCutout}
data-flx="ui.avatar.avatar-component.base-avatar" 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-username={NicknameUtils.formatNameForStreamerMode(user.username)}
data-flx-user-name={displayName} data-flx-user-name={displayName}
data-flx-user-bot={user.bot ? 'true' : undefined} 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-guild-id={guildId ?? undefined}
data-flx-size={String(size)} data-flx-size={String(size)}
data-flx-status={normalizedStatusAttr ?? undefined} data-flx-status={normalizedStatusAttr ?? undefined}
@@ -21,6 +21,37 @@
border-radius: 50%; 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 { .hoverOverlay {
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -61,18 +92,20 @@
transition-duration: 0s; transition-duration: 0s;
} }
.statusAnimationDriven {
transition: none;
}
.animatedMaskShape { .animatedMaskShape {
cx: var(--avatar-mask-cx);
x: var(--avatar-mask-x); x: var(--avatar-mask-x);
y: var(--avatar-mask-y);
width: var(--avatar-mask-width); width: var(--avatar-mask-width);
height: var(--avatar-mask-height);
rx: var(--avatar-mask-radius);
ry: var(--avatar-mask-radius);
transition: transition:
cx var(--avatar-status-transition-duration) var(--avatar-status-transition-easing),
x 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 { .animatedMaskReducedMotion {
@@ -2,7 +2,7 @@
import Accessibility from '@app/features/accessibility/state/Accessibility'; import Accessibility from '@app/features/accessibility/state/Accessibility';
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants'; 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 {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {type AvatarStatusLayout, getAvatarStatusLayout} from '@app/features/ui/components/AvatarStatusLayout'; import {type AvatarStatusLayout, getAvatarStatusLayout} from '@app/features/ui/components/AvatarStatusLayout';
import styles from '@app/features/ui/components/BaseAvatar.module.css'; 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 {normalizeStatus, StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/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({ const AVATAR_DESCRIPTOR = msg({
message: 'Avatar', message: 'Avatar',
@@ -29,7 +29,8 @@ const STATUS_DESCRIPTOR = msg({
}); });
const DEFAULT_CUSTOM_STATUS_BADGE_SCALE = 1.25; const DEFAULT_CUSTOM_STATUS_BADGE_SCALE = 1.25;
const DEFAULT_CUSTOM_STATUS_BADGE_CUTOUT_PADDING_SCALE = 1.2; 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 & { type AvatarCSSProperties = React.CSSProperties & {
'--avatar-status-width'?: string; '--avatar-status-width'?: string;
@@ -42,6 +43,14 @@ type AvatarCSSProperties = React.CSSProperties & {
'--avatar-typing-dot-size'?: string; '--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 { interface AvatarStatusBox {
width: number; width: number;
height: number; height: number;
@@ -51,10 +60,18 @@ interface AvatarStatusBox {
cutoutPadding: number; cutoutPadding: number;
} }
type PlaceholderFade = 'instant' | 'fast' | 'gradual';
interface PaintedAvatarImage {
url: string;
fade: PlaceholderFade;
}
const SVG_MASK_URL_CACHE = new Map<string, string>(); const SVG_MASK_URL_CACHE = new Map<string, string>();
const AVATAR_VIEW_BOX_CACHE = new Map<number, 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 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 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'; type DynamicAvatarMaskKind = 'animated' | 'mobile' | 'round' | 'typing';
@@ -64,9 +81,6 @@ interface AvatarMaskConfig {
} }
function getSvgMaskUrl(maskId: string): string { function getSvgMaskUrl(maskId: string): string {
if (maskId.startsWith('svg-mask-avatar-dynamic-')) {
return `url(#${maskId})`;
}
const cached = SVG_MASK_URL_CACHE.get(maskId); const cached = SVG_MASK_URL_CACHE.get(maskId);
if (cached) return cached; if (cached) return cached;
const value = `url(#${maskId})`; const value = `url(#${maskId})`;
@@ -82,14 +96,13 @@ function getAvatarViewBox(size: number): string {
return value; return value;
} }
function getDynamicAvatarMaskId(kind: DynamicAvatarMaskKind, size: number): string { const isImagePainted = (image: HTMLImageElement): boolean => image.complete && image.naturalWidth > 0;
return `svg-mask-avatar-dynamic-${kind}-${String(size).replace(/[^A-Za-z0-9_-]/g, '_')}`;
}
interface BaseAvatarProps { interface BaseAvatarProps {
size: number; size: number;
avatarUrl: string; avatarUrl: string;
hoverAvatarUrl?: string; hoverAvatarUrl?: string;
onImageLoaded?: (src: string) => void;
status?: StatusType | string | null; status?: StatusType | string | null;
shouldPlayAnimated?: boolean; shouldPlayAnimated?: boolean;
forceAnimatedPlayback?: boolean; forceAnimatedPlayback?: boolean;
@@ -120,6 +133,7 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
size, size,
avatarUrl, avatarUrl,
hoverAvatarUrl, hoverAvatarUrl,
onImageLoaded,
status, status,
shouldPlayAnimated = false, shouldPlayAnimated = false,
forceAnimatedPlayback = false, forceAnimatedPlayback = false,
@@ -145,17 +159,12 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
ref, ref,
) => { ) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const dynamicMaskId = useId();
const animatedMediaPlaybackEnabled = Boolean((shouldPlayAnimated || forceAnimatedPlayback) && hoverAvatarUrl); const animatedMediaPlaybackEnabled = Boolean((shouldPlayAnimated || forceAnimatedPlayback) && hoverAvatarUrl);
const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed({enabled: animatedMediaPlaybackEnabled});
const normalizedStatus = status == null ? null : normalizeStatus(status); const normalizedStatus = status == null ? null : normalizeStatus(status);
const renderableStatus = resolveRenderableStatus(normalizedStatus); const renderableStatus = resolveRenderableStatus(normalizedStatus);
const isMobileOnline = isMobileStatus && renderableStatus === StatusTypes.ONLINE && !isTyping; const isMobileOnline = isMobileStatus && renderableStatus === StatusTypes.ONLINE && !isTyping;
const layout = getAvatarStatusLayout(size, isMobileOnline); 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 hasCustomStatusBadge = customStatusBadge != null;
const shouldShowPresenceStatus = const shouldShowPresenceStatus =
isTyping || (normalizedStatus != null && (showOffline || renderableStatus !== StatusTypes.OFFLINE)); isTyping || (normalizedStatus != null && (showOffline || renderableStatus !== StatusTypes.OFFLINE));
@@ -165,35 +174,75 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
const shouldUseDynamicAvatarMask = shouldShowCustomStatusBadge || shouldAnimateAvatarMask; const shouldUseDynamicAvatarMask = shouldShowCustomStatusBadge || shouldAnimateAvatarMask;
const maskIsMobileOnline = shouldShowCustomStatusBadge ? false : isMobileOnline; const maskIsMobileOnline = shouldShowCustomStatusBadge ? false : isMobileOnline;
const reducedMotion = Accessibility.useReducedMotion; const reducedMotion = Accessibility.useReducedMotion;
const statusAnimationProgress = useAvatarStatusAnimationProgress({ const candidateUrl = animatedMediaPlaybackEnabled ? hoverAvatarUrl || '' : avatarUrl;
target: isTyping ? 1 : 0,
enabled: shouldAnimateAvatarMask && !reducedMotion,
});
const candidateUrl =
animatedMediaPlaybackEnabled && animatedMediaPlaybackAllowed ? hoverAvatarUrl || '' : avatarUrl;
const [imgError, setImgError] = useState(false); 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(() => { useEffect(() => {
setImgError(false); setImgError(false);
setImgRetryCount(0);
}, [candidateUrl]); }, [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(() => { const handleImageError = useCallback(() => {
setImgError(true); setImgError(true);
}, []); }, []);
const showFallback = !candidateUrl || imgError; const showFallback = !candidateUrl || imgError;
const avatarMask = resolveAvatarMask({ const paintedCandidate = paintedImage?.url === candidateUrl ? paintedImage : null;
shouldShowStatus, const shouldMountPlaceholder = showSkeleton && !wasCachedAtMount;
isTyping, const isPlaceholderVisible = showSkeleton && paintedCandidate == null;
isMobileOnline: maskIsMobileOnline, const placeholderStyle = paintedCandidate?.fade === 'instant' ? PLACEHOLDER_INSTANT_STYLE : undefined;
size, const placeholderFastFade = paintedCandidate?.fade === 'fast';
animatedMaskId: shouldUseDynamicAvatarMask ? dynamicAvatarMaskId : null,
});
const avatarMaskUrl = getSvgMaskUrl(avatarMask.id);
const statusMaskId = shouldShowCustomStatusBadge const statusMaskId = shouldShowCustomStatusBadge
? (customStatusBadgeMaskId ?? 'svg-mask-status-online') ? (customStatusBadgeMaskId ?? 'flx-mask-presence-online')
: isMobileOnline : isMobileOnline
? STATIC_AVATAR_MASK_SIZES.has(size) ? STATIC_AVATAR_MASK_SIZES.has(size)
? `svg-mask-status-online-mobile-${size}` ? `flx-mask-presence-online-mobile-${size}`
: 'svg-mask-status-online-mobile' : 'flx-mask-presence-online-mobile'
: `svg-mask-status-${renderableStatus}`; : `flx-mask-presence-${renderableStatus}`;
const avatarViewBox = getAvatarViewBox(size); const avatarViewBox = getAvatarViewBox(size);
const statusColor = shouldShowCustomStatusBadge const statusColor = shouldShowCustomStatusBadge
? (customStatusBadgeColor ?? `var(--status-${renderableStatus})`) ? (customStatusBadgeColor ?? `var(--status-${renderableStatus})`)
@@ -203,11 +252,8 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
[size], [size],
); );
const regularStatusBox = useMemo( const regularStatusBox = useMemo(
() => () => getAvatarStatusBox(layout, isTyping, isMobileOnline),
shouldAnimateAvatarMask && !reducedMotion [isMobileOnline, isTyping, layout],
? getInterpolatedAvatarStatusBox(layout, isMobileOnline, statusAnimationProgress)
: getAvatarStatusBox(layout, isTyping, isMobileOnline),
[isMobileOnline, isTyping, layout, reducedMotion, shouldAnimateAvatarMask, statusAnimationProgress],
); );
const customStatusBox = useMemo( const customStatusBox = useMemo(
() => () =>
@@ -221,6 +267,16 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
[customStatusBadgeCutoutPaddingScale, customStatusBadgeMaxSizeRatio, customStatusBadgeScale, layout, size], [customStatusBadgeCutoutPaddingScale, customStatusBadgeMaxSizeRatio, customStatusBadgeScale, layout, size],
); );
const statusBox = shouldShowCustomStatusBadge ? customStatusBox : regularStatusBox; 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 statusContainerStyle = useMemo<AvatarCSSProperties>(() => {
const dotSize = Math.round(layout.innerTypingHeight * TYPING_DOT_SIZE_RATIO); const dotSize = Math.round(layout.innerTypingHeight * TYPING_DOT_SIZE_RATIO);
const dotGap = Math.round(layout.innerTypingHeight * TYPING_DOT_GAP_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]); }, [layout, statusBox, statusColor]);
const statusContainerClassName = `${styles.statusContainer} ${ const statusContainerClassName = `${styles.statusContainer} ${
isTyping || disableStatusTooltip ? styles.statusTooltipDisabled : '' isTyping || disableStatusTooltip ? styles.statusTooltipDisabled : ''
} ${reducedMotion ? styles.reducedMotion : ''} ${ } ${reducedMotion ? styles.reducedMotion : ''}`.trim();
shouldAnimateAvatarMask && !reducedMotion ? styles.statusAnimationDriven : ''
}`.trim();
const ariaLabel = statusLabel && userTag ? `${userTag}, ${statusLabel}` : userTag || i18n._(AVATAR_DESCRIPTOR); const ariaLabel = statusLabel && userTag ? `${userTag}, ${statusLabel}` : userTag || i18n._(AVATAR_DESCRIPTOR);
const interactiveProps = useMemo( const interactiveProps = useMemo(
() => () =>
@@ -331,30 +385,44 @@ export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
size={size} size={size}
layout={layout} layout={layout}
statusBox={statusBox} statusBox={statusBox}
animateShape={animateAvatarMaskShape}
reducedMotion={reducedMotion}
data-flx="ui.base-avatar.dynamic-avatar-mask" data-flx="ui.base-avatar.dynamic-avatar-mask"
/> />
)} )}
{showSkeleton ? ( {shouldMountPlaceholder && (
<rect <rect
className={`${styles.placeholder} ${isPlaceholderVisible ? styles.placeholderVisible : styles.placeholderHidden} ${placeholderFastFade ? styles.placeholderFastFade : ''}`.trim()}
style={placeholderStyle}
x={0} x={0}
y={0} y={0}
width={size} width={size}
height={size} height={size}
mask={avatarMaskUrl} mask={avatarMaskUrl}
fill="var(--background-modifier-accent)"
opacity={0.45}
data-flx="ui.base-avatar.skeleton" data-flx="ui.base-avatar.skeleton"
/> />
) : showFallback ? null : ( )}
<image {showFallback ? null : (
href={candidateUrl} <foreignObject
x={0}
y={0}
width={size} width={size}
height={size} height={size}
mask={avatarMaskUrl} mask={avatarMaskUrl}
preserveAspectRatio="xMidYMid slice" data-flx="ui.base-avatar.image-frame"
onError={handleImageError} >
data-flx="ui.base-avatar.image" <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 <rect
className={styles.hoverOverlay} 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 = ({ const resolveAvatarMask = ({
shouldShowStatus, shouldShowStatus,
isTyping, isTyping,
isMobileOnline, isMobileOnline,
size, size,
animatedMaskId, dynamicMaskId,
useDynamicMask,
}: { }: {
shouldShowStatus: boolean; shouldShowStatus: boolean;
isTyping: boolean; isTyping: boolean;
isMobileOnline: boolean; isMobileOnline: boolean;
size: number; size: number;
animatedMaskId?: string | null; dynamicMaskId: string;
useDynamicMask: boolean;
}): AvatarMaskConfig => { }): AvatarMaskConfig => {
if (!shouldShowStatus) return {id: 'svg-mask-avatar-default', dynamicKind: null}; if (!shouldShowStatus) return {id: 'flx-mask-avatar-plain', dynamicKind: null};
if (animatedMaskId && !isMobileOnline) { if (useDynamicMask && !isMobileOnline) {
return {id: animatedMaskId, dynamicKind: 'animated'}; return {id: dynamicMaskId, dynamicKind: 'animated'};
} }
if (STATIC_AVATAR_MASK_SIZES.has(size)) { if (STATIC_AVATAR_MASK_SIZES.has(size)) {
if (isTyping) return {id: `svg-mask-avatar-status-typing-${size}`, dynamicKind: null}; if (isTyping) return {id: `flx-mask-avatar-notch-typing-${size}`, dynamicKind: null};
if (isMobileOnline) return {id: `svg-mask-avatar-status-mobile-${size}`, dynamicKind: null}; if (isMobileOnline) return {id: `flx-mask-avatar-notch-phone-${size}`, dynamicKind: null};
return {id: `svg-mask-avatar-status-round-${size}`, dynamicKind: null}; return {id: `flx-mask-avatar-notch-circle-${size}`, dynamicKind: null};
} }
const dynamicKind = isTyping ? 'typing' : isMobileOnline ? 'mobile' : 'round'; return {id: dynamicMaskId, dynamicKind: isTyping ? 'typing' : isMobileOnline ? 'mobile' : 'round'};
return {id: getDynamicAvatarMaskId(dynamicKind, size), dynamicKind};
}; };
interface DynamicAvatarMaskProps { interface DynamicAvatarMaskProps {
@@ -552,6 +543,8 @@ interface DynamicAvatarMaskProps {
size: number; size: number;
layout: AvatarStatusLayout; layout: AvatarStatusLayout;
statusBox: AvatarStatusBox; statusBox: AvatarStatusBox;
animateShape: boolean;
reducedMotion: boolean;
} }
const DynamicAvatarMask = React.memo(function DynamicAvatarMask({ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
@@ -560,6 +553,8 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
size, size,
layout, layout,
statusBox, statusBox,
animateShape,
reducedMotion,
}: DynamicAvatarMaskProps) { }: DynamicAvatarMaskProps) {
const cutoutRadius = layout.cutoutRadius; const cutoutRadius = layout.cutoutRadius;
const cutoutY = layout.cutoutCy - cutoutRadius; const cutoutY = layout.cutoutCy - cutoutRadius;
@@ -573,6 +568,18 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
const typingBridgeShift = layout.innerStatusRight - layout.innerTypingRight; const typingBridgeShift = layout.innerStatusRight - layout.innerTypingRight;
const typingLeftCx = layout.cutoutCx - typingExtension + typingBridgeShift; const typingLeftCx = layout.cutoutCx - typingExtension + typingBridgeShift;
const typingRightCx = layout.cutoutCx + 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 ( return (
<defs data-flx="ui.base-avatar.dynamic-avatar-mask.defs"> <defs data-flx="ui.base-avatar.dynamic-avatar-mask.defs">
<mask <mask
@@ -600,6 +607,8 @@ const DynamicAvatarMask = React.memo(function DynamicAvatarMask({
height={animatedCutoutHeight} height={animatedCutoutHeight}
rx={animatedCutoutRadius} rx={animatedCutoutRadius}
ry={animatedCutoutRadius} ry={animatedCutoutRadius}
className={animatedCutoutClassName}
style={animatedCutoutStyle}
data-flx="ui.base-avatar.dynamic-avatar-mask.animated-cutout" data-flx="ui.base-avatar.dynamic-avatar-mask.animated-cutout"
/> />
) : kind === 'typing' ? ( ) : kind === 'typing' ? (
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants'; 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 {BaseAvatar} from '@app/features/ui/components/BaseAvatar';
import {getDefaultAvatarURLForIndex} from '@app/features/user/utils/AvatarUtils';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import React from 'react'; import React from 'react';
@@ -25,7 +25,7 @@ export const MockAvatar = React.forwardRef<HTMLDivElement, MockAvatarProps>(
( (
{ {
size, size,
avatarUrl = cdnUrl('avatars/0.png'), avatarUrl = getDefaultAvatarURLForIndex(0),
hoverAvatarUrl, hoverAvatarUrl,
status, status,
isTyping = false, isTyping = false,
@@ -25,7 +25,6 @@ export interface StatusAwareAvatarProps {
hoverAvatarUrl?: string | null; hoverAvatarUrl?: string | null;
guildId?: string | null; guildId?: string | null;
mediaSize?: MediaProxyImageSize; mediaSize?: MediaProxyImageSize;
deferImageLoad?: boolean;
status?: string | null; status?: string | null;
animateStatusCutout?: boolean; animateStatusCutout?: boolean;
} }
@@ -54,7 +53,6 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
hoverAvatarUrl, hoverAvatarUrl,
guildId, guildId,
mediaSize, mediaSize,
deferImageLoad,
status: externalStatus, status: externalStatus,
animateStatusCutout, animateStatusCutout,
}) => { }) => {
@@ -86,7 +84,6 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
hoverAvatarUrl={hoverAvatarUrl} hoverAvatarUrl={hoverAvatarUrl}
guildId={guildId} guildId={guildId}
mediaSize={mediaSize} mediaSize={mediaSize}
deferImageLoad={deferImageLoad}
animateStatusCutout={animateStatusCutout} animateStatusCutout={animateStatusCutout}
data-flx="ui.status-aware-avatar.avatar" data-flx="ui.status-aware-avatar.avatar"
/> />
@@ -18,7 +18,7 @@ const normalizeStatus = (status: string) => (status === StatusTypes.INVISIBLE ?
export const StatusIndicator = memo( export const StatusIndicator = memo(
({status, size = 12, className, appearance = 'default', monochromeColor}: StatusIndicatorProps) => { ({status, size = 12, className, appearance = 'default', monochromeColor}: StatusIndicatorProps) => {
const normalizedStatus = normalizeStatus(status); const normalizedStatus = normalizeStatus(status);
const maskId = `svg-mask-status-${normalizedStatus}`; const maskId = `flx-mask-presence-${normalizedStatus}`;
const fill = const fill =
appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`; appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`;
return ( return (
@@ -55,7 +55,7 @@ interface RenderStatusIconOptions {
export const renderStatusIconContent = (status: string, size: number, options: RenderStatusIconOptions = {}) => { export const renderStatusIconContent = (status: string, size: number, options: RenderStatusIconOptions = {}) => {
const {appearance = 'default', monochromeColor} = options; const {appearance = 'default', monochromeColor} = options;
const normalizedStatus = normalizeStatus(status); 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})`; const fill = appearance === 'monochrome' ? (monochromeColor ?? 'currentColor') : `var(--status-${normalizedStatus})`;
return ( return (
<svg <svg