Add cached link previews with privacy toggle

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 13:06:50 -05:00
1 parent ed306bdf65
commit 0eacbd1063
6 files changed
+747

No files matched your search

+20
View File
@@ -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
+6
View File
@@ -521,6 +521,12 @@
>
No description available.
</p>
<div
id="videoDescriptionPreviews"
class="mt-4 space-y-3"
data-link-previews
hidden
></div>
</div>
<div
+249
View File
@@ -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,
};
}
}
+291
View File
@@ -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) {
+31
View File
@@ -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";
+150
View File
@@ -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);
};