/** * CometChatMediaViewer — §8.2.1 * * Unified fullscreen pager for image + video attachments. * Each page renders by mimeType: * - image/* → zoomable image * - video/* → inline react-native-video player * * Audio and file attachments are NOT pages here — they are handled at the tile level. * Contract: `mediaItems` should contain only image/* and video/* attachments. */ import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Dimensions, Image, Modal, NativeModules, PanResponder, Platform, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { CometChat } from '@cometchat/chat-sdk-react-native'; import Video from 'react-native-video'; import { Icon } from '../../icons/Icon'; import { useTheme } from '../../../theme'; import { useCometChatTranslation } from '../../resources/CometChatLocalizeNew'; import { clampIndex, isImageMime, isVideoMime, nextIndexForSwipe } from './mediaViewerUtils'; /** Default: an item is previewable if it's an image or a video. A per-kind bubble (ImagesBubble / * VideosBubble) overrides this so anything outside its own kind renders the "No preview" page. */ const defaultCanPreview = (att: CometChat.Attachment): boolean => { const m = att.getMimeType?.() ?? ''; return isImageMime(m) || isVideoMime(m); }; export interface CometChatMediaViewerProps { /** Attachments to page through. May include non-previewable items → "No preview available" page. */ mediaItems: CometChat.Attachment[]; /** Zero-based index of the item to open on. */ startIndex: number; visible: boolean; onClose: () => void; /** Optional: the parent message (used for sender/timestamp in header). */ message?: CometChat.BaseMessage; /** Which items get a real preview; the rest show the "No preview available" page. Defaults to * image-or-video. VideosBubble passes video-only, ImagesBubble image-only. */ canPreview?: (att: CometChat.Attachment) => boolean; } type DownloadState = 'IDLE' | 'DOWNLOADING' | 'DONE' | 'FAILED'; const { FileManager } = NativeModules; const { width: SCREEN_W } = Dimensions.get('window'); function formatFileSize(bytes: number): string { if (!(bytes > 0)) return ''; // undefined / 0 / NaN → show nothing (not "undefined B") if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(0)} KB`; return `${bytes} B`; } export const CometChatMediaViewer = ({ mediaItems, startIndex, visible, onClose, message, canPreview = defaultCanPreview, }: CometChatMediaViewerProps) => { const { t } = useCometChatTranslation(); const theme = useTheme(); const [currentIndex, setCurrentIndex] = useState(() => clampIndex(startIndex, mediaItems.length)); const [mediaLoaded, setMediaLoaded] = useState(false); const [mediaError, setMediaError] = useState(false); const [isBuffering, setIsBuffering] = useState(false); const [downloadState, setDownloadState] = useState('IDLE'); // Ref so PanResponder closure always reads the latest index without re-creation const currentIndexRef = useRef(clampIndex(startIndex, mediaItems.length)); // Sync startIndex when viewer reopens on a different item (clamped so out-of-range can't blank the page) useEffect(() => { if (visible) { const clamped = clampIndex(startIndex, mediaItems.length); currentIndexRef.current = clamped; setCurrentIndex(clamped); setMediaLoaded(false); setMediaError(false); setDownloadState('IDLE'); setIsBuffering(false); } }, [visible, startIndex, mediaItems.length]); const current = mediaItems[currentIndex]; // A per-kind bubble marks off-kind attachments (e.g. a .jpg inside a VIDEO message) as not // previewable → they render the "No preview available" page instead of the image/video. const previewable = current ? canPreview(current) : false; const isVideo = previewable && current ? isVideoMime(current.getMimeType?.() ?? '') : false; const mediaItemsRef = useRef(mediaItems); // Keep the ref in sync outside render (not during it) so the PanResponder closure reads the latest list. useEffect(() => { mediaItemsRef.current = mediaItems; }, [mediaItems]); const goTo = (idx: number) => { const clamped = clampIndex(idx, mediaItemsRef.current.length); currentIndexRef.current = clamped; setCurrentIndex(clamped); setMediaLoaded(false); setMediaError(false); setDownloadState('IDLE'); setIsBuffering(false); }; const panResponder = useRef( PanResponder.create({ // Swipe-to-page only on IMAGE pages. On video pages the native controls own all gestures // (scrubbing is horizontal too), so we don't hijack them — page via the on-screen arrows (B3). onMoveShouldSetPanResponder: (_, gs) => { const it = mediaItemsRef.current[currentIndexRef.current]; const onVideo = it ? isVideoMime(it.getMimeType?.() ?? '') : false; return !onVideo && Math.abs(gs.dx) > 10 && Math.abs(gs.dx) > Math.abs(gs.dy); }, onPanResponderRelease: (_, gs) => { const idx = currentIndexRef.current; const next = nextIndexForSwipe(gs.dx, idx, mediaItemsRef.current.length); if (next !== idx) goTo(next); }, }) ).current; const handleDownload = () => { if (!current || downloadState === 'DOWNLOADING' || downloadState === 'DONE') return; setDownloadState('DOWNLOADING'); FileManager?.checkAndDownload(current.getUrl(), current.getName(), (result: string) => { try { const parsed = JSON.parse(result); setDownloadState(parsed.success ? 'DONE' : 'FAILED'); } catch { setDownloadState('FAILED'); } }); }; if (!current) return null; const shortName = current.getName().length > 22 ? `${current.getName().substring(0, 19)}…` : current.getName(); const counterLabel = `${currentIndex + 1} / ${mediaItems.length}`; return ( {/* Header */} {shortName} {[formatFileSize(current.getSize()), mediaItems.length > 1 ? counterLabel : ''].filter(Boolean).join(' • ')} {downloadState === 'DOWNLOADING' ? ( ) : downloadState === 'DONE' ? ( {'✓'} ) : ( )} {/* Media area — swipeable */} {previewable && !mediaLoaded && !mediaError && ( )} {!previewable ? ( // "No preview available" — an attachment outside this bubble's kind (e.g. an image inside // a video message). Only a download is offered, like Google Drive's unsupported preview. {t('NO_PREVIEW_AVAILABLE') || 'No preview available'} {t('FILE_TYPE_NOT_SUPPORTED_PREVIEW') || "This file type isn't supported for preview."} {downloadState === 'DOWNLOADING' ? ( ) : ( )} {downloadState === 'DONE' ? t('DOWNLOADED') || 'Downloaded' : t('DOWNLOAD') || 'Download'} ) : mediaError ? ( {t('SOMETHING_WRONG') ?? 'Something went wrong. Please try again.'} ) : isVideo ? ( // Native controls own play/pause/scrub — no custom tap-to-pause overlay, which would // fight the control taps (B3). ) : ( setMediaLoaded(true)} onError={() => { setMediaError(true); setMediaLoaded(true); }} /> )} {/* Prev / Next arrows */} {currentIndex > 0 && ( goTo(currentIndex - 1)} accessibilityRole="button" accessibilityLabel="Previous" > )} {currentIndex < mediaItems.length - 1 && ( goTo(currentIndex + 1)} accessibilityRole="button" accessibilityLabel="Next" > )} {/* Footer — page dots */} {mediaItems.length > 1 && ( {mediaItems.map((_, i) => ( ))} )} ); }; const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' }, header: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'center', paddingTop: Platform.OS === 'ios' ? 52 : 28, paddingHorizontal: 8, paddingBottom: 12, backgroundColor: 'rgba(0,0,0,0.45)', }, headerBtn: { width: 44, height: 44, justifyContent: 'center', alignItems: 'center', }, headerMeta: { flex: 1, marginHorizontal: 4 }, headerTitle: { color: '#fff', fontSize: 15, fontWeight: '600' }, headerSubtitle: { color: 'rgba(255,255,255,0.65)', fontSize: 12, marginTop: 2 }, doneCheck: { color: '#fff', fontSize: 18 }, mediaArea: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: Platform.OS === 'ios' ? 100 : 80, marginBottom: 60, }, videoWrapper: { flex: 1, width: SCREEN_W }, fullMedia: { width: SCREEN_W, flex: 1 }, errorBox: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, paddingHorizontal: 24 }, errorText: { color: 'rgba(255,255,255,0.8)', fontSize: 14, textAlign: 'center' }, noPreviewBox: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, paddingHorizontal: 24 }, noPreviewIconCircle: { width: 128, height: 128, borderRadius: 64, backgroundColor: 'rgba(255,255,255,0.06)', justifyContent: 'center', alignItems: 'center', marginBottom: 12, }, noPreviewTitle: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' }, noPreviewSubtitle: { color: 'rgba(255,255,255,0.6)', fontSize: 15, textAlign: 'center' }, downloadBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12, marginTop: 16, }, downloadBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, navArrow: { position: 'absolute', top: '50%', marginTop: -22, width: 44, height: 44, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.35)', borderRadius: 22, zIndex: 20, }, navLeft: { left: 8 }, navRight: { right: 8 }, footer: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingBottom: Platform.OS === 'ios' ? 32 : 16, alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.35)', }, dotsRow: { flexDirection: 'row', gap: 6, paddingVertical: 10 }, dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.35)' }, dotActive: { backgroundColor: '#fff' }, });