Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions client/src/components/feed_card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,17 @@ import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import { timeago } from "../utils/timeago";
import { HashTag } from "./hashtag";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import { drawBlurhashToCanvas } from "../utils/blurhash";
import { parseImageUrlMetadata } from "../utils/image-upload";
import { useImageLoadState } from "../utils/use-image-load-state";

function FeedCardImage({ src }: { src: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const { src: cleanSrc, blurhash, width, height } = parseImageUrlMetadata(src);
const { failed, imageRef, loaded, onError, onLoad } = useImageLoadState(cleanSrc);
const aspectRatio = width && height ? `${width} / ${height}` : undefined;

useEffect(() => {
setLoaded(false);
setFailed(false);
}, [src]);

useEffect(() => {
if (!blurhash || !canvasRef.current) {
return;
Expand All @@ -30,7 +25,10 @@ function FeedCardImage({ src }: { src: string }) {
}, [blurhash]);

return (
<div className="relative flex flex-row items-center mb-2 overflow-hidden rounded-xl" style={{ aspectRatio }}>
<div
className="relative mb-2 flex max-h-80 w-full flex-row items-center overflow-hidden rounded-xl"
style={{ aspectRatio }}
>
{blurhash && !loaded ? (
<canvas
ref={canvasRef}
Expand All @@ -39,18 +37,13 @@ function FeedCardImage({ src }: { src: string }) {
/>
) : null}
<img
ref={imageRef}
src={cleanSrc}
alt=""
width={width}
height={height}
onLoad={() => {
setLoaded(true);
setFailed(false);
}}
onError={() => {
setLoaded(false);
setFailed(true);
}}
onLoad={onLoad}
onError={onError}
className={`absolute inset-0 h-full w-full object-cover object-center hover:scale-105 translation duration-300 ${blurhash && (!loaded || failed) ? "opacity-0" : "opacity-100"
}`}
/>
Expand Down
22 changes: 6 additions & 16 deletions client/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "katex/dist/katex.min.css";
import React, { cloneElement, isValidElement, useEffect, useMemo, useRef, useState } from "react";
import React, { cloneElement, isValidElement, useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
Expand All @@ -20,6 +20,7 @@ import "yet-another-react-lightbox/styles.css";
import { drawBlurhashToCanvas } from "../utils/blurhash";
import { useColorMode } from "../utils/darkModeUtils";
import { parseImageUrlMetadata } from "../utils/image-upload";
import { useImageLoadState } from "../utils/use-image-load-state";


const countNewlinesBeforeNode = (text: string, offset: number) => {
Expand Down Expand Up @@ -64,17 +65,11 @@ function MarkdownImage({
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const { src: cleanSrc, blurhash, width, height } = parseImageUrlMetadata(src);
const { failed, imageRef, loaded, onError, onLoad } = useImageLoadState(cleanSrc);
const roundedClass = rounded ? "rounded-xl" : "";
const aspectRatio = width && height ? `${width} / ${height}` : undefined;

useEffect(() => {
setLoaded(false);
setFailed(false);
}, [src]);

useEffect(() => {
if (!blurhash || !canvasRef.current) {
return;
Expand All @@ -99,21 +94,16 @@ function MarkdownImage({
/>
) : null}
<img
ref={imageRef}
src={cleanSrc}
alt={alt}
width={width}
height={height}
onClick={() => {
show(cleanSrc);
}}
onLoad={() => {
setLoaded(true);
setFailed(false);
}}
onError={() => {
setLoaded(false);
setFailed(true);
}}
onLoad={onLoad}
onError={onError}
className={`mx-auto max-w-full cursor-zoom-in transition-opacity ${roundedClass} ${className || ""} ${
blurhash && (!loaded || failed) ? "opacity-0" : "opacity-100"
}`}
Expand Down
15 changes: 14 additions & 1 deletion client/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
import '@testing-library/jest-dom'
import { JSDOM } from "jsdom";
import '@testing-library/jest-dom'

if (typeof document === "undefined") {
const { window } = new JSDOM("<!doctype html><html><body></body></html>");

Object.assign(globalThis, {
document: window.document,
HTMLElement: window.HTMLElement,
HTMLImageElement: window.HTMLImageElement,
navigator: window.navigator,
window,
});
}
6 changes: 6 additions & 0 deletions client/src/types/jsdom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module "jsdom" {
export class JSDOM {
constructor(html?: string, options?: unknown);
window: Window & typeof globalThis;
}
}
57 changes: 57 additions & 0 deletions client/src/utils/__tests__/use-image-load-state.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import "../../test/setup";
import { render, waitFor } from "@testing-library/react";
import type { MutableRefObject } from "react";
import { describe, expect, it } from "vitest";
import { useImageLoadState } from "../use-image-load-state";

function TestImage({ src, complete, naturalWidth }: { src: string; complete: boolean; naturalWidth: number }) {
const { imageRef, loaded, failed, onLoad, onError } = useImageLoadState(src);

return (
<>
<div data-testid="status">{JSON.stringify({ failed, loaded })}</div>
<img
ref={(node) => {
(imageRef as MutableRefObject<HTMLImageElement | null>).current = node;
if (!node) {
return;
}
Object.defineProperty(node, "complete", {
configurable: true,
get: () => complete,
});
Object.defineProperty(node, "naturalWidth", {
configurable: true,
get: () => naturalWidth,
});
}}
src={src}
alt=""
onLoad={onLoad}
onError={onError}
/>
</>
);
}

describe("useImageLoadState", () => {
it("marks a cached image as loaded after src changes", async () => {
const { getByTestId, rerender } = render(<TestImage src="https://example.com/a.png" complete={false} naturalWidth={0} />);

expect(getByTestId("status")).toHaveTextContent('{"failed":false,"loaded":false}');

rerender(<TestImage src="https://example.com/b.png" complete={true} naturalWidth={640} />);

await waitFor(() => {
expect(getByTestId("status")).toHaveTextContent('{"failed":false,"loaded":true}');
});
});

it("marks a completed broken image as failed", async () => {
const { getByTestId } = render(<TestImage src="https://example.com/broken.png" complete={true} naturalWidth={0} />);

await waitFor(() => {
expect(getByTestId("status")).toHaveTextContent('{"failed":true,"loaded":false}');
});
});
});
38 changes: 38 additions & 0 deletions client/src/utils/use-image-load-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from "react";

export function useImageLoadState(src?: string) {
const imageRef = useRef<HTMLImageElement>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);

useEffect(() => {
setLoaded(false);
setFailed(false);

const image = imageRef.current;
if (!src || !image || !image.complete) {
return;
}

if (image.naturalWidth > 0) {
setLoaded(true);
return;
}

setFailed(true);
}, [src]);

return {
failed,
imageRef,
loaded,
onError: () => {
setLoaded(false);
setFailed(true);
},
onLoad: () => {
setLoaded(true);
setFailed(false);
},
};
}
Loading