Fix crash on invalid blurhash strings from Nostr events

Validate blurhash hashes before passing them to react-blurhash's
<Blurhash> component, which throws when the encoded length doesn't
match the component count header. Malformed hashes from third-party
events (e.g. length 92 instead of 94) now gracefully fall back to a
skeleton placeholder instead of crashing the page.
This commit is contained in:
Chad Curtis
2026-04-18 04:39:14 -05:00
parent 54a49f1ece
commit 36373400f8
6 changed files with 19 additions and 7 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { isValidBlurhash } from '@/lib/blurhash';
import { openUrl } from '@/lib/downloadFile';
import { Skeleton } from '@/components/ui/skeleton';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
@@ -230,7 +231,7 @@ function GridImage({
>
{/* Placeholder shown while the image is loading */}
{!loaded && (
blurhash ? (
isValidBlurhash(blurhash) ? (
// Blurhash canvas fills the container via CSS — pass small integer decode
// resolution; the canvas is stretched to 100%×100% by the style prop.
<Blurhash
+3 -2
View File
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { Images, Play, ShieldAlert } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
import { isValidBlurhash } from '@/lib/blurhash';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
@@ -139,7 +140,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
onClick={showBlur ? (e) => { e.stopPropagation(); setCwRevealed(true); } : onClick}
aria-label={showBlur ? 'Reveal sensitive content' : 'View media'}
>
{item.blurhash && (
{isValidBlurhash(item.blurhash) && (
<Blurhash
hash={item.blurhash}
width="100%"
@@ -151,7 +152,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
style={{ width: '100%', height: '100%' }}
/>
)}
{!item.blurhash && !loaded && item.type !== 'audio' && (
{!isValidBlurhash(item.blurhash) && !loaded && item.type !== 'audio' && (
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
)}
+2 -1
View File
@@ -4,6 +4,7 @@ import { Check, Copy, QrCode, ExternalLink, Bitcoin, ShieldAlert, Mail } from 'l
import { LinkFooter } from '@/components/LinkFooter';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { isValidBlurhash } from '@/lib/blurhash';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
@@ -193,7 +194,7 @@ function MediaTile({ item }: { item: MediaItem }) {
<div className="relative w-full h-full">
{/* Blurhash or skeleton placeholder while media loads */}
{!loaded && (
item.blurhash ? (
isValidBlurhash(item.blurhash) ? (
<Blurhash
hash={item.blurhash}
width={32}
+2 -1
View File
@@ -3,6 +3,7 @@ import type Hls from 'hls.js';
import { Play, Pause, Volume1, Volume2, VolumeX, Expand } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { isValidBlurhash } from '@/lib/blurhash';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { usePlayerControls } from '@/hooks/usePlayerControls';
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
@@ -203,7 +204,7 @@ export function VideoPlayer({ src: originalSrc, poster, className, dim, blurhash
onClick={(e) => e.stopPropagation()}
>
{/* Blurhash placeholder — shown until the video has a displayable frame */}
{blurhash && !videoReady && !generatedPoster && (
{isValidBlurhash(blurhash) && !videoReady && !generatedPoster && (
<Blurhash
hash={blurhash}
width="100%"
+7
View File
@@ -0,0 +1,7 @@
import { isBlurhashValid } from 'blurhash';
/** Returns `true` when `hash` is a structurally valid blurhash string. */
export function isValidBlurhash(hash: string | undefined | null): hash is string {
if (!hash) return false;
return isBlurhashValid(hash).result;
}
+3 -2
View File
@@ -22,6 +22,7 @@ import { nip19 } from "nostr-tools";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Blurhash } from "react-blurhash";
import { Link } from "react-router-dom";
import { isValidBlurhash } from "@/lib/blurhash";
import { ARC_OVERHANG_PX } from "@/components/ArcBackground";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
@@ -247,7 +248,7 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
>
{/* Thumbnail */}
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
{blurhash && (
{isValidBlurhash(blurhash) && (
<Blurhash
hash={blurhash}
width="100%"
@@ -644,7 +645,7 @@ function ShortThumb({
aria-label={title}
>
<div className="relative w-full aspect-[9/16] overflow-hidden rounded-xl bg-muted">
{blurhash && !thumbnail && (
{isValidBlurhash(blurhash) && !thumbnail && (
<Blurhash
hash={blurhash}
width="100%"