diff --git a/fluxer_app/scripts/GenerateAvatarMasks.ts b/fluxer_app/scripts/GenerateAvatarMasks.ts index 9364d4ffe..44512f6f5 100644 --- a/fluxer_app/scripts/GenerateAvatarMasks.ts +++ b/fluxer_app/scripts/GenerateAvatarMasks.ts @@ -444,74 +444,74 @@ for (const size of SIZES) { const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4); const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4); - output += ` + output += ` - + - + - + - + - + - + - + - + - + `; } -output += ` +output += ` - + - + - + - + - + - + diff --git a/fluxer_app/src/features/app/components/shared/GroupDMAvatar.tsx b/fluxer_app/src/features/app/components/shared/GroupDMAvatar.tsx index a7f0bc61f..9b76c016a 100644 --- a/fluxer_app/src/features/app/components/shared/GroupDMAvatar.tsx +++ b/fluxer_app/src/features/app/components/shared/GroupDMAvatar.tsx @@ -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" /> @@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC = 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 ( 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; +} diff --git a/fluxer_app/src/features/app/hooks/useShouldAnimate.ts b/fluxer_app/src/features/app/hooks/useShouldAnimate.ts index 69293e02b..adfb440b4 100644 --- a/fluxer_app/src/features/app/hooks/useShouldAnimate.ts +++ b/fluxer_app/src/features/app/hooks/useShouldAnimate.ts @@ -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, }); } diff --git a/fluxer_app/src/features/channel/components/MemberListItem.tsx b/fluxer_app/src/features/channel/components/MemberListItem.tsx index bee2d1a53..b3a259111 100644 --- a/fluxer_app/src/features/channel/components/MemberListItem.tsx +++ b/fluxer_app/src/features/channel/components/MemberListItem.tsx @@ -75,8 +75,7 @@ export const MemberListItem: React.FC = observer((props) => roleColor, displayName, disableBackdrop = false, - deferAvatarLoad = false, - deferCustomStatusMedia = false, + deferCustomStatusMedia = false, avatarMediaSize, } = props; const itemRef = useRef(null); @@ -214,8 +213,7 @@ export const MemberListItem: React.FC = 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" />
diff --git a/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx b/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx index 1d6ba20b2..0fa0db6dd 100644 --- a/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx +++ b/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx @@ -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" />
= 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(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 ? ( = observer(function AvatarWithP ( ( { @@ -101,51 +56,73 @@ const AvatarComponent = React.forwardRef( 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(() => hasDistinctHoverAvatar && animationAllowed ? hoverAvatarUrl : null, ); @@ -155,55 +132,34 @@ const AvatarComponent = React.forwardRef( } }, [hasDistinctHoverAvatar, animationAllowed, hoverAvatarUrl]); const isAnimatedNeeded = hasDistinctHoverAvatar && (animationAllowed || requestedAnimatedUrl === hoverAvatarUrl); - const [loadedStaticUrl, setLoadedStaticUrl] = useState(() => - resolveInitiallyLoadedImageUrl(avatarUrl), - ); - const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState(() => - resolveInitiallyLoadedImageUrl(hoverAvatarUrl), - ); + const rendersAnimatedInline = isAnimatedNeeded && inlineAutoplayAllowed; + const [loadedStaticUrl, setLoadedStaticUrl] = useState(null); + const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState(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( ( 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} diff --git a/fluxer_app/src/features/ui/components/BaseAvatar.module.css b/fluxer_app/src/features/ui/components/BaseAvatar.module.css index a1bbe5931..fd7b83ba3 100644 --- a/fluxer_app/src/features/ui/components/BaseAvatar.module.css +++ b/fluxer_app/src/features/ui/components/BaseAvatar.module.css @@ -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 { diff --git a/fluxer_app/src/features/ui/components/BaseAvatar.tsx b/fluxer_app/src/features/ui/components/BaseAvatar.tsx index 305e19ede..55b74af4a 100644 --- a/fluxer_app/src/features/ui/components/BaseAvatar.tsx +++ b/fluxer_app/src/features/ui/components/BaseAvatar.tsx @@ -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(); const AVATAR_VIEW_BOX_CACHE = new Map(); 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( size, avatarUrl, hoverAvatarUrl, + onImageLoaded, status, shouldPlayAnimated = false, forceAnimatedPlayback = false, @@ -145,17 +159,12 @@ export const BaseAvatar = React.forwardRef( 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( 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(() => + 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) => { + 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( [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( [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(() => { 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( }, [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( size={size} layout={layout} statusBox={statusBox} + animateShape={animateAvatarMaskShape} + reducedMotion={reducedMotion} data-flx="ui.base-avatar.dynamic-avatar-mask" /> )} - {showSkeleton ? ( + {shouldMountPlaceholder && ( - ) : showFallback ? null : ( - + data-flx="ui.base-avatar.image-frame" + > + + )} (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 ( ) : kind === 'typing' ? ( diff --git a/fluxer_app/src/features/ui/components/MockAvatar.tsx b/fluxer_app/src/features/ui/components/MockAvatar.tsx index 7558f4cc2..642018322 100644 --- a/fluxer_app/src/features/ui/components/MockAvatar.tsx +++ b/fluxer_app/src/features/ui/components/MockAvatar.tsx @@ -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( ( { size, - avatarUrl = cdnUrl('avatars/0.png'), + avatarUrl = getDefaultAvatarURLForIndex(0), hoverAvatarUrl, status, isTyping = false, diff --git a/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx b/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx index e304ba8f7..69bc535e4 100644 --- a/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx +++ b/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx @@ -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 = observer( hoverAvatarUrl, guildId, mediaSize, - deferImageLoad, status: externalStatus, animateStatusCutout, }) => { @@ -86,7 +84,6 @@ export const StatusAwareAvatar: React.FC = observer( hoverAvatarUrl={hoverAvatarUrl} guildId={guildId} mediaSize={mediaSize} - deferImageLoad={deferImageLoad} animateStatusCutout={animateStatusCutout} data-flx="ui.status-aware-avatar.avatar" /> diff --git a/fluxer_app/src/features/ui/components/StatusIndicator.tsx b/fluxer_app/src/features/ui/components/StatusIndicator.tsx index ba2c169c1..950952068 100644 --- a/fluxer_app/src/features/ui/components/StatusIndicator.tsx +++ b/fluxer_app/src/features/ui/components/StatusIndicator.tsx @@ -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 (