Files
bitvid/js/ui/components/video-modal/linkPreviewController.js
T

310 lines
9.1 KiB
JavaScript

import { LinkPreviewService } from "../../../services/linkPreviewService.js";
import {
allowLinkPreviewDomain,
getLinkPreviewSettings,
isLinkPreviewDomainAllowed,
subscribeToLinkPreviewSettings,
} from "../../../utils/linkPreviewSettings.js";
import { devLogger } from "../../../utils/logger.js";
export class LinkPreviewController {
constructor({ modal }) {
this.modal = modal;
this.linkPreviewService = new LinkPreviewService();
this.linkPreviewAbortControllers = new Map();
this.linkPreviewSettingsUnsubscribe = null;
this.currentDescriptionText = "";
this.videoDescriptionPreviews = null;
this.handleLinkPreviewSettingsChange =
this.handleLinkPreviewSettingsChange.bind(this);
}
get document() {
return this.modal?.document || null;
}
initialize({ playerModal }) {
if (!playerModal) return;
this.videoDescriptionPreviews =
playerModal.querySelector("#videoDescriptionPreviews") || null;
if (this.linkPreviewSettingsUnsubscribe) {
this.linkPreviewSettingsUnsubscribe();
}
this.linkPreviewSettingsUnsubscribe = subscribeToLinkPreviewSettings(
this.handleLinkPreviewSettingsChange,
);
}
destroy() {
this.clearLinkPreviews();
if (this.linkPreviewSettingsUnsubscribe) {
this.linkPreviewSettingsUnsubscribe();
this.linkPreviewSettingsUnsubscribe = null;
}
this.videoDescriptionPreviews = null;
this.linkPreviewAbortControllers.clear();
}
handleLinkPreviewSettingsChange(event) {
const detail = event?.detail?.settings || null;
if (!this.videoDescriptionPreviews) {
return;
}
const nextText = this.currentDescriptionText || "";
if (!nextText) {
return;
}
if (detail && typeof detail.autoFetchUnknownDomains === "boolean") {
this.renderLinkPreviews(nextText);
}
}
clearLinkPreviewRequests() {
if (!(this.linkPreviewAbortControllers instanceof Map)) {
this.linkPreviewAbortControllers = new Map();
return;
}
this.linkPreviewAbortControllers.forEach((controller) => {
try {
controller.abort();
} catch {
// noop
}
});
this.linkPreviewAbortControllers.clear();
}
clearLinkPreviews() {
this.clearLinkPreviewRequests();
this.currentDescriptionText = "";
if (this.videoDescriptionPreviews) {
this.videoDescriptionPreviews.textContent = "";
this.videoDescriptionPreviews.setAttribute("hidden", "");
}
}
renderLinkPreviews(description) {
const root = this.videoDescriptionPreviews;
if (!root || !this.document) {
return;
}
const text =
typeof description === "string" ? description : String(description ?? "");
this.currentDescriptionText = text;
this.clearLinkPreviewRequests();
root.textContent = "";
const urls = this.extractDescriptionUrls(text);
if (!urls.length) {
root.setAttribute("hidden", "");
return;
}
root.removeAttribute("hidden");
const fragment = this.document.createDocumentFragment();
urls.forEach((url) => {
const domain = this.extractPreviewDomain(url);
const card = this.createLinkPreviewCard({ url, domain });
fragment.appendChild(card);
void this.resolveLinkPreview(url, domain, card);
});
root.appendChild(fragment);
}
extractDescriptionUrls(text) {
if (!text) {
return [];
}
const urlPattern = /\bhttps?:\/\/[^\s<>"']+/gi;
const urls = new Set();
let match;
while ((match = urlPattern.exec(text)) !== null) {
const normalized = this.normalizePreviewUrl(match[0]);
if (normalized) {
urls.add(normalized);
}
}
return Array.from(urls);
}
normalizePreviewUrl(candidate) {
if (!candidate) {
return "";
}
let href = typeof candidate === "string" ? candidate : String(candidate ?? "");
let trailing = "";
const trailingPattern = /[)\]\}>"',.;!?]+$/;
while (href && trailingPattern.test(href)) {
trailing = href.slice(-1) + trailing;
href = href.slice(0, -1);
}
return href.trim();
}
extractPreviewDomain(url) {
if (!url) {
return "";
}
try {
return new URL(url).hostname || "";
} catch {
return "";
}
}
createLinkPreviewCard({ url, domain }) {
const card = this.document.createElement("article");
card.className = "card flex flex-col gap-2 p-3";
card.dataset.linkPreviewUrl = url;
const header = this.document.createElement("div");
header.className = "flex items-start gap-3";
const imageWrapper = this.document.createElement("div");
imageWrapper.className =
"flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-border/60 bg-surface-muted text-2xs text-muted";
const image = this.document.createElement("img");
image.hidden = true;
image.loading = "lazy";
image.decoding = "async";
image.className = "h-full w-full object-cover";
const imagePlaceholder = this.document.createElement("span");
imagePlaceholder.textContent = "Preview";
imageWrapper.appendChild(image);
imageWrapper.appendChild(imagePlaceholder);
const content = this.document.createElement("div");
content.className = "flex min-w-0 flex-1 flex-col gap-1";
const domainEl = this.document.createElement("p");
domainEl.className = "text-3xs uppercase tracking-wide text-muted";
domainEl.textContent = domain || "Link preview";
const titleEl = this.document.createElement("a");
titleEl.className =
"text-sm font-semibold text-text break-words hover:underline focus-ring";
titleEl.href = url;
titleEl.target = "_blank";
titleEl.rel = "noopener noreferrer";
titleEl.textContent = url;
const descEl = this.document.createElement("p");
descEl.className = "text-xs text-muted";
descEl.textContent = "";
content.appendChild(domainEl);
content.appendChild(titleEl);
content.appendChild(descEl);
header.appendChild(imageWrapper);
header.appendChild(content);
const statusEl = this.document.createElement("p");
statusEl.className = "text-xs text-muted";
const actions = this.document.createElement("div");
actions.className = "flex flex-wrap gap-2";
card.appendChild(header);
card.appendChild(statusEl);
card.appendChild(actions);
card.__linkPreviewElements = {
imageWrapper,
image,
imagePlaceholder,
domainEl,
titleEl,
descEl,
statusEl,
actions,
};
return card;
}
setLinkPreviewStatus(card, { message = "", actionLabel = "", onAction } = {}) {
const elements = card?.__linkPreviewElements;
if (!elements) {
return;
}
const statusText = typeof message === "string" ? message : "";
elements.statusEl.textContent = statusText;
elements.actions.textContent = "";
if (actionLabel && typeof onAction === "function") {
const button = this.document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = actionLabel;
button.addEventListener("click", onAction);
elements.actions.appendChild(button);
}
}
updateLinkPreviewCard(card, preview, { domain } = {}) {
const elements = card?.__linkPreviewElements;
if (!elements || !preview) {
return;
}
const title = preview.title || preview.siteName || preview.url || "";
elements.titleEl.textContent = title || preview.url;
elements.descEl.textContent = preview.description || "";
const label = preview.siteName || domain || "Link preview";
elements.domainEl.textContent = label;
if (preview.image) {
elements.image.src = preview.image;
elements.image.alt = title || "Link preview";
elements.image.hidden = false;
elements.imagePlaceholder.hidden = true;
} else {
elements.image.hidden = true;
elements.imagePlaceholder.hidden = false;
}
this.setLinkPreviewStatus(card, { message: "" });
}
async resolveLinkPreview(url, domain, card) {
const settings = getLinkPreviewSettings();
const isAllowed =
settings.autoFetchUnknownDomains ||
isLinkPreviewDomainAllowed(domain, settings);
if (!isAllowed) {
this.setLinkPreviewStatus(card, {
message: "Preview disabled for new domains.",
actionLabel: domain ? `Allow previews for ${domain}` : "Allow previews",
onAction: () => {
if (domain) {
allowLinkPreviewDomain(domain);
}
},
});
return;
}
this.setLinkPreviewStatus(card, { message: "Loading preview…" });
const controller = new AbortController();
this.linkPreviewAbortControllers.set(url, controller);
const preview = await this.linkPreviewService.getPreview(url, {
signal: controller.signal,
});
this.linkPreviewAbortControllers.delete(url);
if (!preview) {
this.setLinkPreviewStatus(card, { message: "Preview unavailable." });
return;
}
this.updateLinkPreviewCard(card, preview, { domain });
if (domain) {
allowLinkPreviewDomain(domain, { silent: true });
}
}
}