@oxyhq/services
Version:
1,299 lines (1,254 loc) • 90.6 kB
JavaScript
"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