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

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

No files matched your search

@@ -5,7 +5,6 @@ import styles from '@app/features/app/components/shared/GroupDMAvatar.module.css
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import type {Channel} from '@app/features/channel/models/Channel';
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils';
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
import Presence from '@app/features/presence/state/Presence';
import TransientPresence from '@app/features/presence/state/TransientPresence';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -147,7 +146,7 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
width={1}
height={1}
fill={statusColor}
mask={`url(#svg-mask-status-${renderableStatus})`}
mask={`url(#flx-mask-presence-${renderableStatus})`}
data-flx="app.group-dm-avatar.render-group-status-dot.rect"
/>
</svg>
@@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
const user = Users.getUser(userId);
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
let avatarUrl: string;
if (user) {
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar});
} else {
const avatarIndex = index % 6;
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
}
const avatarUrl = user
? AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar})
: AvatarUtils.getDefaultAvatarURL(userId);
return (
<image
key={userId}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useEffect, useState} from 'react';
type ConnectionLike = {
saveData?: boolean;
addEventListener?: (event: string, cb: () => void) => void;
removeEventListener?: (event: string, cb: () => void) => void;
};
const getConnection = (): ConnectionLike | null => {
if (typeof navigator === 'undefined') return null;
const nav = navigator as Navigator & {connection?: ConnectionLike};
return nav.connection ?? null;
};
const readSaveData = (): boolean => getConnection()?.saveData === true;
const saveDataListeners = new Set<(value: boolean) => void>();
let cachedSaveData = readSaveData();
let detachConnectionListener: (() => void) | null = null;
function publishSaveData(): void {
const next = readSaveData();
if (next === cachedSaveData) return;
cachedSaveData = next;
for (const listener of saveDataListeners) listener(next);
}
export function subscribeSaveData(listener: (value: boolean) => void): () => void {
saveDataListeners.add(listener);
if (detachConnectionListener === null) {
const connection = getConnection();
if (connection?.addEventListener) {
connection.addEventListener('change', publishSaveData);
const {removeEventListener} = connection;
detachConnectionListener = removeEventListener
? () => removeEventListener.call(connection, 'change', publishSaveData)
: () => {};
}
}
return () => {
saveDataListeners.delete(listener);
if (saveDataListeners.size > 0) return;
detachConnectionListener?.();
detachConnectionListener = null;
};
}
export function useSaveData(): boolean {
const [saveData, setSaveData] = useState(readSaveData);
useEffect(() => {
const unsubscribe = subscribeSaveData(setSaveData);
cachedSaveData = readSaveData();
setSaveData(cachedSaveData);
return unsubscribe;
}, []);
return saveData;
}
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useSaveData} from '@app/features/app/hooks/useSaveData';
import UserSettings from '@app/features/user/state/UserSettings';
import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants';
import {useEffect, useState} from 'react';
export type ShouldAnimateKind =
| 'avatar'
@@ -17,65 +16,13 @@ export type ShouldAnimateKind =
export interface UseShouldAnimateOptions {
kind: ShouldAnimateKind;
isAnimated?: boolean;
isHovering?: boolean;
isFocused?: boolean;
entitlementOk?: boolean;
respectPlaybackAllowed?: boolean;
}
type ConnectionLike = {
saveData?: boolean;
addEventListener?: (event: string, cb: () => void) => void;
};
const getConnection = (): ConnectionLike | undefined => {
const nav =
typeof navigator === 'undefined'
? undefined
: (navigator as Navigator & {
connection?: ConnectionLike;
});
return nav?.connection;
};
let cachedSaveData: boolean = getConnection()?.saveData === true;
const saveDataListeners = new Set<(value: boolean) => void>();
let saveDataListenerInstalled = false;
function installSaveDataListener(): void {
if (saveDataListenerInstalled) return;
const connection = getConnection();
if (!connection?.addEventListener) {
saveDataListenerInstalled = true;
return;
}
connection.addEventListener('change', () => {
const next = getConnection()?.saveData === true;
if (next !== cachedSaveData) {
cachedSaveData = next;
saveDataListeners.forEach((listener) => listener(next));
}
});
saveDataListenerInstalled = true;
}
function useSaveData(): boolean {
const [saveData, setSaveData] = useState(() => getConnection()?.saveData === true);
useEffect(() => {
installSaveDataListener();
const live = getConnection()?.saveData === true;
if (live !== cachedSaveData) cachedSaveData = live;
setSaveData(live);
saveDataListeners.add(setSaveData);
return () => {
saveDataListeners.delete(setSaveData);
};
}, []);
return saveData;
}
function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean {
if (kind === 'emoji' || kind === 'gif' || kind === 'sticker') {
return Accessibility.isAnimationKeptUnderReducedMotion(kind);
@@ -106,27 +53,27 @@ function getKindAllowance(kind: ShouldAnimateKind): AnimationAllowanceMode {
}
export interface ShouldAnimateDecisionInput {
isAnimated?: boolean;
allowance: AnimationAllowanceMode;
reducedMotion: boolean;
keptUnderReducedMotion: boolean;
isInteracting: boolean;
entitlementOk?: boolean;
saveData: boolean;
animatedMediaPlaybackAllowed: boolean;
}
export function resolveShouldAnimateDecision({
isAnimated = true,
allowance,
reducedMotion,
keptUnderReducedMotion,
isInteracting,
entitlementOk,
saveData,
animatedMediaPlaybackAllowed,
}: ShouldAnimateDecisionInput): boolean {
if (!isAnimated) return false;
if (entitlementOk === false) return false;
if (saveData) return false;
if (!animatedMediaPlaybackAllowed) return false;
if (allowance === 'NEVER') return false;
if (reducedMotion && !keptUnderReducedMotion) return isInteracting;
if (allowance === 'ALWAYS') return true;
@@ -135,21 +82,20 @@ export function resolveShouldAnimateDecision({
export function useShouldAnimate({
kind,
isAnimated = true,
isHovering = false,
isFocused = false,
entitlementOk,
respectPlaybackAllowed = true,
}: UseShouldAnimateOptions): boolean {
const saveData = useSaveData();
const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed({enabled: respectPlaybackAllowed});
const allowance = getKindAllowance(kind);
return resolveShouldAnimateDecision({
isAnimated,
allowance,
reducedMotion: Accessibility.useReducedMotion,
keptUnderReducedMotion: isKeptUnderReducedMotion(kind),
isInteracting: isHovering || isFocused,
entitlementOk,
saveData,
animatedMediaPlaybackAllowed,
});
}