mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add cached link previews with privacy toggle
This commit is contained in:
1 parent
ed306bdf65
commit
0eacbd1063
6 files changed
+747
No files matched your search
@@ -443,6 +443,26 @@
|
||||
></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-text">Link previews</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Control when bitvid fetches link preview metadata. Disable auto-fetch to review new domains before previews load.
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
for="profileLinkPreviewAutoToggle"
|
||||
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
|
||||
>
|
||||
<input
|
||||
id="profileLinkPreviewAutoToggle"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>
|
||||
Auto-fetch previews from new domains
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -521,6 +521,12 @@
|
||||
>
|
||||
No description available.
|
||||
</p>
|
||||
<div
|
||||
id="videoDescriptionPreviews"
|
||||
class="mt-4 space-y-3"
|
||||
data-link-previews
|
||||
hidden
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvid-link-previews";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "previews";
|
||||
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
const normalizeUrl = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const resolveMetaContent = (doc, selector) => {
|
||||
if (!doc) {
|
||||
return "";
|
||||
}
|
||||
const element = doc.querySelector(selector);
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
const content = element.getAttribute("content") || "";
|
||||
return content.trim();
|
||||
};
|
||||
|
||||
const resolveFirstMetaContent = (doc, selectors) => {
|
||||
if (!doc || !Array.isArray(selectors)) {
|
||||
return "";
|
||||
}
|
||||
for (const selector of selectors) {
|
||||
const value = resolveMetaContent(doc, selector);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveAbsoluteUrl = (candidate, baseUrl) => {
|
||||
if (!candidate) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const resolved = new URL(candidate, baseUrl);
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export class LinkPreviewService {
|
||||
constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
|
||||
this.ttlMs = Number.isFinite(ttlMs) ? Math.max(0, ttlMs) : DEFAULT_TTL_MS;
|
||||
this.db = null;
|
||||
this.dbPromise = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
if (this.dbPromise) {
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
reject(new Error("IndexedDB is not supported."));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "url" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
this.db = event.target.result;
|
||||
resolve(this.db);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("IndexedDB open failed."));
|
||||
};
|
||||
});
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async withStore(mode, callback) {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, mode);
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = callback(store);
|
||||
|
||||
tx.oncomplete = () => resolve(request?.result ?? null);
|
||||
tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed."));
|
||||
tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted."));
|
||||
});
|
||||
}
|
||||
|
||||
async getCachedPreview(url) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const entry = await this.withStore("readonly", (store) => store.get(normalized));
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = Number(entry.expiresAt) || 0;
|
||||
if (expiresAt && expiresAt < Date.now()) {
|
||||
void this.deletePreview(normalized);
|
||||
return null;
|
||||
}
|
||||
return entry.data || null;
|
||||
} catch (error) {
|
||||
userLogger.warn("[LinkPreviewService] Failed to read cache.", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deletePreview(url) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.withStore("readwrite", (store) => store.delete(normalized));
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to clear cached preview.", error);
|
||||
}
|
||||
}
|
||||
|
||||
async setCachedPreview(url, data) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const payload = {
|
||||
url: normalized,
|
||||
fetchedAt: now,
|
||||
expiresAt: now + this.ttlMs,
|
||||
data,
|
||||
};
|
||||
try {
|
||||
await this.withStore("readwrite", (store) => store.put(payload));
|
||||
} catch (error) {
|
||||
userLogger.warn("[LinkPreviewService] Failed to cache preview.", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getPreview(url, { signal } = {}) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const cached = await this.getCachedPreview(normalized);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const preview = await this.fetchPreview(normalized, { signal });
|
||||
if (preview) {
|
||||
await this.setCachedPreview(normalized, preview);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
async fetchPreview(url, { signal } = {}) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(normalized, {
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
redirect: "follow",
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return null;
|
||||
}
|
||||
devLogger.warn("[LinkPreviewService] Preview fetch failed.", error);
|
||||
return null;
|
||||
}
|
||||
if (!response || !response.ok) {
|
||||
return null;
|
||||
}
|
||||
let html = "";
|
||||
try {
|
||||
html = await response.text();
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to read preview response.", error);
|
||||
return null;
|
||||
}
|
||||
return this.parsePreview(html, normalized);
|
||||
}
|
||||
|
||||
parsePreview(html, url) {
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(html, "text/html");
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to parse preview HTML.", error);
|
||||
return null;
|
||||
}
|
||||
const title = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:title"]',
|
||||
'meta[name="twitter:title"]',
|
||||
'meta[name="title"]',
|
||||
]) || doc.title?.trim() || "";
|
||||
const description = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:description"]',
|
||||
'meta[name="twitter:description"]',
|
||||
'meta[name="description"]',
|
||||
]);
|
||||
const image = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:image"]',
|
||||
'meta[name="twitter:image"]',
|
||||
'meta[name="twitter:image:src"]',
|
||||
]);
|
||||
const siteName = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:site_name"]',
|
||||
]);
|
||||
const resolvedImage = resolveAbsoluteUrl(image, url);
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: resolvedImage,
|
||||
siteName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,13 @@ import {
|
||||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||||
import { normalizeHashtag } from "../../utils/hashtagNormalization.js";
|
||||
import { getBreakpointLg } from "../../designSystem/metrics.js";
|
||||
import { LinkPreviewService } from "../../services/linkPreviewService.js";
|
||||
import {
|
||||
allowLinkPreviewDomain,
|
||||
getLinkPreviewSettings,
|
||||
isLinkPreviewDomainAllowed,
|
||||
subscribeToLinkPreviewSettings,
|
||||
} from "../../utils/linkPreviewSettings.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const SIMILAR_CONTENT_LIMIT = 10;
|
||||
@@ -127,6 +134,7 @@ export class VideoModal {
|
||||
this.modalDownloaded = null;
|
||||
this.videoTitle = null;
|
||||
this.videoDescription = null;
|
||||
this.videoDescriptionPreviews = null;
|
||||
this.videoTimestamp = null;
|
||||
this.videoEditedTimestamp = null;
|
||||
this.videoViewCountEl = null;
|
||||
@@ -248,6 +256,12 @@ export class VideoModal {
|
||||
this.commentCallbacks = {
|
||||
teardown: null,
|
||||
};
|
||||
this.linkPreviewService = new LinkPreviewService();
|
||||
this.linkPreviewAbortControllers = new Map();
|
||||
this.linkPreviewSettingsUnsubscribe = null;
|
||||
this.currentDescriptionText = "";
|
||||
this.handleLinkPreviewSettingsChange =
|
||||
this.handleLinkPreviewSettingsChange.bind(this);
|
||||
this.boundCommentSubmitHandler =
|
||||
this.handleCommentComposerSubmit.bind(this);
|
||||
this.boundCommentInputHandler =
|
||||
@@ -598,6 +612,8 @@ export class VideoModal {
|
||||
this.videoTitle = playerModal.querySelector("#videoTitle") || null;
|
||||
this.videoDescription =
|
||||
playerModal.querySelector("#videoDescription") || null;
|
||||
this.videoDescriptionPreviews =
|
||||
playerModal.querySelector("#videoDescriptionPreviews") || null;
|
||||
this.videoTimestamp = playerModal.querySelector("#videoTimestamp") || null;
|
||||
this.videoEditedTimestamp =
|
||||
playerModal.querySelector("#videoEditedTimestamp") || null;
|
||||
@@ -650,6 +666,13 @@ export class VideoModal {
|
||||
|
||||
this.commentsController.initialize({ playerModal });
|
||||
|
||||
if (this.linkPreviewSettingsUnsubscribe) {
|
||||
this.linkPreviewSettingsUnsubscribe();
|
||||
}
|
||||
this.linkPreviewSettingsUnsubscribe = subscribeToLinkPreviewSettings(
|
||||
this.handleLinkPreviewSettingsChange,
|
||||
);
|
||||
|
||||
const closeButton = playerModal.querySelector("#closeModal");
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener("click", () => {
|
||||
@@ -1998,6 +2021,11 @@ export class VideoModal {
|
||||
this.similarContentController.destroy();
|
||||
this.moderationController.destroy();
|
||||
this.invokeCommentTeardown();
|
||||
this.clearLinkPreviews();
|
||||
if (this.linkPreviewSettingsUnsubscribe) {
|
||||
this.linkPreviewSettingsUnsubscribe();
|
||||
this.linkPreviewSettingsUnsubscribe = null;
|
||||
}
|
||||
|
||||
if (this.document) {
|
||||
this.document.removeEventListener(
|
||||
@@ -2783,6 +2811,7 @@ export class VideoModal {
|
||||
this.resetCommentComposer();
|
||||
this.renderVideoTags([]);
|
||||
this.clearSimilarContent();
|
||||
this.clearLinkPreviews();
|
||||
this.forceRemovePoster("close");
|
||||
}
|
||||
|
||||
@@ -4708,6 +4737,7 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
if (description === null || description === undefined) {
|
||||
this.clearLinkPreviews();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4715,6 +4745,7 @@ export class VideoModal {
|
||||
typeof description === "string" ? description : String(description ?? "");
|
||||
|
||||
if (!normalized) {
|
||||
this.clearLinkPreviews();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4729,6 +4760,7 @@ export class VideoModal {
|
||||
});
|
||||
|
||||
target.appendChild(fragment);
|
||||
this.renderLinkPreviews(normalized);
|
||||
}
|
||||
|
||||
appendDescriptionLine(target, line) {
|
||||
@@ -4804,6 +4836,265 @@ export class VideoModal {
|
||||
return { anchor, trailing };
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
updateTimestamps({ posted, edited } = {}) {
|
||||
if (this.videoTimestamp) {
|
||||
if (posted) {
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
uploadAttachment,
|
||||
getAttachmentCacheStats,
|
||||
} from "../services/attachmentService.js";
|
||||
import {
|
||||
getLinkPreviewSettings,
|
||||
setLinkPreviewAutoFetch,
|
||||
} from "../utils/linkPreviewSettings.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -1235,6 +1239,7 @@ export class ProfileModalController {
|
||||
this.profileMessagesRelayAddButton = null;
|
||||
this.profileMessagesRelayPublishButton = null;
|
||||
this.profileMessagesRelayStatus = null;
|
||||
this.profileLinkPreviewAutoToggle = null;
|
||||
this.walletUriInput = null;
|
||||
this.walletDefaultZapInput = null;
|
||||
this.walletSaveButton = null;
|
||||
@@ -1531,6 +1536,8 @@ export class ProfileModalController {
|
||||
document.getElementById("profileMessagesRelayPublish") || null;
|
||||
this.profileMessagesRelayStatus =
|
||||
document.getElementById("profileMessagesRelayStatus") || null;
|
||||
this.profileLinkPreviewAutoToggle =
|
||||
document.getElementById("profileLinkPreviewAutoToggle") || null;
|
||||
|
||||
if (this.pendingMessagesRender) {
|
||||
const { messages, actorPubkey } = this.pendingMessagesRender;
|
||||
@@ -1993,6 +2000,20 @@ export class ProfileModalController {
|
||||
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
|
||||
}
|
||||
|
||||
syncLinkPreviewSettingsUi() {
|
||||
if (!(this.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const settings = getLinkPreviewSettings();
|
||||
this.profileLinkPreviewAutoToggle.checked = Boolean(
|
||||
settings?.autoFetchUnknownDomains,
|
||||
);
|
||||
}
|
||||
|
||||
handleLinkPreviewToggle(enabled) {
|
||||
setLinkPreviewAutoFetch(Boolean(enabled));
|
||||
}
|
||||
|
||||
async refreshDmRelayPreferences({ force = false } = {}) {
|
||||
const owner = this.resolveActiveDmRelayOwner();
|
||||
if (!owner) {
|
||||
@@ -3907,6 +3928,15 @@ export class ProfileModalController {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) {
|
||||
this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => {
|
||||
const toggle = event.currentTarget;
|
||||
if (toggle instanceof HTMLInputElement) {
|
||||
this.handleLinkPreviewToggle(toggle.checked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.profileMessagesRelayAddButton instanceof HTMLElement) {
|
||||
this.profileMessagesRelayAddButton.addEventListener("click", () => {
|
||||
void this.handleAddDmRelayPreference();
|
||||
@@ -10808,6 +10838,7 @@ export class ProfileModalController {
|
||||
this.renderSavedProfiles();
|
||||
this.refreshWalletPaneState();
|
||||
this.refreshModerationSettingsUi();
|
||||
this.syncLinkPreviewSettingsUi();
|
||||
const hasBlockHydrator =
|
||||
this.services.userBlocks &&
|
||||
typeof this.services.userBlocks.ensureLoaded === "function";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { devLogger } from "./logger.js";
|
||||
|
||||
const STORAGE_KEY = "bitvid:linkPreviewSettings:v1";
|
||||
const EVENT_NAME = "bitvid:link-preview-settings";
|
||||
const DEFAULT_SETTINGS = Object.freeze({
|
||||
autoFetchUnknownDomains: true,
|
||||
allowedDomains: [],
|
||||
});
|
||||
|
||||
const getStorage = () => {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return window.localStorage;
|
||||
}
|
||||
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
|
||||
return globalThis.localStorage;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEventTarget = () =>
|
||||
typeof window !== "undefined" ? window : globalThis;
|
||||
|
||||
const normalizeDomain = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const sanitizeDomains = (domains) => {
|
||||
if (!Array.isArray(domains)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = domains
|
||||
.map((domain) => normalizeDomain(domain))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
const sanitizeSettings = (settings) => {
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return {
|
||||
autoFetchUnknownDomains: DEFAULT_SETTINGS.autoFetchUnknownDomains,
|
||||
allowedDomains: [],
|
||||
};
|
||||
}
|
||||
const autoFetchUnknownDomains =
|
||||
typeof settings.autoFetchUnknownDomains === "boolean"
|
||||
? settings.autoFetchUnknownDomains
|
||||
: DEFAULT_SETTINGS.autoFetchUnknownDomains;
|
||||
const allowedDomains = sanitizeDomains(settings.allowedDomains);
|
||||
return {
|
||||
autoFetchUnknownDomains,
|
||||
allowedDomains,
|
||||
};
|
||||
};
|
||||
|
||||
const emitSettings = (settings) => {
|
||||
const target = getEventTarget();
|
||||
if (!target || typeof target.dispatchEvent !== "function") {
|
||||
return;
|
||||
}
|
||||
target.dispatchEvent(
|
||||
new CustomEvent(EVENT_NAME, { detail: { settings } }),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLinkPreviewSettings = () => {
|
||||
const storage = getStorage();
|
||||
if (!storage) {
|
||||
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
|
||||
}
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const sanitized = sanitizeSettings(parsed);
|
||||
return { ...sanitized, allowedDomains: [...sanitized.allowedDomains] };
|
||||
} catch (error) {
|
||||
devLogger.warn("[linkPreviewSettings] Failed to parse settings.", error);
|
||||
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const persistSettings = (settings, { silent = false } = {}) => {
|
||||
const storage = getStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
devLogger.warn("[linkPreviewSettings] Failed to persist settings.", error);
|
||||
}
|
||||
if (!silent) {
|
||||
emitSettings(settings);
|
||||
}
|
||||
};
|
||||
|
||||
export const setLinkPreviewAutoFetch = (enabled) => {
|
||||
const current = getLinkPreviewSettings();
|
||||
const next = {
|
||||
...current,
|
||||
autoFetchUnknownDomains: Boolean(enabled),
|
||||
};
|
||||
persistSettings(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const allowLinkPreviewDomain = (domain, { silent = false } = {}) => {
|
||||
const normalized = normalizeDomain(domain);
|
||||
if (!normalized) {
|
||||
return getLinkPreviewSettings();
|
||||
}
|
||||
const current = getLinkPreviewSettings();
|
||||
if (current.allowedDomains.includes(normalized)) {
|
||||
return current;
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
allowedDomains: [...current.allowedDomains, normalized],
|
||||
};
|
||||
persistSettings(next, { silent });
|
||||
return next;
|
||||
};
|
||||
|
||||
export const isLinkPreviewDomainAllowed = (domain, settings) => {
|
||||
const normalized = normalizeDomain(domain);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const resolvedSettings = settings || getLinkPreviewSettings();
|
||||
return resolvedSettings.allowedDomains.includes(normalized);
|
||||
};
|
||||
|
||||
export const subscribeToLinkPreviewSettings = (callback) => {
|
||||
const target = getEventTarget();
|
||||
if (!target || typeof target.addEventListener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
const handler =
|
||||
typeof callback === "function"
|
||||
? callback
|
||||
: () => {};
|
||||
target.addEventListener(EVENT_NAME, handler);
|
||||
return () => target.removeEventListener(EVENT_NAME, handler);
|
||||
};
|
||||
Reference in new issue
Block a user