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