UNPKG

@oxyhq/services

Version:

Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀

1,244 lines (1,212 loc) • 131 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _react = _interopRequireWildcard(require("react")); var _reactNative = require("react-native"); var _expoImage = require("expo-image"); var _OxyContext = require("../context/OxyContext"); var _fonts = require("../styles/fonts"); var _sonner = require("../../lib/sonner"); var _vectorIcons = require("@expo/vector-icons"); var _fileStore = require("../stores/fileStore"); var _Header = _interopRequireDefault(require("../components/Header")); var _JustifiedPhotoGrid = _interopRequireDefault(require("../components/photogrid/JustifiedPhotoGrid")); var _components = require("../components"); var _jsxRuntime = require("react/jsx-runtime"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } // Exporting props & callback types so external callers (e.g. showBottomSheet config objects) can annotate // Add this helper function near the top (after imports): async function uploadFileRaw(file, userId, oxyServices, visibility) { return await oxyServices.uploadRawFile(file, visibility); } 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 }) => { const { user, oxyServices } = (0, _OxyContext.useOxy)(); const files = (0, _fileStore.useFiles)(); const uploading = (0, _fileStore.useUploading)(); const uploadProgress = (0, _fileStore.useUploadAggregateProgress)(); const deleting = (0, _fileStore.useDeleting)(); const [loading, setLoading] = (0, _react.useState)(true); const [refreshing, setRefreshing] = (0, _react.useState)(false); const [paging, setPaging] = (0, _react.useState)({ offset: 0, limit: 40, total: 0, hasMore: true, loadingMore: false }); const [selectedFile, setSelectedFile] = (0, _react.useState)(null); const [showFileDetails, setShowFileDetails] = (0, _react.useState)(false); // In selectMode we never open the detailed viewer const [openedFile, setOpenedFile] = (0, _react.useState)(null); const [fileContent, setFileContent] = (0, _react.useState)(null); const [loadingFileContent, setLoadingFileContent] = (0, _react.useState)(false); const [showFileDetailsInViewer, setShowFileDetailsInViewer] = (0, _react.useState)(false); const [viewMode, setViewMode] = (0, _react.useState)('all'); const [searchQuery, setSearchQuery] = (0, _react.useState)(''); const [sortBy, setSortBy] = (0, _react.useState)('date'); const [sortOrder, setSortOrder] = (0, _react.useState)('desc'); const [pendingFiles, setPendingFiles] = (0, _react.useState)([]); const [showUploadPreview, setShowUploadPreview] = (0, _react.useState)(false); // Derived filtered and sorted files (avoid setState loops) const filteredFiles = (0, _react.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 [isDragging, setIsDragging] = (0, _react.useState)(false); const [photoDimensions, setPhotoDimensions] = (0, _react.useState)({}); const [loadingDimensions, setLoadingDimensions] = (0, _react.useState)(false); const [hoveredPreview, setHoveredPreview] = (0, _react.useState)(null); const uploadStartRef = (0, _react.useRef)(null); const MIN_BANNER_MS = 600; // Selection state const [selectedIds, setSelectedIds] = (0, _react.useState)(new Set(initialSelectedIds)); const [lastSelectedFileId, setLastSelectedFileId] = (0, _react.useState)(null); const scrollViewRef = (0, _react.useRef)(null); const photoScrollViewRef = (0, _react.useRef)(null); const itemRefs = (0, _react.useRef)(new Map()); // Track item positions const containerRef = (0, _react.useRef)(null); // Ref for drag and drop container (0, _react.useEffect)(() => { if (initialSelectedIds && initialSelectedIds.length) { setSelectedIds(new Set(initialSelectedIds)); } }, [initialSelectedIds]); const toggleSelect = (0, _react.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) { _sonner.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) { _sonner.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 = (0, _react.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 = (0, _react.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(() => { _fileStore.useFileStore.getState().setUploading(false); uploadStartRef.current = null; }, remaining); }, []); // Helper to safely request a thumbnail variant only for image mime types. // Prevents backend warnings: "Variant thumb not supported for mime application/pdf". const getSafeDownloadUrl = (0, _react.useCallback)((file, variant = 'thumb') => { const isImage = file.contentType.startsWith('image/'); const isVideo = file.contentType.startsWith('video/'); // Prefer explicit variant key if variants metadata present if (file.variants && file.variants.length > 0) { // For videos, try 'poster' regardless of requested variant if (isVideo) { const poster = file.variants.find(v => v.type === 'poster'); if (poster) return oxyServices.getFileDownloadUrl(file.id, 'poster'); } if (isImage) { const desired = file.variants.find(v => v.type === variant); if (desired) return oxyServices.getFileDownloadUrl(file.id, variant); } } if (isImage) { return oxyServices.getFileDownloadUrl(file.id, variant); } if (isVideo) { // Fallback to poster if backend supports implicit generation try { return oxyServices.getFileDownloadUrl(file.id, 'poster'); } catch { return oxyServices.getFileDownloadUrl(file.id); } } // Other mime types: no variant return oxyServices.getFileDownloadUrl(file.id); }, [oxyServices]); // Memoize theme-related calculations to prevent unnecessary recalculations const themeStyles = (0, _react.useMemo)(() => { const isDarkTheme = theme === 'dark'; return { isDarkTheme, textColor: isDarkTheme ? '#FFFFFF' : '#000000', backgroundColor: isDarkTheme ? '#121212' : '#f2f2f2', secondaryBackgroundColor: isDarkTheme ? '#222222' : '#FFFFFF', borderColor: isDarkTheme ? '#444444' : '#E0E0E0', primaryColor: '#007AFF', dangerColor: '#FF3B30', successColor: '#34C759' }; }, [theme]); // Extract commonly used theme variables const backgroundColor = themeStyles.backgroundColor; const borderColor = themeStyles.borderColor; const targetUserId = userId || user?.id; const storeSetUploading = (0, _fileStore.useFileStore)(s => s.setUploading); const storeSetUploadProgress = (0, _fileStore.useFileStore)(s => s.setUploadProgress); const storeSetDeleting = (0, _fileStore.useFileStore)(s => s.setDeleting); const loadFiles = (0, _react.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 _fileStore.useFileStore.getState().setFiles(assets, { merge: true }); setPaging(p => ({ ...p, offset: effectiveOffset, total: response.total || effectiveOffset + assets.length, hasMore: response.hasMore, loadingMore: false })); } else { _fileStore.useFileStore.getState().setFiles(assets, { merge: false }); setPaging(p => ({ ...p, offset: 0, total: response.total || assets.length, hasMore: response.hasMore, loadingMore: false })); } } catch (error) { _sonner.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 = (0, _react.useRef)(paging); (0, _react.useEffect)(() => { prevPagingRef.current = paging; }, [paging]); // (removed effect; filteredFiles is memoized) // Load photo dimensions for justified grid const loadPhotoDimensions = (0, _react.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 = getSafeDownloadUrl(photo, 'thumb'); if (_reactNative.Platform.OS === 'web') { const img = new window.Image(); await new Promise((resolve, reject) => { img.onload = () => { newDimensions[photo.id] = { width: img.naturalWidth, height: img.naturalHeight }; hasNewDimensions = true; resolve(); }; img.onerror = () => { // Fallback dimensions for failed loads newDimensions[photo.id] = { width: 1, height: 1 }; hasNewDimensions = true; resolve(); }; img.src = downloadUrl; }); } else { // For mobile, use Image.getSize from react-native await new Promise(resolve => { _reactNative.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); } }, [oxyServices, photoDimensions]); // Create justified rows from photos with responsive algorithm const createJustifiedRows = (0, _react.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 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('\n'); window.alert(`File Size Limit\n\nThe following files are too large (max 50MB):\n${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 }); try { const raw = selectedFiles[i]; const optimisticId = `temp-${Date.now()}-${i}`; 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: [] }; _fileStore.useFileStore.getState().addFile(optimisticFile, { prepend: true }); const result = await uploadFileRaw(raw, targetUserId, oxyServices, defaultVisibility); // Attempt to refresh file list incrementally – fetch single file metadata if API allows if (result?.file || result?.files?.[0]) { const f = result.file || result.files[0]; 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 _fileStore.useFileStore.getState().removeFile(optimisticId); _fileStore.useFileStore.getState().addFile(merged, { prepend: true }); } else { // Fallback: will reconcile on later list refresh _fileStore.useFileStore.getState().updateFile(optimisticId, { metadata: { uploading: false } }); } successCount++; } catch (error) { failureCount++; errors.push(`${selectedFiles[i].name}: ${error.message || 'Upload failed'}`); } } if (successCount > 0) { _sonner.toast.success(`${successCount} file(s) uploaded successfully`); } if (failureCount > 0) { const errorMessage = `${failureCount} file(s) failed to upload${errors.length > 0 ? ':\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? '\n...' : '') : ''}`; _sonner.toast.error(errorMessage); } // Silent background refresh to ensure metadata/variants updated setTimeout(() => { loadFiles('silent'); }, 1200); } catch (error) { _sonner.toast.error(error.message || 'Failed to upload files'); } finally { storeSetUploadProgress(null); } }; const handleFileSelection = (0, _react.useCallback)(async selectedFiles => { const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB const processedFiles = []; for (const file of selectedFiles) { // Validate file size if (file.size > MAX_FILE_SIZE) { _sonner.toast.error(`"${file.name}" is too large. Maximum file size is ${formatFileSize(MAX_FILE_SIZE)}`); continue; } // Generate preview for images let preview; if (file.type.startsWith('image/')) { preview = URL.createObjectURL(file); } processedFiles.push({ file, preview, size: file.size, name: file.name, type: file.type }); } if (processedFiles.length === 0) 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 }); await processFileUploads(filesToUpload); // Cleanup preview URLs pendingFiles.forEach(pf => { if (pf.preview) { URL.revokeObjectURL(pf.preview); } }); setPendingFiles([]); endUpload(); } catch (error) { _sonner.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); } }; const handleFileUpload = async () => { try { if (_reactNative.Platform.OS === 'web') { // Enhanced web file picker const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.accept = '*/*'; const cancellationTimer = setTimeout(() => { const state = _fileStore.useFileStore.getState(); if (state.uploading && uploadStartRef.current && !state.uploadProgress) { endUpload(); } }, 1500); input.onchange = async e => { clearTimeout(cancellationTimer); const selectedFiles = Array.from(e.target.files || []); if (selectedFiles.length === 0) { endUpload(); return; } await handleFileSelection(selectedFiles); }; input.click(); } else { // Mobile file picker with expo-document-picker try { // Dynamically import to avoid breaking if not installed const DocumentPicker = await Promise.resolve().then(() => _interopRequireWildcard(require('expo-document-picker'))).catch(() => null); if (!DocumentPicker) { _sonner.toast.error('File picker not available. Please install expo-document-picker'); return; } const result = await DocumentPicker.getDocumentAsync({ type: '*/*', multiple: true, copyToCacheDirectory: true }); if (result.canceled) { return; } // Convert expo document picker results to File-like objects const files = []; for (const doc of result.assets) { if (doc.file) { // expo-document-picker provides a File-like object files.push(doc.file); } else if (doc.uri) { // Fallback: fetch and create Blob const response = await fetch(doc.uri); const blob = await response.blob(); const file = new File([blob], doc.name || 'file', { type: doc.mimeType || 'application/octet-stream' }); files.push(file); } } if (files.length > 0) { await handleFileSelection(files); } } catch (error) { if (error.message?.includes('expo-document-picker')) { _sonner.toast.error('File picker not available. Please install expo-document-picker'); } else { _sonner.toast.error(error.message || 'Failed to select files'); } } } } catch (error) { _sonner.toast.error(error.message || 'Failed to open file picker'); } }; const handleFileDelete = async (fileId, filename) => { // Use web-compatible confirmation dialog const confirmed = window.confirm(`Are you sure you want to delete "${filename}"? This action cannot be undone.`); if (!confirmed) { return; } try { storeSetDeleting(fileId); await oxyServices.deleteFile(fileId); _sonner.toast.success('File deleted successfully'); // Reload files after successful deletion // Optimistic remove _fileStore.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')) { _sonner.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')) { _sonner.toast.error('You do not have permission to delete this file.'); } else { _sonner.toast.error(error.message || 'Failed to delete file'); } } finally { storeSetDeleting(null); } }; const handleBulkDelete = (0, _react.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 = window.confirm(`Are you sure you want to delete ${selectedFiles.length} file(s)? This action cannot be undone.`); if (!confirmed) return; try { const deletePromises = Array.from(selectedIds).map(async fileId => { try { await oxyServices.deleteFile(fileId); _fileStore.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) { _sonner.toast.success(`${successful} file(s) deleted successfully`); } if (failed > 0) { _sonner.toast.error(`${failed} file(s) failed to delete`); } setSelectedIds(new Set()); setTimeout(() => loadFiles('silent'), 800); } catch (error) { _sonner.toast.error(error.message || 'Failed to delete files'); } }, [selectedIds, files, oxyServices, loadFiles]); const handleBulkVisibilityChange = (0, _react.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) { _sonner.toast.success(`${successful} file(s) visibility updated to ${visibility}`); // Update file metadata in store Array.from(selectedIds).forEach(fileId => { _fileStore.useFileStore.getState().updateFile(fileId, { metadata: { ...files.find(f => f.id === fileId)?.metadata, visibility } }); }); } if (failed > 0) { _sonner.toast.error(`${failed} file(s) failed to update visibility`); } setTimeout(() => loadFiles('silent'), 800); } catch (error) { _sonner.toast.error(error.message || 'Failed to update visibility'); } }, [selectedIds, oxyServices, files, loadFiles]); // Global drag listeners (web) - attach to document for reliable drag and drop (0, _react.useEffect)(() => { if (_reactNative.Platform.OS !== 'web' || user?.id !== targetUserId) return; let dragCounter = 0; // Track drag enter/leave to handle nested elements const onDragEnter = e => { dragCounter++; if (e?.dataTransfer?.types?.includes('Files')) { e.preventDefault(); e.stopPropagation(); setIsDragging(true); } }; const onDragOver = e => { if (e?.dataTransfer?.types?.includes('Files')) { e.preventDefault(); e.stopPropagation(); // Keep dragging state true while over document setIsDragging(true); } }; const onDrop = async e => { dragCounter = 0; setIsDragging(false); if (e?.dataTransfer?.files?.length) { e.preventDefault(); e.stopPropagation(); try { const files = Array.from(e.dataTransfer.files); if (files.length > 0) { await handleFileSelection(files); } } catch (error) { _sonner.toast.error(error.message || 'Failed to upload files'); } } }; const onDragLeave = e => { dragCounter--; // Only hide drag overlay if we're actually leaving the document (drag counter reaches 0) if (dragCounter === 0) { setIsDragging(false); } }; // Attach to document for global drag detection document.addEventListener('dragenter', onDragEnter, false); document.addEventListener('dragover', onDragOver, false); document.addEventListener('drop', onDrop, false); document.addEventListener('dragleave', onDragLeave, false); return () => { document.removeEventListener('dragenter', onDragEnter, false); document.removeEventListener('dragover', onDragOver, false); document.removeEventListener('drop', onDrop, false); document.removeEventListener('dragleave', onDragLeave, false); }; }, [user?.id, targetUserId, handleFileSelection]); const handleFileDownload = async (fileId, filename) => { try { if (_reactNative.Platform.OS === 'web') { // Use the public download URL method const downloadUrl = oxyServices.getFileDownloadUrl(fileId); try { // Method 1: 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); _sonner.toast.success('File download started'); } catch (linkError) { // Method 2: Fallback to authenticated download const blob = await oxyServices.getFileContentAsBlob(fileId); const url = window.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 window.URL.revokeObjectURL(url); _sonner.toast.success('File downloaded successfully'); } } else { _sonner.toast.info('File download not implemented for mobile yet'); } } catch (error) { _sonner.toast.error(error.message || 'Failed to download file'); } }; const formatFileSize = bytes => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const getFileIcon = contentType => { if (contentType.startsWith('image/')) return 'image'; if (contentType.startsWith('video/')) return 'videocam'; if (contentType.startsWith('audio/')) return 'musical-notes'; if (contentType.includes('pdf')) return 'document-text'; if (contentType.includes('word') || contentType.includes('doc')) return 'document'; if (contentType.includes('excel') || contentType.includes('sheet')) return 'grid'; if (contentType.includes('zip') || contentType.includes('archive')) return 'archive'; return 'document-outline'; }; 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')) { _sonner.toast.error('File not found. It may have been deleted.'); } else { _sonner.toast.error('Failed to load file content'); } setFileContent(null); } } else { // For non-viewable files, don't load content setFileContent(null); } } catch (error) { _sonner.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 = (0, _react.useCallback)((photo, index) => { const downloadUrl = getSafeDownloadUrl(photo, 'thumb'); // Calculate photo item width based on actual container size from bottom sheet let itemsPerRow = 3; // Default for mobile if (containerWidth > 768) itemsPerRow = 4; // Desktop/tablet else if (containerWidth > 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 = containerWidth - scrollContainerPadding; const itemWidth = (availableWidth - gaps) / itemsPerRow; return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.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__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.simplePhotoContainer, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, { source: { uri: downloadUrl }, style: styles.simplePhotoImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Photo failed to load, will show placeholder }, accessibilityLabel: photo.filename }), selectMode && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.selectionBadge, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: selectedIds.has(photo.id) ? 'checkmark-circle' : 'ellipse-outline', size: 20, color: selectedIds.has(photo.id) ? themeStyles.primaryColor : themeStyles.textColor }) })] }) }, photo.id); }, [oxyServices, containerWidth, selectMode, selectedIds, themeStyles.primaryColor, themeStyles.textColor]); const renderJustifiedPhotoItem = (0, _react.useCallback)((photo, width, height, isLast) => { const downloadUrl = getSafeDownloadUrl(photo, 'thumb'); return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.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__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.justifiedPhotoContainer, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, { source: { uri: downloadUrl }, style: styles.justifiedPhotoImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Photo failed to load, will show placeholder }, accessibilityLabel: photo.filename }), selectMode && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.selectionBadge, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.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 = (0, _react.useRef)(undefined); (0, _react.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__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: [styles.fileItem, { backgroundColor: themeStyles.secondaryBackgroundColor, borderColor }, selectMode && selectedIds.has(file.id) && { borderColor: themeStyles.primaryColor, borderWidth: 2 }], children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, { style: styles.fileContent, onPress: () => handleFileOpen(file), children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.filePreviewContainer, children: hasPreview ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.filePreview, ...(_reactNative.Platform.OS === 'web' && { onMouseEnter: () => setHoveredPreview(file.id), onMouseLeave: () => setHoveredPreview(null) }), children: [isImage && /*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, { source: { uri: getSafeDownloadUrl(file, 'thumb') }, style: styles.previewImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: () => { // Image preview failed to load }, accessibilityLabel: file.filename }), isPDF && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.pdfPreview, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "document", size: 32, color: themeStyles.primaryColor }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.pdfLabel, { color: themeStyles.primaryColor }], children: "PDF" })] }), isVideo && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.videoPreviewWrapper, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, { source: { uri: getSafeDownloadUrl(file, 'thumb') }, style: styles.videoPosterImage, contentFit: "cover", transition: 120, cachePolicy: "memory-disk", onError: _ => { // If thumbnail not available, we still show icon overlay }, accessibilityLabel: file.filename + ' video thumbnail' }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.videoOverlay, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "play", size: 24, color: "#FFFFFF" }) })] }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: [styles.fallbackIcon, { display: isImage ? 'none' : 'flex' }], ...(_reactNative.Platform.OS === 'web' && { 'data-fallback': 'true' }), children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: getFileIcon(file.contentType), size: 32, color: themeStyles.primaryColor }) }), !selectMode && _reactNative.Platform.OS === 'web' && hoveredPreview === file.id && isImage && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.previewOverlay, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "eye", size: 24, color: "#FFFFFF" }) }), selectMode && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.selectionBadge, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: selectedIds.has(file.id) ? 'checkmark-circle' : 'ellipse-outline', size: 22, color: selectedIds.has(file.id) ? themeStyles.primaryColor : themeStyles.textColor }) })] }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.fileIconContainer, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: getFileIcon(file.contentType), size: 32, color: themeStyles.primaryColor }) }) }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.fileInfo, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.fileName, { color: themeStyles.textColor }], numberOfLines: 1, children: file.filename }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, { style: [styles.fileDetails, { color: themeStyles.isDarkTheme ? '#BBBBBB' : '#666666' }], children: [formatFileSize(file.length), " \u2022 ", new Date(file.uploadDate).toLocaleDateString()] }), file.metadata?.description && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.fileDescription, { color: themeStyles.isDarkTheme ? '#AAAAAA' : '#888888' }], numberOfLines: 2, children: file.metadata.description })] })] }), !selectMode && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.fileActions, children: [hasPreview && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0' }], onPress: () => handleFileOpen(file), children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "eye", size: 20, color: themeStyles.primaryColor }) }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0' }], onPress: () => handleFileDownload(file.id, file.filename), children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "download", size: 20, color: themeStyles.primaryColor }) }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.actionButton, { backgroundColor: themeStyles.isDarkTheme ? '#400000' : '#FFEBEE' }], onPress: () => { handleFileDelete(file.id, file.filename); }, disabled: deleting === file.id, children: deleting === file.id ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, { size: "small", color: themeStyles.dangerColor }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, { name: "trash", size: 20, color: themeStyles.dangerColor }) })] })] }, file.id); }; // GroupedSection-based file items (for 'all' view) replacing legacy flat list look const groupedFileItems = (0, _react.useMemo)(() => { // filteredFiles is already sorted, so just use it