mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
310 lines
9.1 KiB
JavaScript
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 });
|
|
}
|
|
}
|
|
}
|