diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5c11cdc3df..e422a1f00b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -763,6 +763,7 @@ struct ChatView: View { @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var showForwardingSheet: Bool = false + @State private var showTranslationSheet: Bool = false @State private var msgWidth: CGFloat = 0 @Binding var selectedChatItems: Set? @@ -1087,6 +1088,11 @@ struct ChatView: View { ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState) } } + .sheet(isPresented: $showTranslationSheet) { + if #available(iOS 18.0, *) { + TranslateView(source: ci.text).presentationDetents([.medium, .large]) + } + } } private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { @@ -1143,6 +1149,9 @@ struct ChatView: View { shareButton(ci) copyButton(ci) } + if #available(iOS 18.0, *) { + if !ci.text.isEmpty { translateButton(ci) } + } if let fileSource = fileSource, fileExists { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { @@ -1327,6 +1336,14 @@ struct ChatView: View { } } + private func translateButton(_ ci: ChatItem) -> Button { + Button { + showTranslationSheet = true + } label: { + Label("Translate", systemImage: "globe") + } + } + func saveButton(image: UIImage) -> Button { Button { UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) diff --git a/apps/ios/Shared/Views/Chat/TranslateView.swift b/apps/ios/Shared/Views/Chat/TranslateView.swift new file mode 100644 index 0000000000..26230d0ffe --- /dev/null +++ b/apps/ios/Shared/Views/Chat/TranslateView.swift @@ -0,0 +1,73 @@ +// +// TranslateView.swift +// SimpleX +// +// Created by user on 19/09/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import Translation + +@available(iOS 18.0, *) +struct TranslateView: View { + let source: String + @State private var supprtedLanguages = [Locale.Language]() + @State private var target: String? + @State private var configuration = TranslationSession.Configuration() + + var body: some View { + NavigationStack { + List { + Section { + languagePicker("From", selection: $configuration.source) + Text(source).foregroundStyle(.secondary).lineLimit(1) + } + Section { + languagePicker("To ", selection: $configuration.target) + if let target { + Text(target) + } else { + ProgressView() + } + } footer: { + Text("\(Image(systemName: "info.circle")) Uses on device translation") + } + } + .navigationTitle("Translate") + } + .task { supprtedLanguages = await LanguageAvailability().supportedLanguages } + .translationTask(configuration) { session in + do { + target = nil + let response = try await session.translate(source) + await MainActor.run { + configuration.source = response.sourceLanguage + configuration.target = response.targetLanguage + target = response.targetText + } + } catch { + print(error) + } + } + } + + @ViewBuilder + private func languagePicker(_ label: String, selection: Binding) -> some View { + Picker(label, selection: selection) { + Text("Detect language").tag(Optional.none) + ForEach(supprtedLanguages, id: \.self) { language in + Text(title(for: language)).tag(language) + } + } + } + + private func title(for language: Locale.Language) -> String { + if let code = language.languageCode, + let string = Locale.current.localizedString(forLanguageCode: code.identifier) { + string + } else { + language.minimalIdentifier + } + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b0b838eef8..c47fac685b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = CE38A29B2C3FCD72005ED185 /* SwiftyGif */; }; CE75480A2C622630009579B7 /* SwipeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7548092C622630009579B7 /* SwipeLabel.swift */; }; CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; }; + CEA034032C9C0F1800B587E7 /* TranslateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA034022C9C0F1800B587E7 /* TranslateView.swift */; }; CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE70212C48FD9500233B1F /* SEChatState.swift */; }; CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */; }; CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -543,6 +544,7 @@ CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; CE7548092C622630009579B7 /* SwipeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeLabel.swift; sourceTree = ""; }; CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = ""; }; + CEA034022C9C0F1800B587E7 /* TranslateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslateView.swift; sourceTree = ""; }; CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = ""; }; CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX SE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -727,6 +729,7 @@ 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, + CEA034022C9C0F1800B587E7 /* TranslateView.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, ); path = Chat; @@ -1393,6 +1396,7 @@ 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + CEA034032C9C0F1800B587E7 /* TranslateView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 987f7f3d41..441ef9668b 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -177,14 +177,66 @@ public func fromCString(_ c: UnsafeMutablePointer) -> String { return s } +// TODO: Remove after XCode 16 crash has been resolved/merged +class ThreadExecutor { + private var stackSize: Int + private var qos: QualityOfService + + /// Initialize by specifying the stack size + init(stackSize: Int, qos: QualityOfService) { + self.stackSize = stackSize + self.qos = qos + } + + /// Execute a closure synchronously on a separate thread and return the result + func executeSync(_ task: @escaping () throws -> ResultType) throws -> ResultType { + // Initialize the semaphore (initially locked) + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + // Initialize the thread + let thread = Thread { + do { + let taskResult = try task() + result = .success(taskResult) + } catch { + result = .failure(error) + } + // Release the semaphore when the task is completed + semaphore.signal() + } + + thread.stackSize = stackSize + thread.qualityOfService = qos + + thread.start() + + // Wait until the thread completes + semaphore.wait() + + // Return the result + switch result! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } +} + public func chatResponse(_ s: String) -> ChatResponse { let d = s.data(using: .utf8)! // TODO is there a way to do it without copying the data? e.g: // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp + let executor = ThreadExecutor( + stackSize: 2*1024*1024, // 2MiB + qos: Thread.current.qualityOfService // inherit priority + ) + return try executor.executeSync { + try jsonDecoder.decode(APIResponse.self, from: d) + }.resp } catch { logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") }