diff --git a/Telegram/Telegram-iOS/Resources/TextToVoiceFeature.ogg b/Telegram/Telegram-iOS/Resources/TextToVoiceFeature.ogg new file mode 100644 index 00000000000..23c0f677800 Binary files /dev/null and b/Telegram/Telegram-iOS/Resources/TextToVoiceFeature.ogg differ diff --git a/submodules/AccountContext/Sources/MediaManager.swift b/submodules/AccountContext/Sources/MediaManager.swift index f9451544bb5..fec76ecce44 100644 --- a/submodules/AccountContext/Sources/MediaManager.swift +++ b/submodules/AccountContext/Sources/MediaManager.swift @@ -107,6 +107,9 @@ public func peerMessageMediaPlayerType(_ message: EngineMessage) -> MediaManager break } } + if let attribute = message.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute { + file = attribute.file + } return file } diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index c318c993368..d9be4472929 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -1247,6 +1247,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { }, updateHistoryFilter: { _ in }, updateDisplayHistoryFilterAsList: { _ in }, requestLayout: { _ in + }, startTranscribingText: { _ in }, chatController: { return nil }, statuses: nil) diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift index ec3c4cfd7ea..c4bdeba2f40 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift @@ -175,6 +175,7 @@ public final class ChatPanelInterfaceInteraction { public let openBoostToUnrestrict: () -> Void public let updateVideoTrimRange: (Double, Double, Bool, Bool) -> Void public let requestLayout: (ContainedViewLayoutTransition) -> Void + public let startTranscribingText: (Message) -> Void public let chatController: () -> ViewController? public let statuses: ChatPanelInterfaceInteractionStatuses? @@ -292,6 +293,7 @@ public final class ChatPanelInterfaceInteraction { updateHistoryFilter: @escaping ((ChatPresentationInterfaceState.HistoryFilter?) -> ChatPresentationInterfaceState.HistoryFilter?) -> Void, updateDisplayHistoryFilterAsList: @escaping (Bool) -> Void, requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, + startTranscribingText: @escaping (Message) -> Void, chatController: @escaping () -> ViewController?, statuses: ChatPanelInterfaceInteractionStatuses? ) { @@ -408,7 +410,7 @@ public final class ChatPanelInterfaceInteraction { self.updateHistoryFilter = updateHistoryFilter self.updateDisplayHistoryFilterAsList = updateDisplayHistoryFilterAsList self.requestLayout = requestLayout - + self.startTranscribingText = startTranscribingText self.chatController = chatController self.statuses = statuses } @@ -532,6 +534,7 @@ public final class ChatPanelInterfaceInteraction { }, updateHistoryFilter: { _ in }, updateDisplayHistoryFilterAsList: { _ in }, requestLayout: { _ in + }, startTranscribingText: { _ in }, chatController: { return nil }, statuses: nil) diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index 2f0b187bbf6..34d9a511df7 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -175,6 +175,10 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati } } + if let attribute = message.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute { + galleryMedia = attribute.file + } + var stream = false var autoplayingVideo = false var landscape = false diff --git a/submodules/TelegramCore/Sources/Account/AccountManager.swift b/submodules/TelegramCore/Sources/Account/AccountManager.swift index ef367cdca9d..f762f03ba9e 100644 --- a/submodules/TelegramCore/Sources/Account/AccountManager.swift +++ b/submodules/TelegramCore/Sources/Account/AccountManager.swift @@ -208,6 +208,7 @@ private var declaredEncodables: Void = { declareEncodable(SendAsMessageAttribute.self, f: { SendAsMessageAttribute(decoder: $0) }) declareEncodable(ForwardVideoTimestampAttribute.self, f: { ForwardVideoTimestampAttribute(decoder: $0) }) declareEncodable(AudioTranscriptionMessageAttribute.self, f: { AudioTranscriptionMessageAttribute(decoder: $0) }) + declareEncodable(TextTranscriptionMessageAttribute.self, f: { TextTranscriptionMessageAttribute(decoder: $0) }) declareEncodable(NonPremiumMessageAttribute.self, f: { NonPremiumMessageAttribute(decoder: $0) }) declareEncodable(TelegramExtendedMedia.self, f: { TelegramExtendedMedia(decoder: $0) }) declareEncodable(TelegramPeerUsername.self, f: { TelegramPeerUsername(decoder: $0) }) diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index cd4d1e7e3c1..79b84634fb6 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -3926,6 +3926,9 @@ func replayFinalState( updatedAttributes.append(translation) } } + if let transcription = previousMessage.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute { + updatedAttributes.append(transcription) + } } if let previousFactCheckAttribute = previousMessage.attributes.first(where: { $0 is FactCheckMessageAttribute }) as? FactCheckMessageAttribute, let updatedFactCheckAttribute = message.attributes.first(where: { $0 is FactCheckMessageAttribute }) as? FactCheckMessageAttribute { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TextTranscriptionMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TextTranscriptionMessageAttribute.swift new file mode 100644 index 00000000000..629d44dfd92 --- /dev/null +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TextTranscriptionMessageAttribute.swift @@ -0,0 +1,61 @@ +// +// SyncCore_TextTranscriptionMessageAttribute.swift +// Telegram +// +// Created by Dmitry Bolonikov on 7.04.25. +// + +import Postbox + +public class TextTranscriptionMessageAttribute: MessageAttribute, Equatable { + public let id: Int64 + public let visible: Bool + public let downloading: Bool + public let file: TelegramMediaFile + + public var associatedPeerIds: [PeerId] { + return [] + } + + public init( + id: Int64, + visible: Bool, + downloading: Bool, + file: TelegramMediaFile + ) { + self.id = id + self.visible = visible + self.downloading = downloading + self.file = file + } + + required public init(decoder: PostboxDecoder) { + self.id = decoder.decodeInt64ForKey("id", orElse: 0) + self.visible = decoder.decodeBoolForKey("visible", orElse: false) + self.downloading = decoder.decodeBoolForKey("downloading", orElse: false) + self.file = decoder.decodeObjectForKey("file") as! TelegramMediaFile + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt64(self.id, forKey: "id") + encoder.encodeBool(self.visible, forKey: "visible") + encoder.encodeBool(self.downloading, forKey: "downloading") + encoder.encodeObject(file, forKey: "file") + } + + public static func ==(lhs: TextTranscriptionMessageAttribute, rhs: TextTranscriptionMessageAttribute) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.visible != rhs.visible { + return false + } + if lhs.file != rhs.file { + return false + } + if lhs.downloading != rhs.downloading { + return false + } + return true + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 2ae4018184b..ae9cebe9bd6 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -581,6 +581,10 @@ public extension TelegramEngine { |> ignoreValues } + public func transcribeText(messageId: MessageId) -> Signal { + _internal_transcribeText(postbox: self.account.postbox, network: self.account.network, messageId: messageId) + } + public func storeLocallyDerivedData(messageId: MessageId, data: [String: CodableEntry]) -> Signal { return self.account.postbox.transaction { transaction -> Void in transaction.updateMessage(messageId, update: { currentMessage in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TextTranscription.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TextTranscription.swift new file mode 100644 index 00000000000..08972c5c7d4 --- /dev/null +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TextTranscription.swift @@ -0,0 +1,156 @@ +// +// TextTranscription.swift +// Telegram +// +// Created by Dmitry Bolonikov on 7.04.25. +// + +import Foundation +import Postbox +import SwiftSignalKit +import TelegramApi +import MtProtoKit + +public enum EngineTextTranscriptionResult { + case transcribing + case finished +} + +private enum InternalTextTranscriptionResult { + case alreadyTranscribed(TextTranscriptionMessageAttribute) + case startTranscribing(TextTranscriptionMessageAttribute) + case transcribed(TextTranscriptionMessageAttribute) + case error +} + +func _internal_transcribeText(postbox: Postbox, network: Network, messageId: MessageId) -> Signal { + return postbox.transaction { transaction -> Message? in + transaction.getMessage(messageId) + } + |> mapToSignal { message -> Signal in + guard let message else { + return .single(.error) + } + + if let attribute = message.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute { + return .single(.alreadyTranscribed(attribute)) + } + + return Signal { subscriber in + + let fileId = Int64.random(in: Int64.min...Int64.max) + let resource = LocalFileMediaResource(fileId: fileId) + + let mediaId = MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min...Int64.max)) + + let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 0, title: nil, performer: nil, waveform: nil)] + + let file = TelegramMediaFile( + fileId: mediaId, + partialReference: nil, + resource: resource, + previewRepresentations: [], + videoThumbnails: [], + immediateThumbnailData: nil, + mimeType: "audio/ogg", + size: 1, + attributes: voiceAttributes, + alternativeRepresentations: []) + + let attributeId = Int64.random(in: Int64.min...Int64.max) + let attribute = TextTranscriptionMessageAttribute(id: attributeId, + visible: true, + downloading: true, + file: file) + + subscriber.putNext(.startTranscribing(attribute)) + + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 5) { + guard let fileUrl = Bundle.main.url(forResource: "TextToVoiceFeature", withExtension: "ogg"), + let data = try? Data(contentsOf: fileUrl) else { + subscriber.putNext(.error) + subscriber.putCompletion() + return + } + + postbox.mediaBox.storeResourceData(resource.id, data: data) + + // TODO: Fetch duration, waveform from response + let waveformBase64 = "DAAOAAkACQAGAAwADwAMABAADQAPABsAGAALAA0AGAAfABoAHgATABgAGQAYABQADAAVABEAHwANAA0ACQAWABkACQAOAAwACQAfAAAAGQAVAAAAEwATAAAACAAfAAAAHAAAABwAHwAAABcAGQAAABQADgAAABQAHwAAAB8AHwAAAAwADwAAAB8AEwAAABoAFwAAAB8AFAAAAAAAHwAAAAAAHgAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAAAA=" + + let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 0, title: nil, performer: nil, waveform: Data(base64Encoded: waveformBase64)!)] + + let file = TelegramMediaFile( + fileId: mediaId, + partialReference: nil, + resource: resource, + previewRepresentations: [], + videoThumbnails: [], + immediateThumbnailData: nil, + mimeType: "audio/ogg", + size: Int64(data.count), + attributes: voiceAttributes, + alternativeRepresentations: []) + + let attributeId = Int64.random(in: Int64.min...Int64.max) + let attribute = TextTranscriptionMessageAttribute(id: attributeId, + visible: true, + downloading: false, + file: file) + + subscriber.putNext(.transcribed(attribute)) + subscriber.putCompletion() + } + + return EmptyDisposable + } + } + |> mapToSignal { result -> Signal in + return postbox.transaction { transaction -> EngineTextTranscriptionResult in + transaction.updateMessage(messageId, update: { currentMessage in + var attributes = currentMessage.attributes.filter { !($0 is TextTranscriptionMessageAttribute) } + + switch result { + case .transcribed(let attribute): + attributes.append(attribute) + + case .startTranscribing(let attribute): + attributes.append(attribute) + case .alreadyTranscribed(let attribute): + let updatedAttribute = TextTranscriptionMessageAttribute(id: attribute.id, visible: true, downloading: attribute.downloading, file: attribute.file) + guard updatedAttribute != attribute else { + return .skip + } + attributes.append(updatedAttribute) + default: + return .skip + } + + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + + return .update(StoreMessage( + id: currentMessage.id, + globallyUniqueId: currentMessage.globallyUniqueId, + groupingKey: currentMessage.groupingKey, + threadId: currentMessage.threadId, + timestamp: currentMessage.timestamp, + flags: StoreMessageFlags(currentMessage.flags), + tags: currentMessage.tags, + globalTags: currentMessage.globalTags, + localTags: currentMessage.localTags, + forwardInfo: storeForwardInfo, + authorId: currentMessage.author?.id, + text: currentMessage.text, + attributes: attributes, + media: currentMessage.media)) + }) + + switch result { + case .startTranscribing: + return .transcribing + default: + return .finished + } + } + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift index 5357300699f..050a2952dfc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift @@ -65,6 +65,9 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { public let layoutConstants: ChatMessageItemLayoutConstants public let constrainedSize: CGSize public let controllerInteraction: ChatControllerInteraction + public let alwaysDisplayTranscriptionButton: Bool + public let transcriptionState: AudioTranscriptionButtonComponent.TranscriptionState? + public let transcriptionButtonTapped: (() -> Void)? public init( context: AccountContext, @@ -88,7 +91,10 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { isAttachedContentBlock: Bool, layoutConstants: ChatMessageItemLayoutConstants, constrainedSize: CGSize, - controllerInteraction: ChatControllerInteraction + controllerInteraction: ChatControllerInteraction, + alwaysDisplayTranscriptionButton: Bool = false, + transcriptionState: AudioTranscriptionButtonComponent.TranscriptionState? = nil, + transcriptionButtonTapped: (() -> Void)? = nil ) { self.context = context self.presentationData = presentationData @@ -112,6 +118,9 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { self.layoutConstants = layoutConstants self.constrainedSize = constrainedSize self.controllerInteraction = controllerInteraction + self.alwaysDisplayTranscriptionButton = alwaysDisplayTranscriptionButton + self.transcriptionState = transcriptionState + self.transcriptionButtonTapped = transcriptionButtonTapped } } @@ -789,6 +798,10 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { } } + if arguments.alwaysDisplayTranscriptionButton { + displayTranscribe = true + } + let transcribedText = forcedAudioTranscriptionText ?? transcribedText(message: arguments.message) switch audioTranscriptionState { @@ -805,7 +818,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { updatedAudioTranscriptionState = .locked } - let effectiveAudioTranscriptionState = updatedAudioTranscriptionState ?? audioTranscriptionState + let effectiveAudioTranscriptionState = updatedAudioTranscriptionState ?? arguments.transcriptionState ?? audioTranscriptionState var displayTrailingAnimatedDots = false @@ -1356,7 +1369,11 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { guard let strongSelf = self else { return } - strongSelf.transcribe() + if let action = arguments.transcriptionButtonTapped { + action() + } else { + strongSelf.transcribe() + } } )), environment: {}, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD index 5654796921d..102a4b76ae2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD @@ -35,6 +35,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ShimmeringLinkNode", "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", "//submodules/TelegramUI/Components/Chat/MessageQuoteComponent", + "//submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode", "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/TelegramUI/Components/ChatControllerInteraction", "//submodules/TelegramUI/Components/InteractiveTextComponent", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index f6e820a9af6..8e0d14f9c92 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -21,6 +21,9 @@ import EmojiTextAttachmentView import TextNodeWithEntities import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode +import ChatMessageInteractiveFileNode +import ComponentFlow +import AudioTranscriptionButtonComponent import ShimmeringLinkNode import ChatMessageItemCommon import TextLoadingEffect @@ -86,6 +89,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let textNode: InteractiveTextNodeWithEntities private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode + private var audioNode: ChatMessageInteractiveFileNode? public var statusNode: ChatMessageDateAndStatusNode? private var linkHighlightingNode: LinkHighlightingNode? private var shimmeringNode: ShimmeringLinkNode? @@ -113,6 +117,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private var appliedExpandedBlockIds: Set? private var displayContentsUnderSpoilers: (value: Bool, location: CGPoint?) = (false, nil) + private var attributeResettingDisposable: Disposable? + override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { @@ -201,6 +207,9 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let textLayout = InteractiveTextNodeWithEntities.asyncLayout(self.textNode) + + let audioLayout = ChatMessageInteractiveFileNode.asyncLayout(self.audioNode) + let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode) let currentCachedChatMessageText = self.cachedChatMessageText @@ -264,6 +273,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + var audioAttribute: TextTranscriptionMessageAttribute? var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: item.associatedData.accountPeer, message: item.topMessage) if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) { dateReactionsAndPeers = ([], []) @@ -279,6 +289,9 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { dateReplies = Int(attribute.count) } } + else if let attribute = attribute as? TextTranscriptionMessageAttribute { + audioAttribute = attribute + } } let dateFormat: MessageTimestampStatusFormat @@ -654,6 +667,97 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { animationRenderer: item.controllerInteraction.presentationContext.animationRenderer )) } + + let preferredWidth: CGFloat? + let audioFinalizeLayout: ChatMessageInteractiveFileNode.FinalizeLayout? + + if let audioAttribute, audioAttribute.visible { + var insets = UIEdgeInsets() + insets.left = layoutConstants.text.bubbleInsets.left + insets.right = layoutConstants.text.bubbleInsets.right + + let transcriptionState: AudioTranscriptionButtonComponent.TranscriptionState + let forcedResourceStatus: FileMediaResourceStatus? + if audioAttribute.downloading == true { + transcriptionState = .inProgress + forcedResourceStatus = .init(mediaStatus: .fetchStatus(.Local), fetchStatus: .Local) + } else { + transcriptionState = .expanded + forcedResourceStatus = nil + } + + let (_, refineLayout) = audioLayout(ChatMessageInteractiveFileNode.Arguments( + context: item.context, + presentationData: item.presentationData, + customTintColor: nil, + message: message, + topMessage: message, + associatedData: item.associatedData, + chatLocation: item.chatLocation, + attributes: item.attributes, + isPinned: item.isItemPinned, + forcedIsEdited: false, + file: audioAttribute.file, + automaticDownload: false, + incoming: incoming, + isRecentActions: false, + forcedResourceStatus: forcedResourceStatus, + dateAndStatusType: nil, + displayReactions: false, + messageSelection: nil, + isAttachedContentBlock: true, + layoutConstants: layoutConstants, + constrainedSize: CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height), + controllerInteraction: item.controllerInteraction, + alwaysDisplayTranscriptionButton: true, + transcriptionState: transcriptionState, + transcriptionButtonTapped: { [weak self] in + guard let self else { return } + self.attributeResettingDisposable = item.context.account.postbox.transaction { transaction in + transaction.updateMessage(message.id) { message in + var attributes = message.attributes.filter { !($0 is TextTranscriptionMessageAttribute) } + + let newAttribute = TextTranscriptionMessageAttribute( + id: audioAttribute.id, + visible: false, + downloading: audioAttribute.downloading, + file: audioAttribute.file) + attributes.append(newAttribute) + + let storeForwardInfo = message.forwardInfo.flatMap(StoreMessageForwardInfo.init) + return .update(StoreMessage( + id: message.id, + globallyUniqueId: message.globallyUniqueId, + groupingKey: message.groupingKey, + threadId: message.threadId, + timestamp: message.timestamp, + flags: StoreMessageFlags(message.flags), + tags: message.tags, + globalTags: message.globalTags, + localTags: message.localTags, + forwardInfo: storeForwardInfo, + authorId: message.author?.id, + text: message.text, + attributes: attributes, + media: message.media)) + } + } + .startStrict(next: { _ in + self.attributeResettingDisposable?.dispose() + self.attributeResettingDisposable = nil + }) + } + )) + let finalizeLayout = refineLayout(CGSize(width: constrainedSize.width, height: constrainedSize.height)) + + self.audioNode?.audioTranscriptionState = transcriptionState + + preferredWidth = finalizeLayout.0 + audioFinalizeLayout = finalizeLayout.1 + } else { + preferredWidth = nil + audioFinalizeLayout = nil + } var textFrame = CGRect(origin: CGPoint(x: -textInsets.left, y: -textInsets.top), size: textLayout.size) var textFrameWithoutInsets = CGRect(origin: CGPoint(x: textFrame.origin.x + textInsets.left, y: textFrame.origin.y + textInsets.top), size: CGSize(width: textFrame.width - textInsets.left - textInsets.right, height: textFrame.height - textInsets.top - textInsets.bottom)) @@ -665,15 +769,25 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { suggestedBoundingWidth = max(suggestedBoundingWidth, statusSuggestedWidthAndContinue.0) } + + if let preferredWidth { + suggestedBoundingWidth = max(suggestedBoundingWidth, preferredWidth) + } let sideInsets = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right suggestedBoundingWidth += sideInsets return (suggestedBoundingWidth, { boundingWidth in var boundingSize: CGSize + let audioSizeAndApply = audioFinalizeLayout?(boundingWidth - sideInsets) + + boundingSize = textFrameWithoutInsets.size + if let audioSizeAndApply { + boundingSize.height += audioSizeAndApply.0.height + 20 + } + let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - sideInsets) - boundingSize = textFrameWithoutInsets.size if let statusSizeAndApply = statusSizeAndApply { boundingSize.height += statusSizeAndApply.0.height } @@ -759,9 +873,43 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.updateIsTranslating(isTranslating) + + if let audioSizeAndApply { + let audioNode = audioSizeAndApply.1(synchronousLoads, animation, itemApply) + let audioFrame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY + 6), size: audioSizeAndApply.0) + + if strongSelf.audioNode !== audioNode { + strongSelf.audioNode?.removeFromSupernode() + strongSelf.audioNode = audioNode + + audioNode.activateLocalContent = { + guard let self, + let item = self.item, + let audioAttribute = item.message.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute, + !audioAttribute.downloading else { + return + } + let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: .default)) + } + + audioNode.requestUpdateLayout = { _ in + item.controllerInteraction.requestMessageUpdate(item.message.id, false) + } + + strongSelf.addSubnode(audioNode) + + audioNode.frame = audioFrame + } else { + animation.animator.updateFrame(layer: audioNode.layer, frame: audioFrame, completion: nil) + } + } else if let audioNode = strongSelf.audioNode { + strongSelf.audioNode = nil + audioNode.removeFromSupernode() + } + if let statusSizeAndApply { let statusNode = statusSizeAndApply.1(strongSelf.statusNode == nil ? .None : animation) - let statusFrame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0) + let statusFrame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: boundingSize.height - statusSizeAndApply.0.height - bottomInset), size: statusSizeAndApply.0) if strongSelf.statusNode !== statusNode { strongSelf.statusNode?.removeFromSupernode() @@ -1033,6 +1181,9 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { if let statusNode = self.statusNode, statusNode.supernode != nil, let result = statusNode.hitTest(self.view.convert(point, to: statusNode.view), with: event) { return result } + if let audioNode, audioNode.supernode != nil, let result = audioNode.hitTest(self.view.convert(point, to: audioNode.view), with: event) { + return result + } return super.hitTest(point, with: event) } diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift index 8b65489d295..9dff6f7a36c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift @@ -172,6 +172,7 @@ public final class ChatRecentActionsController: TelegramBaseController { }, updateHistoryFilter: { _ in }, updateDisplayHistoryFilterAsList: { _ in }, requestLayout: { _ in + }, startTranscribingText: { _ in }, chatController: { return nil }, statuses: nil) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 1673cf93da7..f39b28f2a4a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -437,6 +437,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { }, updateHistoryFilter: { _ in }, updateDisplayHistoryFilterAsList: { _ in }, requestLayout: { _ in + }, startTranscribingText: { _ in }, chatController: { return nil }, statuses: nil) diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 45b32e5ec60..848deb2e998 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -786,6 +786,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { }, updateHistoryFilter: { _ in }, updateDisplayHistoryFilterAsList: { _ in }, requestLayout: { _ in + }, startTranscribingText: { _ in }, chatController: { return nil }, statuses: nil) diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 3913193b894..fbdb6f994bd 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -4506,6 +4506,24 @@ extension ChatControllerImpl { if let strongSelf = self, let layout = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, transition: transition) } + }, startTranscribingText: { [weak self] message in + guard let strongSelf = self else { + return + } + strongSelf.transcribingTextDisposable = (strongSelf.context.engine.messages.transcribeText(messageId: message.id) + |> deliverOnMainQueue).startStrict(next: { [weak self] result in + guard let strongSelf = self else { + return + } + + switch result { + case .finished: + strongSelf.transcribingTextDisposable?.dispose() + strongSelf.transcribingTextDisposable = nil + default: + break + } + }) }, chatController: { [weak self] in return self }, statuses: ChatPanelInterfaceInteractionStatuses(editingMessage: self.editingMessage.get(), startingBot: self.startingBot.get(), unblockingPeer: self.unblockingPeer.get(), searching: self.searching.get(), loadingMessage: self.loadingMessage.get(), inlineSearch: self.performingInlineSearch.get())) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index f61918c4e73..4b72d6635fd 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -380,6 +380,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var buttonKeyboardMessageDisposable: Disposable? var cachedDataDisposable: Disposable? + var transcribingTextDisposable: Disposable? var chatUnreadCountDisposable: Disposable? var buttonUnreadCountDisposable: Disposable? var chatUnreadMentionCountDisposable: Disposable? diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index e516970a288..85a94db54dd 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1642,6 +1642,21 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } + if !message.text.isEmpty { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + + let contextMenuItem = ContextMenuActionItem( + text: "Listen", // TODO: Localize, + badge: ContextMenuActionBadge(value: presentationData.strings.ChatList_ContextMenuBadgeNew, color: .accent, style: .label), + icon: { theme in + generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOn"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + interfaceInteraction.startTranscribingText(message) + f(.dismissWithoutContent) + }) + actions.append(.action(contextMenuItem)) + } + if !hasAutoremove { for media in message.media { if let action = media as? TelegramMediaAction { diff --git a/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift b/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift index 9eb5ba1858e..042b76f6d13 100644 --- a/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift +++ b/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift @@ -44,6 +44,9 @@ private func extractFileMedia(_ message: Message) -> TelegramMediaFile? { break } } + if let attribute = message.attributes.first(where: { $0 is TextTranscriptionMessageAttribute }) as? TextTranscriptionMessageAttribute { + file = attribute.file + } return file }