From de6836fa5dfae7975509b065d5a2c88f951703c5 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 5 Nov 2024 15:33:17 +0100 Subject: [PATCH] Various improvements --- .../Sources/DebugController.swift | 18 +- .../GalleryUI/Sources/GalleryController.swift | 14 +- .../Sources/ChunkMediaPlayer.swift | 85 ++++---- .../Sources/TelegramBaseController.swift | 34 ++++ .../GroupCallNavigationAccessoryPanel.swift | 39 ++-- .../ManagedSecretChatOutgoingOperations.swift | 4 +- .../TelegramEngine/Calls/GroupCalls.swift | 72 ++++--- .../Messages/TelegramEngineMessages.swift | 4 +- .../TelegramEngine/Messages/Translate.swift | 79 ++++++-- .../TelegramUI/Sources/AppDelegate.swift | 7 + .../Sources/ChatHistoryListNode.swift | 22 +- .../ChatPinnedMessageTitlePanelNode.swift | 16 +- .../Sources/ExperimentalUISettings.swift | 10 +- .../TranslateUI/Sources/ChatTranslation.swift | 4 +- .../TranslateUI/Sources/Translate.swift | 190 ++++++++++++++++++ 15 files changed, 468 insertions(+), 130 deletions(-) diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index d6b9f206a1..255acb2049 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -106,6 +106,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case experimentalCallMute(Bool) case liveStreamV2(Bool) case dynamicStreaming(Bool) + case enableLocalTranslation(Bool) case preferredVideoCodec(Int, String, String?, Bool) case disableVideoAspectScaling(Bool) case enableNetworkFramework(Bool) @@ -130,7 +131,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming, .enableLocalTranslation: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -251,8 +252,10 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 53 case .dynamicStreaming: return 54 + case .enableLocalTranslation: + return 55 case let .preferredVideoCodec(index, _, _, _): - return 55 + index + return 56 + index case .disableVideoAspectScaling: return 100 case .enableNetworkFramework: @@ -1361,6 +1364,16 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) + case let .enableLocalTranslation(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Local Translation", value: value, sectionId: self.section, style: .blocks, updated: { value in + let _ = arguments.sharedContext.accountManager.transaction ({ transaction in + transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in + var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings + settings.enableLocalTranslation = value + return PreferencesEntry(settings) + }) + }).start() + }) case let .preferredVideoCodec(_, title, value, isSelected): return ItemListCheckboxItem(presentationData: presentationData, title: title, style: .right, checked: isSelected, zeroSeparatorInsets: false, sectionId: self.section, action: { let _ = arguments.sharedContext.accountManager.transaction ({ transaction in @@ -1519,6 +1532,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute)) entries.append(.liveStreamV2(experimentalSettings.liveStreamV2)) entries.append(.dynamicStreaming(experimentalSettings.dynamicStreaming)) + entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation)) } /*let codecs: [(String, String?)] = [ diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 3e2b6ce31d..e2adc98ad3 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -246,12 +246,14 @@ public func galleryItemForEntry( } else { if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { var isHLS = false - if NativeVideoContent.isHLSVideo(file: file) { - isHLS = true - - if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double { - if Int(disableHLS) != 0 { - isHLS = false + if #available(iOS 13.0, *) { + if NativeVideoContent.isHLSVideo(file: file) { + isHLS = true + + if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double { + if Int(disableHLS) != 0 { + isHLS = false + } } } } diff --git a/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift b/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift index 007c6eb247..4a5e32c1a6 100644 --- a/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift +++ b/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift @@ -119,13 +119,15 @@ public final class ChunkMediaPlayerPart { public let startTime: Double public let endTime: Double public let file: TempBoxFile + public let clippedStartTime: Double? public var id: Id { return Id(rawValue: self.file.path) } - public init(startTime: Double, endTime: Double, file: TempBoxFile) { + public init(startTime: Double, clippedStartTime: Double? = nil, endTime: Double, file: TempBoxFile) { self.startTime = startTime + self.clippedStartTime = clippedStartTime self.endTime = endTime self.file = file } @@ -620,59 +622,55 @@ private final class ChunkMediaPlayerContext { var validParts: [ChunkMediaPlayerPart] = [] + var minStartTime: Double = 0.0 for i in 0 ..< self.partsState.parts.count { let part = self.partsState.parts[i] + + let partStartTime = max(minStartTime, part.startTime) + let partEndTime = max(partStartTime, part.endTime) + if partStartTime >= partEndTime { + continue + } + var partMatches = false - if timestamp >= part.startTime - 0.5 && timestamp < part.endTime + 0.5 { + if timestamp >= partStartTime - 0.5 && timestamp < partEndTime + 0.5 { partMatches = true } if partMatches { - validParts.append(part) + validParts.append(ChunkMediaPlayerPart( + startTime: part.startTime, + clippedStartTime: partStartTime == part.startTime ? nil : partStartTime, + endTime: part.endTime, + file: part.file + )) + minStartTime = max(minStartTime, partEndTime) } } + if let lastValidPart = validParts.last { for i in 0 ..< self.partsState.parts.count { let part = self.partsState.parts[i] - if lastValidPart !== part && part.startTime > lastValidPart.startTime && part.startTime <= lastValidPart.endTime + 0.5 { - validParts.append(part) + + let partStartTime = max(minStartTime, part.startTime) + let partEndTime = max(partStartTime, part.endTime) + if partStartTime >= partEndTime { + continue + } + + if lastValidPart !== part && partStartTime > (lastValidPart.clippedStartTime ?? lastValidPart.startTime) && partStartTime <= lastValidPart.endTime + 0.5 { + validParts.append(ChunkMediaPlayerPart( + startTime: part.startTime, + clippedStartTime: partStartTime == part.startTime ? nil : partStartTime, + endTime: part.endTime, + file: part.file + )) + minStartTime = max(minStartTime, partEndTime) break } } } - /*for i in 0 ..< self.partsState.parts.count { - let part = self.partsState.parts[i] - var partMatches = false - if timestamp >= part.startTime - 0.001 && timestamp < part.endTime - 0.001 { - partMatches = true - } else if part.startTime < 0.2 && timestamp < part.endTime - 0.001 { - partMatches = true - } - - if !partMatches, i != self.partsState.parts.count - 1, part.startTime >= 0.001, timestamp >= part.startTime { - let nextPart = self.partsState.parts[i + 1] - if timestamp < nextPart.endTime - 0.001 { - if part.endTime >= nextPart.startTime - 0.1 { - partMatches = true - } - } - } - - if partMatches { - validParts.append(part) - - inner: for lookaheadPart in self.partsState.parts { - if lookaheadPart.startTime >= part.endTime - 0.001 && lookaheadPart.startTime - 0.1 < part.endTime { - validParts.append(lookaheadPart) - break inner - } - } - - break - } - }*/ - if validParts.isEmpty, let initialSeekTimestamp = self.initialSeekTimestamp { for part in self.partsState.parts { if initialSeekTimestamp >= part.startTime - 0.2 && initialSeekTimestamp < part.endTime { @@ -701,6 +699,8 @@ private final class ChunkMediaPlayerContext { self.initialSeekTimestamp = nil } + //print("validParts: \(validParts.map { "\($0.startTime) ... \($0.endTime)" })") + self.loadedState.partStates.removeAll(where: { partState in if !validParts.contains(where: { $0.id == partState.part.id }) { return true @@ -742,7 +742,13 @@ private final class ChunkMediaPlayerContext { for i in 0 ..< self.loadedState.partStates.count { let partState = self.loadedState.partStates[i] if partState.mediaBuffersDisposable == nil { - partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : 0.0) + let partSeekOffset: Double + if let clippedStartTime = partState.part.clippedStartTime { + partSeekOffset = clippedStartTime - partState.part.startTime + } else { + partSeekOffset = 0.0 + } + partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : partSeekOffset) |> deliverOn(self.queue)).startStrict(next: { [weak self, weak partState] result in guard let self, let partState else { return @@ -921,13 +927,14 @@ private final class ChunkMediaPlayerContext { for partState in self.loadedState.partStates { if let audioTrackFrameBuffer = partState.mediaBuffers?.audioBuffer { + //print("Poll audio: part \(partState.part.startTime) frames: \(audioTrackFrameBuffer.frames.map(\.pts.seconds))") let frame = audioTrackFrameBuffer.takeFrame() switch frame { case .finished: continue default: /*if case let .frame(frame) = frame { - print("audio: \(frame.position.seconds) \(frame.position.value) next: (\(frame.position.value + frame.duration.value))") + print("audio: \(frame.position.seconds) \(frame.position.value) part \(partState.part.startTime) next: (\(frame.position.value + frame.duration.value))") }*/ return frame } diff --git a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift index 80ae771ecf..2da6f4581e 100644 --- a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift +++ b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift @@ -461,6 +461,40 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder { invite: nil, activeCall: EngineGroupCallDescription(id: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, title: groupCallPanelData.info.title, scheduleTimestamp: groupCallPanelData.info.scheduleTimestamp, subscribedToScheduled: groupCallPanelData.info.subscribedToScheduled, isStream: groupCallPanelData.info.isStream) ) + }, notifyScheduledTapAction: { [weak self] in + guard let self, let groupCallPanelData = self.groupCallPanelData else { + return + } + if groupCallPanelData.info.scheduleTimestamp != nil && !groupCallPanelData.info.subscribedToScheduled { + let _ = self.context.engine.calls.toggleScheduledGroupCallSubscription(peerId: groupCallPanelData.peerId, callId: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, subscribe: true).startStandalone() + + //TODO:localize + let controller = UndoOverlayController( + presentationData: presentationData, + content: .universal( + animation: "anim_profileunmute", + scale: 0.075, + colors: [ + "Middle.Group 1.Fill 1": UIColor.white, + "Top.Group 1.Fill 1": UIColor.white, + "Bottom.Group 1.Fill 1": UIColor.white, + "EXAMPLE.Group 1.Fill 1": UIColor.white, + "Line.Group 1.Stroke 1": UIColor.white + ], + title: nil, + text: "You will be notified when the liver stream starts.", + customUndoText: nil, + timeout: nil + ), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in + return true + } + ) + self.audioRateTooltipController = controller + self.present(controller, in: .current) + } }) if let accessoryPanelContainer = self.accessoryPanelContainer { accessoryPanelContainer.addSubnode(groupCallAccessoryPanel) diff --git a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift index 326d8dd904..025bd94e6e 100644 --- a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift +++ b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift @@ -113,6 +113,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { private var dateTimeFormat: PresentationDateTimeFormat private let tapAction: () -> Void + private let notifyScheduledTapAction: () -> Void private let contentNode: ASDisplayNode @@ -163,13 +164,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { private var currentData: GroupCallPanelData? private var validLayout: (CGSize, CGFloat, CGFloat, Bool)? - public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void) { + public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void, notifyScheduledTapAction: @escaping () -> Void) { self.context = context self.theme = presentationData.theme self.strings = presentationData.strings self.dateTimeFormat = presentationData.dateTimeFormat self.tapAction = tapAction + self.notifyScheduledTapAction = notifyScheduledTapAction self.contentNode = ASDisplayNode() @@ -234,7 +236,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.joinButton.addSubnode(self.joinButtonBackgroundNode) self.joinButton.addSubnode(self.joinButtonTitleNode) self.contentNode.addSubnode(self.joinButton) - self.joinButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside]) + self.joinButton.addTarget(self, action: #selector(self.joinTapped), forControlEvents: [.touchUpInside]) self.micButton.addSubnode(self.micButtonBackgroundNode) self.micButton.addSubnode(self.micButtonForegroundNode) @@ -267,6 +269,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.tapAction() } + @objc private func joinTapped() { + if let info = self.currentData?.info, let _ = info.scheduleTimestamp, !info.subscribedToScheduled { + self.notifyScheduledTapAction() + } else { + self.tapAction() + } + } + @objc private func micTapped() { guard let call = self.currentData?.groupCall else { return @@ -645,7 +655,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { var text = self.currentText var isScheduled = false var isLate = false - if let scheduleTime = self.currentData?.info.scheduleTimestamp { + if let info = self.currentData?.info, let scheduleTime = info.scheduleTimestamp { isScheduled = true if let voiceChatTitle = self.currentData?.info.title { title = voiceChatTitle @@ -655,15 +665,20 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { text = humanReadableStringForTimestamp(strings: self.strings, dateTimeFormat: self.dateTimeFormat, timestamp: scheduleTime, alwaysShowTime: true, format: HumanReadableStringFormat(dateFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsOnShort($0) }, tomorrowFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTomorrowShort($0) }, todayFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTodayShort($0) })).string } - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - let elapsedTime = scheduleTime - currentTime - if elapsedTime >= 86400 { - joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime) - } else if elapsedTime < 0 { - joinText = "-\(textForTimeout(value: abs(elapsedTime)))" - isLate = true + if info.subscribedToScheduled { + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let elapsedTime = scheduleTime - currentTime + if elapsedTime >= 86400 { + joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime).uppercased() + } else if elapsedTime < 0 { + joinText = "-\(textForTimeout(value: abs(elapsedTime)))".uppercased() + isLate = true + } else { + joinText = textForTimeout(value: elapsedTime).uppercased() + } } else { - joinText = textForTimeout(value: elapsedTime) + //TODO:localize + joinText = "Notify Me" } if self.updateTimer == nil { @@ -691,7 +706,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.updateJoinButton() } - self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText.uppercased(), font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor) + self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText, font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor) let joinButtonTitleSize = self.joinButtonTitleNode.updateLayout(CGSize(width: 150.0, height: .greatestFiniteMagnitude)) let joinButtonSize = CGSize(width: joinButtonTitleSize.width + 20.0, height: 28.0) diff --git a/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift b/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift index 27c1a0c58b..93d15dc964 100644 --- a/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift +++ b/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift @@ -944,7 +944,9 @@ private func boxedDecryptedMessage(transaction: Transaction, message: Message, g if let attribute = attribute as? ReplyMessageAttribute { if let message = message.associatedMessages[attribute.messageId] { replyGlobalId = message.globallyUniqueId - flags |= (1 << 3) + if replyGlobalId != nil { + flags |= (1 << 3) + } break } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift index fa18ad9478..28355cbc28 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift @@ -244,40 +244,54 @@ public enum ToggleScheduledGroupCallSubscriptionError { } func _internal_toggleScheduledGroupCallSubscription(account: Account, peerId: PeerId, callId: Int64, accessHash: Int64, subscribe: Bool) -> Signal { - return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse)) - |> mapError { error -> ToggleScheduledGroupCallSubscriptionError in - return .generic - } - |> mapToSignal { result -> Signal in - var parsedCall: GroupCallInfo? - loop: for update in result.allUpdates { - switch update { - case let .updateGroupCall(_, call): - parsedCall = GroupCallInfo(call) - break loop - default: - break + return account.postbox.transaction { transaction -> Void in + transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in + if let cachedData = cachedData as? CachedChannelData, let activeCall = cachedData.activeCall { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream)) + } else if let cachedData = cachedData as? CachedGroupData, let activeCall = cachedData.activeCall { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream)) + } else { + return cachedData } + }) + } + |> castError(ToggleScheduledGroupCallSubscriptionError.self) + |> mapToSignal { _ -> Signal in + return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse)) + |> mapError { error -> ToggleScheduledGroupCallSubscriptionError in + return .generic } - - guard let callInfo = parsedCall else { - return .fail(.generic) - } - - return account.postbox.transaction { transaction in - transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in - if let cachedData = cachedData as? CachedChannelData { - return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) - } else if let cachedData = cachedData as? CachedGroupData { - return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) - } else { - return cachedData + |> mapToSignal { result -> Signal in + var parsedCall: GroupCallInfo? + loop: for update in result.allUpdates { + switch update { + case let .updateGroupCall(_, call): + parsedCall = GroupCallInfo(call) + break loop + default: + break } - }) + } - account.stateManager.addUpdates(result) + guard let callInfo = parsedCall else { + return .fail(.generic) + } + + return account.postbox.transaction { transaction in + transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in + if let cachedData = cachedData as? CachedChannelData { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) + } else if let cachedData = cachedData as? CachedGroupData { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) + } else { + return cachedData + } + }) + + account.stateManager.addUpdates(result) + } + |> castError(ToggleScheduledGroupCallSubscriptionError.self) } - |> castError(ToggleScheduledGroupCallSubscriptionError.self) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 202922f2ab..d6170a6de9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -536,8 +536,8 @@ public extension TelegramEngine { return _internal_translate_texts(network: self.account.network, texts: texts, toLang: toLang) } - public func translateMessages(messageIds: [EngineMessage.Id], toLang: String) -> Signal { - return _internal_translateMessages(account: self.account, messageIds: messageIds, toLang: toLang) + public func translateMessages(messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { + return _internal_translateMessages(account: self.account, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible) } public func togglePeerMessagesTranslationHidden(peerId: EnginePeer.Id, hidden: Bool) -> Signal { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift index 6ddcd43986..73ce8e2649 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift @@ -84,16 +84,22 @@ func _internal_translate_texts(network: Network, texts: [(String, [MessageTextEn } } -func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { var signals: [Signal] = [] for (peerId, messageIds) in messagesIdsGroupedByPeerId(messageIds) { - signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, toLang: toLang)) + signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible)) } return combineLatest(signals) |> ignoreValues } -private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +public protocol ExperimentalInternalTranslationService: AnyObject { + func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> +} + +public var engineExperimentalInternalTranslationService: ExperimentalInternalTranslationService? + +private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { return account.postbox.transaction { transaction -> (Api.InputPeer?, [Message]) in return (transaction.getPeer(peerId).flatMap(apiInputPeer), messageIds.compactMap({ transaction.getMessage($0) })) } @@ -132,21 +138,58 @@ private func _internal_translateMessagesByPeerId(account: Account, peerId: Engin if id.isEmpty { msgs = .single(nil) } else { - msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang)) - |> map(Optional.init) - |> mapError { error -> TranslationError in - if error.errorDescription.hasPrefix("FLOOD_WAIT") { - return .limitExceeded - } else if error.errorDescription == "MSG_ID_INVALID" { - return .invalidMessageId - } else if error.errorDescription == "INPUT_TEXT_EMPTY" { - return .textIsEmpty - } else if error.errorDescription == "INPUT_TEXT_TOO_LONG" { - return .textTooLong - } else if error.errorDescription == "TO_LANG_INVALID" { - return .invalidLanguage - } else { - return .generic + if enableLocalIfPossible, let engineExperimentalInternalTranslationService, let fromLang { + msgs = account.postbox.transaction { transaction -> [MessageId: String] in + var texts: [MessageId: String] = [:] + for messageId in messageIds { + if let message = transaction.getMessage(messageId) { + texts[message.id] = message.text + } + } + return texts + } + |> castError(TranslationError.self) + |> mapToSignal { messageTexts -> Signal in + var mappedTexts: [AnyHashable: String] = [:] + for (id, text) in messageTexts { + mappedTexts[AnyHashable(id)] = text + } + return engineExperimentalInternalTranslationService.translate(texts: mappedTexts, fromLang: fromLang, toLang: toLang) + |> castError(TranslationError.self) + |> mapToSignal { resultTexts -> Signal in + guard let resultTexts else { + return .fail(.generic) + } + var result: [Api.TextWithEntities] = [] + for messageId in messageIds { + if let text = resultTexts[AnyHashable(messageId)] { + result.append(.textWithEntities(text: text, entities: [])) + } else if let text = messageTexts[messageId] { + result.append(.textWithEntities(text: text, entities: [])) + } else { + result.append(.textWithEntities(text: "", entities: [])) + } + } + return .single(.translateResult(result: result)) + } + } + } else { + msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang)) + |> map(Optional.init) + |> mapError { error -> TranslationError in + if error.errorDescription.hasPrefix("FLOOD_WAIT") { + return .limitExceeded + } else if error.errorDescription == "MSG_ID_INVALID" { + return .invalidMessageId + } else if error.errorDescription == "INPUT_TEXT_EMPTY" { + return .textIsEmpty + } else if error.errorDescription == "INPUT_TEXT_TOO_LONG" { + return .textTooLong + } else if error.errorDescription == "TO_LANG_INVALID" { + return .invalidLanguage + } else { + return .generic + } } } } diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 4121cf7a01..2a62eefb53 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -42,6 +42,7 @@ import MediaEditor import TelegramUIDeclareEncodables import ContextMenuScreen import MetalEngine +import TranslateUI #if canImport(AppCenter) import AppCenter @@ -362,6 +363,12 @@ private func extractAccountManagerState(records: AccountRecordsView - private var toLang: String? + private var translationLang: (fromLang: String?, toLang: String)? private var allowDustEffect: Bool = true private var dustEffectLayer: DustEffectLayer? @@ -838,13 +838,13 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto context?.account.viewTracker.refreshStoriesForMessageIds(messageIds: Set(messageIds.map(\.messageId))) } self.translationProcessingManager.process = { [weak self, weak context] messageIds in - if let context = context, let toLang = self?.toLang { - let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone() + if let context = context, let translationLang = self?.translationLang { + let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone() } } self.factCheckProcessingManager.process = { [weak self, weak context] messageIds in - if let context = context, let toLang = self?.toLang { - let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone() + if let context = context, let translationLang = self?.translationLang { + let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone() } } @@ -1848,17 +1848,17 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto providedByGroupBoost: audioTranscriptionProvidedByBoost ) - var translateToLanguage: String? + var translateToLanguage: (fromLang: String, toLang: String)? if let translationState, isPremium && translationState.isEnabled { var languageCode = translationState.toLang ?? chatPresentationData.strings.baseLanguageCode let rawSuffix = "-raw" if languageCode.hasSuffix(rawSuffix) { languageCode = String(languageCode.dropLast(rawSuffix.count)) } - translateToLanguage = normalizeTranslationLanguage(languageCode) + translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(languageCode)) } - let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive")) + let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage?.toLang, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive")) var includeEmbeddedSavedChatInfo = false if case let .replyThread(message) = chatLocation, message.peerId == context.account.peerId, !rotated { @@ -1958,7 +1958,11 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto var scrollAnimationCurve: ListViewAnimationCurve? = nil if let strongSelf = self, case .default = source { - strongSelf.toLang = translateToLanguage + if let translateToLanguage { + strongSelf.translationLang = (fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang) + } else { + strongSelf.translationLang = nil + } if strongSelf.appliedScrollToMessageId == nil, let scrollToMessageId = scrollToMessageId { updatedScrollPosition = .index(subject: MessageHistoryScrollToSubject(index: .message(scrollToMessageId), quote: nil), position: .center(.top), directionHint: .Up, animated: true, highlight: false, displayLink: true, setupReply: false) scrollAnimationCurve = .Spring(duration: 0.4) diff --git a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift index d69089f0a3..9878fcc499 100644 --- a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift @@ -72,7 +72,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { private var currentLayout: (CGFloat, CGFloat, CGFloat)? private var currentMessage: ChatPinnedMessage? private var previousMediaReference: AnyMediaReference? - private var currentTranslateToLanguage: String? + private var currentTranslateToLanguage: (fromLang: String, toLang: String)? private let translationDisposable = MetaDisposable() private var isReplyThread: Bool = false @@ -496,21 +496,21 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) - var translateToLanguage: String? + var translateToLanguage: (fromLang: String, toLang: String)? if let translationState = interfaceState.translationState, translationState.isEnabled { - translateToLanguage = normalizeTranslationLanguage(translationState.toLang) + translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(translationState.toLang)) } var currentTranslateToLanguageUpdated = false - if self.currentTranslateToLanguage != translateToLanguage { + if self.currentTranslateToLanguage?.fromLang != translateToLanguage?.fromLang || self.currentTranslateToLanguage?.toLang != translateToLanguage?.toLang { self.currentTranslateToLanguage = translateToLanguage currentTranslateToLanguageUpdated = true } if currentTranslateToLanguageUpdated || messageUpdated, let message = interfaceState.pinnedMessage?.message { - if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage { - } else if let translateToLanguage { - self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], toLang: translateToLanguage).startStrict()) + if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage?.toLang { + } else if let translateToLanguage { + self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang).startStrict()) } } @@ -522,7 +522,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { if let currentMessage = self.currentMessage, let currentLayout = self.currentLayout { self.dustNode?.update(revealed: false, animated: false) - self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage) + self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage?.toLang) } } diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index 011ccc1132..3e52a63cdb 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -60,6 +60,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var disableReloginTokens: Bool public var liveStreamV2: Bool public var dynamicStreaming: Bool + public var enableLocalTranslation: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -97,7 +98,8 @@ public struct ExperimentalUISettings: Codable, Equatable { allowWebViewInspection: false, disableReloginTokens: false, liveStreamV2: false, - dynamicStreaming: false + dynamicStreaming: false, + enableLocalTranslation: false ) } @@ -136,7 +138,8 @@ public struct ExperimentalUISettings: Codable, Equatable { allowWebViewInspection: Bool, disableReloginTokens: Bool, liveStreamV2: Bool, - dynamicStreaming: Bool + dynamicStreaming: Bool, + enableLocalTranslation: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -173,6 +176,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableReloginTokens = disableReloginTokens self.liveStreamV2 = liveStreamV2 self.dynamicStreaming = dynamicStreaming + self.enableLocalTranslation = enableLocalTranslation } public init(from decoder: Decoder) throws { @@ -213,6 +217,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableReloginTokens = try container.decodeIfPresent(Bool.self, forKey: "disableReloginTokens") ?? false self.liveStreamV2 = try container.decodeIfPresent(Bool.self, forKey: "liveStreamV2") ?? false self.dynamicStreaming = try container.decodeIfPresent(Bool.self, forKey: "dynamicStreaming") ?? false + self.enableLocalTranslation = try container.decodeIfPresent(Bool.self, forKey: "enableLocalTranslation") ?? false } public func encode(to encoder: Encoder) throws { @@ -253,6 +258,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encode(self.disableReloginTokens, forKey: "disableReloginTokens") try container.encode(self.liveStreamV2, forKey: "liveStreamV2") try container.encode(self.dynamicStreaming, forKey: "dynamicStreaming") + try container.encode(self.enableLocalTranslation, forKey: "enableLocalTranslation") } } diff --git a/submodules/TranslateUI/Sources/ChatTranslation.swift b/submodules/TranslateUI/Sources/ChatTranslation.swift index 35f2fdfd62..f6bdf92ec2 100644 --- a/submodules/TranslateUI/Sources/ChatTranslation.swift +++ b/submodules/TranslateUI/Sources/ChatTranslation.swift @@ -117,7 +117,7 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer @available(iOS 12.0, *) private let languageRecognizer = NLLanguageRecognizer() -public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String) -> Signal { return context.account.postbox.transaction { transaction -> Signal in var messageIdsToTranslate: [EngineMessage.Id] = [] var messageIdsSet = Set() @@ -159,7 +159,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess } } } - return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, toLang: toLang) + return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: context.sharedContext.immediateExperimentalUISettings.enableLocalTranslation) |> `catch` { _ -> Signal in return .complete() } diff --git a/submodules/TranslateUI/Sources/Translate.swift b/submodules/TranslateUI/Sources/Translate.swift index 64e664327b..4d48f66e10 100644 --- a/submodules/TranslateUI/Sources/Translate.swift +++ b/submodules/TranslateUI/Sources/Translate.swift @@ -5,6 +5,9 @@ import SwiftSignalKit import AccountContext import NaturalLanguage import TelegramCore +import SwiftUI +import Translation +import Combine // Incuding at least one Objective-C class in a swift file ensures that it doesn't get stripped by the linker private final class LinkHelperClass: NSObject { @@ -213,3 +216,190 @@ public func systemLanguageCodes() -> [String] { } return languages } + +@available(iOS 13.0, *) +class ExternalTranslationTrigger: ObservableObject { + @Published var shouldInvalidate: Int = 0 +} + +@available(iOS 18.0, *) +private struct TranslationViewImpl: View { + @State private var configuration: TranslationSession.Configuration? + @ObservedObject var externalCondition: ExternalTranslationTrigger + private let taskContainer: Atomic + + init(externalCondition: ExternalTranslationTrigger, taskContainer: Atomic) { + self.externalCondition = externalCondition + self.taskContainer = taskContainer + } + + var body: some View { + Text("ABC") + .onChange(of: self.externalCondition.shouldInvalidate) { _ in + let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in + if let firstTask = taskContainer.tasks.first { + return (firstTask.fromLang, firstTask.toLang) + } else { + return nil + } + } + + if let firstTaskLanguagePair { + if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { + self.configuration?.invalidate() + } else { + self.configuration = .init( + source: Locale.Language(identifier: firstTaskLanguagePair.0), + target: Locale.Language(identifier: firstTaskLanguagePair.1) + ) + } + } + } + .translationTask(self.configuration, action: { session in + var task: ExperimentalInternalTranslationServiceImpl.TranslationTask? + task = self.taskContainer.with { taskContainer -> ExperimentalInternalTranslationServiceImpl.TranslationTask? in + if !taskContainer.tasks.isEmpty { + return taskContainer.tasks.removeFirst() + } else { + return nil + } + } + + guard let task else { + return + } + + do { + var nextClientIdentifier: Int = 0 + var clientIdentifierMap: [String: AnyHashable] = [:] + let translationRequests = task.texts.map { key, value in + let id = nextClientIdentifier + nextClientIdentifier += 1 + clientIdentifierMap["\(id)"] = key + return TranslationSession.Request(sourceText: value, clientIdentifier: "\(id)") + } + + let responses = try await session.translations(from: translationRequests) + var resultMap: [AnyHashable: String] = [:] + for response in responses { + if let clientIdentifier = response.clientIdentifier, let originalKey = clientIdentifierMap[clientIdentifier] { + resultMap[originalKey] = "\(response.targetText)" + } + } + + task.completion(resultMap) + } catch let e { + print("Translation error: \(e)") + task.completion(nil) + } + + let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in + if let firstTask = taskContainer.tasks.first { + return (firstTask.fromLang, firstTask.toLang) + } else { + return nil + } + } + + if let firstTaskLanguagePair { + if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { + self.configuration?.invalidate() + } else { + self.configuration = .init( + source: Locale.Language(identifier: firstTaskLanguagePair.0), + target: Locale.Language(identifier: firstTaskLanguagePair.1) + ) + } + } + }) + } +} + +@available(iOS 18.0, *) +public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInternalTranslationService { + fileprivate final class TranslationTask { + let id: Int + let texts: [AnyHashable: String] + let fromLang: String + let toLang: String + let completion: ([AnyHashable: String]?) -> Void + + init(id: Int, texts: [AnyHashable: String], fromLang: String, toLang: String, completion: @escaping ([AnyHashable: String]?) -> Void) { + self.id = id + self.texts = texts + self.fromLang = fromLang + self.toLang = toLang + self.completion = completion + } + } + + fileprivate final class TranslationTaskContainer { + var tasks: [TranslationTask] = [] + + init() { + } + } + + private final class Impl { + private let hostingController: UIViewController + + private let taskContainer = Atomic(value: TranslationTaskContainer()) + private let taskTrigger = ExternalTranslationTrigger() + + private var nextId: Int = 0 + + init(view: UIView) { + self.hostingController = UIHostingController(rootView: TranslationViewImpl( + externalCondition: self.taskTrigger, + taskContainer: self.taskContainer + )) + + view.addSubview(self.hostingController.view) + } + + func translate(texts: [AnyHashable: String], fromLang: String, toLang: String, onResult: @escaping ([AnyHashable: String]?) -> Void) -> Disposable { + let id = self.nextId + self.nextId += 1 + self.taskContainer.with { taskContainer in + taskContainer.tasks.append(TranslationTask( + id: id, + texts: texts, + fromLang: fromLang, + toLang: toLang, + completion: { result in + onResult(result) + } + )) + } + self.taskTrigger.shouldInvalidate += 1 + + return ActionDisposable { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + self.taskContainer.with { taskContainer in + taskContainer.tasks.removeAll(where: { $0.id == id }) + } + } + } + } + } + + private let impl: QueueLocalObject + + public init(view: UIView) { + self.impl = QueueLocalObject(queue: .mainQueue(), generate: { + return Impl(view: view) + }) + } + + public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> { + return self.impl.signalWith { impl, subscriber in + return impl.translate(texts: texts, fromLang: fromLang, toLang: toLang, onResult: { result in + subscriber.putNext(result) + subscriber.putCompletion() + }) + } + } +}