From f1439a5b963f1ebb605a3c3ae2488d628d5d7d51 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 11 Sep 2026 16:19:53 +0200 Subject: [PATCH 1/3] feat: add photo removal from album grid Add a context menu action with confirmation to remove album entries, including photos without local metadata, while preserving original files. Signed-off-by: Marino Faggiana --- iOSClient/Albums/API/Albums+WebDAV.swift | 13 +++++----- .../Details/AlbumDetailsScreen.swift | 7 +++++- .../Details/AlbumDetailsViewModel.swift | 25 ++++++++++++++++++- .../Presentation/Details/PhotosGridView.swift | 25 +++++++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/iOSClient/Albums/API/Albums+WebDAV.swift b/iOSClient/Albums/API/Albums+WebDAV.swift index 4ad6da448e..13665db87e 100644 --- a/iOSClient/Albums/API/Albums+WebDAV.swift +++ b/iOSClient/Albums/API/Albums+WebDAV.swift @@ -467,10 +467,11 @@ public extension NextcloudKit { // MARK: - Delete Photo from Album - /// Asynchronously deletes a Photo FromAlbum from the Nextcloud server. + /// Asynchronously removes a photo from an album without deleting the original file. /// /// - Parameters: - /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - albumName: The name of the album containing the photo. + /// - fileName: The album entry name returned by WebDAV, including its file ID prefix. /// - account: The Nextcloud account identifier. /// - options: Optional request options including headers, timeout, and queue. /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. @@ -481,7 +482,6 @@ public extension NextcloudKit { /// - error: The `NKError` result indicating success or failure. func deletePhotoFromAlbumAsync(albumName: String, fileName: String, - serverUrlFileName: String, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } @@ -493,7 +493,6 @@ public extension NextcloudKit { await withCheckedContinuation { continuation in deletePhotoFromAlbum(albumName: albumName, fileName: fileName, - serverUrlFileName: serverUrlFileName, account: account, options: options, taskHandler: taskHandler) { account, responseData, error in @@ -506,10 +505,11 @@ public extension NextcloudKit { } } - /// Deletes a Photo From Album from the Nextcloud server at the specified URL. + /// Removes a photo from an album without deleting the original file. /// /// - Parameters: - /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - albumName: The name of the album containing the photo. + /// - fileName: The album entry name returned by WebDAV, including its file ID prefix. /// - account: The Nextcloud account identifier. /// - options: Optional request options including headers, timeout, and queue. /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. @@ -519,7 +519,6 @@ public extension NextcloudKit { /// - error: The `NKError` result indicating success or failure. func deletePhotoFromAlbum(albumName: String, fileName: String, - serverUrlFileName: String, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index 79794e79af..ac5010d1e0 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -130,7 +130,12 @@ struct AlbumDetailsScreen: View { localAccount: viewModel.account, photos: viewModel.photos, onAddPhotosIntent: handleAddPhotosIntent, - album: album + album: album, + onRemovePhoto: { photo in + Task { @MainActor in + await viewModel.removePhoto(photo) + } + } ) .refreshable { viewModel.onPulledToRefresh() diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift index 430f82de3f..3d4c9af5db 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -174,6 +174,29 @@ class AlbumDetailsViewModel: ObservableObject { } } + @MainActor + func removePhoto(_ photo: AlbumPhoto) async { + guard !isLoadingPopupVisible, photos.keys.contains(photo) else { return } + + isLoadingPopupVisible = true + defer { isLoadingPopupVisible = false } + + // Remove the album entry, including when the original file has no local metadata. + let result = await NextcloudKit.shared.deletePhotoFromAlbumAsync( + albumName: album.name, + fileName: photo.fileName, + account: account + ) + + guard result.error == .success else { + await showErrorBanner(windowScene: windowScene, error: result.error) + return + } + + photos.removeValue(forKey: photo) + AlbumsManager.shared.syncAlbums() + } + @MainActor func deletePhotos(with metadatas: [tableMetadata]) async { for metadata in metadatas { if let photo = photos.first(where: { $0.value?.ocId == metadata.ocId })?.key { @@ -209,7 +232,7 @@ class AlbumDetailsViewModel: ObservableObject { let fileName: String = photo.fileName print("DEBUG: Attempting to remove: \(fileName)") - let results = await NextcloudKit.shared.deletePhotoFromAlbumAsync(albumName: album.name, fileName: fileName, serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) { task in + let results = await NextcloudKit.shared.deletePhotoFromAlbumAsync(albumName: album.name, fileName: fileName, account: metadata.account) { task in Task { let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, path: metadata.serverUrlFileName, diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index 49ac41b2cd..f881f45c2a 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -10,6 +10,9 @@ struct PhotosGridView: View { let photos: [AlbumPhoto: tableMetadata?] let onAddPhotosIntent: () -> Void let album: Album + let onRemovePhoto: (AlbumPhoto) -> Void + + @State private var photoToRemove: AlbumPhoto? private var columns: [GridItem] { if UIDevice.current.userInterfaceIdiom == .pad { @@ -42,9 +45,31 @@ struct PhotosGridView: View { iconSize: calculatedIconSize ) } + .contextMenu { + Button(role: .destructive) { + photoToRemove = photo + } label: { + Label(NSLocalizedString("_remove_from_album_", comment: ""), systemImage: "minus.circle") + } + } } } } + .alert( + NSLocalizedString("_remove_from_album_", comment: ""), + isPresented: Binding( + get: { photoToRemove != nil }, + set: { if !$0 { photoToRemove = nil } } + ), + presenting: photoToRemove + ) { photo in + Button(NSLocalizedString("_remove_from_album_", comment: ""), role: .destructive) { + onRemovePhoto(photo) + } + Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) {} + } message: { _ in + Text(NSLocalizedString("_want_remove_from_album_", comment: "")) + } } @MainActor From 794f9d97a94163dddaf77a299811348094a5fa59 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 11 Sep 2026 16:25:38 +0200 Subject: [PATCH 2/3] Build 6 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 ++-- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 67a643d71e..b1b5cf5ab0 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6955,8 +6955,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - branch = "unified-sharing"; - kind = branch; + kind = exactVersion; + version = 7.6.0; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6fbc92050e..62fa58b117 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "branch" : "unified-sharing", - "revision" : "1897d66c1687d85ec7c5acbf9fe7fd396b511257" + "revision" : "15145557690e8655227583b1461e5c45df9a6c1e", + "version" : "7.6.0" } }, { From b8952a44b55e1b931a0324b6a04d144bf6275069 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 12 Sep 2026 08:54:00 +0200 Subject: [PATCH 3/3] fix: allow media search while selecting album photos Make album photo selection ready after loading local media, then search for new media in the background. Support media reloads in the selection sheet and bump the build number to 7. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 ++-- .../NCMediaViewRepresentable.swift | 19 ++++++++----------- iOSClient/Media/NCMedia.swift | 7 +++++++ iOSClient/Media/NCMediaDataSource.swift | 6 +++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b1b5cf5ab0..e8f8596050 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6618,7 +6618,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6686,7 +6686,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 09fa958e75..7bda66909c 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -59,12 +59,16 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { viewIfLoaded?.window?.windowScene } + override var allowsSearchWhileSelecting: Bool { true } + + override var isMediaPresentationActive: Bool { isViewActived } + override func viewDidLoad() { super.viewDidLoad() // Keep Media's selection bookkeeping without its share/move/delete toolbar. tabBarSelect = NCMediaSelectTabBar(viewController: self) collectionView.dragInteractionEnabled = false - collectionView.isUserInteractionEnabled = false + isEditMode = true } override func viewWillAppear(_ animated: Bool) { @@ -80,22 +84,15 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { prepareTask = Task { @MainActor [weak self] in guard let self else { return } defer { self.prepareTask = nil } + // This controller is presented in a sheet, not as the selected Media tab. await self.loadDataSource(forced: true) guard !Task.isCancelled else { return } - // The normal search API requires an attached view outside edit mode. - await self.searchMediaTask?.value - guard !Task.isCancelled else { return } - await self.searchMediaUI(true) - guard !Task.isCancelled else { return } - // Search updates the database; its normal reload also requires the Media tab. - await self.loadDataSource(forced: true) - guard !Task.isCancelled else { return } - self.collectionViewReloadData() - self.isEditMode = true self.selectionReady = true self.collectionView.isUserInteractionEnabled = true self.onReady?() + + self.searchNewMedia() } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index ea1f63c9a3..9f7cdd0310 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -122,6 +122,13 @@ class NCMedia: UIViewController { return self.isViewLoaded && self.view.window != nil } + // Album selection presents Media in a sheet and keeps searching while selecting. + var allowsSearchWhileSelecting: Bool { false } + + var isMediaPresentationActive: Bool { + isViewActived && tabBarController?.selectedViewController === navigationController + } + var isPinchGestureActive: Bool { return pinchGesture.state == .began || pinchGesture.state == .changed } diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 213655d874..f533d666f5 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -68,7 +68,7 @@ extension NCMedia { self.isViewActived && self.session.account == account && self.view.window != nil && - self.tabBarController?.selectedViewController === self.navigationController + self.isMediaPresentationActive ) } @@ -91,7 +91,7 @@ extension NCMedia { self.isViewActived && self.session.account == account && self.view.window != nil && - self.tabBarController?.selectedViewController === self.navigationController + self.isMediaPresentationActive ) else { return } @@ -200,7 +200,7 @@ extension NCMedia { !self.isPinchGestureActive, !self.showOnlyImages, !self.showOnlyVideos, - !self.isEditMode else { + (!self.isEditMode || self.allowsSearchWhileSelecting) else { return false }