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 @@
>
+
+
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);
+};