From 635aaa9558d0b04ea14a6563cd6c976b3019a4c7 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 14 Nov 2026 15:43:37 +0100 Subject: [PATCH 1/3] fix: validate recent share destinations before selection Retry pending folder selection after unlocking or refreshing, falling back to the root when validation fails. Refine browser control availability and improve conflict action button sizing and spacing. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Folders.swift | 17 +++++++++++++---- Share/Model/NCShareViewModel.swift | 5 +++++ Share/View/NCShareUploadButton.swift | 3 ++- Share/View/NCShareUploadConflictView.swift | 8 +++++--- Share/View/NCShareView.swift | 10 ++++++---- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index aa051dc6bb..5ec2e966c2 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -63,7 +63,8 @@ extension NCShareViewModel { guard isCurrentBrowser(browserID, account: session.account) else { return } // Publish the replacement snapshot together, without an empty intermediate list. rootFolder = root - selectedFolder = root + // Leave the destination unset while the most recent folder is awaiting validation. + selectedFolder = recent.isEmpty ? root : nil isRecentFolderSelected = false expandedFolderURLs = [home] recentFolders = recent @@ -72,7 +73,7 @@ extension NCShareViewModel { func refreshInitialFolderBrowser() { guard let rootFolder else { return } - if let latest = recentFolders.first { + if selectedFolder == nil, let latest = recentFolders.first { selectFolder(latest, fromRecents: true, automatically: true) } requestFolderChildren(rootFolder, refresh: true) @@ -109,13 +110,21 @@ extension NCShareViewModel { /// fromRecents controls which of the two occurrences displays the checkmark. func selectFolder(_ folder: NCShareFolderItem, fromRecents: Bool = false, automatically: Bool = false) { guard canSelectFolder, automatically || !isLoadingAccounts, let capabilities = serverCapabilities else { return } - guard canAccessFolder(folder, showError: !automatically) else { return } + guard canAccessFolder(folder, showError: !automatically) else { + if automatically { selectedFolder = rootFolder } + return + } let session = sessionStore.getSession() let browserID = folderBrowserID isLoading = true folderSelectionTask = Task { @MainActor in defer { - if folderBrowserID == browserID { isLoading = false } + if isCurrentBrowser(browserID, account: session.account) { + if automatically, isAuthenticated, selectedFolder == nil { + selectedFolder = rootFolder + } + isLoading = false + } } // Recent destinations may have been deleted or moved since last use. let result = await NCNetworking.shared.readFileAsync(serverUrlFileName: folder.serverUrl, account: session.account) diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index d03cb32539..c23d8ee7ae 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -192,6 +192,8 @@ final class NCShareViewModel: NSObject, ObservableObject { guard !isOffline, isAuthenticated, !isClosing, !isSwitchingAccount, !isPreparingUpload else { return } if serverCapabilities == nil { retryCapabilities() + } else if selectedFolder == nil { + refreshInitialFolderBrowser() } else { for row in visibleFolderRows where expandedFolderURLs.contains(row.folder.serverUrl) { requestFolderChildren(row.folder, refresh: true) @@ -467,6 +469,9 @@ final class NCShareViewModel: NSObject, ObservableObject { guard self.validateAccount() else { return } self.access = .unlocked if self.accounts.isEmpty { self.loadAccounts() } + if self.selectedFolder == nil, self.serverCapabilities != nil { + self.refreshInitialFolderBrowser() + } } } } diff --git a/Share/View/NCShareUploadButton.swift b/Share/View/NCShareUploadButton.swift index ca82b2e1b0..0bca9ae715 100644 --- a/Share/View/NCShareUploadButton.swift +++ b/Share/View/NCShareUploadButton.swift @@ -9,6 +9,7 @@ struct NCShareUploadButton: View { let fileCount: Int var isPreparing = false var title: String? + var minimumLabelHeight: CGFloat? let action: @MainActor () -> Void var body: some View { @@ -25,7 +26,7 @@ struct NCShareUploadButton: View { .lineLimit(2) .truncationMode(.middle) .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: minimumLabelHeight) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 12)) diff --git a/Share/View/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift index 118f3437d7..1913288136 100644 --- a/Share/View/NCShareUploadConflictView.swift +++ b/Share/View/NCShareUploadConflictView.swift @@ -8,6 +8,7 @@ import SwiftUI struct NCShareUploadConflictView: View { let confirmation: NCShareUploadConfirmation let resolve: @MainActor (NCShareUploadChoice?) -> Void + private let actionLabelHeight: CGFloat = 44 var body: some View { let renamedItems = confirmation.renamedItems @@ -47,14 +48,14 @@ struct NCShareUploadConflictView: View { .padding(.vertical, 12) } .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 16) { + VStack(spacing: 24) { if !confirmation.isBatch, confirmation.replacementFileName != nil { Button(role: .destructive) { resolve(.replace) } label: { Text(NSLocalizedString("_share_conflict_replace_", comment: "")) .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: actionLabelHeight) } .buttonStyle(.bordered) .buttonBorderShape(.roundedRectangle(radius: 12)) @@ -64,7 +65,8 @@ struct NCShareUploadConflictView: View { NCShareUploadButton( fileCount: confirmation.items.count, - title: confirmation.isBatch ? nil : NSLocalizedString("_share_conflict_upload_with_new_name_", comment: "") + title: confirmation.isBatch ? nil : NSLocalizedString("_share_conflict_upload_with_new_name_", comment: ""), + minimumLabelHeight: actionLabelHeight ) { resolve(.keepBoth) } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 2cb146b121..350cfca6ec 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -201,8 +201,8 @@ struct NCShareView: View { .frame(maxHeight: .infinity) .clipped() .background(Color(uiColor: .systemGroupedBackground)) - .opacity(isRefreshing ? 0.45 : 1) - .disabled(isRefreshing || model.isPreparingUpload || !model.isAccountReady) + .opacity(model.isSwitchingAccount ? 0.45 : 1) + .disabled(model.isSwitchingAccount) } private func folderListTitle(_ key: String) -> some View { @@ -250,6 +250,7 @@ struct NCShareView: View { .contentShape(Rectangle()) } .buttonStyle(.borderless) + .disabled(!model.canUseFolderBrowser) .accessibilityLabel(String(format: NSLocalizedString( model.expandedFolderURLs.contains(folder.serverUrl) ? "_share_collapse_folder_" : "_share_expand_folder_", comment: ""), folder.name)) .padding(.leading, CGFloat(min(depth, 8)) * 16) @@ -314,6 +315,7 @@ struct NCShareView: View { .contentShape(Rectangle()) } .buttonStyle(.borderless) + .disabled(!model.canUseFolderBrowser) .accessibilityAddTraits(isSelected ? .isSelected : []) } .background { @@ -388,8 +390,8 @@ struct NCShareView: View { .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, horizontalInset) - .opacity(isRefreshing ? 0.45 : 1) - .disabled(isRefreshing || model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || !model.inputErrorMessage.isEmpty) + .opacity(model.isSwitchingAccount ? 0.45 : 1) + .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || !model.inputErrorMessage.isEmpty) } private var uploadButton: some View { From 5ba5b8fed0ecd4d90bc67d1a820a24b35e47e600 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 15 Nov 2026 09:45:11 +0100 Subject: [PATCH 2/3] fix: stabilize share folder rows and guard actions during loading Keep folder selection indicators and backgrounds in place to avoid layout shifts, and remove the toolbar spinner. Guard account, folder and upload actions while loading. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 2 +- Share/Model/NCShareViewModel+Folders.swift | 8 ++-- Share/Model/NCShareViewModel+Upload.swift | 2 +- Share/Model/NCShareViewModel.swift | 5 +- Share/View/NCShareView.swift | 53 ++++++++------------- 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 7d299bf216..4524fa26c3 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -34,7 +34,7 @@ extension NCShareViewModel { } func selectAccount(_ account: String) { - guard isAuthenticated, !isClosing, !isPreparingUpload, !isSwitchingAccount, + guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, accounts.contains(where: { $0.id == account }) else { return } guard account != sessionStore.getSession().account else { return } changeAccount(account) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 5ec2e966c2..515a6d31b9 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -80,7 +80,7 @@ extension NCShareViewModel { } func toggleFolder(_ folder: NCShareFolderItem) { - guard canUseFolderBrowser, canAccessFolder(folder) else { return } + guard canUseFolderBrowser, !isLoading, canAccessFolder(folder) else { return } if expandedFolderURLs.contains(folder.serverUrl) { expandedFolderURLs.remove(folder.serverUrl) } else { @@ -89,7 +89,7 @@ extension NCShareViewModel { } func retryFolder(_ folder: NCShareFolderItem) { - guard canUseFolderBrowser, canAccessFolder(folder) else { return } + guard canUseFolderBrowser, !isLoading, canAccessFolder(folder) else { return } expandedFolderURLs.insert(folder.serverUrl) requestFolderChildren(folder, refresh: true) } @@ -109,7 +109,7 @@ extension NCShareViewModel { /// Validates the destination on the server before selecting it. /// fromRecents controls which of the two occurrences displays the checkmark. func selectFolder(_ folder: NCShareFolderItem, fromRecents: Bool = false, automatically: Bool = false) { - guard canSelectFolder, automatically || !isLoadingAccounts, let capabilities = serverCapabilities else { return } + guard canSelectFolder, !isLoading, automatically || !isLoadingAccounts, let capabilities = serverCapabilities else { return } guard canAccessFolder(folder, showError: !automatically) else { if automatically { selectedFolder = rootFolder } return @@ -163,7 +163,7 @@ extension NCShareViewModel { } private var canSelectFolder: Bool { - isAuthenticated && !isClosing && isAccountReady && !isLoading && !isPreparingUpload + isAuthenticated && !isClosing && isAccountReady && !isPreparingUpload } private func canAccessFolder(_ folder: NCShareFolderItem, showError: Bool = true) -> Bool { diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 77d4528467..36fffd0d6c 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -17,7 +17,7 @@ extension NCShareViewModel { /// Upload flow: inspect the destination, resolve name conflicts if needed, /// stage local copies, then transfer. Keep editing disabled throughout this flow. func upload() { - guard canUpload, let capabilities = serverCapabilities else { return } + guard canUpload, !isLoading, folderTasks.isEmpty, let capabilities = serverCapabilities else { return } runUpload { [self] in let plan = try await prepareUploadPlan(capabilities: capabilities) try Task.checkCancellation() diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index c23d8ee7ae..d407b67590 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -58,7 +58,7 @@ final class NCShareViewModel: NSObject, ObservableObject { var canUseUploadDestination: Bool { guard let selectedFolder else { return false } - return canUseFolderBrowser && !isLoadingCapabilities && folderTasks.isEmpty && + return canUseFolderBrowser && !isLoadingCapabilities && folderLoadErrors[selectedFolder.serverUrl] == nil && alertMessage.isEmpty && !isPreparingInput && inputErrorMessage.isEmpty } @@ -361,7 +361,8 @@ final class NCShareViewModel: NSObject, ObservableObject { } func createFolder() { - guard canUseUploadDestination, let folder = selectedFolder, let capabilities = serverCapabilities, + guard canUseUploadDestination, !isLoading, folderTasks.isEmpty, + let folder = selectedFolder, let capabilities = serverCapabilities, let presenter, presenter.viewIfLoaded?.window != nil, presenter.presentedViewController == nil, !presenter.isBeingDismissed else { return } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 350cfca6ec..8c169e2c4f 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -11,11 +11,6 @@ struct NCShareView: View { private var canShowContent: Bool { model.rootFolder != nil && model.isAuthenticated } - private var isRefreshing: Bool { - model.isLoading || model.isSwitchingAccount || model.isLoadingAccounts || - model.isLoadingCapabilities || !model.folderTasks.isEmpty - } - var body: some View { NavigationStack { VStack(spacing: 0) { @@ -78,7 +73,7 @@ struct NCShareView: View { if canShowContent { let account = ToolbarItem(placement: .topBarLeading) { NCShareAccountMenu(model: model) - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + .disabled(model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) } if #available(iOS 27.1, *) { account.axisBehavior(.horizontalOnly) @@ -87,18 +82,6 @@ struct NCShareView: View { account } } - if canShowContent && isRefreshing { - let spinner = ToolbarItem(placement: .topBarLeading) { - ProgressView() - .controlSize(.regular) - .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) - } - if #available(iOS 27.1, *) { - spinner.axisBehavior(.horizontalOnly) - } else { - spinner - } - } ToolbarItem(placement: .topBarTrailing) { Button(NSLocalizedString("_cancel_", comment: "")) { model.cancel() } } @@ -246,11 +229,10 @@ struct NCShareView: View { model.toggleFolder(folder) } label: { Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") + .foregroundStyle(Color.accentColor) .frame(width: 50, height: 50) .contentShape(Rectangle()) } - .buttonStyle(.borderless) - .disabled(!model.canUseFolderBrowser) .accessibilityLabel(String(format: NSLocalizedString( model.expandedFolderURLs.contains(folder.serverUrl) ? "_share_collapse_folder_" : "_share_expand_folder_", comment: ""), folder.name)) .padding(.leading, CGFloat(min(depth, 8)) * 16) @@ -305,31 +287,38 @@ struct NCShareView: View { } } Spacer(minLength: 4) - if isSelected { - Image(systemName: "checkmark") - .frame(width: 50, height: 50) - .accessibilityHidden(true) - } + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + .frame(width: 50, height: 50) + .opacity(isSelected ? 1 : 0) + .accessibilityHidden(true) } .frame(minHeight: 50) .contentShape(Rectangle()) } - .buttonStyle(.borderless) - .disabled(!model.canUseFolderBrowser) .accessibilityAddTraits(isSelected ? .isSelected : []) } + .buttonStyle(FolderRowButtonStyle()) + .disabled(!model.canUseFolderBrowser) .background { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.1)) - .padding(.horizontal, 5) - } + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(isSelected ? 0.1 : 0)) + .padding(.horizontal, 5) } .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2)) .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) } + private struct FolderRowButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(isEnabled ? 1 : 0.55) + } + } + private func fileList(fillsHeight: Bool = false, horizontalInset: CGFloat = 16) -> some View { List(model.fileNames, id: \.self) { fileName in Button { From 5b8b0a72e362d1ec15dcd336b7d04b3a79e10dce Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 15 Nov 2026 09:52:30 +0100 Subject: [PATCH 3/3] fix: use consistent file terminology in English strings Signed-off-by: Marino Faggiana --- .../en.lproj/Localizable.strings | 24 +++++++++---------- .../en.lproj/Localizable.stringsdict | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 5e4b9868b7..9253621922 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -449,7 +449,7 @@ "_empty_trash_" = "Empty trash"; "_trash_no_trash_" = "No files deleted"; "_trash_no_trash_description_" = "You can restore deleted files from here"; -"_confirm_delete_selected_" = "Are you sure you want to delete the selected items?"; +"_confirm_delete_selected_" = "Are you sure you want to delete the selected files?"; "_set_available_offline_" = "Set as available offline"; "_remove_available_offline_" = "Remove as available offline"; "_create_new_document_" = "Document"; @@ -570,7 +570,7 @@ "_additional_options_" = "Additional options"; "_unauthorized_files_passcode_" = "Files app cannot be used with an activated passcode"; "_files_app_disabled_" = "Files app cannot be used because it is disabled"; -"_no_items_" = "No items"; +"_no_items_" = "Nothing to show"; "_check_back_later_" = "Check back later"; "_no_data_available_" = "No data available"; "_recent_activity_" = "Recent activity"; @@ -603,14 +603,14 @@ "_finalizing_wait_" = "Waiting for finalization …"; "_in_this_folder_" = "In this folder"; "_media_no_longer_available_" = "Media no longer available"; -"_this_item_has_been_deleted_" = "This item has been deleted."; +"_this_item_has_been_deleted_" = "This file has been deleted."; "_video_not_available_" = "Video not available"; "_disable_" = "Disable"; "_no_subtitles_available_" = "No subtitles available"; "_no_audio_tracks_available_" = "No audio tracks available"; "_add_external_subtitle_" = "Add external subtitle"; -"_repeat_current_media_" = "Repeat current item"; -"_play_next_media_automatically_" = "Play next item automatically"; +"_repeat_current_media_" = "Repeat current file"; +"_play_next_media_automatically_" = "Play next file automatically"; "_previous_media_" = "Previous"; "_next_media_" = "Next"; "_play_" = "Play"; @@ -694,7 +694,7 @@ "_multiple_albums_" = "Multiple albums"; "_smart_albums_" = "Smart albums"; "_albums_" = "Albums"; -"_auto_upload_no_new_items_to_upload_" = "No new items to upload"; +"_auto_upload_no_new_items_to_upload_" = "No new files to upload"; "_background_upload_extension_" = "PhotoKit background upload extension"; "_experimental_" = "Experimental"; "_background_upload_extension_footer_" = "Experimental: delegate automatic photo uploads to PhotoKit in the background. The compatibility setting (HEIC to JPG) is ignored, so HEIC images are uploaded in their original format."; @@ -715,12 +715,12 @@ "_focused_auto_upload_countdown_" = "Do not lock the screen or exit the app. The screen will turn dark in %d seconds."; "_stop_focused_auto_upload_" = "Stop Focused Auto Upload"; "_finish_" = "Finish"; -"_item_with_same_name_already_exists_" = "An item with the same name already exists."; +"_item_with_same_name_already_exists_" = "This name is already in use."; // MARK: Migration Multi Domains "_preparing_migration_" = "Preparing migration …"; "_scanning_files_" = "Scanning files …"; -"_moving_items_to_domain_" = "Moving items to correct domain …"; +"_moving_items_to_domain_" = "Moving files to the correct domain…"; "_finishing_up_" = "Finishing up …"; // MARK: E2EE @@ -839,13 +839,13 @@ /* Share extension input preparation */ "_share_import_preparing_" = "Preparing shared files…"; -"_share_import_failed_" = "Some shared items could not be prepared. Review the files before uploading."; -"_share_import_no_files_" = "The shared items could not be prepared."; -"_share_import_item_error_" = "Item %d: %@"; +"_share_import_failed_" = "Some shared files could not be prepared. Review the files before uploading."; +"_share_import_no_files_" = "The shared files could not be prepared."; +"_share_import_item_error_" = "File %d: %@"; /* Share extension upload conflicts */ "_share_conflict_title_" = "Files already exist"; -"_share_conflict_single_message_" = "An item named \"%@\" already exists in this folder."; +"_share_conflict_single_message_" = "A file named \"%@\" already exists in this folder."; "_share_conflict_batch_message_" = "Some names are already in use. These files will be uploaded with new names, keeping the existing files."; "_share_conflict_new_names_" = "New file names"; "_share_conflict_replace_" = "Replace existing file"; diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict index 767f642f68..b2d946f1fa 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict +++ b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict @@ -61,9 +61,9 @@ NSStringFormatValueTypeKey d one - You have %d item to back up + You have %d file to back up other - You have %d items to back up + You have %d files to back up _focused_auto_upload_failed_