UNPKG

@oxyhq/services

Version:

OxyHQ Expo/React Native SDK — UI components, screens, and native features

1,299 lines (1,254 loc) 90.6 kB
"use strict"; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, RefreshControl, TextInput, Image, Animated, Easing, Alert } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; // Lazy load expo-document-picker (optional dependency) // This allows the screen to work even if expo-document-picker is not installed let DocumentPicker = null; const loadDocumentPicker = async () => { if (DocumentPicker) return DocumentPicker; try { DocumentPicker = await import('expo-document-picker'); return DocumentPicker; } catch (error) { throw new Error('expo-document-picker is not installed. Please install it: npx expo install expo-document-picker'); } }; import { toast } from '../../lib/sonner'; import { Ionicons } from '@expo/vector-icons'; // @ts-ignore - MaterialCommunityIcons is available at runtime import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFileStore, useFiles, useUploading as useUploadingStore, useUploadAggregateProgress, useDeleting as useDeletingStore } from "../stores/fileStore.js"; import Header from "../components/Header.js"; import JustifiedPhotoGrid from "../components/photogrid/JustifiedPhotoGrid.js"; import { GroupedSection } from "../components/index.js"; import { useThemeStyles } from "../hooks/useThemeStyles.js"; import { useColorScheme } from "../hooks/useColorScheme.js"; import { normalizeTheme } from "../utils/themeUtils.js"; import { useOxy } from "../context/OxyContext.js"; import { useUploadFile } from "../hooks/mutations/useAccountMutations.js"; import { confirmAction, convertDocumentPickerAssetToFile, formatFileSize, getFileIcon, getSafeDownloadUrl } from "../utils/fileManagement.js"; import { FileViewer } from "../components/fileManagement/FileViewer.js"; import { FileDetailsModal } from "../components/fileManagement/FileDetailsModal.js"; import { UploadPreview } from "../components/fileManagement/UploadPreview.js"; import { fileManagementStyles } from "../components/fileManagement/styles.js"; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; // Animated button component for smooth transitions const AnimatedButton = ({ isSelected, onPress, icon, primaryColor, textColor, style }) => { const animatedValue = useRef(new Animated.Value(isSelected ? 1 : 0)).current; useEffect(() => { Animated.timing(animatedValue, { toValue: isSelected ? 1 : 0, duration: 200, easing: Easing.out(Easing.ease), useNativeDriver: false }).start(); }, [isSelected, animatedValue]); const backgroundColor = animatedValue.interpolate({ inputRange: [0, 1], outputRange: ['transparent', primaryColor] }); const iconColor = animatedValue.interpolate({ inputRange: [0, 1], outputRange: [textColor, '#FFFFFF'] }); return /*#__PURE__*/_jsx(TouchableOpacity, { onPress: onPress, activeOpacity: 0.7, children: /*#__PURE__*/_jsx(Animated.View, { style: [style, { backgroundColor }], children: /*#__PURE__*/_jsx(Animated.View, { children: /*#__PURE__*/_jsx(MaterialCommunityIcons, { name: icon, size: 16, color: isSelected ? '#FFFFFF' : textColor }) }) }) }); }; const FileManagementScreen = ({ onClose, theme, goBack, navigate, userId, containerWidth = 400, // Fallback for when not provided by the router selectMode = false, multiSelect = false, onSelect, onConfirmSelection, initialSelectedIds = [], maxSelection, disabledMimeTypes = [], afterSelect = 'close', allowUploadInSelectMode = true, defaultVisibility = 'private', linkContext }) => { // Use useOxy() hook for OxyContext values const { user, oxyServices } = useOxy(); const uploadFileMutation = useUploadFile(); const files = useFiles(); // Ensure containerWidth is a number (TypeScript guard) const safeContainerWidth = typeof containerWidth === 'number' ? containerWidth : 400; const uploading = useUploadingStore(); const uploadProgress = useUploadAggregateProgress(); const deleting = useDeletingStore(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [paging, setPaging] = useState({ offset: 0, limit: 40, total: 0, hasMore: true, loadingMore: false }); const [selectedFile, setSelectedFile] = useState(null); const [showFileDetails, setShowFileDetails] = useState(false); // In selectMode we never open the detailed viewer const [openedFile, setOpenedFile] = useState(null); const [fileContent, setFileContent] = useState(null); const [loadingFileContent, setLoadingFileContent] = useState(false); const [showFileDetailsInViewer, setShowFileDetailsInViewer] = useState(false); const [isPickingDocument, setIsPickingDocument] = useState(false); const [viewMode, setViewMode] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('date'); const [sortOrder, setSortOrder] = useState('desc'); const [pendingFiles, setPendingFiles] = useState([]); const [showUploadPreview, setShowUploadPreview] = useState(false); // Derived filtered and sorted files (avoid setState loops) const filteredFiles = useMemo(() => { let filteredByMode = files; if (viewMode === 'photos') { filteredByMode = files.filter(file => file.contentType.startsWith('image/')); } else if (viewMode === 'videos') { filteredByMode = files.filter(file => file.contentType.startsWith('video/')); } else if (viewMode === 'documents') { filteredByMode = files.filter(file => file.contentType.includes('pdf') || file.contentType.includes('document') || file.contentType.includes('text') || file.contentType.includes('msword') || file.contentType.includes('excel') || file.contentType.includes('spreadsheet') || file.contentType.includes('presentation') || file.contentType.includes('powerpoint')); } else if (viewMode === 'audio') { filteredByMode = files.filter(file => file.contentType.startsWith('audio/')); } let filtered = filteredByMode; if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filteredByMode.filter(file => file.filename.toLowerCase().includes(query) || file.contentType.toLowerCase().includes(query) || file.metadata?.description && file.metadata.description.toLowerCase().includes(query)); } // Sort files const sorted = [...filtered].sort((a, b) => { let comparison = 0; if (sortBy === 'date') { const dateA = new Date(a.uploadDate || 0).getTime(); const dateB = new Date(b.uploadDate || 0).getTime(); comparison = dateA - dateB; } else if (sortBy === 'size') { comparison = (a.length || 0) - (b.length || 0); } else if (sortBy === 'name') { comparison = (a.filename || '').localeCompare(b.filename || ''); } else if (sortBy === 'type') { comparison = (a.contentType || '').localeCompare(b.contentType || ''); } return sortOrder === 'asc' ? comparison : -comparison; }); return sorted; }, [files, searchQuery, viewMode, sortBy, sortOrder]); const [photoDimensions, setPhotoDimensions] = useState({}); const [loadingDimensions, setLoadingDimensions] = useState(false); const uploadStartRef = useRef(null); const MIN_BANNER_MS = 600; // Selection state const [selectedIds, setSelectedIds] = useState(new Set(initialSelectedIds)); const [lastSelectedFileId, setLastSelectedFileId] = useState(null); const scrollViewRef = useRef(null); const photoScrollViewRef = useRef(null); const itemRefs = useRef(new Map()); // Track item positions const containerRef = useRef(null); useEffect(() => { if (initialSelectedIds && initialSelectedIds.length) { setSelectedIds(new Set(initialSelectedIds)); } }, [initialSelectedIds]); const toggleSelect = useCallback(async file => { // Allow selection in regular mode for bulk operations // if (!selectMode) return; if (disabledMimeTypes.length) { const blocked = disabledMimeTypes.some(mt => file.contentType === mt || file.contentType.startsWith(mt.endsWith('/') ? mt : mt + '/')); if (blocked) { toast.error('This file type cannot be selected'); return; } } // Update file visibility if it differs from defaultVisibility const fileVisibility = file.metadata?.visibility || 'private'; if (fileVisibility !== defaultVisibility) { try { await oxyServices.assetUpdateVisibility(file.id, defaultVisibility); } catch (error) { // Continue anyway - selection shouldn't fail if visibility update fails } } // Track the selected file for scrolling setLastSelectedFileId(file.id); // Link file to entity if linkContext is provided if (linkContext) { try { await oxyServices.assetLink(file.id, linkContext.app, linkContext.entityType, linkContext.entityId, defaultVisibility, linkContext.webhookUrl); } catch (error) { // Continue anyway - selection shouldn't fail if linking fails } } if (!multiSelect) { onSelect?.(file); if (afterSelect === 'back') { goBack?.(); } else if (afterSelect === 'close') { onClose?.(); } return; } setSelectedIds(prev => { const next = new Set(prev); const already = next.has(file.id); if (!already) { if (maxSelection && next.size >= maxSelection) { toast.error(`You can select up to ${maxSelection}`); return prev; } next.add(file.id); } else { next.delete(file.id); } return next; }); }, [selectMode, multiSelect, onSelect, onClose, goBack, disabledMimeTypes, maxSelection, afterSelect, defaultVisibility, oxyServices, linkContext]); const confirmMultiSelection = useCallback(async () => { if (!selectMode || !multiSelect) return; const map = {}; files.forEach(f => { map[f.id] = f; }); const chosen = Array.from(selectedIds).map(id => map[id]).filter(Boolean); // Update visibility and link files if needed const updatePromises = chosen.map(async file => { // Update visibility if needed const fileVisibility = file.metadata?.visibility || 'private'; if (fileVisibility !== defaultVisibility) { try { await oxyServices.assetUpdateVisibility(file.id, defaultVisibility); } catch (error) { // Visibility update failed, continue with selection } } // Link file to entity if linkContext provided if (linkContext) { try { await oxyServices.assetLink(file.id, linkContext.app, linkContext.entityType, linkContext.entityId, defaultVisibility, linkContext.webhookUrl); } catch (error) { // File linking failed, continue with selection } } }); // Wait for all updates (but don't block on failures) await Promise.allSettled(updatePromises); onConfirmSelection?.(chosen); onClose?.(); }, [selectMode, multiSelect, selectedIds, files, onConfirmSelection, onClose, defaultVisibility, oxyServices, linkContext]); const endUpload = useCallback(() => { const started = uploadStartRef.current; const elapsed = started ? Date.now() - started : MIN_BANNER_MS; const remaining = elapsed < MIN_BANNER_MS ? MIN_BANNER_MS - elapsed : 0; setTimeout(() => { useFileStore.getState().setUploading(false); uploadStartRef.current = null; }, remaining); }, []); // Helper to safely request a thumbnail variant only for image mime types. const getSafeDownloadUrlCallback = useCallback((file, variant = 'thumb') => { return getSafeDownloadUrl(file, variant, (fileId, variant) => oxyServices.getFileDownloadUrl(fileId, variant)); }, [oxyServices]); // Use centralized theme styles hook for consistency const colorScheme = useColorScheme(); const normalizedTheme = normalizeTheme(theme); const baseThemeStyles = useThemeStyles(normalizedTheme, colorScheme); // FileManagementScreen uses a slightly different light background const themeStyles = useMemo(() => ({ ...baseThemeStyles, backgroundColor: baseThemeStyles.isDarkTheme ? baseThemeStyles.backgroundColor : '#f2f2f2' }), [baseThemeStyles]); // Extract commonly used theme variables const backgroundColor = themeStyles.backgroundColor; const borderColor = themeStyles.borderColor; const targetUserId = userId || user?.id; const storeSetUploading = useFileStore(s => s.setUploading); const storeSetUploadProgress = useFileStore(s => s.setUploadProgress); const storeSetDeleting = useFileStore(s => s.setDeleting); const loadFiles = useCallback(async (mode = 'initial') => { if (!targetUserId) return; try { if (mode === 'refresh') { setRefreshing(true); } else if (mode === 'initial') { setLoading(true); setPaging(p => ({ ...p, offset: 0, hasMore: true })); } else if (mode === 'more') { // Prevent duplicate fetches setPaging(p => ({ ...p, loadingMore: true })); } const currentPaging = mode === 'more' ? prevPagingRef.current ?? paging : paging; const effectiveOffset = mode === 'more' ? currentPaging.offset + currentPaging.limit : 0; const response = await oxyServices.listUserFiles(currentPaging.limit, effectiveOffset); const assets = (response.files || []).map(f => ({ id: f.id, filename: f.originalName || f.sha256, contentType: f.mime, length: f.size, chunkSize: 0, uploadDate: f.createdAt, metadata: f.metadata || {}, variants: f.variants || [] })); if (mode === 'more') { // append useFileStore.getState().setFiles(assets, { merge: true }); setPaging(p => ({ ...p, offset: effectiveOffset, total: response.total || effectiveOffset + assets.length, hasMore: response.hasMore, loadingMore: false })); } else { useFileStore.getState().setFiles(assets, { merge: false }); setPaging(p => ({ ...p, offset: 0, total: response.total || assets.length, hasMore: response.hasMore, loadingMore: false })); } } catch (error) { toast.error(error.message || 'Failed to load files'); } finally { setLoading(false); setRefreshing(false); setPaging(p => ({ ...p, loadingMore: false })); } }, [targetUserId, oxyServices, paging]); // Keep a ref to avoid stale closure when calculating next offset const prevPagingRef = useRef(paging); useEffect(() => { prevPagingRef.current = paging; }, [paging]); // (removed effect; filteredFiles is memoized) // Load photo dimensions for justified grid - unified approach using Image.getSize const loadPhotoDimensions = useCallback(async photos => { if (photos.length === 0) return; setLoadingDimensions(true); const newDimensions = { ...photoDimensions }; let hasNewDimensions = false; // Only load dimensions for photos we don't have yet const photosToLoad = photos.filter(photo => !newDimensions[photo.id]); if (photosToLoad.length === 0) { setLoadingDimensions(false); return; } try { await Promise.all(photosToLoad.map(async photo => { try { const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb'); // Unified approach using Image.getSize (works on all platforms) await new Promise(resolve => { Image.getSize(downloadUrl, (width, height) => { newDimensions[photo.id] = { width, height }; hasNewDimensions = true; resolve(); }, () => { // Fallback dimensions newDimensions[photo.id] = { width: 1, height: 1 }; hasNewDimensions = true; resolve(); }); }); } catch (error) { // Fallback dimensions for any errors newDimensions[photo.id] = { width: 1, height: 1 }; hasNewDimensions = true; } })); if (hasNewDimensions) { setPhotoDimensions(newDimensions); } } catch (error) { // Photo dimensions loading failed, continue without dimensions } finally { setLoadingDimensions(false); } }, [getSafeDownloadUrlCallback, photoDimensions]); // Create justified rows from photos with responsive algorithm const createJustifiedRows = useCallback((photos, containerWidth) => { if (photos.length === 0) return []; const rows = []; const photosPerRow = 3; // Fixed 3 photos per row for consistency for (let i = 0; i < photos.length; i += photosPerRow) { const rowPhotos = photos.slice(i, i + photosPerRow); rows.push(rowPhotos); } return rows; }, []); const processFileUploads = async selectedFiles => { if (selectedFiles.length === 0) return []; if (!targetUserId) return []; // Guard clause to ensure userId is defined const uploadedFiles = []; try { storeSetUploadProgress({ current: 0, total: selectedFiles.length }); const maxSize = 50 * 1024 * 1024; // 50MB const oversizedFiles = selectedFiles.filter(file => file.size > maxSize); if (oversizedFiles.length > 0) { const fileList = oversizedFiles.map(f => f.name).join(', '); toast.error(`The following files are too large (max 50MB): ${fileList}`); return []; } let successCount = 0; let failureCount = 0; const errors = []; for (let i = 0; i < selectedFiles.length; i++) { storeSetUploadProgress({ current: i + 1, total: selectedFiles.length }); const raw = selectedFiles[i]; const fileName = raw.name || `file-${i + 1}`; const optimisticId = `temp-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`; // Unique ID per file try { // Validate file before upload if (!raw || !raw.name || raw.size === undefined || raw.size <= 0) { const errorMsg = `Invalid file: ${fileName}`; if (__DEV__) { console.error('Upload validation failed:', { file: raw, error: errorMsg }); } failureCount++; errors.push(`${fileName}: Invalid file (missing name or size)`); continue; } const optimisticFile = { id: optimisticId, filename: raw.name, contentType: raw.type || 'application/octet-stream', length: raw.size, chunkSize: 0, uploadDate: new Date().toISOString(), metadata: { uploading: true }, variants: [] }; useFileStore.getState().addFile(optimisticFile, { prepend: true }); // Use the mutation hook with authentication handling const result = await uploadFileMutation.mutateAsync({ file: raw, visibility: defaultVisibility }); // Attempt to refresh file list incrementally – fetch single file metadata if API allows const f = result?.file ?? result?.files?.[0]; if (f) { const merged = { id: f.id, filename: f.originalName || f.sha256 || raw.name, contentType: f.mime || raw.type || 'application/octet-stream', length: f.size || raw.size, chunkSize: 0, uploadDate: f.createdAt || new Date().toISOString(), metadata: f.metadata || {}, variants: f.variants || [] }; // Remove optimistic then add real useFileStore.getState().removeFile(optimisticId); useFileStore.getState().addFile(merged, { prepend: true }); uploadedFiles.push(merged); successCount++; } else { // Fallback: will reconcile on later list refresh useFileStore.getState().updateFile(optimisticId, { metadata: { uploading: false } }); if (__DEV__) { console.warn('Upload completed but no file data returned:', { fileName, result }); } // Still count as success if upload didn't throw successCount++; } } catch (error) { failureCount++; const errorMessage = error.message || error.toString() || 'Upload failed'; const fullError = `${fileName}: ${errorMessage}`; errors.push(fullError); if (__DEV__) { console.error('File upload failed:', { fileName, fileSize: raw.size, fileType: raw.type, error: errorMessage, stack: error.stack }); } // Remove optimistic file on error (use the same optimisticId from above) useFileStore.getState().removeFile(optimisticId); } } // Show success/error messages if (successCount > 0) { toast.success(`${successCount} file(s) uploaded successfully`); } if (failureCount > 0) { // Show detailed error message with first few errors const errorDetails = errors.length > 0 ? `\n${errors.slice(0, 3).join('\n')}${errors.length > 3 ? `\n...and ${errors.length - 3} more` : ''}` : ''; toast.error(`${failureCount} file(s) failed to upload${errorDetails}`); } // Silent background refresh to ensure metadata/variants updated setTimeout(() => { loadFiles('silent'); }, 1200); } catch (error) { toast.error(error.message || 'Failed to upload files'); } finally { storeSetUploadProgress(null); } return uploadedFiles; }; const handleFileSelection = useCallback(async selectedFiles => { const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB const processedFiles = []; for (const file of selectedFiles) { // Validate file has required properties if (!file) { if (__DEV__) { console.error('Invalid file: file is null or undefined'); } toast.error('Invalid file: file is missing'); continue; } if (!file.name || typeof file.name !== 'string') { if (__DEV__) { console.error('Invalid file: missing or invalid name property', file); } toast.error('Invalid file: missing file name'); continue; } if (file.size === undefined || file.size === null || isNaN(file.size)) { if (__DEV__) { console.error('Invalid file: missing or invalid size property', file); } toast.error(`Invalid file "${file.name || 'unknown'}": missing file size`); continue; } if (file.size <= 0) { if (__DEV__) { console.error('Invalid file: file size is zero or negative', file); } toast.error(`File "${file.name}" is empty`); continue; } // Validate file size if (file.size > MAX_FILE_SIZE) { toast.error(`"${file.name}" is too large. Maximum file size is ${formatFileSize(MAX_FILE_SIZE)}`); continue; } // Ensure file has a type property const fileType = file.type || 'application/octet-stream'; // Generate preview for images - unified approach let preview; if (fileType.startsWith('image/')) { // Try to use file URI from expo-document-picker if available (works on all platforms) const fileUri = file.uri; if (fileUri && typeof fileUri === 'string' && (fileUri.startsWith('file://') || fileUri.startsWith('content://') || fileUri.startsWith('http://') || fileUri.startsWith('https://') || fileUri.startsWith('blob:'))) { preview = fileUri; } else { // Fallback: create blob URL if possible (works on web) try { if (file instanceof File || file instanceof Blob) { preview = URL.createObjectURL(file); } } catch (error) { if (__DEV__) { console.warn('Failed to create preview URL:', error); } // Preview is optional, continue without it } } } processedFiles.push({ file, preview, size: file.size, name: file.name, type: fileType }); } if (processedFiles.length === 0) { toast.error('No valid files to upload'); return; } // Show preview modal for user to review files before upload setPendingFiles(processedFiles); setShowUploadPreview(true); }, []); const handleConfirmUpload = async () => { if (pendingFiles.length === 0) return; setShowUploadPreview(false); uploadStartRef.current = Date.now(); storeSetUploading(true); storeSetUploadProgress(null); try { const filesToUpload = pendingFiles.map(pf => pf.file); storeSetUploadProgress({ current: 0, total: filesToUpload.length }); const uploadedFiles = await processFileUploads(filesToUpload); // Cleanup preview URLs pendingFiles.forEach(pf => { if (pf.preview) { URL.revokeObjectURL(pf.preview); } }); setPendingFiles([]); // If in selectMode, automatically select the uploaded file(s) if (selectMode && uploadedFiles.length > 0) { // Wait a bit for the file store to update and ensure file is available setTimeout(() => { const fileToSelect = uploadedFiles[0]; if (!multiSelect && fileToSelect) { // Single select mode - directly call onSelect callback onSelect?.(fileToSelect); if (afterSelect === 'back') { goBack?.(); } else if (afterSelect === 'close') { onClose?.(); } } else if (multiSelect) { // Multi-select mode - add all uploaded files to selection uploadedFiles.forEach(file => { if (!selectedIds.has(file.id)) { setSelectedIds(prev => new Set(prev).add(file.id)); } }); } }, 500); } endUpload(); } catch (error) { toast.error(error.message || 'Failed to upload files'); endUpload(); } }; const handleCancelUpload = () => { // Cleanup preview URLs pendingFiles.forEach(pf => { if (pf.preview) { URL.revokeObjectURL(pf.preview); } }); setPendingFiles([]); setShowUploadPreview(false); }; const removePendingFile = index => { const file = pendingFiles[index]; if (file.preview) { URL.revokeObjectURL(file.preview); } const updated = pendingFiles.filter((_, i) => i !== index); setPendingFiles(updated); if (updated.length === 0) { setShowUploadPreview(false); } }; /** * Handle file upload - opens document picker and processes selected files * Expo 54 compatible - works on web, iOS, and Android */ const handleFileUpload = async () => { // Prevent concurrent document picker calls if (isPickingDocument) { toast.error('Please wait for the current file selection to complete'); return; } try { setIsPickingDocument(true); // Lazy load expo-document-picker const picker = await loadDocumentPicker(); // Use expo-document-picker (works on all platforms including web) // On web, it uses the native file input and provides File objects directly const result = await picker.getDocumentAsync({ type: '*/*', multiple: true, copyToCacheDirectory: true }); if (result.canceled) { setIsPickingDocument(false); return; } if (!result.assets || result.assets.length === 0) { setIsPickingDocument(false); toast.error('No files were selected'); return; } // Convert expo document picker results to File-like objects // According to Expo 54 docs, expo-document-picker returns assets with: // - uri: file URI (file://, content://, or blob URL) // - name: file name // - size: file size in bytes // - mimeType: MIME type of the file // - file: (optional) native File object (usually only on web) const files = []; const errors = []; // Process files in parallel for better performance // This allows multiple files to be converted simultaneously const conversionPromises = result.assets.map((doc, index) => convertDocumentPickerAssetToFile(doc, index).then(file => { if (file) { // Validate file has required properties before adding if (!file.name || file.size === undefined) { errors.push(`File "${doc.name || 'file'}" is invalid: missing required properties`); return null; } return file; } return null; }).catch(error => { errors.push(`File "${doc.name || 'file'}": ${error.message || 'Failed to process'}`); return null; })); const convertedFiles = await Promise.all(conversionPromises); // Filter out null values for (const file of convertedFiles) { if (file) { files.push(file); } } // Show errors if any if (errors.length > 0) { const errorMessage = errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n...and ${errors.length - 3} more` : ''); toast.error(`Failed to load some files:\n${errorMessage}`); } // Process successfully converted files if (files.length > 0) { await handleFileSelection(files); } else { // Files were selected but none could be converted toast.error('No files could be processed. Please try selecting files again.'); } } catch (error) { if (__DEV__) { console.error('File upload error:', error); } if (error.message?.includes('expo-document-picker') || error.message?.includes('Different document picking in progress')) { if (error.message?.includes('Different document picking in progress')) { toast.error('Please wait for the current file selection to complete'); } else { toast.error('File picker not available. Please install expo-document-picker'); } } else { toast.error(error.message || 'Failed to select files'); } } finally { // Always reset the picking state, even if there was an error setIsPickingDocument(false); } }; const handleFileDelete = async (fileId, filename) => { // Use platform-aware confirmation dialog const confirmed = await confirmAction(`Are you sure you want to delete "${filename}"? This action cannot be undone.`, 'Delete File', 'Delete', 'Cancel'); if (!confirmed) { return; } try { storeSetDeleting(fileId); await oxyServices.deleteFile(fileId); toast.success('File deleted successfully'); // Reload files after successful deletion // Optimistic remove useFileStore.getState().removeFile(fileId); // Silent background reconcile setTimeout(() => loadFiles('silent'), 800); } catch (error) { // Provide specific error messages if (error.message?.includes('File not found') || error.message?.includes('404')) { toast.error('File not found. It may have already been deleted.'); // Still reload files to refresh the list setTimeout(() => loadFiles('silent'), 800); } else if (error.message?.includes('permission') || error.message?.includes('403')) { toast.error('You do not have permission to delete this file.'); } else { toast.error(error.message || 'Failed to delete file'); } } finally { storeSetDeleting(null); } }; const handleBulkDelete = useCallback(async () => { if (selectedIds.size === 0) return; const fileMap = {}; files.forEach(f => { fileMap[f.id] = f; }); const selectedFiles = Array.from(selectedIds).map(id => fileMap[id]).filter(Boolean); const confirmed = await confirmAction(`Are you sure you want to delete ${selectedFiles.length} file(s)? This action cannot be undone.`, 'Delete Files', 'Delete', 'Cancel'); if (!confirmed) return; try { const deletePromises = Array.from(selectedIds).map(async fileId => { try { await oxyServices.deleteFile(fileId); useFileStore.getState().removeFile(fileId); return { success: true, fileId }; } catch (error) { return { success: false, fileId, error }; } }); const results = await Promise.allSettled(deletePromises); const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; const failed = results.length - successful; if (successful > 0) { toast.success(`${successful} file(s) deleted successfully`); } if (failed > 0) { toast.error(`${failed} file(s) failed to delete`); } setSelectedIds(new Set()); setTimeout(() => loadFiles('silent'), 800); } catch (error) { toast.error(error.message || 'Failed to delete files'); } }, [selectedIds, files, oxyServices, loadFiles]); const handleBulkVisibilityChange = useCallback(async visibility => { if (selectedIds.size === 0) return; try { const updatePromises = Array.from(selectedIds).map(async fileId => { try { await oxyServices.assetUpdateVisibility(fileId, visibility); return { success: true, fileId }; } catch (error) { return { success: false, fileId, error }; } }); const results = await Promise.allSettled(updatePromises); const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; const failed = results.length - successful; if (successful > 0) { toast.success(`${successful} file(s) visibility updated to ${visibility}`); // Update file metadata in store Array.from(selectedIds).forEach(fileId => { useFileStore.getState().updateFile(fileId, { metadata: { ...files.find(f => f.id === fileId)?.metadata, visibility } }); }); } if (failed > 0) { toast.error(`${failed} file(s) failed to update visibility`); } setTimeout(() => loadFiles('silent'), 800); } catch (error) { toast.error(error.message || 'Failed to update visibility'); } }, [selectedIds, oxyServices, files, loadFiles]); // Unified download function - works on all platforms const handleFileDownload = async (fileId, filename) => { try { // Try to use the download URL with a simple approach // On web, this creates a download link. On mobile, it opens the URL. const downloadUrl = oxyServices.getFileDownloadUrl(fileId); // For web platforms, use link download if (typeof window !== 'undefined' && window.document) { try { // Try simple link download first const link = document.createElement('a'); link.href = downloadUrl; link.download = filename; link.target = '_blank'; document.body.appendChild(link); link.click(); document.body.removeChild(link); toast.success('File download started'); } catch (linkError) { // Fallback to authenticated download const blob = await oxyServices.getFileContentAsBlob(fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clean up the blob URL URL.revokeObjectURL(url); toast.success('File downloaded successfully'); } } else { // For mobile, open the URL (user can save from browser) // Note: This is a simplified approach - for full mobile support, // consider using expo-file-system or react-native-fs toast.info('Please use your browser to download the file'); } } catch (error) { toast.error(error.message || 'Failed to download file'); } }; const handleFileOpen = async file => { if (selectMode) { toggleSelect(file); return; } try { setLoadingFileContent(true); setOpenedFile(file); // For text files, images, and other viewable content, try to load the content if (file.contentType.startsWith('text/') || file.contentType.includes('json') || file.contentType.includes('xml') || file.contentType.includes('javascript') || file.contentType.includes('typescript') || file.contentType.startsWith('image/') || file.contentType.includes('pdf') || file.contentType.startsWith('video/') || file.contentType.startsWith('audio/')) { try { if (file.contentType.startsWith('image/') || file.contentType.includes('pdf') || file.contentType.startsWith('video/') || file.contentType.startsWith('audio/')) { // For images, PDFs, videos, and audio, we'll use the URL directly const downloadUrl = oxyServices.getFileDownloadUrl(file.id); setFileContent(downloadUrl); } else { // For text files, get the content using authenticated request const content = await oxyServices.getFileContentAsText(file.id); setFileContent(content); } } catch (error) { if (error.message?.includes('404') || error.message?.includes('not found')) { toast.error('File not found. It may have been deleted.'); } else { toast.error('Failed to load file content'); } setFileContent(null); } } else { // For non-viewable files, don't load content setFileContent(null); } } catch (error) { toast.error(error.message || 'Failed to open file'); } finally { setLoadingFileContent(false); } }; const handleCloseFile = () => { setOpenedFile(null); setFileContent(null); setShowFileDetailsInViewer(false); // Don't reset view mode when closing a file }; const showFileDetailsModal = file => { setSelectedFile(file); setShowFileDetails(true); }; const renderSimplePhotoItem = useCallback((photo, index) => { const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb'); // Calculate photo item width based on actual container size from bottom sheet let itemsPerRow = 3; // Default for mobile if (safeContainerWidth > 768) itemsPerRow = 4; // Desktop/tablet else if (safeContainerWidth > 480) itemsPerRow = 3; // Large mobile // Account for the photoScrollContainer padding (16px on each side = 32px total) const scrollContainerPadding = 32; // Total horizontal padding from photoScrollContainer const gaps = (itemsPerRow - 1) * 4; // Gap between items (4px) const availableWidth = safeContainerWidth - scrollContainerPadding; const itemWidth = (availableWidth - gaps) / itemsPerRow; return /*#__PURE__*/_jsx(TouchableOpacity, { style: [fileManagementStyles.simplePhotoItem, { width: itemWidth, height: itemWidth, marginRight: (index + 1) % itemsPerRow === 0 ? 0 : 4, ...(selectMode && selectedIds.has(photo.id) ? { borderWidth: 2, borderColor: themeStyles.primaryColor } : {}) }], onPress: () => handleFileOpen(photo), activeOpacity: 0.8, children: /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.simplePhotoContainer, children: [/*#__PURE__*/_jsx(ExpoImage, { source: { uri: downloadUrl }, style: fileManagementStyles.simplePhotoImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Photo failed to load, will show placeholder }, accessibilityLabel: photo.filename }), selectMode && /*#__PURE__*/_jsx(View, { style: fileManagementStyles.selectionBadge, children: /*#__PURE__*/_jsx(Ionicons, { name: selectedIds.has(photo.id) ? 'checkmark-circle' : 'ellipse-outline', size: 20, color: selectedIds.has(photo.id) ? themeStyles.primaryColor : themeStyles.textColor }) })] }) }, photo.id); }, [oxyServices, safeContainerWidth, selectMode, selectedIds, themeStyles.primaryColor, themeStyles.textColor]); const renderJustifiedPhotoItem = useCallback((photo, width, height, isLast) => { const downloadUrl = getSafeDownloadUrlCallback(photo, 'thumb'); return /*#__PURE__*/_jsx(TouchableOpacity, { style: [fileManagementStyles.justifiedPhotoItem, { width, height, ...(selectMode && selectedIds.has(photo.id) ? { borderWidth: 2, borderColor: themeStyles.primaryColor } : {}), ...(selectMode && multiSelect && selectedIds.size > 0 && !selectedIds.has(photo.id) ? { opacity: 0.4 } : {}) }], onPress: () => handleFileOpen(photo), activeOpacity: 0.8, children: /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.justifiedPhotoContainer, children: [/*#__PURE__*/_jsx(ExpoImage, { source: { uri: downloadUrl }, style: fileManagementStyles.justifiedPhotoImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Photo failed to load, will show placeholder }, accessibilityLabel: photo.filename }), selectMode && /*#__PURE__*/_jsx(View, { style: fileManagementStyles.selectionBadge, children: /*#__PURE__*/_jsx(Ionicons, { name: selectedIds.has(photo.id) ? 'checkmark-circle' : 'ellipse-outline', size: 20, color: selectedIds.has(photo.id) ? themeStyles.primaryColor : themeStyles.textColor }) })] }) }, photo.id); }, [oxyServices, selectMode, selectedIds, multiSelect, themeStyles.primaryColor, themeStyles.textColor]); // Run initial load once per targetUserId change to avoid accidental loops const lastLoadedFor = useRef(undefined); useEffect(() => { const key = targetUserId || 'anonymous'; if (lastLoadedFor.current !== key) { lastLoadedFor.current = key; loadFiles('initial'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetUserId]); const renderFileItem = file => { const isImage = file.contentType.startsWith('image/'); const isPDF = file.contentType.includes('pdf'); const isVideo = file.contentType.startsWith('video/'); const isAudio = file.contentType.startsWith('audio/'); const hasPreview = isImage || isPDF || isVideo; const borderColor = themeStyles.borderColor; return /*#__PURE__*/_jsxs(View, { style: [fileManagementStyles.fileItem, { backgroundColor: themeStyles.secondaryBackgroundColor, borderColor }, selectMode && selectedIds.has(file.id) && { borderColor: themeStyles.primaryColor, borderWidth: 2 }], children: [/*#__PURE__*/_jsxs(TouchableOpacity, { style: fileManagementStyles.fileContent, onPress: () => handleFileOpen(file), children: [/*#__PURE__*/_jsx(View, { style: fileManagementStyles.filePreviewContainer, children: hasPreview ? /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.filePreview, children: [isImage && /*#__PURE__*/_jsx(ExpoImage, { source: { uri: getSafeDownloadUrlCallback(file, 'thumb') }, style: fileManagementStyles.previewImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Image preview failed to load }, accessibilityLabel: file.filename }), isPDF && /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.pdfPreview, children: [/*#__PURE__*/_jsx(Ionicons, { name: "document", size: 32, color: themeStyles.primaryColor }), /*#__PURE__*/_jsx(Text, { style: [fileManagementStyles.pdfLabel, { color: themeStyles.primaryColor }], children: "PDF" })] }), isVideo && /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.videoPreviewWrapper, children: [/*#__PURE__*/_jsx(ExpoImage, { source: { uri: getSafeDownloadUrlCallback(file, 'thumb') }, style: fileManagementStyles.videoPosterImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: _ => { // If thumbnail not available, we still show icon overlay }, accessibilityLabel: file.filename + ' video thumbnail' }), /*#__PURE__*/_jsx(View, { style: fileManagementStyles.videoOverlay, children: /*#__PURE__*/_jsx(Ionicons, { name: "play", size: 24, color: "#FFFFFF" }) })] }), /*#__PURE__*/_jsx(View, { style: [fileManagementStyles.fallbackIcon, { display: isImage ? 'none' : 'flex' }], children: /*#__PURE__*/_jsx(Ionicons, { name: getFileIcon(file.contentType), size: 32, color: themeStyles.primaryColor }) }), selectMode && /*#__PURE__*/_jsx(View, { style: fileManagementStyles.selectionBadge, children: /*#__PURE__*/_jsx(Ionicons, { name: selectedIds.has(file.id) ? 'checkmark-circle' : 'ellipse-outline', size: 22, color: selectedIds.has(file.id) ? themeStyles.primaryColor : themeStyles.textColor }) })] }) : /*#__PURE__*/_jsx(View, { style: fileManagementStyles.fileIconContainer, children: /*#__PURE__*/_jsx(Ionicons, { name: getFileIcon(file.contentType), size: 32, color: themeStyles.primaryColor }) }) }), /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.fileInfo, children: [/*#__PURE__*/_jsx(Text, { style: [fileManagementStyles.fileName, { color: themeStyles.textColor }], numberOfLines: 1, children: file.filename }), /*#__PURE__*/_jsxs(Text, { style: [fileManagementStyles.fileDetails, { color: themeStyles.isDarkTheme ? '#BBBBBB' : '#666666' }], children: [formatFileSize(file.length), " \u2022 ", new Date(file.uploadDate).toLocaleDateString()] }), file.metadata?.description && /*#__PURE__*/_jsx(Text, { style: [fileManagementStyles.fileDescription, { color: themeStyles.isDarkTheme ? '#AAAAAA' : '#888888' }], numberOfLines: 2, children: file.metadata.description })] })] }), !selectMode && /*#__PURE__*/_jsxs(View, { style: fileManagementStyles.fileActions, children: [hasPreview && /*#__PURE__*/_jsx(TouchableOpacity, { style: [fileManagementStyles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0' }], onPress: () => handleFileOpen(file), children: /*#__PURE__*/_jsx(Ionicons, { name: "eye", size: 20, color: themeStyles.primaryColor }) }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [fileManagementStyles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0' }], onPress: () => handleFileDownload(file.id, file.filename), children: /*#__PURE__*/_jsx(Ionicons, { name: "download", size: 20, color: themeStyles.primaryColor }) }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [fileManagementStyles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#400000' : '#FFEBEE' }], onPress: () => { handleFileDelete(file.id, file.filenam