Add blurhash support for user profile images in ActivityIndicator component
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Integrated the blurhash library to decode and display blurred placeholders for user profile images.
- Updated UserProfileImage component to accept a blurHash prop and render the decoded image when available.
- Enhanced UserActivityAvatar to retrieve and pass the blurHash from user data, improving the visual experience during image loading.
This commit is contained in:
Tom Butcher 2026-07-26 21:28:43 +01:00
parent 8d0f92af45
commit 28dfb645ef
3 changed files with 60 additions and 3 deletions

View File

@ -42,6 +42,7 @@
"antd": "^5.27.1", "antd": "^5.27.1",
"antd-style": "^3.7.1", "antd-style": "^3.7.1",
"axios": "^1.11.0", "axios": "^1.11.0",
"blurhash": "^2.0.5",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",

8
pnpm-lock.yaml generated
View File

@ -107,6 +107,9 @@ importers:
axios: axios:
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.13.4 version: 1.13.4
blurhash:
specifier: ^2.0.5
version: 2.0.5
country-list: country-list:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.4.1 version: 2.4.1
@ -2358,6 +2361,9 @@ packages:
bluebird@3.7.2: bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
blurhash@2.0.5:
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -8644,6 +8650,8 @@ snapshots:
bluebird@3.7.2: {} bluebird@3.7.2: {}
blurhash@2.0.5: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useContext, useRef, memo } from 'react' import { useState, useEffect, useContext, useRef, memo } from 'react'
import { Flex, Card, Popover, Divider } from 'antd' import { Flex, Card, Popover, Divider } from 'antd'
import { UserOutlined } from '@ant-design/icons' import { UserOutlined } from '@ant-design/icons'
import { decode } from 'blurhash'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import EditIcon from '../../Icons/EditIcon' import EditIcon from '../../Icons/EditIcon'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -19,6 +20,14 @@ const getProfileImageId = (user) => {
) )
} }
const getProfileImageBlurHash = (user) => {
const profileImage = user?.profileImage
if (typeof profileImage === 'object' && profileImage !== null) {
return profileImage.metaData?.blurHash || null
}
return null
}
const getDisplayName = (user) => const getDisplayName = (user) =>
user?.name || user?.name ||
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || [user?.firstName, user?.lastName].filter(Boolean).join(' ') ||
@ -37,16 +46,39 @@ const avatarCardStyle = { borderRadius: '12px', width: 32, height: 32 }
const UserProfileImage = memo(function UserProfileImage({ const UserProfileImage = memo(function UserProfileImage({
profileImageId, profileImageId,
displayName displayName,
blurHash
}) { }) {
const { fetchFileThumbnail } = useContext(ApiServerContext) const { fetchFileThumbnail } = useContext(ApiServerContext)
const fetchFileThumbnailRef = useRef(fetchFileThumbnail) const fetchFileThumbnailRef = useRef(fetchFileThumbnail)
fetchFileThumbnailRef.current = fetchFileThumbnail fetchFileThumbnailRef.current = fetchFileThumbnail
const [profileImageUrl, setProfileImageUrl] = useState(null) const [profileImageUrl, setProfileImageUrl] = useState(null)
const [blurHashUrl, setBlurHashUrl] = useState(null)
const profileImageUrlRef = useRef(null) const profileImageUrlRef = useRef(null)
const loadedImageIdRef = useRef(null) const loadedImageIdRef = useRef(null)
useEffect(() => {
if (!blurHash) {
setBlurHashUrl(null)
return
}
try {
const pixels = decode(blurHash, 32, 32)
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
const imageData = ctx.createImageData(32, 32)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
setBlurHashUrl(canvas.toDataURL())
} catch {
setBlurHashUrl(null)
}
}, [blurHash])
useEffect(() => { useEffect(() => {
if (!profileImageId) { if (!profileImageId) {
if (profileImageUrlRef.current) { if (profileImageUrlRef.current) {
@ -111,6 +143,17 @@ const UserProfileImage = memo(function UserProfileImage({
) )
} }
if (profileImageId && blurHashUrl) {
return (
<img
src={blurHashUrl}
alt=''
aria-hidden
style={avatarImageStyle}
/>
)
}
return ( return (
<Card <Card
style={avatarCardStyle} style={avatarCardStyle}
@ -130,12 +173,14 @@ const UserProfileImage = memo(function UserProfileImage({
UserProfileImage.propTypes = { UserProfileImage.propTypes = {
profileImageId: PropTypes.string, profileImageId: PropTypes.string,
displayName: PropTypes.string.isRequired displayName: PropTypes.string.isRequired,
blurHash: PropTypes.string
} }
const UserActivityAvatar = memo( const UserActivityAvatar = memo(
function UserActivityAvatar({ user, mode }) { function UserActivityAvatar({ user, mode }) {
const profileImageId = getProfileImageId(user) const profileImageId = getProfileImageId(user)
const blurHash = getProfileImageBlurHash(user)
const displayName = getDisplayName(user) const displayName = getDisplayName(user)
const navigate = useNavigate() const navigate = useNavigate()
@ -173,6 +218,7 @@ const UserActivityAvatar = memo(
<UserProfileImage <UserProfileImage
profileImageId={profileImageId} profileImageId={profileImageId}
displayName={displayName} displayName={displayName}
blurHash={blurHash}
/> />
{mode === 'editing' && ( {mode === 'editing' && (
<div <div
@ -201,7 +247,9 @@ const UserActivityAvatar = memo(
return ( return (
prevProps.mode === nextProps.mode && prevProps.mode === nextProps.mode &&
getUserId(prevProps.user) === getUserId(nextProps.user) && getUserId(prevProps.user) === getUserId(nextProps.user) &&
getProfileImageId(prevProps.user) === getProfileImageId(nextProps.user) getProfileImageId(prevProps.user) === getProfileImageId(nextProps.user) &&
getProfileImageBlurHash(prevProps.user) ===
getProfileImageBlurHash(nextProps.user)
) )
} }
) )