// 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 && (
)}
setImageError(true)}
/>
);
// Render video types
case 'video/mp4':
case 'video/quicktime':
case 'video/avi':
case 'video/mpeg':
return (
{/* Quicktime special fallback for .mp4 */}
{medium.mimeType === 'video/quicktime' && (
)}
Your browser does not support this video format.
{/* 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 ? (
) : (
)
}
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 (
);
}
// 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}
>
);
});
// 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