From 3ea0121e481ec365a30244019894c35de3646ff0 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Fri, 13 Jun 2025 23:44:48 -0500 Subject: [PATCH 1/8] gif support --- Django Files.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Views/Preview/AnimatedImageView.swift | 142 ++++++++++++++++++ Django Files/Views/Preview/Preview.swift | 13 +- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 Django Files/Views/Preview/AnimatedImageView.swift diff --git a/Django Files.xcodeproj/project.pbxproj b/Django Files.xcodeproj/project.pbxproj index ec6cfbc..73d94b8 100644 --- a/Django Files.xcodeproj/project.pbxproj +++ b/Django Files.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 4CE57E8B2D7C9F440073CFC1 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE57E8A2D7C9F440073CFC1 /* SnapshotHelper.swift */; }; A21CC8852DEB967300EF776C /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = A21CC8842DEB967300EF776C /* FirebaseAnalytics */; }; A21CC88B2DEB991100EF776C /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = A21CC88A2DEB991100EF776C /* FirebaseCrashlytics */; }; + A22380612DFD33F000A544A0 /* FLAnimatedImage in Frameworks */ = {isa = PBXBuildFile; productRef = A22380602DFD33F000A544A0 /* FLAnimatedImage */; }; A2DF11D52DDA13FE0096E7C4 /* HighlightSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */; }; /* End PBXBuildFile section */ @@ -135,6 +136,7 @@ A2DF11D52DDA13FE0096E7C4 /* HighlightSwift in Frameworks */, 4C82CB512D62372200C0893B /* HTTPTypes in Frameworks */, A21CC88B2DEB991100EF776C /* FirebaseCrashlytics in Frameworks */, + A22380612DFD33F000A544A0 /* FLAnimatedImage in Frameworks */, A21CC8852DEB967300EF776C /* FirebaseAnalytics in Frameworks */, 4C82CB532D62372200C0893B /* HTTPTypesFoundation in Frameworks */, ); @@ -224,6 +226,7 @@ A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */, A21CC8842DEB967300EF776C /* FirebaseAnalytics */, A21CC88A2DEB991100EF776C /* FirebaseCrashlytics */, + A22380602DFD33F000A544A0 /* FLAnimatedImage */, ); productName = "Django Files"; productReference = 4C5E20EC2D603C3B009EE83A /* Django Files.app */; @@ -339,6 +342,7 @@ 4CE57E892D7C9EB70073CFC1 /* XCRemoteSwiftPackageReference "fastlane" */, A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */, A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + A223805D2DFD335A00A544A0 /* XCRemoteSwiftPackageReference "FLAnimatedImage" */, ); preferredProjectObjectVersion = 77; productRefGroup = 4C5E20ED2D603C3B009EE83A /* Products */; @@ -871,6 +875,14 @@ minimumVersion = 11.13.0; }; }; + A223805D2DFD335A00A544A0 /* XCRemoteSwiftPackageReference "FLAnimatedImage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Flipboard/FLAnimatedImage.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.17; + }; + }; A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/appstefan/highlightswift"; @@ -912,6 +924,11 @@ package = A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCrashlytics; }; + A22380602DFD33F000A544A0 /* FLAnimatedImage */ = { + isa = XCSwiftPackageProductDependency; + package = A223805D2DFD335A00A544A0 /* XCRemoteSwiftPackageReference "FLAnimatedImage" */; + productName = FLAnimatedImage; + }; A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */ = { isa = XCSwiftPackageProductDependency; package = A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */; diff --git a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8dca451..ef872c8 100644 --- a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "95078f967a74c51784635d525bd21732de1f1e8c1af188b95996cd3de41eafca", + "originHash" : "b0a791833a5831c67bbf19b9caedfbc7042e2b6d03c5813c921d8a15aa7bcc17", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -37,6 +37,15 @@ "version" : "11.13.0" } }, + { + "identity" : "flanimatedimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Flipboard/FLAnimatedImage.git", + "state" : { + "revision" : "d4f07b6f164d53c1212c3e54d6460738b1981e9f", + "version" : "1.0.17" + } + }, { "identity" : "googleappmeasurement", "kind" : "remoteSourceControl", diff --git a/Django Files/Views/Preview/AnimatedImageView.swift b/Django Files/Views/Preview/AnimatedImageView.swift new file mode 100644 index 0000000..bc061b0 --- /dev/null +++ b/Django Files/Views/Preview/AnimatedImageView.swift @@ -0,0 +1,142 @@ +import SwiftUI +import FLAnimatedImage + +struct AnimatedImageView: UIViewRepresentable { + let data: Data + + func makeUIView(context: Context) -> FLAnimatedImageView { + let imageView = FLAnimatedImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + return imageView + } + + func updateUIView(_ imageView: FLAnimatedImageView, context: Context) { + if let animatedImage = FLAnimatedImage(animatedGIFData: data) { + imageView.animatedImage = animatedImage + } + } +} + +struct AnimatedImageScrollView: UIViewRepresentable { + let data: Data + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = CustomScrollView() + scrollView.delegate = context.coordinator + + let imageView = FLAnimatedImageView() + if let animatedImage = FLAnimatedImage(animatedGIFData: data) { + imageView.animatedImage = animatedImage + } + imageView.contentMode = .scaleAspectFit + imageView.frame = CGRect(origin: .zero, size: imageView.animatedImage?.size ?? .zero) + scrollView.addSubview(imageView) + + context.coordinator.imageView = imageView + context.coordinator.scrollView = scrollView + + let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTapGesture) + + // Calculate initial zoom scale + if let size = imageView.animatedImage?.size { + let widthScale = UIScreen.main.bounds.width / size.width + let heightScale = UIScreen.main.bounds.height / size.height + let minScale = min(widthScale, heightScale) + + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = 5.0 + + // Set content size to image size + scrollView.contentSize = size + + // Set initial zoom scale + scrollView.zoomScale = minScale + } + + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + if let imageView = context.coordinator.imageView as? FLAnimatedImageView, + let animatedImage = FLAnimatedImage(animatedGIFData: data) { + imageView.animatedImage = animatedImage + context.coordinator.updateZoomScaleForSize(scrollView.bounds.size) + } + } + + class Coordinator: NSObject, UIScrollViewDelegate { + let parent: AnimatedImageScrollView + weak var imageView: FLAnimatedImageView? + weak var scrollView: UIScrollView? + + init(_ parent: AnimatedImageScrollView) { + self.parent = parent + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } + + func updateZoomScaleForSize(_ size: CGSize) { + guard let imageView = imageView, + let animatedImage = imageView.animatedImage, + let scrollView = scrollView, + size.width > 0, + size.height > 0, + animatedImage.size.width > 0, + animatedImage.size.height > 0 else { return } + + let widthScale = size.width / animatedImage.size.width + let heightScale = size.height / animatedImage.size.height + let minScale = min(widthScale, heightScale) + + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = max(minScale * 5, 5.0) + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView = gesture.view as? UIScrollView else { return } + + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let point = gesture.location(in: imageView) + let size = scrollView.bounds.size + let w = size.width / (scrollView.maximumZoomScale / 5) + let h = size.height / (scrollView.maximumZoomScale / 5) + let x = point.x - (w / 2.0) + let y = point.y - (h / 2.0) + let rect = CGRect(x: x, y: y, width: w, height: h) + scrollView.zoom(to: rect, animated: true) + } + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard let imageView = imageView else { return } + + let boundsSize = scrollView.bounds.size + var frameToCenter = imageView.frame + + if frameToCenter.size.width < boundsSize.width { + frameToCenter.origin.x = (boundsSize.width - frameToCenter.size.width) / 2 + } else { + frameToCenter.origin.x = 0 + } + + if frameToCenter.size.height < boundsSize.height { + frameToCenter.origin.y = (boundsSize.height - frameToCenter.size.height) / 2 + } else { + frameToCenter.origin.y = 0 + } + + imageView.frame = frameToCenter + } + } +} diff --git a/Django Files/Views/Preview/Preview.swift b/Django Files/Views/Preview/Preview.swift index 63e4837..c4e9049 100644 --- a/Django Files/Views/Preview/Preview.swift +++ b/Django Files/Views/Preview/Preview.swift @@ -79,9 +79,16 @@ struct ContentPreview: View { // Image Preview private var imagePreview: some View { GeometryReader { geometry in - if let content = content, let uiImage = UIImage(data: content) { - ImageScrollView(image: uiImage) - .frame(width: geometry.size.width, height: geometry.size.height) + if let content = content { + if mimeType == "image/gif" { + AnimatedImageScrollView(data: content) + .frame(width: geometry.size.width, height: geometry.size.height) + } else if let uiImage = UIImage(data: content) { + ImageScrollView(image: uiImage) + .frame(width: geometry.size.width, height: geometry.size.height) + } else { + Text("Unable to load image") + } } else { Text("Unable to load image") } From 1cd22c15a643da70e835987ececa25726be5e112 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 09:58:51 -0500 Subject: [PATCH 2/8] preview deep link functionality --- Django Files/Django_FilesApp.swift | 128 +++++++++++++++++++++++ Django Files/Views/Lists/FileList.swift | 54 ++++++++-- Django Files/Views/Preview/Preview.swift | 105 ++++++++++++++----- Django Files/Views/TabView.swift | 2 + 4 files changed, 254 insertions(+), 35 deletions(-) diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index 7cfabf2..ba911ea 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -11,6 +11,11 @@ import FirebaseCore import FirebaseAnalytics import FirebaseCrashlytics +class PreviewStateManager: ObservableObject { + @Published var deepLinkFile: DFFile? + @Published var showingDeepLinkPreview = false +} + class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { @@ -36,6 +41,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { @main struct Django_FilesApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @StateObject private var previewStateManager = PreviewStateManager() + @State private var showFileInfo = false var sharedModelContainer: ModelContainer = { let schema = Schema([ DjangoFilesSession.self, @@ -132,6 +139,23 @@ struct Django_FilesApp: App { context: sharedModelContainer.mainContext ) } + .fullScreenCover(isPresented: $previewStateManager.showingDeepLinkPreview) { + if let file = previewStateManager.deepLinkFile { + FilePreviewView( + file: .constant(file), + server: .constant(nil), + showingPreview: $previewStateManager.showingDeepLinkPreview, + showFileInfo: $showFileInfo, + fileListDelegate: nil, + allFiles: [file], + currentIndex: 0, + onNavigate: { _ in } + ) + .onDisappear { + previewStateManager.deepLinkFile = nil + } + } + } } .modelContainer(sharedModelContainer) #if os(macOS) @@ -158,12 +182,116 @@ struct Django_FilesApp: App { selectedTab = .settings case "filelist": handleFileListDeepLink(components) + case "preview": + handlePreviewLink(components) default: ToastManager.shared.showToast(message: "Unsupported deep link \(url)") print("Unsupported deep link type: \(components.host ?? "unknown")") } } + private func handlePreviewLink(_ components: URLComponents) { + print("🔍 Handling preview deep link with components: \(components)") + + guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, + let serverURL = URL(string: urlString), + let fileIDString = components.queryItems?.first(where: { $0.name == "file_id" })?.value, + let fileID = Int(fileIDString), + let fileName = components.queryItems?.first(where: { $0.name == "file_name" })?.value?.removingPercentEncoding else { + print("❌ Invalid preview deep link parameters") + return + } + + print("📡 Parsed deep link - Server: \(serverURL), FileID: \(fileID), FileName: \(fileName)") + + // Check if this server exists in user's sessions + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let session = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + // Server exists in user's sessions, handle normally + print("✅ Preview link for known server: \(serverURL.absoluteString)") + + // Check if session is authenticated + if !session.auth { + print("❌ Session is not authenticated") + await MainActor.run { + ToastManager.shared.showToast(message: "Please log in to view this file") + selectedTab = .settings + } + return + } + + // Create API instance with session token + let api = DFAPI(url: serverURL, token: session.token) + + // Get file details to check ownership + if let fileDetails = await api.getFileDetails(fileID: fileID) { + // Check if file belongs to current user + if fileDetails.user != session.userID { + print("❌ File does not belong to current user") + // Handle like unknown server + await MainActor.run { + selectedTab = .files + previewStateManager.deepLinkFile = fileDetails + previewStateManager.showingDeepLinkPreview = true + } + return + } + + // File belongs to current user, navigate to file list + await MainActor.run { + sessionManager.selectedSession = session + selectedTab = .files + // Post notification to update deep link target file ID + NotificationCenter.default.post(name: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil, userInfo: ["fileID": fileID]) + } + } else { + print("❌ Failed to fetch file details") + await MainActor.run { + ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") + } + } + } else { + // Server not in user's sessions, try to fetch file info + print("🔑 Preview link for unknown server: \(serverURL.absoluteString)") + + // Create a temporary API instance without authentication + let api = DFAPI(url: serverURL, token: "") + print("🌐 Created API instance for server: \(serverURL)") + + // Try to fetch file details + print("📥 Attempting to fetch file details for ID: \(fileID)") + if let fileDetails = await api.getFileDetails(fileID: fileID) { + print("✅ Successfully fetched file details: \(fileDetails.name)") + // Successfully got file details, show preview + await MainActor.run { + print("🎯 Setting up preview view") + selectedTab = .files + previewStateManager.deepLinkFile = fileDetails + previewStateManager.showingDeepLinkPreview = true + print("🎯 Preview view setup complete") + } + } else { + print("❌ Failed to fetch file details") + // Failed to get file details (404/403/etc) + await MainActor.run { + ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") + } + } + } + } catch { + print("❌ Error checking for existing sessions: \(error)") + await MainActor.run { + ToastManager.shared.showToast(message: "Error accessing file: \(error.localizedDescription)") + } + } + } + } + private func handleFileListDeepLink(_ components: URLComponents) { guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, let serverURL = URL(string: urlString) else { diff --git a/Django Files/Views/Lists/FileList.swift b/Django Files/Views/Lists/FileList.swift index 96b6087..cbc5f14 100644 --- a/Django Files/Views/Lists/FileList.swift +++ b/Django Files/Views/Lists/FileList.swift @@ -196,6 +196,9 @@ struct FileListView: View { @State private var fileNameText = "" @State private var fileToRename: DFFile? = nil + @State private var showingShareSheet = false + @State private var deepLinkTargetFileID: Int? = nil + @State private var redirectURLs: [String: String] = [:] @State private var showFileInfo: Bool = false @@ -220,6 +223,11 @@ struct FileListView: View { if let currentUserID = server.wrappedValue?.userID { _filterUserID = State(initialValue: currentUserID) } + // Check for deep link target file ID on init + if let targetFileID = UserDefaults.standard.object(forKey: "deepLinkTargetFileID") as? Int { + _deepLinkTargetFileID = State(initialValue: targetFileID) + UserDefaults.standard.removeObject(forKey: "deepLinkTargetFileID") + } } private var files: [DFFile] { @@ -243,6 +251,26 @@ struct FileListView: View { return components?.url ?? URL(string: server.wrappedValue!.url)! } + private func checkForDeepLinkTarget() { + if let targetFileID = deepLinkTargetFileID, + let index = files.firstIndex(where: { $0.id == targetFileID }) { + selectedFile = files[index] + showingPreview = true + deepLinkTargetFileID = nil + } + } + + private func loadFiles() { + if (files.count > 0) { return } + isLoading = true + errorMessage = nil + currentPage = 1 + Task { + await fetchFiles(page: currentPage) + checkForDeepLinkTarget() + } + } + var body: some View { List { if files.count == 0 && !isLoading { @@ -488,6 +516,22 @@ struct FileListView: View { ) .onAppear { loadFiles() + checkForDeepLinkTarget() + // Add observer for deep link target file ID + NotificationCenter.default.addObserver(forName: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil, queue: .main) { notification in + if let fileID = notification.userInfo?["fileID"] as? Int { + deepLinkTargetFileID = fileID + } + } + } + .onDisappear { + // Remove observer when view disappears + NotificationCenter.default.removeObserver(self, name: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil) + } + .onChange(of: deepLinkTargetFileID) { _, newValue in + if newValue != nil { + checkForDeepLinkTarget() + } } } @@ -636,16 +680,6 @@ struct FileListView: View { ) } - private func loadFiles() { - if (files.count > 0) { return } - isLoading = true - errorMessage = nil - currentPage = 1 - Task { - await fetchFiles(page: currentPage) - } - } - private func loadNextPage() { guard hasNextPage else { return } guard !isLoading else { return } // Prevent multiple simultaneous loading requests diff --git a/Django Files/Views/Preview/Preview.swift b/Django Files/Views/Preview/Preview.swift index c4e9049..333e4eb 100644 --- a/Django Files/Views/Preview/Preview.swift +++ b/Django Files/Views/Preview/Preview.swift @@ -23,6 +23,11 @@ struct ContentPreview: View { Group { if isLoading { ProgressView() + .onAppear { + print("🔄 ContentPreview: Loading view appeared") + print("📄 ContentPreview: MIME type: \(mimeType)") + print("🔗 ContentPreview: File URL: \(fileURL)") + } } else if let error = error { VStack { Image(systemName: "exclamationmark.triangle") @@ -31,16 +36,24 @@ struct ContentPreview: View { .multilineTextAlignment(.center) .padding() } + .onAppear { + print("❌ ContentPreview: Error view appeared - \(error.localizedDescription)") + } } else { contentView + .onAppear { + print("✅ ContentPreview: Content view appeared") + } } } .onAppear { + print("📱 ContentPreview: View appeared - URL: \(fileURL)") loadContent() loadFileDetails() isPreviewing = true } .onDisappear { + print("👋 ContentPreview: View disappeared") isPreviewing = false } } @@ -134,39 +147,60 @@ struct ContentPreview: View { // Load content from URL private func loadContent() { + print("📥 ContentPreview: Starting content load") isLoading = true // For video, audio, and PDF, we don't need to download the content as we'll use the URL directly if mimeType.starts(with: "video/") || mimeType.starts(with: "audio/") || mimeType == "application/pdf" { + print("🎥 ContentPreview: Using direct URL for media/PDF") isLoading = false return } + print("📥 ContentPreview: Downloading content from URL") URLSession.shared.dataTask(with: fileURL) { data, response, error in DispatchQueue.main.async { self.isLoading = false if let error = error { + print("❌ ContentPreview: Download error - \(error.localizedDescription)") self.error = error return } - self.content = data + if let httpResponse = response as? HTTPURLResponse { + print("📡 ContentPreview: HTTP Response - \(httpResponse.statusCode)") + } + + if let data = data { + print("✅ ContentPreview: Successfully downloaded \(data.count) bytes") + self.content = data + } else { + print("❌ ContentPreview: No data received") + } } }.resume() } private func loadFileDetails() { - guard let serverURL = URL(string: file.url)?.host else { return } + print("📋 ContentPreview: Loading file details") + guard let serverURL = URL(string: file.url)?.host else { + print("❌ ContentPreview: Could not extract server URL from file URL") + return + } let baseURL = URL(string: "https://\(serverURL)")! let api = DFAPI(url: baseURL, token: "") Task { + print("🌐 ContentPreview: Fetching file details from API") if let details = await api.getFileDetails(fileID: file.id) { + print("✅ ContentPreview: Successfully fetched file details") await MainActor.run { self.fileDetails = details self.selectedFileDetails = details } + } else { + print("❌ ContentPreview: Failed to fetch file details") } } } @@ -271,6 +305,17 @@ struct FilePreviewView: View { let currentIndex: Int let onNavigate: (Int) -> Void + init(file: Binding, server: Binding, showingPreview: Binding, showFileInfo: Binding, fileListDelegate: FileListDelegate?, allFiles: [DFFile], currentIndex: Int, onNavigate: @escaping (Int) -> Void) { + self._file = file + self.server = server + self._showingPreview = showingPreview + self._showFileInfo = showFileInfo + self.fileListDelegate = fileListDelegate + self.allFiles = allFiles + self.currentIndex = currentIndex + self.onNavigate = onNavigate + } + @State private var redirectURLs: [String: String] = [:] @State private var dragOffset = CGSize.zero @GestureState private var dragState = DragState.inactive @@ -294,6 +339,10 @@ struct FilePreviewView: View { @State private var showingShareSheet = false + private var isDeepLinkPreview: Bool { + fileListDelegate == nil + } + private enum DragState { case inactive case dragging(translation: CGSize) @@ -406,19 +455,21 @@ struct FilePreviewView: View { .foregroundColor(file.mime.starts(with: "text") ? .primary : .white) .shadow(color: .black, radius: file.mime.starts(with: "text") ? 0 : 3) Spacer() - Menu { - fileContextMenu(for: file, isPreviewing: true, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) - .padding() - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 20)) - .padding() + if !isDeepLinkPreview { + Menu { + fileContextMenu(for: file, isPreviewing: true, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) + .padding() + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 20)) + .padding() + } + .menuStyle(.button) + .background(.ultraThinMaterial) + .frame(width: 32, height: 32) + .cornerRadius(16) + .padding(.trailing, 10) } - .menuStyle(.button) - .background(.ultraThinMaterial) - .frame(width: 32, height: 32) - .cornerRadius(16) - .padding(.trailing, 10) } .padding(.vertical, 8) .frame(maxWidth: .infinity) @@ -535,15 +586,19 @@ struct FilePreviewView: View { } } - @MainActor - private func loadRedirectURL(for file: DFFile) async { - await preloadFiles() - } - @MainActor private func loadSingleFileRedirect(_ file: DFFile) async { - guard redirectURLs[file.raw] == nil, - let serverURL = URL(string: file.url)?.host else { + guard redirectURLs[file.raw] == nil else { + return + } + + // For deep link previews without a server session, use the raw URL directly + if server.wrappedValue == nil { + redirectURLs[file.raw] = file.raw + return + } + + guard let serverURL = URL(string: file.url)?.host else { return } @@ -639,7 +694,7 @@ struct FilePreviewView: View { onCopyRawLink: { if redirectURLs[file.raw] == nil { Task { - await loadRedirectURL(for: file) + await loadSingleFileRedirect(file) // Only copy the URL after we've loaded the redirect if let redirectURL = redirectURLs[file.raw] { await MainActor.run { @@ -661,7 +716,7 @@ struct FilePreviewView: View { if let url = URL(string: file.raw), UIApplication.shared.canOpenURL(url) { if redirectURLs[file.raw] == nil { Task { - await loadRedirectURL(for: file) + await loadSingleFileRedirect(file) // Only open the URL after we've loaded the redirect if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { await MainActor.run { @@ -716,7 +771,7 @@ struct FilePreviewView: View { onCopyRawLink: { if redirectURLs[file.raw] == nil { Task { - await loadRedirectURL(for: file) + await loadSingleFileRedirect(file) // Only copy the URL after we've loaded the redirect if let redirectURL = redirectURLs[file.raw] { await MainActor.run { @@ -738,7 +793,7 @@ struct FilePreviewView: View { if let url = URL(string: file.raw), UIApplication.shared.canOpenURL(url) { if redirectURLs[file.raw] == nil { Task { - await loadRedirectURL(for: file) + await loadSingleFileRedirect(file) // Only open the URL after we've loaded the redirect if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { await MainActor.run { diff --git a/Django Files/Views/TabView.swift b/Django Files/Views/TabView.swift index 8a32869..16029e3 100644 --- a/Django Files/Views/TabView.swift +++ b/Django Files/Views/TabView.swift @@ -10,6 +10,7 @@ import SwiftData struct TabViewWindow: View { @Environment(\.modelContext) private var modelContext + @EnvironmentObject private var previewStateManager: PreviewStateManager @ObservedObject var sessionManager: SessionManager @Binding var selectedTab: Tab @@ -22,6 +23,7 @@ struct TabViewWindow: View { @State private var showLoginSheet = false @State private var filesNavigationPath = NavigationPath() @State private var albumsNavigationPath = NavigationPath() + @State private var showFileInfo = false init(sessionManager: SessionManager, selectedTab: Binding) { self.sessionManager = sessionManager From 2e55805c16ab3d7df0424403ae54742be11ab679 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:36:44 -0500 Subject: [PATCH 3/8] better preview state handling --- Django Files/Django_FilesApp.swift | 5 +-- Django Files/Views/Lists/FileList.swift | 46 ++++++++++++------------- Django Files/Views/TabView.swift | 1 + 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index ba911ea..35ea7fb 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -14,6 +14,7 @@ import FirebaseCrashlytics class PreviewStateManager: ObservableObject { @Published var deepLinkFile: DFFile? @Published var showingDeepLinkPreview = false + @Published var deepLinkTargetFileID: Int? = nil } class AppDelegate: NSObject, UIApplicationDelegate { @@ -119,6 +120,7 @@ struct Django_FilesApp: App { TabViewWindow(sessionManager: sessionManager, selectedTab: $selectedTab) } } + .environmentObject(previewStateManager) .onOpenURL { url in handleDeepLink(url) } @@ -246,8 +248,7 @@ struct Django_FilesApp: App { await MainActor.run { sessionManager.selectedSession = session selectedTab = .files - // Post notification to update deep link target file ID - NotificationCenter.default.post(name: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil, userInfo: ["fileID": fileID]) + previewStateManager.deepLinkTargetFileID = fileID } } else { print("❌ Failed to fetch file details") diff --git a/Django Files/Views/Lists/FileList.swift b/Django Files/Views/Lists/FileList.swift index cbc5f14..0e2a822 100644 --- a/Django Files/Views/Lists/FileList.swift +++ b/Django Files/Views/Lists/FileList.swift @@ -167,6 +167,7 @@ struct FileListView: View { let albumName: String? @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var previewStateManager: PreviewStateManager @StateObject private var fileListManager: FileListManager @State private var currentPage = 1 @@ -223,11 +224,6 @@ struct FileListView: View { if let currentUserID = server.wrappedValue?.userID { _filterUserID = State(initialValue: currentUserID) } - // Check for deep link target file ID on init - if let targetFileID = UserDefaults.standard.object(forKey: "deepLinkTargetFileID") as? Int { - _deepLinkTargetFileID = State(initialValue: targetFileID) - UserDefaults.standard.removeObject(forKey: "deepLinkTargetFileID") - } } private var files: [DFFile] { @@ -252,11 +248,26 @@ struct FileListView: View { } private func checkForDeepLinkTarget() { - if let targetFileID = deepLinkTargetFileID, - let index = files.firstIndex(where: { $0.id == targetFileID }) { - selectedFile = files[index] - showingPreview = true - deepLinkTargetFileID = nil + print("checkForDeepLinkTarget Called with target: \(String(describing: previewStateManager.deepLinkTargetFileID))") + if let targetFileID = previewStateManager.deepLinkTargetFileID { + Task { + var currentPage = 1 + var foundFile = false + while !foundFile { + await fetchFiles(page: currentPage, append: currentPage > 1) + if let index = files.firstIndex(where: { $0.id == targetFileID }) { + await MainActor.run { + selectedFile = files[index] + showingPreview = true + previewStateManager.deepLinkTargetFileID = nil + } + foundFile = true + } else if !hasNextPage { + break + } + currentPage += 1 + } + } } } @@ -479,7 +490,7 @@ struct FileListView: View { }) { if let _ = server.wrappedValue { UserFilterView(users: $users, selectedUserID: $filterUserID) - .onChange(of: filterUserID) { _ in + .onChange(of: filterUserID) { oldValue, newValue in Task { await refreshFiles() } @@ -516,19 +527,8 @@ struct FileListView: View { ) .onAppear { loadFiles() - checkForDeepLinkTarget() - // Add observer for deep link target file ID - NotificationCenter.default.addObserver(forName: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil, queue: .main) { notification in - if let fileID = notification.userInfo?["fileID"] as? Int { - deepLinkTargetFileID = fileID - } - } - } - .onDisappear { - // Remove observer when view disappears - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("UpdateDeepLinkTargetFileID"), object: nil) } - .onChange(of: deepLinkTargetFileID) { _, newValue in + .onChange(of: previewStateManager.deepLinkTargetFileID) { _, newValue in if newValue != nil { checkForDeepLinkTarget() } diff --git a/Django Files/Views/TabView.swift b/Django Files/Views/TabView.swift index 16029e3..eb9e946 100644 --- a/Django Files/Views/TabView.swift +++ b/Django Files/Views/TabView.swift @@ -93,6 +93,7 @@ struct TabViewWindow: View { .tag(Tab.settings) } } + .environmentObject(previewStateManager) .onAppear { sessionManager.loadLastSelectedSession(from: sessions) if let selectedSession = sessionManager.selectedSession { From 58eafb70d4ab58d13f5c2cbf0a8a46bb0d3df3cb Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 11:18:54 -0500 Subject: [PATCH 4/8] refactor --- Django Files/Django_FilesApp.swift | 276 ++---------------- Django Files/Utils/DeepLinks.swift | 229 +++++++++++++++ .../Views/Preview/AnimatedImageView.swift | 2 +- 3 files changed, 261 insertions(+), 246 deletions(-) create mode 100644 Django Files/Utils/DeepLinks.swift diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index 35ea7fb..a1f6db0 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -122,7 +122,17 @@ struct Django_FilesApp: App { } .environmentObject(previewStateManager) .onOpenURL { url in - handleDeepLink(url) + DeepLinks.shared.handleDeepLink( + url, + context: sharedModelContainer.mainContext, + sessionManager: sessionManager, + previewStateManager: previewStateManager, + selectedTab: $selectedTab, + hasExistingSessions: $hasExistingSessions, + showingServerConfirmation: $showingServerConfirmation, + pendingAuthURL: $pendingAuthURL, + pendingAuthSignature: $pendingAuthSignature + ) } .sheet(isPresented: $showingServerConfirmation) { ServerConfirmationView( @@ -130,12 +140,30 @@ struct Django_FilesApp: App { signature: $pendingAuthSignature, onConfirm: { setAsDefault in Task { - await handleServerConfirmation(confirmed: true, setAsDefault: setAsDefault) + await DeepLinks.shared.handleServerConfirmation( + confirmed: true, + setAsDefault: setAsDefault, + pendingAuthURL: $pendingAuthURL, + pendingAuthSignature: $pendingAuthSignature, + context: sharedModelContainer.mainContext, + sessionManager: sessionManager, + hasExistingSessions: $hasExistingSessions, + selectedTab: $selectedTab + ) } }, onCancel: { Task { - await handleServerConfirmation(confirmed: false, setAsDefault: false) + await DeepLinks.shared.handleServerConfirmation( + confirmed: false, + setAsDefault: false, + pendingAuthURL: $pendingAuthURL, + pendingAuthSignature: $pendingAuthSignature, + context: sharedModelContainer.mainContext, + sessionManager: sessionManager, + hasExistingSessions: $hasExistingSessions, + selectedTab: $selectedTab + ) } }, context: sharedModelContainer.mainContext @@ -167,248 +195,6 @@ struct Django_FilesApp: App { #endif } - private func handleDeepLink(_ url: URL) { - print("Deep link received: \(url)") - guard url.scheme == "djangofiles" else { return } - - // Extract the signature from the URL parameters - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { - print("Invalid deep link URL") - return - } - print("Deep link host: \(components.host ?? "unknown")") - switch components.host { - case "authorize": - deepLinkAuth(components) - case "serverlist": - selectedTab = .settings - case "filelist": - handleFileListDeepLink(components) - case "preview": - handlePreviewLink(components) - default: - ToastManager.shared.showToast(message: "Unsupported deep link \(url)") - print("Unsupported deep link type: \(components.host ?? "unknown")") - } - } - - private func handlePreviewLink(_ components: URLComponents) { - print("🔍 Handling preview deep link with components: \(components)") - - guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, - let serverURL = URL(string: urlString), - let fileIDString = components.queryItems?.first(where: { $0.name == "file_id" })?.value, - let fileID = Int(fileIDString), - let fileName = components.queryItems?.first(where: { $0.name == "file_name" })?.value?.removingPercentEncoding else { - print("❌ Invalid preview deep link parameters") - return - } - - print("📡 Parsed deep link - Server: \(serverURL), FileID: \(fileID), FileName: \(fileName)") - - // Check if this server exists in user's sessions - let context = sharedModelContainer.mainContext - let descriptor = FetchDescriptor() - - Task { - do { - let existingSessions = try context.fetch(descriptor) - if let session = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { - // Server exists in user's sessions, handle normally - print("✅ Preview link for known server: \(serverURL.absoluteString)") - - // Check if session is authenticated - if !session.auth { - print("❌ Session is not authenticated") - await MainActor.run { - ToastManager.shared.showToast(message: "Please log in to view this file") - selectedTab = .settings - } - return - } - - // Create API instance with session token - let api = DFAPI(url: serverURL, token: session.token) - - // Get file details to check ownership - if let fileDetails = await api.getFileDetails(fileID: fileID) { - // Check if file belongs to current user - if fileDetails.user != session.userID { - print("❌ File does not belong to current user") - // Handle like unknown server - await MainActor.run { - selectedTab = .files - previewStateManager.deepLinkFile = fileDetails - previewStateManager.showingDeepLinkPreview = true - } - return - } - - // File belongs to current user, navigate to file list - await MainActor.run { - sessionManager.selectedSession = session - selectedTab = .files - previewStateManager.deepLinkTargetFileID = fileID - } - } else { - print("❌ Failed to fetch file details") - await MainActor.run { - ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") - } - } - } else { - // Server not in user's sessions, try to fetch file info - print("🔑 Preview link for unknown server: \(serverURL.absoluteString)") - - // Create a temporary API instance without authentication - let api = DFAPI(url: serverURL, token: "") - print("🌐 Created API instance for server: \(serverURL)") - - // Try to fetch file details - print("📥 Attempting to fetch file details for ID: \(fileID)") - if let fileDetails = await api.getFileDetails(fileID: fileID) { - print("✅ Successfully fetched file details: \(fileDetails.name)") - // Successfully got file details, show preview - await MainActor.run { - print("🎯 Setting up preview view") - selectedTab = .files - previewStateManager.deepLinkFile = fileDetails - previewStateManager.showingDeepLinkPreview = true - print("🎯 Preview view setup complete") - } - } else { - print("❌ Failed to fetch file details") - // Failed to get file details (404/403/etc) - await MainActor.run { - ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") - } - } - } - } catch { - print("❌ Error checking for existing sessions: \(error)") - await MainActor.run { - ToastManager.shared.showToast(message: "Error accessing file: \(error.localizedDescription)") - } - } - } - } - - private func handleFileListDeepLink(_ components: URLComponents) { - guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, - let serverURL = URL(string: urlString) else { - print("Invalid server URL in filelist deep link") - return - } - - // Find the session with matching URL and select it - let context = sharedModelContainer.mainContext - let descriptor = FetchDescriptor() - - Task { - do { - let existingSessions = try context.fetch(descriptor) - if let matchingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { - await MainActor.run { - sessionManager.selectedSession = matchingSession - selectedTab = .files - } - } else { - print("No session found for URL: \(serverURL.absoluteString)") - } - } catch { - print("Error fetching sessions: \(error)") - } - } - } - - private func deepLinkAuth(_ components: URLComponents) { - guard let signature = components.queryItems?.first(where: { $0.name == "signature" })?.value?.removingPercentEncoding, - let serverURL = URL(string: components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding ?? "") else { - print("Unable to parse auth deep link.") - return - } - - // Check if a session with this URL already exists - let context = sharedModelContainer.mainContext - let descriptor = FetchDescriptor() - - Task { - do { - let existingSessions = try context.fetch(descriptor) - if let existingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { - // If session exists, just select it and update UI - await MainActor.run { - sessionManager.selectedSession = existingSession - hasExistingSessions = true - ToastManager.shared.showToast(message: "Connected to existing server \(existingSession.url)") - } - return - } - - // No existing session, show confirmation dialog - await MainActor.run { - pendingAuthURL = serverURL - pendingAuthSignature = signature - showingServerConfirmation = true - } - } catch { - print("Error checking for existing sessions: \(error)") - } - } - } - - private func handleServerConfirmation(confirmed: Bool, setAsDefault: Bool) async { - guard let serverURL = pendingAuthURL, - let signature = pendingAuthSignature else { - return - } - - // If user cancelled, just clear the pending data and return - if !confirmed { - pendingAuthURL = nil - pendingAuthSignature = nil - return - } - - await MainActor.run { - // Create and authenticate the new session - let context = sharedModelContainer.mainContext - - do { - let descriptor = FetchDescriptor() - let existingSessions = try context.fetch(descriptor) - - // Create and authenticate the new session - Task { - if let newSession = await sessionManager.createAndAuthenticateSession( - url: serverURL, - signature: signature, - context: context - ) { - if setAsDefault { - // Reset all other sessions to not be default - for session in existingSessions { - session.defaultSession = false - } - newSession.defaultSession = true - } - sessionManager.selectedSession = newSession - hasExistingSessions = true - selectedTab = .files - ToastManager.shared.showToast(message: "Successfully logged into \(newSession.url)") - } - } - } catch { - ToastManager.shared.showToast(message: "Problem signing into server \(error)") - print("Error creating new session: \(error)") - } - - // Clear pending auth data - pendingAuthURL = nil - pendingAuthSignature = nil - } - } - private func checkDefaultServer() { let context = sharedModelContainer.mainContext let descriptor = FetchDescriptor() diff --git a/Django Files/Utils/DeepLinks.swift b/Django Files/Utils/DeepLinks.swift new file mode 100644 index 0000000..54f78f7 --- /dev/null +++ b/Django Files/Utils/DeepLinks.swift @@ -0,0 +1,229 @@ +// +// DeepLinks.swift +// Django Files +// +// Created by Ralph Luaces on 6/15/25. +// + +import SwiftUI +import SwiftData + +class DeepLinks { + static let shared = DeepLinks() + private init() {} + + func handleDeepLink(_ url: URL, context: ModelContext, sessionManager: SessionManager, previewStateManager: PreviewStateManager, selectedTab: Binding, hasExistingSessions: Binding, showingServerConfirmation: Binding, pendingAuthURL: Binding, pendingAuthSignature: Binding) { + print("Deep link received: \(url)") + guard url.scheme == "djangofiles" else { return } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + print("Invalid deep link URL") + return + } + + switch components.host { + case "authorize": + deepLinkAuth(components, context: context, sessionManager: sessionManager, hasExistingSessions: hasExistingSessions, showingServerConfirmation: showingServerConfirmation, pendingAuthURL: pendingAuthURL, pendingAuthSignature: pendingAuthSignature) + case "serverlist": + selectedTab.wrappedValue = .settings + case "filelist": + handleFileListDeepLink(components, context: context, sessionManager: sessionManager, selectedTab: selectedTab) + case "preview": + handlePreviewLink(components, context: context, sessionManager: sessionManager, previewStateManager: previewStateManager, selectedTab: selectedTab) + default: + ToastManager.shared.showToast(message: "Unsupported deep link \(url)") + print("Unsupported deep link type: \(components.host ?? "unknown")") + } + } + + private func handlePreviewLink(_ components: URLComponents, context: ModelContext, sessionManager: SessionManager, previewStateManager: PreviewStateManager, selectedTab: Binding) { + print("🔍 Handling preview deep link with components: \(components)") + + guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, + let serverURL = URL(string: urlString), + let fileIDString = components.queryItems?.first(where: { $0.name == "file_id" })?.value, + let fileID = Int(fileIDString), + let fileName = components.queryItems?.first(where: { $0.name == "file_name" })?.value?.removingPercentEncoding else { + print("❌ Invalid preview deep link parameters") + return + } + + print("📡 Parsed deep link - Server: \(serverURL), FileID: \(fileID), FileName: \(fileName)") + + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let session = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + print("✅ Preview link for known server: \(serverURL.absoluteString)") + + if !session.auth { + print("❌ Session is not authenticated") + await MainActor.run { + ToastManager.shared.showToast(message: "Please log in to view this file") + selectedTab.wrappedValue = .settings + } + return + } + + let api = DFAPI(url: serverURL, token: session.token) + + if let fileDetails = await api.getFileDetails(fileID: fileID) { + if fileDetails.user != session.userID { + print("❌ File does not belong to current user") + await MainActor.run { + selectedTab.wrappedValue = .files + previewStateManager.deepLinkFile = fileDetails + previewStateManager.showingDeepLinkPreview = true + } + return + } + + await MainActor.run { + sessionManager.selectedSession = session + selectedTab.wrappedValue = .files + previewStateManager.deepLinkTargetFileID = fileID + } + } else { + print("❌ Failed to fetch file details") + await MainActor.run { + ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") + } + } + } else { + print("🔑 Preview link for unknown server: \(serverURL.absoluteString)") + + let api = DFAPI(url: serverURL, token: "") + print("🌐 Created API instance for server: \(serverURL)") + + print("📥 Attempting to fetch file details for ID: \(fileID)") + if let fileDetails = await api.getFileDetails(fileID: fileID) { + print("✅ Successfully fetched file details: \(fileDetails.name)") + await MainActor.run { + print("🎯 Setting up preview view") + selectedTab.wrappedValue = .files + previewStateManager.deepLinkFile = fileDetails + previewStateManager.showingDeepLinkPreview = true + print("🎯 Preview view setup complete") + } + } else { + print("❌ Failed to fetch file details") + await MainActor.run { + ToastManager.shared.showToast(message: "Unable to access file. It may be private or no longer available.") + } + } + } + } catch { + print("❌ Error checking for existing sessions: \(error)") + await MainActor.run { + ToastManager.shared.showToast(message: "Error accessing file: \(error.localizedDescription)") + } + } + } + } + + private func handleFileListDeepLink(_ components: URLComponents, context: ModelContext, sessionManager: SessionManager, selectedTab: Binding) { + guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, + let serverURL = URL(string: urlString) else { + print("Invalid server URL in filelist deep link") + return + } + + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let matchingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + await MainActor.run { + sessionManager.selectedSession = matchingSession + selectedTab.wrappedValue = .files + } + } else { + print("No session found for URL: \(serverURL.absoluteString)") + } + } catch { + print("Error fetching sessions: \(error)") + } + } + } + + private func deepLinkAuth(_ components: URLComponents, context: ModelContext, sessionManager: SessionManager, hasExistingSessions: Binding, showingServerConfirmation: Binding, pendingAuthURL: Binding, pendingAuthSignature: Binding) { + guard let signature = components.queryItems?.first(where: { $0.name == "signature" })?.value?.removingPercentEncoding, + let serverURL = URL(string: components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding ?? "") else { + print("Unable to parse auth deep link.") + return + } + + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let existingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + await MainActor.run { + sessionManager.selectedSession = existingSession + hasExistingSessions.wrappedValue = true + ToastManager.shared.showToast(message: "Connected to existing server \(existingSession.url)") + } + return + } + + await MainActor.run { + pendingAuthURL.wrappedValue = serverURL + pendingAuthSignature.wrappedValue = signature + showingServerConfirmation.wrappedValue = true + } + } catch { + print("Error checking for existing sessions: \(error)") + } + } + } + + @MainActor func handleServerConfirmation(confirmed: Bool, setAsDefault: Bool, pendingAuthURL: Binding, pendingAuthSignature: Binding, context: ModelContext, sessionManager: SessionManager, hasExistingSessions: Binding, selectedTab: Binding) async { + guard let serverURL = pendingAuthURL.wrappedValue, + let signature = pendingAuthSignature.wrappedValue else { + return + } + + if !confirmed { + pendingAuthURL.wrappedValue = nil + pendingAuthSignature.wrappedValue = nil + return + } + + await MainActor.run { + do { + let descriptor = FetchDescriptor() + let existingSessions = try context.fetch(descriptor) + + Task { + if let newSession = await sessionManager.createAndAuthenticateSession( + url: serverURL, + signature: signature, + context: context + ) { + if setAsDefault { + for session in existingSessions { + session.defaultSession = false + } + newSession.defaultSession = true + } + sessionManager.selectedSession = newSession + hasExistingSessions.wrappedValue = true + selectedTab.wrappedValue = .files + ToastManager.shared.showToast(message: "Successfully logged into \(newSession.url)") + } + } + } catch { + ToastManager.shared.showToast(message: "Problem signing into server \(error)") + print("Error creating new session: \(error)") + } + + pendingAuthURL.wrappedValue = nil + pendingAuthSignature.wrappedValue = nil + } + } +} + diff --git a/Django Files/Views/Preview/AnimatedImageView.swift b/Django Files/Views/Preview/AnimatedImageView.swift index bc061b0..9ac6b51 100644 --- a/Django Files/Views/Preview/AnimatedImageView.swift +++ b/Django Files/Views/Preview/AnimatedImageView.swift @@ -64,7 +64,7 @@ struct AnimatedImageScrollView: UIViewRepresentable { } func updateUIView(_ scrollView: UIScrollView, context: Context) { - if let imageView = context.coordinator.imageView as? FLAnimatedImageView, + if let imageView = context.coordinator.imageView, let animatedImage = FLAnimatedImage(animatedGIFData: data) { imageView.animatedImage = animatedImage context.coordinator.updateZoomScaleForSize(scrollView.bounds.size) From 0a706a110341a19e325f830e519d29c0c7131a39 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 12:15:41 -0500 Subject: [PATCH 5/8] cleanup --- Django Files/Django_FilesApp.swift | 5 ++--- Django Files/Utils/SessionManager.swift | 15 ++++++--------- Django Files/Utils/Toast.swift | 4 ++-- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index a1f6db0..6b8ac3b 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -20,7 +20,6 @@ class PreviewStateManager: ObservableObject { class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - // Skip Firebase initialization if disabled via launch arguments let shouldDisableFirebase = ProcessInfo.processInfo.arguments.contains("--DisableFirebase") if !shouldDisableFirebase { FirebaseApp.configure() @@ -228,10 +227,10 @@ struct Django_FilesApp: App { if hasExistingSessions { checkDefaultServer() } - isLoading = false // Set loading to false after check completes + isLoading = false } catch { print("Error checking for existing sessions: \(error)") - isLoading = false // Ensure we exit loading state even on error + isLoading = false } } } diff --git a/Django Files/Utils/SessionManager.swift b/Django Files/Utils/SessionManager.swift index 81ebd54..d0d1597 100644 --- a/Django Files/Utils/SessionManager.swift +++ b/Django Files/Utils/SessionManager.swift @@ -17,31 +17,29 @@ class SessionManager: ObservableObject { UserDefaults.standard.set(session.url, forKey: userDefaultsKey) } } - + func createAndAuthenticateSession(url: URL, signature: String, context: ModelContext) async -> DjangoFilesSession? { let serverURL = "\(url.scheme ?? "https")://\(url.host ?? "")" let newSession = DjangoFilesSession(url: serverURL) let api = DFAPI(url: URL(string: serverURL)!, token: "") - // Get token using the signature if let token = await api.applicationAuth(signature: signature, selectedServer: newSession) { newSession.token = token newSession.auth = true - - // Save the session + context.insert(newSession) try? context.save() - + return newSession } - + return nil } - + func loadLastSelectedSession(from sessions: [DjangoFilesSession]) { if selectedSession != nil { return } - + if let lastSessionURL = UserDefaults.standard.string(forKey: userDefaultsKey) { selectedSession = sessions.first(where: { $0.url == lastSessionURL }) } else if let defaultSession = sessions.first(where: { $0.defaultSession }) { @@ -50,5 +48,4 @@ class SessionManager: ObservableObject { selectedSession = firstSession } } - } diff --git a/Django Files/Utils/Toast.swift b/Django Files/Utils/Toast.swift index 6bd2c6f..a61b384 100644 --- a/Django Files/Utils/Toast.swift +++ b/Django Files/Utils/Toast.swift @@ -13,7 +13,7 @@ class ToastManager { func showToast(message: String) { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { return } - + // Create a label for the toast let toastContainer = UIView() toastContainer.backgroundColor = UIColor.darkGray.withAlphaComponent(0.9) @@ -67,7 +67,7 @@ class ToastManager { toastContainer.alpha = 1 }) { _ in // Dismiss the toast after a delay - UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseOut, animations: { + UIView.animate(withDuration: 0.2, delay: 2.0, options: .curveEaseOut, animations: { toastContainer.alpha = 0 }) { _ in toastContainer.removeFromSuperview() From f70d253408c79530d169c25c285402b90a98ca9e Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 12:38:01 -0500 Subject: [PATCH 6/8] password protected file support --- Django Files/API/Files.swift | 9 +++++-- Django Files/Django_FilesApp.swift | 1 + Django Files/Utils/DeepLinks.swift | 39 +++++++++++++++++------------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/Django Files/API/Files.swift b/Django Files/API/Files.swift index cb3e8b4..b284073 100644 --- a/Django Files/API/Files.swift +++ b/Django Files/API/Files.swift @@ -262,12 +262,17 @@ extension DFAPI { return nil } - public func getFileDetails(fileID: Int, selectedServer: DjangoFilesSession? = nil) async -> DFFile? { + public func getFileDetails(fileID: Int, password: String? = nil, selectedServer: DjangoFilesSession? = nil) async -> DFFile? { do { + var parameters: [String: String] = [:] + if let password = password { + parameters["password"] = password + } + let responseBody = try await makeAPIRequest( body: Data(), path: getAPIPath(.file) + "\(fileID)", - parameters: [:], + parameters: parameters, method: .get, selectedServer: selectedServer ) diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index 6b8ac3b..1734367 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -15,6 +15,7 @@ class PreviewStateManager: ObservableObject { @Published var deepLinkFile: DFFile? @Published var showingDeepLinkPreview = false @Published var deepLinkTargetFileID: Int? = nil + @Published var deepLinkFilePassword: String? = nil } class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/Django Files/Utils/DeepLinks.swift b/Django Files/Utils/DeepLinks.swift index 54f78f7..36673ae 100644 --- a/Django Files/Utils/DeepLinks.swift +++ b/Django Files/Utils/DeepLinks.swift @@ -20,7 +20,7 @@ class DeepLinks { print("Invalid deep link URL") return } - + switch components.host { case "authorize": deepLinkAuth(components, context: context, sessionManager: sessionManager, hasExistingSessions: hasExistingSessions, showingServerConfirmation: showingServerConfirmation, pendingAuthURL: pendingAuthURL, pendingAuthSignature: pendingAuthSignature) @@ -38,7 +38,7 @@ class DeepLinks { private func handlePreviewLink(_ components: URLComponents, context: ModelContext, sessionManager: SessionManager, previewStateManager: PreviewStateManager, selectedTab: Binding) { print("🔍 Handling preview deep link with components: \(components)") - + guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, let serverURL = URL(string: urlString), let fileIDString = components.queryItems?.first(where: { $0.name == "file_id" })?.value, @@ -47,11 +47,13 @@ class DeepLinks { print("❌ Invalid preview deep link parameters") return } - - print("📡 Parsed deep link - Server: \(serverURL), FileID: \(fileID), FileName: \(fileName)") - + + let filePassword = components.queryItems?.first(where: { $0.name == "file_password" })?.value?.removingPercentEncoding + + print("📡 Parsed deep link - Server: \(serverURL), FileID: \(fileID), FileName: \(fileName), HasPassword: \(filePassword != nil)") + let descriptor = FetchDescriptor() - + Task { do { let existingSessions = try context.fetch(descriptor) @@ -66,24 +68,26 @@ class DeepLinks { } return } - + let api = DFAPI(url: serverURL, token: session.token) - if let fileDetails = await api.getFileDetails(fileID: fileID) { + if let fileDetails = await api.getFileDetails(fileID: fileID, password: filePassword) { if fileDetails.user != session.userID { print("❌ File does not belong to current user") await MainActor.run { selectedTab.wrappedValue = .files previewStateManager.deepLinkFile = fileDetails previewStateManager.showingDeepLinkPreview = true + previewStateManager.deepLinkFilePassword = filePassword } return } - + await MainActor.run { sessionManager.selectedSession = session selectedTab.wrappedValue = .files previewStateManager.deepLinkTargetFileID = fileID + previewStateManager.deepLinkFilePassword = filePassword } } else { print("❌ Failed to fetch file details") @@ -93,18 +97,19 @@ class DeepLinks { } } else { print("🔑 Preview link for unknown server: \(serverURL.absoluteString)") - + let api = DFAPI(url: serverURL, token: "") print("🌐 Created API instance for server: \(serverURL)") - + print("📥 Attempting to fetch file details for ID: \(fileID)") - if let fileDetails = await api.getFileDetails(fileID: fileID) { + if let fileDetails = await api.getFileDetails(fileID: fileID, password: filePassword) { print("✅ Successfully fetched file details: \(fileDetails.name)") await MainActor.run { print("🎯 Setting up preview view") selectedTab.wrappedValue = .files previewStateManager.deepLinkFile = fileDetails previewStateManager.showingDeepLinkPreview = true + previewStateManager.deepLinkFilePassword = filePassword print("🎯 Preview view setup complete") } } else { @@ -129,9 +134,9 @@ class DeepLinks { print("Invalid server URL in filelist deep link") return } - + let descriptor = FetchDescriptor() - + Task { do { let existingSessions = try context.fetch(descriptor) @@ -157,7 +162,7 @@ class DeepLinks { } let descriptor = FetchDescriptor() - + Task { do { let existingSessions = try context.fetch(descriptor) @@ -180,7 +185,7 @@ class DeepLinks { } } } - + @MainActor func handleServerConfirmation(confirmed: Bool, setAsDefault: Bool, pendingAuthURL: Binding, pendingAuthSignature: Binding, context: ModelContext, sessionManager: SessionManager, hasExistingSessions: Binding, selectedTab: Binding) async { guard let serverURL = pendingAuthURL.wrappedValue, let signature = pendingAuthSignature.wrappedValue else { @@ -220,7 +225,7 @@ class DeepLinks { ToastManager.shared.showToast(message: "Problem signing into server \(error)") print("Error creating new session: \(error)") } - + pendingAuthURL.wrappedValue = nil pendingAuthSignature.wrappedValue = nil } From 2253d5a18942cdccb8a28b052328acec83e792b1 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 16:14:28 -0500 Subject: [PATCH 7/8] enhance preview swiping and page loading --- Django Files/Django_FilesApp.swift | 2 +- Django Files/Utils/ImageCache.swift | 26 +++++ Django Files/Views/Lists/FileList.swift | 22 +++- Django Files/Views/Preview/Image.swift | 69 ++++++------ Django Files/Views/Preview/Preview.swift | 129 +++++++++++++++++------ 5 files changed, 176 insertions(+), 72 deletions(-) diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index 1734367..4853435 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -177,7 +177,7 @@ struct Django_FilesApp: App { showingPreview: $previewStateManager.showingDeepLinkPreview, showFileInfo: $showFileInfo, fileListDelegate: nil, - allFiles: [file], + allFiles: .constant([file]), currentIndex: 0, onNavigate: { _ in } ) diff --git a/Django Files/Utils/ImageCache.swift b/Django Files/Utils/ImageCache.swift index a610534..08e1c3a 100644 --- a/Django Files/Utils/ImageCache.swift +++ b/Django Files/Utils/ImageCache.swift @@ -11,6 +11,7 @@ import Foundation class ImageCache { static let shared = ImageCache() private let cache = NSCache() + private let contentCache = NSCache() private init() { // Cache is unlimited @@ -23,6 +24,14 @@ class ImageCache { func get(for key: String) -> UIImage? { return cache.object(forKey: key as NSString) } + + func setContent(_ data: Data, for key: String) { + contentCache.setObject(data as NSData, forKey: key as NSString) + } + + func getContent(for key: String) -> Data? { + return contentCache.object(forKey: key as NSString) as Data? + } } struct CachedAsyncImage: View { @@ -98,3 +107,20 @@ struct CachedAsyncImage: View { } } } + +// Add a new generic content loader +struct CachedContentLoader { + static func loadContent(from url: URL) async throws -> Data { + let urlString = url.absoluteString + + // Check cache first + if let cachedData = ImageCache.shared.getContent(for: urlString) { + return cachedData + } + + // Download and cache if not found + let (data, _) = try await URLSession.shared.data(from: url) + ImageCache.shared.setContent(data, for: urlString) + return data + } +} diff --git a/Django Files/Views/Lists/FileList.swift b/Django Files/Views/Lists/FileList.swift index 0e2a822..5c42be9 100644 --- a/Django Files/Views/Lists/FileList.swift +++ b/Django Files/Views/Lists/FileList.swift @@ -369,18 +369,26 @@ struct FileListView: View { } } .fullScreenCover(isPresented: $showingPreview) { - if let index = files.firstIndex(where: { $0.id == selectedFile?.id }) { + if let index = fileListManager.files.firstIndex(where: { $0.id == selectedFile?.id }) { FilePreviewView( file: $fileListManager.files[index], server: server, showingPreview: $showingPreview, showFileInfo: $showFileInfo, fileListDelegate: fileListManager, - allFiles: files, + allFiles: Binding( + get: { fileListManager.files }, + set: { fileListManager.files = $0 } + ), currentIndex: index, onNavigate: { newIndex in - if newIndex >= 0 && newIndex < files.count { - selectedFile = files[newIndex] + if newIndex >= 0 && newIndex < fileListManager.files.count { + selectedFile = fileListManager.files[newIndex] + } + }, + onLoadMore: { + if hasNextPage && !isLoading { + await loadNextPage() } } ) @@ -711,7 +719,11 @@ struct FileListView: View { if let filesResponse = await api.getFiles(page: page, album: albumID, selectedServer: serverInstance, filterUserID: filterUserID) { if append { - files.append(contentsOf: filesResponse.files) + // Only append new files that aren't already in the list + let newFiles = filesResponse.files.filter { newFile in + !files.contains { $0.id == newFile.id } + } + files.append(contentsOf: newFiles) } else { files = filesResponse.files } diff --git a/Django Files/Views/Preview/Image.swift b/Django Files/Views/Preview/Image.swift index ecfabcb..3d06aab 100644 --- a/Django Files/Views/Preview/Image.swift +++ b/Django Files/Views/Preview/Image.swift @@ -30,7 +30,6 @@ struct ImageScrollView: UIViewRepresentable { doubleTapGesture.numberOfTapsRequired = 2 scrollView.addGestureRecognizer(doubleTapGesture) - // Calculate initial zoom scale let widthScale = UIScreen.main.bounds.width / image.size.width let heightScale = UIScreen.main.bounds.height / image.size.height let minScale = min(widthScale, heightScale) @@ -38,12 +37,14 @@ struct ImageScrollView: UIViewRepresentable { scrollView.minimumZoomScale = minScale scrollView.maximumZoomScale = 5.0 - // Set content size to image size scrollView.contentSize = image.size - // Set initial zoom scale scrollView.zoomScale = minScale + scrollView.decelerationRate = .fast + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + return scrollView } @@ -65,39 +66,39 @@ struct ImageScrollView: UIViewRepresentable { return imageView } - func updateZoomScaleForSize(_ size: CGSize) { - guard let imageView = imageView, - let image = imageView.image, - let scrollView = scrollView, - size.width > 0, - size.height > 0, - image.size.width > 0, - image.size.height > 0 else { return } - - let widthScale = size.width / image.size.width - let heightScale = size.height / image.size.height - let minScale = min(widthScale, heightScale) + func updateZoomScaleForSize(_ size: CGSize) { + guard let imageView = imageView, + let image = imageView.image, + let scrollView = scrollView, + size.width > 0, + size.height > 0, + image.size.width > 0, + image.size.height > 0 else { return } + + let widthScale = size.width / image.size.width + let heightScale = size.height / image.size.height + let minScale = min(widthScale, heightScale) - scrollView.minimumZoomScale = minScale - scrollView.maximumZoomScale = max(minScale * 5, 5.0) + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = max(minScale * 5, 5.0) + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView = gesture.view as? UIScrollView else { return } + + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let point = gesture.location(in: imageView) + let size = scrollView.bounds.size + let w = size.width / (scrollView.maximumZoomScale / 5) + let h = size.height / (scrollView.maximumZoomScale / 5) + let x = point.x - (w / 2.0) + let y = point.y - (h / 2.0) + let rect = CGRect(x: x, y: y, width: w, height: h) + scrollView.zoom(to: rect, animated: true) + } } - - @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { - guard let scrollView = gesture.view as? UIScrollView else { return } - - if scrollView.zoomScale > scrollView.minimumZoomScale { - scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) - } else { - let point = gesture.location(in: imageView) - let size = scrollView.bounds.size - let w = size.width / (scrollView.maximumZoomScale / 5) - let h = size.height / (scrollView.maximumZoomScale / 5) - let x = point.x - (w / 2.0) - let y = point.y - (h / 2.0) - let rect = CGRect(x: x, y: y, width: w, height: h) - scrollView.zoom(to: rect, animated: true) - } - } func scrollViewDidZoom(_ scrollView: UIScrollView) { guard let imageView = imageView else { return } diff --git a/Django Files/Views/Preview/Preview.swift b/Django Files/Views/Preview/Preview.swift index 333e4eb..ddcd06c 100644 --- a/Django Files/Views/Preview/Preview.swift +++ b/Django Files/Views/Preview/Preview.swift @@ -89,7 +89,6 @@ struct ContentPreview: View { ) } - // Image Preview private var imagePreview: some View { GeometryReader { geometry in if let content = content { @@ -145,7 +144,6 @@ struct ContentPreview: View { .padding() } - // Load content from URL private func loadContent() { print("📥 ContentPreview: Starting content load") isLoading = true @@ -158,28 +156,21 @@ struct ContentPreview: View { } print("📥 ContentPreview: Downloading content from URL") - URLSession.shared.dataTask(with: fileURL) { data, response, error in - DispatchQueue.main.async { - self.isLoading = false - - if let error = error { - print("❌ ContentPreview: Download error - \(error.localizedDescription)") - self.error = error - return - } - - if let httpResponse = response as? HTTPURLResponse { - print("📡 ContentPreview: HTTP Response - \(httpResponse.statusCode)") - } - - if let data = data { - print("✅ ContentPreview: Successfully downloaded \(data.count) bytes") + Task { + do { + let data = try await CachedContentLoader.loadContent(from: fileURL) + await MainActor.run { self.content = data - } else { - print("❌ ContentPreview: No data received") + self.isLoading = false + } + } catch { + print("❌ ContentPreview: Download error - \(error.localizedDescription)") + await MainActor.run { + self.error = error + self.isLoading = false } } - }.resume() + } } private func loadFileDetails() { @@ -213,6 +204,7 @@ struct PageViewController: UIViewControllerRepresentable { var showFileInfo: Binding @Binding var selectedFileDetails: DFFile? var onPageChange: (Int) -> Void + var onLoadMore: (() async -> Void)? func makeCoordinator() -> Coordinator { Coordinator(self) @@ -226,7 +218,6 @@ struct PageViewController: UIViewControllerRepresentable { pageViewController.dataSource = context.coordinator pageViewController.delegate = context.coordinator - // Create and set the initial view controller if let initialVC = context.coordinator.createContentViewController(for: currentIndex) { pageViewController.setViewControllers([initialVC], direction: .forward, animated: false) } @@ -235,26 +226,66 @@ struct PageViewController: UIViewControllerRepresentable { } func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { + context.coordinator.parent = self + + // Only update if the current index has changed and we're not in the middle of a transition if let currentVC = pageViewController.viewControllers?.first as? UIHostingController, let currentFileIndex = files.firstIndex(where: { $0.id == currentVC.rootView.file.id }), - currentFileIndex != currentIndex { + currentFileIndex != currentIndex, + !context.coordinator.isTransitioning { // Update to the new index if it's different if let newVC = context.coordinator.createContentViewController(for: currentIndex) { let direction: UIPageViewController.NavigationDirection = currentFileIndex > currentIndex ? .reverse : .forward - pageViewController.setViewControllers([newVC], direction: direction, animated: true) + context.coordinator.isTransitioning = true + pageViewController.setViewControllers([newVC], direction: direction, animated: true) { _ in + context.coordinator.isTransitioning = false + } } } } class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { var parent: PageViewController + var isTransitioning: Bool = false + private var preloadedViewControllers: [Int: UIHostingController] = [:] + private var preloadTask: Task? + private var isLoadingMore: Bool = false init(_ pageViewController: PageViewController) { self.parent = pageViewController + super.init() + preloadAdjacentPages() + } + + deinit { + preloadTask?.cancel() + } + + private func preloadAdjacentPages() { + preloadTask?.cancel() + preloadTask = Task { + // Preload current and adjacent pages + let indicesToPreload = [-2, -1, 0, 1, 2].map { parent.currentIndex + $0 } + .filter { $0 >= 0 && $0 < parent.files.count } + + for index in indicesToPreload { + if preloadedViewControllers[index] == nil { + await MainActor.run { + preloadedViewControllers[index] = createContentViewController(for: index) + } + } + } + } } func createContentViewController(for index: Int) -> UIHostingController? { guard index >= 0 && index < parent.files.count else { return nil } + + // Check if we already have a preloaded controller + if let preloadedVC = preloadedViewControllers[index] { + return preloadedVC + } + let file = parent.files[index] let contentPreview = ContentPreview( mimeType: file.mime, @@ -263,7 +294,11 @@ struct PageViewController: UIViewControllerRepresentable { showFileInfo: parent.showFileInfo, selectedFileDetails: parent.$selectedFileDetails ) - return UIHostingController(rootView: contentPreview) + let vc = UIHostingController(rootView: contentPreview) + vc.view.backgroundColor = .clear + vc.view.isOpaque = false + preloadedViewControllers[index] = vc + return vc } func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { @@ -277,11 +312,33 @@ struct PageViewController: UIViewControllerRepresentable { func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { guard let currentVC = viewController as? UIHostingController, - let currentIndex = parent.files.firstIndex(where: { $0.id == currentVC.rootView.file.id }), - currentIndex < parent.files.count - 1 + let currentIndex = parent.files.firstIndex(where: { $0.id == currentVC.rootView.file.id }) else { return nil } - return createContentViewController(for: currentIndex + 1) + if currentIndex >= parent.files.count - 2 && !isLoadingMore { + Task { + isLoadingMore = true + await parent.onLoadMore?() + isLoadingMore = false + + if currentIndex == parent.files.count - 2 { + await MainActor.run { + preloadedViewControllers.removeAll() + preloadAdjacentPages() + // If we're at the second-to-last file, update to show the last file + if let nextVC = createContentViewController(for: currentIndex + 1) { + pageViewController.setViewControllers([nextVC], direction: .forward, animated: false) + } + } + } + } + } + + if currentIndex < parent.files.count - 1 { + return createContentViewController(for: currentIndex + 1) + } + + return nil } func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { @@ -291,6 +348,11 @@ struct PageViewController: UIViewControllerRepresentable { else { return } parent.onPageChange(currentIndex) + preloadAdjacentPages() + } + + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + isTransitioning = true } } } @@ -301,19 +363,21 @@ struct FilePreviewView: View { @Binding var showingPreview: Bool @Binding var showFileInfo: Bool let fileListDelegate: FileListDelegate? - let allFiles: [DFFile] + @Binding var allFiles: [DFFile] let currentIndex: Int let onNavigate: (Int) -> Void + let onLoadMore: (() async -> Void)? - init(file: Binding, server: Binding, showingPreview: Binding, showFileInfo: Binding, fileListDelegate: FileListDelegate?, allFiles: [DFFile], currentIndex: Int, onNavigate: @escaping (Int) -> Void) { + init(file: Binding, server: Binding, showingPreview: Binding, showFileInfo: Binding, fileListDelegate: FileListDelegate?, allFiles: Binding<[DFFile]>, currentIndex: Int, onNavigate: @escaping (Int) -> Void, onLoadMore: (() async -> Void)? = nil) { self._file = file self.server = server self._showingPreview = showingPreview self._showFileInfo = showFileInfo self.fileListDelegate = fileListDelegate - self.allFiles = allFiles + self._allFiles = allFiles self.currentIndex = currentIndex self.onNavigate = onNavigate + self.onLoadMore = onLoadMore } @State private var redirectURLs: [String: String] = [:] @@ -396,7 +460,8 @@ struct FilePreviewView: View { Task { await preloadFiles() } - } + }, + onLoadMore: onLoadMore ) .ignoresSafeArea() .background( From 2bf6216d8effbaa6ea3b496723756e64debdb519 Mon Sep 17 00:00:00 2001 From: Ralph Luaces <16159770+raluaces@users.noreply.github.com> Date: Sun, 15 Jun 2025 16:21:00 -0500 Subject: [PATCH 8/8] fix server switch bug --- Django Files/Views/TabView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Django Files/Views/TabView.swift b/Django Files/Views/TabView.swift index eb9e946..e44f70c 100644 --- a/Django Files/Views/TabView.swift +++ b/Django Files/Views/TabView.swift @@ -73,6 +73,13 @@ struct TabViewWindow: View { } .onChange(of: sessionManager.selectedSession) { oldValue, newValue in if let session = newValue { + // Clear navigation paths when switching servers + filesNavigationPath = NavigationPath() + albumsNavigationPath = NavigationPath() + + // Force view refresh + serverChangeRefreshTrigger = UUID() + sessionManager.saveSelectedSession() Task { await refreshUserData(session: session)