refactor(ui): extract bindThumbnailFallbacks from app.js

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-23 01:16:32 +00:00
1 parent ee8569a99b
commit 4a5099d745
4 files changed
+244 -80

No files matched your search

@@ -0,0 +1,22 @@
# Refactor Agent: Extract bindThumbnailFallbacks
## Summary
Extracted the `bindThumbnailFallbacks` logic from `js/app.js` into a new dedicated UI utility module `js/ui/thumbnailBinder.js`. This reduces the complexity of the monolithic `Application` class and improves testability of the thumbnail fallback logic.
## Changes
- Created `js/ui/thumbnailBinder.js`.
- Extracted `bindThumbnailFallbacks` function and `FALLBACK_THUMBNAIL_SRC` constant.
- Updated `js/app.js` to import and delegate to the new module.
- Added unit tests in `tests/unit/ui/thumbnailBinder.test.mjs`.
## Verification
- `npm run test:unit`: Passed (including new tests).
- `npm run lint`: Passed.
## AGENTS.md Refactor Checklist Compliance
- **Select one refactor target**: Selected `bindThumbnailFallbacks` (>50 LOC, mix of concerns).
- **Extract one coherent helper**: Extracted `bindThumbnailFallbacks`.
- **Wire thin wrapper**: `Application.prototype.bindThumbnailFallbacks` now delegates to the imported function.
- **Add unit tests**: Added comprehensive tests covering normal flow and edge cases.
- **Produce small PR**: Changes are limited to extraction and wiring.
- **Preserve semantics**: Original behavior, including event binding logic and fallback source selection, is preserved.
+5 -80
View File
@@ -185,13 +185,16 @@ import {
safeDecodeNpub,
normalizeHexPubkey,
} from "./utils/nostrHelpers.js";
import {
bindThumbnailFallbacks as bindThumbnailFallbacksUtil,
FALLBACK_THUMBNAIL_SRC,
} from "./ui/thumbnailBinder.js";
const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args);
const UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
const FALLBACK_THUMBNAIL_SRC = "/assets/jpg/video-thumbnail-fallback.jpg";
const VIDEO_EVENT_KIND = 30078;
const RELAY_UI_BATCH_DELAY_MS = 250;
@@ -4109,85 +4112,7 @@ class Application {
}
bindThumbnailFallbacks(container) {
if (!container || typeof container.querySelectorAll !== "function") {
return;
}
const thumbnails = container.querySelectorAll("[data-video-thumbnail]");
thumbnails.forEach((img) => {
if (!img) {
return;
}
const ensureFallbackSource = () => {
let fallbackSrc = "";
if (typeof img.dataset.fallbackSrc === "string") {
fallbackSrc = img.dataset.fallbackSrc.trim();
}
if (!fallbackSrc) {
const attr = img.getAttribute("data-fallback-src") || "";
fallbackSrc = attr.trim();
}
if (!fallbackSrc && img.tagName === "IMG") {
fallbackSrc = FALLBACK_THUMBNAIL_SRC;
}
if (fallbackSrc) {
if (img.dataset.fallbackSrc !== fallbackSrc) {
img.dataset.fallbackSrc = fallbackSrc;
}
if (!img.getAttribute("data-fallback-src")) {
img.setAttribute("data-fallback-src", fallbackSrc);
}
}
return fallbackSrc;
};
const applyFallback = () => {
const fallbackSrc = ensureFallbackSource() || FALLBACK_THUMBNAIL_SRC;
if (!fallbackSrc) {
return;
}
if (img.src !== fallbackSrc) {
img.src = fallbackSrc;
}
img.dataset.thumbnailFailed = "true";
};
const handleLoad = () => {
if (
(img.naturalWidth === 0 && img.naturalHeight === 0) ||
!img.currentSrc
) {
applyFallback();
} else {
delete img.dataset.thumbnailFailed;
}
};
ensureFallbackSource();
if (img.dataset.thumbnailFallbackBound === "true") {
if (img.complete) {
handleLoad();
}
return;
}
img.addEventListener("error", applyFallback);
img.addEventListener("load", handleLoad);
img.dataset.thumbnailFallbackBound = "true";
if (img.complete) {
handleLoad();
}
});
bindThumbnailFallbacksUtil(container);
}
ensureGlobalMoreMenuHandlers() {
+92
View File
@@ -0,0 +1,92 @@
/**
* Fallback image source for video thumbnails.
*/
export const FALLBACK_THUMBNAIL_SRC = "/assets/jpg/video-thumbnail-fallback.jpg";
/**
* Binds error handling and fallback logic to video thumbnail images within a container.
*
* @param {HTMLElement} container - The container element to search for thumbnails.
* @param {string} [fallbackSrc] - The fallback image source to use. Defaults to FALLBACK_THUMBNAIL_SRC.
*/
export function bindThumbnailFallbacks(container, fallbackSrc = FALLBACK_THUMBNAIL_SRC) {
if (!container || typeof container.querySelectorAll !== "function") {
return;
}
const thumbnails = container.querySelectorAll("[data-video-thumbnail]");
thumbnails.forEach((img) => {
if (!img) {
return;
}
const ensureFallbackSource = () => {
let currentFallback = "";
if (typeof img.dataset.fallbackSrc === "string") {
currentFallback = img.dataset.fallbackSrc.trim();
}
if (!currentFallback) {
const attr = img.getAttribute("data-fallback-src") || "";
currentFallback = attr.trim();
}
if (!currentFallback && img.tagName === "IMG") {
currentFallback = fallbackSrc;
}
if (currentFallback) {
if (img.dataset.fallbackSrc !== currentFallback) {
img.dataset.fallbackSrc = currentFallback;
}
if (!img.getAttribute("data-fallback-src")) {
img.setAttribute("data-fallback-src", currentFallback);
}
}
return currentFallback;
};
const applyFallback = () => {
const targetFallback = ensureFallbackSource() || fallbackSrc;
if (!targetFallback) {
return;
}
if (img.src !== targetFallback) {
img.src = targetFallback;
}
img.dataset.thumbnailFailed = "true";
};
const handleLoad = () => {
if (
(img.naturalWidth === 0 && img.naturalHeight === 0) ||
!img.currentSrc
) {
applyFallback();
} else {
delete img.dataset.thumbnailFailed;
}
};
ensureFallbackSource();
if (img.dataset.thumbnailFallbackBound === "true") {
if (img.complete) {
handleLoad();
}
return;
}
img.addEventListener("error", applyFallback);
img.addEventListener("load", handleLoad);
img.dataset.thumbnailFallbackBound = "true";
if (img.complete) {
handleLoad();
}
});
}
+125
View File
@@ -0,0 +1,125 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { bindThumbnailFallbacks, FALLBACK_THUMBNAIL_SRC } from "../../../js/ui/thumbnailBinder.js";
describe("bindThumbnailFallbacks", () => {
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
vi.restoreAllMocks();
});
it("should do nothing if container is invalid", () => {
// Should not throw
bindThumbnailFallbacks(null);
bindThumbnailFallbacks({});
});
it("should bind error handlers to images with data-video-thumbnail", () => {
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
img.src = "invalid-image.jpg";
container.appendChild(img);
// Spy on addEventListener
const addEventListenerSpy = vi.spyOn(img, "addEventListener");
bindThumbnailFallbacks(container);
expect(addEventListenerSpy).toHaveBeenCalledWith("error", expect.any(Function));
expect(addEventListenerSpy).toHaveBeenCalledWith("load", expect.any(Function));
expect(img.dataset.thumbnailFallbackBound).toBe("true");
});
it("should apply fallback source on error", () => {
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
img.src = "invalid-image.jpg";
container.appendChild(img);
bindThumbnailFallbacks(container);
// Trigger error event
const errorEvent = new Event("error");
img.dispatchEvent(errorEvent);
expect(img.src).toContain(FALLBACK_THUMBNAIL_SRC);
expect(img.dataset.thumbnailFailed).toBe("true");
});
it("should use custom fallback source if provided in dataset", () => {
const customFallback = "custom-fallback.jpg";
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
img.setAttribute("data-fallback-src", customFallback);
img.src = "invalid-image.jpg";
container.appendChild(img);
bindThumbnailFallbacks(container);
// Trigger error event
const errorEvent = new Event("error");
img.dispatchEvent(errorEvent);
expect(img.src).toContain(customFallback);
});
it("should use passed fallback source argument", () => {
const argFallback = "arg-fallback.jpg";
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
img.src = "invalid-image.jpg";
container.appendChild(img);
bindThumbnailFallbacks(container, argFallback);
// Trigger error event
const errorEvent = new Event("error");
img.dispatchEvent(errorEvent);
expect(img.src).toContain(argFallback);
});
it("should handle naturalWidth/Height checks on load", () => {
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
img.src = "empty-image.jpg";
container.appendChild(img);
// Mock natural dimensions
Object.defineProperty(img, "naturalWidth", { value: 0 });
Object.defineProperty(img, "naturalHeight", { value: 0 });
Object.defineProperty(img, "currentSrc", { value: "empty-image.jpg" }); // Ensure currentSrc exists
bindThumbnailFallbacks(container);
// Trigger load event
const loadEvent = new Event("load");
img.dispatchEvent(loadEvent);
expect(img.src).toContain(FALLBACK_THUMBNAIL_SRC);
expect(img.dataset.thumbnailFailed).toBe("true");
});
it("should not re-bind if already bound", () => {
const img = document.createElement("img");
img.setAttribute("data-video-thumbnail", "true");
container.appendChild(img);
bindThumbnailFallbacks(container);
const addEventListenerSpy = vi.spyOn(img, "addEventListener");
// Call again
bindThumbnailFallbacks(container);
expect(addEventListenerSpy).not.toHaveBeenCalled();
});
});