import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react';
import { ProgressSpinner } from 'primereact/progressspinner';
import { authFetch } from '@/utils/authFetch';
// ============================================================================
// Image modal context for child components to trigger modal
const ImageModalContext = createContext(null);
// Trusted domains that can be embedded directly without server-side proxy
const TRUSTED_DOMAINS = new Set([
'youtube.com', 'www.youtube.com', 'youtu.be',
'instagram.com', 'www.instagram.com',
'twitter.com', 'x.com', 'www.twitter.com',
'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv',
'spotify.com', 'open.spotify.com',
'soundcloud.com', 'www.soundcloud.com',
'vimeo.com', 'www.vimeo.com',
'imgur.com', 'i.imgur.com',
'giphy.com', 'media.giphy.com',
'tenor.com', 'media.tenor.com',
'gfycat.com',
'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it',
'github.com', 'gist.github.com',
'raw.githubusercontent.com', 'avatars.githubusercontent.com',
'user-images.githubusercontent.com', 'camo.githubusercontent.com',
'opengraph.githubassets.com',
'codepen.io', 'codesandbox.io',
'streamable.com', 'medal.tv',
'discord.com', 'cdn.discordapp.com', 'media.discordapp.net',
// Common image CDNs
'picsum.photos', 'images.unsplash.com', 'unsplash.com',
'pbs.twimg.com', 'abs.twimg.com',
'img.youtube.com', 'i.ytimg.com',
'w3schools.com', 'www.w3schools.com', // for demo video
]);
// Image extensions
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i;
// Video extensions
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v)(\?.*)?$/i;
// Audio extensions
const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|flac|m4a|aac)(\?.*)?$/i;
/**
* Check if URL is from a trusted domain
*/
function isTrustedDomain(url) {
try {
const parsed = new URL(url);
return TRUSTED_DOMAINS.has(parsed.hostname);
} catch {
return false;
}
}
/**
* Check if a URL is already a proxy URL (from our server)
*/
function isProxyUrl(url) {
if (!url) return false;
return url.startsWith('/api/image-proxy');
}
/**
* Extract YouTube video ID
*/
function getYouTubeId(url) {
try {
const parsed = new URL(url);
if (parsed.hostname === 'youtu.be') {
return parsed.pathname.slice(1);
}
if (parsed.hostname.includes('youtube.com')) {
return parsed.searchParams.get('v') || parsed.pathname.split('/').pop();
}
} catch {
return null;
}
return null;
}
/**
* Format file size
*/
function formatFileSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Format Discord timestamp
*/
function formatTimestamp(timestamp, format = 'full') {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString();
const time = date.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
if (format === 'time') return time;
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
}) + ` at ${time}`;
}
/**
* Format date for divider
*/
function formatDateDivider(timestamp) {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
/**
* Decode HTML entities safely. Uses the DOM if available (client-side), otherwise
* falls back to a robust regex for SSR environments.
*/
function decodeHtmlEntities(str) {
if (!str) return str;
try {
if (typeof document !== 'undefined') {
const tx = document.createElement('textarea');
tx.innerHTML = str;
return tx.value;
}
} catch (e) {
// fall through to fallback
}
return str.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g, (m, e) => {
if (e[0] === '#') return e[1] === 'x' ? String.fromCharCode(parseInt(e.slice(2), 16)) : String.fromCharCode(parseInt(e.slice(1), 10));
const map = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', rsquo: '’', lsquo: '‘', hellip: '…', rdquo: '”', ldquo: '“' };
return map[e] || m;
});
}
/**
* Parse archived messages from #dds-archive channel
* These messages were re-sent by a bot with this format:
*
* **Topic/Category**
* **YYYY-MM-DD HH:MM:SS.ssssss+00:00 (UTC)
* username**: message content
*
* Note: The ** wraps from the timestamp line through the username
* Some messages are nested (archive of an archive) and need recursive parsing
*
* Returns { originalAuthor, originalTimestamp, originalContent, topic } or null if not parseable
*/
function parseArchivedMessage(content, depth = 0) {
if (!content || depth > 3) return null; // Prevent infinite recursion
// The format is:
// **Topic**
// **Timestamp (UTC)
// username**: content
// Username cannot contain * or : characters, and ends with **:
// This prevents matching nested archive content
const boldPattern = /^\*\*(.+?)\*\*\s*\n\*\*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*\(UTC\)\s*\n([^*:]+)\*\*:\s*([\s\S]*)$/;
const boldMatch = content.match(boldPattern);
if (boldMatch) {
const topic = boldMatch[1].trim();
const timestampStr = boldMatch[2].trim();
let originalAuthor = boldMatch[3].trim();
let originalContent = boldMatch[4].trim();
// Parse the timestamp
let originalTimestamp;
try {
const tsString = timestampStr.replace(' ', 'T');
originalTimestamp = new Date(tsString);
if (isNaN(originalTimestamp.getTime())) {
originalTimestamp = new Date(tsString + 'Z');
}
} catch {
return null;
}
if (isNaN(originalTimestamp.getTime())) {
return null;
}
// Check if the content is itself another archive format (nested archive)
// Pattern: **Topic**\n**username**: content (simpler format without timestamp)
const nestedPattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/;
const nestedMatch = originalContent.match(nestedPattern);
if (nestedMatch) {
// This is a nested archive - use the inner author and content
originalAuthor = nestedMatch[2].trim();
originalContent = nestedMatch[3].trim();
}
// Also try recursive parsing for deeply nested archives
const recursiveParsed = parseArchivedMessage(originalContent, depth + 1);
if (recursiveParsed) {
return {
originalAuthor: recursiveParsed.originalAuthor,
originalTimestamp: recursiveParsed.originalTimestamp || originalTimestamp.toISOString(),
originalContent: recursiveParsed.originalContent,
topic: recursiveParsed.topic || topic
};
}
return {
originalAuthor,
originalTimestamp: originalTimestamp.toISOString(),
originalContent,
topic
};
}
// Try simpler format: **Topic**\n**username**: content (no timestamp)
const simplePattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/;
const simpleMatch = content.match(simplePattern);
if (simpleMatch) {
const topic = simpleMatch[1].trim();
const originalAuthor = simpleMatch[2].trim();
const originalContent = simpleMatch[3].trim();
return {
originalAuthor,
originalTimestamp: null, // No timestamp in this format
originalContent,
topic
};
}
// Fallback: try without bold markers (plain text format)
const lines = content.split('\n');
if (lines.length < 3) return null;
const topic = lines[0].replace(/^\*\*|\*\*$/g, '').trim();
const timestampLine = lines[1].replace(/^\*\*/, '').trim();
// Parse timestamp - format: "2024-11-08 18:41:34.031000+00:00 (UTC)"
const timestampMatch = timestampLine.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*(?:\(UTC\))?$/);
if (!timestampMatch) return null;
// Get the rest as author**: message or author: message
const messagePart = lines.slice(2).join('\n').trim();
// Parse "author**: message" or "author: message" - author cannot contain * or :
const authorMatch = messagePart.match(/^([^*:]+)\*?\*?:\s*([\s\S]*)$/);
if (!authorMatch) return null;
const originalAuthor = authorMatch[1].trim();
const originalContent = authorMatch[2].trim();
// Parse the timestamp
let originalTimestamp;
try {
const tsString = timestampMatch[1].replace(' ', 'T');
originalTimestamp = new Date(tsString);
if (isNaN(originalTimestamp.getTime())) {
originalTimestamp = new Date(timestampMatch[1].replace(' ', 'T') + 'Z');
}
} catch {
return null;
}
if (isNaN(originalTimestamp.getTime())) {
return null;
}
return {
originalAuthor,
originalTimestamp: originalTimestamp.toISOString(),
originalContent,
topic
};
}
/**
* Hardcoded user data for archived messages
* Since these users may not be mentioned in current messages, we store their data directly
* This includes avatar URLs, display names, and colors from the database
*/
const ARCHIVE_USERS = {
// kriegerin -> cyberkriegerin (user_id: 992437729927376996)
'kriegerin': {
id: '992437729927376996',
username: 'cyberkriegerin',
displayName: 'kriegerin',
avatar: 'https://cdn.discordapp.com/avatars/992437729927376996/3c4030cf3a210db4a180eab76e559ea2.png?size=1024',
color: null,
},
// codey/Chris -> gizmo_a (user_id: 1172340700663255091)
'codey': {
id: '1172340700663255091',
username: 'gizmo_a',
displayName: 'Chris',
avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024',
color: null,
},
'Chris': {
id: '1172340700663255091',
username: 'gizmo_a',
displayName: 'Chris',
avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024',
color: null,
},
// Havoc bot (user_id: 1175471063438737519)
'Havoc': {
id: '1175471063438737519',
username: 'Havoc',
displayName: 'Havoc',
avatar: 'https://cdn.discordapp.com/avatars/1175471063438737519/5e70b92d710a8584d27ca76220f93d67.png?size=1024',
color: null,
bot: true,
},
// Deleted User / slip (user_id: 456226577798135808)
'Deleted User': {
id: '456226577798135808',
username: 'slip',
displayName: 'poopboy',
avatar: 'https://codey.lol/images/456226577798135808.png',
color: null,
},
};
/**
* Look up a real user from an archived username
* First checks hardcoded ARCHIVE_USERS, then falls back to usersMap
* If members data is provided, looks up color from there
* Returns user data if found, otherwise returns a basic object with just the username
*/
function resolveArchivedUser(archivedUsername, usersMap, members) {
// First check hardcoded archive users
if (ARCHIVE_USERS[archivedUsername]) {
const archivedUser = ARCHIVE_USERS[archivedUsername];
// Look up color from members data if available
let color = archivedUser.color;
if (!color && members?.groups && archivedUser.id) {
for (const group of members.groups) {
const member = group.members?.find(m => m.id === archivedUser.id);
if (member?.color) {
color = member.color;
break;
}
}
}
return {
...archivedUser,
color,
isArchiveResolved: true,
};
}
// Fall back to usersMap lookup by username match
if (usersMap) {
for (const [userId, userData] of Object.entries(usersMap)) {
if (userData.username === archivedUsername || userData.displayName === archivedUsername) {
// Look up color from members data if not in usersMap
let color = userData.color;
if (!color && members?.groups) {
for (const group of members.groups) {
const member = group.members?.find(m => m.id === userId);
if (member?.color) {
color = member.color;
break;
}
}
}
return {
...userData,
id: userId,
color,
isArchiveResolved: true,
};
}
}
}
// Return basic user object if not found
return {
username: archivedUsername,
displayName: archivedUsername,
id: `archive-${archivedUsername}`,
isArchiveResolved: false,
};
}
/**
* Parse Discord markdown-like formatting
* @param {string} text - The text to parse
* @param {Object} options - Options for parsing
* @param {Map} options.channelMap - Map of channel IDs to channel objects
* @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color }
* @param {Object} options.rolesMap - Map of role IDs to role objects { name, color }
* @param {Function} options.onChannelClick - Callback when channel is clicked
*/
function parseDiscordMarkdown(text, options = {}) {
if (!text) return '';
// Normalize HTML entities that sometimes make it into messages/embed fields
// We decode before we escape so strings like "A & B" become "A & B"
// and avoid double-encoding when we later run an escape pass.
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), onChannelClick } = options;
// Normalize entities then escape HTML to avoid XSS while ensuring
// already-encoded entities don't become double-encoded in the UI.
const normalized = decodeHtmlEntities(text);
// Escape HTML first
let parsed = normalized
.replace(/&/g, '&')
.replace(//g, '>');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
// Don't trim - preserve whitespace for ASCII art
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
// Only trim trailing newline, preserve all other whitespace
const trimmedCode = code.replace(/\n$/, '');
return `
${trimmedCode}
`;
});
// Inline code (`) - must be early to prevent formatting inside code
parsed = parsed.replace(/`([^`]+)`/g, '$1');
// Blockquotes (> at start of line) - process before newline conversion
// Group consecutive > lines into a single blockquote
parsed = parsed.replace(/(^|\n)((?:> .+(?:\n|$))+)/gm, (_, before, block) => {
const content = block
.split('\n')
.map(line => line.replace(/^> /, ''))
.join('\n');
return `${before}
${content}
`;
});
// Headings (# ## ###) - must be at start of line
// Process before other inline formatting
parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}${content}`;
});
parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}${content}`;
});
parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}${content}`;
});
// Subtext/small text (-# at start of line) - process before newline conversion
parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}${content}`;
});
// Unordered lists (- or * at start of line, but not ---)
parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}• ${content}`;
});
// Ordered lists (1. 2. etc at start of line)
parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => {
return `${before}${num}. ${content}`;
});
// Bold + Italic + Underline combinations (most specific first)
// ___***text***___ or ***___text___***
parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '$3');
// Bold + Italic (***text***)
parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '$1');
// Bold + Underline (__**text**__ or **__text__**)
parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '$1');
parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '$1');
// Italic + Underline (__*text*__ or *__text__* or ___text___)
parsed = parsed.replace(/__\*([^*_]+)\*__/g, '$1');
parsed = parsed.replace(/\*__([^*_]+)__\*/g, '$1');
parsed = parsed.replace(/___([^_]+)___/g, '$1');
// Bold (**)
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '$1');
// Underline (__) - must come before italic _ handling
parsed = parsed.replace(/__([^_]+)__/g, '$1');
// Italic (* or _)
parsed = parsed.replace(/\*([^*]+)\*/g, '$1');
parsed = parsed.replace(/\b_([^_]+)_\b/g, '$1');
// Strikethrough (~~)
parsed = parsed.replace(/~~([^~]+)~~/g, '$1');
// Spoiler (||)
parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '$1');
// Discord Timestamps ()
parsed = parsed.replace(/<t:(\d+)(?::([tTdDfFR]))?>/g, (_, timestamp, format) => {
const date = new Date(parseInt(timestamp) * 1000);
let formatted;
switch (format) {
case 't': // Short time (9:30 PM)
formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
break;
case 'T': // Long time (9:30:00 PM)
formatted = date.toLocaleTimeString();
break;
case 'd': // Short date (11/28/2024)
formatted = date.toLocaleDateString();
break;
case 'D': // Long date (November 28, 2024)
formatted = date.toLocaleDateString([], { dateStyle: 'long' });
break;
case 'f': // Short date/time (November 28, 2024 9:30 PM)
default:
formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
break;
case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM)
formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' });
break;
case 'R': // Relative (2 hours ago)
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(Math.abs(diff) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (diff < 0) {
// Future
if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`;
else if (months > 0) formatted = `in ${months} month${months > 1 ? 's' : ''}`;
else if (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`;
else if (hours > 0) formatted = `in ${hours} hour${hours > 1 ? 's' : ''}`;
else if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`;
else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`;
} else {
// Past
if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`;
else if (months > 0) formatted = `${months} month${months > 1 ? 's' : ''} ago`;
else if (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`;
else if (hours > 0) formatted = `${hours} hour${hours > 1 ? 's' : ''} ago`;
else if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
}
break;
}
return `${formatted}`;
});
// User mentions (<@123456789>)
parsed = parsed.replace(/<@!?(\d+)>/g, (_, userId) => {
const user = usersMap[userId];
const displayName = user?.displayName || user?.username || 'User';
const colorStyle = user?.color ? ` style="color: ${user.color}"` : '';
return `@${displayName}`;
});
// @everyone and @here mentions
parsed = parsed.replace(/@(everyone|here)/g, '@$1');
// Channel mentions (<#123456789>)
parsed = parsed.replace(/<#(\d+)>/g, (_, channelId) => {
const channel = channelMap.get(channelId);
const channelName = channel?.name || 'channel';
if (channel) {
return `#${channelName}`;
}
return `#${channelName}`;
});
// Role mentions (<@&123456789>)
parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => {
const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId];
const roleName = role?.name || 'role';
const roleColor = role?.color || null;
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
return `@${roleName}`;
});
// Slash command mentions ()
parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '/$1');
// Custom emoji (<:name:123456789> or )
parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => {
const ext = animated ? 'gif' : 'png';
return ``;
});
// Unicode emoji (keep as-is, they render natively)
// Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection
parsed = parsed.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g,
(_, text, url, title) => {
const titleAttr = title ? ` title="${title}"` : '';
return `${text}`;
}
);
// Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=")
// Match URLs that are NOT preceded by =" or ='
parsed = parsed.replace(
/(?"']+)/g,
'$1'
);
// Newlines
parsed = parsed.replace(/\n/g, ' ');
// Unescape Discord markdown escape sequences (\_ \* \~ \` \| \\)
// Must be done after all markdown processing
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
return parsed;
}
/**
* Extract URLs from text
*/
function extractUrls(text) {
if (!text) return [];
const urlRegex = /(https?:\/\/[^\s<]+)/g;
const matches = text.match(urlRegex);
return matches || [];
}
// ============================================================================
// Link Preview Component
// ============================================================================
const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }) {
const [preview, setPreview] = useState(cachedPreview || null);
const [loading, setLoading] = useState(!cachedPreview);
const [error, setError] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
if (cachedPreview) {
setPreview(cachedPreview);
setLoading(false);
return;
}
// For direct media URLs, only allow trusted domains
if (IMAGE_EXTENSIONS.test(url)) {
if (isTrustedDomain(url)) {
const previewData = { url, type: 'image', image: url, trusted: true };
setPreview(previewData);
setLoading(false);
onPreviewLoad?.(url, previewData);
} else {
// Fetch through server to get metadata without exposing user IP
fetchPreviewFromServer();
}
return;
}
if (VIDEO_EXTENSIONS.test(url)) {
if (isTrustedDomain(url)) {
const previewData = { url, type: 'video', video: url, trusted: true };
setPreview(previewData);
setLoading(false);
onPreviewLoad?.(url, previewData);
} else {
// Don't show untrusted video embeds - too risky
setError(true);
setLoading(false);
}
return;
}
// Check for YouTube - trusted domain, can embed directly
const ytId = getYouTubeId(url);
if (ytId) {
const previewData = {
url,
type: 'youtube',
videoId: ytId,
title: 'YouTube Video',
siteName: 'YouTube',
themeColor: '#FF0000',
trusted: true,
};
setPreview(previewData);
setLoading(false);
onPreviewLoad?.(url, previewData);
return;
}
// All other URLs: fetch preview from server
fetchPreviewFromServer();
async function fetchPreviewFromServer() {
try {
const response = await authFetch(`/api/link-preview?url=${encodeURIComponent(url)}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (data.error) throw new Error(data.error);
setPreview(data);
onPreviewLoad?.(url, data);
} catch (err) {
console.warn('Failed to fetch link preview:', url, err);
setError(true);
} finally {
setLoading(false);
}
}
}, [url, cachedPreview, onPreviewLoad]);
if (loading) {
return (
);
}
if (error || !preview) {
return null; // Don't show anything for failed previews
}
// YouTube embed - trusted, use iframe
if (preview.type === 'youtube' && preview.videoId) {
return (