import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Animated, Easing, Image, LayoutChangeEvent, NativeModules, PanResponder, StyleSheet, Text, TextStyle, TouchableOpacity, View, ViewStyle, } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; import Video from 'react-native-video'; import { useTheme } from '../../../theme'; import { CometChatTheme } from '../../../theme/type'; import { useCompTheme } from '../../../theme/hook'; import { deepMerge } from '../../helper/helperFunctions'; import { SelectedAttachment } from '../../modals/SelectedAttachment'; import { Icon } from '../../icons/Icon'; import { getFileTypeIcon } from '../../constants/UIKitConstants'; const { SoundPlayer } = NativeModules; // One-audio-at-a-time coordinator (WhatsApp-style): when one card starts playing, every other // card resets its UI. The native SoundPlayer already holds a single MediaPlayer, so this only // keeps each card's play/pause state in sync with that reality — resetters must NOT call any // SoundPlayer method (that would stop the clip that just started). const audioPlaySubscribers = new Set<(activeFileId: string) => void>(); const announceAudioPlay = (activeFileId: string) => { audioPlaySubscribers.forEach((fn) => fn(activeFileId)); }; const parseJSON = (s: string): any => { try { return JSON.parse(s); } catch { return {}; } }; const fmtTime = (secs: number) => { const s = Math.max(0, Math.floor(secs || 0)); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }; // Upload ring for the file-card loading state, with two distinct modes: // - QUEUED (indeterminate): a fixed quarter-arc that spins continuously → "waiting to upload". // - UPLOADING (determinate): the spin STOPS and the arc grows with the real progress %, starting // from the top, so the user can actually read how far along the upload is. const UploadRing = ({ progress, indeterminate }: { progress: number; indeterminate: boolean }) => { const theme = useTheme(); const RING_SIZE = theme.spacing.spacing.s7; // 28 (nearest token to 30) const RING_STROKE = theme.spacing.spacing.s1; // 4 (nearest token to 3) const r = (RING_SIZE - RING_STROKE) / 2; const circumference = 2 * Math.PI * r; const spin = useRef(new Animated.Value(0)).current; useEffect(() => { // Only spin while QUEUED. Once uploading starts we show real progress, so the ring must be static. if (!indeterminate) { spin.setValue(0); return; } const loop = Animated.loop( Animated.timing(spin, { toValue: 1, duration: 900, easing: Easing.linear, useNativeDriver: true, }) ); loop.start(); return () => loop.stop(); }, [spin, indeterminate]); const rotate = spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }); // Queued → a fixed 25% arc (the spin makes it read as a loader). Uploading → the actual progress // fraction, held static (the -90° rotation just makes the arc start from 12 o'clock). const pct = indeterminate ? 0.25 : Math.min(1, Math.max(0, (progress || 0) / 100)); return ( ); }; export interface CometChatAttachmentTileProps { attachment: SelectedAttachment; onRemove: (fileId: string) => void; onRetry: (fileId: string) => void; /** Called when the user taps the tile thumbnail (for image/video preview). */ onPress?: (fileId: string) => void; /** * testID forwarded to the tile's outer container (a native View); derived ids: * `${testID}.remove`, `${testID}.retry`, `${testID}.uploading` (present only while uploading). * A `testID` on a custom component is inert unless forwarded to a native node — so we spread it * onto the real View/TouchableOpacity, not the component wrapper. */ testID?: string; /** * Structured style overrides for the tile's variants (file card / media tile / * audio card), deep-merged over the theme's `attachmentTileStyles`. */ style?: CometChatTheme["attachmentTileStyles"]; } // Non-media file tiles are a wide card (icon + name + type), not a square thumbnail. // Figma (Android): width 200, height 72 (spacing.s18), radius 12 (radius.r3), padding 12 // (padding.p3), gap 8 (spacing.s2) — those four are theme tokens now; only the fixed 200px // card width has no matching token, so it lives in styles.fileCard. // Media-overlay colors (error dim, video scrim, duration pill) now come from the theme: // theme.color.mediaErrorOverlay / mediaScrim / mediaDurationChip. See theme/default/color. // ── Audio attachment player card ──────────────────────────────────────────────── // Picked/pasted audio (audio/*) renders as a player, not the plain file card: a purple // play/pause circle + filename + a draggable seek bar + elapsed/total time, sharing the // LOADING (ring) and ERROR (retry/"!" glyph + "Upload failed · Retry") states of the file card. // Playback uses the shared native SoundPlayer. ponytail: SoundPlayer holds ONE MediaPlayer, so // only one clip plays at a time and the pre-play total-duration shows the last-prepared clip // when several audio files are staged together — play(uri) always reloads the correct clip, so // playback is per-card correct. Per-card native players only if simultaneous durations matter. const AudioAttachmentCard = ({ attachment, onRemove, onRetry, onPress, containerStyle, audioCardStyle, fileNameStyle, removeButtonStyle, }: Omit & { containerStyle?: ViewStyle; audioCardStyle?: ViewStyle; fileNameStyle?: TextStyle; removeButtonStyle?: ViewStyle; }) => { const theme = useTheme(); const { fileId, file, uploadState, uploadProgress } = attachment; const [status, setStatus] = useState<'stopped' | 'playing' | 'paused'>('stopped'); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [seeking, setSeeking] = useState(false); const [seekFrac, setSeekFrac] = useState(0); const trackWidth = useRef(0); const pollRef = useRef | null>(null); const loading = uploadState === 'NONE' || uploadState === 'UPLOADING'; const failed = uploadState === 'FAILED'; const rejected = uploadState === 'REJECTED'; const isError = failed || rejected; const showRemove = !loading; // PanResponder / interval callbacks capture their first-render closure — read live values // (loading/isError/duration) from a ref so seek + end-detection never go stale. const liveRef = useRef({ loading, isError, duration, status }); liveRef.current = { loading, isError, duration, status }; const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; const startPolling = () => { stopPolling(); pollRef.current = setInterval(() => { SoundPlayer?.getPosition?.((info: string) => { const pos = parseJSON(info)?.position ?? 0; setCurrentTime(pos); const d = liveRef.current.duration; if (d > 0 && pos >= d - 0.25) { stopPolling(); setStatus('stopped'); setCurrentTime(0); } }); }, 250); }; // Duration for the total-time display. Prefer the value the react-native-video probe already read // (one independent player per file) — only fall back to the shared SoundPlayer when we don't have // it. Prepping every audio tile on the single shared Android player made tap-to-preview flaky. useEffect(() => { if (attachment.durationSeconds && attachment.durationSeconds > 0) { setDuration(attachment.durationSeconds); } else { SoundPlayer?.prepareMediaPlayer?.(file.uri, (info: string) => { const d = parseJSON(info)?.duration; if (typeof d === 'number' && d > 0) setDuration(d); }); } return () => { stopPolling(); // Only release the SHARED player if THIS tile was the one using it — releasing on every tile's // unmount thrashes the single Android player (still stops OUR playback on unmount). if (liveRef.current.status === 'playing' || liveRef.current.status === 'paused') { SoundPlayer?.releaseMediaPlayer?.(); } }; }, [file.uri, attachment.durationSeconds]); // When ANOTHER card starts playing, reset this one's UI (do not touch SoundPlayer — the // native player is already playing the other clip). useEffect(() => { const onOtherPlay = (activeFileId: string) => { if (activeFileId !== fileId) { stopPolling(); setStatus('stopped'); setCurrentTime(0); } }; audioPlaySubscribers.add(onOtherPlay); return () => { audioPlaySubscribers.delete(onOtherPlay); }; }, [fileId]); const onToggle = () => { if (loading || isError) return; if (status === 'playing') { SoundPlayer?.pause?.(() => {}); stopPolling(); setStatus('paused'); } else if (status === 'paused') { announceAudioPlay(fileId); // resuming stops any other card that's playing SoundPlayer?.resume?.(); setStatus('playing'); startPolling(); } else { announceAudioPlay(fileId); // starting stops any other card that's playing SoundPlayer?.play?.(file.uri, (info: string) => { const d = parseJSON(info)?.duration; if (typeof d === 'number' && d > 0) setDuration(d); }); setStatus('playing'); setCurrentTime(0); startPolling(); } }; const seekTo = (frac: number) => { const clamped = Math.min(1, Math.max(0, frac)); const secs = Math.round(clamped * liveRef.current.duration); SoundPlayer?.playAt?.(secs, () => {}); setCurrentTime(secs); }; const pan = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => !liveRef.current.loading && !liveRef.current.isError && liveRef.current.duration > 0, onMoveShouldSetPanResponder: () => !liveRef.current.loading && !liveRef.current.isError && liveRef.current.duration > 0, onPanResponderGrant: (e) => { setSeeking(true); const w = trackWidth.current || 1; setSeekFrac(Math.min(1, Math.max(0, e.nativeEvent.locationX / w))); }, onPanResponderMove: (e) => { const w = trackWidth.current || 1; setSeekFrac(Math.min(1, Math.max(0, e.nativeEvent.locationX / w))); }, onPanResponderRelease: (e) => { const w = trackWidth.current || 1; seekTo(e.nativeEvent.locationX / w); setSeeking(false); }, }) ).current; // Clamp the displayed time to the duration — a mis-read clip (e.g. an .aac whose duration reads // short) can make playback overshoot, and the time must never read past the total (00:03/00:01). const rawTime = seeking ? seekFrac * duration : currentTime; const dispTime = duration > 0 ? Math.min(rawTime, duration) : rawTime; const frac = duration > 0 ? Math.min(1, Math.max(0, dispTime / duration)) : 0; const fillPct = `${frac * 100}%` as const; const S = theme.spacing.spacing; const CIRCLE = S.s14; // 56 — same play circle as the sent audio bubble (CometChatAudiosBubble) const TRACK_H = S.s1; // 4 — thin seek track const THUMB = S.s3; // 12 — seek thumb // Rejected → the whole card taps to reveal the failure reason (mirrors the file/media cards). const AudioCardContainer: any = rejected && onPress ? TouchableOpacity : View; const audioCardPressProps = rejected && onPress ? { activeOpacity: 0.7, onPress: () => onPress(fileId) } : {}; return ( {/* Purple play/pause circle; LOADING dims it + spins the ring, ERROR shows the glyph. */} {loading && ( )} {isError && ( // Design: keep the play circle, dim it with a dark tint, then center a SMALL red badge with a // white glyph (retry / "!") on top — NOT a full red fill. Mirrors the media tile's error badge. )} {/* Filename · seek bar · elapsed/total — stacked in a column beside the circle (▶ ──●──). */} {file.name} {isError ? ( failed ? ( // FAILED (transient) — whole label taps to re-upload. onRetry(fileId)} suppressHighlighting style={{ ...theme.typography.caption2.medium, color: theme.color.error, marginTop: S.s0_5 }} > {'Tap to retry'} ) : ( // REJECTED (server denied) — remove-only, no retry. {'Upload failed'} ) ) : ( <> {/* Draggable seek bar: thin track + purple fill + thumb. */} { trackWidth.current = e.nativeEvent.layout.width; }} style={{ height: S.s6, justifyContent: 'center', marginTop: S.s0_5 }} > {`${fmtTime(dispTime)}/${fmtTime(duration)}`} )} {showRemove && ( onRemove(fileId)} hitSlop={{ top: theme.spacing.spacing.s2, right: theme.spacing.spacing.s2, bottom: theme.spacing.spacing.s2, left: theme.spacing.spacing.s2 }} style={[{ position: 'absolute', top: -theme.spacing.spacing.s2, right: -theme.spacing.spacing.s2, width: theme.spacing.spacing.s6, height: theme.spacing.spacing.s6, borderRadius: theme.spacing.radius.r3, backgroundColor: theme.color.neutral700, borderWidth: 2, borderColor: theme.color.background1, justifyContent: 'center', alignItems: 'center', }, removeButtonStyle]} accessibilityRole="button" accessibilityLabel="Remove attachment" > {'✕'} )} ); }; export const CometChatAttachmentTile = ({ attachment, onRemove, onRetry, onPress, style, testID, }: CometChatAttachmentTileProps) => { const theme = useTheme(); const compTheme = useCompTheme(); const s = deepMerge( theme.attachmentTileStyles ?? {}, compTheme.attachmentTileStyles ?? {}, style ?? {} ); const { fileId, file, uploadState, uploadProgress } = attachment; // Audio (audio/*) renders as a player card (play/pause + seek + time), not the file card. if (file.type.startsWith('audio/')) { return ( ); } const isMedia = file.type.startsWith('image/') || file.type.startsWith('video/'); // Tap opens the full-screen viewer ONLY once the media is fully uploaded (COMPLETED) — the tile // is not tappable while it is still uploading / queued / errored. const canPreview = isMedia && uploadState === 'COMPLETED'; // Non-media files (PDF / MP3 / docs) render as a WIDE card — colored file-type icon + // name + type label — and stay clean: no exclamation / error overlay. Photos & videos // keep the square thumbnail tile below. (Failed uploads: subtle red border + tap to retry.) if (!isMedia) { const ext = (file.name.split('.').pop() || '').toUpperCase(); const loading = uploadState === 'NONE' || uploadState === 'UPLOADING'; const failed = uploadState === 'FAILED'; // transient (onFailure) → Retry offered const rejected = uploadState === 'REJECTED'; // server denied (onError) → remove-only, no Retry const isError = failed || rejected; const showRemove = !loading; // ✕ hidden while uploading; shown on DEFAULT + ERROR // Only a REJECTED card is tappable (tap → failure-reason toast); keep it a plain View otherwise so // the FAILED card's inner "Tap to retry" text keeps its own touch handling. const FileCardContainer: any = rejected && onPress ? TouchableOpacity : View; const fileCardPressProps = rejected && onPress ? { activeOpacity: 0.7, onPress: () => onPress(fileId) } : {}; return ( // Wrapper is `relative` (not clipped) so the ✕ badge can sit on the card's corner. {/* 48px colored file-type icon. LOADING dims it + spins the upload ring on top. */} {isError ? ( // Error/retry (matches design, image 1): red document + the SAME dark backdrop tint // (theme.color.mediaErrorOverlay) as the image/video/audio error tiles, with the status badge on top. // FAILED (retryable) → white reload; REJECTED (denied) → white "!". <> {/* REJECTED → the SAME `error-fill` SVG the message-list error/retry receipt uses (CometChatReceipt), so the tray error "!" matches the failed-message "!". */} ) : ( <> {loading && ( )} )} {file.name} {isError ? ( failed ? ( // FAILED (transient) — whole label taps to re-upload. onRetry(fileId)} suppressHighlighting style={{ ...theme.typography.caption2.medium, color: theme.color.error, marginTop: theme.spacing.margin.m0_5 }} > {'Tap to retry'} ) : ( // REJECTED (server denied) — remove-only, no retry. {'Upload failed'} ) ) : ( Boolean(ext) && ( {ext} ) )} {/* Remove (✕) — corner badge, DEFAULT + ERROR only (hidden while uploading). */} {showRemove && ( onRemove(fileId)} hitSlop={{ top: theme.spacing.spacing.s2, right: theme.spacing.spacing.s2, bottom: theme.spacing.spacing.s2, left: theme.spacing.spacing.s2 }} style={[{ position: 'absolute', top: -theme.spacing.spacing.s2, right: -theme.spacing.spacing.s2, width: theme.spacing.spacing.s6, height: theme.spacing.spacing.s6, borderRadius: theme.spacing.radius.r3, backgroundColor: theme.color.neutral700, borderWidth: 2, borderColor: theme.color.background1, justifyContent: 'center', alignItems: 'center', }, s.removeButtonStyle]} accessibilityRole="button" accessibilityLabel="Remove attachment" > {'✕'} )} ); } // Media (image/video) square tile states — mirror the file/audio cards: // DEFAULT → clean thumbnail + ✕ corner badge // LOADING → dimmed + white upload ring (NONE/UPLOADING), ✕ hidden // RETRY → FAILED (transient): red border + red retry circle (tap to re-upload) // ERROR → REJECTED (denied): red border + red "!" circle, remove-only const loading = uploadState === 'NONE' || uploadState === 'UPLOADING'; const failed = uploadState === 'FAILED'; const rejected = uploadState === 'REJECTED'; const isError = failed || rejected; const showRemove = !loading; return ( // Wrapper is relative (not clipped) so the ✕ badge can sit on the tile's corner. onPress(fileId) : undefined} style={[{ width: theme.spacing.spacing.s18, height: theme.spacing.spacing.s18, borderRadius: theme.spacing.radius.r2, overflow: 'hidden', backgroundColor: theme.color.neutral200, borderWidth: isError ? 1.5 : 0, borderColor: theme.color.error, }, s.mediaTileStyle]} > {file.type.startsWith('video/') ? ( // A local video file can't render as an , so show its FIRST FRAME via a paused, // muted react-native-video (no controls, never plays) as the thumbnail. The dark scrim // sits behind it so the tile reads as a video before the frame decodes. <> {/* Remove ✕ — corner badge; DEFAULT + ERROR/RETRY (hidden while uploading). */} {showRemove && ( onRemove(fileId)} hitSlop={{ top: theme.spacing.spacing.s2, right: theme.spacing.spacing.s2, bottom: theme.spacing.spacing.s2, left: theme.spacing.spacing.s2 }} style={[{ position: 'absolute', top: -theme.spacing.spacing.s2, right: -theme.spacing.spacing.s2, width: theme.spacing.spacing.s6, height: theme.spacing.spacing.s6, borderRadius: theme.spacing.radius.r3, backgroundColor: theme.color.neutral700, borderWidth: 2, borderColor: theme.color.background1, justifyContent: 'center', alignItems: 'center', }, s.removeButtonStyle]} accessibilityRole="button" accessibilityLabel="Remove attachment" > {'✕'} )} ); }; // Backward-compat alias export const CometChatAttachmentPreviewItem = CometChatAttachmentTile; export type CometChatAttachmentPreviewItemProps = CometChatAttachmentTileProps; const styles = StyleSheet.create({ fillCenter: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', }, fill: { width: '100%', height: '100%', }, fileCard: { width: 200, }, audioCard: { width: 260, }, });