mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): paint avatars from a real image with a fading placeholder (#1856)
This commit is contained in:
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">
|
||||
|
||||
+1
-2
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user