// MediaItemWidget.tsx — Main Media Item Widget file for rendering various types of media inside Memori import type { Medium } from '@memori.ai/memori-api-client/dist/types'; import React, { useCallback, useEffect, useMemo, useState, memo, useRef, } from 'react'; import { getResourceUrl } from '../../helpers/media'; import { withLinksOpenInNewTab, stripDocumentAttachmentTags, } from '../../helpers/utils'; import { getTranslation } from '../../helpers/translations'; import { prismSyntaxLangs } from '../../helpers/constants'; import ModelViewer from '../CustomGLBModelViewer/ModelViewer'; import Snippet from '../Snippet/Snippet'; import Card from '../ui/Card'; import Modal from '../ui/Modal'; import File from '../icons/File'; import { Transition } from '@headlessui/react'; import cx from 'classnames'; import Sound from '../icons/Sound'; import Link from '../icons/Link'; import { ellipsis } from 'ellipsed'; import type { MediaItemWidgetProps as Props, MediaItem, RenderMediaItemProps, RenderSnippetItemProps, LinkPreviewInfo, } from './MediaItemWidget.types'; import { formatBytes, getFileExtensionFromUrl, getFileExtensionFromMime, countLines, shouldUseDarkFileCard, fetchLinkPreview, getContentSize, normalizeUrl, getImageDisplaySource, FALLBACK_IMAGE_BASE64, TEXT_FILE_EXTENSIONS, IMAGE_MIME_TYPES, } from './MediaItemWidget.utils'; import { DocumentCard } from './DocumentCard'; import { MediaPreviewModal } from './MediaPreviewModal'; export type { LinkPreviewInfo, ILinkPreviewInfo, } from './MediaItemWidget.types'; export type { Props }; // List of code mime types from Prism's available languages const CODE_MIME_TYPES = prismSyntaxLangs.map(l => l.mimeType); /** Image MIME types that open in the preview modal on click (when mediumID + onClick are set) */ const IMAGE_MODAL_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/jpg', 'image/gif', ] as const; function isImageMime(mimeType: string): boolean { return (IMAGE_MODAL_MIME_TYPES as readonly string[]).includes(mimeType); } // RenderMediaItem — Renders the suitable content for a Medium (images, files, html, code, audio, video…) export const RenderMediaItem = memo(function RenderMediaItem({ isChild: _isChild = false, item, sessionID, tenantID, preview = false, baseURL, apiURL, onClick: _onClick, customMediaRenderer, descriptionOneLine = false, onLinkPreviewInfo, }: RenderMediaItemProps): React.ReactElement | null { // State for "copy to clipboard" notification for snippets const [copyNotification, setCopyNotification] = useState(false); // State for fallback image const [imageError, setImageError] = useState(false); // Link preview info (site title, description, image, etc) const [link, setLink] = useState< (LinkPreviewInfo & { urlKey: string }) | null >(null); // Persistent ref for onLinkPreviewInfo callback const onLinkPreviewInfoRef = useRef(onLinkPreviewInfo); onLinkPreviewInfoRef.current = onLinkPreviewInfo; // Get URL with possible session/tenant/base/api const resourceUrl = getResourceUrl({ resourceURI: item.url, sessionID, tenantID, baseURL, apiURL, }); // Normalize URL (strip protocol, etc) const normURL = normalizeUrl(item.url); // Fetch link preview info for HTML links, only if relevant and not already loaded useEffect(() => { if ( item.mimeType !== 'text/html' || !normURL || normURL === link?.urlKey || !baseURL ) { return; } let cancelled = false; fetchLinkPreview(normURL, baseURL).then(siteInfo => { if (cancelled) return; setLink( siteInfo ? ({ ...siteInfo, urlKey: normURL } as LinkPreviewInfo & { urlKey: string; }) : null ); if (siteInfo && onLinkPreviewInfoRef.current) { onLinkPreviewInfoRef.current(siteInfo); } }); return () => { cancelled = true; }; }, [item?.url, baseURL, item.mimeType, normURL, link?.urlKey]); // Custom renderer for media type, overrides our logic const customRenderer = customMediaRenderer?.(item.mimeType); if (customRenderer) { return customRenderer; } // Media type detection flags const isCodeSnippet = CODE_MIME_TYPES.includes(item.mimeType); const isHTML = item.mimeType === 'text/html'; const isDocumentAttachment = item.properties?.isDocumentAttachment === true; const isAttachedFile = item.properties?.isAttachedFile === true; // Single source of truth for image display (resource URL, base64, or rgb/rgba) const imageDisplay = getImageDisplaySource(item, resourceUrl); const { src: imageSrc, isRgb: isImageRGB } = imageDisplay; // Link preview fields (title, description, video, image) const linkTitle = item.title && item.title.length > 0 ? item.title : link?.title; const linkDescription = link?.description; const linkVideo = link?.video; const linkImage = link?.image ?? link?.images?.[0]; /** * Render the actual content for a media item based on its MIME type */ const renderMediaContent = useCallback( (medium: Medium & { type?: string }) => { // Get resource url for this medium const url = getResourceUrl({ resourceURI: medium.url, sessionID, tenantID, baseURL, apiURL, }); switch (medium.mimeType) { // Render images (RGB images as colored swatches) case 'image/jpeg': case 'image/png': case 'image/jpg': case 'image/gif': return isImageRGB ? (
) : ( {!preview && imageSrc && ( )} {medium.title} setImageError(true)} /> ); // Render video types case 'video/mp4': case 'video/quicktime': case 'video/avi': case 'video/mpeg': return (
{/* Play overlay icon (hidden by default) */}
); // Render audio types (shows a sound icon + audio controls) case 'audio/mpeg3': case 'audio/wav': case 'audio/mpeg': return (
); // Render 3D models (GLB only) case 'model/gltf-binary': return (
); // Plain text: snippet preview (same UX as other media / code) case 'text/plain': return medium.content ? ( ) : ( { const size = getContentSize(medium); return size != null && size > 0 ? formatBytes(size) : null; })()} icon={} /> ); // HTML files are now handled as file cards (rendered above in the isFile check) // This case is kept for backwards compatibility but should not be reached case 'text/html': // Fallback to file card - HTML files are handled in the file card section above return ( { const size = getContentSize(medium); return size != null && size > 0 ? formatBytes(size) : null; })()} icon={} /> ); // Generic fallback: Render a document card for unknown types default: return ( { const size = getContentSize(medium); return size != null && size > 0 ? formatBytes(size) : null; })()} icon={} /> ); } }, [ sessionID, tenantID, baseURL, apiURL, preview, imageSrc, imageError, isImageRGB, linkImage, linkVideo, ] ); // Extension and file detection helpers const fileExtensionFromUrl = getFileExtensionFromUrl(normURL || item.url); const fileExtensionFromMime = getFileExtensionFromMime(item.mimeType); const fileExtension = fileExtensionFromUrl || fileExtensionFromMime; const isFile = shouldUseDarkFileCard( item, fileExtensionFromUrl, item.mimeType ); // Text file detection for line counting const isTextFile = (TEXT_FILE_EXTENSIONS as readonly string[]).includes( fileExtension || '' ); // Derive line count and line label for text files const lineCount = isTextFile && item.content ? countLines(item.content) : null; const lineText = lineCount !== null ? lineCount === 1 ? '1 line' : `${lineCount} lines` : null; // File-like cards that are NOT code: render as clickable file cards if (isFile && !isCodeSnippet) { const contentSize = getContentSize(item); const sizeText = contentSize != null && contentSize > 0 ? formatBytes(contentSize) : null; const displayName = item.title || linkTitle || 'File'; const metaParts = [lineText, sizeText].filter(Boolean); const metaLine = metaParts.length > 0 ? metaParts.join(' · ') : null; // Document attachments and attached files should open in modal, not as links if ((isDocumentAttachment || isAttachedFile) && item.mediumID && _onClick) { return (
_onClick(item)} className="memori-media-item--link memori-media-item--document-link" style={{ cursor: 'pointer' }} title={displayName} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _onClick(item); } }} > ) : ( ) } />
); } // Build href: open in new tab (never modal). Use URL, or blob for content-only items. const getFileCardHref = (): string => { if (item.url) { return ( getResourceUrl({ resourceURI: item.url, sessionID, tenantID, baseURL, apiURL, }) || item.url || '#' ); } if (isHTML && item.content) { let htmlContent = item.content; if ( item.properties?.isDocumentAttachment || htmlContent.includes('document_attachment') || htmlContent.includes(' ) : ( ) } /> ); } // Inline previews for code snippets and plain text: Card with preview, click opens modal (same as other media) const isPreviewableText = (isCodeSnippet || item.mimeType === 'text/plain') && !!item.content; if (isPreviewableText && item.mediumID && _onClick) { return (
_onClick(item)} title={item.title} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _onClick(item); } }} >
); } // HTML file with link info / preview or video/image: render card with link preview (image, video, description) if (isHTML && (linkImage || linkVideo || linkDescription)) { // Compute card cover image/video src const coverSrc = linkImage?.includes('data:image') === true ? undefined : linkImage?.startsWith('https') ? linkImage : linkImage ? `https://${linkImage.replace('http://', '')}` : undefined; return ( ) : linkImage ? ( {linkTitle} ) : (
) } title={linkTitle} description={linkDescription} />
); } // Run ellipsed.js to clamp link card description, only when a linkDescription is present useEffect(() => { if (!linkDescription) return; const t = setTimeout(() => { ellipsis('.memori-media-item--card .memori-card--description', 3, { responsive: true, }); }, 300); return () => clearTimeout(t); }, [linkDescription, item.mediumID]); // ------------------------------------------------------------------------- // Image link flow: images with mediumID open in preview modal on click // ------------------------------------------------------------------------- if (isImageMime(item.mimeType)) { if (isImageRGB) { return ( ); } if (item.mediumID && _onClick) { return (
_onClick(item)} className="memori-media-item--link memori-media-item--image-link" style={{ cursor: 'pointer' }} title={item.title} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _onClick(item); } }} >
); } return ( ); } // Video, audio, 3D, and other types: open in new tab (never modal) switch (item.mimeType) { case 'video/mp4': case 'video/quicktime': case 'video/avi': case 'video/mpeg': return ( {renderMediaContent(item)} ); // Audio and 3D models: open URL in new tab when available case 'audio/mpeg3': case 'audio/wav': case 'audio/mpeg': case 'model/gltf-binary': if (resourceUrl) { return ( {renderMediaContent(item)} ); } return renderMediaContent(item); // All other files: open URL in new tab (never modal) default: return ( {renderMediaContent(item)} ); } }); // RenderSnippetItem: Renders a single code snippet (opens in new tab via href) export const RenderSnippetItem = memo(function RenderSnippetItem({ item, onClick: _onClick, // kept for API compatibility; links open in new tab, not modal sessionID, tenantID, baseURL, apiURL, }: RenderSnippetItemProps): React.ReactElement { void _onClick; const resourceUrl = getResourceUrl({ resourceURI: item.url, sessionID, tenantID, baseURL, apiURL, }); const hasUrl = !!(resourceUrl && resourceUrl !== '#'); // Count lines, chars, determine "short" snippet const lineCount = countLines(item.content); const contentLength = item.content?.length ?? 0; const isShortSnippet = lineCount <= 5 && contentLength <= 200; const lineText = lineCount === 1 ? '1 riga' : `${lineCount} righe`; if (isShortSnippet) { return (
{item.title}
{lineText}
); } // Long snippet: open in new tab (resource URL or blob URL from content) const snippetHref = hasUrl ? resourceUrl : item.content ? (() => { const blob = new Blob([item.content], { type: item.mimeType || 'text/plain', }); return URL.createObjectURL(blob); })() : '#'; return (
{ if (item.mediumID && _onClick) { _onClick(item); } }} style={{ cursor: 'pointer' }} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (item.mediumID && _onClick) { _onClick(item); } } }} className="memori-media-item--link" title={item.title} >
{item.title}
{lineText}
); }); // Main MediaItemWidget component: renders all media, overlays, and preview modals const MediaItemWidget: React.FC = ({ items, sessionID, tenantID, translateTo, baseURL, apiURL, customMediaRenderer, fromUser = false, descriptionOneLine = false, onLinkPreviewInfo, }) => { // Internal tracked media set (may be translated versions) const [media, setMedia] = useState(items); // Modal state (holds currently open medium) const [openModalMedium, setOpenModalMedium] = useState(); // Update component-local media when prop changes (reset modal to current items) useEffect(() => { setMedia(items); }, [items]); // Optional: Translate all media captions/titles if translateTo provided const translateMediaCaptions = useCallback(async () => { if (!translateTo) return; const translated = await Promise.all( (items ?? []).map(async m => { if (!m.title) return m; try { const t = await getTranslation(m.title, translateTo); return { ...m, title: t.text ?? m.title }; } catch (e) { console.error(e); return m; } }) ); setMedia(translated); }, [translateTo, items]); useEffect(() => { if (translateTo) translateMediaCaptions(); }, [translateTo, translateMediaCaptions]); // Derive top-level "display" lists: // 1. All non-code, non-executable media sorted by timestamp (displayed as document, images, video, etc) const nonCodeDisplayMedia = useMemo( () => media .filter( m => !m.properties?.executable && !CODE_MIME_TYPES.includes(m.mimeType) ) .sort((a, b) => { const at = a.creationTimestamp ?? 0; const bt = b.creationTimestamp ?? 0; return at > bt ? 1 : at < bt ? -1 : 0; }), [media] ); // 2. Only code snippets (unless marked as executable) const codeSnippets = useMemo( () => media.filter( m => !m.properties?.executable && CODE_MIME_TYPES.includes(m.mimeType) ), [media] ); // 3. Only CSS code marked as executable (to inject as