From 0eacbd10635632f1f9030c180305aa8b111497b1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:06:50 -0500 Subject: [PATCH] Add cached link previews with privacy toggle --- components/profile-modal.html | 20 ++ components/video-modal.html | 6 + js/services/linkPreviewService.js | 249 +++++++++++++++++++++++++ js/ui/components/VideoModal.js | 291 ++++++++++++++++++++++++++++++ js/ui/profileModalController.js | 31 ++++ js/utils/linkPreviewSettings.js | 150 +++++++++++++++ 6 files changed, 747 insertions(+) create mode 100644 js/services/linkPreviewService.js create mode 100644 js/utils/linkPreviewSettings.js diff --git a/components/profile-modal.html b/components/profile-modal.html index a4a8643e..d9e40350 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -443,6 +443,26 @@ > + +
+
+

Link previews

+

+ Control when bitvid fetches link preview metadata. Disable auto-fetch to review new domains before previews load. +

+
+ +
No description available.

+
{ + 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, + }; + } +} diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index f34761e4..e7968dfc 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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) { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index b5292bf4..87927e93 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -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"; diff --git a/js/utils/linkPreviewSettings.js b/js/utils/linkPreviewSettings.js new file mode 100644 index 00000000..61dd6e3e --- /dev/null +++ b/js/utils/linkPreviewSettings.js @@ -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); +};