@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
1,308 lines (1,274 loc) • 121 kB
JavaScript
"use strict";
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, Platform, RefreshControl, Modal, TextInput, Image,
// kept for Image.getSize only
Animated, Easing, Alert } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { useOxy } from '../context/OxyContext';
import { fontFamilies } from '../styles/fonts';
import { toast } from '../../lib/sonner';
import { Ionicons } from '@expo/vector-icons';
import { useFileStore, useFiles, useUploading as useUploadingStore, useUploadAggregateProgress, useDeleting as useDeletingStore } from '../stores/fileStore';
import Header from '../components/Header';
import JustifiedPhotoGrid from '../components/photogrid/JustifiedPhotoGrid';
import { GroupedSection } from '../components';
// Exporting props & callback types so external callers (e.g. showBottomSheet config objects) can annotate
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
// 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
} = useOxy();
const files = useFiles();
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 [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 [isDragging, setIsDragging] = useState(false);
const [photoDimensions, setPhotoDimensions] = useState({});
const [loadingDimensions, setLoadingDimensions] = useState(false);
const [hoveredPreview, setHoveredPreview] = useState(null);
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); // Ref for drag and drop container
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.
// Prevents backend warnings: "Variant thumb not supported for mime application/pdf".
const getSafeDownloadUrl = 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 = 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 = 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
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 = getSafeDownloadUrl(photo, 'thumb');
if (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 => {
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 = 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: []
};
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
useFileStore.getState().removeFile(optimisticId);
useFileStore.getState().addFile(merged, {
prepend: true
});
} else {
// Fallback: will reconcile on later list refresh
useFileStore.getState().updateFile(optimisticId, {
metadata: {
uploading: false
}
});
}
successCount++;
} catch (error) {
failureCount++;
errors.push(`${selectedFiles[i].name}: ${error.message || 'Upload failed'}`);
}
}
if (successCount > 0) {
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...' : '') : ''}`;
toast.error(errorMessage);
}
// 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);
}
};
const handleFileSelection = 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) {
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) {
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 (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 = 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 import('expo-document-picker').catch(() => null);
if (!DocumentPicker) {
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')) {
toast.error('File picker not available. Please install expo-document-picker');
} else {
toast.error(error.message || 'Failed to select files');
}
}
}
} catch (error) {
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);
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 = 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);
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]);
// Global drag listeners (web) - attach to document for reliable drag and drop
useEffect(() => {
if (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) {
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 (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);
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);
toast.success('File downloaded successfully');
}
} else {
toast.info('File download not implemented for mobile yet');
}
} catch (error) {
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')) {
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 = 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__*/_jsx(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__*/_jsxs(View, {
style: styles.simplePhotoContainer,
children: [/*#__PURE__*/_jsx(ExpoImage, {
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__*/_jsx(View, {
style: styles.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, containerWidth, selectMode, selectedIds, themeStyles.primaryColor, themeStyles.textColor]);
const renderJustifiedPhotoItem = useCallback((photo, width, height, isLast) => {
const downloadUrl = getSafeDownloadUrl(photo, 'thumb');
return /*#__PURE__*/_jsx(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__*/_jsxs(View, {
style: styles.justifiedPhotoContainer,
children: [/*#__PURE__*/_jsx(ExpoImage, {
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__*/_jsx(View, {
style: styles.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: [styles.fileItem, {
backgroundColor: themeStyles.secondaryBackgroundColor,
borderColor
}, selectMode && selectedIds.has(file.id) && {
borderColor: themeStyles.primaryColor,
borderWidth: 2
}],
children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
style: styles.fileContent,
onPress: () => handleFileOpen(file),
children: [/*#__PURE__*/_jsx(View, {
style: styles.filePreviewContainer,
children: hasPreview ? /*#__PURE__*/_jsxs(View, {
style: styles.filePreview,
...(Platform.OS === 'web' && {
onMouseEnter: () => setHoveredPreview(file.id),
onMouseLeave: () => setHoveredPreview(null)
}),
children: [isImage && /*#__PURE__*/_jsx(ExpoImage, {
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__*/_jsxs(View, {
style: styles.pdfPreview,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "document",
size: 32,
color: themeStyles.primaryColor
}), /*#__PURE__*/_jsx(Text, {
style: [styles.pdfLabel, {
color: themeStyles.primaryColor
}],
children: "PDF"
})]
}), isVideo && /*#__PURE__*/_jsxs(View, {
style: styles.videoPreviewWrapper,
children: [/*#__PURE__*/_jsx(ExpoImage, {
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__*/_jsx(View, {
style: styles.videoOverlay,
children: /*#__PURE__*/_jsx(Ionicons, {
name: "play",
size: 24,
color: "#FFFFFF"
})
})]
}), /*#__PURE__*/_jsx(View, {
style: [styles.fallbackIcon, {
display: isImage ? 'none' : 'flex'
}],
...(Platform.OS === 'web' && {
'data-fallback': 'true'
}),
children: /*#__PURE__*/_jsx(Ionicons, {
name: getFileIcon(file.contentType),
size: 32,
color: themeStyles.primaryColor
})
}), !selectMode && Platform.OS === 'web' && hoveredPreview === file.id && isImage && /*#__PURE__*/_jsx(View, {
style: styles.previewOverlay,
children: /*#__PURE__*/_jsx(Ionicons, {
name: "eye",
size: 24,
color: "#FFFFFF"
})
}), selectMode && /*#__PURE__*/_jsx(View, {
style: styles.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: styles.fileIconContainer,
children: /*#__PURE__*/_jsx(Ionicons, {
name: getFileIcon(file.contentType),
size: 32,
color: themeStyles.primaryColor
})
})
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileInfo,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.fileName, {
color: themeStyles.textColor
}],
numberOfLines: 1,
children: file.filename
}), /*#__PURE__*/_jsxs(Text, {
style: [styles.fileDetails, {
color: themeStyles.isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: [formatFileSize(file.length), " \u2022 ", new Date(file.uploadDate).toLocaleDateString()]
}), file.metadata?.description && /*#__PURE__*/_jsx(Text, {
style: [styles.fileDescription, {
color: themeStyles.isDarkTheme ? '#AAAAAA' : '#888888'
}],
numberOfLines: 2,
children: file.metadata.description
})]
})]
}), !selectMode && /*#__PURE__*/_jsxs(View, {
style: styles.fileActions,
children: [hasPreview && /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileOpen(file),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "eye",
size: 20,
color: themeStyles.primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.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: [styles.actionButton, {
backgroundColor: themeStyles.isDarkTheme ? '#400000' : '#FFEBEE'
}],
onPress: () => {
handleFileDelete(file.id, file.filename);
},
disabled: deleting === file.id,
children: deleting === file.id ? /*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: themeStyles.dangerColor
}) : /*#__PURE__*/_jsx(Ionicons, {
name: "trash",
size: 20,
color: themeStyles.dangerColor
})
})]
})]
}, file.id);
};
// GroupedSection-based file items (for 'all' view) replacing legacy flat list look
const groupedFileItems = useMemo(() => {
// filteredFiles is already sorted, so just use it directly
const sortedFiles = filteredFiles;
// Store file positions for scrolling
sortedFiles.forEach((file, index) => {
itemRefs.current.set(file.id, index);
});
return sortedFiles.map(file => {
const isImage = file.contentType.startsWith('image/');
const isVideo = file.contentType.startsWith('video/');
const hasPreview = isImage || isVideo;
const previewUrl = hasPreview ? isVideo ? getSafeDownloadUrl(file, 'poster') : getSafeDownloadUrl(file, 'thumb') : undefined;
const isSelected = selectedIds.has(file.id);
return {
id: file.id,
image: previewUrl,
imageSize: 44,
icon: !previewUrl ? getFileIcon(file.contentType) : undefined,
iconColor: themeStyles.primaryColor,
title: file.filename,
subtitle: `${formatFileSize(file.length)} • ${new Date(file.uploadDate).toLocaleDateString()}`,
theme: theme,
onPress: () => {
// Support selection in regular mode with long press or if already selecting
if (!selectMode && selectedIds.size > 0) {
// If already in selection mode (some files selected), toggle selection
toggleSelect(file);
} else {
handleFileOpen(file);
}
},
onLongPress: !selectMode ? () => {
// Enable selection mode on long press
if (selectedIds.size === 0) {
setSelectedIds(new Set([file.id]));
} else {
toggleSelect(file);
}
} : undefined,
showChevron: false,
dense: true,
multiRow: !!file.metadata?.description,
selected: (selectMode || selectedIds.size > 0) && isSelected,
// Hide action buttons when selecting (in selectMode or bulk operations mode)
customContent: !selectMode && selectedIds.size === 0 ? /*#__PURE__*/_jsxs(View, {
style: styles.groupedActions,
children: [(isImage || isVideo || file.contentType.includes('pdf')) && /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.groupedActionBtn, {
backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileOpen(file),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "eye",
size: 18,
color: themeStyles.primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.groupedActionBtn, {
backgroundColor: themeStyles.isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileDownload(file.id, file.filename),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "download",
size: 18,
color: themeStyles.primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacit