@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
JavaScript
"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