diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 4b68394076..d33e4f91d1 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -160,7 +160,12 @@ F3BB46522A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB46512A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift */; }; F3BB46542A3A1E9D00461F6E /* CCCellMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */; }; F3C587AE2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */; }; + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */; }; F3CA337D2D0B2B6C00672333 /* AlbumModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */; }; + F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */; }; + F3DDFE1E2F1F8EC600A784C8 /* ChatInputField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */; }; + F3DDFE212F1F953000A784C8 /* NCAssistantChatConversations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */; }; + F3DDFE232F1FB4C300A784C8 /* NCAssistantChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */; }; F3E173B02C9AF637006D177A /* ScreenAwakeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173AF2C9AF637006D177A /* ScreenAwakeManager.swift */; }; F3E173C02C9B1067006D177A /* AwakeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173BF2C9B1067006D177A /* AwakeMode.swift */; }; F3E173C12C9B1067006D177A /* AwakeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173BF2C9B1067006D177A /* AwakeMode.swift */; }; @@ -1267,7 +1272,12 @@ F3BB46512A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMoreAppSuggestionsCell.swift; sourceTree = ""; }; F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCCellMore.swift; sourceTree = ""; }; F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHAssetCollectionThumbnailLoader.swift; sourceTree = ""; }; + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatConversationsModel.swift; sourceTree = ""; }; F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumModel.swift; sourceTree = ""; }; + F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChat.swift; sourceTree = ""; }; + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputField.swift; sourceTree = ""; }; + F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatConversations.swift; sourceTree = ""; }; + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatModel.swift; sourceTree = ""; }; F3E173AF2C9AF637006D177A /* ScreenAwakeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAwakeManager.swift; sourceTree = ""; }; F3E173BF2C9B1067006D177A /* AwakeMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AwakeMode.swift; sourceTree = ""; }; F3F442ED2DDE292600FD701F /* NCMetadataPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMetadataPermissions.swift; sourceTree = ""; }; @@ -2107,6 +2117,7 @@ F3374A7F2D64AB40002A38F9 /* Components */ = { isa = PBXGroup; children = ( + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */, F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */, F3374A802D64AB9E002A38F9 /* StatusInfo.swift */, F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */, @@ -2138,10 +2149,12 @@ isa = PBXGroup; children = ( F3A047962BD2668800658E7B /* NCAssistant.swift */, + F3A047932BD2668800658E7B /* NCAssistantModel.swift */, + F3DDFE0D2F15452F00A784C8 /* Chat */, + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */, F3A047902BD2668800658E7B /* Create Task */, F3A047942BD2668800658E7B /* Task Detail */, F3374A7F2D64AB40002A38F9 /* Components */, - F3A047922BD2668800658E7B /* Models */, ); path = Assistant; sourceTree = ""; @@ -2154,14 +2167,6 @@ path = "Create Task"; sourceTree = ""; }; - F3A047922BD2668800658E7B /* Models */ = { - isa = PBXGroup; - children = ( - F3A047932BD2668800658E7B /* NCAssistantModel.swift */, - ); - path = Models; - sourceTree = ""; - }; F3A047942BD2668800658E7B /* Task Detail */ = { isa = PBXGroup; children = ( @@ -2181,6 +2186,24 @@ path = Cells; sourceTree = ""; }; + F3DDFE0D2F15452F00A784C8 /* Chat */ = { + isa = PBXGroup; + children = ( + F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */, + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */, + ); + path = Chat; + sourceTree = ""; + }; + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */ = { + isa = PBXGroup; + children = ( + F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */, + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */, + ); + path = "Chat Sessions"; + sourceTree = ""; + }; F3E173BE2C9B1057006D177A /* ScreenAwakeManager */ = { isa = PBXGroup; children = ( @@ -4412,6 +4435,7 @@ F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */, F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */, F702F30825EE5D47008F8E80 /* NCPopupViewController.swift in Sources */, + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift in Sources */, F76340FC2EBDF64D0056F538 /* NCManageDatabase+Tag.swift in Sources */, F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */, 370D26AF248A3D7A00121797 /* NCCellMain.swift in Sources */, @@ -4583,6 +4607,7 @@ F7816EF22C2C3E1F00A52517 /* NCPushNotification.swift in Sources */, F76882342C0DD1E7001CF441 /* NCDisplayView.swift in Sources */, F7C30DF6291BC0CA0017149B /* NCNetworkingE2EEUpload.swift in Sources */, + F3DDFE232F1FB4C300A784C8 /* NCAssistantChatModel.swift in Sources */, F7501C332212E57500FB1415 /* NCMedia.swift in Sources */, F7411C552D7B26D700F57358 /* NCNetworking+ServerError.swift in Sources */, F72944F22A84246400246839 /* NCEndToEndMetadataV20.swift in Sources */, @@ -4699,7 +4724,9 @@ F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */, F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */, F76882232C0DD1E7001CF441 /* NCCapabilitiesModel.swift in Sources */, + F3DDFE212F1F953000A784C8 /* NCAssistantChatConversations.swift in Sources */, F7E2B64F2DDCC5C30075B4D0 /* NCMedia+TransferDelegate.swift in Sources */, + F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */, F7D68FCC28CB9051009139F3 /* NCManageDatabase+DashboardWidget.swift in Sources */, F76882292C0DD1E7001CF441 /* NCManageE2EEModel.swift in Sources */, F799DF8B2C4B84EB003410B5 /* NCCollectionViewCommon+EndToEndInitialize.swift in Sources */, @@ -4723,6 +4750,7 @@ F76882272C0DD1E7001CF441 /* NCManageE2EEView.swift in Sources */, F7864ACC2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift in Sources */, F7327E302B73A86700A462C7 /* NCNetworking+WebDAV.swift in Sources */, + F3DDFE1E2F1F8EC600A784C8 /* ChatInputField.swift in Sources */, F7D61EA82EBF1694007F865B /* NCManageDatabase+TableCapabilities.swift in Sources */, F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */, F70D8D8124A4A9BF000A5756 /* NCNetworkingProcess.swift in Sources */, @@ -5597,10 +5625,12 @@ ALWAYS_SEARCH_USER_PATHS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Brand/iOSClient.entitlements"; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = NKUJUXUJ3B; GCC_SYMBOLS_PRIVATE_EXTERN = YES; INFOPLIST_FILE = "$(SRCROOT)/Brand/iOSClient.plist"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 33.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "it.twsweb.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5620,10 +5650,12 @@ ALWAYS_SEARCH_USER_PATHS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Brand/iOSClient.entitlements"; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = NKUJUXUJ3B; GCC_SYMBOLS_PRIVATE_EXTERN = YES; INFOPLIST_FILE = "$(SRCROOT)/Brand/iOSClient.plist"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 33.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "it.twsweb.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5747,7 +5779,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -5813,7 +5845,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = NKUJUXUJ3B; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; diff --git a/Tests/NextcloudUITests/AssistantUITests.swift b/Tests/NextcloudUITests/AssistantUITests.swift index bce341aed9..51835393ea 100644 --- a/Tests/NextcloudUITests/AssistantUITests.swift +++ b/Tests/NextcloudUITests/AssistantUITests.swift @@ -50,7 +50,7 @@ final class AssistantUITests: BaseUIXCTestCase { } private func createTask(input: String) { - app.navigationBars["Assistant"].buttons["CreateButton"].tap() + app.navigationBars["Assistant"].buttons["ConversationsButton"].tap() let inputTextEditor = app.textViews["InputTextEditor"] inputTextEditor.await() diff --git a/iOSClient/Account/NCAccount.swift b/iOSClient/Account/NCAccount.swift index dd13742efe..e9d8d20190 100644 --- a/iOSClient/Account/NCAccount.swift +++ b/iOSClient/Account/NCAccount.swift @@ -194,7 +194,7 @@ class NCAccount: NSObject { return } - await showErrorBanner(controller: controller, text: "_account_unauthorized_", errorCode: NCGlobal.shared.errorUnauthorized401) + await showErrorBanner(controller: controller, text: String(format: NSLocalizedString("_account_unauthorized_", comment: ""), account), errorCode: NCGlobal.shared.errorUnauthorized401) let resultsWipe = await NextcloudKit.shared.getRemoteWipeStatusAsync(serverUrl: tblAccount.urlBase, token: token, account: account) { task in Task { diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift new file mode 100644 index 0000000000..5fd16cf93d --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftUI +import NextcloudKit + +struct NCAssistantChatConversations: View { + var conversationsModel: NCAssistantChatConversationsModel + var selectedConversation: AssistantConversation? + var onConversationSelected: (AssistantConversation?) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + List(conversationsModel.conversations, id: \.id) { conversation in + Button { + onConversationSelected(conversation) + dismiss() + } label: { + HStack { + Text(conversation.validTitle) + Spacer() + if selectedConversation?.id == conversation.id { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + .contentShape(Rectangle()) + } + } + } + .navigationTitle(NSLocalizedString("_conversations_", comment: "")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("_new_conversation_", systemImage: "plus.message.fill") { + Task { + let session = await conversationsModel.createNewConversation() + onConversationSelected(session) + dismiss() + } + } + } + } + } +} + +#Preview { + NCAssistantChatConversations(conversationsModel: NCAssistantChatConversationsModel(controller: nil), selectedConversation: nil, onConversationSelected: { _ in }) +} diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift new file mode 100644 index 0000000000..4ad1bb1d70 --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +@Observable class NCAssistantChatConversationsModel { + var conversations: [AssistantConversation] = [] + var isLoading: Bool = false + var hasError: Bool = false + + private let ncSession: NCSession.Session + + init(controller: NCMainTabBarController?) { + self.ncSession = NCSession.shared.getSession(controller: controller) + loadAllSessions() + } + + func loadAllSessions() { + Task { + let result = await NextcloudKit.shared.getAssistantChatConversations(account: ncSession.account) + conversations = result.sessions ?? [] + } + } + + func createNewConversation(title: String? = nil) async -> AssistantConversation? { + let timestamp = Int(Date().timeIntervalSince1970) + let result = await NextcloudKit.shared.createAssistantChatConversation(title: title, timestamp: timestamp, account: ncSession.account) + if result.error == .success, let newConversation = result.conversation?.conversation { + conversations.insert(newConversation, at: 0) + return newConversation + } else { + hasError = true + return nil + } + } +} diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift new file mode 100644 index 0000000000..184754418f --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +struct NCAssistantChat: View { + @Environment(NCAssistantChatModel.self) var chatModel + @Binding var conversationsModel: NCAssistantChatConversationsModel + + var body: some View { + @Bindable var chatModel = chatModel + + if chatModel.messages.isEmpty { + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_no_chat_subtitle_") + } + + ZStack { + VStack(spacing: 0) { + messageListView + } + + } + .safeAreaInset(edge: .bottom) { + ChatInputField(isLoading: $chatModel.isSending, isDisabled: $chatModel.isSendingDisabled) { input in + if chatModel.selectedConversation != nil { + chatModel.sendMessage(input: input) + } else { + chatModel.startNewConversationViaMessage(input: input, sessionsModel: conversationsModel) + } + } + } + .navigationTitle(NSLocalizedString("_assistant_chat_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + } + + private var messageListView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(chatModel.messages) { message in + MessageBubbleView(message: message, account: chatModel.controller?.account ?? "") + .id(message.id) + } + + if chatModel.isThinking { + ThinkingBubbleView() + .id("thinking") + } + + if chatModel.showRetryResponseGenerationButton { + let button = Button("_retry_response_generation_") { + chatModel.onRetryResponseGeneration() + } + .frame(maxWidth: .infinity) + .padding() + + if #available(iOS 26.0, *) { + button + .buttonStyle(.glass) + } else { + button + .buttonStyle(.bordered) + } + } + } + .padding(.vertical) + } + .onChange(of: chatModel.messages.count) { _, _ in + withAnimation { + if let lastMessage = chatModel.messages.last { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + .onChange(of: chatModel.isThinking) { _, isThinking in + if isThinking { + withAnimation { + proxy.scrollTo("thinking", anchor: .bottom) + } + } + } + } + } +} + +// MARK: - Message Bubble View + +struct MessageBubbleView: View { + let message: AssistantChatMessage + let account: String + + var body: some View { + HStack { + if message.isFromHuman { + Spacer(minLength: 50) + } + + VStack(alignment: message.isFromHuman ? .trailing : .leading, spacing: 4) { + Text(message.content) + .font(.body) + .foregroundStyle(message.isFromHuman ? .white : .primary) + .padding() + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16)) + + Text(NCUtility().getRelativeDateTitle(Date(timeIntervalSince1970: TimeInterval(message.timestamp)))) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity, alignment: message.isFromHuman ? .trailing : .leading) + .padding(.horizontal) + + if !message.isFromHuman { + Spacer(minLength: 50) + } + } + } + + private var bubbleBackground: Color { + if message.isFromHuman { + return Color(NCBrandColor.shared.getElement(account: account)) + } else { + return Color(NCBrandColor.shared.textColor2).opacity(0.1) + } + } +} + +// MARK: - Thinking Bubble View + +struct ThinkingBubbleView: View { + @State private var scale1: CGFloat = 1.0 + @State private var scale2: CGFloat = 1.0 + @State private var scale3: CGFloat = 1.0 + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale1) + + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale2) + + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale3) + } + .padding() + .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) + .clipShape(.rect(cornerRadius: 16)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { + scale1 = 1.3 + } + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true).delay(0.15)) { + scale2 = 1.3 + } + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true).delay(0.3)) { + scale3 = 1.3 + } + } +} + +// MARK: - Empty Chat View + +struct EmptyChatView: View { + @Environment(NCAssistantChatModel.self) var chatModel + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "bubble.left.and.bubble.right") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: chatModel.controller?.account))) + .font(Font.system(.body).weight(.light)) + .frame(height: 100) + + Text(NSLocalizedString("_start_conversation_", comment: "")) + .font(.system(size: 22, weight: .bold)) + .padding(.bottom, 5) + + Text(NSLocalizedString("_ask_assistant_anything_", comment: "")) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) + .environment(NCAssistantChatModel(controller: nil)) + .environment(NCAssistantModel(controller: nil)) + } +} + +#Preview("With Messages") { + NavigationStack { + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) + .environment(NCAssistantChatModel.example) + .environment(NCAssistantModel(controller: nil)) + } +} diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift new file mode 100644 index 0000000000..140f50870d --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit + +@Observable class NCAssistantChatModel { + var messages: [AssistantChatMessage] = [] + var isSending: Bool = false + var isThinking: Bool = false + var isSendingDisabled = false + var hasError: Bool = false + var showRetryResponseGenerationButton = false + var showMessageNotSentError: Bool = false + + public private(set) var selectedConversation: AssistantConversation? + + var currentSession: AssistantSession? + + private let ncSession: NCSession.Session + private var pollingTask: Task? + + @ObservationIgnored var controller: NCMainTabBarController? + @ObservationIgnored private var chatMessageTaskId: Int? + + init(controller: NCMainTabBarController?, messages: [AssistantChatMessage] = []) { + self.controller = controller + self.ncSession = NCSession.shared.getSession(controller: controller) + self.messages = messages + } + + func startPollingForResponse(interval: TimeInterval = 4.0) { + stopPolling() + isSendingDisabled = true + isThinking = true + showRetryResponseGenerationButton = false + + pollingTask = Task { + while !Task.isCancelled { + + await loadLastMessage() + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + isThinking = false + isSendingDisabled = false + } + + func selectConversation(selectedConversation: AssistantConversation) async { + self.selectedConversation = selectedConversation + + stopPolling() + showRetryResponseGenerationButton = false + currentSession = nil + + await loadAllMessages() + currentSession = await checkChatSession(sessionId: selectedConversation.id) + chatMessageTaskId = currentSession?.messageTaskId + + if messages.last?.isFromHuman == true, chatMessageTaskId == nil, isSending == false { + showRetryResponseGenerationButton = true + } else if chatMessageTaskId != nil { + startPollingForResponse() + } + } + + func generateChatSession() async { + guard let sessionId = selectedConversation?.id else { return } + + let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) + chatMessageTaskId = result.sessionTask?.taskId + } + + func onRetryResponseGeneration() { + Task { + await generateChatSession() + startPollingForResponse() + } + } + + private func checkChatSession(sessionId: Int) async -> AssistantSession? { + let result = await NextcloudKit.shared.checkAssistantChatSession(sessionId: sessionId, account: ncSession.account) + return result.session + } + + private func loadAllMessages() async { + guard let sessionId = selectedConversation?.id else { return } + + let result = await NextcloudKit.shared.getAssistantChatMessages(sessionId: sessionId, account: ncSession.account) + + if result.error == .success { + messages = result.chatMessages ?? [] + } else { + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_load_messages_", errorCode: result.error.errorCode) + } + } + + private func loadLastMessage() async { + guard let chatMessageTaskId else { return } + + let result = await NextcloudKit.shared.checkAssistantChatGeneration(taskId: chatMessageTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) + + if result.error != .success { + stopPolling() + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_generate_response_", errorCode: result.error.errorCode) + return + } + + if let lastMessage = result.chatMessage, lastMessage.role == "assistant" { + stopPolling() + messages.append(lastMessage) + } + } + + func sendMessage(input: String) { + guard let selectedConversation else { return } + + let request = AssistantChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) + isSending = true + isSendingDisabled = true + + Task { + let result = await NextcloudKit.shared.createAssistantChatMessage(messageRequest: request, account: ncSession.account) + if result.error == .success { + guard let chatMessage = result.chatMessage else { return } + messages.append(chatMessage) + + stopPolling() + await generateChatSession() + startPollingForResponse() + } else { + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_send_message_", errorCode: 20) + } + + isSending = false + } + } + + func startNewConversationViaMessage(input: String, sessionsModel: NCAssistantChatConversationsModel) { + Task { + isSending = true + guard let conversation = await sessionsModel.createNewConversation(title: input) else { return } + await selectConversation(selectedConversation: conversation) + sendMessage(input: input) + } + } +} + +extension NCAssistantChatModel { + static var example = NCAssistantChatModel(controller: nil, messages: [ + AssistantChatMessage( + id: 1, + sessionId: 0, + role: "human", + content: "Hello! Can you help me summarize this document?", + timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 2, + sessionId: 0, + role: "assistant", + content: "Of course! I'd be happy to help you summarize your document. Please share the document or paste the text you'd like me to summarize.", + timestamp: Int(Date().addingTimeInterval(-240).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 3, + sessionId: 0, + role: "human", + content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + timestamp: Int(Date().addingTimeInterval(-180).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 4, + sessionId: 0, + role: "assistant", + content: "Based on the text you provided, here's a concise summary: The document discusses the classic Lorem Ipsum placeholder text, which has been used in the printing and typesetting industry for centuries as a standard dummy text.", + timestamp: Int(Date().addingTimeInterval(-120).timeIntervalSince1970 * 1000) + )]) +} diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift new file mode 100644 index 0000000000..907d06e339 --- /dev/null +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct ChatInputField: View { + @FocusState private var isInputFocused: Bool + @State var text: String = "" + @Binding var isLoading: Bool + @Binding var isDisabled: Bool + var onSend: ((_ input: String) -> Void)? + + init(isLoading: Binding = .constant(false), isDisabled: Binding = .constant(false), onSend: ((_: String) -> Void)? = nil) { + _isLoading = isLoading + _isDisabled = isDisabled + self.onSend = onSend + } + + var body: some View { + VStack { + Text("_assistant_ai_warning_") + .lineLimit(1) + .allowsTightening(true) + .minimumScaleFactor(0.5) + + HStack(spacing: 8) { + TextField(NSLocalizedString("_type_message_", comment: ""), text: $text, axis: .vertical) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.primary.opacity(0.1)) + .clipShape(.rect(cornerRadius: 20)) + .focused($isInputFocused) + .lineLimit(1...5) + + Button(action: { + isInputFocused = false + onSend?(text.trimmingCharacters(in: .whitespaces)) + text = "" + }) { + if isLoading { + ProgressView() + .frame(width: 28, height: 28) + } else { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) + } + } + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isDisabled || isLoading) + } + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 16) + .background(.background) + } +} + +#Preview { + ChatInputField(isLoading: .constant(false)) + ChatInputField(isLoading: .constant(true)) +} diff --git a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift index d24cca06ca..0c6f424a9d 100644 --- a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift +++ b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift @@ -1,15 +1,11 @@ -// -// EmptyTasksView.swift -// Nextcloud -// -// Created by Milen on 16.04.24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2024 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI struct NCAssistantEmptyView: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let titleKey, subtitleKey: String var body: some View { @@ -18,7 +14,7 @@ struct NCAssistantEmptyView: View { .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) - .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: assistantModel.controller?.account))) .font(Font.system(.body).weight(.light)) .frame(height: 100) diff --git a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift index cd3250d38c..a172e724f7 100644 --- a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift +++ b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift @@ -9,7 +9,7 @@ import SwiftUI struct NCAssistantCreateNewTask: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @State var text = "" @FocusState private var inFocus: Bool @Environment(\.presentationMode) var presentationMode @@ -60,7 +60,7 @@ struct NCAssistantCreateNewTask: View { let model = NCAssistantModel(controller: nil) NCAssistantCreateNewTask() - .environmentObject(model) + .environment(model) .onAppear { model.loadDummyData() } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 673f37e6e5..07dea8b63d 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -1,36 +1,33 @@ -// -// NCAssistant.swift -// Nextcloud -// -// Created by Milen on 03.04.24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit import PopupView struct NCAssistant: View { - @EnvironmentObject var model: NCAssistantModel + @State var assistantModel: NCAssistantModel + @State var chatModel: NCAssistantChatModel + @State var conversationsModel: NCAssistantChatConversationsModel @State var input = "" @Environment(\.presentationMode) var presentationMode var body: some View { NavigationView { ZStack { - TaskList() + if assistantModel.types.isEmpty, !assistantModel.isLoading { + NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") + } else if assistantModel.isSelectedTypeChat { + NCAssistantChat(conversationsModel: $conversationsModel) + } else { + TaskList() + } - if model.isLoading, !model.isRefreshing { + if assistantModel.isLoading, !assistantModel.isRefreshing { ProgressView() .controlSize(.regular) } - - if model.types.isEmpty, !model.isLoading { - NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") - } else if model.filteredTasks.isEmpty, !model.isLoading { - NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") - } - } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -41,17 +38,27 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantCreateNewTask()) { - Image(systemName: "plus") + NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, selectedConversation: chatModel.selectedConversation) { conversation in + guard let conversation else { return } + + Task { + await chatModel.selectConversation(selectedConversation: conversation) + assistantModel.selectChatTaskType() + } + }) { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) } - .disabled(model.selectedType == nil) - .accessibilityIdentifier("CreateButton") + .disabled(assistantModel.selectedType == nil) + .accessibilityIdentifier("ConversationsButton") } } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) + .modifier(NavigationSubtitleModifier(subtitle: assistantModel.isSelectedTypeChat ? + chatModel.currentSession?.sessionTitle ?? chatModel.selectedConversation?.validTitle + : "")) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { TypeList() @@ -59,7 +66,7 @@ struct NCAssistant: View { } .navigationViewStyle(.stack) - .popup(isPresented: $model.hasError) { + .popup(isPresented: $assistantModel.hasError) { Text(NSLocalizedString("_error_occurred_", comment: "")) .padding() .background(.red) @@ -71,22 +78,27 @@ struct NCAssistant: View { .position(.bottom) } .accentColor(Color(NCBrandColor.shared.iconImageColor)) - .environmentObject(model) + .environment(assistantModel) + .environment(chatModel) + .onDisappear { + chatModel.stopPolling() + } } } #Preview { + @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) + let conversationsModel = NCAssistantChatConversationsModel(controller: nil) - NCAssistant() - .environmentObject(model) + NCAssistant(assistantModel: model, chatModel: chatModel, conversationsModel: conversationsModel) .onAppear { model.loadDummyData() } } struct TaskList: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel @State var presentEditTask = false @State var showDeleteConfirmation = false @@ -94,11 +106,13 @@ struct TaskList: View { @State var taskToDelete: AssistantTask? var body: some View { - List(model.filteredTasks, id: \.id) { task in + @Bindable var assistantModel = assistantModel + + List(assistantModel.filteredTasks, id: \.id) { task in TaskItem(showDeleteConfirmation: $showDeleteConfirmation, taskToDelete: $taskToDelete, task: task) .contextMenu { Button { - model.shareTask(task) + assistantModel.shareTask(task) } label: { Label { Text("_share_") @@ -108,7 +122,7 @@ struct TaskList: View { } Button { - model.scheduleTask(input: task.input?.input ?? "") + assistantModel.scheduleTask(input: task.input?.input ?? "") } label: { Label { Text("_retry_") @@ -144,16 +158,16 @@ struct TaskList: View { } .accessibilityIdentifier("TaskContextMenu") } - .if(!model.types.isEmpty) { view in + .if(!assistantModel.types.isEmpty) { view in view.refreshable { - model.refresh() + assistantModel.refresh() } } .confirmationDialog("", isPresented: $showDeleteConfirmation) { Button(NSLocalizedString("_delete_", comment: ""), role: .destructive) { withAnimation { guard let taskToDelete else { return } - model.deleteTask(taskToDelete) + assistantModel.deleteTask(taskToDelete) } } } @@ -162,11 +176,20 @@ struct TaskList: View { NCAssistantCreateNewTask(text: taskToEdit?.input?.input ?? "", editMode: true) } } + .safeAreaInset(edge: .bottom) { + ChatInputField(isLoading: $assistantModel.isLoading) { input in + assistantModel.scheduleTask(input: input) + } + } + + if assistantModel.filteredTasks.isEmpty, !assistantModel.isLoading { + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") + } } } struct TypeButton: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model let taskType: TaskTypeData? var scrollProxy: ScrollViewProxy @@ -201,7 +224,7 @@ struct TypeButton: View { } struct TaskItem: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @Binding var showDeleteConfirmation: Bool @Binding var taskToDelete: AssistantTask? var task: AssistantTask @@ -245,8 +268,20 @@ struct TaskItem: View { } } +struct NavigationSubtitleModifier: ViewModifier { + let subtitle: String? + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.navigationSubtitle(subtitle ?? "") + } else { + content + } + } +} + struct TypeList: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model var body: some View { ScrollViewReader { scrollProxy in @@ -260,6 +295,11 @@ struct TypeList: View { .frame(height: 50) } .background(.ultraThinMaterial) + .onChange(of: model.scrollTypeListToTop) { + withAnimation(.easeInOut(duration: 0.7)) { + scrollProxy.scrollTo(model.types.first?.id, anchor: .center) + } + } } } } diff --git a/iOSClient/Assistant/Models/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift similarity index 62% rename from iOSClient/Assistant/Models/NCAssistantModel.swift rename to iOSClient/Assistant/NCAssistantModel.swift index dd99f16704..3a6640e376 100644 --- a/iOSClient/Assistant/Models/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -7,22 +7,24 @@ import UIKit import NextcloudKit import SwiftUI -class NCAssistantModel: ObservableObject { - @Published var types: [TaskTypeData] = [] - @Published var filteredTasks: [AssistantTask] = [] - @Published var selectedType: TaskTypeData? - @Published var selectedTask: AssistantTask? - - @Published var hasError: Bool = false - @Published var isLoading: Bool = false - @Published var isRefreshing: Bool = false - @Published var controller: NCMainTabBarController? - - private var tasks: [AssistantTask] = [] - - private let session: NCSession.Session - - private let useV2: Bool +@Observable +class NCAssistantModel { + var types: [TaskTypeData] = [] + var filteredTasks: [AssistantTask] = [] + var selectedType: TaskTypeData? + var selectedTask: AssistantTask? + + var hasError: Bool = false + var isLoading: Bool = false + var isRefreshing: Bool = false + var scrollTypeListToTop: Bool = false + + @ObservationIgnored let controller: NCMainTabBarController? + @ObservationIgnored private var tasks: [AssistantTask] = [] + @ObservationIgnored private let session: NCSession.Session + @ObservationIgnored private let useV2: Bool + @ObservationIgnored private let chatTypeId = "core:text2text:chat" + @ObservationIgnored var isSelectedTypeChat: Bool { selectedType?.id == chatTypeId } init(controller: NCMainTabBarController?) { self.controller = controller @@ -30,7 +32,6 @@ class NCAssistantModel: ObservableObject { let capabilities = NCNetworking.shared.capabilities[session.account] ?? NKCapabilities.Capabilities() useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 - // useV2 = false loadAllTypes() } @@ -49,6 +50,11 @@ class NCAssistantModel: ObservableObject { self.filteredTasks = filteredTasks.sorted(by: { $0.completionExpectedAt ?? 0 > $1.completionExpectedAt ?? 0 }) } + func selectChatTaskType() { + selectTaskType(types.first) + scrollTypeListToTop.toggle() + } + func selectTaskType(_ type: TaskTypeData?) { selectedType = type @@ -60,19 +66,18 @@ class NCAssistantModel: ObservableObject { selectedTask = task isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTasksV2(taskType: task.type ?? "", account: session.account, completion: { _, _, _, error in - handle(task: task, error: error) - }) - } else { - NextcloudKit.shared.textProcessingGetTask(taskId: Int(task.id), account: session.account) { _, task, _, error in - guard let task else { return } - let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first - handle(task: taskV2, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTasksV2(taskType: task.type ?? "", account: session.account) + handle(task: task, error: result.error) + } else { + NextcloudKit.shared.textProcessingGetTask(taskId: Int(task.id), account: session.account) { _, task, _, error in + guard let task else { return } + let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first + handle(task: taskV2, error: error) + } } } - */ func handle(task: AssistantTask?, error: NKError?) { self.isLoading = false @@ -89,19 +94,18 @@ class NCAssistantModel: ObservableObject { func scheduleTask(input: String) { isLoading = true - /* - if useV2 { - guard let selectedType else { return } - NextcloudKit.shared.textProcessingScheduleV2(input: input, taskType: selectedType, account: session.account) { _, task, _, error in - handle(task: task, error: error) - } - } else { - NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in - guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } - handle(task: taskV2, error: error) + Task { + if useV2 { + guard let selectedType else { return } + let result = await NextcloudKit.shared.textProcessingScheduleV2(input: input, taskType: selectedType, account: session.account) + handle(task: result.task, error: result.error) + } else { + NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in + guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } + handle(task: taskV2, error: error) + } } } - */ func handle(task: AssistantTask?, error: NKError?) { self.isLoading = false @@ -121,17 +125,16 @@ class NCAssistantModel: ObservableObject { func deleteTask(_ task: AssistantTask) { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingDeleteTaskV2(taskId: task.id, account: session.account) { _, _, error in - handle(task: task, error: error) - } - } else { - NextcloudKit.shared.textProcessingDeleteTask(taskId: Int(task.id), account: session.account) { _, _, _, error in - handle(task: task, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingDeleteTaskV2(taskId: task.id, account: session.account) + handle(task: task, error: result.error) + } else { + NextcloudKit.shared.textProcessingDeleteTask(taskId: Int(task.id), account: session.account) { _, _, _, error in + handle(task: task, error: error) + } } } - */ func handle(task: AssistantTask, error: NKError?) { self.isLoading = false @@ -154,20 +157,19 @@ class NCAssistantModel: ObservableObject { private func loadAllTypes() { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTypesV2(account: session.account) { _, types, _, error in - handle(types: types, error: error) - } - } else { - NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in - guard let types else { return } - let typesV2 = NKTextProcessingTaskType.toV2(type: types).types - - handle(types: typesV2, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTypesV2(account: session.account) + handle(types: result.types, error: result.error) + } else { + NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in + guard let types else { return } + let typesV2 = NKTextProcessingTaskType.toV2(type: types).types + + handle(types: typesV2, error: error) + } } } - */ func handle(types: [TaskTypeData]?, error: NKError) { self.isLoading = false @@ -179,10 +181,10 @@ class NCAssistantModel: ObservableObject { guard let types else { return } - self.types = types + self.types = types.sorted { $0.id == chatTypeId && $1.id != chatTypeId } if self.selectedType == nil { - self.selectTaskType(types.first) + self.selectTaskType(self.types.first) } self.loadAllTasks(type: selectedType) @@ -192,19 +194,18 @@ class NCAssistantModel: ObservableObject { private func loadAllTasks(appId: String = "assistant", type: TaskTypeData?) { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTasksV2(taskType: type?.id ?? "", account: session.account) { _, tasks, _, error in - guard let tasks = tasks?.tasks.filter({ $0.appId == "assistant" }) else { return } - handle(tasks: tasks, error: error) - } - } else { - NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in - guard let tasks else { return } - handle(tasks: NKTextProcessingTask.toV2(tasks: tasks).tasks, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTasksV2(taskType: type?.id ?? "", account: session.account) + guard let tasks = result.tasks?.tasks.filter({ $0.appId == "assistant" }) else { return } + handle(tasks: tasks, error: result.error) + } else { + NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in + guard let tasks else { return } + handle(tasks: NKTextProcessingTask.toV2(tasks: tasks).tasks, error: error) + } } } - */ func handle(tasks: [AssistantTask], error: NKError?) { isLoading = false @@ -232,10 +233,11 @@ extension NCAssistantModel { } self.types = [ - TaskTypeData(id: "1", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "1", name: "Chat", description: "", inputShape: nil, outputShape: nil), TaskTypeData(id: "2", name: "Summarize", description: "", inputShape: nil, outputShape: nil), TaskTypeData(id: "3", name: "Generate headline", description: "", inputShape: nil, outputShape: nil), - TaskTypeData(id: "4", name: "Reformulate", description: "", inputShape: nil, outputShape: nil) + TaskTypeData(id: "4", name: "Reformulate", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "5", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil) ] self.tasks = tasks diff --git a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift index 3a2e9b9bba..7533dfc588 100644 --- a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift +++ b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift @@ -10,7 +10,7 @@ import SwiftUI import NextcloudKit struct NCAssistantTaskDetail: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let task: AssistantTask var body: some View { @@ -21,7 +21,7 @@ struct NCAssistantTaskDetail: View { } .toolbar { Button(action: { - model.shareTask(task) + assistantModel.shareTask(task) }, label: { Image(systemName: "square.and.arrow.up") }) @@ -29,23 +29,23 @@ struct NCAssistantTaskDetail: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_task_details_", comment: "")) .onAppear { - model.selectTask(task) + assistantModel.selectTask(task) } } } #Preview { - let model = NCAssistantModel(controller: nil) + let assistantModel = NCAssistantModel(controller: nil) - NCAssistantTaskDetail(task: model.selectedTask!) - .environmentObject(model) + NCAssistantTaskDetail(task: assistantModel.selectedTask!) + .environment(assistantModel) .onAppear { - model.loadDummyData() + assistantModel.loadDummyData() } } struct InputOutputScrollView: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model let task: AssistantTask var body: some View { @@ -79,7 +79,7 @@ struct InputOutputScrollView: View { } struct BottomDetailsBar: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let task: AssistantTask var body: some View { diff --git a/iOSClient/Extensions/NotificationCenter+MainThread.swift b/iOSClient/Extensions/NotificationCenter+MainThread.swift index 3286a76f50..76c9982c4b 100644 --- a/iOSClient/Extensions/NotificationCenter+MainThread.swift +++ b/iOSClient/Extensions/NotificationCenter+MainThread.swift @@ -30,4 +30,9 @@ extension NotificationCenter { NotificationCenter.default.post(name: Notification.Name(rawValue: name), object: anObject, userInfo: aUserInfo) } } + func postOnGlobal(name: String, object anObject: Any? = nil, userInfo aUserInfo: [AnyHashable: Any]? = nil, second: Double = 0) { + DispatchQueue.global().asyncAfter(deadline: .now() + second) { + NotificationCenter.default.post(name: Notification.Name(rawValue: name), object: anObject, userInfo: aUserInfo) + } + } } diff --git a/iOSClient/Extensions/UIImage+Extension.swift b/iOSClient/Extensions/UIImage+Extension.swift index 0502b7f08a..797238c539 100644 --- a/iOSClient/Extensions/UIImage+Extension.swift +++ b/iOSClient/Extensions/UIImage+Extension.swift @@ -26,6 +26,24 @@ import UIKit import Accelerate extension UIImage { + /// Returns a raster-resized copy of the image at the specified size, + /// preserving the original scale and renderingMode. + /// + /// - Parameter size: Target size in points. + /// - Returns: A resized UIImage. + func rasterResized(to size: CGSize) -> UIImage { + let format = UIGraphicsImageRendererFormat.default() + format.scale = self.scale + format.opaque = false + + let renderer = UIGraphicsImageRenderer(size: size, format: format) + + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: size)) + } + .withRenderingMode(self.renderingMode) + } + func resizeImage(size: CGSize, isAspectRation: Bool = true) -> UIImage? { let originRatio = self.size.width / self.size.height let newRatio = size.width / size.height @@ -41,14 +59,16 @@ extension UIImage { } } - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - self.draw(in: CGRect(origin: .zero, size: newSize)) - defer { UIGraphicsEndImageContext() } - return UIGraphicsGetImageFromCurrentImageContext() + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + format.scale = 1.0 + let renderer = UIGraphicsImageRenderer(size: newSize, format: format) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: newSize)) + } } func fixedOrientation() -> UIImage? { - guard imageOrientation != UIImage.Orientation.up else { // This is default orientation, don't need to do anything return self.copy() as? UIImage diff --git a/iOSClient/GUI/Lucid Banner/BannerView.swift b/iOSClient/GUI/Lucid Banner/BannerView.swift index 90ad6db9c8..3661314136 100644 --- a/iOSClient/GUI/Lucid Banner/BannerView.swift +++ b/iOSClient/GUI/Lucid Banner/BannerView.swift @@ -309,7 +309,8 @@ func bannerContainsError(errorCode: Int?, afError: AFError? = nil) -> Bool { // The same error code is shown to the user only once. // Error 401 (maintenance mode) // Error 507 (insufficient storage) - if errorCode == 401 || errorCode == 507 { + // Error -1009 (not connected to internet) + if errorCode == 401 || errorCode == 507 || errorCode == URLError.notConnectedToInternet.rawValue { shownErrors.insert(errorCode) } return false diff --git a/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift b/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift index e48bf71263..d31c53262a 100644 --- a/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift +++ b/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift @@ -1,8 +1,6 @@ -// -// UIView+BlurVibrancy.swift -// -// Created by Xcode Assistant. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import UIKit import ObjectiveC @@ -85,65 +83,4 @@ public extension UIView { blurEffectView = blurView return blurView } - - /// Adds a vibrancy overlay tied to a blur view and returns the vibrancy effect view. - /// If `blurView` is `nil`, this method will use the previously added blur view or create a new one with default style. - /// The vibrancy view is added inside the blur view's contentView and pinned to its edges. - /// - /// - Parameters: - /// - using: The blur view to attach vibrancy to. If `nil`, uses/creates one. - /// - style: The `UIVibrancyEffectStyle` to use. Defaults to `.label`. - /// - insets: Edge insets to apply when pinning the vibrancy view. Defaults to `.zero`. - /// - Returns: The configured and inserted `UIVisualEffectView` for vibrancy, or `nil` if a blur effect could not be determined. - @discardableResult - func addVibrancyOverlay(using blurView: UIVisualEffectView? = nil, - style: UIVibrancyEffectStyle = .label, - insets: UIEdgeInsets = .zero) -> UIVisualEffectView? { - // Ensure we have a blur view - let blur: UIVisualEffectView - if let provided = blurView { - blur = provided - } else if let existing = blurEffectView { - blur = existing - } else { - // Create a default blur if none exists - blur = addBlurBackground() - } - - guard let blurEffect = blur.effect as? UIBlurEffect else { return nil } - - // Remove existing vibrancy (if any) that was previously added via this extension - if let existingVibrancy = vibrancyEffectView { - existingVibrancy.removeFromSuperview() - vibrancyEffectView = nil - } - - let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: style) - let vibrancyView = UIVisualEffectView(effect: vibrancyEffect) - vibrancyView.isUserInteractionEnabled = false - vibrancyView.translatesAutoresizingMaskIntoConstraints = false - - blur.contentView.addSubview(vibrancyView) - NSLayoutConstraint.activate([ - vibrancyView.leadingAnchor.constraint(equalTo: blur.contentView.leadingAnchor, constant: insets.left), - vibrancyView.trailingAnchor.constraint(equalTo: blur.contentView.trailingAnchor, constant: -insets.right), - vibrancyView.topAnchor.constraint(equalTo: blur.contentView.topAnchor, constant: insets.top), - vibrancyView.bottomAnchor.constraint(equalTo: blur.contentView.bottomAnchor, constant: -insets.bottom) - ]) - - vibrancyEffectView = vibrancyView - return vibrancyView - } - - /// Removes the blur and vibrancy effect views previously added via this extension. - func removeBlurAndVibrancy() { - if let vibrancy = vibrancyEffectView { - vibrancy.removeFromSuperview() - vibrancyEffectView = nil - } - if let blur = blurEffectView { - blur.removeFromSuperview() - blurEffectView = nil - } - } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index e1a560361c..8d0c486b37 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -190,10 +190,10 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, let searchBar = searchController?.searchBar searchBar?.delegate = self searchBar?.autocapitalizationType = .none - searchBar?.backgroundImage = UIImage() navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false + navigationItem.preferredSearchBarPlacement = .inline } // Cell diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommonSelectTabBar.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommonSelectTabBar.swift index d43da0c46a..f58f75dc19 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommonSelectTabBar.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommonSelectTabBar.swift @@ -31,6 +31,11 @@ class NCCollectionViewCommonSelectTabBar: ObservableObject { @Published var isSelectedEmpty = true @Published var metadatas: [tableMetadata] = [] + var isFilesLockCapabilityEnabled: Bool { + let capabilities = NCNetworking.shared.capabilities[controller?.account ?? ""] ?? NKCapabilities.Capabilities() + return !capabilities.filesLockVersion.isEmpty + } + init(controller: NCMainTabBarController? = nil, viewController: UIViewController, delegate: NCCollectionViewCommonSelectTabBarDelegate? = nil) { guard let controller else { return @@ -127,7 +132,7 @@ class NCCollectionViewCommonSelectTabBar: ObservableObject { } // else: file is not offline, continue } let capabilities = NCNetworking.shared.capabilities[controller?.account ?? ""] ?? NKCapabilities.Capabilities() - enableLock = !isAnyDirectory && canUnlock && !capabilities.filesLockVersion.isEmpty + enableLock = !isAnyDirectory && canUnlock && isFilesLockCapabilityEnabled } self.isSelectedEmpty = fileSelect.isEmpty } @@ -187,17 +192,18 @@ struct NCCollectionViewCommonSelectTabBarView: View { }) .disabled(!tabBarSelect.isAnyOffline && (!tabBarSelect.canSetAsOffline || tabBarSelect.isSelectedEmpty)) - Button(action: { - tabBarSelect.delegate?.lock(isAnyLocked: tabBarSelect.isAnyLocked) - }, label: { - Label(NSLocalizedString(tabBarSelect.isAnyLocked ? "_unlock_" : "_lock_", comment: ""), systemImage: tabBarSelect.isAnyLocked ? "lock.open" : "lock") - - if !tabBarSelect.enableLock { - Text(NSLocalizedString("_lock_no_permissions_selected_", comment: "")) - } - }) - .disabled(!tabBarSelect.enableLock || tabBarSelect.isSelectedEmpty) - + if tabBarSelect.isFilesLockCapabilityEnabled { + Button(action: { + tabBarSelect.delegate?.lock(isAnyLocked: tabBarSelect.isAnyLocked) + }, label: { + Label(NSLocalizedString(tabBarSelect.isAnyLocked ? "_unlock_" : "_lock_", comment: ""), systemImage: tabBarSelect.isAnyLocked ? "lock.open" : "lock") + + if !tabBarSelect.enableLock { + Text(NSLocalizedString("_lock_no_permissions_selected_", comment: "")) + } + }) + .disabled(!tabBarSelect.enableLock || tabBarSelect.isSelectedEmpty) + } Button(action: { tabBarSelect.delegate?.selectAll() }, label: { diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index 3b7a5d7a1d..c3c552613a 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -80,8 +80,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButtonItem.title = NSLocalizedString("_assistant_", comment: "") assistantButtonItem.tintColor = NCBrandColor.shared.iconImageColor assistantButtonItem.primaryAction = UIAction(handler: { _ in - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) + let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller), conversationsModel: NCAssistantChatConversationsModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) self.present(hostingController, animated: true, completion: nil) }) @@ -911,11 +910,9 @@ class NCMainNavigationController: UINavigationController, UINavigationController @MainActor private func applyTint(_ button: UIButton, color: UIColor) { if var cfg = button.configuration { - // Se in futuro userai UIButton.Configuration, tieni il colore allineato qui cfg.baseForegroundColor = color button.configuration = cfg } else { - // Config attuale (nessuna configuration): SF Symbols sono template, quindi basta tintColor button.tintColor = color button.setTitleColor(color, for: .normal) } diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index c57af4a957..0da40bb617 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -47,10 +47,6 @@ class NCContextMenuMain: NSObject { let deleteMenu = buildDeleteMenu(metadata: metadata) - if !NCNetworking.shared.isOnline { - return UIMenu() - } - // Assemble final menu let baseChildren = [ UIMenu(title: "", options: .displayInline, children: mainActionsMenu), @@ -502,12 +498,52 @@ class NCContextMenuMain: NSObject { if shouldShowMenu { let deferredElement = UIDeferredMenuElement { completion in Task { - var iconImage = UIImage() + var iconImage = UIImage(systemName: "exclamationmark.triangle.fill") + if let iconUrl = item.icon { +<<<<<<< HEAD +<<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> d99135d60a (svg-fix (#3990)) + if let image = await NCUtility().convertSVGtoPNGWriteToUserData( + serverUrl: metadata.urlBase + iconUrl, + rewrite: false, + account: metadata.account + ).image { + let image = image.rasterResized(to: CGSize(width: 20, height: 20)) + iconImage = image.withTintColor( +<<<<<<< HEAD + NCBrandColor.shared.iconImageColor, + renderingMode: .alwaysOriginal + ) +======= if let image = await NCUtility().convertSVGtoPNGWriteToUserData(serverUrl: metadata.urlBase + iconUrl, rewrite: false, account: metadata.account).image { + if let image = image.withTintColor( + NCBrandColor.shared.iconImageColor, + renderingMode: .alwaysOriginal + ).resizeImage(size: CGSize(width: 20, height: 20)) { + iconImage = image + } +>>>>>>> fd0de89732 (Fix gui svg (#3989)) +======= + NCBrandColor.shared.iconImageColor, + renderingMode: .alwaysOriginal + ) +>>>>>>> d99135d60a (svg-fix (#3990)) +======= + let results = await NextcloudKit.shared.downloadContentAsync(serverUrl: metadata.urlBase + iconUrl, account: metadata.account) + if results.error == .success, let data = results.responseData?.data, + let image = try? await NCSVGRenderer().renderSVGToUIImage( + svgData: data, + size: CGSize(width: UIScreen.main.scale * 20, + height: UIScreen.main.scale * 20), + tintColor: NCBrandColor.shared.iconImageColor, + trimTransparentPixels: false) { iconImage = image +>>>>>>> 688c5b5c5a (added tintcolor (#3992)) } } diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift index 52dfb9e3f7..0fe44f09b4 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift @@ -28,7 +28,6 @@ import SwiftUI import NextcloudKit class NCMoreAppSuggestionsCell: BaseNCMoreCell { - @IBOutlet weak var assistantView: UIStackView! @IBOutlet weak var talkView: UIStackView! @IBOutlet weak var notesView: UIStackView! @IBOutlet weak var moreAppsView: UIStackView! @@ -44,7 +43,6 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { super.awakeFromNib() backgroundColor = .clear - assistantView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(assistantTapped(_:)))) talkView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(talkTapped(_:)))) notesView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(notesTapped(_:)))) moreAppsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreAppsTapped(_:)))) @@ -55,19 +53,9 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { return } - assistantView.isHidden = !capabilities.assistantEnabled self.controller = controller } - @objc func assistantTapped(_ sender: Any?) { - if let viewController = self.window?.rootViewController { - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) - let hostingController = UIHostingController(rootView: assistant) - viewController.present(hostingController, animated: true, completion: nil) - } - } - @objc func talkTapped(_ sender: Any?) { guard let url = URL(string: NCGlobal.shared.talkSchemeUrl) else { return } diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib index b3ec728c77..3bc0d1a9eb 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -20,41 +19,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -62,7 +31,7 @@ - + - + @@ -92,7 +61,7 @@ - + - + @@ -122,7 +91,7 @@ - - @@ -163,7 +130,6 @@ - @@ -174,7 +140,6 @@ - diff --git a/iOSClient/More/NCMore.swift b/iOSClient/More/NCMore.swift index 43301a4fd4..57c3f016cc 100644 --- a/iOSClient/More/NCMore.swift +++ b/iOSClient/More/NCMore.swift @@ -407,11 +407,6 @@ class NCMore: UIViewController, UITableViewDelegate, UITableViewDataSource { alertController.addAction(actionYes) alertController.addAction(actionNo) self.present(alertController, animated: true, completion: nil) - } else if item.url == "openAssistant" { - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) - let hostingController = UIHostingController(rootView: assistant) - present(hostingController, animated: true, completion: nil) } else if item.url == "openSettings" { let settingsView = NCSettingsView(model: NCSettingsModel(controller: self.controller)) let settingsController = UIHostingController(rootView: settingsView) diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index b6c5f21d76..a935dc2314 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -278,6 +278,7 @@ final class NCGlobal: Sendable { let notificationCenterCheckUserDelaultErrorDone = "checkUserDelaultErrorDone" // userInfo: account, controller let notificationCenterServerDidUpdate = "serverDidUpdate" // userInfo: account let notificationCenterNetworkReachability = "networkReachability" + let notificationCenterNetworkProcess = "networkProcess" let notificationCenterMenuSearchTextPDF = "menuSearchTextPDF" let notificationCenterMenuGotToPageInPDF = "menuGotToPageInPDF" diff --git a/iOSClient/Networking/NCNetworkingProcess.swift b/iOSClient/Networking/NCNetworkingProcess.swift index 992f25c24b..745b1f1b9e 100644 --- a/iOSClient/Networking/NCNetworkingProcess.swift +++ b/iOSClient/Networking/NCNetworkingProcess.swift @@ -33,6 +33,7 @@ actor NCNetworkingProcess { private var timer: DispatchSourceTimer? private let timerQueue = DispatchQueue(label: "com.nextcloud.timerProcess", qos: .utility) private var lastUsedInterval: TimeInterval = 3.5 + private let offlineInterval: TimeInterval = 10.0 private let maxInterval: TimeInterval = 3.5 private let minInterval: TimeInterval = 2.5 @@ -57,6 +58,14 @@ actor NCNetworkingProcess { } } + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterNetworkProcess), object: nil, queue: nil) { [weak self] _ in + guard let self else { return } + + Task { @MainActor in + await self.handleTimerTick() + } + } + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in guard let self else { return } @@ -243,7 +252,9 @@ actor NCNetworkingProcess { // TODO: Check temperature - if lastUsedInterval != minInterval { + if !networking.isOnline { + await startTimer(interval: offlineInterval) + } else if lastUsedInterval != minInterval { await startTimer(interval: minInterval) } } else { diff --git a/iOSClient/Supporting Files/af.lproj/Localizable.strings b/iOSClient/Supporting Files/af.lproj/Localizable.strings index 98442d0074..b9315db5e3 100644 Binary files a/iOSClient/Supporting Files/af.lproj/Localizable.strings and b/iOSClient/Supporting Files/af.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/an.lproj/Localizable.strings b/iOSClient/Supporting Files/an.lproj/Localizable.strings index 72f71f0000..542424c046 100644 Binary files a/iOSClient/Supporting Files/an.lproj/Localizable.strings and b/iOSClient/Supporting Files/an.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ar.lproj/Localizable.strings b/iOSClient/Supporting Files/ar.lproj/Localizable.strings index c307c24dda..3ada0df365 100644 Binary files a/iOSClient/Supporting Files/ar.lproj/Localizable.strings and b/iOSClient/Supporting Files/ar.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ast.lproj/Localizable.strings b/iOSClient/Supporting Files/ast.lproj/Localizable.strings index eb64319013..6b86ed32ed 100644 Binary files a/iOSClient/Supporting Files/ast.lproj/Localizable.strings and b/iOSClient/Supporting Files/ast.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/az.lproj/Localizable.strings b/iOSClient/Supporting Files/az.lproj/Localizable.strings index 957afe380d..ceba3dd3d9 100644 Binary files a/iOSClient/Supporting Files/az.lproj/Localizable.strings and b/iOSClient/Supporting Files/az.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/be.lproj/Localizable.strings b/iOSClient/Supporting Files/be.lproj/Localizable.strings index f2b9ca54cd..0408b3dd81 100644 Binary files a/iOSClient/Supporting Files/be.lproj/Localizable.strings and b/iOSClient/Supporting Files/be.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/bg_BG.lproj/Localizable.strings b/iOSClient/Supporting Files/bg_BG.lproj/Localizable.strings index 48b6ac3e02..30b744dccb 100644 Binary files a/iOSClient/Supporting Files/bg_BG.lproj/Localizable.strings and b/iOSClient/Supporting Files/bg_BG.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/bn_BD.lproj/Localizable.strings b/iOSClient/Supporting Files/bn_BD.lproj/Localizable.strings index ac71347725..4f0d69d4d0 100644 Binary files a/iOSClient/Supporting Files/bn_BD.lproj/Localizable.strings and b/iOSClient/Supporting Files/bn_BD.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/br.lproj/Localizable.strings b/iOSClient/Supporting Files/br.lproj/Localizable.strings index 4e00dedbbe..c79fa6d5d3 100644 Binary files a/iOSClient/Supporting Files/br.lproj/Localizable.strings and b/iOSClient/Supporting Files/br.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/bs.lproj/Localizable.strings b/iOSClient/Supporting Files/bs.lproj/Localizable.strings index eeed23d60e..8b99feab2d 100644 Binary files a/iOSClient/Supporting Files/bs.lproj/Localizable.strings and b/iOSClient/Supporting Files/bs.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ca.lproj/Localizable.strings b/iOSClient/Supporting Files/ca.lproj/Localizable.strings index 43d4d6f924..2385b4a3b5 100644 Binary files a/iOSClient/Supporting Files/ca.lproj/Localizable.strings and b/iOSClient/Supporting Files/ca.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/cs-CZ.lproj/Localizable.strings b/iOSClient/Supporting Files/cs-CZ.lproj/Localizable.strings index 582da406e3..992a8d1b8f 100644 Binary files a/iOSClient/Supporting Files/cs-CZ.lproj/Localizable.strings and b/iOSClient/Supporting Files/cs-CZ.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/cy_GB.lproj/Localizable.strings b/iOSClient/Supporting Files/cy_GB.lproj/Localizable.strings index b16b7edcae..de77b9e5cd 100644 Binary files a/iOSClient/Supporting Files/cy_GB.lproj/Localizable.strings and b/iOSClient/Supporting Files/cy_GB.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/da.lproj/Localizable.strings b/iOSClient/Supporting Files/da.lproj/Localizable.strings index 527677d273..8b4705b741 100644 Binary files a/iOSClient/Supporting Files/da.lproj/Localizable.strings and b/iOSClient/Supporting Files/da.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/de.lproj/Localizable.strings b/iOSClient/Supporting Files/de.lproj/Localizable.strings index 7c42fa6b55..0238b09d43 100644 Binary files a/iOSClient/Supporting Files/de.lproj/Localizable.strings and b/iOSClient/Supporting Files/de.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/el.lproj/Localizable.strings b/iOSClient/Supporting Files/el.lproj/Localizable.strings index 9768d14283..998431eb1e 100644 Binary files a/iOSClient/Supporting Files/el.lproj/Localizable.strings and b/iOSClient/Supporting Files/el.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings b/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings index e69532e62a..2738d96179 100644 Binary files a/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings and b/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 59cf1839df..d83b987a48 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -711,12 +711,21 @@ You can stop it at any time, adjust the settings, and enable it again."; "_task_details_" = "Task details"; "_assistant_" = "Assistant"; "_new_task_" = "New %@ task"; -"_no_tasks_" = "No tasks in here"; -"_create_task_subtitle_" = "Use the + button to create one"; +"_no_tasks_" = "Hello there! What can I help you with today?"; +"_create_task_subtitle_" = "Use the + button to create a new task"; "_edit_task_" = "Edit %@ task"; "_no_types_" = "No provider found"; "_no_types_subtitle_" = "AI Providers need to be installed to use the Assistant."; - +"_assistant_ai_warning_" = "Output shown here is generated by AI. Make sure to always double-check."; +"_no_chat_subtitle_" = "Try sending a message to spark a conversation."; +"_retry_response_generation_" = "Retry response generation"; +"_conversations_" = "Conversations"; +"_new_conversation_" = "New conversation"; +"_type_message_" = "Type a message..."; +"_assistant_chat_" = "Assistant Chat"; +"_assistant_error_send_message_" = "Could not send message. Please try again."; +"_assistant_error_load_messages_" = "Could not load messages. Please try again."; +"_assistant_error_generate_response_" = "Could not generate response. Please try again."; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; "_no_client_cert_found_desc_" = "Do you want to install a TLS client certificate? \n Note that the .p12 certificate must be installed on your device first by clicking on it and installing it as an Identitity Certificate Profile in Settings. The certificate MUST also have a password as that is a requirement by iOS."; diff --git a/iOSClient/Supporting Files/eo.lproj/Localizable.strings b/iOSClient/Supporting Files/eo.lproj/Localizable.strings index 8adc86a084..dcf3f284fd 100644 Binary files a/iOSClient/Supporting Files/eo.lproj/Localizable.strings and b/iOSClient/Supporting Files/eo.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-419.lproj/Localizable.strings b/iOSClient/Supporting Files/es-419.lproj/Localizable.strings index 3dfde505da..6e711ce883 100644 Binary files a/iOSClient/Supporting Files/es-419.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-419.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-AR.lproj/Localizable.strings b/iOSClient/Supporting Files/es-AR.lproj/Localizable.strings index 0173ace457..88653ffadf 100644 Binary files a/iOSClient/Supporting Files/es-AR.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-AR.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-CL.lproj/Localizable.strings b/iOSClient/Supporting Files/es-CL.lproj/Localizable.strings index 813d39b8e9..7ed7a063cf 100644 Binary files a/iOSClient/Supporting Files/es-CL.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-CL.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-CO.lproj/Localizable.strings b/iOSClient/Supporting Files/es-CO.lproj/Localizable.strings index 094e6f6464..17618356e9 100644 Binary files a/iOSClient/Supporting Files/es-CO.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-CO.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-CR.lproj/Localizable.strings b/iOSClient/Supporting Files/es-CR.lproj/Localizable.strings index bb660f3940..cc07ee06a2 100644 Binary files a/iOSClient/Supporting Files/es-CR.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-CR.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-DO.lproj/Localizable.strings b/iOSClient/Supporting Files/es-DO.lproj/Localizable.strings index 3bbd81e3ec..b7ec834e01 100644 Binary files a/iOSClient/Supporting Files/es-DO.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-DO.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-EC.lproj/Localizable.strings b/iOSClient/Supporting Files/es-EC.lproj/Localizable.strings index 3f2ee56b6c..56a86ed341 100644 Binary files a/iOSClient/Supporting Files/es-EC.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-EC.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-GT.lproj/Localizable.strings b/iOSClient/Supporting Files/es-GT.lproj/Localizable.strings index 003db62acf..7632f31d53 100644 Binary files a/iOSClient/Supporting Files/es-GT.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-GT.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-HN.lproj/Localizable.strings b/iOSClient/Supporting Files/es-HN.lproj/Localizable.strings index 99ed77cd95..114968b6ca 100644 Binary files a/iOSClient/Supporting Files/es-HN.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-HN.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-MX.lproj/Localizable.strings b/iOSClient/Supporting Files/es-MX.lproj/Localizable.strings index acbd47b094..21a980a7fd 100644 Binary files a/iOSClient/Supporting Files/es-MX.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-MX.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-NI.lproj/Localizable.strings b/iOSClient/Supporting Files/es-NI.lproj/Localizable.strings index 154884bb10..158b9ee045 100644 Binary files a/iOSClient/Supporting Files/es-NI.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-NI.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-PA.lproj/Localizable.strings b/iOSClient/Supporting Files/es-PA.lproj/Localizable.strings index 42e2fc0eb6..68c9f47a9e 100644 Binary files a/iOSClient/Supporting Files/es-PA.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-PA.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-PE.lproj/Localizable.strings b/iOSClient/Supporting Files/es-PE.lproj/Localizable.strings index 3d1f7a9bc6..708a0c1656 100644 Binary files a/iOSClient/Supporting Files/es-PE.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-PE.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-PR.lproj/Localizable.strings b/iOSClient/Supporting Files/es-PR.lproj/Localizable.strings index 56f6ebe331..68e2a30136 100644 Binary files a/iOSClient/Supporting Files/es-PR.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-PR.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-PY.lproj/Localizable.strings b/iOSClient/Supporting Files/es-PY.lproj/Localizable.strings index 40c10678bd..0431501442 100644 Binary files a/iOSClient/Supporting Files/es-PY.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-PY.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-SV.lproj/Localizable.strings b/iOSClient/Supporting Files/es-SV.lproj/Localizable.strings index e69a090677..7e75435e89 100644 Binary files a/iOSClient/Supporting Files/es-SV.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-SV.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es-UY.lproj/Localizable.strings b/iOSClient/Supporting Files/es-UY.lproj/Localizable.strings index 7817b34901..2fc6a3a9be 100644 Binary files a/iOSClient/Supporting Files/es-UY.lproj/Localizable.strings and b/iOSClient/Supporting Files/es-UY.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/es.lproj/Localizable.strings b/iOSClient/Supporting Files/es.lproj/Localizable.strings index 3bd368c343..20fb4ec6ac 100644 Binary files a/iOSClient/Supporting Files/es.lproj/Localizable.strings and b/iOSClient/Supporting Files/es.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/et.lproj/Localizable.strings b/iOSClient/Supporting Files/et.lproj/Localizable.strings index 2ffefc9677..3d8cce44dd 100644 Binary files a/iOSClient/Supporting Files/et.lproj/Localizable.strings and b/iOSClient/Supporting Files/et.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/eu.lproj/Localizable.strings b/iOSClient/Supporting Files/eu.lproj/Localizable.strings index e9152307c1..18c04ff0c6 100644 Binary files a/iOSClient/Supporting Files/eu.lproj/Localizable.strings and b/iOSClient/Supporting Files/eu.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/fa.lproj/Localizable.strings b/iOSClient/Supporting Files/fa.lproj/Localizable.strings index 48957c579b..278509d96c 100644 Binary files a/iOSClient/Supporting Files/fa.lproj/Localizable.strings and b/iOSClient/Supporting Files/fa.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/fi-FI.lproj/Localizable.strings b/iOSClient/Supporting Files/fi-FI.lproj/Localizable.strings index e1ec113f85..ac12cb8fef 100644 Binary files a/iOSClient/Supporting Files/fi-FI.lproj/Localizable.strings and b/iOSClient/Supporting Files/fi-FI.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/fo.lproj/Localizable.strings b/iOSClient/Supporting Files/fo.lproj/Localizable.strings index b211be7f05..b705fbf26a 100644 Binary files a/iOSClient/Supporting Files/fo.lproj/Localizable.strings and b/iOSClient/Supporting Files/fo.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/fr.lproj/Localizable.strings b/iOSClient/Supporting Files/fr.lproj/Localizable.strings index 3bf809ad0d..42b2d80a3b 100644 Binary files a/iOSClient/Supporting Files/fr.lproj/Localizable.strings and b/iOSClient/Supporting Files/fr.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ga.lproj/Localizable.strings b/iOSClient/Supporting Files/ga.lproj/Localizable.strings index bd9299f151..b48d5cae1c 100644 Binary files a/iOSClient/Supporting Files/ga.lproj/Localizable.strings and b/iOSClient/Supporting Files/ga.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/gd.lproj/Localizable.strings b/iOSClient/Supporting Files/gd.lproj/Localizable.strings index c8028ea352..67b5ab107c 100644 Binary files a/iOSClient/Supporting Files/gd.lproj/Localizable.strings and b/iOSClient/Supporting Files/gd.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/gl.lproj/Localizable.strings b/iOSClient/Supporting Files/gl.lproj/Localizable.strings index 491c519dc9..ce72891647 100644 Binary files a/iOSClient/Supporting Files/gl.lproj/Localizable.strings and b/iOSClient/Supporting Files/gl.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/he.lproj/Localizable.strings b/iOSClient/Supporting Files/he.lproj/Localizable.strings index 60d6324ca5..bebae3aa54 100644 Binary files a/iOSClient/Supporting Files/he.lproj/Localizable.strings and b/iOSClient/Supporting Files/he.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/hi_IN.lproj/Localizable.strings b/iOSClient/Supporting Files/hi_IN.lproj/Localizable.strings index 4acf7722bd..8b53315190 100644 Binary files a/iOSClient/Supporting Files/hi_IN.lproj/Localizable.strings and b/iOSClient/Supporting Files/hi_IN.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/hr.lproj/Localizable.strings b/iOSClient/Supporting Files/hr.lproj/Localizable.strings index d036610fca..27a4bdd03c 100644 Binary files a/iOSClient/Supporting Files/hr.lproj/Localizable.strings and b/iOSClient/Supporting Files/hr.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/hsb.lproj/Localizable.strings b/iOSClient/Supporting Files/hsb.lproj/Localizable.strings index 27113a9dd1..dfc0a57525 100644 Binary files a/iOSClient/Supporting Files/hsb.lproj/Localizable.strings and b/iOSClient/Supporting Files/hsb.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/hu.lproj/Localizable.strings b/iOSClient/Supporting Files/hu.lproj/Localizable.strings index 39f252fc07..388fa258d8 100644 Binary files a/iOSClient/Supporting Files/hu.lproj/Localizable.strings and b/iOSClient/Supporting Files/hu.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/hy.lproj/Localizable.strings b/iOSClient/Supporting Files/hy.lproj/Localizable.strings index 0bf8dfb850..e20d9ac337 100644 Binary files a/iOSClient/Supporting Files/hy.lproj/Localizable.strings and b/iOSClient/Supporting Files/hy.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ia.lproj/Localizable.strings b/iOSClient/Supporting Files/ia.lproj/Localizable.strings index f09813e089..45bd5bac13 100644 Binary files a/iOSClient/Supporting Files/ia.lproj/Localizable.strings and b/iOSClient/Supporting Files/ia.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/id.lproj/Localizable.strings b/iOSClient/Supporting Files/id.lproj/Localizable.strings index 242314f4f0..414316c20e 100644 Binary files a/iOSClient/Supporting Files/id.lproj/Localizable.strings and b/iOSClient/Supporting Files/id.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ig.lproj/Localizable.strings b/iOSClient/Supporting Files/ig.lproj/Localizable.strings index 351a6ed1e7..2ced5bac19 100644 Binary files a/iOSClient/Supporting Files/ig.lproj/Localizable.strings and b/iOSClient/Supporting Files/ig.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/is.lproj/Localizable.strings b/iOSClient/Supporting Files/is.lproj/Localizable.strings index 783cfbf16a..c0bf913044 100644 Binary files a/iOSClient/Supporting Files/is.lproj/Localizable.strings and b/iOSClient/Supporting Files/is.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/it.lproj/Localizable.strings b/iOSClient/Supporting Files/it.lproj/Localizable.strings index 5ba204181f..5539588037 100644 Binary files a/iOSClient/Supporting Files/it.lproj/Localizable.strings and b/iOSClient/Supporting Files/it.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ja-JP.lproj/Localizable.strings b/iOSClient/Supporting Files/ja-JP.lproj/Localizable.strings index f060e8243c..31286b4e5c 100644 Binary files a/iOSClient/Supporting Files/ja-JP.lproj/Localizable.strings and b/iOSClient/Supporting Files/ja-JP.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ka-GE.lproj/Localizable.strings b/iOSClient/Supporting Files/ka-GE.lproj/Localizable.strings index 5e19ef2c07..d1674981f2 100644 Binary files a/iOSClient/Supporting Files/ka-GE.lproj/Localizable.strings and b/iOSClient/Supporting Files/ka-GE.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ka.lproj/Localizable.strings b/iOSClient/Supporting Files/ka.lproj/Localizable.strings index d88100a0ef..8d030886c9 100644 Binary files a/iOSClient/Supporting Files/ka.lproj/Localizable.strings and b/iOSClient/Supporting Files/ka.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/kab.lproj/Localizable.strings b/iOSClient/Supporting Files/kab.lproj/Localizable.strings index eac58dd08b..fbe26df7e3 100644 Binary files a/iOSClient/Supporting Files/kab.lproj/Localizable.strings and b/iOSClient/Supporting Files/kab.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/km.lproj/Localizable.strings b/iOSClient/Supporting Files/km.lproj/Localizable.strings index f2b1adb532..29fb678f37 100644 Binary files a/iOSClient/Supporting Files/km.lproj/Localizable.strings and b/iOSClient/Supporting Files/km.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/kn.lproj/Localizable.strings b/iOSClient/Supporting Files/kn.lproj/Localizable.strings index 8e0949d6ab..8fecd74223 100644 Binary files a/iOSClient/Supporting Files/kn.lproj/Localizable.strings and b/iOSClient/Supporting Files/kn.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ko.lproj/Localizable.strings b/iOSClient/Supporting Files/ko.lproj/Localizable.strings index f1fc28a950..fb576a638a 100644 Binary files a/iOSClient/Supporting Files/ko.lproj/Localizable.strings and b/iOSClient/Supporting Files/ko.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/la.lproj/Localizable.strings b/iOSClient/Supporting Files/la.lproj/Localizable.strings index 6ebec8be96..b18eb4d20c 100644 Binary files a/iOSClient/Supporting Files/la.lproj/Localizable.strings and b/iOSClient/Supporting Files/la.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/lb.lproj/Localizable.strings b/iOSClient/Supporting Files/lb.lproj/Localizable.strings index 0db8fe7c13..fe35a0a8bc 100644 Binary files a/iOSClient/Supporting Files/lb.lproj/Localizable.strings and b/iOSClient/Supporting Files/lb.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/lo.lproj/Localizable.strings b/iOSClient/Supporting Files/lo.lproj/Localizable.strings index a782e89a67..6a743f03ee 100644 Binary files a/iOSClient/Supporting Files/lo.lproj/Localizable.strings and b/iOSClient/Supporting Files/lo.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/lt_LT.lproj/Localizable.strings b/iOSClient/Supporting Files/lt_LT.lproj/Localizable.strings index 36e6667439..e7470b668e 100644 Binary files a/iOSClient/Supporting Files/lt_LT.lproj/Localizable.strings and b/iOSClient/Supporting Files/lt_LT.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/lv.lproj/Localizable.strings b/iOSClient/Supporting Files/lv.lproj/Localizable.strings index 9f6a46ddab..3e18deb48e 100644 Binary files a/iOSClient/Supporting Files/lv.lproj/Localizable.strings and b/iOSClient/Supporting Files/lv.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/mk.lproj/Localizable.strings b/iOSClient/Supporting Files/mk.lproj/Localizable.strings index 972aa30934..8e0abcdc98 100644 Binary files a/iOSClient/Supporting Files/mk.lproj/Localizable.strings and b/iOSClient/Supporting Files/mk.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/mn.lproj/Localizable.strings b/iOSClient/Supporting Files/mn.lproj/Localizable.strings index c24d9b278b..6ecb7788ad 100644 Binary files a/iOSClient/Supporting Files/mn.lproj/Localizable.strings and b/iOSClient/Supporting Files/mn.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/mr.lproj/Localizable.strings b/iOSClient/Supporting Files/mr.lproj/Localizable.strings index a0e70ab106..c1cb3a6ace 100644 Binary files a/iOSClient/Supporting Files/mr.lproj/Localizable.strings and b/iOSClient/Supporting Files/mr.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ms_MY.lproj/Localizable.strings b/iOSClient/Supporting Files/ms_MY.lproj/Localizable.strings index c7f075fb5e..f6da79e72c 100644 Binary files a/iOSClient/Supporting Files/ms_MY.lproj/Localizable.strings and b/iOSClient/Supporting Files/ms_MY.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/my.lproj/Localizable.strings b/iOSClient/Supporting Files/my.lproj/Localizable.strings index 8e12ebc518..7cb007ea18 100644 Binary files a/iOSClient/Supporting Files/my.lproj/Localizable.strings and b/iOSClient/Supporting Files/my.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/nb-NO.lproj/Localizable.strings b/iOSClient/Supporting Files/nb-NO.lproj/Localizable.strings index 8a59d163d9..2135c256bb 100644 Binary files a/iOSClient/Supporting Files/nb-NO.lproj/Localizable.strings and b/iOSClient/Supporting Files/nb-NO.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ne.lproj/Localizable.strings b/iOSClient/Supporting Files/ne.lproj/Localizable.strings index 68b899e474..a2c8dbd825 100644 Binary files a/iOSClient/Supporting Files/ne.lproj/Localizable.strings and b/iOSClient/Supporting Files/ne.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/nl.lproj/Localizable.strings b/iOSClient/Supporting Files/nl.lproj/Localizable.strings index 8d5fc3b13f..65b652295c 100644 Binary files a/iOSClient/Supporting Files/nl.lproj/Localizable.strings and b/iOSClient/Supporting Files/nl.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/nn_NO.lproj/Localizable.strings b/iOSClient/Supporting Files/nn_NO.lproj/Localizable.strings index e0ebe22892..fb19c70479 100644 Binary files a/iOSClient/Supporting Files/nn_NO.lproj/Localizable.strings and b/iOSClient/Supporting Files/nn_NO.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/oc.lproj/Localizable.strings b/iOSClient/Supporting Files/oc.lproj/Localizable.strings index 115a8e8506..aadbd4f192 100644 Binary files a/iOSClient/Supporting Files/oc.lproj/Localizable.strings and b/iOSClient/Supporting Files/oc.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/pl.lproj/Localizable.strings b/iOSClient/Supporting Files/pl.lproj/Localizable.strings index b8862598bc..5354c33206 100644 Binary files a/iOSClient/Supporting Files/pl.lproj/Localizable.strings and b/iOSClient/Supporting Files/pl.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ps.lproj/Localizable.strings b/iOSClient/Supporting Files/ps.lproj/Localizable.strings index f2bad1c7f9..a6987869b7 100644 Binary files a/iOSClient/Supporting Files/ps.lproj/Localizable.strings and b/iOSClient/Supporting Files/ps.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/pt-BR.lproj/Localizable.strings b/iOSClient/Supporting Files/pt-BR.lproj/Localizable.strings index 82062c8b00..4d69d98b14 100644 Binary files a/iOSClient/Supporting Files/pt-BR.lproj/Localizable.strings and b/iOSClient/Supporting Files/pt-BR.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/pt-PT.lproj/Localizable.strings b/iOSClient/Supporting Files/pt-PT.lproj/Localizable.strings index d95b685c99..47e20bba71 100644 Binary files a/iOSClient/Supporting Files/pt-PT.lproj/Localizable.strings and b/iOSClient/Supporting Files/pt-PT.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ro.lproj/Localizable.strings b/iOSClient/Supporting Files/ro.lproj/Localizable.strings index c5044b0180..9efd4433e6 100644 Binary files a/iOSClient/Supporting Files/ro.lproj/Localizable.strings and b/iOSClient/Supporting Files/ro.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ru.lproj/Localizable.strings b/iOSClient/Supporting Files/ru.lproj/Localizable.strings index d5ab7a15eb..8d6dadfa18 100644 Binary files a/iOSClient/Supporting Files/ru.lproj/Localizable.strings and b/iOSClient/Supporting Files/ru.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sc.lproj/Localizable.strings b/iOSClient/Supporting Files/sc.lproj/Localizable.strings index ae320c3e7b..62d3880ece 100644 Binary files a/iOSClient/Supporting Files/sc.lproj/Localizable.strings and b/iOSClient/Supporting Files/sc.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/si.lproj/Localizable.strings b/iOSClient/Supporting Files/si.lproj/Localizable.strings index 3429cd544f..707374b723 100644 Binary files a/iOSClient/Supporting Files/si.lproj/Localizable.strings and b/iOSClient/Supporting Files/si.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sk-SK.lproj/Localizable.strings b/iOSClient/Supporting Files/sk-SK.lproj/Localizable.strings index b7bfff2251..bc3b85fb7b 100644 Binary files a/iOSClient/Supporting Files/sk-SK.lproj/Localizable.strings and b/iOSClient/Supporting Files/sk-SK.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sl.lproj/Localizable.strings b/iOSClient/Supporting Files/sl.lproj/Localizable.strings index 54e70073b4..c942c69ec0 100644 Binary files a/iOSClient/Supporting Files/sl.lproj/Localizable.strings and b/iOSClient/Supporting Files/sl.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sq.lproj/Localizable.strings b/iOSClient/Supporting Files/sq.lproj/Localizable.strings index 445f350402..785271e4fb 100644 Binary files a/iOSClient/Supporting Files/sq.lproj/Localizable.strings and b/iOSClient/Supporting Files/sq.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sr.lproj/Localizable.strings b/iOSClient/Supporting Files/sr.lproj/Localizable.strings index 4cd2c14ad5..15c18e0490 100644 Binary files a/iOSClient/Supporting Files/sr.lproj/Localizable.strings and b/iOSClient/Supporting Files/sr.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sr@latin.lproj/Localizable.strings b/iOSClient/Supporting Files/sr@latin.lproj/Localizable.strings index 95ccbc8025..e7110e701f 100644 Binary files a/iOSClient/Supporting Files/sr@latin.lproj/Localizable.strings and b/iOSClient/Supporting Files/sr@latin.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sv.lproj/Localizable.strings b/iOSClient/Supporting Files/sv.lproj/Localizable.strings index f13f7b7cbe..f770f499b6 100644 Binary files a/iOSClient/Supporting Files/sv.lproj/Localizable.strings and b/iOSClient/Supporting Files/sv.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/sw.lproj/Localizable.strings b/iOSClient/Supporting Files/sw.lproj/Localizable.strings index 78e97d97e4..edf86e3076 100644 Binary files a/iOSClient/Supporting Files/sw.lproj/Localizable.strings and b/iOSClient/Supporting Files/sw.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ta.lproj/Localizable.strings b/iOSClient/Supporting Files/ta.lproj/Localizable.strings index 34f9b6d878..659c9e3dd1 100644 Binary files a/iOSClient/Supporting Files/ta.lproj/Localizable.strings and b/iOSClient/Supporting Files/ta.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/th_TH.lproj/Localizable.strings b/iOSClient/Supporting Files/th_TH.lproj/Localizable.strings index b6c1e547e5..4eda54f09b 100644 Binary files a/iOSClient/Supporting Files/th_TH.lproj/Localizable.strings and b/iOSClient/Supporting Files/th_TH.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/tk.lproj/Localizable.strings b/iOSClient/Supporting Files/tk.lproj/Localizable.strings index c2571af504..2618fad11a 100644 Binary files a/iOSClient/Supporting Files/tk.lproj/Localizable.strings and b/iOSClient/Supporting Files/tk.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/tr.lproj/Localizable.strings b/iOSClient/Supporting Files/tr.lproj/Localizable.strings index 6de73fc4a2..61a8ba00d2 100644 Binary files a/iOSClient/Supporting Files/tr.lproj/Localizable.strings and b/iOSClient/Supporting Files/tr.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ug.lproj/Localizable.strings b/iOSClient/Supporting Files/ug.lproj/Localizable.strings index b1b6b24de1..9333156387 100644 Binary files a/iOSClient/Supporting Files/ug.lproj/Localizable.strings and b/iOSClient/Supporting Files/ug.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/uk.lproj/Localizable.strings b/iOSClient/Supporting Files/uk.lproj/Localizable.strings index a44582aec7..0101bc097f 100644 Binary files a/iOSClient/Supporting Files/uk.lproj/Localizable.strings and b/iOSClient/Supporting Files/uk.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/ur_PK.lproj/Localizable.strings b/iOSClient/Supporting Files/ur_PK.lproj/Localizable.strings index ce28e21970..eed072512a 100644 Binary files a/iOSClient/Supporting Files/ur_PK.lproj/Localizable.strings and b/iOSClient/Supporting Files/ur_PK.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/uz.lproj/Localizable.strings b/iOSClient/Supporting Files/uz.lproj/Localizable.strings index 7a535fe984..72f6cfa58b 100644 Binary files a/iOSClient/Supporting Files/uz.lproj/Localizable.strings and b/iOSClient/Supporting Files/uz.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/vi.lproj/Localizable.strings b/iOSClient/Supporting Files/vi.lproj/Localizable.strings index 06334a05ca..16b939fd99 100644 Binary files a/iOSClient/Supporting Files/vi.lproj/Localizable.strings and b/iOSClient/Supporting Files/vi.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/zh-Hans.lproj/Localizable.strings b/iOSClient/Supporting Files/zh-Hans.lproj/Localizable.strings index 95758e95cb..5d1fa916ec 100644 Binary files a/iOSClient/Supporting Files/zh-Hans.lproj/Localizable.strings and b/iOSClient/Supporting Files/zh-Hans.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/zh-Hant-TW.lproj/Localizable.strings b/iOSClient/Supporting Files/zh-Hant-TW.lproj/Localizable.strings index 6d9f253467..0b35ba3a0b 100644 Binary files a/iOSClient/Supporting Files/zh-Hant-TW.lproj/Localizable.strings and b/iOSClient/Supporting Files/zh-Hant-TW.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/zh_HK.lproj/Localizable.strings b/iOSClient/Supporting Files/zh_HK.lproj/Localizable.strings index f59392b323..393d734456 100644 Binary files a/iOSClient/Supporting Files/zh_HK.lproj/Localizable.strings and b/iOSClient/Supporting Files/zh_HK.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/zu_ZA.lproj/Localizable.strings b/iOSClient/Supporting Files/zu_ZA.lproj/Localizable.strings index 4acf7722bd..8b53315190 100644 Binary files a/iOSClient/Supporting Files/zu_ZA.lproj/Localizable.strings and b/iOSClient/Supporting Files/zu_ZA.lproj/Localizable.strings differ diff --git a/iOSClient/Utility/NCSVGRenderer.swift b/iOSClient/Utility/NCSVGRenderer.swift index be88278e0c..a3e56505d8 100644 --- a/iOSClient/Utility/NCSVGRenderer.swift +++ b/iOSClient/Utility/NCSVGRenderer.swift @@ -5,63 +5,172 @@ import UIKit import WebKit +/// SVG rasterizer based on WKWebView + takeSnapshot. +/// +/// Design goals: +/// - Render at the final pixel size (avoid "rasterize small then upscale"). +/// - Prefer inline SVG in the DOM (avoid rasterization path). +/// - Keep alpha edges intact (avoid trimming that kills antialiasing). @MainActor final class NCSVGRenderer: NSObject, WKNavigationDelegate { + + // MARK: - State + private var navigationContinuation: CheckedContinuation? private var webView: WKWebView? - private let utilityFileSystem = NCUtilityFileSystem() +<<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> d99135d60a (svg-fix (#3990)) + // MARK: - Public API + + /// Renders an SVG into a UIImage using WKWebView snapshotting. + /// + /// - Parameters: + /// - svgData: Raw SVG data (UTF-8 expected). + /// - size: Target output size in *pixels* (e.g. 256x256). + /// - backgroundColor: Background fill behind the SVG (use .clear for transparency). + /// - trimTransparentPixels: If true, crops transparent borders. + /// - alphaThreshold: Pixels with alpha <= threshold are considered transparent during trimming. + /// - Returns: A UIImage with sharp edges at the requested pixel size, or nil if input is nil. + func renderSVGToUIImage( + svgData: Data?, + size: CGSize = CGSize(width: 256, height: 256), + backgroundColor: UIColor = .clear, + tintColor: UIColor? = nil, + trimTransparentPixels: Bool = true, + alphaThreshold: UInt8 = 0 + ) async throws -> UIImage? { +<<<<<<< HEAD +======= func renderSVGToUIImage(svgData: Data?, - size: CGSize = CGSize(width: 128, height: 128), - backgroundColor: UIColor = .clear) async throws -> UIImage? { + size: CGSize = CGSize(width: 256, height: 256), + backgroundColor: UIColor = .clear, + trimTransparentPixels: Bool = true, + alphaThreshold: UInt8 = 8) async throws -> UIImage? { +>>>>>>> fd0de89732 (Fix gui svg (#3989)) +======= +>>>>>>> d99135d60a (svg-fix (#3990)) guard let svgData else { return nil } - let targetSize = size - let logicalSize = CGSize(width: max(1, targetSize.width / max(UIScreen.main.scale, 1)), - height: max(1, targetSize.height / max(UIScreen.main.scale, 1))) + // Treat `size` as pixels. Convert to points for WKWebView/snapshot. + let scale = max(UIScreen.main.scale, 1) + let targetPixelSize = CGSize(width: max(1, size.width), height: max(1, size.height)) + let targetPointSize = CGSize( + width: max(1, targetPixelSize.width / scale), + height: max(1, targetPixelSize.height / scale) + ) - let webView = WKWebView(frame: CGRect(origin: .zero, size: logicalSize)) + // Build a dedicated WKWebView sized in points. + let webView = makeWebView(sizeInPoints: targetPointSize, backgroundColor: backgroundColor) self.webView = webView + // Inline the SVG into the DOM to avoid rasterization path. + let html = makeHTML(svgData: svgData, canvasPointSize: targetPointSize, backgroundColor: backgroundColor, tintColor: tintColor) + + try await loadHTMLAsync(webView: webView, html: html) + try await waitForInlineSVGReady(webView: webView) + + // Snapshot exactly the webView bounds; WebKit will render at device scale. + let config = WKSnapshotConfiguration() + config.rect = CGRect(origin: .zero, size: targetPointSize) + config.afterScreenUpdates = true + config.snapshotWidth = NSNumber(value: Double(targetPointSize.width)) + + let snapshot = try await takeSnapshotAsync(webView: webView, configuration: config) + + // Ensure the returned image is exactly the requested pixel dimensions. + // This is a defensive step; it should usually already match. + let finalImage = Self.normalize(snapshot, toPixelSize: targetPixelSize, scale: scale) + + if trimTransparentPixels, + let trimmed = Self.trimTransparentPixels(in: finalImage, alphaThreshold: alphaThreshold) { + return trimmed + } + + return finalImage + } + + // MARK: - WebView / HTML + + private func makeWebView(sizeInPoints: CGSize, backgroundColor: UIColor) -> WKWebView { + let config = WKWebViewConfiguration() + config.suppressesIncrementalRendering = false + + let webView = WKWebView(frame: CGRect(origin: .zero, size: sizeInPoints), configuration: config) webView.navigationDelegate = self webView.isOpaque = false webView.backgroundColor = backgroundColor webView.scrollView.backgroundColor = backgroundColor webView.layer.backgroundColor = backgroundColor.cgColor + webView.scrollView.isScrollEnabled = false + return webView + } - let cssBackground = backgroundColor == .clear ? "transparent" : backgroundColor.toCSSColor() + private func makeHTML( + svgData: Data, + canvasPointSize: CGSize, + backgroundColor: UIColor, + tintColor: UIColor? + ) -> String { - let html = """ - - - - - - - + margin: 0; + padding: 0; + width: \(w)px; + height: \(h)px; + overflow: hidden; + background: \(cssBackground); + color: \(cssTint); + } + +<<<<<<< HEAD + // Ensure a viewBox exists (better scaling behavior for many icons). + const svg = container.querySelector('svg'); + if (svg) { + const hasViewBox = svg.getAttribute('viewBox'); + if (!hasViewBox) { + const width = svg.getAttribute('width') || \(w); + const height = svg.getAttribute('height') || \(h); + svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + } + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + } + })(); + """ +<<<<<<< HEAD +<<<<<<< HEAD +======= try await loadHTMLAsync(webView: webView, html: html) try await waitForImageReady(webView: webView) @@ -78,14 +187,68 @@ final class NCSVGRenderer: NSObject, WKNavigationDelegate { let scaled = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) } + + if trimTransparentPixels, + let trimmed = Self.trimTransparentPixels(in: scaled, alphaThreshold: alphaThreshold) { + return trimmed + } + return scaled +>>>>>>> fd0de89732 (Fix gui svg (#3989)) +======= +>>>>>>> d99135d60a (svg-fix (#3990)) +======= + #container { + width: 100%; + height: 100%; + display: block; + background: \(cssBackground); + } + + #container svg { + width: 100%; + height: 100%; + display: block; + } + + \(svgFillRule) + + + +
+ + + + """ +>>>>>>> 688c5b5c5a (added tintcolor (#3992)) } private func loadHTMLAsync(webView: WKWebView, html: String) async throws { - // Cancel any in-flight load to avoid overlapping delegates/continuations webView.stopLoading() + if let pending = navigationContinuation { - pending.resume(throwing: NSError(domain: "NCSVGRenderer", code: -22, userInfo: [NSLocalizedDescriptionKey: "Cancelled previous load"])) + pending.resume(throwing: NSError( + domain: "NCSVGRenderer", + code: -22, + userInfo: [NSLocalizedDescriptionKey: "Cancelled previous load."] + )) navigationContinuation = nil } @@ -95,22 +258,29 @@ final class NCSVGRenderer: NSObject, WKNavigationDelegate { } } - private func waitForImageReady(webView: WKWebView) async throws { + private func waitForInlineSVGReady(webView: WKWebView) async throws { + // Wait until the inline SVG exists and has a non-zero bounding box. let js = """ (function() { - const img = document.getElementById('svgImage'); - if (!img) return false; - return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0; + const svg = document.querySelector('#container svg'); + if (!svg) return false; + const box = svg.getBoundingClientRect(); + return box.width > 0 && box.height > 0; })(); """ - // wait max ~3 sec + // ~3 seconds max (100 * 30ms) for _ in 0..<100 { let ready = try await webView.evaluateJavaScript(js) as? Bool if ready == true { return } try await Task.sleep(nanoseconds: 30_000_000) } - throw NSError(domain: "NCSVGRenderer", code: -24, userInfo: [NSLocalizedDescriptionKey: "Image not ready within timeout"]) + + throw NSError( + domain: "NCSVGRenderer", + code: -24, + userInfo: [NSLocalizedDescriptionKey: "Inline SVG not ready within timeout."] + ) } private func takeSnapshotAsync(webView: WKWebView, configuration: WKSnapshotConfiguration) async throws -> UIImage { @@ -136,4 +306,252 @@ final class NCSVGRenderer: NSObject, WKNavigationDelegate { navigationContinuation?.resume(throwing: error) navigationContinuation = nil } + + // MARK: - Image helpers + + /// Ensures an image matches a requested pixel size without "double scaling" artifacts. + /// If the snapshot already matches, it is returned unchanged. + private static func normalize(_ image: UIImage, toPixelSize pixelSize: CGSize, scale: CGFloat) -> UIImage { + let currentPixelSize = CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale) + + // Close enough: avoid any resample. + if abs(currentPixelSize.width - pixelSize.width) < 0.5, + abs(currentPixelSize.height - pixelSize.height) < 0.5 { + return image + } + + // Render in points with the intended scale, producing exactly `pixelSize` pixels. + let targetPointSize = CGSize(width: pixelSize.width / scale, height: pixelSize.height / scale) + + let format = UIGraphicsImageRendererFormat.default() + format.scale = scale + format.opaque = false + + let renderer = UIGraphicsImageRenderer(size: targetPointSize, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetPointSize)) + } + } + + /// Crops transparent borders while preserving antialiased edges. + /// To avoid clipping feathered pixels, default alphaThreshold should be 0. + private static func trimTransparentPixels(in image: UIImage, alphaThreshold: UInt8) -> UIImage? { + guard let cgImage = image.cgImage else { return nil } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = width * 4 + let colorSpace = CGColorSpaceCreateDeviceRGB() + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), let data = context.data else { + return nil + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let buffer = data.bindMemory(to: UInt8.self, capacity: width * height * 4) + var minX = width + var minY = height + var maxX = 0 + var maxY = 0 + var found = false + + for y in 0.. alphaThreshold { + found = true + if x < minX { minX = x } + if y < minY { minY = y } + if x > maxX { maxX = x } + if y > maxY { maxY = y } + } + } + } + + guard found else { return nil } + + // Expand by 1 pixel to preserve edge AA when threshold > 0. + minX = max(minX - 1, 0) + minY = max(minY - 1, 0) + maxX = min(maxX + 1, width - 1) + maxY = min(maxY + 1, height - 1) + + let cropRect = CGRect( + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + ) + + guard let cropped = cgImage.cropping(to: cropRect) else { return nil } + return UIImage(cgImage: cropped, scale: image.scale, orientation: .up) + } +<<<<<<< HEAD + + private func loadHTMLAsync(webView: WKWebView, html: String) async throws { + webView.stopLoading() + + if let pending = navigationContinuation { + pending.resume(throwing: NSError( + domain: "NCSVGRenderer", + code: -22, + userInfo: [NSLocalizedDescriptionKey: "Cancelled previous load."] + )) + navigationContinuation = nil + } + + try await withCheckedThrowingContinuation { cont in + navigationContinuation = cont + webView.loadHTMLString(html, baseURL: nil) + } + } + + private func waitForInlineSVGReady(webView: WKWebView) async throws { + // Wait until the inline SVG exists and has a non-zero bounding box. + let js = """ + (function() { + const svg = document.querySelector('#container svg'); + if (!svg) return false; + const box = svg.getBoundingClientRect(); + return box.width > 0 && box.height > 0; + })(); + """ + + // ~3 seconds max (100 * 30ms) + for _ in 0..<100 { + let ready = try await webView.evaluateJavaScript(js) as? Bool + if ready == true { return } + try await Task.sleep(nanoseconds: 30_000_000) + } + + throw NSError( + domain: "NCSVGRenderer", + code: -24, + userInfo: [NSLocalizedDescriptionKey: "Inline SVG not ready within timeout."] + ) + } + + private func takeSnapshotAsync(webView: WKWebView, configuration: WKSnapshotConfiguration) async throws -> UIImage { + try await withCheckedThrowingContinuation { cont in + webView.takeSnapshot(with: configuration) { image, error in + if let image { + cont.resume(returning: image) + } else { + cont.resume(throwing: error ?? NSError(domain: "NCSVGRenderer", code: -21)) + } + } + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + navigationContinuation?.resume() + navigationContinuation = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + navigationContinuation?.resume(throwing: error) + navigationContinuation = nil + } + + // MARK: - Image helpers + + /// Ensures an image matches a requested pixel size without "double scaling" artifacts. + /// If the snapshot already matches, it is returned unchanged. + private static func normalize(_ image: UIImage, toPixelSize pixelSize: CGSize, scale: CGFloat) -> UIImage { + let currentPixelSize = CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale) + + // Close enough: avoid any resample. + if abs(currentPixelSize.width - pixelSize.width) < 0.5, + abs(currentPixelSize.height - pixelSize.height) < 0.5 { + return image + } + + // Render in points with the intended scale, producing exactly `pixelSize` pixels. + let targetPointSize = CGSize(width: pixelSize.width / scale, height: pixelSize.height / scale) + + let format = UIGraphicsImageRendererFormat.default() + format.scale = scale + format.opaque = false + + let renderer = UIGraphicsImageRenderer(size: targetPointSize, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetPointSize)) + } + } + + /// Crops transparent borders while preserving antialiased edges. + /// To avoid clipping feathered pixels, default alphaThreshold should be 0. + private static func trimTransparentPixels(in image: UIImage, alphaThreshold: UInt8) -> UIImage? { + guard let cgImage = image.cgImage else { return nil } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = width * 4 + let colorSpace = CGColorSpaceCreateDeviceRGB() + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), let data = context.data else { + return nil + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let buffer = data.bindMemory(to: UInt8.self, capacity: width * height * 4) + var minX = width + var minY = height + var maxX = 0 + var maxY = 0 + var found = false + + for y in 0.. alphaThreshold { + found = true + if x < minX { minX = x } + if y < minY { minY = y } + if x > maxX { maxX = x } + if y > maxY { maxY = y } + } + } + } + + guard found else { return nil } + + // Expand by 1 pixel to preserve edge AA when threshold > 0. + minX = max(minX - 1, 0) + minY = max(minY - 1, 0) + maxX = min(maxX + 1, width - 1) + maxY = min(maxY + 1, height - 1) + + let cropRect = CGRect( + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + ) + + guard let cropped = cgImage.cropping(to: cropRect) else { return nil } + return UIImage(cgImage: cropped, scale: image.scale, orientation: .up) + } +======= +>>>>>>> d99135d60a (svg-fix (#3990)) } diff --git a/iOSClient/Utility/NCUtility+Image.swift b/iOSClient/Utility/NCUtility+Image.swift index 0f495107f4..583789726f 100644 --- a/iOSClient/Utility/NCUtility+Image.swift +++ b/iOSClient/Utility/NCUtility+Image.swift @@ -264,8 +264,9 @@ extension NCUtility { #if !EXTENSION func convertSVGtoPNGWriteToUserData(serverUrl: String, - size: CGFloat = 128, + size: CGFloat = 256, rewrite: Bool, + trimTransparentPixels: Bool = true, account: String, id: Int? = nil) async -> (image: UIImage?, id: Int?) { var serverUrl = serverUrl @@ -313,7 +314,7 @@ extension NCUtility { // is a SVG do { - let image = try await NCSVGRenderer().renderSVGToUIImage(svgData: data, size: CGSize(width: size, height: size)) + let image = try await NCSVGRenderer().renderSVGToUIImage(svgData: data, size: CGSize(width: size, height: size), trimTransparentPixels: trimTransparentPixels) guard let image, let pngImageData = image.pngData() else { return(nil, id)