diff --git a/ui/package-lock.json b/ui/package-lock.json
index 0feab6e7d..d86a5147e 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -15,6 +15,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
+ "blurhash": "^2.0.5",
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",
@@ -4431,6 +4432,12 @@
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==",
"license": "MIT"
},
+ "node_modules/blurhash": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
+ "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
+ "license": "MIT"
+ },
"node_modules/boxen": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index b02012104..4eb920f3d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -24,6 +24,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
+ "blurhash": "^2.0.5",
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",
diff --git a/ui/src/common/BlurHashCanvas.jsx b/ui/src/common/BlurHashCanvas.jsx
new file mode 100644
index 000000000..25a9dd43e
--- /dev/null
+++ b/ui/src/common/BlurHashCanvas.jsx
@@ -0,0 +1,46 @@
+import { useEffect, useRef } from 'react'
+import PropTypes from 'prop-types'
+import { decode } from 'blurhash'
+
+// A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
+const DECODE_SIZE = 32
+
+export const BlurHashCanvas = ({ hash, className }) => {
+ const canvasRef = useRef(null)
+
+ useEffect(() => {
+ if (!hash || !canvasRef.current) {
+ return
+ }
+ try {
+ const pixels = decode(hash, DECODE_SIZE, DECODE_SIZE)
+ const ctx = canvasRef.current.getContext('2d')
+ if (!ctx) {
+ return
+ }
+ const imageData = ctx.createImageData(DECODE_SIZE, DECODE_SIZE)
+ imageData.data.set(pixels)
+ ctx.putImageData(imageData, 0, 0)
+ } catch {
+ // A malformed hash simply leaves the canvas transparent.
+ }
+ }, [hash])
+
+ if (!hash) {
+ return null
+ }
+ return (
+
+ )
+}
+
+BlurHashCanvas.propTypes = {
+ hash: PropTypes.string,
+ className: PropTypes.string,
+}
diff --git a/ui/src/common/BlurHashCanvas.test.jsx b/ui/src/common/BlurHashCanvas.test.jsx
new file mode 100644
index 000000000..edd567794
--- /dev/null
+++ b/ui/src/common/BlurHashCanvas.test.jsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { BlurHashCanvas } from './BlurHashCanvas'
+
+describe('BlurHashCanvas', () => {
+ // jsdom has no real 2D context; stub it so getContext doesn't hit its noisy "not implemented" path.
+ let getContextSpy
+ beforeEach(() => {
+ getContextSpy = vi
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
+ .mockReturnValue({
+ createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
+ putImageData: () => {},
+ })
+ })
+ afterEach(() => {
+ getContextSpy.mockRestore()
+ })
+
+ it('renders nothing without a hash', () => {
+ const { container } = render()
+ expect(container.querySelector('canvas')).toBeNull()
+ })
+
+ it('renders a canvas for a valid hash', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector('canvas')).not.toBeNull()
+ })
+
+ it('renders a canvas without throwing on a malformed hash', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const { container } = render()
+ expect(container.querySelector('canvas')).not.toBeNull()
+ spy.mockRestore()
+ })
+})